--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:
CRTkafa 2026-10-14 17:36:00 +08:00
parent bab3f63227
commit 357319a775
30 changed files with 865 additions and 209 deletions

View file

@ -2,6 +2,23 @@
## Unreleased ## Unreleased
- **`--json` prints only JSON on stdout.** Every command run with `--json` sends its log, step and table lines to
stderr (one place: `withJsonOutput` in cli/lib/log.ts, wrapped around every command); the last commands that printed
JSON with console.log use `printJson`. A test runs every command that reads `--json` against the fake stores and
parses its stdout (`prices ppp --json` printed log lines first).
- **PPP prices compare like with like.** `prices ppp` takes the store's own conversion of the US price (VAT included,
today's rate: Play convertRegionPrices, Apple equalizations; offline base x rate x (1 + VAT)) as par and applies the
PPP price level to it. Defaults: only lower prices (`--only-decreases`), only economies with a price level at most
50 % of the US (`--max-level`), World Bank high-income economies keep par (`--include-high-income` to include
them), mainland China at par on Apple and skipped on Play, never above par (`--max-ratio`, `--min-ratio`; the old
`--floor` / `--ceiling` still work). Germany keeps EUR 5.99 for USD 4.99 (was EUR 3.59). A top-10 revenue market
that drops more than 20 % is warned about (from your sales reports, else a static list). Regions that keep their
price stay out of the sheet (`kept` in `--json`).
- **Creating products never changes a live price.** `play products`, `prices import`, `play subscriptions` and `apple
subscriptions` change prices of existing products only with `--allow-price-changes`, in a terminal too: one "y"
covered them before. With the flag they get their own question listing each old -> new; dry runs show a separate
"price changes on existing products (held)" section.
- **`whats-new` drafts from player-facing notes before the git log.** It takes `whatsNew.source`, then - **`whats-new` drafts from player-facing notes before the git log.** It takes `whatsNew.source`, then
`docs/RELEASE-<version>.md`, then the CHANGELOG.md section of the version (else Unreleased); version from `docs/RELEASE-<version>.md`, then the CHANGELOG.md section of the version (else Unreleased); version from
`--release`, else package.json. Only without one (or with `--from-git`) it drafts from commit messages, and says so. `--release`, else package.json. Only without one (or with `--from-git`) it drafts from commit messages, and says so.

View file

