--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
|
|
@ -714,9 +714,19 @@ export interface PppConfig {
|
|||
source?: 'worldbank' | 'bigmac' | 'file';
|
||||
/** CSV for source "file": region (ISO alpha-2), factor (local currency per US dollar at PPP), currency, fx (market rate, optional). */
|
||||
file?: string;
|
||||
/** Lowest price as a share of the US price after the PPP adjustment (default 0.3). */
|
||||
/** Lowest price as a share of the store's US-equivalent price (default 0.3). */
|
||||
minRatio?: number;
|
||||
/** Highest share of the US-equivalent price (default 1: never above it). */
|
||||
maxRatio?: number;
|
||||
/** Only economies whose price level (PPP factor / exchange rate) is at most this share of the US get a PPP price (default 0.5). */
|
||||
maxLevel?: number;
|
||||
/** Never propose a price above the current store price (default true). */
|
||||
onlyDecreases?: boolean;
|
||||
/** World Bank high-income economies get PPP prices too (default false: they keep the US-equivalent price). */
|
||||
includeHighIncome?: boolean;
|
||||
/** Old name of minRatio. */
|
||||
floor?: number;
|
||||
/** Highest share of the US price (default 1.2). */
|
||||
/** Old name of maxRatio. */
|
||||
ceiling?: number;
|
||||
/** Price sheet the proposal is written to (default store/prices-ppp.csv). */
|
||||
out?: string;
|
||||
|
|
@ -917,7 +927,7 @@ export function validateExtras(cfg: Partial<ShipConfig>): string[] {
|
|||
if (ppp) {
|
||||
if (ppp.source !== undefined && !['worldbank', 'bigmac', 'file'].includes(ppp.source)) e.push('prices.ppp.source: worldbank | bigmac | file');
|
||||
if (ppp.source === 'file' && !ppp.file) e.push('prices.ppp.file: needed with source "file"');
|
||||
for (const k of ['floor', 'ceiling'] as const) if (ppp[k] !== undefined && !(typeof ppp[k] === 'number' && ppp[k]! > 0 && ppp[k]! <= 5)) e.push(`prices.ppp.${k}: a share of the US price, e.g. 0.3`);
|
||||
for (const k of ['floor', 'ceiling', 'minRatio', 'maxRatio', 'maxLevel'] as const) if (ppp[k] !== undefined && !(typeof ppp[k] === 'number' && ppp[k]! > 0 && ppp[k]! <= 5)) e.push(`prices.ppp.${k}: a share of the US price, e.g. 0.3`);
|
||||
if (ppp.floor !== undefined && ppp.ceiling !== undefined && ppp.floor > ppp.ceiling) e.push('prices.ppp: floor is above ceiling');
|
||||
}
|
||||
const w = cfg.web;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Language, Loaded } from './config';
|
|||
import { playCodes } from './config';
|
||||
import * as rt from './rt';
|
||||
import { stdinIsTTY } from './tty';
|
||||
import { promptOut } from './log';
|
||||
|
||||
export interface Ctx { loaded: Loaded; args: Args }
|
||||
|
||||
|
|
@ -43,7 +44,7 @@ export async function confirm(ctx: Ctx, question: string): Promise<boolean> {
|
|||
console.log(`${question}\n not a terminal: re-run with --yes to confirm`);
|
||||
return declined('not a terminal and no --yes (re-run with --yes to confirm)');
|
||||
}
|
||||
process.stdout.write(`${question} [y/N] `);
|
||||
promptOut().write(`${question} [y/N] `);
|
||||
const line = await rt.readLine();
|
||||
return line !== null && /^y(es)?$/i.test(line.trim()) || declined(line === null ? 'no answer (stdin closed)' : 'the owner answered no');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,28 @@ export const log = (tag: string, msg: string): void => console.log(`${stamp()} [
|
|||
export const warn = (tag: string, msg: string): void => console.warn(`${stamp()} [${tag}] WARN ${msg}`);
|
||||
export const done = (tag: string, msg: string): void => console.log(`${stamp()} [${tag}] ${msg} (${since()})`);
|
||||
|
||||
// The count lives on globalThis: the Node bundle (tools/build-node.ts) can carry this module twice (once for the
|
||||
// static imports, once inside a lazily initialised chunk), and both copies must see the same --json run.
|
||||
const JSON_RUNS = Symbol.for('storebound.jsonRuns');
|
||||
const runs = globalThis as unknown as Record<symbol, number>;
|
||||
/** Whether a `--json` run is going on: stdout carries only the JSON (printJson), everything else goes to stderr. */
|
||||
export const jsonOutput = (): boolean => (runs[JSON_RUNS] ?? 0) > 0;
|
||||
/** Where a question / prompt is written: stderr during a --json run, so stdout stays one JSON document. */
|
||||
export const promptOut = (): NodeJS.WriteStream => (jsonOutput() ? process.stderr : process.stdout);
|
||||
|
||||
/**
|
||||
* Runs a command with --json: stdout is only the JSON. console.log / console.info (log, done, step lines, tables and
|
||||
* every other line a command prints) go to stderr for the run; printJson writes the result to stdout itself. index.ts
|
||||
* wraps every command run with --json in this, so no command has to remember it.
|
||||
*/
|
||||
export async function withJsonOutput<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const log = console.log, info = console.info;
|
||||
console.log = (...a: unknown[]) => console.error(...a);
|
||||
console.info = (...a: unknown[]) => console.error(...a);
|
||||
runs[JSON_RUNS] = (runs[JSON_RUNS] ?? 0) + 1;
|
||||
try { return await fn(); } finally { runs[JSON_RUNS] = runs[JSON_RUNS]! - 1; console.log = log; console.info = info; }
|
||||
}
|
||||
|
||||
/** "3/14 de-DE" style counters. */
|
||||
export const step = (i: number, n: number, what: string): string => `${String(i).padStart(String(n).length)}/${n} ${what}`;
|
||||
|
||||
|
|
@ -20,5 +42,7 @@ export const RESULT_MARK = '\u001estorebound-result ';
|
|||
*/
|
||||
export function printJson(data: unknown): void {
|
||||
if (process.env.STOREBOUND_MACHINE === '1') process.stdout.write(RESULT_MARK + JSON.stringify(data) + '\n');
|
||||
// during a --json run console.log goes to stderr, so the JSON goes to stdout itself
|
||||
else if (jsonOutput()) process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
||||
else console.log(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
|
|
|||
238
cli/lib/ppp.ts
238
cli/lib/ppp.ts
|
|
@ -1,14 +1,21 @@
|
|||
// 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)
|
||||
// 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.
|
||||
|
|
@ -111,6 +118,23 @@ export function snapEnding(v: number, currency: string): number {
|
|||
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;
|
||||
|
|
@ -124,72 +148,214 @@ export function fxOf(ds: PppDataset, currency: string): number | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
export interface Proposal { usdRaw: number; usd: number; clamped: 'floor' | 'ceiling' | null }
|
||||
// ---- the method (see the header and docs/ppp-pricing.md) ----
|
||||
|
||||
/** 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 };
|
||||
/**
|
||||
* 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;
|
||||
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';
|
||||
/** 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[] }
|
||||
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;
|
||||
floor: number;
|
||||
ceiling: number;
|
||||
/** Online: the store's regions (Apple territories with price points, Play convertRegionPrices). Offline: undefined. */
|
||||
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 }>;
|
||||
}
|
||||
|
||||
/** 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 }[] } {
|
||||
/**
|
||||
* 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, ...(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' });
|
||||
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];
|
||||
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' });
|
||||
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, skipped };
|
||||
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 })));
|
||||
|
||||
|
|
|
|||
|
|
@ -11,16 +11,19 @@ export const ALLOW_PRICE_CHANGES = 'allow-price-changes';
|
|||
export interface HeldPrice { id: string; changes: { region: string; from: string; to: string }[] }
|
||||
|
||||
/**
|
||||
* Price changes of existing (live) products are the owner's call. `--yes` (also the one `flow run --apply --yes` adds
|
||||
* to write steps) confirms creates, first prices and other updates, never these. They go with --allow-price-changes,
|
||||
* or when the owner answers the question in a terminal (no --yes), or as a desktop owner action. Nothing to change,
|
||||
* true. When false the command applies everything else and ends with holdPriceChanges().
|
||||
* Price changes of existing (live) products are the owner's call, and only ever with --allow-price-changes, TTY or
|
||||
* not: no confirmation of creates or other updates (a terminal "y", `--yes`, `flow run --apply --yes`) covers them.
|
||||
* Without the flag: false (the command applies everything else and ends with holdPriceChanges()). With the flag they
|
||||
* get their own question that lists each change old -> new: asked in a terminal (even with --yes, which never answers
|
||||
* it there), answered by --yes outside a terminal (the flag plus --yes is the explicit owner's run), by the desktop
|
||||
* owner dialog under serve. Nothing to change: true.
|
||||
*/
|
||||
export async function allowPriceChanges(ctx: Ctx, count: number, question: string): Promise<boolean> {
|
||||
if (!count || ctx.args.has('allow-price-changes')) return true;
|
||||
const yes = ctx.args.has('yes');
|
||||
if (process.env.STOREBOUND_SERVE === '1' || (stdinIsTTY() && !yes)) return confirm(withoutYes(ctx), question);
|
||||
return false;
|
||||
export async function allowPriceChanges(ctx: Ctx, changes: HeldPrice[], question: string): Promise<boolean> {
|
||||
if (!changes.length) return true;
|
||||
if (!ctx.args.has(ALLOW_PRICE_CHANGES)) return false;
|
||||
const list = changes.flatMap(h => h.changes.map(c => ` ${h.id} ${c.region} ${c.from} -> ${c.to}`)).join('\n');
|
||||
const q = `${question}\n${list}\n`;
|
||||
return confirm(stdinIsTTY() && process.env.STOREBOUND_SERVE !== '1' ? withoutYes(ctx) : ctx, q);
|
||||
}
|
||||
|
||||
/** The end of a run that left price changes alone: one line per product, then declined (exit DECLINED_EXIT), so a flow stops there. */
|
||||
|
|
@ -40,3 +43,13 @@ export function priceDiff(changes: HeldPrice['changes']): string {
|
|||
export function withoutYes(ctx: Ctx): Ctx {
|
||||
return { ...ctx, args: { ...ctx.args, has: (n: string) => n !== 'yes' && ctx.args.has(n) } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The dry run's own section for price changes of existing products: held unless the real run gets
|
||||
* --allow-price-changes. One line per product and region, old -> new. Empty when nothing is held. Pure.
|
||||
*/
|
||||
export function heldSection(held: HeldPrice[], allowed = false): string {
|
||||
if (!held.length) return '';
|
||||
const head = `price changes on existing products (${allowed ? 'with --allow-price-changes: asked separately, each old -> new' : 'held: only with --allow-price-changes'}):`;
|
||||
return [head, ...held.flatMap(h => h.changes.map(c => ` ${h.id} ${c.region} ${c.from} -> ${c.to}`))].join('\n');
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue