--json prints only JSON on stdout (withJsonOutput around every command: logs, tables and prompts to stderr; printJson everywhere; test runs every --json command against the fake stores and parses stdout); prices ppp compares like with like: par = the store's own US-equivalent price (Play convertRegionPrices, Apple equalizations; offline base x fx x (1 + VAT) rounded up) scaled by the PPP price level; defaults only lower, only price levels <= 0.5, World Bank high-income at par, CN at par on Apple and skipped on Play, never above par (--only-decreases, --max-ratio, --min-ratio, --max-level, --include-high-income; --floor / --ceiling kept), top-10 revenue market drops > 20 % warned (sales reports, else a static list), unchanged regions out of the sheet; price changes of existing products only with --allow-price-changes, TTY or not (a terminal yes covered them), then their own question listing each old -> new, held section in dry runs (play products, prices import, play / apple subscriptions); pitfalls 61-63; tests
This commit is contained in:
parent
bab3f63227
commit
357319a775
30 changed files with 865 additions and 209 deletions
|
|
@ -1,6 +1,12 @@
|
|||
// 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]
|
||||
// [--min-ratio 0.3] [--max-ratio 1] [--max-level 0.5] [--only-decreases | --no-only-decreases]
|
||||
// [--include-high-income] [--out store/prices-ppp.csv] [--offline] [--dry] [--json]
|
||||
// Defaults (STAR DASH, 2026-10-14: the old method cut Germany to EUR 3.59 for a USD 4.99 product): only lower prices,
|
||||
// only in economies whose price level is at most 50 % of the US, World Bank high-income economies at par (the store's
|
||||
// own US-equivalent price), mainland China at par on Apple and skipped on Play; --floor / --ceiling are the old names
|
||||
// of --min-ratio / --max-ratio. A top-10 revenue market (from .storebound/reports/ when a sales report is there, else
|
||||
// lib/ppp.ts TOP_MARKETS) that would drop more than 20 % is warned about.
|
||||
// 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
|
||||
|
|
@ -15,7 +21,7 @@
|
|||
// 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 { existsSync, mkdirSync, readdirSync, 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';
|
||||
|
|
@ -25,7 +31,11 @@ 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 {
|
||||
bigMacDataset, datasetJson, datasetProblems, parseFactorCsv, planRows, PPP_DEFAULTS, type PppDataset, pppCsv, type PppOptions, type PppRow, type StoreRegion, TOP_DROP,
|
||||
TOP_MARKETS, topMarketDrops, topMarketsFromReports,
|
||||
} from './lib/ppp';
|
||||
import { parsePlayCsv, parseTsv } from './lib/reports';
|
||||
import { alpha2, diffPrices, diffTable, playPriceRows, type PriceRow } from './lib/prices';
|
||||
import { playClient, type PlayClient } from './play/client';
|
||||
import { appleCurrent, iapId, pages, playProduct } from './prices';
|
||||
|
|
@ -64,8 +74,21 @@ export function loadDataset(source: string, root: string, file: string | undefin
|
|||
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>> {
|
||||
/**
|
||||
* Apple's own conversion of the US price: the equalizations of the USA price point (tax included, today's rates),
|
||||
* region (alpha-2) -> customer price. Empty when the call fails (then the offline par price is used).
|
||||
*/
|
||||
export async function appleEqualized(c: Asc, usaPointId: string): Promise<Map<string, number>> {
|
||||
const out = new Map<string, number>();
|
||||
try {
|
||||
const r = await pages(c, `/v1/inAppPurchasePricePoints/${usaPointId}/equalizations?include=territory&limit=8000`);
|
||||
for (const p of r.data) { const t: string | undefined = p.relationships?.territory?.data?.id; if (t) out.set(alpha2(t), Number(p.attributes?.customerPrice)); }
|
||||
} catch (e) { warn(TAG, `Apple equalizations of the US price: ${(e as Error).message}; offline par prices for Apple`); }
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Apple price points of one IAP in the given territories (alpha-3), batched: region (alpha-2) -> currency + points (+ ids of the USA points). */
|
||||
export async function applePoints(c: Asc, id: string, territories: string[], usaIds?: Map<number, 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);
|
||||
|
|
@ -77,13 +100,40 @@ export async function applePoints(c: Asc, id: string, territories: string[]): Pr
|
|||
const region = alpha2(t);
|
||||
const e: StoreRegion = out.get(region) ?? { currency: curr.get(t) ?? '', points: [] };
|
||||
e.points!.push(Number(p.attributes?.customerPrice));
|
||||
if (t === 'USA' && usaIds && p.id) usaIds.set(Number(p.attributes?.customerPrice), p.id);
|
||||
out.set(region, e);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type Converted = { convertedRegionPrices?: Record<string, { regionCode: string; price: { currencyCode: string } }> };
|
||||
type Converted = { convertedRegionPrices?: Record<string, { regionCode: string; price: { currencyCode: string; units?: string; nanos?: number } }> };
|
||||
|
||||
/** The options of a run: flags over prices.ppp in the config over PPP_DEFAULTS (--floor / --ceiling: old names). */
|
||||
export function pppOptions(a: Ctx['args'], pc: NonNullable<NonNullable<Ctx['loaded']['config']['prices']>['ppp']>): PppOptions {
|
||||
const o: PppOptions = {
|
||||
minRatio: a.num('min-ratio', a.num('floor', pc.minRatio ?? pc.floor ?? PPP_DEFAULTS.minRatio)),
|
||||
maxRatio: a.num('max-ratio', a.num('ceiling', pc.maxRatio ?? pc.ceiling ?? PPP_DEFAULTS.maxRatio)),
|
||||
maxLevel: a.num('max-level', pc.maxLevel ?? PPP_DEFAULTS.maxLevel),
|
||||
onlyDecreases: a.has('no-only-decreases') ? false : a.has('only-decreases') ? true : pc.onlyDecreases ?? PPP_DEFAULTS.onlyDecreases,
|
||||
includeHighIncome: a.has('include-high-income') || (pc.includeHighIncome ?? PPP_DEFAULTS.includeHighIncome),
|
||||
};
|
||||
if (!(o.minRatio > 0) || !(o.maxRatio > 0) || o.minRatio > o.maxRatio) throw new Error('--min-ratio and --max-ratio (old names --floor / --ceiling): shares of the US-equivalent price, 0 < min <= max (e.g. 0.3 and 1)');
|
||||
if (!(o.maxLevel > 0)) throw new Error('--max-level: the highest price level (share of the US) that gets a PPP price, e.g. 0.5');
|
||||
return o;
|
||||
}
|
||||
|
||||
/** Top revenue markets: from the sales reports in .storebound/reports/ when there are any, else the static list. */
|
||||
export function topMarkets(root: string, ds: PppDataset): { markets: string[]; from: 'sales-reports' | 'static' } {
|
||||
const dir = join(root, '.storebound', 'reports');
|
||||
const reports: Record<string, string>[][] = [];
|
||||
if (existsSync(dir)) for (const f of readdirSync(dir)) {
|
||||
if (f.endsWith('.summary.csv') || !/\.(tsv|csv|txt)$/.test(f)) continue;
|
||||
try { const t = readFileSync(join(dir, f), 'utf8'); reports.push(f.endsWith('.csv') ? parsePlayCsv(t) : parseTsv(t)); } catch { /* unreadable: skipped */ }
|
||||
}
|
||||
const m = topMarketsFromReports(reports, ds);
|
||||
return m.length ? { markets: m, from: 'sales-reports' } : { markets: TOP_MARKETS, from: 'static' };
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
const { config, root, resolve } = ctx.loaded;
|
||||
|
|
@ -97,8 +147,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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 options = pppOptions(a, pc);
|
||||
const { minRatio: floor, maxRatio: ceiling } = options;
|
||||
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');
|
||||
|
|
@ -117,9 +167,11 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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(', ')}`);
|
||||
log(TAG, `${ds.name}: ${ds.source}; retrieved ${ds.retrieved || '-'}; ${stores.map(s => `${s} ${online[s] ? 'online' : 'offline'}`).join(', ')}`);
|
||||
log(TAG, `rules: ${options.onlyDecreases ? 'only lower prices' : 'lower and raise'}; PPP prices only where the price level is <= ${options.maxLevel} of the US; share of the US-equivalent price ${floor}..${ceiling}; ${options.includeHighIncome ? 'high-income economies included' : 'high-income economies at par'}; CN at par on Apple, not on Play`);
|
||||
|
||||
const rows: PppRow[] = [];
|
||||
const kept: PppRow[] = [];
|
||||
const current: PriceRow[] = [];
|
||||
const skipped: { product: string; store: string; region: string; why: string }[] = [];
|
||||
const offlineApple = new Set<string>();
|
||||
|
|
@ -138,7 +190,11 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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);
|
||||
const usaIds = new Map<number, string>();
|
||||
regions = await applePoints(c, id, want, usaIds);
|
||||
const usaId = [...usaIds.entries()].find(([price]) => Math.round(price * 100) === Math.round(usd * 100))?.[1];
|
||||
if (usaId) for (const [region, par] of await appleEqualized(c, usaId)) { const r = regions.get(region); if (r) r.par = par; }
|
||||
else warn(TAG, `${p.id}: USD ${usd} is not an App Store price point; offline par prices for Apple`);
|
||||
}
|
||||
}
|
||||
if (s === 'play' && play) {
|
||||
|
|
@ -146,11 +202,15 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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));
|
||||
// convertRegionPrices: the US price in every region, tax included, at today's rates: Play's par price
|
||||
regions = new Map(Object.values(converted.get(usd)!.convertedRegionPrices ?? {}).map(r => [r.regionCode, { currency: r.price.currencyCode, par: Number(r.price.units ?? 0) + (r.price.nanos ?? 0) / 1e9 }] 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 });
|
||||
const cur = new Map(current.filter(r => r.product === p.id && r.store === s && r.kind !== 'auto').map(r => [r.region, { currency: r.currency, price: r.price }] as const));
|
||||
for (const r of current) if (r.product === p.id && r.store === s && r.kind === 'auto' && !cur.has(r.region)) cur.set(r.region, { currency: r.currency, price: r.price });
|
||||
const plan = planRows({ product: p.id, base: usd, store: s, dataset: ds, options, regions, current: cur });
|
||||
rows.push(...plan.rows);
|
||||
kept.push(...plan.kept);
|
||||
skipped.push(...plan.skipped.map(k => ({ product: p.id, store: s, ...k })));
|
||||
}
|
||||
}
|
||||
|
|
@ -159,14 +219,17 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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); }
|
||||
const top = topMarkets(root, ds);
|
||||
const drops = topMarketDrops(rows, top.markets);
|
||||
for (const d of drops) warn(TAG, `top market ${d.region} (${top.from === 'sales-reports' ? 'from your sales reports' : 'static top-10 list'}): ${d.product} ${d.store} ${d.currency} ${d.from} -> ${d.to} (-${Math.round(d.drop * 100)} %, more than ${TOP_DROP * 100} %); keep it out of the sheet unless you mean it`);
|
||||
if (a.has('json')) {
|
||||
printJson({ source: sourceInfo, base, floor, ceiling, online, rows: rows.map(({ territory: _t, ...r }) => r), skipped, diff, out, written: !dry });
|
||||
printJson({ source: sourceInfo, base, floor, ceiling, options, online, rows: rows.map(({ territory: _t, ...r }) => r), kept: kept.map(({ territory: _t, ...r }) => r), skipped, diff, topMarkets: { ...top, drops }, 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 ? ', ...' : ''})` : ''}`);
|
||||
const cut = rows.filter(r => r.share < 1).length, par = kept.length;
|
||||
log(TAG, `${rows.length} rows (${cut} below the US-equivalent price; ${par} region(s) keep the store's own price: high-income, price level above ${options.maxLevel}, CN, or only-decreases), ${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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue