storebound/cli/lib/ppp.ts

342 lines
20 KiB
TypeScript

// Purchasing power parity (PPP) price proposals for `storebound prices ppp`, as pure parts: datasets, the math, the
// clamp, the snapping rules and the rows for the price sheet (the same CSV `storebound prices import` reads).
//
// The math, per product, store and region:
// factor = PPP conversion factor (local currency units per international dollar; the international dollar has the
// US dollar's purchasing power in the US, so the US factor is 1)
// fx = market exchange rate (local currency units per US dollar, period average)
// usd = base x factor / fx (the USD value of the PPP price at the market rate)
// usd = clamp(usd, floor x base, ceiling x base) (floor 0.3, ceiling 1.2 unless the config or flags say)
// target = usd x fx(store currency) (the store may price a region in USD or EUR instead of the local currency)
// price = snap(target)
// Snapping (storebound's own rules, not a store rule):
// - Apple, online: the nearest real price point of that product in that territory (GET pricePoints). Apple only
// accepts its own price points; offline values below are a proposal and `prices import` checks them.
// - Play (and Apple offline): a clean local ending. Whole-number currencies (ISO 4217 zero-decimal ones such as
// JPY, KRW, VND, CLP, plus currencies stores show without decimals such as IDR, HUF, TWD, COP, KZT) and any
// value >= 1000: two significant digits (480, 1200, 46000; under 100 the nearest whole number). Two-decimal
// currencies: under 1 -> x.x9 (0.29), under 100 -> x.99 (1.99, 49.99), under 1000 -> whole numbers ending in 9
// (129, 249). The US row is always the base itself.
// - Play takes any price in the region's currency within its limits; per-currency limits are not checked here
// (not verified against a Play doc; Play rejects an out-of-range price on import).
// Currency per region: CURRENCY (ISO 3166 alpha-2 -> ISO 4217) for the App Store territories. Stores price some
// regions in USD or EUR instead; online the stores' answer wins (Apple: the price point's territory currency; Play:
// convertRegionPrices). Offline, Apple uses APPLE_CURRENCY_HINT (from Apple's "Financial Report regions and
// currencies" page: regions with their own report currency use it, the Euro-Zone group EUR, the USD groups USD) and
// Play the local currency; `prices import` rejects a currency the store does not use.
// Sources (checked October 2026):
// World Bank indicators PA.NUS.PPP and PA.NUS.FCRF (CC BY 4.0): https://data.worldbank.org/indicator/PA.NUS.PPP,
// https://data.worldbank.org/indicator/PA.NUS.FCRF, API https://api.worldbank.org/v2/country/all/indicator/PA.NUS.PPP?format=json
// The Economist Big Mac index data (data CC BY 4.0, code MIT; not bundled, see tools/update-ppp.ts):
// https://github.com/TheEconomist/big-mac-data
// Apple price points: https://developer.apple.com/documentation/appstoreconnectapi/get-v2-inapppurchases-_id_-pricepoints
// (filter[territory], include=territory, limit <= 8000; customerPrice; territory.currency)
// Apple report currencies: https://developer.apple.com/help/app-store-connect/reference/reporting/financial-report-regions-and-currencies
// Play convertRegionPrices (input tax exclusive, converted regional prices tax inclusive):
// https://developers.google.com/android-publisher/api-ref/rest/v3/monetization/convertRegionPrices
import { parseCsvRecords } from './csv';
import { ALPHA3, nearestPrices } from './money';
import { type PriceRow, pricesCsv } from './prices';
export interface PppEntry {
/** Local currency units per international dollar (PPP conversion factor). */
ppp: number;
/** Market rate, local currency units per US dollar. */
fx?: number;
/** Year of the PPP value (fxYear when the rate is from another year). */
year?: number;
fxYear?: number;
/** ISO 4217 currency the two values are in. */
currency?: string;
}
export interface PppDataset {
name: 'worldbank' | 'bigmac' | 'file';
source: string;
url: string;
urls?: string[];
license: string;
attribution?: string;
/** YYYY-MM-DD the data was downloaded. */
retrieved: string;
/** Latest data year in the file. */
year?: number;
notes?: string[];
/** Euro rate (EUR per USD) for regions a store prices in EUR. */
eur?: { fx: number; year?: number };
/** ISO 3166 alpha-2 -> values. */
countries: Record<string, PppEntry>;
}
const C = `AE AED AF AFN AG XCD AI XCD AL ALL AM AMD AO AOA AR ARS AT EUR AU AUD AZ AZN BA BAM BB BBD BE EUR BF XOF BG EUR
BH BHD BJ XOF BM BMD BN BND BO BOB BR BRL BS BSD BT BTN BW BWP BY BYN BZ BZD CA CAD CD CDF CG XAF CH CHF CI XOF CL CLP CM XAF
CN CNY CO COP CR CRC CV CVE CY EUR CZ CZK DE EUR DK DKK DM XCD DO DOP DZ DZD EC USD EE EUR EG EGP ES EUR FI EUR FJ FJD FM USD
FR EUR GA XAF GB GBP GD XCD GE GEL GH GHS GM GMD GR EUR GT GTQ GW XOF GY GYD HK HKD HN HNL HR EUR HU HUF ID IDR IE EUR IL ILS
IN INR IQ IQD IS ISK IT EUR JM JMD JO JOD JP JPY KE KES KG KGS KH KHR KN XCD KR KRW KW KWD KY KYD KZ KZT LA LAK LB LBP LC XCD
LK LKR LR LRD LT EUR LU EUR LV EUR LY LYD MA MAD MD MDL ME EUR MG MGA MK MKD ML XOF MM MMK MN MNT MO MOP MR MRU MS XCD MT EUR
MU MUR MV MVR MW MWK MX MXN MY MYR MZ MZN NA NAD NE XOF NG NGN NI NIO NL EUR NO NOK NP NPR NR AUD NZ NZD OM OMR PA PAB PE PEN
PG PGK PH PHP PK PKR PL PLN PT EUR PW USD PY PYG QA QAR RO RON RS RSD RU RUB RW RWF SA SAR SB SBD SC SCR SE SEK SG SGD SI EUR
SK EUR SL SLE SN XOF SR SRD ST STN SV USD SZ SZL TC USD TD XAF TH THB TJ TJS TM TMT TN TND TO TOP TR TRY TT TTD TW TWD TZ TZS
UA UAH UG UGX US USD UY UYU UZ UZS VC XCD VE VES VG USD VN VND VU VUV XK EUR YE YER ZA ZAR ZM ZMW ZW ZWG`.split(/\s+/);
/** ISO 3166 alpha-2 -> ISO 4217 local currency, for every App Store territory (ALPHA3 keys). Bulgaria: EUR since 2026-01-01. */
export const CURRENCY: Record<string, string> = Object.fromEntries(Array.from({ length: C.length / 2 }, (_, i) => [C[2 * i]!, C[2 * i + 1]!]));
// Apple "Financial Report regions and currencies": regions with their own report currency.
const APPLE_OWN = new Set('US AU BR BG CA CL CN CO CZ HR DK EG HK HU IN ID IL JP KZ KR MY MX NZ NG NO PK PE PH PL QA RO RU SA SG ZA SE CH TW TH TR AE GB TZ VN'.split(' '));
// The Euro-Zone report group (countries that also have their own report region above keep that one).
const APPLE_EUROZONE = new Set('AT BE BA BG CY CZ EE FI FR DE GR HU IE IT LV LT LU MT ME NL PL PT RO RS SK SI ES'.split(' '));
/** Offline guess of the currency the App Store prices a territory in (online: the price point's territory currency). */
export function appleCurrencyHint(region: string): string {
if (APPLE_OWN.has(region)) return CURRENCY[region] ?? 'USD';
if (APPLE_EUROZONE.has(region)) return 'EUR';
return 'USD';
}
/** Currencies priced in whole numbers (ISO 4217 zero-decimal, plus ones stores show without decimals). */
export const WHOLE = new Set('BIF CLP DJF GNF ISK JPY KMF KRW PYG RWF UGX VND VUV XAF XOF XPF HUF IDR TWD COP KZT TZS UZS MMK LAK KHR MNT IQD LBP AMD'.split(' '));
const r2 = (n: number) => Math.round(n * 100) / 100;
/** A clean price near `v` in `currency` (see the header). */
export function snapEnding(v: number, currency: string): number {
if (!(v > 0)) throw new Error(`bad price ${v}`);
if (WHOLE.has(currency) || v >= 1000) {
if (v < 100) return Math.max(1, Math.round(v));
const step = 10 ** (Math.floor(Math.log10(v)) - 1);
return Math.round(v / step) * step;
}
if (v < 1) return r2(Math.max(0.09, Math.round(v * 10) / 10 - 0.01));
if (v < 100) return r2(Math.max(0.99, Math.round(v + 0.01) - 0.01));
return Math.round((v + 1) / 10) * 10 - 1;
}
/** Nearest Apple price point (customer prices of one territory). */
export function snapApple(points: number[], v: number): number | null {
return points.length ? nearestPrices(points, v, 1)[0]! : null;
}
/** Local currency units per US dollar of `currency`, from the dataset (USD = 1, EUR = the euro rate). */
export function fxOf(ds: PppDataset, currency: string): number | null {
if (currency === 'USD') return 1;
if (currency === 'EUR' && ds.eur?.fx) return ds.eur.fx;
for (const [region, e] of Object.entries(ds.countries)) if ((e.currency ?? CURRENCY[region]) === currency && e.fx) return e.fx;
return null;
}
export interface Proposal { usdRaw: number; usd: number; clamped: 'floor' | 'ceiling' | null }
/** PPP-adjusted USD value of `base`, clamped to floor..ceiling of the base. */
export function propose(base: number, factor: number, fx: number, floor: number, ceiling: number): Proposal {
if (!(factor > 0) || !(fx > 0)) throw new Error('PPP factor and exchange rate must be positive');
const usdRaw = (base * factor) / fx;
const lo = base * floor, hi = base * ceiling;
return usdRaw < lo ? { usdRaw, usd: lo, clamped: 'floor' } : usdRaw > hi ? { usdRaw, usd: hi, clamped: 'ceiling' } : { usdRaw, usd: usdRaw, clamped: null };
}
export interface PppRow extends PriceRow {
factor: number;
fx: number;
/** USD value of the final (snapped) price at the market rate. */
usdEquivalent: number;
clamped: 'floor' | 'ceiling' | null;
/** Unsnapped price in the store currency. */
snappedFrom: number;
/** apple-point: a real Apple price point; ending: storebound's clean-ending rule. */
snap: 'apple-point' | 'ending' | 'base';
}
/** What one store says about one region (online), or nothing (offline). */
export interface StoreRegion { currency: string; points?: number[] }
export interface PlanInput {
product: string;
base: number;
store: 'apple' | 'play';
dataset: PppDataset;
floor: number;
ceiling: number;
/** Online: the store's regions (Apple territories with price points, Play convertRegionPrices). Offline: undefined. */
regions?: Map<string, StoreRegion>;
}
/** Rows of one product on one store, plus the regions skipped and why. */
export function planRows(i: PlanInput): { rows: PppRow[]; skipped: { region: string; why: string }[] } {
const rows: PppRow[] = [];
const skipped: { region: string; why: string }[] = [];
const regions = i.regions ? [...i.regions.keys()] : Object.keys(ALPHA3);
for (const region of regions.sort()) {
const known = i.regions?.get(region);
const currency = known?.currency || (i.store === 'apple' ? appleCurrencyHint(region) : i.dataset.countries[region]?.currency ?? CURRENCY[region]);
if (!currency) { skipped.push({ region, why: 'no currency known' }); continue; }
const storeFx = fxOf(i.dataset, currency);
if (region === 'US' && currency === 'USD') {
const price = known?.points?.length ? snapApple(known.points, i.base)! : i.base;
rows.push({ product: i.product, store: i.store, region, ...(i.store === 'apple' ? { territory: 'USA' } : {}), currency, price, factor: 1, fx: 1, usdEquivalent: price, clamped: null, snappedFrom: i.base, snap: known?.points?.length ? 'apple-point' : 'base' });
continue;
}
const e = i.dataset.countries[region];
if (!e) { skipped.push({ region, why: `no PPP factor in ${i.dataset.name}` }); continue; }
if (!e.fx) { skipped.push({ region, why: 'no exchange rate' }); continue; }
if (!storeFx) { skipped.push({ region, why: `no exchange rate for ${currency}` }); continue; }
const p = propose(i.base, e.ppp, e.fx, i.floor, i.ceiling);
const target = p.usd * storeFx;
const point = known?.points ? snapApple(known.points, target) : null;
if (known?.points && point === null) { skipped.push({ region, why: 'no Apple price points' }); continue; }
const price = point ?? snapEnding(target, currency);
rows.push({ product: i.product, store: i.store, region, ...(i.store === 'apple' ? { territory: ALPHA3[region] } : {}), currency, price,
factor: e.ppp, fx: e.fx, usdEquivalent: r2(price / storeFx), clamped: p.clamped, snappedFrom: r2(target), snap: point !== null ? 'apple-point' : 'ending' });
}
return { rows, skipped };
}
/** The price sheet (`prices import` format) of the proposal. */
export const pppCsv = (rows: PppRow[]): string => pricesCsv(rows.map(r => ({ product: r.product, store: r.store, region: r.region, currency: r.currency, price: r.price })));
// ---- datasets ----
/** --source file: CSV region, factor, currency, fx (fx optional: then the bundled rate of that region when the currency matches). */
export function parseFactorCsv(text: string, fallback?: PppDataset, path = 'file'): PppDataset {
const countries: Record<string, PppEntry> = {};
const errors: string[] = [];
for (const [i, r] of parseCsvRecords(text).entries()) {
const at = `row ${i + 2}`;
const region = (r.region ?? '').trim().toUpperCase();
const currency = (r.currency ?? '').trim().toUpperCase();
const factor = Number((r.factor ?? '').replace(',', '.'));
const fxText = (r.fx ?? '').trim();
if (!/^[A-Z]{2}$/.test(region)) { errors.push(`${at}: region "${r.region}" must be ISO alpha-2`); continue; }
if (!/^[A-Z]{3}$/.test(currency)) errors.push(`${at}: currency "${r.currency}" must be ISO 4217`);
if (!(factor > 0)) errors.push(`${at}: factor must be a positive number`);
let fx = fxText ? Number(fxText.replace(',', '.')) : undefined;
if (fxText && !(fx! > 0)) errors.push(`${at}: fx must be a positive number`);
if (fx === undefined) {
const fb = fallback?.countries[region];
fx = currency === 'USD' ? 1 : fb?.fx && (fb.currency ?? CURRENCY[region]) === currency ? fb.fx : undefined;
}
if (countries[region]) errors.push(`${at}: ${region} listed twice`);
countries[region] = { ppp: factor, ...(fx ? { fx } : {}), currency };
}
if (errors.length) throw new Error(`PPP factor CSV: ${errors.join('; ')}`);
return { name: 'file', source: `factor CSV ${path}`, url: path, license: 'owner supplied', retrieved: '', eur: fallback?.eur, countries };
}
/** Problems with a dataset's shape (used by tests and the update tool). */
export function datasetProblems(ds: PppDataset): string[] {
const e: string[] = [];
for (const k of ['source', 'url', 'license', 'retrieved'] as const) if (!ds[k]) e.push(`${k} missing`);
if (!Object.keys(ds.countries ?? {}).length) e.push('no countries');
for (const [r, v] of Object.entries(ds.countries ?? {})) {
if (!/^[A-Z]{2}$/.test(r)) e.push(`${r}: not alpha-2`);
if (!(v.ppp > 0)) e.push(`${r}: ppp`);
if (v.fx !== undefined && !(v.fx > 0)) e.push(`${r}: fx`);
}
return e;
}
/** Dataset file text: header fields pretty, one line per country (small diffs on update). */
export function datasetJson(ds: PppDataset): string {
const { countries, ...head } = ds;
const top = JSON.stringify(head, null, 2).replace(/\n}$/, '');
const lines = Object.keys(countries).sort().map(k => ` ${JSON.stringify(k)}: ${JSON.stringify(countries[k])}`);
return `${top},\n "countries": {\n${lines.join(',\n')}\n }\n}\n`;
}
export const WB = {
ppp: 'PA.NUS.PPP',
fx: 'PA.NUS.FCRF',
api: (ind: string, from = 2018, to = new Date().getFullYear()) => `https://api.worldbank.org/v2/country/all/indicator/${ind}?format=json&per_page=20000&date=${from}:${to}`,
countries: 'https://api.worldbank.org/v2/country?format=json&per_page=400',
};
interface WbObs { country?: { id?: string }; countryiso3code?: string; date?: string; value?: number | null }
type WbResp = [unknown, WbObs[] | null];
const latest = (resp: WbResp, keep: (iso2: string) => boolean): Map<string, { v: number; y: number }> => {
const m = new Map<string, { v: number; y: number }>();
for (const o of resp[1] ?? []) {
const id = o.country?.id ?? '', y = Number(o.date);
if (o.value === null || o.value === undefined || !keep(id)) continue;
const cur = m.get(id);
if (!cur || cur.y < y) m.set(id, { v: o.value, y });
}
return m;
};
/**
* World Bank API responses -> dataset. Aggregates (regions, income groups) are dropped; only App Store territories
* (CURRENCY keys) are kept. Every EUR region uses the euro area rate (XC), since per-country FCRF is missing or still
* in the old currency for new members (Bulgaria 2025: PPP already in EUR, FCRF in BGN).
*/
export function worldBankDataset(pppResp: WbResp, fxResp: WbResp, countriesResp: [unknown, { id: string; iso2Code: string; region?: { value?: string } }[]], retrieved: string): PppDataset {
const real = new Set((countriesResp[1] ?? []).filter(c => (c.region?.value ?? '').trim() !== 'Aggregates').map(c => c.iso2Code));
const ppp = latest(pppResp, id => real.has(id) && !!CURRENCY[id]);
const fx = latest(fxResp, id => (real.has(id) && !!CURRENCY[id]) || id === 'XC');
const eur = fx.get('XC');
const countries: Record<string, PppEntry> = {};
const dropped: string[] = [];
for (const [id, p] of [...ppp.entries()].sort(([a], [b]) => a.localeCompare(b))) {
const currency = CURRENCY[id]!;
const f = currency === 'EUR' ? eur : fx.get(id);
if (!f) { countries[id] = { ppp: sig8(p.v), year: p.y, currency }; continue; }
const ratio = p.v / f.v;
// a price level under 5 % or over 300 % of the US is a unit mismatch (redenomination), not a price level
if (ratio < 0.05 || ratio > 3) { dropped.push(`${id} (${ratio.toFixed(3)})`); continue; }
countries[id] = { ppp: sig8(p.v), fx: sig8(f.v), year: p.y, ...(f.y !== p.y ? { fxYear: f.y } : {}), currency };
}
return {
name: 'worldbank',
source: 'World Bank, World Development Indicators: PPP conversion factor, GDP (LCU per international $) [PA.NUS.PPP] (International Comparison Program) and Official exchange rate (LCU per US$, period average) [PA.NUS.FCRF]',
url: 'https://data.worldbank.org/indicator/PA.NUS.PPP',
urls: ['https://data.worldbank.org/indicator/PA.NUS.PPP', 'https://data.worldbank.org/indicator/PA.NUS.FCRF', WB.api(WB.ppp), WB.api(WB.fx)],
license: 'CC BY 4.0',
attribution: 'The World Bank: World Development Indicators, PA.NUS.PPP and PA.NUS.FCRF (CC BY 4.0, https://creativecommons.org/licenses/by/4.0/). Values used unchanged except the euro rate note below.',
retrieved,
year: Math.max(...[...ppp.values()].map(v => v.y)),
notes: [
'Latest available year per country (2018 onwards); fxYear is set when the exchange rate is from another year than the PPP factor.',
`Every EUR region (euro area members, Montenegro, Kosovo) uses the euro area rate (XC, ${eur ? `${sig8(eur.v)} EUR per USD, ${eur.y}` : 'missing'}): per-country FCRF is missing for most members and still in BGN for Bulgaria, whose PPP factor is already in EUR.`,
'Only App Store territories are kept; aggregates (regions, income groups) are excluded.',
...(dropped.length ? [`Dropped as a unit mismatch (PPP / fx outside 0.05..3): ${dropped.join(', ')}.`] : []),
'Missing countries (no recent World Bank data, e.g. Taiwan, Venezuela) get no proposal; set them by hand or with --source file.',
],
...(eur ? { eur: { fx: sig8(eur.v), year: eur.y } } : {}),
countries,
};
}
const sig8 = (n: number) => Number(n.toPrecision(8));
/** The Economist big-mac-raw-index.csv -> dataset (latest date). Implied PPP = local price / US price; fx = dollar_ex. */
export function bigMacDataset(csv: string, retrieved: string): PppDataset {
const recs = parseCsvRecords(csv);
const date = recs.map(r => r.date ?? '').sort().at(-1) ?? '';
const rows = recs.filter(r => r.date === date);
const us = rows.find(r => r.iso_a3 === 'USA');
if (!us) throw new Error(`Big Mac data: no USA row on ${date}`);
const usPrice = Number(us.local_price);
const a2 = Object.fromEntries(Object.entries(ALPHA3).map(([k, v]) => [v, k] as const));
const countries: Record<string, PppEntry> = {};
const year = Number(date.slice(0, 4));
let eur: PppEntry | undefined;
for (const r of rows) {
const e: PppEntry = { ppp: sig8(Number(r.local_price) / usPrice), fx: sig8(Number(r.dollar_ex)), year, currency: r.currency_code };
if (!(e.ppp > 0) || !(e.fx! > 0)) continue;
if (r.iso_a3 === 'EUZ') { eur = e; continue; }
const id = a2[r.iso_a3 ?? ''];
if (id) countries[id] = e;
}
if (eur) for (const [id, c] of Object.entries(CURRENCY)) if (c === 'EUR' && !countries[id]) countries[id] = { ...eur };
return {
name: 'bigmac',
source: `The Economist, The Big Mac index (big-mac-raw-index.csv, ${date})`,
url: 'https://github.com/TheEconomist/big-mac-data',
license: 'CC BY 4.0 (data), MIT (code); third-party data not covered',
attribution: 'The Economist, big-mac-data (https://github.com/TheEconomist/big-mac-data). Implied PPP = local Big Mac price / US price.',
retrieved,
year,
notes: ['Euro area members without their own row use the euro area (EUZ) row.', 'Downloaded on this machine; not redistributed by storebound.'],
...(eur ? { eur: { fx: eur.fx!, year } } : {}),
countries,
};
}