// 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] [--allow-price-changes] // storebound prices ppp [--base ] [...] 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. A product that already // has prices on a store is a price change: the owner's call. In a terminal (no --yes) the one question covers it; // --yes (also from `flow run --apply --yes`) only sets first prices and never changes existing ones, which need // --allow-price-changes: without it the run writes the first prices, prints "price change on ( -> ) // needs the owner" per product and exits 3 (declined). The dry run marks those pairs "owner". 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 { allowPriceChanges, type HeldPrice, holdPriceChanges } from './lib/price-gate'; 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'; import { stdinIsTTY } from './lib/tty'; const TAG = 'prices'; export async function iapId(c: Asc, appId: string, productId: string): Promise { 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 { try { return await play.call('GET', `${play.API}/oneTimeProducts/${encodeURIComponent(id)}`); } catch (e) { if (/-> 404/.test(String(e))) return null; throw e; } } export async function run(ctx: Ctx): Promise { 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 ] [--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(); const playProducts = new Map(); 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(); 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('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`); // a pair that has prices on the store already is a price change of a live product: the owner's call const live = [...changed].filter(k => current.some(r => `${r.product}|${r.store}` === k && r.kind !== 'auto')); const first = [...changed].filter(k => !live.includes(k)); if (live.length) log(TAG, `owner: price change of ${live.length} existing product / store pair(s) (--allow-price-changes): ${live.map(k => k.replace('|', ' ')).join(', ')}`); if (dry) { done(TAG, 'dry run: nothing written'); return; } let liveOk: boolean; if (stdinIsTTY() && !a.has('yes') && process.env.STOREBOUND_SERVE !== '1') { 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; liveOk = true; } else { if (first.length && !(await confirm(ctx, `Set the first prices of ${first.length} product / store pair(s) (${first.map(k => k.replace('|', ' ')).join(', ')})?`))) return; liveOk = await allowPriceChanges(ctx, live.length, `Change the prices of ${live.length} existing product / store pair(s) (${live.map(k => k.replace('|', ' ')).join(', ')})? Users see them as soon as the stores apply them; an App Store price increase cannot be undone once it starts.`); } const held: HeldPrice[] = []; if (!liveOk) for (const k of live) { changed.delete(k); const rows = diff.filter(d => `${d.product}|${d.store}` === k && d.change !== 'same'); held.push({ id: k.replace('|', ' '), changes: rows.length ? rows.map(d => ({ region: d.region, from: d.from === null ? '-' : `${d.currency} ${d.from}`, to: `${d.currency} ${d.to}${d.startDate ? ` from ${d.startDate}` : ''}` })) : [{ region: 'every region', from: 'current', to: 'reconverted' }] }); } 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; 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`); } } holdPriceChanges(held); done(TAG, held.length ? `${first.length ? 'first prices imported' : 'nothing written'}, price changes left for the owner` : 'prices imported (storebound prices export shows the stores\' view)'); }