--json prints only JSON on stdout (withJsonOutput around every command: logs, tables and prompts to stderr; printJson everywhere; test runs every --json command against the fake stores and parses stdout); prices ppp compares like with like: par = the store's own US-equivalent price (Play convertRegionPrices, Apple equalizations; offline base x fx x (1 + VAT) rounded up) scaled by the PPP price level; defaults only lower, only price levels <= 0.5, World Bank high-income at par, CN at par on Apple and skipped on Play, never above par (--only-decreases, --max-ratio, --min-ratio, --max-level, --include-high-income; --floor / --ceiling kept), top-10 revenue market drops > 20 % warned (sales reports, else a static list), unchanged regions out of the sheet; price changes of existing products only with --allow-price-changes, TTY or not (a terminal yes covered them), then their own question listing each old -> new, held section in dry runs (play products, prices import, play / apple subscriptions); pitfalls 61-63; tests
This commit is contained in:
parent
bab3f63227
commit
357319a775
30 changed files with 865 additions and 209 deletions
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -2,6 +2,23 @@
|
|||
|
||||
## 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
|
||||
`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.
|
||||
|
|
|
|||
|
|
@ -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' : ''})`);
|
||||
|
|
|
|||
13
cli/api.ts
13
cli/api.ts
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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})`
|
||||
|
|
|
|||
20
cli/index.ts
20
cli/index.ts
|
|
@ -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); });
|
||||
|
|
|
|||
|
|
@ -714,9 +714,19 @@ export interface PppConfig {
|
|||
source?: 'worldbank' | 'bigmac' | 'file';
|
||||
/** CSV for source "file": region (ISO alpha-2), factor (local currency per US dollar at PPP), currency, fx (market rate, optional). */
|
||||
file?: string;
|
||||
/** Lowest price as a share of the US price after the PPP adjustment (default 0.3). */
|
||||
/** Lowest price as a share of the store's US-equivalent price (default 0.3). */
|
||||
minRatio?: number;
|
||||
/** Highest share of the US-equivalent price (default 1: never above it). */
|
||||
maxRatio?: number;
|
||||
/** Only economies whose price level (PPP factor / exchange rate) is at most this share of the US get a PPP price (default 0.5). */
|
||||
maxLevel?: number;
|
||||
/** Never propose a price above the current store price (default true). */
|
||||
onlyDecreases?: boolean;
|
||||
/** World Bank high-income economies get PPP prices too (default false: they keep the US-equivalent price). */
|
||||
includeHighIncome?: boolean;
|
||||
/** Old name of minRatio. */
|
||||
floor?: number;
|
||||
/** Highest share of the US price (default 1.2). */
|
||||
/** Old name of maxRatio. */
|
||||
ceiling?: number;
|
||||
/** Price sheet the proposal is written to (default store/prices-ppp.csv). */
|
||||
out?: string;
|
||||
|
|
@ -917,7 +927,7 @@ export function validateExtras(cfg: Partial<ShipConfig>): string[] {
|
|||
if (ppp) {
|
||||
if (ppp.source !== undefined && !['worldbank', 'bigmac', 'file'].includes(ppp.source)) e.push('prices.ppp.source: worldbank | bigmac | file');
|
||||
if (ppp.source === 'file' && !ppp.file) e.push('prices.ppp.file: needed with source "file"');
|
||||
for (const k of ['floor', 'ceiling'] as const) if (ppp[k] !== undefined && !(typeof ppp[k] === 'number' && ppp[k]! > 0 && ppp[k]! <= 5)) e.push(`prices.ppp.${k}: a share of the US price, e.g. 0.3`);
|
||||
for (const k of ['floor', 'ceiling', 'minRatio', 'maxRatio', 'maxLevel'] as const) if (ppp[k] !== undefined && !(typeof ppp[k] === 'number' && ppp[k]! > 0 && ppp[k]! <= 5)) e.push(`prices.ppp.${k}: a share of the US price, e.g. 0.3`);
|
||||
if (ppp.floor !== undefined && ppp.ceiling !== undefined && ppp.floor > ppp.ceiling) e.push('prices.ppp: floor is above ceiling');
|
||||
}
|
||||
const w = cfg.web;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Language, Loaded } from './config';
|
|||
import { playCodes } from './config';
|
||||
import * as rt from './rt';
|
||||
import { stdinIsTTY } from './tty';
|
||||
import { promptOut } from './log';
|
||||
|
||||
export interface Ctx { loaded: Loaded; args: Args }
|
||||
|
||||
|
|
@ -43,7 +44,7 @@ export async function confirm(ctx: Ctx, question: string): Promise<boolean> {
|
|||
console.log(`${question}\n not a terminal: re-run with --yes to confirm`);
|
||||
return declined('not a terminal and no --yes (re-run with --yes to confirm)');
|
||||
}
|
||||
process.stdout.write(`${question} [y/N] `);
|
||||
promptOut().write(`${question} [y/N] `);
|
||||
const line = await rt.readLine();
|
||||
return line !== null && /^y(es)?$/i.test(line.trim()) || declined(line === null ? 'no answer (stdin closed)' : 'the owner answered no');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,28 @@ export const log = (tag: string, msg: string): void => console.log(`${stamp()} [
|
|||
export const warn = (tag: string, msg: string): void => console.warn(`${stamp()} [${tag}] WARN ${msg}`);
|
||||
export const done = (tag: string, msg: string): void => console.log(`${stamp()} [${tag}] ${msg} (${since()})`);
|
||||
|
||||
// The count lives on globalThis: the Node bundle (tools/build-node.ts) can carry this module twice (once for the
|
||||
// static imports, once inside a lazily initialised chunk), and both copies must see the same --json run.
|
||||
const JSON_RUNS = Symbol.for('storebound.jsonRuns');
|
||||
const runs = globalThis as unknown as Record<symbol, number>;
|
||||
/** Whether a `--json` run is going on: stdout carries only the JSON (printJson), everything else goes to stderr. */
|
||||
export const jsonOutput = (): boolean => (runs[JSON_RUNS] ?? 0) > 0;
|
||||
/** Where a question / prompt is written: stderr during a --json run, so stdout stays one JSON document. */
|
||||
export const promptOut = (): NodeJS.WriteStream => (jsonOutput() ? process.stderr : process.stdout);
|
||||
|
||||
/**
|
||||
* Runs a command with --json: stdout is only the JSON. console.log / console.info (log, done, step lines, tables and
|
||||
* every other line a command prints) go to stderr for the run; printJson writes the result to stdout itself. index.ts
|
||||
* wraps every command run with --json in this, so no command has to remember it.
|
||||
*/
|
||||
export async function withJsonOutput<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const log = console.log, info = console.info;
|
||||
console.log = (...a: unknown[]) => console.error(...a);
|
||||
console.info = (...a: unknown[]) => console.error(...a);
|
||||
runs[JSON_RUNS] = (runs[JSON_RUNS] ?? 0) + 1;
|
||||
try { return await fn(); } finally { runs[JSON_RUNS] = runs[JSON_RUNS]! - 1; console.log = log; console.info = info; }
|
||||
}
|
||||
|
||||
/** "3/14 de-DE" style counters. */
|
||||
export const step = (i: number, n: number, what: string): string => `${String(i).padStart(String(n).length)}/${n} ${what}`;
|
||||
|
||||
|
|
@ -20,5 +42,7 @@ export const RESULT_MARK = '\u001estorebound-result ';
|
|||
*/
|
||||
export function printJson(data: unknown): void {
|
||||
if (process.env.STOREBOUND_MACHINE === '1') process.stdout.write(RESULT_MARK + JSON.stringify(data) + '\n');
|
||||
// during a --json run console.log goes to stderr, so the JSON goes to stdout itself
|
||||
else if (jsonOutput()) process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
||||
else console.log(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
|
|
|||
238
cli/lib/ppp.ts
238
cli/lib/ppp.ts
|
|
@ -1,14 +1,21 @@
|
|||
// Purchasing power parity (PPP) price proposals for `storebound prices ppp`, as pure parts: datasets, the math, the
|
||||
// clamp, the snapping rules and the rows for the price sheet (the same CSV `storebound prices import` reads).
|
||||
//
|
||||
// The math, per product, store and region:
|
||||
// factor = PPP conversion factor (local currency units per international dollar; the international dollar has the
|
||||
// US dollar's purchasing power in the US, so the US factor is 1)
|
||||
// fx = market exchange rate (local currency units per US dollar, period average)
|
||||
// usd = base x factor / fx (the USD value of the PPP price at the market rate)
|
||||
// usd = clamp(usd, floor x base, ceiling x base) (floor 0.3, ceiling 1.2 unless the config or flags say)
|
||||
// target = usd x fx(store currency) (the store may price a region in USD or EUR instead of the local currency)
|
||||
// price = snap(target)
|
||||
// The math, per product, store and region (like with like: store prices include VAT and follow today's exchange
|
||||
// rate, the US price excludes sales tax, the ICP price level compares net prices):
|
||||
// factor = PPP conversion factor (local currency units per international dollar; the US factor is 1)
|
||||
// fx = market exchange rate of the same year (local currency units per US dollar, period average)
|
||||
// level = factor / fx (the region's price level against the US, net of tax)
|
||||
// par = the store's own conversion of the US price into that region: Play convertRegionPrices, Apple the
|
||||
// equalizations of the USA price point (both: tax included, today's rate). Offline: base x fx x (1 + VAT)
|
||||
// rounded up to a clean price (VAT: standard rates below; the dataset's rate stands in for today's)
|
||||
// share = 1 (par) for the US, World Bank high-income economies (unless includeHighIncome), mainland China and
|
||||
// price levels above maxLevel (0.5); else clamp(level, minRatio 0.3, maxRatio 1)
|
||||
// price = snap(par x share); with onlyDecreases (default) never above the store's current price
|
||||
// Only regions whose price changes are written to the sheet (rows); the others are reported as kept, so `prices
|
||||
// import` leaves the store's own price there. The old method (STAR DASH, 2026-10-14) took base x level x fx: a net
|
||||
// price set against VAT-inclusive store prices and every region cut, so Germany got EUR 3.59 for a USD 4.99 product
|
||||
// whose store price is EUR 5.99; now Germany keeps EUR 5.99 (tests/ppp.test.ts).
|
||||
// Snapping (storebound's own rules, not a store rule):
|
||||
// - Apple, online: the nearest real price point of that product in that territory (GET pricePoints). Apple only
|
||||
// accepts its own price points; offline values below are a proposal and `prices import` checks them.
|
||||
|
|
@ -111,6 +118,23 @@ export function snapEnding(v: number, currency: string): number {
|
|||
return Math.round((v + 1) / 10) * 10 - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* The clean price at or above `v` (same endings as snapEnding): the offline par price, so the US-equivalent price of
|
||||
* a region is never rounded below the exchange-rate value (stores round their own conversions up the same way).
|
||||
*/
|
||||
export function snapUp(v: number, currency: string): number {
|
||||
if (!(v > 0)) throw new Error(`bad price ${v}`);
|
||||
const eps = 1e-9;
|
||||
if (WHOLE.has(currency) || v >= 1000) {
|
||||
if (v < 100) return Math.max(1, Math.ceil(v - eps));
|
||||
const step = 10 ** (Math.floor(Math.log10(v)) - 1);
|
||||
return Math.ceil(v / step - eps) * step;
|
||||
}
|
||||
if (v < 1) return r2(Math.max(0.09, Math.ceil((v + 0.01) * 10 - eps) / 10 - 0.01));
|
||||
if (v < 100) return r2(Math.max(0.99, Math.ceil(v + 0.01 - eps) - 0.01));
|
||||
return Math.ceil((v + 1) / 10 - eps) * 10 - 1;
|
||||
}
|
||||
|
||||
/** Nearest Apple price point (customer prices of one territory). */
|
||||
export function snapApple(points: number[], v: number): number | null {
|
||||
return points.length ? nearestPrices(points, v, 1)[0]! : null;
|
||||
|
|
@ -124,72 +148,214 @@ export function fxOf(ds: PppDataset, currency: string): number | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
export interface Proposal { usdRaw: number; usd: number; clamped: 'floor' | 'ceiling' | null }
|
||||
// ---- the method (see the header and docs/ppp-pricing.md) ----
|
||||
|
||||
/** PPP-adjusted USD value of `base`, clamped to floor..ceiling of the base. */
|
||||
export function propose(base: number, factor: number, fx: number, floor: number, ceiling: number): Proposal {
|
||||
if (!(factor > 0) || !(fx > 0)) throw new Error('PPP factor and exchange rate must be positive');
|
||||
const usdRaw = (base * factor) / fx;
|
||||
const lo = base * floor, hi = base * ceiling;
|
||||
return usdRaw < lo ? { usdRaw, usd: lo, clamped: 'floor' } : usdRaw > hi ? { usdRaw, usd: hi, clamped: 'ceiling' } : { usdRaw, usd: usdRaw, clamped: null };
|
||||
/**
|
||||
* World Bank high-income economies (FY2026 classification, July 2025; GNI per capita, Atlas method), as ISO alpha-2,
|
||||
* App Store territories only. They keep the store's US-equivalent price unless --include-high-income.
|
||||
* Source: https://datahelpdesk.worldbank.org/knowledgebase/articles/906519-world-bank-country-and-lending-groups
|
||||
*/
|
||||
export const HIGH_INCOME = new Set(('AD AE AG AT AU AW BB BE BG BH BM BN BS CA CH CL CR CY CZ DE DK EE ES FI FR GB GR HK HR HU IE IL IS IT JP KN KR ' +
|
||||
'KW KY LI LT LU LV MC MO MT NL NO NR NZ OM PA PL PT PW QA RO RU SA SC SE SG SI SK SM TC TT TW US UY VG').split(' '));
|
||||
|
||||
/**
|
||||
* Standard VAT / GST rates (share) the stores' prices include, for the offline par price only (online the store's own
|
||||
* converted price is the par price, VAT and current exchange rate included). Regions not listed: 0 (the offline par
|
||||
* price is then the plain exchange-rate value; online runs do not have that gap). US and CA store prices exclude sales
|
||||
* tax, so they are 0. Checked October 2026 against the national tax authorities' published standard rates.
|
||||
*/
|
||||
export const VAT: Record<string, number> = {
|
||||
AT: 0.2, BE: 0.21, BG: 0.2, HR: 0.25, CY: 0.19, CZ: 0.21, DK: 0.25, EE: 0.24, FI: 0.255, FR: 0.2, DE: 0.19, GR: 0.24, HU: 0.27,
|
||||
IE: 0.23, IT: 0.22, LV: 0.21, LT: 0.21, LU: 0.17, MT: 0.18, NL: 0.21, PL: 0.23, PT: 0.23, RO: 0.21, SK: 0.23, SI: 0.22, ES: 0.21,
|
||||
SE: 0.25, GB: 0.2, NO: 0.25, CH: 0.081, IS: 0.24, JP: 0.1, KR: 0.1, AU: 0.1, NZ: 0.15, IN: 0.18, TR: 0.2, ZA: 0.15, MX: 0.16,
|
||||
CL: 0.19, CO: 0.19, SA: 0.15, AE: 0.05, SG: 0.09, TW: 0.05, TH: 0.07, PH: 0.12, VN: 0.1, EG: 0.14, IL: 0.18, UA: 0.2, RU: 0.2,
|
||||
};
|
||||
|
||||
/**
|
||||
* The top-10 App Store / Google Play revenue markets for in-app purchases (a static fallback when no sales report is
|
||||
* in .storebound/reports/): a drop of more than 20 % in one of them is named before anything is imported.
|
||||
*/
|
||||
export const TOP_MARKETS = ['US', 'JP', 'CN', 'KR', 'GB', 'DE', 'FR', 'CA', 'AU', 'TW'];
|
||||
/** A drop larger than this share in a top market is warned about. */
|
||||
export const TOP_DROP = 0.2;
|
||||
|
||||
export interface PppOptions {
|
||||
/** Lowest price as a share of the par price (default 0.3; --min-ratio, old name --floor). */
|
||||
minRatio: number;
|
||||
/** Highest share of the par price (default 1: never above the US-equivalent price; --max-ratio, old name --ceiling). */
|
||||
maxRatio: number;
|
||||
/** Only economies whose price level (PPP factor / exchange rate) is at or below this share of the US get a PPP price (default 0.5). */
|
||||
maxLevel: number;
|
||||
/** Never propose a price above the store's current one (default true; --only-decreases=false turns it off). */
|
||||
onlyDecreases: boolean;
|
||||
/** World Bank high-income economies get PPP prices too (default false: they keep par). */
|
||||
includeHighIncome: boolean;
|
||||
}
|
||||
|
||||
export const PPP_DEFAULTS: PppOptions = { minRatio: 0.3, maxRatio: 1, maxLevel: 0.5, onlyDecreases: true, includeHighIncome: false };
|
||||
|
||||
/** Why a row has the price it has. */
|
||||
export type PppReason = 'base' | 'ppp' | 'min-ratio' | 'max-ratio' | 'high-income' | 'above-level' | 'china' | 'only-decreases' | 'no-ppp-data';
|
||||
|
||||
/**
|
||||
* The share of the par price a region gets. level = PPP factor / market exchange rate of the same year (the region's
|
||||
* price level against the US, both net of tax: ICP compares prices of the same basket). The par price is the store's
|
||||
* own conversion of the US price (tax included, current rate), so par x share compares like with like.
|
||||
*/
|
||||
export function shareOf(region: string, level: number | null, o: PppOptions): { share: number; reason: PppReason } {
|
||||
if (region === 'US') return { share: 1, reason: 'base' };
|
||||
if (region === 'CN') return { share: 1, reason: 'china' };
|
||||
if (HIGH_INCOME.has(region) && !o.includeHighIncome) return { share: 1, reason: 'high-income' };
|
||||
if (level === null) return { share: 1, reason: 'no-ppp-data' };
|
||||
if (level > o.maxLevel) return { share: Math.min(1, o.maxRatio), reason: 'above-level' };
|
||||
if (level < o.minRatio) return { share: o.minRatio, reason: 'min-ratio' };
|
||||
if (level > o.maxRatio) return { share: o.maxRatio, reason: 'max-ratio' };
|
||||
return { share: level, reason: 'ppp' };
|
||||
}
|
||||
|
||||
/** Offline par price: the US price at the dataset's exchange rate plus the region's VAT (stores show prices with VAT). */
|
||||
export function offlinePar(base: number, region: string, currency: string, ds: PppDataset): number | null {
|
||||
const fx = fxOf(ds, currency);
|
||||
return fx ? base * fx * (1 + (VAT[region] ?? 0)) : null;
|
||||
}
|
||||
|
||||
export interface PppRow extends PriceRow {
|
||||
factor: number;
|
||||
fx: number;
|
||||
/** Price level against the US (factor / fx), null without PPP data. */
|
||||
level: number | null;
|
||||
/** The store's (online) or computed (offline) US-equivalent price in the row's currency. */
|
||||
par: number;
|
||||
parFrom: 'store' | 'offline';
|
||||
/** Share of par applied. */
|
||||
share: number;
|
||||
reason: PppReason;
|
||||
/** The store's current price in that region (same currency), when known. */
|
||||
current: number | null;
|
||||
/** USD value of the final (snapped) price at the market rate. */
|
||||
usdEquivalent: number;
|
||||
clamped: 'floor' | 'ceiling' | null;
|
||||
/** Unsnapped price in the store currency. */
|
||||
snappedFrom: number;
|
||||
/** apple-point: a real Apple price point; ending: storebound's clean-ending rule. */
|
||||
snap: 'apple-point' | 'ending' | 'base';
|
||||
/** apple-point: a real Apple price point; ending: storebound's clean-ending rule; par: the store's own price kept. */
|
||||
snap: 'apple-point' | 'ending' | 'base' | 'par' | 'current';
|
||||
}
|
||||
|
||||
/** What one store says about one region (online), or nothing (offline). */
|
||||
export interface StoreRegion { currency: string; points?: number[] }
|
||||
export interface StoreRegion {
|
||||
currency: string;
|
||||
points?: number[];
|
||||
/** The store's own conversion of the US price (Play convertRegionPrices, Apple equalizations): the par price. */
|
||||
par?: number;
|
||||
}
|
||||
|
||||
export interface PlanInput {
|
||||
product: string;
|
||||
base: number;
|
||||
store: 'apple' | 'play';
|
||||
dataset: PppDataset;
|
||||
floor: number;
|
||||
ceiling: number;
|
||||
/** Online: the store's regions (Apple territories with price points, Play convertRegionPrices). Offline: undefined. */
|
||||
options: PppOptions;
|
||||
/** Online: the store's regions (Apple territories with price points and equalized prices, Play convertRegionPrices). Offline: undefined. */
|
||||
regions?: Map<string, StoreRegion>;
|
||||
/** Current store prices per region (same store), for --only-decreases and the top-market check. */
|
||||
current?: Map<string, { currency: string; price: number }>;
|
||||
}
|
||||
|
||||
/** Rows of one product on one store, plus the regions skipped and why. */
|
||||
export function planRows(i: PlanInput): { rows: PppRow[]; skipped: { region: string; why: string }[] } {
|
||||
/**
|
||||
* One product on one store: `rows` are the price sheet (the US base and every region whose price changes), `kept` the
|
||||
* regions that keep the US-equivalent price or their current one (high-income, price level above maxLevel, CN,
|
||||
* only-decreases): they are left out of the sheet, so `prices import` leaves the store's own price there. Pure.
|
||||
*/
|
||||
export function planRows(i: PlanInput): { rows: PppRow[]; kept: PppRow[]; skipped: { region: string; why: string }[] } {
|
||||
const rows: PppRow[] = [];
|
||||
const kept: PppRow[] = [];
|
||||
const skipped: { region: string; why: string }[] = [];
|
||||
const regions = i.regions ? [...i.regions.keys()] : Object.keys(ALPHA3);
|
||||
const o = i.options;
|
||||
for (const region of regions.sort()) {
|
||||
// Google Play is not offered in mainland China: no Play prices there
|
||||
if (region === 'CN' && i.store === 'play') { skipped.push({ region, why: 'Google Play is not available in mainland China' }); continue; }
|
||||
const known = i.regions?.get(region);
|
||||
const currency = known?.currency || (i.store === 'apple' ? appleCurrencyHint(region) : i.dataset.countries[region]?.currency ?? CURRENCY[region]);
|
||||
if (!currency) { skipped.push({ region, why: 'no currency known' }); continue; }
|
||||
const storeFx = fxOf(i.dataset, currency);
|
||||
const cur = i.current?.get(region);
|
||||
const current = cur && cur.currency === currency ? cur.price : null;
|
||||
const apple = i.store === 'apple' ? { territory: ALPHA3[region] } : {};
|
||||
if (region === 'US' && currency === 'USD') {
|
||||
const price = known?.points?.length ? snapApple(known.points, i.base)! : i.base;
|
||||
rows.push({ product: i.product, store: i.store, region, ...(i.store === 'apple' ? { territory: 'USA' } : {}), currency, price, factor: 1, fx: 1, usdEquivalent: price, clamped: null, snappedFrom: i.base, snap: known?.points?.length ? 'apple-point' : 'base' });
|
||||
rows.push({ product: i.product, store: i.store, region, ...apple, currency, price, factor: 1, fx: 1, level: 1, par: i.base, parFrom: 'store', share: 1, reason: 'base', current,
|
||||
usdEquivalent: price, snappedFrom: i.base, snap: known?.points?.length ? 'apple-point' : 'base' });
|
||||
continue;
|
||||
}
|
||||
const e = i.dataset.countries[region];
|
||||
if (!e) { skipped.push({ region, why: `no PPP factor in ${i.dataset.name}` }); continue; }
|
||||
if (!e.fx) { skipped.push({ region, why: 'no exchange rate' }); continue; }
|
||||
if (!storeFx) { skipped.push({ region, why: `no exchange rate for ${currency}` }); continue; }
|
||||
const p = propose(i.base, e.ppp, e.fx, i.floor, i.ceiling);
|
||||
const target = p.usd * storeFx;
|
||||
const point = known?.points ? snapApple(known.points, target) : null;
|
||||
if (known?.points && point === null) { skipped.push({ region, why: 'no Apple price points' }); continue; }
|
||||
const price = point ?? snapEnding(target, currency);
|
||||
rows.push({ product: i.product, store: i.store, region, ...(i.store === 'apple' ? { territory: ALPHA3[region] } : {}), currency, price,
|
||||
factor: e.ppp, fx: e.fx, usdEquivalent: r2(price / storeFx), clamped: p.clamped, snappedFrom: r2(target), snap: point !== null ? 'apple-point' : 'ending' });
|
||||
const level = e?.fx ? e.ppp / e.fx : null;
|
||||
const atPar = region === 'CN' || (HIGH_INCOME.has(region) && !o.includeHighIncome);
|
||||
if (!atPar && !e) { skipped.push({ region, why: `no PPP factor in ${i.dataset.name}` }); continue; }
|
||||
if (!atPar && !e!.fx) { skipped.push({ region, why: 'no exchange rate' }); continue; }
|
||||
const par = known?.par ?? offlinePar(i.base, region, currency, i.dataset);
|
||||
if (par === null || !storeFx) { skipped.push({ region, why: `no exchange rate for ${currency}` }); continue; }
|
||||
let { share, reason } = shareOf(region, level, o);
|
||||
let target = par * share;
|
||||
let snap: PppRow['snap'];
|
||||
let price: number;
|
||||
if (share === 1 && known?.par !== undefined) { price = known.par; snap = 'par'; }
|
||||
else if (share === 1 && !known?.points) { price = snapUp(par, currency); snap = 'par'; }
|
||||
else {
|
||||
// par (share 1): the lowest price point at or above it; a reduced price: the nearest point
|
||||
const up = share === 1 && known?.points ? [...known.points].filter(p => p >= target - 1e-9).sort((a, b) => a - b)[0] : undefined;
|
||||
const point = known?.points ? up ?? snapApple(known.points, target) : null;
|
||||
if (known?.points && point === null) { skipped.push({ region, why: 'no Apple price points' }); continue; }
|
||||
price = point ?? snapEnding(target, currency);
|
||||
snap = point !== null ? 'apple-point' : 'ending';
|
||||
// snapping must not push a reduced price above par
|
||||
if (share < 1 && price > par) {
|
||||
const lower = known?.points ? [...known.points].filter(p => p <= par).sort((a, b) => b - a)[0] : undefined;
|
||||
if (lower !== undefined) price = lower;
|
||||
}
|
||||
}
|
||||
if (o.onlyDecreases && current !== null && price > current) { price = current; target = current; reason = 'only-decreases'; snap = 'current'; share = current / par; }
|
||||
const row: PppRow = { product: i.product, store: i.store, region, ...apple, currency, price, factor: e?.ppp ?? 0, fx: e?.fx ?? 0, level: level === null ? null : r4(level),
|
||||
par: r2(par), parFrom: known?.par !== undefined ? 'store' : 'offline', share: r4(share), reason, current,
|
||||
usdEquivalent: r2(price / storeFx / (1 + (VAT[region] ?? 0))), snappedFrom: r2(target), snap };
|
||||
const unchanged = current !== null && Math.round(price * 100) === Math.round(current * 100);
|
||||
(unchanged || ['high-income', 'above-level', 'china', 'only-decreases'].includes(reason) ? kept : rows).push(row);
|
||||
}
|
||||
return { rows, skipped };
|
||||
return { rows, kept, skipped };
|
||||
}
|
||||
|
||||
const r4 = (n: number) => Math.round(n * 10000) / 10000;
|
||||
|
||||
/** Top-market drops: rows in `markets` whose price is more than TOP_DROP below the current price (or par without one). Pure. */
|
||||
export function topMarketDrops(rows: PppRow[], markets: string[]): { product: string; store: string; region: string; from: number; to: number; drop: number; currency: string }[] {
|
||||
return rows.filter(r => markets.includes(r.region)).flatMap(r => {
|
||||
const from = r.current ?? r.par;
|
||||
const drop = from > 0 ? (from - r.price) / from : 0;
|
||||
return drop > TOP_DROP ? [{ product: r.product, store: r.store, region: r.region, from, to: r.price, drop: r4(drop), currency: r.currency }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Top revenue markets from the raw sales reports in .storebound/reports/ (`storebound reports apple-sales`,
|
||||
* `play-sales`, `play-earnings`): amounts per buyer country converted to USD with the dataset rates, top `n`.
|
||||
* Empty when no report has a country column. Pure (takes the parsed report rows).
|
||||
*/
|
||||
export function topMarketsFromReports(reports: Record<string, string>[][], ds: PppDataset, n = 10): string[] {
|
||||
const sum = new Map<string, number>();
|
||||
for (const rows of reports) for (const r of rows) {
|
||||
const country = (r['Country Code'] ?? r['Buyer Country'] ?? r['Country of Buyer'] ?? '').trim().toUpperCase();
|
||||
if (!/^[A-Z]{2}$/.test(country)) continue;
|
||||
const [amount, currency] = r['Developer Proceeds'] !== undefined ? [num(r.Units) * num(r['Developer Proceeds']), r['Currency of Proceeds'] ?? '']
|
||||
: r['Amount (Merchant Currency)'] !== undefined ? [num(r['Amount (Merchant Currency)']), r['Merchant Currency'] ?? '']
|
||||
: [num(r['Charged Amount']), r['Currency of Sale'] ?? ''];
|
||||
const fx = fxOf(ds, currency.trim().toUpperCase());
|
||||
if (!fx || !(amount > 0)) continue;
|
||||
sum.set(country, (sum.get(country) ?? 0) + amount / fx);
|
||||
}
|
||||
return [...sum.entries()].sort((a, b) => b[1] - a[1]).slice(0, n).map(([c]) => c);
|
||||
}
|
||||
|
||||
const num = (s: string | undefined): number => { const v = Number(String(s ?? '').replace(/,/g, '')); return Number.isFinite(v) ? v : 0; };
|
||||
|
||||
/** The price sheet (`prices import` format) of the proposal. */
|
||||
export const pppCsv = (rows: PppRow[]): string => pricesCsv(rows.map(r => ({ product: r.product, store: r.store, region: r.region, currency: r.currency, price: r.price })));
|
||||
|
||||
|
|
|
|||
|
|
@ -11,16 +11,19 @@ export const ALLOW_PRICE_CHANGES = 'allow-price-changes';
|
|||
export interface HeldPrice { id: string; changes: { region: string; from: string; to: string }[] }
|
||||
|
||||
/**
|
||||
* Price changes of existing (live) products are the owner's call. `--yes` (also the one `flow run --apply --yes` adds
|
||||
* to write steps) confirms creates, first prices and other updates, never these. They go with --allow-price-changes,
|
||||
* or when the owner answers the question in a terminal (no --yes), or as a desktop owner action. Nothing to change,
|
||||
* true. When false the command applies everything else and ends with holdPriceChanges().
|
||||
* Price changes of existing (live) products are the owner's call, and only ever with --allow-price-changes, TTY or
|
||||
* not: no confirmation of creates or other updates (a terminal "y", `--yes`, `flow run --apply --yes`) covers them.
|
||||
* Without the flag: false (the command applies everything else and ends with holdPriceChanges()). With the flag they
|
||||
* get their own question that lists each change old -> new: asked in a terminal (even with --yes, which never answers
|
||||
* it there), answered by --yes outside a terminal (the flag plus --yes is the explicit owner's run), by the desktop
|
||||
* owner dialog under serve. Nothing to change: true.
|
||||
*/
|
||||
export async function allowPriceChanges(ctx: Ctx, count: number, question: string): Promise<boolean> {
|
||||
if (!count || ctx.args.has('allow-price-changes')) return true;
|
||||
const yes = ctx.args.has('yes');
|
||||
if (process.env.STOREBOUND_SERVE === '1' || (stdinIsTTY() && !yes)) return confirm(withoutYes(ctx), question);
|
||||
return false;
|
||||
export async function allowPriceChanges(ctx: Ctx, changes: HeldPrice[], question: string): Promise<boolean> {
|
||||
if (!changes.length) return true;
|
||||
if (!ctx.args.has(ALLOW_PRICE_CHANGES)) return false;
|
||||
const list = changes.flatMap(h => h.changes.map(c => ` ${h.id} ${c.region} ${c.from} -> ${c.to}`)).join('\n');
|
||||
const q = `${question}\n${list}\n`;
|
||||
return confirm(stdinIsTTY() && process.env.STOREBOUND_SERVE !== '1' ? withoutYes(ctx) : ctx, q);
|
||||
}
|
||||
|
||||
/** The end of a run that left price changes alone: one line per product, then declined (exit DECLINED_EXIT), so a flow stops there. */
|
||||
|
|
@ -40,3 +43,13 @@ export function priceDiff(changes: HeldPrice['changes']): string {
|
|||
export function withoutYes(ctx: Ctx): Ctx {
|
||||
return { ...ctx, args: { ...ctx.args, has: (n: string) => n !== 'yes' && ctx.args.has(n) } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The dry run's own section for price changes of existing products: held unless the real run gets
|
||||
* --allow-price-changes. One line per product and region, old -> new. Empty when nothing is held. Pure.
|
||||
*/
|
||||
export function heldSection(held: HeldPrice[], allowed = false): string {
|
||||
if (!held.length) return '';
|
||||
const head = `price changes on existing products (${allowed ? 'with --allow-price-changes: asked separately, each old -> new' : 'held: only with --allow-price-changes'}):`;
|
||||
return [head, ...held.flatMap(h => h.changes.map(c => ` ${h.id} ${c.region} ${c.from} -> ${c.to}`))].join('\n');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_updateInstance` | PATCH | `/v1/inAppPurchaseOfferCodes/{id}` | write (dry by default) | |
|
||||
| **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 | |
|
||||
| **inAppPurchasePriceSchedules** (8) | | | | |
|
||||
| `inAppPurchasePriceSchedules_automaticPrices_getToManyRelated` | GET | `/v1/inAppPurchasePriceSchedules/{id}/automaticPrices` | read | prices |
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ fails without the fix.
|
|||
|
||||
| # | 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`. |
|
||||
| 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`). |
|
||||
|
|
|
|||
|
|
@ -158,17 +158,17 @@ export const DATA_SAFETY_CSV = [
|
|||
/** Data safety CSV rows `storebound check` counts for the config. */
|
||||
export const DECL_TOTAL = 768;
|
||||
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,AG,USD,1.99,,manual', 'com.example.app.no_ads,apple,AL,USD,0.99,,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,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,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,BB,USD,2.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,BG,EUR,0.99,,manual', 'com.example.app.no_ads,apple,BH,USD,0.99,,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,BN,USD,0.99,,manual', 'com.example.app.no_ads,apple,BO,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,AR,USD,0.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,BJ,USD,0.99,,manual', 'com.example.app.no_ads,apple,BO,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,BW,USD,0.99,,manual', 'com.example.app.no_ads,apple,BY,USD,0.89,,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,CI,USD,0.99,,manual', 'com.example.app.no_ads,apple,CM,USD,0.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';
|
||||
/** The cards that drop onto the pile, faster and faster. at = seconds into the scene; x / y = the card's centre
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
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)
|
||||
|
|
@ -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.
|
||||
|
||||
## What it computes
|
||||
Per product, store and region:
|
||||
- `usd = base x PPP factor / exchange rate` (the PPP price, back in US dollars at the market rate).
|
||||
- Clamped to `floor..ceiling` of the base (defaults 0.3 and 1.2): no region below 30 % or above 120 % of the US price.
|
||||
- Converted to the currency the store uses there (some App Store storefronts are priced in USD or EUR) and snapped:
|
||||
- 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). Snapping can move a price a little past
|
||||
the floor or ceiling.
|
||||
- The US row is always the base itself.
|
||||
Like with like: store prices include VAT and follow today's exchange rate, the US price excludes sales tax, and the
|
||||
PPP price level compares net prices. So the proposal starts from the store's own conversion of the US price and
|
||||
scales it by the price level. Per product, store and region:
|
||||
- `level = PPP factor / exchange rate` (same year): the region's price level against the US.
|
||||
- `par` = the store's US-equivalent price there: Play `convertRegionPrices`, Apple the equalizations of the USA price
|
||||
point (both VAT included, today's rate). Offline: `base x rate x (1 + VAT)` rounded up to a clean price (standard
|
||||
VAT rates in `cli/lib/ppp.ts`; the dataset's rate stands in for today's).
|
||||
- `share`:
|
||||
- 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)
|
||||
- `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).
|
||||
- `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.
|
||||
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
|
||||
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.
|
||||
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
|
||||
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
|
||||
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).
|
||||
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
|
||||
|
|
@ -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
|
||||
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
|
||||
the US base is tax-exclusive. PPP factors describe consumer price levels including taxes, so the sheet is a
|
||||
customer price; proceeds per region differ by the local tax. Play's convertRegionPrices also returns tax-inclusive
|
||||
prices.
|
||||
the US base is tax-exclusive. That is why par is the store's own (tax-inclusive) conversion and the PPP level only
|
||||
scales it; never compare a PPP price in dollars with a local store price directly.
|
||||
- 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.
|
||||
- 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).
|
||||
|
||||
## Output
|
||||
`--json` prints `{ source: {name, year, retrieved, url, license}, base: {product: usd}, floor, ceiling, online:
|
||||
{apple, play}, rows: [{product, store, region, currency, price, factor, fx, usdEquivalent, clamped, snappedFrom,
|
||||
snap}], skipped: [{product, store, region, why}], diff, out, written }`. Use `--json --dry` for a read-only run.
|
||||
`--json` prints only JSON on stdout (logs on stderr): `{ source: {name, year, retrieved, url, license}, base:
|
||||
{product: usd}, floor, ceiling, options, online: {apple, play}, rows: [{product, store, region, currency, price,
|
||||
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
|
||||
- Writing prices to a store (that is `storebound prices import`, after the owner's yes).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
rows `owner`, and a real run writes everything else, prints `price change on <id> (<from> -> <to>) needs the
|
||||
owner: re-run with --allow-price-changes` and exits 3 (declined; a flow stops there). After the owner's yes in
|
||||
chat for exactly that diff, run `storebound play products --allow-price-changes`; the owner can also answer the
|
||||
question in a terminal (no `--yes`) or use the desktop Prices "Apply" (owner action).
|
||||
rows `owner` and lists them in a "price changes on existing products (held)" section, and a real run writes
|
||||
everything else, prints `price change on <id> (<from> -> <to>) needs the owner: re-run with
|
||||
--allow-price-changes` and exits 3 (declined; a flow stops there). A terminal "y" never covers them either: only
|
||||
`--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`.
|
||||
4. Test: Play Console > Settings > License testing: add tester Gmail accounts; install from the internal testing
|
||||
link; purchases are free test orders.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
- **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).
|
||||
|
||||
## 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
100
tests/json-stdout.test.ts
Normal 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]');
|
||||
});
|
||||
});
|
||||
|
|
@ -436,7 +436,9 @@ describe('play products: price and region changes of existing products are the o
|
|||
process.env.STOREBOUND_SERVE = '1';
|
||||
expect((await runWith(over, ['--yes'])).writes.filter(w => w.includes('no_ads'))).toEqual([]);
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import bundledJson from '../cli/data/ppp-worldbank.json';
|
|||
import { parseArgs } from '../cli/lib/args';
|
||||
import { ALPHA3 } from '../cli/lib/money';
|
||||
import {
|
||||
appleCurrencyHint, bigMacDataset, CURRENCY, datasetJson, datasetProblems, fxOf, parseFactorCsv, planRows, type PppDataset, pppCsv, propose, snapApple,
|
||||
snapEnding, worldBankDataset,
|
||||
appleCurrencyHint, bigMacDataset, CURRENCY, datasetJson, datasetProblems, fxOf, HIGH_INCOME, offlinePar, parseFactorCsv, planRows, PPP_DEFAULTS, type PppDataset,
|
||||
type PppOptions, pppCsv, shareOf, snapApple, snapEnding, snapUp, TOP_MARKETS, topMarketDrops, topMarketsFromReports, VAT, worldBankDataset,
|
||||
} from '../cli/lib/ppp';
|
||||
import { parsePriceCsv } from '../cli/lib/prices';
|
||||
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' },
|
||||
JP: { ppp: 97, fx: 150, year: 2025, currency: 'JPY' },
|
||||
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' },
|
||||
},
|
||||
};
|
||||
|
||||
const OPEN: PppOptions = { ...PPP_DEFAULTS, maxRatio: 1.2, maxLevel: 5, onlyDecreases: false, includeHighIncome: true };
|
||||
|
||||
describe('ppp math', () => {
|
||||
test('usd = base x factor / fx, clamped to floor..ceiling', () => {
|
||||
expect(propose(10, 0.72, 0.9, 0.3, 1.2)).toMatchObject({ clamped: null });
|
||||
expect(propose(10, 0.72, 0.9, 0.3, 1.2).usd).toBeCloseTo(8, 9);
|
||||
const lo = propose(10, 20, 87, 0.3, 1.2);
|
||||
expect(lo.clamped).toBe('floor');
|
||||
expect(lo.usd).toBe(3);
|
||||
expect(lo.usdRaw).toBeCloseTo(2.299, 3);
|
||||
expect(propose(10, 1.2, 0.8, 0.3, 1.2)).toMatchObject({ usd: 12, clamped: 'ceiling' });
|
||||
expect(() => propose(10, 0, 1, 0.3, 1.2)).toThrow();
|
||||
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(shareOf('US', 1, PPP_DEFAULTS)).toEqual({ share: 1, reason: 'base' });
|
||||
expect(shareOf('DE', 0.8, PPP_DEFAULTS)).toEqual({ share: 1, reason: 'high-income' });
|
||||
expect(shareOf('CN', 0.47, PPP_DEFAULTS)).toEqual({ share: 1, reason: 'china' });
|
||||
expect(shareOf('MX', 0.55, PPP_DEFAULTS)).toEqual({ share: 1, reason: 'above-level' });
|
||||
expect(shareOf('TR', 0.41, PPP_DEFAULTS)).toEqual({ share: 0.41, reason: 'ppp' });
|
||||
expect(shareOf('IN', 0.23, PPP_DEFAULTS)).toEqual({ share: 0.3, reason: 'min-ratio' });
|
||||
expect(shareOf('DE', 0.8, OPEN)).toEqual({ share: 0.8, reason: 'ppp' });
|
||||
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', () => {
|
||||
expect(fxOf(ds, 'USD')).toBe(1);
|
||||
|
|
@ -67,6 +79,13 @@ describe('snapping', () => {
|
|||
expect(snapEnding(923, 'HUF')).toBe(920);
|
||||
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', () => {
|
||||
expect(snapApple([49.99, 59.99, 64.99, 69.99], 62)).toBe(59.99);
|
||||
expect(snapApple([], 62)).toBeNull();
|
||||
|
|
@ -74,47 +93,84 @@ describe('snapping', () => {
|
|||
});
|
||||
|
||||
describe('planRows', () => {
|
||||
test('offline Play: local currency, US row is the base, clamp flags, skipped regions', () => {
|
||||
const { rows, skipped } = planRows({ product: P, base: 4.99, store: 'play', dataset: ds, floor: 0.3, ceiling: 1.2 });
|
||||
const by = new Map(rows.map(r => [r.region, r] as const));
|
||||
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, kept, skipped } = planRows({ product: P, base: 4.99, store: 'play', dataset: ds, options: PPP_DEFAULTS });
|
||||
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('DE')).toMatchObject({ currency: 'EUR', price: 3.99, clamped: null, snap: 'ending' });
|
||||
expect(by.get('IN')).toMatchObject({ currency: 'INR', clamped: 'floor', price: 129 });
|
||||
expect(by.get('CH')).toMatchObject({ clamped: 'ceiling', price: 4.99 });
|
||||
expect(by.get('JP')).toMatchObject({ currency: 'JPY', price: 480 });
|
||||
expect(by.get('BA')!.currency).toBe('BAM');
|
||||
expect(by.get('DE')).toMatchObject({ currency: 'EUR', price: 5.99, reason: 'high-income', share: 1 });
|
||||
expect(by.get('JP')).toMatchObject({ currency: 'JPY', reason: 'high-income' });
|
||||
expect(by.get('JP')!.price).toBeGreaterThanOrEqual(4.99 * 150 * 1.1);
|
||||
expect(by.get('CH')).toMatchObject({ reason: 'high-income', share: 1 });
|
||||
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 === '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);
|
||||
});
|
||||
test('offline Apple: the currency hint (Euro-Zone group EUR, USD groups USD), alpha-3 territory', () => {
|
||||
const { rows } = planRows({ product: P, base: 4.99, store: 'apple', dataset: ds, floor: 0.3, ceiling: 1.2 });
|
||||
const ba = rows.find(r => r.region === 'BA')!;
|
||||
expect(ba).toMatchObject({ currency: 'EUR', territory: 'BIH' });
|
||||
expect(ba.snappedFrom).toBeCloseTo((4.99 * 0.7 / 1.75) * 0.9, 2);
|
||||
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: 'play', dataset: ds, options: OPEN });
|
||||
const de = rows.find(r => r.region === 'DE')!;
|
||||
expect(de.share).toBeCloseTo(0.8, 6);
|
||||
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('AF')).toBe('USD');
|
||||
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([
|
||||
['US', { currency: 'USD', points: [3.99, 4.99, 5.99] }],
|
||||
['TR', { currency: 'TRY', points: [49.99, 59.99, 64.99, 69.99, 79.99] }],
|
||||
['AF', { currency: 'USD', points: [0.99, 1.99] }],
|
||||
['DE', { currency: 'EUR', points: [4.99, 5.99, 6.99], par: 5.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[] }],
|
||||
]);
|
||||
const { rows, skipped } = planRows({ product: P, base: 4.99, store: 'apple', dataset: ds, floor: 0.3, ceiling: 1.2, 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[0]!.snappedFrom).toBeCloseTo(4.99 * 0.4 * 40, 1);
|
||||
expect(skipped.map(s => `${s.region}: ${s.why}`)).toEqual(['AF: no exchange rate', 'JP: no Apple price points']);
|
||||
const { rows, kept, skipped } = planRows({ product: P, base: 4.99, store: 'apple', dataset: ds, options: PPP_DEFAULTS, regions });
|
||||
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.find(r => r.region === 'TR')!.snappedFrom).toBeCloseTo(249.99 * 0.4, 1);
|
||||
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)', () => {
|
||||
const rows = [...planRows({ product: P, base: 4.99, store: 'apple', dataset: ds, floor: 0.3, ceiling: 1.2 }).rows,
|
||||
...planRows({ product: P, base: 4.99, store: 'play', 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, options: PPP_DEFAULTS }).rows];
|
||||
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}`;
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
@ -206,20 +262,23 @@ describe('command', () => {
|
|||
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: {} }] };
|
||||
const lines: string[] = [];
|
||||
const orig = console.log;
|
||||
const orig = console.log, write = process.stdout.write;
|
||||
const fetchOrig = globalThis.fetch;
|
||||
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;
|
||||
try {
|
||||
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 };
|
||||
}
|
||||
|
||||
test('offline --dry --json: the documented shape, nothing written', async () => {
|
||||
const { out, dir } = await runIn(['--offline', '--dry', '--json', '--base', '4.99']);
|
||||
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.base).toEqual({ [P]: 4.99 });
|
||||
expect(j.written).toBe(false);
|
||||
|
|
@ -231,14 +290,15 @@ describe('command', () => {
|
|||
const { out, dir } = await runIn(['--offline', '--store', 'play', '--floor', '0.4']);
|
||||
const csv = readFileSync(join(dir, 'store/prices-ppp.csv'), 'utf8');
|
||||
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(out).toMatch(/storebound prices import --csv store\/prices-ppp\.csv --dry/);
|
||||
});
|
||||
test('source file and bad bounds', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ppp-f-'));
|
||||
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/);
|
||||
const { out } = await runIn(['--offline', '--dry', '--json', '--source', 'file', '--file', join(dir, 'f.csv'), '--store', 'play']);
|
||||
const j = JSON.parse(out.slice(out.indexOf('{')));
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
// 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
|
||||
// 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 { DECLINED_EXIT } from '../cli/lib/ctx';
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
// 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`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue