508 lines
31 KiB
TypeScript
508 lines
31 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 (like with like: store prices include VAT and follow today's exchange
|
|
// rate, the US price excludes sales tax, the ICP price level compares net prices):
|
|
// factor = PPP conversion factor (local currency units per international dollar; the US factor is 1)
|
|
// fx = market exchange rate of the same year (local currency units per US dollar, period average)
|
|
// level = factor / fx (the region's price level against the US, net of tax)
|
|
// par = the store's own conversion of the US price into that region: Play convertRegionPrices, Apple the
|
|
// equalizations of the USA price point (both: tax included, today's rate). Offline: base x fx x (1 + VAT)
|
|
// rounded up to a clean price (VAT: standard rates below; the dataset's rate stands in for today's)
|
|
// share = 1 (par) for the US, World Bank high-income economies (unless includeHighIncome), mainland China and
|
|
// price levels above maxLevel (0.5); else clamp(level, minRatio 0.3, maxRatio 1)
|
|
// price = snap(par x share); with onlyDecreases (default) never above the store's current price
|
|
// Only regions whose price changes are written to the sheet (rows); the others are reported as kept, so `prices
|
|
// import` leaves the store's own price there. The old method (STAR DASH, 2026-10-14) took base x level x fx: a net
|
|
// price set against VAT-inclusive store prices and every region cut, so Germany got EUR 3.59 for a USD 4.99 product
|
|
// whose store price is EUR 5.99; now Germany keeps EUR 5.99 (tests/ppp.test.ts).
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* The clean price at or above `v` (same endings as snapEnding): the offline par price, so the US-equivalent price of
|
|
* a region is never rounded below the exchange-rate value (stores round their own conversions up the same way).
|
|
*/
|
|
export function snapUp(v: number, currency: string): number {
|
|
if (!(v > 0)) throw new Error(`bad price ${v}`);
|
|
const eps = 1e-9;
|
|
if (WHOLE.has(currency) || v >= 1000) {
|
|
if (v < 100) return Math.max(1, Math.ceil(v - eps));
|
|
const step = 10 ** (Math.floor(Math.log10(v)) - 1);
|
|
return Math.ceil(v / step - eps) * step;
|
|
}
|
|
if (v < 1) return r2(Math.max(0.09, Math.ceil((v + 0.01) * 10 - eps) / 10 - 0.01));
|
|
if (v < 100) return r2(Math.max(0.99, Math.ceil(v + 0.01 - eps) - 0.01));
|
|
return Math.ceil((v + 1) / 10 - eps) * 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;
|
|
}
|
|
|
|
// ---- the method (see the header and docs/ppp-pricing.md) ----
|
|
|
|
/**
|
|
* World Bank high-income economies (FY2026 classification, July 2025; GNI per capita, Atlas method), as ISO alpha-2,
|
|
* App Store territories only. They keep the store's US-equivalent price unless --include-high-income.
|
|
* Source: https://datahelpdesk.worldbank.org/knowledgebase/articles/906519-world-bank-country-and-lending-groups
|
|
*/
|
|
export const HIGH_INCOME = new Set(('AD AE AG AT AU AW BB BE BG BH BM BN BS CA CH CL CR CY CZ DE DK EE ES FI FR GB GR HK HR HU IE IL IS IT JP KN KR ' +
|
|
'KW KY LI LT LU LV MC MO MT NL NO NR NZ OM PA PL PT PW QA RO RU SA SC SE SG SI SK SM TC TT TW US UY VG').split(' '));
|
|
|
|
/**
|
|
* Standard VAT / GST rates (share) the stores' prices include, for the offline par price only (online the store's own
|
|
* converted price is the par price, VAT and current exchange rate included). Regions not listed: 0 (the offline par
|
|
* price is then the plain exchange-rate value; online runs do not have that gap). US and CA store prices exclude sales
|
|
* tax, so they are 0. Checked October 2026 against the national tax authorities' published standard rates.
|
|
*/
|
|
export const VAT: Record<string, number> = {
|
|
AT: 0.2, BE: 0.21, BG: 0.2, HR: 0.25, CY: 0.19, CZ: 0.21, DK: 0.25, EE: 0.24, FI: 0.255, FR: 0.2, DE: 0.19, GR: 0.24, HU: 0.27,
|
|
IE: 0.23, IT: 0.22, LV: 0.21, LT: 0.21, LU: 0.17, MT: 0.18, NL: 0.21, PL: 0.23, PT: 0.23, RO: 0.21, SK: 0.23, SI: 0.22, ES: 0.21,
|
|
SE: 0.25, GB: 0.2, NO: 0.25, CH: 0.081, IS: 0.24, JP: 0.1, KR: 0.1, AU: 0.1, NZ: 0.15, IN: 0.18, TR: 0.2, ZA: 0.15, MX: 0.16,
|
|
CL: 0.19, CO: 0.19, SA: 0.15, AE: 0.05, SG: 0.09, TW: 0.05, TH: 0.07, PH: 0.12, VN: 0.1, EG: 0.14, IL: 0.18, UA: 0.2, RU: 0.2,
|
|
};
|
|
|
|
/**
|
|
* The top-10 App Store / Google Play revenue markets for in-app purchases (a static fallback when no sales report is
|
|
* in .storebound/reports/): a drop of more than 20 % in one of them is named before anything is imported.
|
|
*/
|
|
export const TOP_MARKETS = ['US', 'JP', 'CN', 'KR', 'GB', 'DE', 'FR', 'CA', 'AU', 'TW'];
|
|
/** A drop larger than this share in a top market is warned about. */
|
|
export const TOP_DROP = 0.2;
|
|
|
|
export interface PppOptions {
|
|
/** Lowest price as a share of the par price (default 0.3; --min-ratio, old name --floor). */
|
|
minRatio: number;
|
|
/** Highest share of the par price (default 1: never above the US-equivalent price; --max-ratio, old name --ceiling). */
|
|
maxRatio: number;
|
|
/** Only economies whose price level (PPP factor / exchange rate) is at or below this share of the US get a PPP price (default 0.5). */
|
|
maxLevel: number;
|
|
/** Never propose a price above the store's current one (default true; --only-decreases=false turns it off). */
|
|
onlyDecreases: boolean;
|
|
/** World Bank high-income economies get PPP prices too (default false: they keep par). */
|
|
includeHighIncome: boolean;
|
|
}
|
|
|
|
export const PPP_DEFAULTS: PppOptions = { minRatio: 0.3, maxRatio: 1, maxLevel: 0.5, onlyDecreases: true, includeHighIncome: false };
|
|
|
|
/** Why a row has the price it has. */
|
|
export type PppReason = 'base' | 'ppp' | 'min-ratio' | 'max-ratio' | 'high-income' | 'above-level' | 'china' | 'only-decreases' | 'no-ppp-data';
|
|
|
|
/**
|
|
* The share of the par price a region gets. level = PPP factor / market exchange rate of the same year (the region's
|
|
* price level against the US, both net of tax: ICP compares prices of the same basket). The par price is the store's
|
|
* own conversion of the US price (tax included, current rate), so par x share compares like with like.
|
|
*/
|
|
export function shareOf(region: string, level: number | null, o: PppOptions): { share: number; reason: PppReason } {
|
|
if (region === 'US') return { share: 1, reason: 'base' };
|
|
if (region === 'CN') return { share: 1, reason: 'china' };
|
|
if (HIGH_INCOME.has(region) && !o.includeHighIncome) return { share: 1, reason: 'high-income' };
|
|
if (level === null) return { share: 1, reason: 'no-ppp-data' };
|
|
if (level > o.maxLevel) return { share: Math.min(1, o.maxRatio), reason: 'above-level' };
|
|
if (level < o.minRatio) return { share: o.minRatio, reason: 'min-ratio' };
|
|
if (level > o.maxRatio) return { share: o.maxRatio, reason: 'max-ratio' };
|
|
return { share: level, reason: 'ppp' };
|
|
}
|
|
|
|
/** Offline par price: the US price at the dataset's exchange rate plus the region's VAT (stores show prices with VAT). */
|
|
export function offlinePar(base: number, region: string, currency: string, ds: PppDataset): number | null {
|
|
const fx = fxOf(ds, currency);
|
|
return fx ? base * fx * (1 + (VAT[region] ?? 0)) : null;
|
|
}
|
|
|
|
export interface PppRow extends PriceRow {
|
|
factor: number;
|
|
fx: number;
|
|
/** Price level against the US (factor / fx), null without PPP data. */
|
|
level: number | null;
|
|
/** The store's (online) or computed (offline) US-equivalent price in the row's currency. */
|
|
par: number;
|
|
parFrom: 'store' | 'offline';
|
|
/** Share of par applied. */
|
|
share: number;
|
|
reason: PppReason;
|
|
/** The store's current price in that region (same currency), when known. */
|
|
current: number | null;
|
|
/** USD value of the final (snapped) price at the market rate. */
|
|
usdEquivalent: number;
|
|
/** Unsnapped price in the store currency. */
|
|
snappedFrom: number;
|
|
/** apple-point: a real Apple price point; ending: storebound's clean-ending rule; par: the store's own price kept. */
|
|
snap: 'apple-point' | 'ending' | 'base' | 'par' | 'current';
|
|
}
|
|
|
|
/** What one store says about one region (online), or nothing (offline). */
|
|
export interface StoreRegion {
|
|
currency: string;
|
|
points?: number[];
|
|
/** The store's own conversion of the US price (Play convertRegionPrices, Apple equalizations): the par price. */
|
|
par?: number;
|
|
}
|
|
|
|
export interface PlanInput {
|
|
product: string;
|
|
base: number;
|
|
store: 'apple' | 'play';
|
|
dataset: PppDataset;
|
|
options: PppOptions;
|
|
/** Online: the store's regions (Apple territories with price points and equalized prices, Play convertRegionPrices). Offline: undefined. */
|
|
regions?: Map<string, StoreRegion>;
|
|
/** Current store prices per region (same store), for --only-decreases and the top-market check. */
|
|
current?: Map<string, { currency: string; price: number }>;
|
|
}
|
|
|
|
/**
|
|
* One product on one store: `rows` are the price sheet (the US base and every region whose price changes), `kept` the
|
|
* regions that keep the US-equivalent price or their current one (high-income, price level above maxLevel, CN,
|
|
* only-decreases): they are left out of the sheet, so `prices import` leaves the store's own price there. Pure.
|
|
*/
|
|
export function planRows(i: PlanInput): { rows: PppRow[]; kept: PppRow[]; skipped: { region: string; why: string }[] } {
|
|
const rows: PppRow[] = [];
|
|
const kept: PppRow[] = [];
|
|
const skipped: { region: string; why: string }[] = [];
|
|
const regions = i.regions ? [...i.regions.keys()] : Object.keys(ALPHA3);
|
|
const o = i.options;
|
|
for (const region of regions.sort()) {
|
|
// Google Play is not offered in mainland China: no Play prices there
|
|
if (region === 'CN' && i.store === 'play') { skipped.push({ region, why: 'Google Play is not available in mainland China' }); continue; }
|
|
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);
|
|
const cur = i.current?.get(region);
|
|
const current = cur && cur.currency === currency ? cur.price : null;
|
|
const apple = i.store === 'apple' ? { territory: ALPHA3[region] } : {};
|
|
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, ...apple, currency, price, factor: 1, fx: 1, level: 1, par: i.base, parFrom: 'store', share: 1, reason: 'base', current,
|
|
usdEquivalent: price, snappedFrom: i.base, snap: known?.points?.length ? 'apple-point' : 'base' });
|
|
continue;
|
|
}
|
|
const e = i.dataset.countries[region];
|
|
const level = e?.fx ? e.ppp / e.fx : null;
|
|
const atPar = region === 'CN' || (HIGH_INCOME.has(region) && !o.includeHighIncome);
|
|
if (!atPar && !e) { skipped.push({ region, why: `no PPP factor in ${i.dataset.name}` }); continue; }
|
|
if (!atPar && !e!.fx) { skipped.push({ region, why: 'no exchange rate' }); continue; }
|
|
const par = known?.par ?? offlinePar(i.base, region, currency, i.dataset);
|
|
if (par === null || !storeFx) { skipped.push({ region, why: `no exchange rate for ${currency}` }); continue; }
|
|
let { share, reason } = shareOf(region, level, o);
|
|
let target = par * share;
|
|
let snap: PppRow['snap'];
|
|
let price: number;
|
|
if (share === 1 && known?.par !== undefined) { price = known.par; snap = 'par'; }
|
|
else if (share === 1 && !known?.points) { price = snapUp(par, currency); snap = 'par'; }
|
|
else {
|
|
// par (share 1): the lowest price point at or above it; a reduced price: the nearest point
|
|
const up = share === 1 && known?.points ? [...known.points].filter(p => p >= target - 1e-9).sort((a, b) => a - b)[0] : undefined;
|
|
const point = known?.points ? up ?? snapApple(known.points, target) : null;
|
|
if (known?.points && point === null) { skipped.push({ region, why: 'no Apple price points' }); continue; }
|
|
price = point ?? snapEnding(target, currency);
|
|
snap = point !== null ? 'apple-point' : 'ending';
|
|
// snapping must not push a reduced price above par
|
|
if (share < 1 && price > par) {
|
|
const lower = known?.points ? [...known.points].filter(p => p <= par).sort((a, b) => b - a)[0] : undefined;
|
|
if (lower !== undefined) price = lower;
|
|
}
|
|
}
|
|
if (o.onlyDecreases && current !== null && price > current) { price = current; target = current; reason = 'only-decreases'; snap = 'current'; share = current / par; }
|
|
const row: PppRow = { product: i.product, store: i.store, region, ...apple, currency, price, factor: e?.ppp ?? 0, fx: e?.fx ?? 0, level: level === null ? null : r4(level),
|
|
par: r2(par), parFrom: known?.par !== undefined ? 'store' : 'offline', share: r4(share), reason, current,
|
|
usdEquivalent: r2(price / storeFx / (1 + (VAT[region] ?? 0))), snappedFrom: r2(target), snap };
|
|
const unchanged = current !== null && Math.round(price * 100) === Math.round(current * 100);
|
|
(unchanged || ['high-income', 'above-level', 'china', 'only-decreases'].includes(reason) ? kept : rows).push(row);
|
|
}
|
|
return { rows, kept, skipped };
|
|
}
|
|
|
|
const r4 = (n: number) => Math.round(n * 10000) / 10000;
|
|
|
|
/** Top-market drops: rows in `markets` whose price is more than TOP_DROP below the current price (or par without one). Pure. */
|
|
export function topMarketDrops(rows: PppRow[], markets: string[]): { product: string; store: string; region: string; from: number; to: number; drop: number; currency: string }[] {
|
|
return rows.filter(r => markets.includes(r.region)).flatMap(r => {
|
|
const from = r.current ?? r.par;
|
|
const drop = from > 0 ? (from - r.price) / from : 0;
|
|
return drop > TOP_DROP ? [{ product: r.product, store: r.store, region: r.region, from, to: r.price, drop: r4(drop), currency: r.currency }] : [];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Top revenue markets from the raw sales reports in .storebound/reports/ (`storebound reports apple-sales`,
|
|
* `play-sales`, `play-earnings`): amounts per buyer country converted to USD with the dataset rates, top `n`.
|
|
* Empty when no report has a country column. Pure (takes the parsed report rows).
|
|
*/
|
|
export function topMarketsFromReports(reports: Record<string, string>[][], ds: PppDataset, n = 10): string[] {
|
|
const sum = new Map<string, number>();
|
|
for (const rows of reports) for (const r of rows) {
|
|
const country = (r['Country Code'] ?? r['Buyer Country'] ?? r['Country of Buyer'] ?? '').trim().toUpperCase();
|
|
if (!/^[A-Z]{2}$/.test(country)) continue;
|
|
const [amount, currency] = r['Developer Proceeds'] !== undefined ? [num(r.Units) * num(r['Developer Proceeds']), r['Currency of Proceeds'] ?? '']
|
|
: r['Amount (Merchant Currency)'] !== undefined ? [num(r['Amount (Merchant Currency)']), r['Merchant Currency'] ?? '']
|
|
: [num(r['Charged Amount']), r['Currency of Sale'] ?? ''];
|
|
const fx = fxOf(ds, currency.trim().toUpperCase());
|
|
if (!fx || !(amount > 0)) continue;
|
|
sum.set(country, (sum.get(country) ?? 0) + amount / fx);
|
|
}
|
|
return [...sum.entries()].sort((a, b) => b[1] - a[1]).slice(0, n).map(([c]) => c);
|
|
}
|
|
|
|
const num = (s: string | undefined): number => { const v = Number(String(s ?? '').replace(/,/g, '')); return Number.isFinite(v) ? v : 0; };
|
|
|
|
/** 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 in the sheet 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,
|
|
};
|
|
}
|