storebound/cli/prices.ts

207 lines
16 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] [--allow-price-changes]
// 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. A product that already
// has prices on a store is a price change: the owner's call, only with --allow-price-changes, TTY or not. The first
// prices question (a terminal "y" or --yes, also from `flow run --apply --yes`) never covers them; with the flag they
// get their own question listing each old -> new (lib/price-gate.ts). Without it the run writes the first prices,
// prints "price change on <id> (<from> -> <to>) needs the owner" per product and exits 3 (declined). The dry run
// shows them in its own "price changes on existing products (held)" section. 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, heldSection, 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';
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);
// `prices diff`: the config's prices against both stores (the same read-only report as `catalog diff`)
if (sub === 'diff') { ctx.args._.splice(0, 1, 'diff'); return (await import('./catalog')).run(ctx); }
if (sub !== 'export' && sub !== 'import') throw new Error('usage: storebound prices export | import [--csv store/prices.csv] [--dry] | diff | 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`);
// 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(', ')}`);
const liveHeld: HeldPrice[] = live.map(k => {
const rows = diff.filter(d => `${d.product}|${d.store}` === k && d.change !== 'same');
return { 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' }] };
});
if (dry) { const h = heldSection(liveHeld, a.has('allow-price-changes')); if (h) console.log(h); done(TAG, 'dry run: nothing written'); return; }
// first prices: one confirmation (terminal or --yes). Price changes of existing products: never part of it, only
// with --allow-price-changes and their own question listing each old -> new (lib/price-gate.ts), TTY or not.
if (first.length && !(await confirm(ctx, `Set the first prices of ${first.length} product / store pair(s) (${first.map(k => k.replace('|', ' ')).join(', ')})?`))) return;
const liveOk = await allowPriceChanges(ctx, liveHeld, `Change the prices of ${live.length} existing 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.`);
const held: HeldPrice[] = [];
if (!liveOk) { for (const k of live) changed.delete(k); held.push(...liveHeld); }
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`);
}
}
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)');
}