174 lines
12 KiB
TypeScript
174 lines
12 KiB
TypeScript
// Purchasing power parity price proposals: one price sheet for every product, store and region.
|
|
// storebound prices ppp [--base <USD>] [--source worldbank|bigmac|file] [--file <csv>] [--only ids] [--store apple|play]
|
|
// [--floor 0.3] [--ceiling 1.2] [--out store/prices-ppp.csv] [--offline] [--dry] [--json]
|
|
// storebound prices ppp --update-bigmac [--dry] download The Economist Big Mac index to .storebound/ppp-bigmac.json
|
|
// It writes a CSV in the `prices import` format (nothing is sent to a store), prints the diff against the stores'
|
|
// current prices and the next step (`storebound prices import --csv <out> --dry`). --dry prints the CSV and the diff
|
|
// and writes nothing. --json prints { source, base, rows, skipped, diff, out, written } (with --dry: written false).
|
|
// Online (credentials set, no --offline): Apple prices snap to the product's real price points per territory
|
|
// (GET /v2/inAppPurchases/{id}/pricePoints?filter[territory]=...&include=territory, read-only) and the territory's
|
|
// currency comes from Apple; Play regions and currencies come from pricing:convertRegionPrices of the base (a
|
|
// read-only conversion). A product missing on a store falls back to offline snapping for that store. Math and
|
|
// snapping rules: lib/ppp.ts.
|
|
// Sources (checked October 2026):
|
|
// https://developer.apple.com/documentation/appstoreconnectapi/get-v2-inapppurchases-_id_-pricepoints
|
|
// https://developers.google.com/android-publisher/api-ref/rest/v3/monetization/convertRegionPrices
|
|
// https://data.worldbank.org/indicator/PA.NUS.PPP, https://data.worldbank.org/indicator/PA.NUS.FCRF
|
|
// https://github.com/TheEconomist/big-mac-data (output-data/big-mac-raw-index.csv)
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import worldbank from './data/ppp-worldbank.json' with { type: 'json' };
|
|
import { ascClient, type Asc, type J } from './apple/asc';
|
|
import { sellsOn } from './lib/config';
|
|
import { credentialValue } from './lib/credentials';
|
|
import type { Ctx } from './lib/ctx';
|
|
import { need } from './lib/ctx';
|
|
import { done, log, printJson, step, warn } from './lib/log';
|
|
import { ALPHA3, toMoney } from './lib/money';
|
|
import { bigMacDataset, datasetJson, datasetProblems, parseFactorCsv, planRows, type PppDataset, pppCsv, type PppRow, type StoreRegion } from './lib/ppp';
|
|
import { alpha2, diffPrices, diffTable, playPriceRows, type PriceRow } from './lib/prices';
|
|
import { playClient, type PlayClient } from './play/client';
|
|
import { appleCurrent, iapId, pages, playProduct } from './prices';
|
|
|
|
const TAG = 'ppp';
|
|
const BIGMAC_URL = 'https://raw.githubusercontent.com/TheEconomist/big-mac-data/master/output-data/big-mac-raw-index.csv';
|
|
export const bundled = worldbank as unknown as PppDataset;
|
|
|
|
/** Downloads the Big Mac index and writes <root>/.storebound/ppp-bigmac.json (also used by tools/update-ppp.ts). */
|
|
export async function updateBigMac(root: string, dry: boolean): Promise<void> {
|
|
const r = await fetch(BIGMAC_URL);
|
|
if (!r.ok) throw new Error(`${BIGMAC_URL} -> ${r.status}`);
|
|
const ds = bigMacDataset(await r.text(), new Date().toISOString().slice(0, 10));
|
|
const p = join(root, '.storebound', 'ppp-bigmac.json');
|
|
if (dry) { done(TAG, `dry run: would write ${Object.keys(ds.countries).length} countries (${ds.source}) to ${p}`); return; }
|
|
mkdirSync(dirname(p), { recursive: true });
|
|
writeFileSync(p, datasetJson(ds));
|
|
done(TAG, `${Object.keys(ds.countries).length} countries (${ds.source}) -> ${p}`);
|
|
}
|
|
|
|
export function loadDataset(source: string, root: string, file: string | undefined): PppDataset {
|
|
if (source === 'worldbank') return bundled;
|
|
if (source === 'bigmac') {
|
|
const p = join(root, '.storebound', 'ppp-bigmac.json');
|
|
if (!existsSync(p)) throw new Error(`no Big Mac data at ${p}: run storebound prices ppp --update-bigmac (downloads The Economist's data to your machine; it is not bundled)`);
|
|
const ds = JSON.parse(readFileSync(p, 'utf8')) as PppDataset;
|
|
const e = datasetProblems(ds);
|
|
if (e.length) throw new Error(`${p}: ${e.join('; ')} (run storebound prices ppp --update-bigmac again)`);
|
|
return ds;
|
|
}
|
|
if (source === 'file') {
|
|
if (!file) throw new Error('--source file needs --file <csv> (or prices.ppp.file in the config)');
|
|
if (!existsSync(file)) throw new Error(`no PPP factor CSV at ${file} (columns: region, factor, currency, fx)`);
|
|
return parseFactorCsv(readFileSync(file, 'utf8'), bundled, file);
|
|
}
|
|
throw new Error('--source worldbank | bigmac | file');
|
|
}
|
|
|
|
/** Apple price points of one IAP in the given territories (alpha-3), batched: region (alpha-2) -> currency + points. */
|
|
export async function applePoints(c: Asc, id: string, territories: string[]): Promise<Map<string, StoreRegion>> {
|
|
const out = new Map<string, StoreRegion>();
|
|
for (let i = 0; i < territories.length; i += 10) {
|
|
const batch = territories.slice(i, i + 10);
|
|
const r = await pages(c, `/v2/inAppPurchases/${id}/pricePoints?filter[territory]=${batch.join(',')}&include=territory&fields[territories]=currency&limit=8000`);
|
|
const curr = new Map(r.included.filter((x: J) => x.type === 'territories').map((x: J) => [x.id, x.attributes?.currency] as const));
|
|
for (const p of r.data) {
|
|
const t: string | undefined = p.relationships?.territory?.data?.id;
|
|
if (!t) continue;
|
|
const region = alpha2(t);
|
|
const e: StoreRegion = out.get(region) ?? { currency: curr.get(t) ?? '', points: [] };
|
|
e.points!.push(Number(p.attributes?.customerPrice));
|
|
out.set(region, e);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
type Converted = { convertedRegionPrices?: Record<string, { regionCode: string; price: { currencyCode: string } }> };
|
|
|
|
export async function run(ctx: Ctx): Promise<void> {
|
|
const { config, root, resolve } = ctx.loaded;
|
|
const a = ctx.args;
|
|
const pc = config.prices?.ppp ?? {};
|
|
const dry = a.has('dry');
|
|
if (a.has('update-bigmac')) return updateBigMac(root, dry);
|
|
|
|
const store = a.get('store');
|
|
if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play');
|
|
const source = a.get('source') ?? pc.source ?? 'worldbank';
|
|
const fileArg = a.get('file') ?? pc.file;
|
|
const ds = loadDataset(source, root, fileArg ? resolve(fileArg) : undefined);
|
|
const floor = a.num('floor', pc.floor ?? 0.3), ceiling = a.num('ceiling', pc.ceiling ?? 1.2);
|
|
if (!(floor > 0) || !(ceiling > 0) || floor > ceiling) throw new Error('--floor and --ceiling: shares of the base price, 0 < floor <= ceiling (e.g. 0.3 and 1.2)');
|
|
const baseArg = a.get('base') !== undefined ? a.num('base', 0) : undefined;
|
|
if (baseArg !== undefined && !(baseArg > 0)) throw new Error('--base: a USD price, e.g. 4.99');
|
|
const only = a.list('only');
|
|
const products = need(config.products, 'products').filter(p => !only.length || only.includes(p.id));
|
|
if (!products.length) throw new Error(only.length ? `--only ${only.join(',')}: no such product in products[]` : 'products[] is empty');
|
|
const out = resolve(a.get('out') ?? pc.out ?? 'store/prices-ppp.csv');
|
|
const offline = a.has('offline');
|
|
const stores = (['apple', 'play'] as const).filter(s => (!store || store === s) && !!config[s]);
|
|
if (!stores.length) throw new Error('no store configured for this run (apple / play in storebound.config.ts)');
|
|
|
|
const online = {
|
|
apple: !offline && stores.includes('apple') && !!credentialValue(root, 'ASC_KEY_ID') && !!credentialValue(root, 'ASC_ISSUER_ID'),
|
|
play: !offline && stores.includes('play') && !!credentialValue(root, 'PLAY_SERVICE_ACCOUNT'),
|
|
};
|
|
for (const s of stores) if (!offline && !online[s]) warn(TAG, `${s}: no credentials, offline snapping (storebound doctor shows what is missing)`);
|
|
const c = online.apple ? await ascClient(root, config.apple!.bundleId, config.apple?.appId) : null;
|
|
const appId = c ? await c.app() : '';
|
|
const play: PlayClient | null = online.play ? await playClient(root, config.play!.packageName) : null;
|
|
log(TAG, `${ds.name}: ${ds.source}; retrieved ${ds.retrieved || '-'}; floor ${floor}, ceiling ${ceiling}; ${stores.map(s => `${s} ${online[s] ? 'online' : 'offline'}`).join(', ')}`);
|
|
|
|
const rows: PppRow[] = [];
|
|
const current: PriceRow[] = [];
|
|
const skipped: { product: string; store: string; region: string; why: string }[] = [];
|
|
const offlineApple = new Set<string>();
|
|
const converted = new Map<number, Converted>();
|
|
const base: Record<string, number> = {};
|
|
for (const [i, p] of products.entries()) {
|
|
const usd = baseArg ?? p.usd;
|
|
base[p.id] = usd;
|
|
log(TAG, `${step(i + 1, products.length, p.id)}: base ${usd} USD`);
|
|
for (const s of stores) {
|
|
if (!sellsOn(config, p, s)) continue;
|
|
let regions: Map<string, StoreRegion> | undefined;
|
|
if (s === 'apple' && 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); offline snapping for Apple`);
|
|
else {
|
|
current.push(...(await appleCurrent(c, id, p.id, true)).rows);
|
|
const want = Object.keys(ALPHA3).filter(r => r === 'US' || ds.countries[r]).map(r => ALPHA3[r]!);
|
|
regions = await applePoints(c, id, want);
|
|
}
|
|
}
|
|
if (s === 'play' && play) {
|
|
const pp = await playProduct(play, p.id);
|
|
if (!pp) warn(TAG, `${p.id}: not on Google Play (storebound play products creates it); proposal against no current prices`);
|
|
else current.push(...playPriceRows(pp));
|
|
if (!converted.has(usd)) converted.set(usd, await play.call<Converted>('POST', `${play.API}/pricing:convertRegionPrices`, { price: toMoney('USD', usd) }));
|
|
regions = new Map(Object.values(converted.get(usd)!.convertedRegionPrices ?? {}).map(r => [r.regionCode, { currency: r.price.currencyCode }] as const));
|
|
}
|
|
if (s === 'apple' && !regions) offlineApple.add(p.id);
|
|
const plan = planRows({ product: p.id, base: usd, store: s, dataset: ds, floor, ceiling, regions });
|
|
rows.push(...plan.rows);
|
|
skipped.push(...plan.skipped.map(k => ({ product: p.id, store: s, ...k })));
|
|
}
|
|
}
|
|
|
|
const csv = pppCsv(rows);
|
|
const diff = diffPrices(current, rows.map(({ product, store, region, territory, currency, price }) => ({ product, store, region, territory, currency, price })));
|
|
const sourceInfo = { name: ds.name, year: ds.year ?? null, retrieved: ds.retrieved, url: ds.url, license: ds.license };
|
|
if (!dry) { mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, csv); }
|
|
if (a.has('json')) {
|
|
printJson({ source: sourceInfo, base, floor, ceiling, online, rows: rows.map(({ territory: _t, ...r }) => r), skipped, diff, out, written: !dry });
|
|
return;
|
|
}
|
|
if (dry) console.log(csv);
|
|
console.log(diffTable(diff));
|
|
const clamped = rows.filter(r => r.clamped).length;
|
|
log(TAG, `${rows.length} rows (${clamped} clamped to the floor / ceiling), ${skipped.length} region(s) without a proposal${skipped.length ? ` (${[...new Set(skipped.map(k => k.region))].slice(0, 12).join(', ')}${skipped.length > 12 ? ', ...' : ''})` : ''}`);
|
|
if (offlineApple.size) warn(TAG, `Apple values for ${[...offlineApple].join(', ')} are offline proposals: each must still match an App Store price point (prices import checks it and lists the neighbours)`);
|
|
log(TAG, `source: ${ds.attribution ?? ds.source} (${ds.license})`);
|
|
log(TAG, 'a proposal: review the sheet (remove rows you do not want, round to your own price ladder); nothing was sent to a store');
|
|
done(TAG, dry ? `dry run: would write ${rows.length} rows to ${out}` : `${rows.length} rows -> ${out}; next: storebound prices import --csv ${a.get('out') ?? pc.out ?? 'store/prices-ppp.csv'} --dry`);
|
|
}
|