@ -20,6 +20,7 @@ import { applyImport, describeProfiles, planImport } from './lib/accounts';
import { activeProfile, defaultPointer, defaultProfile, profileDir, setDefaultPointer } from './lib/credentials'; import { activeProfile, defaultPointer, defaultProfile, profileDir, setDefaultPointer } from './lib/credentials';
import { confirm, type Ctx } from './lib/ctx'; import { confirm, type Ctx } from './lib/ctx';
import { textTable } from './lib/table'; import { textTable } from './lib/table';
import { printJson } from './lib/log';
const tilde = (p: string) => (process.env.HOME && p.startsWith(process.env.HOME) ? '~' + p.slice(process.env.HOME.length) : p); const tilde = (p: string) => (process.env.HOME && p.startsWith(process.env.HOME) ? '~' + p.slice(process.env.HOME.length) : p);
@ -32,7 +33,7 @@ export async function run(args: Args): Promise<void> {
try { active = activeProfile(dir); } catch { /* unreadable project file */ } try { active = activeProfile(dir); } catch { /* unreadable project file */ }
const def = defaultProfile(); const def = defaultProfile();
const pointer = defaultPointer(); const pointer = defaultPointer();
if (args.has('json')) { console.log(JSON.stringify({ folder: tilde(profileDir()), profiles: rows.map(r => ({ ...r, default: r.profile === def })), default: def, defaultFrom: def ? (def === pointer ? 'pointer' : rows.length === 1 ? 'only' : 'named-default') : null, thisFolder: active }, null, 2)); return; } if (args.has('json')) { printJson({ folder: tilde(profileDir()), profiles: rows.map(r => ({ ...r, default: r.profile === def })), default: def, defaultFrom: def ? (def === pointer ? 'pointer' : rows.length === 1 ? 'only' : 'named-default') : null, thisFolder: active }); return; }
if (!rows.length) { if (!rows.length) {
console.log(`no store accounts on this machine yet (${tilde(profileDir())}).\n From a project that has its keys in .storebound/credentials.json: storebound accounts import\n Or add them in the desktop app (Accounts).`); console.log(`no store accounts on this machine yet (${tilde(profileDir())}).\n From a project that has its keys in .storebound/credentials.json: storebound accounts import\n Or add them in the desktop app (Accounts).`);
return; return;
@ -47,7 +48,7 @@ export async function run(args: Args): Promise<void> {
const plan = planImport(dir, { name: args.get('name') }); const plan = planImport(dir, { name: args.get('name') });
const json = args.has('json'); const json = args.has('json');
const dry = args.has('dry'); const dry = args.has('dry');
if (json && dry) { console.log(JSON.stringify({ ...plan, writes: plan.writes.map(w => ({ path: tilde(w.path), what: w.what, action: w.action })), project: tilde(plan.project), projectFile: tilde(plan.projectFile) }, null, 2)); return; } if (json && dry) { printJson({ ...plan, writes: plan.writes.map(w => ({ path: tilde(w.path), what: w.what, action: w.action })), project: tilde(plan.project), projectFile: tilde(plan.projectFile) }); return; }
console.log(`${dry ? 'dry run: ' : ''}import the store accounts of ${tilde(plan.project)} into profile "${plan.profile}"${plan.reuse ? ' (the same account is already there)' : ' (new)'}`); console.log(`${dry ? 'dry run: ' : ''}import the store accounts of ${tilde(plan.project)} into profile "${plan.profile}"${plan.reuse ? ' (the same account is already there)' : ' (new)'}`);
console.log(` stores: ${plan.stores.join(', ')}; moved out of the project file: ${plan.moved.join(', ')}`); console.log(` stores: ${plan.stores.join(', ')}; moved out of the project file: ${plan.moved.join(', ')}`);
for (const w of plan.writes) console.log(` ${w.action === 'same' ? 'unchanged' : w.action.padEnd(9)} ${tilde(w.path)} (${w.what}${w.mode === 0o600 && w.action !== 'same' ? ', mode 600' : ''})`); for (const w of plan.writes) console.log(` ${w.action === 'same' ? 'unchanged' : w.action.padEnd(9)} ${tilde(w.path)} (${w.what}${w.mode === 0o600 && w.action !== 'same' ? ', mode 600' : ''})`);

View file

@ -19,6 +19,7 @@ import { PLAY_APIS } from './api/play.gen';
import { WRAPPED } from './api/wrapped.gen'; import { WRAPPED } from './api/wrapped.gen';
import type { ParamValue } from './api/types'; import type { ParamValue } from './api/types';
import * as rt from './lib/rt'; import * as rt from './lib/rt';
import { printJson, promptOut } from './lib/log';
import { stdinIsTTY } from './lib/tty'; import { stdinIsTTY } from './lib/tty';
const STORES: Store[] = ['apple', 'play']; const STORES: Store[] = ['apple', 'play'];
@ -109,14 +110,14 @@ export async function run(args: Args): Promise<void> {
if (sub === 'list') { if (sub === 'list') {
const list = args.get('search') ? search(rows(store), args.get('search')!) : rows(store); const list = args.get('search') ? search(rows(store), args.get('search')!) : rows(store);
if (json) { console.log(JSON.stringify(list)); return; } if (json) { printJson(list); return; }
for (const r of list) console.log(`${r.store.padEnd(5)} ${r.method.padEnd(6)} ${r.id.padEnd(60)} ${r.path}${r.gate === 'owner' ? ' [owner only]' : ''}${r.wrapped ? ` (wrapped: ${r.wrapped})` : ''}`); for (const r of list) console.log(`${r.store.padEnd(5)} ${r.method.padEnd(6)} ${r.id.padEnd(60)} ${r.path}${r.gate === 'owner' ? ' [owner only]' : ''}${r.wrapped ? ` (wrapped: ${r.wrapped})` : ''}`);
console.error(`${list.length} operation(s)`); console.error(`${list.length} operation(s)`);
return; return;
} }
if (sub === 'coverage') { if (sub === 'coverage') {
const c = coverage(store); const c = coverage(store);
if (json) { console.log(JSON.stringify(c)); return; } if (json) { printJson(c); return; }
for (const a of c.apis) console.log(`${a.title}: ${a.operations} operations (${a.get} GET, ${a.write} write, ${a.owner} owner only), ${a.wrapped} wrapped by storebound commands`); for (const a of c.apis) console.log(`${a.title}: ${a.operations} operations (${a.get} GET, ${a.write} write, ${a.owner} owner only), ${a.wrapped} wrapped by storebound commands`);
console.log(`\n${'resource'.padEnd(62)} ops wrapped`); console.log(`\n${'resource'.padEnd(62)} ops wrapped`);
for (const r of c.resources) console.log(`${(r.store + ' ' + r.resource).padEnd(62)} ${String(r.operations).padStart(3)} ${String(r.wrapped).padStart(7)}`); for (const r of c.resources) console.log(`${(r.store + ' ' + r.resource).padEnd(62)} ${String(r.operations).padStart(3)} ${String(r.wrapped).padStart(7)}`);
@ -146,13 +147,13 @@ export async function run(args: Args): Promise<void> {
}); });
if (decision.action === 'refuse') throw new Error(`${op.id}: ${decision.why}`); if (decision.action === 'refuse') throw new Error(`${op.id}: ${decision.why}`);
if (decision.action === 'dry') { if (decision.action === 'dry') {
if (json) console.log(JSON.stringify({ dryRun: true, operation: op.id, gate: kind, why: decision.why, request: { method: req.method, url: req.url, body: typeof req.body === 'string' ? JSON.parse(req.body) : req.body ? `<${req.body.length} bytes>` : undefined } })); if (json) printJson({ dryRun: true, operation: op.id, gate: kind, why: decision.why, request: { method: req.method, url: req.url, body: typeof req.body === 'string' ? JSON.parse(req.body) : req.body ? `<${req.body.length} bytes>` : undefined } });
else console.log(`dry run (${decision.why}): nothing sent\n${kind === 'owner' ? `owner only: this ${ownerReason(st, op.id, op.method)}; sending also asks you to type the operation id\n` : ''}\n${describeRequest(req)}`); else console.log(`dry run (${decision.why}): nothing sent\n${kind === 'owner' ? `owner only: this ${ownerReason(st, op.id, op.method)}; sending also asks you to type the operation id\n` : ''}\n${describeRequest(req)}`);
return; return;
} }
if (decision.action === 'confirm') { if (decision.action === 'confirm') {
console.log(describeRequest(req)); console.log(describeRequest(req));
process.stdout.write(`\nThis ${ownerReason(st, op.id, op.method)}. Type the operation id (${op.id}) to send it: `); promptOut().write(`\nThis ${ownerReason(st, op.id, op.method)}. Type the operation id (${op.id}) to send it: `);
if ((await readLine()) !== op.id) { console.log('not sent'); process.exitCode = 1; return; } if ((await readLine()) !== op.id) { console.log('not sent'); process.exitCode = 1; return; }
} }
@ -166,10 +167,10 @@ export async function run(args: Args): Promise<void> {
if (r.bytes) { if (r.bytes) {
if (!out) throw new Error(`${op.id}: binary response (${r.bytes.length} bytes): pass --out <file>`); if (!out) throw new Error(`${op.id}: binary response (${r.bytes.length} bytes): pass --out <file>`);
writeFileSync(resolve(out), r.bytes); writeFileSync(resolve(out), r.bytes);
console.log(json ? JSON.stringify({ status: r.status, out, bytes: r.bytes.length }) : `wrote ${out} (${r.bytes.length} bytes)`); if (json) printJson({ status: r.status, out, bytes: r.bytes.length }); else console.log(`wrote ${out} (${r.bytes.length} bytes)`);
return; return;
} }
if (out) { writeFileSync(resolve(out), JSON.stringify(r.json, null, 2) + '\n'); if (!json) console.log(`wrote ${out}`); return; } if (out) { writeFileSync(resolve(out), JSON.stringify(r.json, null, 2) + '\n'); if (!json) console.log(`wrote ${out}`); return; }
console.log(json ? JSON.stringify(r.json) : JSON.stringify(r.json, null, 2)); if (json) printJson(r.json); else console.log(JSON.stringify(r.json, null, 2));
} }

View file

@ -143,7 +143,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'inAppPurchaseOfferCodes_customCodes_getToManyRelated': 'apple/offer-codes', 'inAppPurchaseOfferCodes_customCodes_getToManyRelated': 'apple/offer-codes',
'inAppPurchaseOfferCodes_getInstance': 'apple/offer-codes', 'inAppPurchaseOfferCodes_getInstance': 'apple/offer-codes',
'inAppPurchaseOfferCodes_oneTimeUseCodes_getToManyRelated': 'apple/offer-codes', 'inAppPurchaseOfferCodes_oneTimeUseCodes_getToManyRelated': 'apple/offer-codes',
'inAppPurchasePricePoints_equalizations_getToManyRelated': 'apple/offer-codes', 'inAppPurchasePricePoints_equalizations_getToManyRelated': 'apple/offer-codes prices-ppp',
'inAppPurchasePriceSchedules_automaticPrices_getToManyRelated': 'prices', 'inAppPurchasePriceSchedules_automaticPrices_getToManyRelated': 'prices',
'inAppPurchasePriceSchedules_baseTerritory_getToOneRelated': 'prices', 'inAppPurchasePriceSchedules_baseTerritory_getToOneRelated': 'prices',
'inAppPurchasePriceSchedules_createInstance': 'apple/iap prices', 'inAppPurchasePriceSchedules_createInstance': 'apple/iap prices',

View file

@ -66,6 +66,9 @@ async function readState(c: Asc, appId: string): Promise<AppleSubState> {
return state; return state;
} }
const heldOf = (id: string, changes: { territory: string; from: number | null; to: number; startDate?: string | null }[]): HeldPrice =>
({ id, changes: changes.map(ch => ({ region: ch.territory, from: ch.from === null ? '-' : String(ch.from), to: `${ch.to}${ch.startDate ? ` from ${ch.startDate}` : ''}` })) });
export async function run(ctx: Ctx): Promise<void> { export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded; const { config, root, resolve } = ctx.loaded;
const only = ctx.args.list('only'); const only = ctx.args.list('only');
@ -83,11 +86,10 @@ export async function run(ctx: Ctx): Promise<void> {
if (dry) { done(TAG, `subscriptions planned (dry run, nothing written; prices are compared per territory on a real run${csv.length ? `, ${csv.length} CSV rows` : ''})`); return; } if (dry) { done(TAG, `subscriptions planned (dry run, nothing written; prices are compared per territory on a real run${csv.length ? `, ${csv.length} CSV rows` : ''})`); return; }
const yes = ctx.args.has('yes'); const yes = ctx.args.has('yes');
// price changes of existing subscriptions: known per subscription, decided at the first one (lib/price-gate.ts) // price changes of existing subscriptions: only with --allow-price-changes, their own question per subscription
let liveOk: boolean | undefined; // listing each old -> new (lib/price-gate.ts); the terminal question here covers creates and other updates only
if (stdinIsTTY() && !yes && process.env.STOREBOUND_SERVE !== '1') { if (stdinIsTTY() && !yes && process.env.STOREBOUND_SERVE !== '1') {
if (!(await confirm(ctx, 'Create / update these subscriptions on App Store Connect? Nothing is public before App Review, but price changes of existing subscriptions apply to new subscribers right away.'))) return; if (!(await confirm(ctx, 'Create / update these subscriptions on App Store Connect? Nothing is public before App Review. Price changes of existing subscriptions are not part of this answer (--allow-price-changes).'))) return;
liveOk = true;
} else if (!yes) log(TAG, 'not a terminal: creating what is new; price changes of existing subscriptions wait for the owner'); } else if (!yes) log(TAG, 'not a terminal: creating what is new; price changes of existing subscriptions wait for the owner');
const held: HeldPrice[] = []; const held: HeldPrice[] = [];
@ -148,9 +150,9 @@ export async function run(ctx: Ctx): Promise<void> {
const current = hasPrices ? appleSubPrices(await pages(c, `/v1/subscriptions/${id}/prices?include=subscriptionPricePoint,territory&limit=200`)) : []; const current = hasPrices ? appleSubPrices(await pages(c, `/v1/subscriptions/${id}/prices?include=subscriptionPricePoint,territory&limit=200`)) : [];
const changes = applePriceChanges(current, appleWantedPrices(s, equalized, csv), csv.filter(r => r.product === s.id)); const changes = applePriceChanges(current, appleWantedPrices(s, equalized, csv), csv.filter(r => r.product === s.id));
if (!changes.length) log(TAG, ' prices up to date'); if (!changes.length) log(TAG, ' prices up to date');
else if (current.length && !(liveOk ??= await allowPriceChanges(ctx, changes.length, `Change the prices of existing subscriptions on App Store Connect (first: ${s.id}, ${changes.length} territories)? New subscribers pay the new price.`))) { else if (current.length && !(await allowPriceChanges(ctx, [heldOf(s.id, changes)], `Change the prices of the existing subscription ${s.id} on App Store Connect (${changes.length} territories)? New subscribers pay the new price.`))) {
log(TAG, ' owner: price change of an existing subscription (--allow-price-changes)'); log(TAG, ' owner: price change of an existing subscription (--allow-price-changes)');
held.push({ id: s.id, changes: changes.map(ch => ({ region: ch.territory, from: ch.from === null ? '-' : String(ch.from), to: `${ch.to}${ch.startDate ? ` from ${ch.startDate}` : ''}` })) }); held.push(heldOf(s.id, changes));
} else { } else {
const resolved: { territory: string; pointId: string; startDate?: string | null }[] = []; const resolved: { territory: string; pointId: string; startDate?: string | null }[] = [];
for (const ch of changes) { for (const ch of changes) {

View file

@ -116,8 +116,14 @@
"csv", "csv",
"file", "file",
"floor", "floor",
"include-high-income",
"max-level",
"max-ratio",
"min-ratio",
"no-only-decreases",
"no-subscriptions", "no-subscriptions",
"offline", "offline",
"only-decreases",
"out", "out",
"reconvert", "reconvert",
"source", "source",
@ -203,8 +209,14 @@
"csv", "csv",
"file", "file",
"floor", "floor",
"include-high-income",
"max-level",
"max-ratio",
"min-ratio",
"no-only-decreases",
"no-subscriptions", "no-subscriptions",
"offline", "offline",
"only-decreases",
"out", "out",
"reconvert", "reconvert",
"source", "source",
@ -253,8 +265,14 @@
"csv", "csv",
"file", "file",
"floor", "floor",
"include-high-income",
"max-level",
"max-ratio",
"min-ratio",
"no-only-decreases",
"no-subscriptions", "no-subscriptions",
"offline", "offline",
"only-decreases",
"out", "out",
"reconvert", "reconvert",
"source", "source",
@ -304,8 +322,14 @@
"csv", "csv",
"file", "file",
"floor", "floor",
"include-high-income",
"max-level",
"max-ratio",
"min-ratio",
"no-only-decreases",
"no-subscriptions", "no-subscriptions",
"offline", "offline",
"only-decreases",
"out", "out",
"reconvert", "reconvert",
"source", "source",
@ -479,11 +503,17 @@
"from", "from",
"godot", "godot",
"identity", "identity",
"include-high-income",
"max-level",
"max-ratio",
"min-ratio",
"no-art", "no-art",
"no-catalog", "no-catalog",
"no-only-decreases",
"no-subscriptions", "no-subscriptions",
"no-web", "no-web",
"offline", "offline",
"only-decreases",
"out", "out",
"play", "play",
"react-native", "react-native",
@ -587,8 +617,14 @@
"csv", "csv",
"file", "file",
"floor", "floor",
"include-high-income",
"max-level",
"max-ratio",
"min-ratio",
"no-only-decreases",
"no-subscriptions", "no-subscriptions",
"offline", "offline",
"only-decreases",
"out", "out",
"reconvert", "reconvert",
"source", "source",
@ -611,8 +647,14 @@
"csv", "csv",
"file", "file",
"floor", "floor",
"include-high-income",
"max-level",
"max-ratio",
"min-ratio",
"no-only-decreases",
"no-subscriptions", "no-subscriptions",
"offline", "offline",
"only-decreases",
"out", "out",
"reconvert", "reconvert",
"source", "source",
@ -896,6 +938,7 @@
"icons", "icons",
"identity", "identity",
"include-drafts", "include-drafts",
"include-high-income",
"internal", "internal",
"internal-only", "internal-only",
"invite", "invite",
@ -917,9 +960,12 @@
"locale", "locale",
"lookup", "lookup",
"manual-release", "manual-release",
"max-level",
"max-ratio",
"md", "md",
"merge", "merge",
"method", "method",
"min-ratio",
"module", "module",
"month", "month",
"name", "name",
@ -933,6 +979,7 @@
"no-install", "no-install",
"no-local", "no-local",
"no-mcp", "no-mcp",
"no-only-decreases",
"no-prebuild", "no-prebuild",
"no-save", "no-save",
"no-scan", "no-scan",
@ -947,6 +994,7 @@
"off", "off",
"offline", "offline",
"older-than", "older-than",
"only-decreases",
"only-keywords", "only-keywords",
"only-missing", "only-missing",
"only-terms", "only-terms",

View file

@ -26,6 +26,7 @@ import { verify } from './lib/minisign';
import { parsePlist } from './lib/plist'; import { parsePlist } from './lib/plist';
import { addFolder } from './lib/registry'; import { addFolder } from './lib/registry';
import * as rt from './lib/rt'; import * as rt from './lib/rt';
import { printJson, promptOut } from './lib/log';
import { stdinIsTTY } from './lib/tty'; import { stdinIsTTY } from './lib/tty';
type Fetch = (url: string) => Promise<Response>; type Fetch = (url: string) => Promise<Response>;
@ -128,7 +129,7 @@ function canWrite(dir: string): boolean {
} }
async function askYes(text: string): Promise<boolean> { async function askYes(text: string): Promise<boolean> {
process.stdout.write(text); promptOut().write(text);
const line = await rt.readLine(); const line = await rt.readLine();
return line !== null && !/^n(o)?$/i.test(line.trim()); return line !== null && !/^n(o)?$/i.test(line.trim());
} }
@ -158,7 +159,7 @@ export async function run(args: Args): Promise<void> {
if (manifest && !chosen && !error) error = `version ${manifest.version} has no ${args.get('format') ?? 'installer'} for ${platform} ${process.arch} yet`; if (manifest && !chosen && !error) error = `version ${manifest.version} has no ${args.get('format') ?? 'installer'} for ${platform} ${process.arch} yet`;
if (args.has('download')) { if (args.has('download')) {
if (json) { console.log(JSON.stringify({ cli, installed, installedVersion, page: PAGE, version: manifest?.version ?? null, download: chosen ? { kind: chosen[0], ...chosen[1] } : null, error: error || null }, null, 2)); return; } if (json) { printJson({ cli, installed, installedVersion, page: PAGE, version: manifest?.version ?? null, download: chosen ? { kind: chosen[0], ...chosen[1] } : null, error: error || null }); return; }
if (!chosen) throw new Error(`${error}; see ${PAGE}`); if (!chosen) throw new Error(`${error}; see ${PAGE}`);
const [kind, d] = chosen; const [kind, d] = chosen;
if (dry) { console.log(`dry run: would download ${d.file} (${(d.size / 1e6).toFixed(1)} MB, ${DOWNLOAD_LABEL[kind]}) into ${tilde(downloadsDir())} and check it`); return; } if (dry) { console.log(`dry run: would download ${d.file} (${(d.size / 1e6).toFixed(1)} MB, ${DOWNLOAD_LABEL[kind]}) into ${tilde(downloadsDir())} and check it`); return; }
@ -179,12 +180,12 @@ export async function run(args: Args): Promise<void> {
}) : null; }) : null;
if (json) { if (json) {
console.log(JSON.stringify({ printJson({
cli, installed, installedVersion, decision, link, folder: isApp ? dir : null, cli, installed, installedVersion, decision, link, folder: isApp ? dir : null,
download: chosen && changing ? { kind: chosen[0], ...chosen[1] } : null, download: chosen && changing ? { kind: chosen[0], ...chosen[1] } : null,
plan: plan ? { target: plan.target, place: plan.place, manual: plan.manual ?? null, steps: plan.steps.map(s => s.what) } : null, plan: plan ? { target: plan.target, place: plan.place, manual: plan.manual ?? null, steps: plan.steps.map(s => s.what) } : null,
command: installed && !changing ? openCommand(platform, installed, link) : null, command: installed && !changing ? openCommand(platform, installed, link) : null,
}, null, 2)); });
return; return;
} }
for (const n of decision.notes) console.log(`storebound desktop: ${n}`); for (const n of decision.notes) console.log(`storebound desktop: ${n}`);

View file

@ -8,7 +8,7 @@ import pkg from '../package.json' with { type: 'json' };
import type { Args } from './lib/args'; import type { Args } from './lib/args';
import { CONFIG_NAMES } from './lib/config'; import { CONFIG_NAMES } from './lib/config';
import { credentialStatus } from './lib/credentials'; import { credentialStatus } from './lib/credentials';
import { log } from './lib/log'; import { log, printJson } from './lib/log';
import { textTable } from './lib/table'; import { textTable } from './lib/table';
import { androidSdk, type AndroidSdkLookup, detectTools, findPlaywright, installCommands, type ToolStatus } from './lib/tools'; import { androidSdk, type AndroidSdkLookup, detectTools, findPlaywright, installCommands, type ToolStatus } from './lib/tools';
import * as rt from './lib/rt'; import * as rt from './lib/rt';
@ -51,7 +51,7 @@ export function doctor(cwd: string, env: Record<string, string | undefined> = pr
export async function run(args: Args): Promise<void> { export async function run(args: Args): Promise<void> {
const r = doctor(resolve(args.str('dir', '.'))); const r = doctor(resolve(args.str('dir', '.')));
if (args.has('json')) { console.log(JSON.stringify(r, null, 2)); return; } if (args.has('json')) { printJson(r); return; }
log('doctor', `storebound ${r.storebound} · ${r.runtime} · ${r.platform}${r.project ? ' · project config found' : ''}`); log('doctor', `storebound ${r.storebound} · ${r.runtime} · ${r.platform}${r.project ? ' · project config found' : ''}`);
console.log(textTable(['tool', 'status', 'version', 'used for'], r.tools.map(t => [t.name, t.found ? (t.source === 'app' ? 'ok (app copy)' : 'ok') : 'missing', t.version ?? '', t.usedFor]))); console.log(textTable(['tool', 'status', 'version', 'used for'], r.tools.map(t => [t.name, t.found ? (t.source === 'app' ? 'ok (app copy)' : 'ok') : 'missing', t.version ?? '', t.usedFor])));
log('doctor', r.androidSdk.path ? `Android SDK: ${r.androidSdk.path} (from ${r.androidSdk.source})` log('doctor', r.androidSdk.path ? `Android SDK: ${r.androidSdk.path} (from ${r.androidSdk.source})`

View file

@ -4,6 +4,7 @@
// `--help` / `-h` anywhere prints the usage of the command (or of a group like `storebound shots`) and exits 0 before // `--help` / `-h` anywhere prints the usage of the command (or of a group like `storebound shots`) and exits 0 before
// anything loads; an unknown --flag (cli/data/flags.json, tools/gen-flags.ts) exits 2 with the usage, also before. // anything loads; an unknown --flag (cli/data/flags.json, tools/gen-flags.ts) exits 2 with the usage, also before.
import { parseArgs } from './lib/args'; import { parseArgs } from './lib/args';
import { withJsonOutput } from './lib/log';
import { toolPath } from './lib/tools'; import { toolPath } from './lib/tools';
import { COMMANDS, PLAIN } from './commands'; import { COMMANDS, PLAIN } from './commands';
import FLAGS from './data/flags.json'; import FLAGS from './data/flags.json';
@ -81,14 +82,19 @@ async function main(): Promise<void> {
const bad = [...new Set(rest.filter(x => /^--[^-]/.test(x)).map(x => x.slice(2).split('=')[0]!).filter(f => !ok.has(f)))]; const bad = [...new Set(rest.filter(x => /^--[^-]/.test(x)).map(x => x.slice(2).split('=')[0]!).filter(f => !ok.has(f)))];
if (bad.length) { console.error(`unknown option${bad.length > 1 ? 's' : ''} for storebound ${key}: ${bad.map(f => '--' + f).join(', ')}\n\n${commandUsage(key)}`); process.exit(2); } if (bad.length) { console.error(`unknown option${bad.length > 1 ? 's' : ''} for storebound ${key}: ${bad.map(f => '--' + f).join(', ')}\n\n${commandUsage(key)}`); process.exit(2); }
} }
if (PLAIN[key]) return (await PLAIN[key]![0]()).run(parseArgs(rest)); // --json: stdout is only the JSON, every log line goes to stderr (lib/log.ts withJsonOutput)
const json = (f: () => Promise<void>): Promise<void> => (rest.includes('--json') && key !== 'mcp' && key !== 'serve' ? withJsonOutput(f) : f());
if (PLAIN[key]) { const plain = PLAIN[key]![0]; return json(async () => (await plain()).run(parseArgs(rest))); }
const args = parseArgs(rest); const args = parseArgs(rest);
const { loadConfig } = await import('./lib/config'); await json(async () => {
const loaded = await loadConfig(args.get('config')); const { loadConfig } = await import('./lib/config');
// one writer per app and store (cli/lib/lock.ts): dry runs and reads take no lock const loaded = await loadConfig(args.get('config'));
const { acquireLock, lockKeys } = await import('./lib/lock'); // one writer per app and store (cli/lib/lock.ts): dry runs and reads take no lock
for (const k of lockKeys(key, args, loaded.config)) acquireLock(loaded.root, k, argv.filter(x => x !== '--force-unlock').join(' '), { force: args.has('force-unlock') }); const { acquireLock, lockKeys } = await import('./lib/lock');
await (await COMMANDS[key]![0]()).run({ loaded, args }); for (const k of lockKeys(key, args, loaded.config)) acquireLock(loaded.root, k, argv.filter(x => x !== '--force-unlock').join(' '), { force: args.has('force-unlock') });
await (await COMMANDS[key]![0]()).run({ loaded, args });
});
} }
main().catch(e => { console.error(`\nerror: ${(e as Error).message}`); process.exit(1); }); main().catch(e => { console.error(`\nerror: ${(e as Error).message}`); process.exit(1); });

View file

@ -714,9 +714,19 @@ export interface PppConfig {
source?: 'worldbank' | 'bigmac' | 'file'; 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). */ /** CSV for source "file": region (ISO alpha-2), factor (local currency per US dollar at PPP), currency, fx (market rate, optional). */
file?: string; 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; floor?: number;
/** Highest share of the US price (default 1.2). */ /** Old name of maxRatio. */
ceiling?: number; ceiling?: number;
/** Price sheet the proposal is written to (default store/prices-ppp.csv). */ /** Price sheet the proposal is written to (default store/prices-ppp.csv). */
out?: string; out?: string;
@ -917,7 +927,7 @@ export function validateExtras(cfg: Partial<ShipConfig>): string[] {
if (ppp) { if (ppp) {
if (ppp.source !== undefined && !['worldbank', 'bigmac', 'file'].includes(ppp.source)) e.push('prices.ppp.source: worldbank | bigmac | file'); 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"'); 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'); if (ppp.floor !== undefined && ppp.ceiling !== undefined && ppp.floor > ppp.ceiling) e.push('prices.ppp: floor is above ceiling');
} }
const w = cfg.web; const w = cfg.web;

View file

@ -3,6 +3,7 @@ import type { Language, Loaded } from './config';
import { playCodes } from './config'; import { playCodes } from './config';
import * as rt from './rt'; import * as rt from './rt';
import { stdinIsTTY } from './tty'; import { stdinIsTTY } from './tty';
import { promptOut } from './log';
export interface Ctx { loaded: Loaded; args: Args } 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`); 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)'); 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(); 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'); return line !== null && /^y(es)?$/i.test(line.trim()) || declined(line === null ? 'no answer (stdin closed)' : 'the owner answered no');
} }

View file

@ -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 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()})`); 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. */ /** "3/14 de-DE" style counters. */
export const step = (i: number, n: number, what: string): string => `${String(i).padStart(String(n).length)}/${n} ${what}`; 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 { export function printJson(data: unknown): void {
if (process.env.STOREBOUND_MACHINE === '1') process.stdout.write(RESULT_MARK + JSON.stringify(data) + '\n'); 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)); else console.log(JSON.stringify(data, null, 2));
} }

View file

@ -1,14 +1,21 @@
// Purchasing power parity (PPP) price proposals for `storebound prices ppp`, as pure parts: datasets, the math, the // 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). // 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: // The math, per product, store and region (like with like: store prices include VAT and follow today's exchange
// factor = PPP conversion factor (local currency units per international dollar; the international dollar has the // rate, the US price excludes sales tax, the ICP price level compares net prices):
// US dollar's purchasing power in the US, so the US factor is 1) // factor = PPP conversion factor (local currency units per international dollar; the US factor is 1)
// fx = market exchange rate (local currency units per US dollar, period average) // fx = market exchange rate of the same year (local currency units per US dollar, period average)
// usd = base x factor / fx (the USD value of the PPP price at the market rate) // level = factor / fx (the region's price level against the US, net of tax)
// usd = clamp(usd, floor x base, ceiling x base) (floor 0.3, ceiling 1.2 unless the config or flags say) // par = the store's own conversion of the US price into that region: Play convertRegionPrices, Apple the
// target = usd x fx(store currency) (the store may price a region in USD or EUR instead of the local currency) // equalizations of the USA price point (both: tax included, today's rate). Offline: base x fx x (1 + VAT)
// price = snap(target) // 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): // 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 // - 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. // 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; 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). */ /** Nearest Apple price point (customer prices of one territory). */
export function snapApple(points: number[], v: number): number | null { export function snapApple(points: number[], v: number): number | null {
return points.length ? nearestPrices(points, v, 1)[0]! : 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; 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 { * World Bank high-income economies (FY2026 classification, July 2025; GNI per capita, Atlas method), as ISO alpha-2,
if (!(factor > 0) || !(fx > 0)) throw new Error('PPP factor and exchange rate must be positive'); * App Store territories only. They keep the store's US-equivalent price unless --include-high-income.
const usdRaw = (base * factor) / fx; * Source: https://datahelpdesk.worldbank.org/knowledgebase/articles/906519-world-bank-country-and-lending-groups
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 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 { export interface PppRow extends PriceRow {
factor: number; factor: number;
fx: 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. */ /** USD value of the final (snapped) price at the market rate. */
usdEquivalent: number; usdEquivalent: number;
clamped: 'floor' | 'ceiling' | null;
/** Unsnapped price in the store currency. */ /** Unsnapped price in the store currency. */
snappedFrom: number; snappedFrom: number;
/** apple-point: a real Apple price point; ending: storebound's clean-ending rule. */ /** 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'; snap: 'apple-point' | 'ending' | 'base' | 'par' | 'current';
} }
/** What one store says about one region (online), or nothing (offline). */ /** 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 { export interface PlanInput {
product: string; product: string;
base: number; base: number;
store: 'apple' | 'play'; store: 'apple' | 'play';
dataset: PppDataset; dataset: PppDataset;
floor: number; options: PppOptions;
ceiling: number; /** Online: the store's regions (Apple territories with price points and equalized prices, Play convertRegionPrices). Offline: undefined. */
/** Online: the store's regions (Apple territories with price points, Play convertRegionPrices). Offline: undefined. */
regions?: Map<string, StoreRegion>; 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 rows: PppRow[] = [];
const kept: PppRow[] = [];
const skipped: { region: string; why: string }[] = []; const skipped: { region: string; why: string }[] = [];
const regions = i.regions ? [...i.regions.keys()] : Object.keys(ALPHA3); const regions = i.regions ? [...i.regions.keys()] : Object.keys(ALPHA3);
const o = i.options;
for (const region of regions.sort()) { 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 known = i.regions?.get(region);
const currency = known?.currency || (i.store === 'apple' ? appleCurrencyHint(region) : i.dataset.countries[region]?.currency ?? CURRENCY[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; } if (!currency) { skipped.push({ region, why: 'no currency known' }); continue; }
const storeFx = fxOf(i.dataset, currency); 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') { if (region === 'US' && currency === 'USD') {
const price = known?.points?.length ? snapApple(known.points, i.base)! : i.base; 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; continue;
} }
const e = i.dataset.countries[region]; const e = i.dataset.countries[region];
if (!e) { skipped.push({ region, why: `no PPP factor in ${i.dataset.name}` }); continue; } const level = e?.fx ? e.ppp / e.fx : null;
if (!e.fx) { skipped.push({ region, why: 'no exchange rate' }); continue; } const atPar = region === 'CN' || (HIGH_INCOME.has(region) && !o.includeHighIncome);
if (!storeFx) { skipped.push({ region, why: `no exchange rate for ${currency}` }); continue; } if (!atPar && !e) { skipped.push({ region, why: `no PPP factor in ${i.dataset.name}` }); continue; }
const p = propose(i.base, e.ppp, e.fx, i.floor, i.ceiling); if (!atPar && !e!.fx) { skipped.push({ region, why: 'no exchange rate' }); continue; }
const target = p.usd * storeFx; const par = known?.par ?? offlinePar(i.base, region, currency, i.dataset);
const point = known?.points ? snapApple(known.points, target) : null; if (par === null || !storeFx) { skipped.push({ region, why: `no exchange rate for ${currency}` }); continue; }
if (known?.points && point === null) { skipped.push({ region, why: 'no Apple price points' }); continue; } let { share, reason } = shareOf(region, level, o);
const price = point ?? snapEnding(target, currency); let target = par * share;
rows.push({ product: i.product, store: i.store, region, ...(i.store === 'apple' ? { territory: ALPHA3[region] } : {}), currency, price, let snap: PppRow['snap'];
factor: e.ppp, fx: e.fx, usdEquivalent: r2(price / storeFx), clamped: p.clamped, snappedFrom: r2(target), snap: point !== null ? 'apple-point' : 'ending' }); 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. */ /** 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 }))); 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 })));

View file

@ -11,16 +11,19 @@ export const ALLOW_PRICE_CHANGES = 'allow-price-changes';
export interface HeldPrice { id: string; changes: { region: string; from: string; to: string }[] } 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 * Price changes of existing (live) products are the owner's call, and only ever with --allow-price-changes, TTY or
* to write steps) confirms creates, first prices and other updates, never these. They go with --allow-price-changes, * not: no confirmation of creates or other updates (a terminal "y", `--yes`, `flow run --apply --yes`) covers them.
* or when the owner answers the question in a terminal (no --yes), or as a desktop owner action. Nothing to change, * Without the flag: false (the command applies everything else and ends with holdPriceChanges()). With the flag they
* true. When false the command applies everything else and ends with holdPriceChanges(). * 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> { export async function allowPriceChanges(ctx: Ctx, changes: HeldPrice[], question: string): Promise<boolean> {
if (!count || ctx.args.has('allow-price-changes')) return true; if (!changes.length) return true;
const yes = ctx.args.has('yes'); if (!ctx.args.has(ALLOW_PRICE_CHANGES)) return false;
if (process.env.STOREBOUND_SERVE === '1' || (stdinIsTTY() && !yes)) return confirm(withoutYes(ctx), question); const list = changes.flatMap(h => h.changes.map(c => ` ${h.id} ${c.region} ${c.from} -> ${c.to}`)).join('\n');
return false; 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. */ /** 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 { export function withoutYes(ctx: Ctx): Ctx {
return { ...ctx, args: { ...ctx.args, has: (n: string) => n !== 'yes' && ctx.args.has(n) } }; 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');
}

View file

@ -85,11 +85,15 @@ export const TOOLS: ToolDef[] = [
props: { csv: { type: 'string', description: 'CSV path (default prices.csv in the config, else store/prices.csv)' }, store: { type: 'string', enum: ['apple', 'play'] }, only, all: { type: 'boolean', default: false, description: 'Also list unchanged rows' } }, props: { csv: { type: 'string', description: 'CSV path (default prices.csv in the config, else store/prices.csv)' }, store: { type: 'string', enum: ['apple', 'play'] }, only, all: { type: 'boolean', default: false, description: 'Also list unchanged rows' } },
args: i => [...opt('csv', i.csv), ...opt('store', i.store), ...opt('only', i.only), ...flag('all', i.all), '--dry'] }, args: i => [...opt('csv', i.csv), ...opt('store', i.store), ...opt('only', i.only), ...flag('all', i.all), '--dry'] },
{ name: 'prices_ppp', title: 'Purchasing power price sheet', command: ['prices', 'ppp'], write: true, network: true, idempotent: true, { name: 'prices_ppp', title: 'Purchasing power price sheet', command: ['prices', 'ppp'], write: true, network: true, idempotent: true,
description: 'Proposes per-region prices adjusted for purchasing power (bundled World Bank ICP PPP factors, CC BY 4.0, or the Big Mac index / a CSV), clamped to floor..ceiling of the US price and snapped to store price points, and writes a price sheet in the prices import format (default store/prices-ppp.csv) with a diff against the current store prices. Writes only that local file; nothing goes to a store (prices import does, after the owner confirms in a terminal).', description: 'Proposes per-region prices adjusted for purchasing power (bundled World Bank ICP PPP factors, CC BY 4.0, or the Big Mac index / a CSV), as a share of the US-equivalent price (VAT included, the current rate). Defaults: only lower prices, only in economies whose price level is at most 50 % of the US, World Bank high-income economies and mainland China keep the US-equivalent price, never above it; a top-10 revenue market that drops more than 20 % is warned about. Snapped to store price points; writes a price sheet in the prices import format (default store/prices-ppp.csv) with a diff against the current store prices. Writes only that local file; nothing goes to a store (prices import does, after the owner confirms in a terminal).',
props: { base: { type: 'number', minimum: 0.01, description: 'USD base price for every selected product (default: each product\'s usd)' }, source: { type: 'string', enum: ['worldbank', 'bigmac', 'file'] }, props: { base: { type: 'number', minimum: 0.01, description: 'USD base price for every selected product (default: each product\'s usd)' }, source: { type: 'string', enum: ['worldbank', 'bigmac', 'file'] },
store: { type: 'string', enum: ['apple', 'play'] }, only, floor: { type: 'number', minimum: 0.01, maximum: 5 }, ceiling: { type: 'number', minimum: 0.01, maximum: 5 }, store: { type: 'string', enum: ['apple', 'play'] }, only,
min_ratio: { type: 'number', minimum: 0.01, maximum: 5, description: 'Lowest share of the US-equivalent price (default 0.3)' }, max_ratio: { type: 'number', minimum: 0.01, maximum: 5, description: 'Highest share (default 1)' },
max_level: { type: 'number', minimum: 0.01, maximum: 5, description: 'Only economies with a price level at most this share of the US get a PPP price (default 0.5)' },
only_decreases: { type: 'boolean', default: true, description: 'Never raise a price above the current store price' }, include_high_income: { type: 'boolean', default: false, description: 'PPP prices for World Bank high-income economies too' },
offline: { type: 'boolean', default: false, description: 'No store reads: snap with the local ending rules only' } }, offline: { type: 'boolean', default: false, description: 'No store reads: snap with the local ending rules only' } },
args: i => [...opt('base', i.base), ...opt('source', i.source), ...opt('store', i.store), ...opt('only', i.only), ...opt('floor', i.floor), ...opt('ceiling', i.ceiling), ...flag('offline', i.offline)] }, args: i => [...opt('base', i.base), ...opt('source', i.source), ...opt('store', i.store), ...opt('only', i.only), ...opt('min-ratio', i.min_ratio), ...opt('max-ratio', i.max_ratio), ...opt('max-level', i.max_level),
...(i.only_decreases === false ? ['--no-only-decreases'] : []), ...flag('include-high-income', i.include_high_income), ...flag('offline', i.offline)] },
{ name: 'ads_apple_status', title: 'Apple Ads access', command: ['ads', 'apple', 'status'], write: false, network: true, { name: 'ads_apple_status', title: 'Apple Ads access', command: ['ads', 'apple', 'status'], write: false, network: true,
description: 'Read-only: does the Apple Ads API key work, which ad accounts and roles it has, and which one storebound uses.' }, description: 'Read-only: does the Apple Ads API key work, which ad accounts and roles it has, and which one storebound uses.' },
{ name: 'ads_apple_campaigns', title: 'Apple Ads campaigns', command: ['ads', 'apple', 'campaigns'], write: false, network: true, { name: 'ads_apple_campaigns', title: 'Apple Ads campaigns', command: ['ads', 'apple', 'campaigns'], write: false, network: true,

View file

@ -6,11 +6,12 @@
// (new options start as DRAFT) with purchaseOptions:batchUpdateStates. Idempotent. // (new options start as DRAFT) with purchaseOptions:batchUpdateStates. Idempotent.
// New products (with their first prices) and listing text changes are written directly. Activating the purchase // New products (with their first prices) and listing text changes are written directly. Activating the purchase
// option of an EXISTING product needs a confirmation (a terminal question or --yes). Changing what an existing product // option of an EXISTING product needs a confirmation (a terminal question or --yes). Changing what an existing product
// costs or where it is sold (regional prices, the price for other regions, dropped regions) is the owner's call: // costs or where it is sold (regional prices, the price for other regions, dropped regions) is the owner's call and
// --yes never confirms it (flows add --yes); it needs --allow-price-changes, the owner's answer in a terminal (no // needs --allow-price-changes, TTY or not: no terminal "y" and no --yes covers it (STAR DASH, 2026-10-14: creating new
// --yes) or the desktop owner action. Without that the run writes everything else, prints one "price change on <id> // products also changed live prices). With the flag it gets its own question listing each old -> new (the desktop
// (<from> -> <to>) needs the owner" line per product and exits 3 (declined), so a flow stops there. The dry run marks // owner action answers it). Without it the run writes everything else, prints one "price change on <id> (<from> ->
// those rows "owner". (lib/price-gate.ts) // <to>) needs the owner" line per product and exits 3 (declined), so a flow stops there. The dry run lists them in
// a "price changes on existing products (held)" section. (lib/price-gate.ts)
// Play has no non-renewing type: a `non_renewing` product is a plain one-time product here (your app consumes it // Play has no non-renewing type: a `non_renewing` product is a plain one-time product here (your app consumes it
// when the access ends). Products with `platforms` that leave out android are skipped. // when the access ends). Products with `platforms` that leave out android are skipped.
// --dry ends with the standard plan table (lib/dry.ts). After a real run the product list is read again: a product // --dry ends with the standard plan table (lib/dry.ts). After a real run the product list is read again: a product
@ -20,7 +21,7 @@ import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx'; import { confirm, need } from '../lib/ctx';
import { dryPlan } from '../lib/dry'; import { dryPlan } from '../lib/dry';
import { done, log, step, warn } from '../lib/log'; import { done, log, step, warn } from '../lib/log';
import { allowPriceChanges, type HeldPrice, holdPriceChanges } from '../lib/price-gate'; import { allowPriceChanges, type HeldPrice, heldSection, holdPriceChanges } from '../lib/price-gate';
import { LAG_HINT, reportMissing } from '../lib/store-lag'; import { LAG_HINT, reportMissing } from '../lib/store-lag';
import { fromMoney, type Money, playRegionConfigs, type RegionConfig, toMoney } from '../lib/money'; import { fromMoney, type Money, playRegionConfigs, type RegionConfig, toMoney } from '../lib/money';
import { playClient } from './client'; import { playClient } from './client';
@ -136,6 +137,8 @@ export async function run(ctx: Ctx): Promise<void> {
console.log(dryPlan('play products', plan.map(x => !x.changes.length ? { item: x.p.id, action: 'keep' } console.log(dryPlan('play products', plan.map(x => !x.changes.length ? { item: x.p.id, action: 'keep' }
: !x.cur ? { item: x.p.id, action: 'create', detail: `${x.regions} regions, ${x.listings} listings, USD ${x.p.usd}` } : !x.cur ? { item: x.p.id, action: 'create', detail: `${x.regions} regions, ${x.listings} listings, USD ${x.p.usd}` }
: { item: x.p.id, action: x.changes.some(c => c.owner) ? 'update (owner: price change, --allow-price-changes)' : 'update', detail: x.changes.map(c => c.text).join('; ') }))); : { item: x.p.id, action: x.changes.some(c => c.owner) ? 'update (owner: price change, --allow-price-changes)' : 'update', detail: x.changes.map(c => c.text).join('; ') })));
const h = heldSection(plan.filter(x => x.cur && x.changes.some(c => c.owner)).map(x => ({ id: x.p.id, changes: x.changes.flatMap(c => c.prices ?? []) })), ctx.args.has('allow-price-changes'));
if (h) console.log(h);
const absent = plan.filter(x => !x.cur).map(x => x.p.id); const absent = plan.filter(x => !x.cur).map(x => x.p.id);
if (absent.length) log('play', `not on Google Play: ${absent.join(', ')}. If you created them in the last minutes, ${LAG_HINT} before acting on this plan`); if (absent.length) log('play', `not on Google Play: ${absent.join(', ')}. If you created them in the last minutes, ${LAG_HINT} before acting on this plan`);
done('play', 'products checked (dry run, nothing written)'); done('play', 'products checked (dry run, nothing written)');
@ -144,7 +147,8 @@ export async function run(ctx: Ctx): Promise<void> {
// prices and regions of existing products: the owner's call (never --yes); activation of an existing one: confirmed // prices and regions of existing products: the owner's call (never --yes); activation of an existing one: confirmed
const priced = plan.filter(x => x.cur && x.changes.some(c => c.owner)); const priced = plan.filter(x => x.cur && x.changes.some(c => c.owner));
const priceOk = await allowPriceChanges(ctx, priced.length, `Change prices / regions of ${priced.length} existing product(s) on Google Play (${priced.map(x => x.p.id).join(', ')})? Buyers see new prices as soon as Play applies them.`); const priceOk = await allowPriceChanges(ctx, priced.map(x => ({ id: x.p.id, changes: x.changes.flatMap(c => c.prices ?? []) })),
`Change prices / regions of ${priced.length} existing product(s) on Google Play? Buyers see new prices as soon as Play applies them.`);
const optionOf = (x: (typeof plan)[number]) => x.cur?.purchaseOptions?.find(o => o.purchaseOptionId === OPTION); const optionOf = (x: (typeof plan)[number]) => x.cur?.purchaseOptions?.find(o => o.purchaseOptionId === OPTION);
// an existing product without the option gets it with the prices, so it waits for them // an existing product without the option gets it with the prices, so it waits for them
const activate = plan.filter(x => x.cur && optionOf(x)?.state !== 'ACTIVE' && (optionOf(x) || priceOk)); const activate = plan.filter(x => x.cur && optionOf(x)?.state !== 'ACTIVE' && (optionOf(x) || priceOk));

View file

@ -17,7 +17,7 @@ import { sellsOn } from '../lib/config';
import type { Ctx } from '../lib/ctx'; import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx'; import { confirm, need } from '../lib/ctx';
import { done, log, step, warn } from '../lib/log'; import { done, log, step, warn } from '../lib/log';
import { allowPriceChanges, type HeldPrice, holdPriceChanges } from '../lib/price-gate'; import { allowPriceChanges, type HeldPrice, heldSection, holdPriceChanges } from '../lib/price-gate';
import { type Money, toMoney } from '../lib/money'; import { type Money, toMoney } from '../lib/money';
import { import {
allSubscriptions, basePlanId, offerId, playOfferBody, playPatchSubscription, type PlaySubscription, playSubDiff, playSubRegions, allSubscriptions, basePlanId, offerId, playOfferBody, playPatchSubscription, type PlaySubscription, playSubDiff, playSubRegions,
@ -82,20 +82,24 @@ export async function run(ctx: Ctx): Promise<void> {
if (diff.priceChanges.length > 5) log(TAG, ` ... ${diff.priceChanges.length - 5} more`); if (diff.priceChanges.length > 5) log(TAG, ` ... ${diff.priceChanges.length - 5} more`);
work.push({ s, body, diff, version: conv.regionVersion.version, offerMissing, extraMissing }); work.push({ s, body, diff, version: conv.regionVersion.version, offerMissing, extraMissing });
} }
if (dry) { done(TAG, 'subscriptions planned (dry run, nothing written)'); return; } if (dry) {
const h = heldSection(work.filter(w => w.diff.basePlan === 'changed' && w.diff.priceChanges.length).map(w => ({ id: w.s.id, changes: w.diff.priceChanges.map(p => ({ region: p.regionCode, from: p.from, to: p.to })) })), ctx.args.has('allow-price-changes'));
if (h) console.log(h);
done(TAG, 'subscriptions planned (dry run, nothing written)'); return;
}
const todo = work.filter(w => w.diff.create || w.diff.listings.length || w.diff.basePlan !== 'same' || w.diff.activate || w.offerMissing || w.extraMissing.length); const todo = work.filter(w => w.diff.create || w.diff.listings.length || w.diff.basePlan !== 'same' || w.diff.activate || w.offerMissing || w.extraMissing.length);
if (!todo.length) { done(TAG, 'subscriptions up to date'); return; } if (!todo.length) { done(TAG, 'subscriptions up to date'); return; }
const yes = ctx.args.has('yes'); const yes = ctx.args.has('yes');
const priced = todo.filter(w => w.diff.basePlan === 'changed' && w.diff.priceChanges.length); const priced = todo.filter(w => w.diff.basePlan === 'changed' && w.diff.priceChanges.length);
let liveOk: boolean; // the terminal question covers creates and other updates; price changes of existing base plans need
// --allow-price-changes and their own question listing each old -> new (lib/price-gate.ts)
if (stdinIsTTY() && !yes && process.env.STOREBOUND_SERVE !== '1') { if (stdinIsTTY() && !yes && process.env.STOREBOUND_SERVE !== '1') {
if (!(await confirm(ctx, `Write ${todo.length} subscription(s) to Google Play? Activated base plans and offers can be bought as soon as the app is published; price changes apply to new subscribers.`))) return; if (!(await confirm(ctx, `Write ${todo.length} subscription(s) to Google Play? Activated base plans and offers can be bought as soon as the app is published. Price changes of existing base plans are not part of this answer (--allow-price-changes).`))) return;
liveOk = true;
} else {
liveOk = await allowPriceChanges(ctx, priced.length, `Change the base plan prices of ${priced.length} existing subscription(s) on Google Play (${priced.map(w => w.s.id).join(', ')})? New subscribers pay the new price.`);
if (!yes && !liveOk) log(TAG, 'not a terminal: writing what is new; price changes of existing base plans wait for the owner');
} }
const liveOk = await allowPriceChanges(ctx, priced.map(w => ({ id: w.s.id, changes: w.diff.priceChanges.map(p => ({ region: p.regionCode, from: p.from, to: p.to })) })),
`Change the base plan prices of ${priced.length} existing subscription(s) on Google Play? New subscribers pay the new price.`);
if (!liveOk && priced.length) log(TAG, 'writing what is new; price changes of existing base plans wait for the owner (--allow-price-changes)');
const held: HeldPrice[] = []; const held: HeldPrice[] = [];
for (const { s, body, diff, version, offerMissing, extraMissing } of todo) { for (const { s, body, diff, version, offerMissing, extraMissing } of todo) {

View file

@ -1,6 +1,12 @@
// Purchasing power parity price proposals: one price sheet for every product, store and region. // 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] // 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 // 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' // 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 // 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://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://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) // 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 { dirname, join } from 'node:path';
import worldbank from './data/ppp-worldbank.json' with { type: 'json' }; import worldbank from './data/ppp-worldbank.json' with { type: 'json' };
import { ascClient, type Asc, type J } from './apple/asc'; 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 { need } from './lib/ctx';
import { done, log, printJson, step, warn } from './lib/log'; import { done, log, printJson, step, warn } from './lib/log';
import { ALPHA3, toMoney } from './lib/money'; 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 { alpha2, diffPrices, diffTable, playPriceRows, type PriceRow } from './lib/prices';
import { playClient, type PlayClient } from './play/client'; import { playClient, type PlayClient } from './play/client';
import { appleCurrent, iapId, pages, playProduct } from './prices'; 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'); 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>(); const out = new Map<string, StoreRegion>();
for (let i = 0; i < territories.length; i += 10) { for (let i = 0; i < territories.length; i += 10) {
const batch = territories.slice(i, 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 region = alpha2(t);
const e: StoreRegion = out.get(region) ?? { currency: curr.get(t) ?? '', points: [] }; const e: StoreRegion = out.get(region) ?? { currency: curr.get(t) ?? '', points: [] };
e.points!.push(Number(p.attributes?.customerPrice)); 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); out.set(region, e);
} }
} }
return out; 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> { export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded; 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 source = a.get('source') ?? pc.source ?? 'worldbank';
const fileArg = a.get('file') ?? pc.file; const fileArg = a.get('file') ?? pc.file;
const ds = loadDataset(source, root, fileArg ? resolve(fileArg) : undefined); 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); const options = pppOptions(a, pc);
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 { minRatio: floor, maxRatio: ceiling } = options;
const baseArg = a.get('base') !== undefined ? a.num('base', 0) : undefined; 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'); if (baseArg !== undefined && !(baseArg > 0)) throw new Error('--base: a USD price, e.g. 4.99');
const only = a.list('only'); 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 c = online.apple ? await ascClient(root, config.apple!.bundleId, config.apple?.appId) : null;
const appId = c ? await c.app() : ''; const appId = c ? await c.app() : '';
const play: PlayClient | null = online.play ? await playClient(root, config.play!.packageName) : null; 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 rows: PppRow[] = [];
const kept: PppRow[] = [];
const current: PriceRow[] = []; const current: PriceRow[] = [];
const skipped: { product: string; store: string; region: string; why: string }[] = []; const skipped: { product: string; store: string; region: string; why: string }[] = [];
const offlineApple = new Set<string>(); const offlineApple = new Set<string>();
@ -138,7 +190,11 @@ export async function run(ctx: Ctx): Promise<void> {
else { else {
current.push(...(await appleCurrent(c, id, p.id, true)).rows); 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]!); 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) { 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`); 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)); 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) })); 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); 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); rows.push(...plan.rows);
kept.push(...plan.kept);
skipped.push(...plan.skipped.map(k => ({ product: p.id, store: s, ...k }))); 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 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 }; const sourceInfo = { name: ds.name, year: ds.year ?? null, retrieved: ds.retrieved, url: ds.url, license: ds.license };
if (!dry) { mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, csv); } if (!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')) { 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; return;
} }
if (dry) console.log(csv); if (dry) console.log(csv);
console.log(diffTable(diff)); console.log(diffTable(diff));
const clamped = rows.filter(r => r.clamped).length; const cut = rows.filter(r => r.share < 1).length, par = kept.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 ? ', ...' : ''})` : ''}`); 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)`); 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, `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'); 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');

View file

@ -9,10 +9,11 @@
// only, a future YYYY-MM-DD = scheduled change), kind (manual | auto; auto rows are Apple's equalized prices, exported // only, a future YYYY-MM-DD = scheduled change), kind (manual | auto; auto rows are Apple's equalized prices, exported
// for reference and ignored on import). // for reference and ignored on import).
// import: reads the current prices, prints the diff table, and (not --dry) asks before writing. A product that already // import: reads the current prices, prints the diff table, and (not --dry) asks before writing. A product that already
// has prices on a store is a price change: the owner's call. In a terminal (no --yes) the one question covers it; // has prices on a store is a price change: the owner's call, only with --allow-price-changes, TTY or not. The first
// --yes (also from `flow run --apply --yes`) only sets first prices and never changes existing ones, which need // prices question (a terminal "y" or --yes, also from `flow run --apply --yes`) never covers them; with the flag they
// --allow-price-changes: without it the run writes the first prices, prints "price change on <id> (<from> -> <to>) // get their own question listing each old -> new (lib/price-gate.ts). Without it the run writes the first prices,
// needs the owner" per product and exits 3 (declined). The dry run marks those pairs "owner". Apple: one new price // prints "price change on <id> (<from> -> <to>) needs the owner" per product and exits 3 (declined). The dry run
// shows them in its own "price changes on existing products (held)" section. Apple: one new price
// schedule per changed product (base territory + every manual price; territories without one are equalized by Apple; // schedule per changed product (base territory + every manual price; territories without one are equalized by Apple;
// an increase cannot be undone once it starts). Play: the purchase option's regional prices are replaced: CSV regions // an increase cannot be undone once it starts). Play: the purchase option's regional prices are replaced: CSV regions
// on top of the current ones; regions without a price get convertRegionPrices from the USD base (CSV US row, else the // on top of the current ones; regions without a price get convertRegionPrices from the USD base (CSV US row, else the
@ -22,7 +23,7 @@ import { ascClient, type Asc, type J } from './apple/asc';
import type { Ctx } from './lib/ctx'; import type { Ctx } from './lib/ctx';
import { confirm, need } from './lib/ctx'; import { confirm, need } from './lib/ctx';
import { done, log, printJson, step, warn } from './lib/log'; import { done, log, printJson, step, warn } from './lib/log';
import { allowPriceChanges, type HeldPrice, holdPriceChanges } from './lib/price-gate'; import { allowPriceChanges, type HeldPrice, heldSection, holdPriceChanges } from './lib/price-gate';
import { toMoney } from './lib/money'; import { toMoney } from './lib/money';
import { import {
appleSchedule, appleScheduleBody, applePriceRows, diffPrices, diffTable, parsePriceCsv, type PlayProduct, playPatchBody, playPriceRows, playRegions, appleSchedule, appleScheduleBody, applePriceRows, diffPrices, diffTable, parsePriceCsv, type PlayProduct, playPatchBody, playPriceRows, playRegions,
@ -30,7 +31,6 @@ import {
} from './lib/prices'; } from './lib/prices';
import { playClient, type PlayClient } from './play/client'; import { playClient, type PlayClient } from './play/client';
import { allSubscriptions, appleSubPriceRows, basePlanId, type PlaySubscription, playSubPriceRows } from './lib/subscriptions'; import { allSubscriptions, appleSubPriceRows, basePlanId, type PlaySubscription, playSubPriceRows } from './lib/subscriptions';
import { stdinIsTTY } from './lib/tty';
const TAG = 'prices'; const TAG = 'prices';
@ -153,22 +153,18 @@ export async function run(ctx: Ctx): Promise<void> {
const live = [...changed].filter(k => current.some(r => `${r.product}|${r.store}` === k && r.kind !== 'auto')); const live = [...changed].filter(k => current.some(r => `${r.product}|${r.store}` === k && r.kind !== 'auto'));
const first = [...changed].filter(k => !live.includes(k)); const first = [...changed].filter(k => !live.includes(k));
if (live.length) log(TAG, `owner: price change of ${live.length} existing product / store pair(s) (--allow-price-changes): ${live.map(k => k.replace('|', ' ')).join(', ')}`); if (live.length) log(TAG, `owner: price change of ${live.length} existing product / store pair(s) (--allow-price-changes): ${live.map(k => k.replace('|', ' ')).join(', ')}`);
if (dry) { done(TAG, 'dry run: nothing written'); return; } const liveHeld: HeldPrice[] = live.map(k => {
let liveOk: boolean;
if (stdinIsTTY() && !a.has('yes') && process.env.STOREBOUND_SERVE !== '1') {
if (!(await confirm(ctx, `Write the new prices for ${changed.size} product / store pair(s)? Users see them as soon as the stores apply them; an App Store price increase cannot be undone once it starts.`))) return;
liveOk = true;
} else {
if (first.length && !(await confirm(ctx, `Set the first prices of ${first.length} product / store pair(s) (${first.map(k => k.replace('|', ' ')).join(', ')})?`))) return;
liveOk = await allowPriceChanges(ctx, live.length, `Change the prices of ${live.length} existing product / store pair(s) (${live.map(k => k.replace('|', ' ')).join(', ')})? Users see them as soon as the stores apply them; an App Store price increase cannot be undone once it starts.`);
}
const held: HeldPrice[] = [];
if (!liveOk) for (const k of live) {
changed.delete(k);
const rows = diff.filter(d => `${d.product}|${d.store}` === k && d.change !== 'same'); const rows = diff.filter(d => `${d.product}|${d.store}` === k && d.change !== 'same');
held.push({ id: k.replace('|', ' '), changes: rows.length ? rows.map(d => ({ region: d.region, from: d.from === null ? '-' : `${d.currency} ${d.from}`, to: `${d.currency} ${d.to}${d.startDate ? ` from ${d.startDate}` : ''}` })) return { id: k.replace('|', ' '), changes: rows.length ? rows.map(d => ({ region: d.region, from: d.from === null ? '-' : `${d.currency} ${d.from}`, to: `${d.currency} ${d.to}${d.startDate ? ` from ${d.startDate}` : ''}` }))
: [{ region: 'every region', from: 'current', to: 'reconverted' }] }); : [{ region: 'every region', from: 'current', to: 'reconverted' }] };
} });
if (dry) { const h = heldSection(liveHeld, a.has('allow-price-changes')); if (h) console.log(h); done(TAG, 'dry run: nothing written'); return; }
// first prices: one confirmation (terminal or --yes). Price changes of existing products: never part of it, only
// with --allow-price-changes and their own question listing each old -> new (lib/price-gate.ts), TTY or not.
if (first.length && !(await confirm(ctx, `Set the first prices of ${first.length} product / store pair(s) (${first.map(k => k.replace('|', ' ')).join(', ')})?`))) return;
const liveOk = await allowPriceChanges(ctx, liveHeld, `Change the prices of ${live.length} existing product / store pair(s)? Users see them as soon as the stores apply them; an App Store price increase cannot be undone once it starts.`);
const held: HeldPrice[] = [];
if (!liveOk) { for (const k of live) changed.delete(k); held.push(...liveHeld); }
for (const p of products) { for (const p of products) {
const wanted = wantedAll.filter(r => r.product === p.id); const wanted = wantedAll.filter(r => r.product === p.id);

View file

@ -16,7 +16,7 @@
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'; import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
import { basename, join, relative, resolve } from 'node:path'; import { basename, join, relative, resolve } from 'node:path';
import type { Args } from './lib/args'; import type { Args } from './lib/args';
import { log } from './lib/log'; import { log, printJson } from './lib/log';
import { nativeIosPlugins } from './lib/node-packages'; import { nativeIosPlugins } from './lib/node-packages';
import { parsePlist, toXmlPlist, type PlistValue } from './lib/plist'; import { parsePlist, toXmlPlist, type PlistValue } from './lib/plist';
import { manifestFromBytes } from './lib/privacy-files'; import { manifestFromBytes } from './lib/privacy-files';
@ -127,7 +127,7 @@ export async function run(args: Args): Promise<void> {
return; return;
} }
const findings = evaluatePrivacyManifest({ appManifest, sourceHits, sdks, asksTracking: /NSUserTrackingUsageDescription/.test(infoPlist) }); const findings = evaluatePrivacyManifest({ appManifest, sourceHits, sdks, asksTracking: /NSUserTrackingUsageDescription/.test(infoPlist) });
if (args.has('json')) console.log(JSON.stringify({ appManifest, sdks, findings }, null, 2)); if (args.has('json')) printJson({ appManifest, sdks, findings });
else for (const f of findings) console.log(`${f.level.toUpperCase().padEnd(7)} ${f.msg}`); else for (const f of findings) console.log(`${f.level.toUpperCase().padEnd(7)} ${f.msg}`);
if (findings.some(f => f.level === 'blocker')) process.exitCode = 1; if (findings.some(f => f.level === 'blocker')) process.exitCode = 1;
} }

View file

@ -1201,7 +1201,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `inAppPurchaseOfferCodes_prices_getToManyRelationship` | GET | `/v1/inAppPurchaseOfferCodes/{id}/relationships/prices` | read | | | `inAppPurchaseOfferCodes_prices_getToManyRelationship` | GET | `/v1/inAppPurchaseOfferCodes/{id}/relationships/prices` | read | |
| `inAppPurchaseOfferCodes_updateInstance` | PATCH | `/v1/inAppPurchaseOfferCodes/{id}` | write (dry by default) | | | `inAppPurchaseOfferCodes_updateInstance` | PATCH | `/v1/inAppPurchaseOfferCodes/{id}` | write (dry by default) | |
| **inAppPurchasePricePoints** (2) | | | | | | **inAppPurchasePricePoints** (2) | | | | |
| `inAppPurchasePricePoints_equalizations_getToManyRelated` | GET | `/v1/inAppPurchasePricePoints/{id}/equalizations` | read | apple/offer-codes | | `inAppPurchasePricePoints_equalizations_getToManyRelated` | GET | `/v1/inAppPurchasePricePoints/{id}/equalizations` | read | apple/offer-codes, prices-ppp |
| `inAppPurchasePricePoints_equalizations_getToManyRelationship` | GET | `/v1/inAppPurchasePricePoints/{id}/relationships/equalizations` | read | | | `inAppPurchasePricePoints_equalizations_getToManyRelationship` | GET | `/v1/inAppPurchasePricePoints/{id}/relationships/equalizations` | read | |
| **inAppPurchasePriceSchedules** (8) | | | | | | **inAppPurchasePriceSchedules** (8) | | | | |
| `inAppPurchasePriceSchedules_automaticPrices_getToManyRelated` | GET | `/v1/inAppPurchasePriceSchedules/{id}/automaticPrices` | read | prices | | `inAppPurchasePriceSchedules_automaticPrices_getToManyRelated` | GET | `/v1/inAppPurchasePriceSchedules/{id}/automaticPrices` | read | prices |

View file

@ -9,6 +9,9 @@ fails without the fix.
| # | date | lesson | guard | | # | date | lesson | guard |
|---|---|---|---| |---|---|---|---|
| 63 | 2026-10 | STAR DASH: re-running `play products` / `prices import` to create 8 new IAPs also changed the live prices of existing products; `--only` was needed. In a terminal (no `--yes`) the one confirmation covered price changes too. | Price changes of existing products need `--allow-price-changes`, TTY or not; the terminal question covers creates and other updates only; with the flag a second question lists each old -> new (asked in a terminal even with `--yes`); dry runs show a "price changes on existing products (held)" section (`allowPriceChanges`, `heldSection` in `cli/lib/price-gate.ts`; `play products`, `prices import`, `play subscriptions`, `apple subscriptions`). `tests/price-gate.test.ts` (TTY and non-TTY). |
| 62 | 2026-10 | STAR DASH: `prices ppp` cut high-income markets (Germany EUR 3.59 instead of 5.99 for USD 4.99; UK, France, Japan cut too): it set base x PPP level x exchange rate, a net price, against VAT-inclusive store prices, and cut every region. | Par = the store's own conversion of the US price (Play convertRegionPrices, Apple equalizations; offline base x fx x (1 + VAT) rounded up); share = clamp(PPP level, 0.3, 1); defaults: only decreases, PPP only where the level is <= 0.5, World Bank high-income economies and CN at par, Play skips CN, top-10 revenue market drops > 20 % warned (from `.storebound/reports/` sales reports, else a static list); unchanged regions are left out of the sheet (`shareOf`, `offlinePar`, `planRows`, `topMarketDrops`, `topMarketsFromReports` in `cli/lib/ppp.ts`). `tests/ppp.test.ts`. |
| 61 | 2026-10 | STAR DASH: `prices ppp --json` printed log lines on stdout before the JSON, so parsing stdout failed. | `withJsonOutput` (`cli/lib/log.ts`) wraps every `--json` run in `cli/index.ts`: console.log / info go to stderr, `printJson` writes stdout, prompts use `promptOut()`; commands that printed JSON with console.log use `printJson`. `tests/json-stdout.test.ts` runs every command that reads `--json` against the fake stores and parses stdout. |
| 55 | 2026-10 | The Play feature graphic (1024 x 500) cut the phone off at the bottom: the game template's phone was 232 px wide at top 36 and rotated 7 deg, so its outline spanned about y 24..552 and the game's bottom bar was lost; the app template bled too, and `shots.feature` only took frame + palette. Nothing measured the device against the canvas. | `featureLayout` / `rotatedBox` (`cli/shots/lib.ts`) size the device so its rotated box fits the canvas with a 24 px margin (`shots.feature.fit` 'contain', the default; 'bleed' keeps the crop on purpose; `scale` 0.5-1.5), both templates use it. The templates record each device's measured layout box (`devices` in report.json) and `shots check` fails a feature graphic whose device leaves the canvas unless fit is 'bleed', and warns for a screenshot's front device off a side or the top (`deviceIssues`, `cli/shots/report.ts`). `tests/feature-fit.test.ts`. | | 55 | 2026-10 | The Play feature graphic (1024 x 500) cut the phone off at the bottom: the game template's phone was 232 px wide at top 36 and rotated 7 deg, so its outline spanned about y 24..552 and the game's bottom bar was lost; the app template bled too, and `shots.feature` only took frame + palette. Nothing measured the device against the canvas. | `featureLayout` / `rotatedBox` (`cli/shots/lib.ts`) size the device so its rotated box fits the canvas with a 24 px margin (`shots.feature.fit` 'contain', the default; 'bleed' keeps the crop on purpose; `scale` 0.5-1.5), both templates use it. The templates record each device's measured layout box (`devices` in report.json) and `shots check` fails a feature graphic whose device leaves the canvas unless fit is 'bleed', and warns for a screenshot's front device off a side or the top (`deviceIssues`, `cli/shots/report.ts`). `tests/feature-fit.test.ts`. |
| 54 | 2026-10 | The second `apple build` of a Capacitor game archived fine, then `xcodebuild -exportArchive` failed with "exportArchive Copy failed" (exit 70). The IDEDistribution log had the real cause: `rsync error: syntax or usage error (code 1)` in IDEDistributionCreateIPAStep, because a Homebrew rsync 3.x, installed and linked that day by another session's release tool, came first on PATH. A tool changed a global binary on a shared machine as a side effect. | `apple build` runs xcodebuild with Xcode's and Apple's tool folders first on PATH (`xcodePath`) and prints a note when a non-Apple rsync is first (`foreignRsync`); an export failure shows the error lines of the newest IDEDistribution*.log (`distributionErrors`, `cli/lib/xcode.ts`, `cli/apple/build.ts`). `storebound doctor` flags a non-Apple rsync first on PATH. `tools/desktop-release.ts publish` calls rsync by absolute path (`/usr/bin/rsync` without `--chmod`, modes set on the server; else the Homebrew keg), never from PATH (`publishSteps`). Rule: tools never install or relink global binaries as a side effect. `tests/apple-build-export.test.ts`. | | 54 | 2026-10 | The second `apple build` of a Capacitor game archived fine, then `xcodebuild -exportArchive` failed with "exportArchive Copy failed" (exit 70). The IDEDistribution log had the real cause: `rsync error: syntax or usage error (code 1)` in IDEDistributionCreateIPAStep, because a Homebrew rsync 3.x, installed and linked that day by another session's release tool, came first on PATH. A tool changed a global binary on a shared machine as a side effect. | `apple build` runs xcodebuild with Xcode's and Apple's tool folders first on PATH (`xcodePath`) and prints a note when a non-Apple rsync is first (`foreignRsync`); an export failure shows the error lines of the newest IDEDistribution*.log (`distributionErrors`, `cli/lib/xcode.ts`, `cli/apple/build.ts`). `storebound doctor` flags a non-Apple rsync first on PATH. `tools/desktop-release.ts publish` calls rsync by absolute path (`/usr/bin/rsync` without `--chmod`, modes set on the server; else the Homebrew keg), never from PATH (`publishSteps`). Rule: tools never install or relink global binaries as a side effect. `tests/apple-build-export.test.ts`. |
| 53 | 2026-10 | After that failed export, `apple upload build/ios/export/App.ipa --yes` uploaded nothing: it read the stale IPA of build 1, found build 1 already VALID on App Store Connect and exited 0 ("build 1 is VALID"), so `build && upload` looked green. The export also wrote into the same folder every time. | `apple build` exports each build into its own folder `build/ios/export/<version>-<build>/` (removed first) and exits non-zero on any export failure or a missing IPA (`exportIpa`). `apple upload` fails with exit 1 when the IPA's build number is already on App Store Connect ("this ipa is build 1, which is already on App Store Connect (VALID): did the last build fail?"); only `--allow-existing` re-checks it. `--build N` picks / requires that build's IPA; the release and beta flows use `apple upload --no-wait --build ${BUILD} --allow-existing`, so a resumed flow still passes and a stale IPA never does (`existingBuild`, `exportedIpas`, `cli/apple/upload.ts`; `tests/apple-build-export.test.ts`). | | 53 | 2026-10 | After that failed export, `apple upload build/ios/export/App.ipa --yes` uploaded nothing: it read the stale IPA of build 1, found build 1 already VALID on App Store Connect and exited 0 ("build 1 is VALID"), so `build && upload` looked green. The export also wrote into the same folder every time. | `apple build` exports each build into its own folder `build/ios/export/<version>-<build>/` (removed first) and exits non-zero on any export failure or a missing IPA (`exportIpa`). `apple upload` fails with exit 1 when the IPA's build number is already on App Store Connect ("this ipa is build 1, which is already on App Store Connect (VALID): did the last build fail?"); only `--allow-existing` re-checks it. `--build N` picks / requires that build's IPA; the release and beta flows use `apple upload --no-wait --build ${BUILD} --allow-existing`, so a resumed flow still passes and a stale IPA never does (`existingBuild`, `exportedIpas`, `cli/apple/upload.ts`; `tests/apple-build-export.test.ts`). |

View file

@ -158,17 +158,17 @@ export const DATA_SAFETY_CSV = [
/** Data safety CSV rows `storebound check` counts for the config. */ /** Data safety CSV rows `storebound check` counts for the config. */
export const DECL_TOTAL = 768; export const DECL_TOTAL = 768;
export const PRICE_ROWS = [ export const PRICE_ROWS = [
'com.example.app.no_ads,apple,AE,AED,6.99,,manual', 'com.example.app.no_ads,apple,AF,USD,0.89,,manual', 'com.example.app.no_ads,apple,AF,USD,0.89,,manual', 'com.example.app.no_ads,apple,AL,USD,0.99,,manual',
'com.example.app.no_ads,apple,AG,USD,1.99,,manual', 'com.example.app.no_ads,apple,AL,USD,0.99,,manual',
'com.example.app.no_ads,apple,AM,USD,0.99,,manual', 'com.example.app.no_ads,apple,AO,USD,0.99,,manual', 'com.example.app.no_ads,apple,AM,USD,0.99,,manual', 'com.example.app.no_ads,apple,AO,USD,0.99,,manual',
'com.example.app.no_ads,apple,AR,USD,0.99,,manual', 'com.example.app.no_ads,apple,AT,EUR,1.99,,manual', 'com.example.app.no_ads,apple,AR,USD,0.99,,manual', 'com.example.app.no_ads,apple,AZ,USD,0.89,,manual',
'com.example.app.no_ads,apple,AU,AUD,3.99,,manual', 'com.example.app.no_ads,apple,AZ,USD,0.89,,manual', 'com.example.app.no_ads,apple,BA,EUR,0.99,,manual', 'com.example.app.no_ads,apple,BF,USD,0.99,,manual',
'com.example.app.no_ads,apple,BA,EUR,0.99,,manual', 'com.example.app.no_ads,apple,BB,USD,2.99,,manual', 'com.example.app.no_ads,apple,BJ,USD,0.99,,manual', 'com.example.app.no_ads,apple,BO,USD,0.99,,manual',
'com.example.app.no_ads,apple,BE,EUR,1.99,,manual', 'com.example.app.no_ads,apple,BF,USD,0.99,,manual', 'com.example.app.no_ads,apple,BR,BRL,7.99,,manual', 'com.example.app.no_ads,apple,BT,USD,0.89,,manual',
'com.example.app.no_ads,apple,BG,EUR,0.99,,manual', 'com.example.app.no_ads,apple,BH,USD,0.99,,manual', 'com.example.app.no_ads,apple,BW,USD,0.99,,manual', 'com.example.app.no_ads,apple,BY,USD,0.89,,manual',
'com.example.app.no_ads,apple,BJ,USD,0.99,,manual', 'com.example.app.no_ads,apple,BM,USD,3.99,,manual', 'com.example.app.no_ads,apple,CD,USD,0.99,,manual', 'com.example.app.no_ads,apple,CG,USD,0.99,,manual',
'com.example.app.no_ads,apple,BN,USD,0.99,,manual', 'com.example.app.no_ads,apple,BO,USD,0.99,,manual', 'com.example.app.no_ads,apple,CI,USD,0.99,,manual', 'com.example.app.no_ads,apple,CM,USD,0.99,,manual',
'com.example.app.no_ads,apple,BR,BRL,7.99,,manual', 'com.example.app.no_ads,apple,BS,USD,2.99,,manual', 'com.example.app.no_ads,apple,CO,COP,5500,,manual', 'com.example.app.no_ads,apple,CV,USD,0.99,,manual',
'com.example.app.no_ads,apple,DM,USD,0.99,,manual', 'com.example.app.no_ads,apple,DO,USD,0.99,,manual',
]; ];
export const PRICE_CMD = 'prices ppp --offline --dry --store apple --only com.example.app.no_ads'; export const PRICE_CMD = 'prices ppp --offline --dry --store apple --only com.example.app.no_ads';
/** The cards that drop onto the pile, faster and faster. at = seconds into the scene; x / y = the card's centre /** The cards that drop onto the pile, faster and faster. at = seconds into the scene; x / y = the card's centre

View file

@ -1,6 +1,6 @@
--- ---
name: ppp-pricing name: ppp-pricing
description: Propose purchasing power parity (PPP) prices for a mobile app's or game's in-app products in every App Store territory and Google Play region with `storebound prices ppp` - World Bank ICP PPP factors (bundled, CC BY 4.0), The Economist Big Mac index (downloaded on the owner's machine) or the owner's own factor CSV, clamped to a floor and ceiling of the US price, snapped to real App Store price points and clean local Play prices, written as a price sheet that `storebound prices import` applies after its own diff and confirmation. Use when the user asks for regional pricing, PPP or purchasing power pricing, cheaper prices in emerging markets, localized price tiers, or "price by country". description: Propose purchasing power parity (PPP) prices for a mobile app's or game's in-app products in every App Store territory and Google Play region with `storebound prices ppp` - World Bank ICP PPP factors (bundled, CC BY 4.0), The Economist Big Mac index (downloaded on the owner's machine) or the owner's own factor CSV, applied to the store's own US-equivalent price (VAT included) only in lower-income economies and never upward by default, snapped to real App Store price points and clean local Play prices, written as a price sheet that `storebound prices import` applies after its own diff and confirmation. Use when the user asks for regional pricing, PPP or purchasing power pricing, cheaper prices in emerging markets, localized price tiers, or "price by country".
--- ---
# PPP pricing (proposal sheet, both stores) # PPP pricing (proposal sheet, both stores)
@ -9,15 +9,31 @@ CLI: `storebound` (or `bunx storebound`, or `bun <toolchain>/cli/index.ts`). Thi
CSV and prints a diff; nothing reaches a store until the owner runs `storebound prices import` and confirms there. CSV and prints a diff; nothing reaches a store until the owner runs `storebound prices import` and confirms there.
## What it computes ## What it computes
Per product, store and region: Like with like: store prices include VAT and follow today's exchange rate, the US price excludes sales tax, and the
- `usd = base x PPP factor / exchange rate` (the PPP price, back in US dollars at the market rate). PPP price level compares net prices. So the proposal starts from the store's own conversion of the US price and
- Clamped to `floor..ceiling` of the base (defaults 0.3 and 1.2): no region below 30 % or above 120 % of the US price. scales it by the price level. Per product, store and region:
- Converted to the currency the store uses there (some App Store storefronts are priced in USD or EUR) and snapped: - `level = PPP factor / exchange rate` (same year): the region's price level against the US.
- App Store, online: the nearest real price point of that product in that territory (Apple takes nothing else). - `par` = the store's US-equivalent price there: Play `convertRegionPrices`, Apple the equalizations of the USA price
- Google Play, and the App Store offline: a clean local ending (x.99, 0.x9, whole numbers ending in 9; round point (both VAT included, today's rate). Offline: `base x rate x (1 + VAT)` rounded up to a clean price (standard
numbers for JPY, KRW, VND, IDR, HUF, CLP, TWD and every value over 1000). Snapping can move a price a little past VAT rates in `cli/lib/ppp.ts`; the dataset's rate stands in for today's).
the floor or ceiling. - `share`:
- The US row is always the base itself. - 1 (par) for the US, World Bank high-income economies (unless `--include-high-income`), mainland China, and every
economy whose price level is above `--max-level` (default 0.5: only economies at or below half the US price
level get a PPP price);
- else `clamp(level, --min-ratio 0.3, --max-ratio 1)`: never below 30 % and never above the US-equivalent price.
- `price = snap(par x share)`; with `--only-decreases` (the default; `--no-only-decreases` turns it off) never above
the store's current price.
- Snapping: App Store online, the nearest real price point of that product in that territory (Apple takes nothing
else); Google Play and the App Store offline, a clean local ending (x.99, 0.x9, whole numbers ending in 9; round
numbers for JPY, KRW, VND, IDR, HUF, CLP, TWD and every value over 1000).
- China: Google Play is not available in mainland China (no Play rows); on the App Store CN keeps the store's price.
- Top markets: a top-10 revenue market (from the sales reports in `.storebound/reports/`, `storebound reports
apple-sales` / `play-sales`, else US JP CN KR GB DE FR CA AU TW) that would drop more than 20 % is warned about.
- Only regions whose price changes go into the sheet (`rows`); regions that keep their price are listed as `kept`, so
`prices import` leaves the store's own price there.
Example (bundled World Bank data, USD 4.99, defaults): Germany EUR 5.99 (high-income, par; the old method gave EUR
3.59), UK, France, Japan at par; India and Turkey drop (price levels 0.23 and 0.41); Mexico stays (level above 0.5).
## Data sources (the owner picks one) ## Data sources (the owner picks one)
- `worldbank` (default, bundled `cli/data/ppp-worldbank.json`): World Bank PA.NUS.PPP (PPP conversion factor, GDP) - `worldbank` (default, bundled `cli/data/ppp-worldbank.json`): World Bank PA.NUS.PPP (PPP conversion factor, GDP)
@ -28,17 +44,20 @@ Per product, store and region:
(writes `.storebound/ppp-bigmac.json`, gitignored). (writes `.storebound/ppp-bigmac.json`, gitignored).
- `file`: the owner's own CSV, `region,factor,currency,fx` (`fx` optional: then the bundled rate when the currency - `file`: the owner's own CSV, `region,factor,currency,fx` (`fx` optional: then the bundled rate when the currency
matches). `--file <csv>` or `prices.ppp.file` in the config. matches). `--file <csv>` or `prices.ppp.file` in the config.
Config (optional): `prices: { ppp: { source, file, floor, ceiling, out } }`. Config (optional): `prices: { ppp: { source, file, minRatio, maxRatio, maxLevel, onlyDecreases, includeHighIncome, out } }`
(`floor` / `ceiling` are the old names of `minRatio` / `maxRatio`).
## Steps ## Steps
1. `storebound check`. Products must be in `products[]` with a `usd` base. For real App Store price points the 1. `storebound check`. Products must be in `products[]` with a `usd` base. For real App Store price points the
products must exist on App Store Connect (`storebound apple iap`); otherwise those rows are offline proposals. products must exist on App Store Connect (`storebound apple iap`); otherwise those rows are offline proposals.
2. Dry run and show it to the owner: 2. Dry run and show it to the owner:
`storebound prices ppp --dry` (all products; `--only id1,id2`, `--store apple|play`, `--base 4.99` to try another `storebound prices ppp --dry` (all products; `--only id1,id2`, `--store apple|play`, `--base 4.99` to try another
US price, `--floor 0.4 --ceiling 1.1`, `--source bigmac|file`, `--offline` to skip every store read). US price, `--min-ratio 0.4`, `--max-level 0.6`, `--include-high-income`, `--source bigmac|file`, `--offline` to
skip every store read). Change a default only when the owner asks for it.
It prints the sheet, the diff against the stores' current prices (offline: every row "new") and the regions It prints the sheet, the diff against the stores' current prices (offline: every row "new") and the regions
without a proposal. without a proposal.
3. The owner decides: base price, floor and ceiling, and which regions to keep. Then write the sheet: 3. The owner decides: base price, the rules above if not the defaults, and which regions to keep. Name every top-market
warning to the owner. Then write the sheet:
`storebound prices ppp` (default `store/prices-ppp.csv`, `--out` to change it). `storebound prices ppp` (default `store/prices-ppp.csv`, `--out` to change it).
4. The owner reviews and edits the CSV (delete rows to keep a store's own conversion, round to a house price ladder). 4. The owner reviews and edits the CSV (delete rows to keep a store's own conversion, round to a house price ladder).
5. Apply through the normal price sheet flow: `storebound prices import --csv store/prices-ppp.csv --dry`, then 5. Apply through the normal price sheet flow: `storebound prices import --csv store/prices-ppp.csv --dry`, then
@ -50,9 +69,8 @@ Config (optional): `prices: { ppp: { source, file, floor, ceiling, out } }`.
- PPP is a proposal, not a rule. Cheaper prices can cannibalize, invite VPN arbitrage on web checkouts and change - PPP is a proposal, not a rule. Cheaper prices can cannibalize, invite VPN arbitrage on web checkouts and change
revenue; the owner decides region by region. revenue; the owner decides region by region.
- Taxes: App Store and Play customer prices are tax-inclusive in most regions (EU VAT, UK VAT, many others), while - Taxes: App Store and Play customer prices are tax-inclusive in most regions (EU VAT, UK VAT, many others), while
the US base is tax-exclusive. PPP factors describe consumer price levels including taxes, so the sheet is a the US base is tax-exclusive. That is why par is the store's own (tax-inclusive) conversion and the PPP level only
customer price; proceeds per region differ by the local tax. Play's convertRegionPrices also returns tax-inclusive scales it; never compare a PPP price in dollars with a local store price directly.
prices.
- Offline values: Apple only accepts its own price points and its own storefront currency (USD or EUR in many - Offline values: Apple only accepts its own price points and its own storefront currency (USD or EUR in many
smaller storefronts). An offline sheet is a starting point; `prices import --dry` shows what does not fit. smaller storefronts). An offline sheet is a starting point; `prices import --dry` shows what does not fit.
- An App Store price increase cannot be undone once it starts. - An App Store price increase cannot be undone once it starts.
@ -60,9 +78,11 @@ Config (optional): `prices: { ppp: { source, file, floor, ceiling, out } }`.
with `bun tools/update-ppp.ts` (`--check` prints its age). with `bun tools/update-ppp.ts` (`--check` prints its age).
## Output ## Output
`--json` prints `{ source: {name, year, retrieved, url, license}, base: {product: usd}, floor, ceiling, online: `--json` prints only JSON on stdout (logs on stderr): `{ source: {name, year, retrieved, url, license}, base:
{apple, play}, rows: [{product, store, region, currency, price, factor, fx, usdEquivalent, clamped, snappedFrom, {product: usd}, floor, ceiling, options, online: {apple, play}, rows: [{product, store, region, currency, price,
snap}], skipped: [{product, store, region, why}], diff, out, written }`. Use `--json --dry` for a read-only run. factor, fx, level, par, parFrom, share, reason, current, usdEquivalent, snappedFrom, snap}], kept: [same shape],
skipped: [{product, store, region, why}], diff, topMarkets: {markets, from, drops}, out, written }`. Use
`--json --dry` for a read-only run.
## Out of scope ## Out of scope
- Writing prices to a store (that is `storebound prices import`, after the owner's yes). - Writing prices to a store (that is `storebound prices import`, after the owner's yes).

View file

@ -33,10 +33,12 @@ Auto-renewable subscriptions are a separate list (`subscriptions`, `store-subscr
New products (with their first prices) and listing text go straight through; `--yes` confirms activating the New products (with their first prices) and listing text go straight through; `--yes` confirms activating the
purchase option of an existing product. Price or region changes of an EXISTING product are the owner's decision purchase option of an existing product. Price or region changes of an EXISTING product are the owner's decision
and `--yes` never confirms them (also not the `--yes` that `flow run --apply --yes` adds): the dry run marks those and `--yes` never confirms them (also not the `--yes` that `flow run --apply --yes` adds): the dry run marks those
rows `owner`, and a real run writes everything else, prints `price change on <id> (<from> -> <to>) needs the rows `owner` and lists them in a "price changes on existing products (held)" section, and a real run writes
owner: re-run with --allow-price-changes` and exits 3 (declined; a flow stops there). After the owner's yes in everything else, prints `price change on <id> (<from> -> <to>) needs the owner: re-run with
chat for exactly that diff, run `storebound play products --allow-price-changes`; the owner can also answer the --allow-price-changes` and exits 3 (declined; a flow stops there). A terminal "y" never covers them either: only
question in a terminal (no `--yes`) or use the desktop Prices "Apply" (owner action). `--allow-price-changes` does, and then a second question lists each old -> new. So adding new products to the
config and re-running is safe without `--only`. After the owner's yes in chat for exactly that diff, run
`storebound play products --yes --allow-price-changes`, or the owner uses the desktop Prices "Apply" (owner action).
3. Verify: `storebound play status` lists every product with `buy:ACTIVE`. 3. Verify: `storebound play status` lists every product with `buy:ACTIVE`.
4. Test: Play Console > Settings > License testing: add tester Gmail accounts; install from the internal testing 4. Test: Play Console > Settings > License testing: add tester Gmail accounts; install from the internal testing
link; purchases are free test orders. link; purchases are free test orders.

View file

@ -712,3 +712,31 @@ that fails without the fix (see CONTRIBUTING.md).
one owner question list. Remove an area from `unanswered` only after the owner answered it. one owner question list. Remove an area from `unanswered` only after the owner answered it.
- **Guard:** `cli/lib/template-answers.ts`, `cli/lib/onboard-declarations.ts`, the `template-answers` scan rule, - **Guard:** `cli/lib/template-answers.ts`, `cli/lib/onboard-declarations.ts`, the `template-answers` scan rule,
`tests/template-answers.test.ts` (init blocks, data-safety --dry refuses, onboard on auth code without an ad SDK). `tests/template-answers.test.ts` (init blocks, data-safety --dry refuses, onboard on auth code without an ad SDK).
## 61. `prices ppp --json` printed log lines on stdout before the JSON
- **Do:** with `--json`, read stdout as one JSON document and stderr as the log. Every command run with `--json` goes
through `withJsonOutput` (cli/lib/log.ts, wrapped in cli/index.ts): log / done / tables go to stderr, `printJson` is
the only writer to stdout, prompts go to stderr. Never print a result with `console.log(JSON.stringify(...))`.
- **Guard:** `tests/json-stdout.test.ts` runs every command whose module reads `--json` against the fake stores and
`JSON.parse`s its stdout, plus one real CLI run (`prices ppp --offline --dry --json`).
## 62. PPP prices cut Germany to EUR 3.59 for a USD 4.99 product (store price EUR 5.99)
- **Do:** trust `prices ppp` defaults: only lower prices, only economies with a price level at most 50 % of the US,
World Bank high-income economies and mainland China keep the store's own US-equivalent price, never above it; a
top-10 revenue market dropping more than 20 % is warned about. The old math set a net price (base x PPP level x
exchange rate) against VAT-inclusive store prices and cut every region. Now par = the store's own conversion (Play
convertRegionPrices, Apple equalizations; offline base x fx x (1 + VAT), rounded up), share = clamp(level, 0.3, 1).
Regions that keep their price are left out of the sheet (`kept` in `--json`), so `prices import` leaves the store's
price there. `--include-high-income`, `--max-level`, `--min-ratio` / `--max-ratio`, `--no-only-decreases` widen it,
only after the owner asks.
- **Guard:** `shareOf`, `offlinePar`, `planRows`, `topMarketDrops` (cli/lib/ppp.ts), `tests/ppp.test.ts` (Germany
keeps EUR 5.99, India and Turkey drop, CN skipped on Play).
## 63. Creating new IAPs also changed live prices of existing ones (a terminal "y" covered them)
- **Do:** never rely on a confirmation for price changes. Creating products never changes an existing product's price
unless `--allow-price-changes` is passed, TTY or not; the terminal question of `play products`, `prices import`,
`play subscriptions` and `apple subscriptions` covers creates and other updates only. With the flag, price changes
get their own question listing each old -> new (in a terminal even with `--yes`). Dry runs list them in a separate
"price changes on existing products (held)" section. `apple iap` never changes an existing schedule.
- **Guard:** `allowPriceChanges` / `heldSection` (cli/lib/price-gate.ts), `tests/price-gate.test.ts` (TTY and non-TTY:
a "y" creates the new product and holds the live price; the flag's question names each change).

100
tests/json-stdout.test.ts Normal file
View file

@ -0,0 +1,100 @@
// `--json` runs write only the JSON to stdout; every log, step, table and warning line goes to stderr (lib/log.ts
// withJsonOutput, wrapped around every command by index.ts). STAR DASH, 2026-10-14: `prices ppp --json` printed log
// lines on stdout before the JSON, so `JSON.parse(stdout)` failed. This runs every command whose module reads --json
// against the fake stores, in a temp project and HOME, and requires stdout to be empty (the command failed or wrote
// nothing) or exactly one JSON document.
import { afterAll, beforeAll, describe, expect, spyOn, test } from 'bun:test';
import * as rt from '../cli/lib/rt';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import type { ShipConfig } from '../cli/lib/config';
import { parseArgs } from '../cli/lib/args';
import { withJsonOutput } from '../cli/lib/log';
import { COMMANDS, PLAIN } from '../cli/commands';
import { ascEnv, BASE, capture, playEnv, project, restore } from './helpers/store-mock';
import { installStores, uninstallStores } from './helpers/fake-stores';
const CLI = resolve(import.meta.dir, '../cli');
/** Never run here: long-lived servers, and the ones that install or open things on the machine. */
const SKIP = new Set(['mcp', 'serve', 'install', 'desktop', 'init', 'flow', 'ws']);
/** Commands that need a subcommand (or arguments) to reach their --json output. */
const ARGV: Record<string, string[]> = {
'prices': ['ppp', '--offline'], 'api': ['list'], 'accounts': ['list'], 'release': ['status'], 'apps': ['folders'],
'ads': ['scan'], 'reviews': ['list'], 'health': ['status'],
};
/** Ones that must print JSON here (the report's command and a few plain readers). */
const MUST = ['prices', 'inspect', 'doctor', 'api'];
/** command key -> module source (the `import('./x')` of cli/commands.ts), for the commands that read --json. */
function jsonCommands(): string[] {
const src = readFileSync(join(CLI, 'commands.ts'), 'utf8');
const out: string[] = [];
for (const m of src.matchAll(/^ {2}'([a-z][a-z0-9 -]*)': \[.*?import\('(\.\/[^']+)'\)/gm)) {
const key = m[1]!;
if (SKIP.has(key)) continue;
let file = join(CLI, m[2]!);
try { file = readFileSync(file + '.ts', 'utf8'); } catch { try { file = readFileSync(join(file, 'index.ts'), 'utf8'); } catch { continue; } }
if (/has\('json'\)|get\('json'\)|'json'\)/.test(file)) out.push(key);
}
return out;
}
const CFG: ShipConfig = { ...BASE, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }],
products: [{ id: 'com.example.notes.pro', type: 'non_consumable', usd: 4.99, listings: { en: { title: 'Pro', description: 'Every feature.' } } }] };
let home = '', cwd = '';
const saved = { home: process.env.HOME, cwd: process.cwd() };
// retries and store-lag waits must not sleep here: the doubles answer at once
let sleep: { mockRestore: () => void } | undefined;
beforeAll(() => { sleep = spyOn(rt, 'sleep').mockImplementation(async () => {}); ascEnv(); playEnv(); home = mkdtempSync(join(tmpdir(), 'sb-json-home-')); process.env.HOME = home; });
afterAll(() => { sleep?.mockRestore(); restore(); process.env.HOME = saved.home; process.chdir(saved.cwd); });
describe('--json: stdout is only the JSON', () => {
const keys = jsonCommands();
test('finds the commands that read --json', () => {
expect(keys.length).toBeGreaterThan(20);
for (const k of MUST) expect(keys).toContain(k);
});
const printed: string[] = [];
for (const key of keys) {
test(`storebound ${key} --json`, async () => {
const p = project(CFG);
cwd = p.root; process.chdir(cwd);
installStores();
const argv = [...(ARGV[key] ?? []), '--json'];
const args = parseArgs(argv);
const r = await capture(() => withJsonOutput(async () => {
if (PLAIN[key]) await (await PLAIN[key]![0]()).run(args);
else await (await COMMANDS[key]![0]()).run({ loaded: p.loaded, args });
})).catch(e => ({ out: '', err: String(e), exit: 1 }));
uninstallStores();
process.chdir(saved.cwd);
const out = r.out.trim();
if (!out) return;
let ok = true;
try { JSON.parse(out); } catch { ok = false; }
if (!ok) throw new Error(`storebound ${key} ${argv.join(' ')}: stdout is not one JSON document:\n${out.slice(0, 600)}`);
printed.push(key);
}, 20_000);
}
test('the report\'s commands printed JSON', () => {
for (const k of MUST) expect(printed).toContain(k);
});
});
describe('--json through the real CLI (index.ts wraps every command)', () => {
test('prices ppp --offline --json --dry: stdout parses, the log lines are on stderr', () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-json-cli-'));
writeFileSync(join(dir, 'storebound.config.ts'), `export default ${JSON.stringify(CFG)};\n`);
const r = Bun.spawnSync([process.execPath, join(CLI, 'index.ts'), 'prices', 'ppp', '--offline', '--dry', '--json'],
{ cwd: dir, env: { PATH: process.env.PATH ?? '', HOME: dir, STOREBOUND_NO_NETWORK: '1' }, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
const out = r.stdout.toString(), err = r.stderr.toString();
expect(r.exitCode).toBe(0);
const j = JSON.parse(out) as { rows: unknown[] };
expect(j.rows.length).toBeGreaterThan(50);
expect(err).toContain('[ppp]');
});
});

View file

@ -436,7 +436,9 @@ describe('play products: price and region changes of existing products are the o
process.env.STOREBOUND_SERVE = '1'; process.env.STOREBOUND_SERVE = '1';
expect((await runWith(over, ['--yes'])).writes.filter(w => w.includes('no_ads'))).toEqual([]); expect((await runWith(over, ['--yes'])).writes.filter(w => w.includes('no_ads'))).toEqual([]);
process.env.STOREBOUND_OWNER_CONFIRMED = '1'; process.env.STOREBOUND_OWNER_CONFIRMED = '1';
expect((await runWith(over)).writes.filter(w => w.includes('no_ads'))).toEqual(['PATCH /onetimeproducts/com.example.game.no_ads?allowMissing=true&updateMask=listings,purchaseOptions']); // serve adds --allow-price-changes to the owner action's real run (serve.ts OWNER_REAL_ONLY); without it nothing changes
expect((await runWith(over)).writes.filter(w => w.includes('no_ads'))).toEqual([]);
expect((await runWith(over, ['--allow-price-changes'])).writes.filter(w => w.includes('no_ads'))).toEqual(['PATCH /onetimeproducts/com.example.game.no_ads?allowMissing=true&updateMask=listings,purchaseOptions']);
}); });
test('nothing to change: no write for the existing product', async () => { test('nothing to change: no write for the existing product', async () => {

View file

@ -6,8 +6,8 @@ import bundledJson from '../cli/data/ppp-worldbank.json';
import { parseArgs } from '../cli/lib/args'; import { parseArgs } from '../cli/lib/args';
import { ALPHA3 } from '../cli/lib/money'; import { ALPHA3 } from '../cli/lib/money';
import { import {
appleCurrencyHint, bigMacDataset, CURRENCY, datasetJson, datasetProblems, fxOf, parseFactorCsv, planRows, type PppDataset, pppCsv, propose, snapApple, appleCurrencyHint, bigMacDataset, CURRENCY, datasetJson, datasetProblems, fxOf, HIGH_INCOME, offlinePar, parseFactorCsv, planRows, PPP_DEFAULTS, type PppDataset,
snapEnding, worldBankDataset, type PppOptions, pppCsv, shareOf, snapApple, snapEnding, snapUp, TOP_MARKETS, topMarketDrops, topMarketsFromReports, VAT, worldBankDataset,
} from '../cli/lib/ppp'; } from '../cli/lib/ppp';
import { parsePriceCsv } from '../cli/lib/prices'; import { parsePriceCsv } from '../cli/lib/prices';
import { applePoints, run } from '../cli/prices-ppp'; import { applePoints, run } from '../cli/prices-ppp';
@ -23,20 +23,32 @@ const ds: PppDataset = {
CH: { ppp: 1.2, fx: 0.8, year: 2025, currency: 'CHF' }, CH: { ppp: 1.2, fx: 0.8, year: 2025, currency: 'CHF' },
JP: { ppp: 97, fx: 150, year: 2025, currency: 'JPY' }, JP: { ppp: 97, fx: 150, year: 2025, currency: 'JPY' },
BA: { ppp: 0.7, fx: 1.75, year: 2025, currency: 'BAM' }, BA: { ppp: 0.7, fx: 1.75, year: 2025, currency: 'BAM' },
MX: { ppp: 10, fx: 18, year: 2025, currency: 'MXN' },
CN: { ppp: 3.4, fx: 7.2, year: 2025, currency: 'CNY' },
AF: { ppp: 13, year: 2024, currency: 'AFN' }, AF: { ppp: 13, year: 2024, currency: 'AFN' },
}, },
}; };
const OPEN: PppOptions = { ...PPP_DEFAULTS, maxRatio: 1.2, maxLevel: 5, onlyDecreases: false, includeHighIncome: true };
describe('ppp math', () => { describe('ppp math', () => {
test('usd = base x factor / fx, clamped to floor..ceiling', () => { test('share of par: level = factor / fx, clamped to min..max ratio; defaults keep high-income, CN and levels above 0.5 at par', () => {
expect(propose(10, 0.72, 0.9, 0.3, 1.2)).toMatchObject({ clamped: null }); expect(shareOf('US', 1, PPP_DEFAULTS)).toEqual({ share: 1, reason: 'base' });
expect(propose(10, 0.72, 0.9, 0.3, 1.2).usd).toBeCloseTo(8, 9); expect(shareOf('DE', 0.8, PPP_DEFAULTS)).toEqual({ share: 1, reason: 'high-income' });
const lo = propose(10, 20, 87, 0.3, 1.2); expect(shareOf('CN', 0.47, PPP_DEFAULTS)).toEqual({ share: 1, reason: 'china' });
expect(lo.clamped).toBe('floor'); expect(shareOf('MX', 0.55, PPP_DEFAULTS)).toEqual({ share: 1, reason: 'above-level' });
expect(lo.usd).toBe(3); expect(shareOf('TR', 0.41, PPP_DEFAULTS)).toEqual({ share: 0.41, reason: 'ppp' });
expect(lo.usdRaw).toBeCloseTo(2.299, 3); expect(shareOf('IN', 0.23, PPP_DEFAULTS)).toEqual({ share: 0.3, reason: 'min-ratio' });
expect(propose(10, 1.2, 0.8, 0.3, 1.2)).toMatchObject({ usd: 12, clamped: 'ceiling' }); expect(shareOf('DE', 0.8, OPEN)).toEqual({ share: 0.8, reason: 'ppp' });
expect(() => propose(10, 0, 1, 0.3, 1.2)).toThrow(); expect(shareOf('CH', 1.5, OPEN)).toEqual({ share: 1.2, reason: 'max-ratio' });
expect(shareOf('AF', null, PPP_DEFAULTS)).toEqual({ share: 1, reason: 'no-ppp-data' });
expect(HIGH_INCOME.has('DE') && HIGH_INCOME.has('GB') && HIGH_INCOME.has('JP') && !HIGH_INCOME.has('IN') && !HIGH_INCOME.has('CN')).toBe(true);
});
test('offline par: US price x exchange rate x (1 + VAT): store prices include VAT, the US price does not', () => {
expect(offlinePar(4.99, 'DE', 'EUR', ds)).toBeCloseTo(4.99 * 0.9 * 1.19, 6);
expect(offlinePar(4.99, 'US', 'USD', ds)).toBe(4.99);
expect(offlinePar(4.99, 'XX', 'XYZ', ds)).toBeNull();
expect(VAT.US).toBeUndefined();
}); });
test('fxOf: USD 1, EUR the euro rate, else a country with that currency', () => { test('fxOf: USD 1, EUR the euro rate, else a country with that currency', () => {
expect(fxOf(ds, 'USD')).toBe(1); expect(fxOf(ds, 'USD')).toBe(1);
@ -67,6 +79,13 @@ describe('snapping', () => {
expect(snapEnding(923, 'HUF')).toBe(920); expect(snapEnding(923, 'HUF')).toBe(920);
expect(snapEnding(1234.5, 'PKR')).toBe(1200); expect(snapEnding(1234.5, 'PKR')).toBe(1200);
}); });
test('par rounds up to the next clean price (stores round their own conversions up)', () => {
expect(snapUp(5.26, 'EUR')).toBe(5.99);
expect(snapUp(4.99, 'EUR')).toBe(4.99);
expect(snapUp(821.5, 'JPY')).toBe(830);
expect(snapUp(111.35, 'MXN')).toBe(119);
expect(snapUp(0.31, 'GBP')).toBe(0.39);
});
test('Apple: nearest real price point', () => { test('Apple: nearest real price point', () => {
expect(snapApple([49.99, 59.99, 64.99, 69.99], 62)).toBe(59.99); expect(snapApple([49.99, 59.99, 64.99, 69.99], 62)).toBe(59.99);
expect(snapApple([], 62)).toBeNull(); expect(snapApple([], 62)).toBeNull();
@ -74,47 +93,84 @@ describe('snapping', () => {
}); });
describe('planRows', () => { describe('planRows', () => {
test('offline Play: local currency, US row is the base, clamp flags, skipped regions', () => { test('defaults: Germany stays near EUR 5.99 for USD 4.99, India and Turkey drop, high-income and price levels above 0.5 keep par', () => {
const { rows, skipped } = planRows({ product: P, base: 4.99, store: 'play', dataset: ds, floor: 0.3, ceiling: 1.2 }); const { rows, kept, skipped } = planRows({ product: P, base: 4.99, store: 'play', dataset: ds, options: PPP_DEFAULTS });
const by = new Map(rows.map(r => [r.region, r] as const)); const by = new Map([...rows, ...kept].map(r => [r.region, r] as const));
expect(rows.map(r => r.region)).toEqual(['BA', 'IN', 'TR', 'US']);
for (const r of ['CH', 'DE', 'FR', 'JP', 'MX']) expect(kept.map(k => k.region)).toContain(r);
expect(by.get('MX')).toMatchObject({ reason: 'above-level', share: 1 });
expect(by.get('US')).toMatchObject({ currency: 'USD', price: 4.99, snap: 'base' }); expect(by.get('US')).toMatchObject({ currency: 'USD', price: 4.99, snap: 'base' });
expect(by.get('DE')).toMatchObject({ currency: 'EUR', price: 3.99, clamped: null, snap: 'ending' }); expect(by.get('DE')).toMatchObject({ currency: 'EUR', price: 5.99, reason: 'high-income', share: 1 });
expect(by.get('IN')).toMatchObject({ currency: 'INR', clamped: 'floor', price: 129 }); expect(by.get('JP')).toMatchObject({ currency: 'JPY', reason: 'high-income' });
expect(by.get('CH')).toMatchObject({ clamped: 'ceiling', price: 4.99 }); expect(by.get('JP')!.price).toBeGreaterThanOrEqual(4.99 * 150 * 1.1);
expect(by.get('JP')).toMatchObject({ currency: 'JPY', price: 480 }); expect(by.get('CH')).toMatchObject({ reason: 'high-income', share: 1 });
expect(by.get('BA')!.currency).toBe('BAM'); expect(by.get('IN')).toMatchObject({ currency: 'INR', reason: 'min-ratio', share: 0.3 });
expect(by.get('IN')!.price).toBeLessThan(4.99 * 87 * 1.18 * 0.35);
expect(by.get('TR')).toMatchObject({ currency: 'TRY', reason: 'ppp', share: 0.4 });
expect(by.get('TR')!.price).toBe(snapEnding(4.99 * 40 * 1.2 * 0.4, 'TRY'));
expect(by.get('BA')).toMatchObject({ currency: 'BAM', reason: 'ppp', share: 0.4 });
expect(skipped.find(s => s.region === 'AF')!.why).toBe('no exchange rate'); expect(skipped.find(s => s.region === 'AF')!.why).toBe('no exchange rate');
expect(skipped.find(s => s.region === 'FR')!.why).toMatch(/no PPP factor/); expect(skipped.find(s => s.region === 'PE')!.why).toMatch(/no PPP factor/);
expect(skipped.find(s => s.region === 'CN')!.why).toMatch(/not available in mainland China/);
expect(rows.every(r => r.price <= r.par * 1.2 || r.share === 1)).toBe(true);
expect(rows.every(r => r.territory === undefined)).toBe(true); expect(rows.every(r => r.territory === undefined)).toBe(true);
}); });
test('offline Apple: the currency hint (Euro-Zone group EUR, USD groups USD), alpha-3 territory', () => { test('the old method (no VAT, no par, everything cut) would have given Germany EUR 3.59: the open options still compare net with net', () => {
const { rows } = planRows({ product: P, base: 4.99, store: 'apple', dataset: ds, floor: 0.3, ceiling: 1.2 }); const { rows } = planRows({ product: P, base: 4.99, store: 'play', dataset: ds, options: OPEN });
const ba = rows.find(r => r.region === 'BA')!; const de = rows.find(r => r.region === 'DE')!;
expect(ba).toMatchObject({ currency: 'EUR', territory: 'BIH' }); expect(de.share).toBeCloseTo(0.8, 6);
expect(ba.snappedFrom).toBeCloseTo((4.99 * 0.7 / 1.75) * 0.9, 2); expect(de.price).toBe(snapEnding(4.99 * 0.9 * 1.19 * 0.8, 'EUR'));
expect(de.price).toBeGreaterThan(3.59);
});
test('only-decreases: never above the current store price; max-ratio caps a region at N x par', () => {
const current = new Map([['TR', { currency: 'TRY', price: 59.99 }], ['DE', { currency: 'EUR', price: 4.99 }]]);
const { rows, kept } = planRows({ product: P, base: 4.99, store: 'play', dataset: ds, options: PPP_DEFAULTS, current });
expect(rows.some(r => r.region === 'TR' || r.region === 'DE')).toBe(false);
expect(kept.find(r => r.region === 'TR')).toMatchObject({ price: 59.99, reason: 'only-decreases' });
expect(kept.find(r => r.region === 'DE')).toMatchObject({ price: 4.99, reason: 'only-decreases' });
const up = planRows({ product: P, base: 4.99, store: 'play', dataset: ds, options: { ...PPP_DEFAULTS, onlyDecreases: false, includeHighIncome: true, maxLevel: 1 }, current }).rows;
expect(up.find(r => r.region === 'TR')!.price).toBeGreaterThan(59.99);
const capped = planRows({ product: P, base: 4.99, store: 'play', dataset: ds, options: { ...OPEN, maxRatio: 0.5 } }).rows;
expect(capped.find(r => r.region === 'DE')).toMatchObject({ share: 0.5, reason: 'max-ratio' });
});
test('offline Apple: the currency hint (Euro-Zone group EUR, USD groups USD), alpha-3 territory, CN at par', () => {
const { rows, kept } = planRows({ product: P, base: 4.99, store: 'apple', dataset: ds, options: PPP_DEFAULTS });
expect(rows.find(r => r.region === 'BA')).toMatchObject({ currency: 'EUR', territory: 'BIH' });
expect(kept.find(r => r.region === 'CN')).toMatchObject({ reason: 'china', share: 1 });
expect(appleCurrencyHint('TR')).toBe('TRY'); expect(appleCurrencyHint('TR')).toBe('TRY');
expect(appleCurrencyHint('AF')).toBe('USD'); expect(appleCurrencyHint('AF')).toBe('USD');
expect(appleCurrencyHint('RS')).toBe('EUR'); expect(appleCurrencyHint('RS')).toBe('EUR');
}); });
test('online Apple: snaps to the mocked price points and takes the store currency', () => { test('online: the store\'s own conversion is par (Apple equalizations, Play convertRegionPrices); Apple snaps to its price points', () => {
const regions = new Map([ const regions = new Map([
['US', { currency: 'USD', points: [3.99, 4.99, 5.99] }], ['US', { currency: 'USD', points: [3.99, 4.99, 5.99] }],
['TR', { currency: 'TRY', points: [49.99, 59.99, 64.99, 69.99, 79.99] }], ['DE', { currency: 'EUR', points: [4.99, 5.99, 6.99], par: 5.99 }],
['AF', { currency: 'USD', points: [0.99, 1.99] }], ['TR', { currency: 'TRY', points: [49.99, 59.99, 64.99, 69.99, 79.99, 99.99], par: 249.99 }],
['JP', { currency: 'JPY', points: [] as number[] }], ['JP', { currency: 'JPY', points: [] as number[] }],
]); ]);
const { rows, skipped } = planRows({ product: P, base: 4.99, store: 'apple', dataset: ds, floor: 0.3, ceiling: 1.2, regions }); const { rows, kept, skipped } = planRows({ product: P, base: 4.99, store: 'apple', dataset: ds, options: PPP_DEFAULTS, regions });
expect(rows.map(r => `${r.region} ${r.currency} ${r.price} ${r.snap}`)).toEqual(['TR TRY 79.99 apple-point', 'US USD 4.99 apple-point']); expect([...rows, ...kept].map(r => `${r.region} ${r.currency} ${r.price} ${r.snap}`)).toEqual(['TR TRY 99.99 apple-point', 'US USD 4.99 apple-point', 'DE EUR 5.99 par']);
expect(rows[0]!.snappedFrom).toBeCloseTo(4.99 * 0.4 * 40, 1); expect(rows.find(r => r.region === 'TR')!.snappedFrom).toBeCloseTo(249.99 * 0.4, 1);
expect(skipped.map(s => `${s.region}: ${s.why}`)).toEqual(['AF: no exchange rate', 'JP: no Apple price points']); expect(kept.find(r => r.region === 'DE')!.parFrom).toBe('store');
expect(skipped.map(s => `${s.region}: ${s.why}`)).toEqual(['JP: no Apple price points']);
});
test('top-market check: a drop of more than 20 % in a top market is named', () => {
const { rows } = planRows({ product: P, base: 4.99, store: 'play', dataset: ds, options: OPEN });
const drops = topMarketDrops(rows, TOP_MARKETS);
expect(drops.map(d => d.region)).toContain('JP');
expect(drops.every(d => d.drop > 0.2)).toBe(true);
expect(topMarketDrops(planRows({ product: P, base: 4.99, store: 'play', dataset: ds, options: PPP_DEFAULTS }).rows, TOP_MARKETS)).toEqual([]);
expect(topMarketsFromReports([[{ 'Country Code': 'TR', Units: '10', 'Developer Proceeds': '80', 'Currency of Proceeds': 'TRY' },
{ 'Country Code': 'DE', Units: '3', 'Developer Proceeds': '4', 'Currency of Proceeds': 'EUR' }], [{ 'Buyer Country': 'IN', 'Charged Amount': '2000', 'Currency of Sale': 'INR' }]], ds))
.toEqual(['IN', 'TR', 'DE']);
}); });
test('CSV round trip through parsePriceCsv (the prices import parser)', () => { test('CSV round trip through parsePriceCsv (the prices import parser)', () => {
const rows = [...planRows({ product: P, base: 4.99, store: 'apple', dataset: ds, floor: 0.3, ceiling: 1.2 }).rows, const rows = [...planRows({ product: P, base: 4.99, store: 'apple', dataset: ds, options: PPP_DEFAULTS }).rows,
...planRows({ product: P, base: 4.99, store: 'play', dataset: ds, floor: 0.3, ceiling: 1.2 }).rows]; ...planRows({ product: P, base: 4.99, store: 'play', dataset: ds, options: PPP_DEFAULTS }).rows];
const back = parsePriceCsv(pppCsv(rows), { products: [P], today: '2026-10-11' }); const back = parsePriceCsv(pppCsv(rows), { products: [P], today: '2026-10-11' });
const k = (r: { store: string; region: string; currency: string; price: number }) => `${r.store} ${r.region} ${r.currency} ${r.price}`; const k = (r: { store: string; region: string; currency: string; price: number }) => `${r.store} ${r.region} ${r.currency} ${r.price}`;
expect(back.map(k).sort()).toEqual(rows.map(k).sort()); expect(back.map(k).sort()).toEqual(rows.map(k).sort());
expect(back.find(r => r.store === 'apple' && r.region === 'DE')!.territory).toBe('DEU'); expect(back.find(r => r.store === 'apple' && r.region === 'BA')!.territory).toBe('BIH');
expect(pppCsv(rows).split('\n')[0]).toBe('product,store,region,currency,price,start_date,kind'); expect(pppCsv(rows).split('\n')[0]).toBe('product,store,region,currency,price,start_date,kind');
}); });
}); });
@ -206,20 +262,23 @@ describe('command', () => {
const config = { app: { name: 'Example' }, apple: { bundleId: 'com.example.game' }, play: { packageName: 'com.example.game' }, const config = { app: { name: 'Example' }, apple: { bundleId: 'com.example.game' }, play: { packageName: 'com.example.game' },
products: [{ id: P, type: 'non_consumable', usd: 2.99, listings: {} }] }; products: [{ id: P, type: 'non_consumable', usd: 2.99, listings: {} }] };
const lines: string[] = []; const lines: string[] = [];
const orig = console.log; const orig = console.log, write = process.stdout.write;
const fetchOrig = globalThis.fetch; const fetchOrig = globalThis.fetch;
console.log = (...x: unknown[]) => { lines.push(x.join(' ')); }; console.log = (...x: unknown[]) => { lines.push(x.join(' ')); };
process.stdout.write = ((t: string) => { lines.push(String(t)); return true; }) as typeof process.stdout.write;
globalThis.fetch = (() => { throw new Error('no network in tests'); }) as never; globalThis.fetch = (() => { throw new Error('no network in tests'); }) as never;
try { try {
await run({ loaded: { config, root: dir, path: join(dir, 'storebound.config.ts'), resolve: (p: string) => pathResolve(dir, p) }, args: parseArgs(argv) } as never); await run({ loaded: { config, root: dir, path: join(dir, 'storebound.config.ts'), resolve: (p: string) => pathResolve(dir, p) }, args: parseArgs(argv) } as never);
} finally { console.log = orig; globalThis.fetch = fetchOrig; } } finally { console.log = orig; process.stdout.write = write; globalThis.fetch = fetchOrig; }
return { out: lines.join('\n'), dir }; return { out: lines.join('\n'), dir };
} }
test('offline --dry --json: the documented shape, nothing written', async () => { test('offline --dry --json: the documented shape, nothing written', async () => {
const { out, dir } = await runIn(['--offline', '--dry', '--json', '--base', '4.99']); const { out, dir } = await runIn(['--offline', '--dry', '--json', '--base', '4.99']);
const j = JSON.parse(out.slice(out.indexOf('{'))); const j = JSON.parse(out.slice(out.indexOf('{')));
expect(Object.keys(j)).toEqual(['source', 'base', 'floor', 'ceiling', 'online', 'rows', 'skipped', 'diff', 'out', 'written']); expect(Object.keys(j)).toEqual(['source', 'base', 'floor', 'ceiling', 'options', 'online', 'rows', 'kept', 'skipped', 'diff', 'topMarkets', 'out', 'written']);
expect(j.options).toEqual(PPP_DEFAULTS);
expect(j.kept.find((r: { store: string; region: string }) => r.store === 'play' && r.region === 'DE')).toMatchObject({ currency: 'EUR', reason: 'high-income', price: 5.99 });
expect(j.source).toMatchObject({ name: 'worldbank', license: 'CC BY 4.0' }); expect(j.source).toMatchObject({ name: 'worldbank', license: 'CC BY 4.0' });
expect(j.base).toEqual({ [P]: 4.99 }); expect(j.base).toEqual({ [P]: 4.99 });
expect(j.written).toBe(false); expect(j.written).toBe(false);
@ -231,14 +290,15 @@ describe('command', () => {
const { out, dir } = await runIn(['--offline', '--store', 'play', '--floor', '0.4']); const { out, dir } = await runIn(['--offline', '--store', 'play', '--floor', '0.4']);
const csv = readFileSync(join(dir, 'store/prices-ppp.csv'), 'utf8'); const csv = readFileSync(join(dir, 'store/prices-ppp.csv'), 'utf8');
const rows = parsePriceCsv(csv, { products: [P] }); const rows = parsePriceCsv(csv, { products: [P] });
expect(rows.length).toBeGreaterThan(100); expect(rows.length).toBeGreaterThan(50);
expect(rows.every(r => r.store === 'play')).toBe(true); expect(rows.every(r => r.store === 'play')).toBe(true);
expect(out).toMatch(/storebound prices import --csv store\/prices-ppp\.csv --dry/); expect(out).toMatch(/storebound prices import --csv store\/prices-ppp\.csv --dry/);
}); });
test('source file and bad bounds', async () => { test('source file and bad bounds', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ppp-f-')); const dir = mkdtempSync(join(tmpdir(), 'ppp-f-'));
writeFileSync(join(dir, 'f.csv'), 'region,factor,currency,fx\nTR,16,TRY,40\n'); writeFileSync(join(dir, 'f.csv'), 'region,factor,currency,fx\nTR,16,TRY,40\n');
await expect(runIn(['--offline', '--floor', '2', '--ceiling', '1'])).rejects.toThrow(/floor/); await expect(runIn(['--offline', '--floor', '2', '--ceiling', '1'])).rejects.toThrow(/--min-ratio/);
await expect(runIn(['--offline', '--min-ratio', '0.6', '--max-ratio', '0.5'])).rejects.toThrow(/--min-ratio/);
await expect(runIn(['--offline', '--source', 'bigmac'])).rejects.toThrow(/--update-bigmac/); await expect(runIn(['--offline', '--source', 'bigmac'])).rejects.toThrow(/--update-bigmac/);
const { out } = await runIn(['--offline', '--dry', '--json', '--source', 'file', '--file', join(dir, 'f.csv'), '--store', 'play']); const { out } = await runIn(['--offline', '--dry', '--json', '--source', 'file', '--file', join(dir, 'f.csv'), '--store', 'play']);
const j = JSON.parse(out.slice(out.indexOf('{'))); const j = JSON.parse(out.slice(out.indexOf('{')));

View file

@ -2,7 +2,8 @@
// adds to write steps, confirms creates, first prices and other updates, never a price change. Without // adds to write steps, confirms creates, first prices and other updates, never a price change. Without
// --allow-price-changes the command writes everything else, names each held change and exits 3 (declined), so a flow // --allow-price-changes the command writes everything else, names each held change and exits 3 (declined), so a flow
// stops there. Runs against the stateful store doubles (tests/helpers/fake-stores.ts). // stops there. Runs against the stateful store doubles (tests/helpers/fake-stores.ts).
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test';
import * as rt from '../cli/lib/rt';
import type { ShipConfig } from '../cli/lib/config'; import type { ShipConfig } from '../cli/lib/config';
import { DECLINED_EXIT } from '../cli/lib/ctx'; import { DECLINED_EXIT } from '../cli/lib/ctx';
import { planStep, toFlow, validateFlows } from '../cli/lib/flow'; import { planStep, toFlow, validateFlows } from '../cli/lib/flow';
@ -92,3 +93,82 @@ describe('flows never pass --allow-price-changes', () => {
expect(priceDiff(['A', 'B', 'C', 'D', 'E', 'F'].map(region => ({ region, from: '1', to: '2' })))).toBe('A 1 -> 2, B 1 -> 2, C 1 -> 2, D 1 -> 2, +2 more'); expect(priceDiff(['A', 'B', 'C', 'D', 'E', 'F'].map(region => ({ region, from: '1', to: '2' })))).toBe('A 1 -> 2, B 1 -> 2, C 1 -> 2, D 1 -> 2, +2 more');
}); });
}); });
// A terminal "y" covers creates and other updates only (STAR DASH, 2026-10-14: one yes changed the prices of live
// products while new ones were created). Price changes of existing products need --allow-price-changes, then their
// own question listing each old -> new, asked in a terminal even with --yes.
describe('terminal runs: one yes never changes a live price', () => {
let answers: (string | null)[] = [];
let spy: { mockRestore: () => void } | undefined;
beforeEach(() => {
process.env.STOREBOUND_ASSUME_TTY = '1';
answers = [];
spy = spyOn(rt, 'readLine').mockImplementation(async () => answers.shift() ?? null);
});
afterEach(() => { delete process.env.STOREBOUND_ASSUME_TTY; spy?.mockRestore(); });
const PRO = 'com.example.notes.pro', COINS = 'com.example.notes.coins';
const cfg = (usd: number, extra = false): ShipConfig => ({ ...BASE, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }], products: [
{ id: PRO, type: 'non_consumable', usd, listings: { en: { title: 'Pro', description: 'Every feature.' } } },
...(extra ? [{ id: COINS, type: 'consumable' as const, usd: 0.99, listings: { en: { title: 'Coins', description: 'A few coins.' } } }] : []),
] });
type Opt = { regionalPricingAndAvailabilityConfigs: { regionCode: string; price: unknown }[] };
const usPrice = () => JSON.stringify((s.play.products.get(PRO)!.purchaseOptions as Opt[])[0]!.regionalPricingAndAvailabilityConfigs.find(r => r.regionCode === 'US')!.price);
test('play products: the new product is created on "y", the live price is held (exit 3), no price question without the flag', async () => {
await ok(() => playProducts(project(cfg(2.99)).ctx(['--yes'])));
const before = usPrice();
answers = ['y', 'y', 'y'];
const r = await capture(() => playProducts(project(cfg(4.99, true)).ctx([])));
expect(r.exit).toBe(DECLINED_EXIT);
expect(r.err).toContain(`price change on ${PRO} (`); expect(r.err).toContain("US USD 2.99 -> USD 4.99");
expect(usPrice()).toBe(before);
expect(s.play.products.has(COINS)).toBe(true);
expect(r.out).not.toContain('Change prices');
});
test('play products --allow-price-changes --yes in a terminal: its own question lists old -> new; "n" holds, "y" applies', async () => {
await ok(() => playProducts(project(cfg(2.99)).ctx(['--yes'])));
const before = usPrice();
const p = project(cfg(4.99));
answers = ['n'];
const r = await capture(() => playProducts(p.ctx(['--yes', '--allow-price-changes'])));
expect(r.out).toContain('Change prices / regions of 1 existing product(s)');
expect(r.out).toContain(`${PRO} US USD 2.99 -> USD 4.99`);
expect(r.exit).toBe(DECLINED_EXIT);
expect(usPrice()).toBe(before);
answers = ['y'];
const r2 = await capture(() => playProducts(p.ctx(['--yes', '--allow-price-changes'])));
expect([r2.exit, r2.err.includes('needs the owner')]).toEqual([null, false]);
expect(usPrice()).not.toBe(before);
});
test('prices import in a terminal: "y" writes nothing on a live product without the flag', async () => {
const p = project(cfg(2.99), { 'store/prices.csv': `product,store,region,currency,price\n${PRO},play,DE,EUR,3.49\n` });
await ok(() => playProducts(p.ctx(['--yes'])));
const k = s.calls.filter(c => c.method === 'PATCH').length;
answers = ['y', 'y'];
const r = await capture(() => prices(p.ctx(['import', '--store', 'play'])));
expect(r.exit).toBe(DECLINED_EXIT);
expect(r.err).toContain(`price change on ${PRO} play (DE EUR 2.99 -> EUR 3.49) needs the owner`);
expect(s.calls.filter(c => c.method === 'PATCH').length).toBe(k);
});
test('play subscriptions in a terminal: "y" writes the listing, the base plan price stays', async () => {
await ok(() => playSubscriptions(project(sub(4.99)).ctx(['--yes'])));
const price = () => JSON.stringify((s.play.subs.get(MONTHLY)!.basePlans as { regionalConfigs: { regionCode: string; price: unknown }[] }[])[0]!.regionalConfigs.find(r => r.regionCode === 'US')!.price);
const before = price();
answers = ['y', 'y'];
const r = await capture(() => playSubscriptions(project(sub(9.99, 'Monthly Pro')).ctx([])));
expect(r.exit).toBe(DECLINED_EXIT);
expect(price()).toBe(before);
expect((s.play.subs.get(MONTHLY)!.listings as { title: string }[])[0]!.title).toBe('Monthly Pro');
});
test('dry runs show the held price changes in their own section', async () => {
await ok(() => playProducts(project(cfg(2.99)).ctx(['--yes'])));
const r = await capture(() => playProducts(project(cfg(4.99, true)).ctx(['--dry'])));
expect(r.out).toContain('price changes on existing products (held: only with --allow-price-changes):');
expect(r.out).toContain(`${PRO} US USD 2.99 -> USD 4.99`);
});
});