--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

@ -20,6 +20,7 @@ import { applyImport, describeProfiles, planImport } from './lib/accounts';
import { activeProfile, defaultPointer, defaultProfile, profileDir, setDefaultPointer } from './lib/credentials';
import { confirm, type Ctx } from './lib/ctx';
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);
@ -32,7 +33,7 @@ export async function run(args: Args): Promise<void> {
try { active = activeProfile(dir); } catch { /* unreadable project file */ }
const def = defaultProfile();
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) {
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;
@ -47,7 +48,7 @@ export async function run(args: Args): Promise<void> {
const plan = planImport(dir, { name: args.get('name') });
const json = args.has('json');
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(` 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' : ''})`);

View file

@ -19,6 +19,7 @@ import { PLAY_APIS } from './api/play.gen';
import { WRAPPED } from './api/wrapped.gen';
import type { ParamValue } from './api/types';
import * as rt from './lib/rt';
import { printJson, promptOut } from './lib/log';
import { stdinIsTTY } from './lib/tty';
const STORES: Store[] = ['apple', 'play'];
@ -109,14 +110,14 @@ export async function run(args: Args): Promise<void> {
if (sub === 'list') {
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})` : ''}`);
console.error(`${list.length} operation(s)`);
return;
}
if (sub === 'coverage') {
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`);
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)}`);
@ -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 === '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)}`);
return;
}
if (decision.action === 'confirm') {
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; }
}
@ -166,10 +167,10 @@ export async function run(args: Args): Promise<void> {
if (r.bytes) {
if (!out) throw new Error(`${op.id}: binary response (${r.bytes.length} bytes): pass --out <file>`);
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;
}
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_getInstance': '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_baseTerritory_getToOneRelated': 'prices',
'inAppPurchasePriceSchedules_createInstance': 'apple/iap prices',

View file

@ -66,6 +66,9 @@ async function readState(c: Asc, appId: string): Promise<AppleSubState> {
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> {
const { config, root, resolve } = ctx.loaded;
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; }
const yes = ctx.args.has('yes');
// price changes of existing subscriptions: known per subscription, decided at the first one (lib/price-gate.ts)
let liveOk: boolean | undefined;
// price changes of existing subscriptions: only with --allow-price-changes, their own question per subscription
// 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 (!(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;
liveOk = true;
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;
} else if (!yes) log(TAG, 'not a terminal: creating what is new; price changes of existing subscriptions wait for the owner');
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 changes = applePriceChanges(current, appleWantedPrices(s, equalized, csv), csv.filter(r => r.product === s.id));
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)');
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 {
const resolved: { territory: string; pointId: string; startDate?: string | null }[] = [];
for (const ch of changes) {

View file

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

View file

@ -26,6 +26,7 @@ import { verify } from './lib/minisign';
import { parsePlist } from './lib/plist';
import { addFolder } from './lib/registry';
import * as rt from './lib/rt';
import { printJson, promptOut } from './lib/log';
import { stdinIsTTY } from './lib/tty';
type Fetch = (url: string) => Promise<Response>;
@ -128,7 +129,7 @@ function canWrite(dir: string): boolean {
}
async function askYes(text: string): Promise<boolean> {
process.stdout.write(text);
promptOut().write(text);
const line = await rt.readLine();
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 (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}`);
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; }
@ -179,12 +180,12 @@ export async function run(args: Args): Promise<void> {
}) : null;
if (json) {
console.log(JSON.stringify({
printJson({
cli, installed, installedVersion, decision, link, folder: isApp ? dir : 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,
command: installed && !changing ? openCommand(platform, installed, link) : null,
}, null, 2));
});
return;
}
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 { CONFIG_NAMES } from './lib/config';
import { credentialStatus } from './lib/credentials';
import { log } from './lib/log';
import { log, printJson } from './lib/log';
import { textTable } from './lib/table';
import { androidSdk, type AndroidSdkLookup, detectTools, findPlaywright, installCommands, type ToolStatus } from './lib/tools';
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> {
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' : ''}`);
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})`

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
// 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 { withJsonOutput } from './lib/log';
import { toolPath } from './lib/tools';
import { COMMANDS, PLAIN } from './commands';
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)))];
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 { loadConfig } = await import('./lib/config');
const loaded = await loadConfig(args.get('config'));
// one writer per app and store (cli/lib/lock.ts): dry runs and reads take no lock
const { acquireLock, lockKeys } = await import('./lib/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') });
await (await COMMANDS[key]![0]()).run({ loaded, args });
await json(async () => {
const { loadConfig } = await import('./lib/config');
const loaded = await loadConfig(args.get('config'));
// one writer per app and store (cli/lib/lock.ts): dry runs and reads take no lock
const { acquireLock, lockKeys } = await import('./lib/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') });
await (await COMMANDS[key]![0]()).run({ loaded, args });
});
}
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';
/** CSV for source "file": region (ISO alpha-2), factor (local currency per US dollar at PPP), currency, fx (market rate, optional). */
file?: string;
/** Lowest price as a share of the US price after the PPP adjustment (default 0.3). */
/** Lowest price as a share of the store's US-equivalent price (default 0.3). */
minRatio?: number;
/** Highest share of the US-equivalent price (default 1: never above it). */
maxRatio?: number;
/** Only economies whose price level (PPP factor / exchange rate) is at most this share of the US get a PPP price (default 0.5). */
maxLevel?: number;
/** Never propose a price above the current store price (default true). */
onlyDecreases?: boolean;
/** World Bank high-income economies get PPP prices too (default false: they keep the US-equivalent price). */
includeHighIncome?: boolean;
/** Old name of minRatio. */
floor?: number;
/** Highest share of the US price (default 1.2). */
/** Old name of maxRatio. */
ceiling?: number;
/** Price sheet the proposal is written to (default store/prices-ppp.csv). */
out?: string;
@ -917,7 +927,7 @@ export function validateExtras(cfg: Partial<ShipConfig>): string[] {
if (ppp) {
if (ppp.source !== undefined && !['worldbank', 'bigmac', 'file'].includes(ppp.source)) e.push('prices.ppp.source: worldbank | bigmac | file');
if (ppp.source === 'file' && !ppp.file) e.push('prices.ppp.file: needed with source "file"');
for (const k of ['floor', 'ceiling'] as const) if (ppp[k] !== undefined && !(typeof ppp[k] === 'number' && ppp[k]! > 0 && ppp[k]! <= 5)) e.push(`prices.ppp.${k}: a share of the US price, e.g. 0.3`);
for (const k of ['floor', 'ceiling', 'minRatio', 'maxRatio', 'maxLevel'] as const) if (ppp[k] !== undefined && !(typeof ppp[k] === 'number' && ppp[k]! > 0 && ppp[k]! <= 5)) e.push(`prices.ppp.${k}: a share of the US price, e.g. 0.3`);
if (ppp.floor !== undefined && ppp.ceiling !== undefined && ppp.floor > ppp.ceiling) e.push('prices.ppp: floor is above ceiling');
}
const w = cfg.web;

View file

@ -3,6 +3,7 @@ import type { Language, Loaded } from './config';
import { playCodes } from './config';
import * as rt from './rt';
import { stdinIsTTY } from './tty';
import { promptOut } from './log';
export interface Ctx { loaded: Loaded; args: Args }
@ -43,7 +44,7 @@ export async function confirm(ctx: Ctx, question: string): Promise<boolean> {
console.log(`${question}\n not a terminal: re-run with --yes to confirm`);
return declined('not a terminal and no --yes (re-run with --yes to confirm)');
}
process.stdout.write(`${question} [y/N] `);
promptOut().write(`${question} [y/N] `);
const line = await rt.readLine();
return line !== null && /^y(es)?$/i.test(line.trim()) || declined(line === null ? 'no answer (stdin closed)' : 'the owner answered no');
}

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 done = (tag: string, msg: string): void => console.log(`${stamp()} [${tag}] ${msg} (${since()})`);
// The count lives on globalThis: the Node bundle (tools/build-node.ts) can carry this module twice (once for the
// static imports, once inside a lazily initialised chunk), and both copies must see the same --json run.
const JSON_RUNS = Symbol.for('storebound.jsonRuns');
const runs = globalThis as unknown as Record<symbol, number>;
/** Whether a `--json` run is going on: stdout carries only the JSON (printJson), everything else goes to stderr. */
export const jsonOutput = (): boolean => (runs[JSON_RUNS] ?? 0) > 0;
/** Where a question / prompt is written: stderr during a --json run, so stdout stays one JSON document. */
export const promptOut = (): NodeJS.WriteStream => (jsonOutput() ? process.stderr : process.stdout);
/**
* Runs a command with --json: stdout is only the JSON. console.log / console.info (log, done, step lines, tables and
* every other line a command prints) go to stderr for the run; printJson writes the result to stdout itself. index.ts
* wraps every command run with --json in this, so no command has to remember it.
*/
export async function withJsonOutput<T>(fn: () => Promise<T>): Promise<T> {
const log = console.log, info = console.info;
console.log = (...a: unknown[]) => console.error(...a);
console.info = (...a: unknown[]) => console.error(...a);
runs[JSON_RUNS] = (runs[JSON_RUNS] ?? 0) + 1;
try { return await fn(); } finally { runs[JSON_RUNS] = runs[JSON_RUNS]! - 1; console.log = log; console.info = info; }
}
/** "3/14 de-DE" style counters. */
export const step = (i: number, n: number, what: string): string => `${String(i).padStart(String(n).length)}/${n} ${what}`;
@ -20,5 +42,7 @@ export const RESULT_MARK = '\u001estorebound-result ';
*/
export function printJson(data: unknown): void {
if (process.env.STOREBOUND_MACHINE === '1') process.stdout.write(RESULT_MARK + JSON.stringify(data) + '\n');
// during a --json run console.log goes to stderr, so the JSON goes to stdout itself
else if (jsonOutput()) process.stdout.write(JSON.stringify(data, null, 2) + '\n');
else console.log(JSON.stringify(data, null, 2));
}

View file

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

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 }[] }
/**
* Price changes of existing (live) products are the owner's call. `--yes` (also the one `flow run --apply --yes` adds
* to write steps) confirms creates, first prices and other updates, never these. They go with --allow-price-changes,
* or when the owner answers the question in a terminal (no --yes), or as a desktop owner action. Nothing to change,
* true. When false the command applies everything else and ends with holdPriceChanges().
* Price changes of existing (live) products are the owner's call, and only ever with --allow-price-changes, TTY or
* not: no confirmation of creates or other updates (a terminal "y", `--yes`, `flow run --apply --yes`) covers them.
* Without the flag: false (the command applies everything else and ends with holdPriceChanges()). With the flag they
* get their own question that lists each change old -> new: asked in a terminal (even with --yes, which never answers
* it there), answered by --yes outside a terminal (the flag plus --yes is the explicit owner's run), by the desktop
* owner dialog under serve. Nothing to change: true.
*/
export async function allowPriceChanges(ctx: Ctx, count: number, question: string): Promise<boolean> {
if (!count || ctx.args.has('allow-price-changes')) return true;
const yes = ctx.args.has('yes');
if (process.env.STOREBOUND_SERVE === '1' || (stdinIsTTY() && !yes)) return confirm(withoutYes(ctx), question);
return false;
export async function allowPriceChanges(ctx: Ctx, changes: HeldPrice[], question: string): Promise<boolean> {
if (!changes.length) return true;
if (!ctx.args.has(ALLOW_PRICE_CHANGES)) return false;
const list = changes.flatMap(h => h.changes.map(c => ` ${h.id} ${c.region} ${c.from} -> ${c.to}`)).join('\n');
const q = `${question}\n${list}\n`;
return confirm(stdinIsTTY() && process.env.STOREBOUND_SERVE !== '1' ? withoutYes(ctx) : ctx, q);
}
/** The end of a run that left price changes alone: one line per product, then declined (exit DECLINED_EXIT), so a flow stops there. */
@ -40,3 +43,13 @@ export function priceDiff(changes: HeldPrice['changes']): string {
export function withoutYes(ctx: Ctx): Ctx {
return { ...ctx, args: { ...ctx.args, has: (n: string) => n !== 'yes' && ctx.args.has(n) } };
}
/**
* The dry run's own section for price changes of existing products: held unless the real run gets
* --allow-price-changes. One line per product and region, old -> new. Empty when nothing is held. Pure.
*/
export function heldSection(held: HeldPrice[], allowed = false): string {
if (!held.length) return '';
const head = `price changes on existing products (${allowed ? 'with --allow-price-changes: asked separately, each old -> new' : 'held: only with --allow-price-changes'}):`;
return [head, ...held.flatMap(h => h.changes.map(c => ` ${h.id} ${c.region} ${c.from} -> ${c.to}`))].join('\n');
}

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' } },
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,
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'] },
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' } },
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,
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,

View file

@ -6,11 +6,12 @@
// (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
// 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:
// --yes never confirms it (flows add --yes); it needs --allow-price-changes, the owner's answer in a terminal (no
// --yes) or the desktop owner action. Without that the run writes everything else, prints one "price change on <id>
// (<from> -> <to>) needs the owner" line per product and exits 3 (declined), so a flow stops there. The dry run marks
// those rows "owner". (lib/price-gate.ts)
// costs or where it is sold (regional prices, the price for other regions, dropped regions) is the owner's call and
// needs --allow-price-changes, TTY or not: no terminal "y" and no --yes covers it (STAR DASH, 2026-10-14: creating new
// products also changed live prices). With the flag it gets its own question listing each old -> new (the desktop
// owner action answers it). Without it the run writes everything else, prints one "price change on <id> (<from> ->
// <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
// 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
@ -20,7 +21,7 @@ import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { dryPlan } from '../lib/dry';
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 { fromMoney, type Money, playRegionConfigs, type RegionConfig, toMoney } from '../lib/money';
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' }
: !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('; ') })));
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);
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)');
@ -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
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);
// 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));

View file

@ -17,7 +17,7 @@ import { sellsOn } from '../lib/config';
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
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 {
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`);
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);
if (!todo.length) { done(TAG, 'subscriptions up to date'); return; }
const yes = ctx.args.has('yes');
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 (!(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;
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');
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;
}
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[] = [];
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.
// storebound prices ppp [--base <USD>] [--source worldbank|bigmac|file] [--file <csv>] [--only ids] [--store apple|play]
// [--floor 0.3] [--ceiling 1.2] [--out store/prices-ppp.csv] [--offline] [--dry] [--json]
// [--min-ratio 0.3] [--max-ratio 1] [--max-level 0.5] [--only-decreases | --no-only-decreases]
// [--include-high-income] [--out store/prices-ppp.csv] [--offline] [--dry] [--json]
// Defaults (STAR DASH, 2026-10-14: the old method cut Germany to EUR 3.59 for a USD 4.99 product): only lower prices,
// only in economies whose price level is at most 50 % of the US, World Bank high-income economies at par (the store's
// own US-equivalent price), mainland China at par on Apple and skipped on Play; --floor / --ceiling are the old names
// of --min-ratio / --max-ratio. A top-10 revenue market (from .storebound/reports/ when a sales report is there, else
// lib/ppp.ts TOP_MARKETS) that would drop more than 20 % is warned about.
// storebound prices ppp --update-bigmac [--dry] download The Economist Big Mac index to .storebound/ppp-bigmac.json
// It writes a CSV in the `prices import` format (nothing is sent to a store), prints the diff against the stores'
// current prices and the next step (`storebound prices import --csv <out> --dry`). --dry prints the CSV and the diff
@ -15,7 +21,7 @@
// https://developers.google.com/android-publisher/api-ref/rest/v3/monetization/convertRegionPrices
// https://data.worldbank.org/indicator/PA.NUS.PPP, https://data.worldbank.org/indicator/PA.NUS.FCRF
// https://github.com/TheEconomist/big-mac-data (output-data/big-mac-raw-index.csv)
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import worldbank from './data/ppp-worldbank.json' with { type: 'json' };
import { ascClient, type Asc, type J } from './apple/asc';
@ -25,7 +31,11 @@ import type { Ctx } from './lib/ctx';
import { need } from './lib/ctx';
import { done, log, printJson, step, warn } from './lib/log';
import { ALPHA3, toMoney } from './lib/money';
import { bigMacDataset, datasetJson, datasetProblems, parseFactorCsv, planRows, type PppDataset, pppCsv, type PppRow, type StoreRegion } from './lib/ppp';
import {
bigMacDataset, datasetJson, datasetProblems, parseFactorCsv, planRows, PPP_DEFAULTS, type PppDataset, pppCsv, type PppOptions, type PppRow, type StoreRegion, TOP_DROP,
TOP_MARKETS, topMarketDrops, topMarketsFromReports,
} from './lib/ppp';
import { parsePlayCsv, parseTsv } from './lib/reports';
import { alpha2, diffPrices, diffTable, playPriceRows, type PriceRow } from './lib/prices';
import { playClient, type PlayClient } from './play/client';
import { appleCurrent, iapId, pages, playProduct } from './prices';
@ -64,8 +74,21 @@ export function loadDataset(source: string, root: string, file: string | undefin
throw new Error('--source worldbank | bigmac | file');
}
/** Apple price points of one IAP in the given territories (alpha-3), batched: region (alpha-2) -> currency + points. */
export async function applePoints(c: Asc, id: string, territories: string[]): Promise<Map<string, StoreRegion>> {
/**
* Apple's own conversion of the US price: the equalizations of the USA price point (tax included, today's rates),
* region (alpha-2) -> customer price. Empty when the call fails (then the offline par price is used).
*/
export async function appleEqualized(c: Asc, usaPointId: string): Promise<Map<string, number>> {
const out = new Map<string, number>();
try {
const r = await pages(c, `/v1/inAppPurchasePricePoints/${usaPointId}/equalizations?include=territory&limit=8000`);
for (const p of r.data) { const t: string | undefined = p.relationships?.territory?.data?.id; if (t) out.set(alpha2(t), Number(p.attributes?.customerPrice)); }
} catch (e) { warn(TAG, `Apple equalizations of the US price: ${(e as Error).message}; offline par prices for Apple`); }
return out;
}
/** Apple price points of one IAP in the given territories (alpha-3), batched: region (alpha-2) -> currency + points (+ ids of the USA points). */
export async function applePoints(c: Asc, id: string, territories: string[], usaIds?: Map<number, string>): Promise<Map<string, StoreRegion>> {
const out = new Map<string, StoreRegion>();
for (let i = 0; i < territories.length; i += 10) {
const batch = territories.slice(i, i + 10);
@ -77,13 +100,40 @@ export async function applePoints(c: Asc, id: string, territories: string[]): Pr
const region = alpha2(t);
const e: StoreRegion = out.get(region) ?? { currency: curr.get(t) ?? '', points: [] };
e.points!.push(Number(p.attributes?.customerPrice));
if (t === 'USA' && usaIds && p.id) usaIds.set(Number(p.attributes?.customerPrice), p.id);
out.set(region, e);
}
}
return out;
}
type Converted = { convertedRegionPrices?: Record<string, { regionCode: string; price: { currencyCode: string } }> };
type Converted = { convertedRegionPrices?: Record<string, { regionCode: string; price: { currencyCode: string; units?: string; nanos?: number } }> };
/** The options of a run: flags over prices.ppp in the config over PPP_DEFAULTS (--floor / --ceiling: old names). */
export function pppOptions(a: Ctx['args'], pc: NonNullable<NonNullable<Ctx['loaded']['config']['prices']>['ppp']>): PppOptions {
const o: PppOptions = {
minRatio: a.num('min-ratio', a.num('floor', pc.minRatio ?? pc.floor ?? PPP_DEFAULTS.minRatio)),
maxRatio: a.num('max-ratio', a.num('ceiling', pc.maxRatio ?? pc.ceiling ?? PPP_DEFAULTS.maxRatio)),
maxLevel: a.num('max-level', pc.maxLevel ?? PPP_DEFAULTS.maxLevel),
onlyDecreases: a.has('no-only-decreases') ? false : a.has('only-decreases') ? true : pc.onlyDecreases ?? PPP_DEFAULTS.onlyDecreases,
includeHighIncome: a.has('include-high-income') || (pc.includeHighIncome ?? PPP_DEFAULTS.includeHighIncome),
};
if (!(o.minRatio > 0) || !(o.maxRatio > 0) || o.minRatio > o.maxRatio) throw new Error('--min-ratio and --max-ratio (old names --floor / --ceiling): shares of the US-equivalent price, 0 < min <= max (e.g. 0.3 and 1)');
if (!(o.maxLevel > 0)) throw new Error('--max-level: the highest price level (share of the US) that gets a PPP price, e.g. 0.5');
return o;
}
/** Top revenue markets: from the sales reports in .storebound/reports/ when there are any, else the static list. */
export function topMarkets(root: string, ds: PppDataset): { markets: string[]; from: 'sales-reports' | 'static' } {
const dir = join(root, '.storebound', 'reports');
const reports: Record<string, string>[][] = [];
if (existsSync(dir)) for (const f of readdirSync(dir)) {
if (f.endsWith('.summary.csv') || !/\.(tsv|csv|txt)$/.test(f)) continue;
try { const t = readFileSync(join(dir, f), 'utf8'); reports.push(f.endsWith('.csv') ? parsePlayCsv(t) : parseTsv(t)); } catch { /* unreadable: skipped */ }
}
const m = topMarketsFromReports(reports, ds);
return m.length ? { markets: m, from: 'sales-reports' } : { markets: TOP_MARKETS, from: 'static' };
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
@ -97,8 +147,8 @@ export async function run(ctx: Ctx): Promise<void> {
const source = a.get('source') ?? pc.source ?? 'worldbank';
const fileArg = a.get('file') ?? pc.file;
const ds = loadDataset(source, root, fileArg ? resolve(fileArg) : undefined);
const floor = a.num('floor', pc.floor ?? 0.3), ceiling = a.num('ceiling', pc.ceiling ?? 1.2);
if (!(floor > 0) || !(ceiling > 0) || floor > ceiling) throw new Error('--floor and --ceiling: shares of the base price, 0 < floor <= ceiling (e.g. 0.3 and 1.2)');
const options = pppOptions(a, pc);
const { minRatio: floor, maxRatio: ceiling } = options;
const baseArg = a.get('base') !== undefined ? a.num('base', 0) : undefined;
if (baseArg !== undefined && !(baseArg > 0)) throw new Error('--base: a USD price, e.g. 4.99');
const only = a.list('only');
@ -117,9 +167,11 @@ export async function run(ctx: Ctx): Promise<void> {
const c = online.apple ? await ascClient(root, config.apple!.bundleId, config.apple?.appId) : null;
const appId = c ? await c.app() : '';
const play: PlayClient | null = online.play ? await playClient(root, config.play!.packageName) : null;
log(TAG, `${ds.name}: ${ds.source}; retrieved ${ds.retrieved || '-'}; floor ${floor}, ceiling ${ceiling}; ${stores.map(s => `${s} ${online[s] ? 'online' : 'offline'}`).join(', ')}`);
log(TAG, `${ds.name}: ${ds.source}; retrieved ${ds.retrieved || '-'}; ${stores.map(s => `${s} ${online[s] ? 'online' : 'offline'}`).join(', ')}`);
log(TAG, `rules: ${options.onlyDecreases ? 'only lower prices' : 'lower and raise'}; PPP prices only where the price level is <= ${options.maxLevel} of the US; share of the US-equivalent price ${floor}..${ceiling}; ${options.includeHighIncome ? 'high-income economies included' : 'high-income economies at par'}; CN at par on Apple, not on Play`);
const rows: PppRow[] = [];
const kept: PppRow[] = [];
const current: PriceRow[] = [];
const skipped: { product: string; store: string; region: string; why: string }[] = [];
const offlineApple = new Set<string>();
@ -138,7 +190,11 @@ export async function run(ctx: Ctx): Promise<void> {
else {
current.push(...(await appleCurrent(c, id, p.id, true)).rows);
const want = Object.keys(ALPHA3).filter(r => r === 'US' || ds.countries[r]).map(r => ALPHA3[r]!);
regions = await applePoints(c, id, want);
const usaIds = new Map<number, string>();
regions = await applePoints(c, id, want, usaIds);
const usaId = [...usaIds.entries()].find(([price]) => Math.round(price * 100) === Math.round(usd * 100))?.[1];
if (usaId) for (const [region, par] of await appleEqualized(c, usaId)) { const r = regions.get(region); if (r) r.par = par; }
else warn(TAG, `${p.id}: USD ${usd} is not an App Store price point; offline par prices for Apple`);
}
}
if (s === 'play' && play) {
@ -146,11 +202,15 @@ export async function run(ctx: Ctx): Promise<void> {
if (!pp) warn(TAG, `${p.id}: not on Google Play (storebound play products creates it); proposal against no current prices`);
else current.push(...playPriceRows(pp));
if (!converted.has(usd)) converted.set(usd, await play.call<Converted>('POST', `${play.API}/pricing:convertRegionPrices`, { price: toMoney('USD', usd) }));
regions = new Map(Object.values(converted.get(usd)!.convertedRegionPrices ?? {}).map(r => [r.regionCode, { currency: r.price.currencyCode }] as const));
// convertRegionPrices: the US price in every region, tax included, at today's rates: Play's par price
regions = new Map(Object.values(converted.get(usd)!.convertedRegionPrices ?? {}).map(r => [r.regionCode, { currency: r.price.currencyCode, par: Number(r.price.units ?? 0) + (r.price.nanos ?? 0) / 1e9 }] as const));
}
if (s === 'apple' && !regions) offlineApple.add(p.id);
const plan = planRows({ product: p.id, base: usd, store: s, dataset: ds, floor, ceiling, regions });
const cur = new Map(current.filter(r => r.product === p.id && r.store === s && r.kind !== 'auto').map(r => [r.region, { currency: r.currency, price: r.price }] as const));
for (const r of current) if (r.product === p.id && r.store === s && r.kind === 'auto' && !cur.has(r.region)) cur.set(r.region, { currency: r.currency, price: r.price });
const plan = planRows({ product: p.id, base: usd, store: s, dataset: ds, options, regions, current: cur });
rows.push(...plan.rows);
kept.push(...plan.kept);
skipped.push(...plan.skipped.map(k => ({ product: p.id, store: s, ...k })));
}
}
@ -159,14 +219,17 @@ export async function run(ctx: Ctx): Promise<void> {
const diff = diffPrices(current, rows.map(({ product, store, region, territory, currency, price }) => ({ product, store, region, territory, currency, price })));
const sourceInfo = { name: ds.name, year: ds.year ?? null, retrieved: ds.retrieved, url: ds.url, license: ds.license };
if (!dry) { mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, csv); }
const top = topMarkets(root, ds);
const drops = topMarketDrops(rows, top.markets);
for (const d of drops) warn(TAG, `top market ${d.region} (${top.from === 'sales-reports' ? 'from your sales reports' : 'static top-10 list'}): ${d.product} ${d.store} ${d.currency} ${d.from} -> ${d.to} (-${Math.round(d.drop * 100)} %, more than ${TOP_DROP * 100} %); keep it out of the sheet unless you mean it`);
if (a.has('json')) {
printJson({ source: sourceInfo, base, floor, ceiling, online, rows: rows.map(({ territory: _t, ...r }) => r), skipped, diff, out, written: !dry });
printJson({ source: sourceInfo, base, floor, ceiling, options, online, rows: rows.map(({ territory: _t, ...r }) => r), kept: kept.map(({ territory: _t, ...r }) => r), skipped, diff, topMarkets: { ...top, drops }, out, written: !dry });
return;
}
if (dry) console.log(csv);
console.log(diffTable(diff));
const clamped = rows.filter(r => r.clamped).length;
log(TAG, `${rows.length} rows (${clamped} clamped to the floor / ceiling), ${skipped.length} region(s) without a proposal${skipped.length ? ` (${[...new Set(skipped.map(k => k.region))].slice(0, 12).join(', ')}${skipped.length > 12 ? ', ...' : ''})` : ''}`);
const cut = rows.filter(r => r.share < 1).length, par = kept.length;
log(TAG, `${rows.length} rows (${cut} below the US-equivalent price; ${par} region(s) keep the store's own price: high-income, price level above ${options.maxLevel}, CN, or only-decreases), ${skipped.length} region(s) without a proposal${skipped.length ? ` (${[...new Set(skipped.map(k => k.region))].slice(0, 12).join(', ')}${skipped.length > 12 ? ', ...' : ''})` : ''}`);
if (offlineApple.size) warn(TAG, `Apple values for ${[...offlineApple].join(', ')} are offline proposals: each must still match an App Store price point (prices import checks it and lists the neighbours)`);
log(TAG, `source: ${ds.attribution ?? ds.source} (${ds.license})`);
log(TAG, 'a proposal: review the sheet (remove rows you do not want, round to your own price ladder); nothing was sent to a store');

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
// 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
// has prices on a store is a price change: the owner's call. In a terminal (no --yes) the one question covers it;
// --yes (also from `flow run --apply --yes`) only sets first prices and never changes existing ones, which need
// --allow-price-changes: without it the run writes the first prices, prints "price change on <id> (<from> -> <to>)
// needs the owner" per product and exits 3 (declined). The dry run marks those pairs "owner". Apple: one new price
// has prices on a store is a price change: the owner's call, only with --allow-price-changes, TTY or not. The first
// prices question (a terminal "y" or --yes, also from `flow run --apply --yes`) never covers them; with the flag they
// get their own question listing each old -> new (lib/price-gate.ts). Without it the run writes the first prices,
// 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;
// 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
@ -22,7 +23,7 @@ import { ascClient, type Asc, type J } from './apple/asc';
import type { Ctx } from './lib/ctx';
import { confirm, need } from './lib/ctx';
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 {
appleSchedule, appleScheduleBody, applePriceRows, diffPrices, diffTable, parsePriceCsv, type PlayProduct, playPatchBody, playPriceRows, playRegions,
@ -30,7 +31,6 @@ import {
} from './lib/prices';
import { playClient, type PlayClient } from './play/client';
import { allSubscriptions, appleSubPriceRows, basePlanId, type PlaySubscription, playSubPriceRows } from './lib/subscriptions';
import { stdinIsTTY } from './lib/tty';
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 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 (dry) { done(TAG, 'dry run: nothing written'); return; }
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 liveHeld: HeldPrice[] = live.map(k => {
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}` : ''}` }))
: [{ region: 'every region', from: 'current', to: 'reconverted' }] });
}
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' }] };
});
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) {
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 { basename, join, relative, resolve } from 'node:path';
import type { Args } from './lib/args';
import { log } from './lib/log';
import { log, printJson } from './lib/log';
import { nativeIosPlugins } from './lib/node-packages';
import { parsePlist, toXmlPlist, type PlistValue } from './lib/plist';
import { manifestFromBytes } from './lib/privacy-files';
@ -127,7 +127,7 @@ export async function run(args: Args): Promise<void> {
return;
}
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}`);
if (findings.some(f => f.level === 'blocker')) process.exitCode = 1;
}