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

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

View file

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