- storebound flow list|validate|run: repo-local workflows (store/workflows/*.yml, storebound.workflows.*), writes dry unless --apply, owner steps never run, --from / --resume; init templates for app and game; MCP flow_list + dry flow_run; release-flows skill - storebound listing translate|check|reviewed: per-language listing drafts with store limits and keyword rules, agent or --translate hook, marked until reviewed; MCP listing_translate / listing_check; listing-localization skill - scan: Apple 2.2 beta/demo/test wording rule; scan --originality [--against] - prices export includes subscriptions; prices import skips their rows - site: rejection scan section, Desktop app section with fixture-mode screenshots, Everything else grid, FAQ 4.3 answer - README, CHANGELOG, adapters, API tables
184 lines
13 KiB
TypeScript
184 lines
13 KiB
TypeScript
// In-app product prices for many products and regions from one CSV (both stores).
|
|
// storebound prices export [--out store/prices.csv] [--store apple|play] [--only ids] [--with-automatic] [--no-subscriptions] [--dry] [--json]
|
|
// one-time products and (unless --no-subscriptions) auto-renewable subscriptions: Apple's current and scheduled
|
|
// subscription prices per territory, Play's regional prices of the base plan. Subscription rows use the same
|
|
// columns; `apple subscriptions --csv` / `play subscriptions --csv` read them back (prices import skips them).
|
|
// storebound prices import [--csv store/prices.csv] [--store apple|play] [--only ids] [--reconvert] [--all] [--dry] [--yes]
|
|
// storebound prices ppp [--base <USD>] [...] purchasing power price sheet (prices-ppp.ts)
|
|
// CSV columns: product, store (apple | play | both), region (ISO alpha-2), currency, price, start_date (App Store
|
|
// only, a future YYYY-MM-DD = scheduled change), kind (manual | auto; auto rows are Apple's equalized prices, exported
|
|
// for reference and ignored on import).
|
|
// import: reads the current prices, prints the diff table, and (not --dry) asks before writing. Apple: one new price
|
|
// schedule per changed product (base territory + every manual price; territories without one are equalized by Apple;
|
|
// an increase cannot be undone once it starts). Play: the purchase option's regional prices are replaced: CSV regions
|
|
// on top of the current ones; regions without a price get convertRegionPrices from the USD base (CSV US row, else the
|
|
// config's usd); --reconvert resets every region the CSV does not list to Play's converted price. Pure parts: lib/prices.ts.
|
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { ascClient, type Asc, type J } from './apple/asc';
|
|
import type { Ctx } from './lib/ctx';
|
|
import { confirm, need } from './lib/ctx';
|
|
import { done, log, printJson, step, warn } from './lib/log';
|
|
import { toMoney } from './lib/money';
|
|
import {
|
|
appleSchedule, appleScheduleBody, applePriceRows, diffPrices, diffTable, parsePriceCsv, type PlayProduct, playPatchBody, playPriceRows, playRegions,
|
|
pointKey, type PriceRow, pricesCsv, sameSchedule,
|
|
} from './lib/prices';
|
|
import { playClient, type PlayClient } from './play/client';
|
|
import { allSubscriptions, appleSubPriceRows, basePlanId, type PlaySubscription, playSubPriceRows } from './lib/subscriptions';
|
|
|
|
const TAG = 'prices';
|
|
|
|
export async function iapId(c: Asc, appId: string, productId: string): Promise<string | null> {
|
|
return (await c.asc('GET', `/v1/apps/${appId}/inAppPurchasesV2?filter[productId]=${encodeURIComponent(productId)}`)).data?.[0]?.id ?? null;
|
|
}
|
|
|
|
export async function appleCurrent(c: Asc, id: string, product: string, withAuto: boolean): Promise<{ rows: PriceRow[]; base: string | null }> {
|
|
const q = 'include=inAppPurchasePricePoint,territory&limit=200';
|
|
const rows = applePriceRows(product, await pages(c, `/v1/inAppPurchasePriceSchedules/${id}/manualPrices?${q}`));
|
|
if (withAuto) rows.push(...applePriceRows(product, await pages(c, `/v1/inAppPurchasePriceSchedules/${id}/automaticPrices?${q}`), 'auto'));
|
|
const base = (await c.maybe(`/v1/inAppPurchasePriceSchedules/${id}/baseTerritory`))?.data?.id ?? null;
|
|
return { rows, base };
|
|
}
|
|
|
|
/** data + included of every page of a paged GET (c.all keeps only data). */
|
|
export async function pages(c: Asc, path: string): Promise<{ data: J[]; included: J[] }> {
|
|
const out = { data: [] as J[], included: [] as J[] };
|
|
let url: string | null = path;
|
|
while (url) { const page: J = await c.asc('GET', url); out.data.push(...(page.data ?? [])); out.included.push(...(page.included ?? [])); url = page.links?.next ?? null; }
|
|
return out;
|
|
}
|
|
|
|
export async function playProduct(play: PlayClient, id: string): Promise<PlayProduct | null> {
|
|
try { return await play.call<PlayProduct>('GET', `${play.API}/oneTimeProducts/${encodeURIComponent(id)}`); }
|
|
catch (e) { if (/-> 404/.test(String(e))) return null; throw e; }
|
|
}
|
|
|
|
export async function run(ctx: Ctx): Promise<void> {
|
|
const { config, root, resolve } = ctx.loaded;
|
|
const a = ctx.args;
|
|
const sub = a._[0];
|
|
if (sub === 'ppp') return (await import('./prices-ppp')).run(ctx);
|
|
if (sub !== 'export' && sub !== 'import') throw new Error('usage: storebound prices export | import [--csv store/prices.csv] [--dry] | ppp [--base <USD>] [--dry]');
|
|
const store = a.get('store');
|
|
if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play');
|
|
const useApple = (!store || store === 'apple') && !!config.apple;
|
|
const usePlay = (!store || store === 'play') && !!config.play;
|
|
const only = a.list('only');
|
|
const products = (config.products ?? []).filter(p => !only.length || only.includes(p.id));
|
|
const subs = sub === 'export' && !a.has('no-subscriptions') ? allSubscriptions(config.subscriptions).map(x => x.sub).filter(s => !only.length || only.includes(s.id)) : [];
|
|
if (!products.length && !subs.length) need(undefined, sub === 'export' ? 'products or subscriptions' : 'products');
|
|
const csvPath = resolve(a.get(sub === 'export' ? 'out' : 'csv') ?? config.prices?.csv ?? 'store/prices.csv');
|
|
const dry = a.has('dry');
|
|
|
|
const c = useApple ? await ascClient(root, config.apple!.bundleId, config.apple?.appId) : null;
|
|
const appId = c ? await c.app() : '';
|
|
const play = usePlay ? await playClient(root, config.play!.packageName) : null;
|
|
|
|
// current state of every product on the chosen stores
|
|
const current: PriceRow[] = [];
|
|
const missing: { product: string; store: 'apple' | 'play' }[] = [];
|
|
const iaps = new Map<string, { id: string; base: string | null }>();
|
|
const playProducts = new Map<string, PlayProduct>();
|
|
for (const [i, p] of products.entries()) {
|
|
log(TAG, `${step(i + 1, products.length, p.id)}: reading current prices`);
|
|
if (c) {
|
|
const id = await iapId(c, appId, p.id);
|
|
if (!id) { warn(TAG, `${p.id}: not on App Store Connect (storebound apple iap creates it)`); missing.push({ product: p.id, store: 'apple' }); }
|
|
else { const cur = await appleCurrent(c, id, p.id, sub === 'export' && a.has('with-automatic')); current.push(...cur.rows); iaps.set(p.id, { id, base: cur.base }); }
|
|
}
|
|
if (play) {
|
|
const pp = await playProduct(play, p.id);
|
|
if (!pp) { warn(TAG, `${p.id}: not on Google Play (storebound play products creates it)`); missing.push({ product: p.id, store: 'play' }); }
|
|
else { current.push(...playPriceRows(pp)); playProducts.set(p.id, pp); }
|
|
}
|
|
}
|
|
|
|
if (sub === 'export' && subs.length) {
|
|
const appleSubs = new Map<string, string>();
|
|
if (c) for (const g of await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`))
|
|
for (const x of await c.all(`/v1/subscriptionGroups/${g.id}/subscriptions?limit=200`)) appleSubs.set(String(x.attributes.productId), x.id);
|
|
for (const [i, s] of subs.entries()) {
|
|
log(TAG, `${step(i + 1, subs.length, s.id)}: reading subscription prices`);
|
|
if (c) {
|
|
const id = appleSubs.get(s.id);
|
|
if (!id) { warn(TAG, `${s.id}: subscription not on App Store Connect (storebound apple subscriptions creates it)`); missing.push({ product: s.id, store: 'apple' }); }
|
|
else current.push(...appleSubPriceRows(s.id, await pages(c, `/v1/subscriptions/${id}/prices?include=subscriptionPricePoint,territory&limit=200`)));
|
|
}
|
|
if (play) {
|
|
let ps: PlaySubscription | null = null;
|
|
try { ps = await play.call<PlaySubscription>('GET', `${play.API}/subscriptions/${encodeURIComponent(s.id)}`); } catch (e) { if (!/-> 404/.test(String(e))) throw e; }
|
|
if (!ps) { warn(TAG, `${s.id}: subscription not on Google Play (storebound play subscriptions creates it)`); missing.push({ product: s.id, store: 'play' }); }
|
|
else current.push(...playSubPriceRows(ps, basePlanId(s)));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (sub === 'export') {
|
|
// --json: the current prices as data (the desktop price view); read-only, never writes the CSV
|
|
if (a.has('json')) {
|
|
printJson({ csv: csvPath, products: products.map(p => p.id), subscriptions: subs.map(s => s.id), stores: [...(useApple ? ['apple'] : []), ...(usePlay ? ['play'] : [])], missing,
|
|
rows: current.map(({ pointId: _p, ...r }) => r) });
|
|
return;
|
|
}
|
|
const csv = pricesCsv(current);
|
|
if (dry) { console.log(csv); done(TAG, `dry run: would write ${current.length} rows to ${csvPath}`); return; }
|
|
writeFileSync(csvPath, csv);
|
|
done(TAG, `${current.length} rows -> ${csvPath} (edit, then storebound prices import --dry${subs.length ? '; subscription rows: apple subscriptions --csv / play subscriptions --csv' : ''})`);
|
|
return;
|
|
}
|
|
|
|
if (!existsSync(csvPath)) throw new Error(`no prices CSV at ${csvPath} (storebound prices export writes one)`);
|
|
const subIds = allSubscriptions(config.subscriptions).map(x => x.sub.id);
|
|
const sheet = parsePriceCsv(readFileSync(csvPath, 'utf8'), { products: [...(config.products ?? []).map(p => p.id), ...subIds] });
|
|
const subRows = sheet.filter(r => subIds.includes(r.product));
|
|
if (subRows.length) log(TAG, `${subRows.length} subscription row(s) skipped here: storebound apple subscriptions --csv ${a.get('csv') ?? config.prices?.csv ?? 'store/prices.csv'} --dry (and play subscriptions --csv) apply them`);
|
|
const wantedAll = sheet
|
|
.filter(r => products.some(p => p.id === r.product) && ((r.store === 'apple' && useApple) || (r.store === 'play' && usePlay)));
|
|
const diff = diffPrices(current, wantedAll);
|
|
const changed = new Set(diff.filter(d => d.change !== 'same').map(d => `${d.product}|${d.store}`));
|
|
const reconvert = a.has('reconvert');
|
|
if (reconvert && usePlay) for (const p of products) if (playProducts.has(p.id)) changed.add(`${p.id}|play`);
|
|
// --json is a read-only diff: it never writes, whatever the other flags say.
|
|
if (a.has('json')) { printJson({ csv: csvPath, diff: a.has('all') ? diff : diff.filter(d => d.change !== 'same'), changed: [...changed] }); return; }
|
|
console.log(diffTable(diff, { all: a.has('all') }));
|
|
if (!changed.size) { done(TAG, 'prices up to date'); return; }
|
|
log(TAG, `${changed.size} product / store pair(s) to update`);
|
|
if (dry) { done(TAG, 'dry run: nothing written'); return; }
|
|
if (!(await confirm(ctx, `Write the new prices for ${changed.size} product / store pair(s)? Users see them as soon as the stores apply them; an App Store price increase cannot be undone once it starts.`))) return;
|
|
|
|
for (const p of products) {
|
|
const wanted = wantedAll.filter(r => r.product === p.id);
|
|
if (c && changed.has(`${p.id}|apple`)) {
|
|
const iap = iaps.get(p.id);
|
|
if (!iap) { warn(TAG, `${p.id}: no App Store product, skipped`); continue; }
|
|
const cur = current.filter(r => r.product === p.id && r.store === 'apple');
|
|
const base = config.prices?.baseTerritory ?? iap.base ?? 'USA';
|
|
const entries = appleSchedule(cur, wanted.filter(r => r.store === 'apple'), base);
|
|
if (sameSchedule(entries, cur.filter(r => r.kind !== 'auto').map(r => ({ territory: r.territory!, price: r.price, startDate: r.startDate ?? null })))) { log(TAG, `${p.id} apple: schedule unchanged`); }
|
|
else {
|
|
const points = new Map(cur.filter(r => r.pointId).map(r => [pointKey(r.territory!, r.price), r.pointId!] as const));
|
|
for (const t of new Set(entries.filter(e => !points.has(pointKey(e.territory, e.price))).map(e => e.territory))) {
|
|
for (const pp of await c.all(`/v2/inAppPurchases/${iap.id}/pricePoints?filter[territory]=${t}&limit=8000`)) points.set(pointKey(t, pp.attributes.customerPrice), pp.id);
|
|
}
|
|
const missing = entries.filter(e => !points.has(pointKey(e.territory, e.price)));
|
|
if (missing.length) throw new Error(`${p.id}: no App Store price point for ${missing.map(e => `${e.territory} ${e.price}`).join(', ')} (Apple only takes its own price points; storebound prices export --with-automatic shows the equalized ones)`);
|
|
await c.asc('POST', '/v1/inAppPurchasePriceSchedules', appleScheduleBody(iap.id, base, entries, points));
|
|
log(TAG, `${p.id} apple: schedule set (${entries.length} manual price(s), base ${base})`);
|
|
}
|
|
}
|
|
if (play && changed.has(`${p.id}|play`)) {
|
|
const pp = playProducts.get(p.id);
|
|
if (!pp) { warn(TAG, `${p.id}: no Play product, skipped`); continue; }
|
|
const usd = wanted.find(r => r.store === 'play' && r.region === 'US')?.price ?? p.usd;
|
|
const conv = await play.call<{ convertedRegionPrices?: Record<string, { regionCode: string; price: { currencyCode: string; units: string; nanos?: number } }>; regionVersion?: { version: string } }>(
|
|
'POST', `${play.API}/pricing:convertRegionPrices`, { price: toMoney('USD', usd) });
|
|
const opt = pp.purchaseOptions?.find(o => o.purchaseOptionId === 'buy') ?? pp.purchaseOptions?.[0];
|
|
if (!opt) { warn(TAG, `${p.id}: Play product has no purchase option, skipped`); continue; }
|
|
const regions = playRegions(opt.regionalPricingAndAvailabilityConfigs ?? [], wanted.filter(r => r.store === 'play'), conv.convertedRegionPrices ?? null, { reconvert });
|
|
const v = encodeURIComponent(conv.regionVersion?.version ?? '2022/02');
|
|
await play.call('PATCH', `${play.API}/onetimeproducts/${encodeURIComponent(p.id)}?regionsVersion.version=${v}&updateMask=purchaseOptions`, playPatchBody(pp, regions, opt.purchaseOptionId));
|
|
log(TAG, `${p.id} play: ${regions.length} regions set`);
|
|
}
|
|
}
|
|
done(TAG, 'prices imported (storebound prices export shows the stores\' view)');
|
|
}
|