--json prints only JSON on stdout (withJsonOutput around every command: logs, tables and prompts to stderr; printJson everywhere; test runs every --json command against the fake stores and parses stdout); prices ppp compares like with like: par = the store's own US-equivalent price (Play convertRegionPrices, Apple equalizations; offline base x fx x (1 + VAT) rounded up) scaled by the PPP price level; defaults only lower, only price levels <= 0.5, World Bank high-income at par, CN at par on Apple and skipped on Play, never above par (--only-decreases, --max-ratio, --min-ratio, --max-level, --include-high-income; --floor / --ceiling kept), top-10 revenue market drops > 20 % warned (sales reports, else a static list), unchanged regions out of the sheet; price changes of existing products only with --allow-price-changes, TTY or not (a terminal yes covered them), then their own question listing each old -> new, held section in dry runs (play products, prices import, play / apple subscriptions); pitfalls 61-63; tests

This commit is contained in:
CRTkafa 2026-10-14 17:36:00 +08:00
parent bab3f63227
commit 357319a775
30 changed files with 865 additions and 209 deletions

View file

@ -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);