price changes of existing products need --allow-price-changes (play products, play/apple subscriptions, prices import): --yes and flow run --apply --yes confirm creates, first prices and other updates only; without the flag the run writes the rest, names each held change and exits 3 (declined), dry runs mark them owner, flows refuse the flag; lib/price-gate.ts, tests/price-gate.test.ts. CLI wording: console fill leaves what it cannot open or fill to the calling agent's browser tool (console plan / verify) instead of 'yourself'; other 'by hand' output points at the config; do-it-dont-ask test scans cli/ output

This commit is contained in:
CRTkafa 2026-10-12 09:38:56 +08:00
parent b5b8f2da9d
commit e01a27d7b7
33 changed files with 414 additions and 117 deletions

View file

@ -1,13 +1,16 @@
// In-app products on Google Play through monetization.onetimeproducts (the legacy inappproducts API answers 403
// "Please migrate to the new publishing API" for apps on the new model).
// storebound play products [--dry] [--only id1,id2]
// storebound play products [--dry] [--only id1,id2] [--yes] [--allow-price-changes]
// Per product: convertRegionPrices (USD base -> every region at local price points, plus regionsVersion), your
// explicit overrides on top, PATCH ...?allowMissing=true (create or update), then activate the purchase option
// (new options start as DRAFT) with purchaseOptions:batchUpdateStates. Idempotent.
// New products and listing text changes are written directly. Changes to what an EXISTING product costs or where it
// is sold (regional prices, the price for other regions, dropped regions, activating its purchase option) are shown
// as a diff first and need a confirmation: a terminal question (or --yes), or under the desktop app an owner action
// confirm token. Without one (MCP, CI, serve) those changes are skipped with a warning; listing changes still go.
// 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)
// 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
@ -17,6 +20,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 { LAG_HINT, reportMissing } from '../lib/store-lag';
import { fromMoney, type Money, playRegionConfigs, type RegionConfig, toMoney } from '../lib/money';
import { playClient } from './client';
@ -38,7 +42,13 @@ export interface Existing {
const money = (m?: PlayMoney): string => (m ? `${m.currencyCode} ${fromMoney(m).toFixed(2)}` : '-');
export interface ProductChange { text: string; /** price / region / activation change of an existing product: asks first */ owner: boolean }
export interface ProductChange {
text: string;
/** price or region change of an existing product: the owner's call (--allow-price-changes), never --yes */
owner: boolean;
/** owner rows: per region, current -> wanted */
prices?: HeldPrice['changes'];
}
/**
* What a run would change on one product, from the product Play has now (oneTimeProducts list) and the body the run
@ -49,7 +59,7 @@ export function productChanges(cur: Existing | undefined, want: { listings: { la
if (!cur) return [{ text: 'create', owner: false }];
const out: ProductChange[] = [];
const text = (t: string) => out.push({ text: t, owner: false });
const owner = (t: string) => out.push({ text: t, owner: true });
const owner = (t: string, prices: HeldPrice['changes']) => out.push({ text: t, owner: true, prices });
const have = new Map((cur.listings ?? []).map(l => [l.languageCode, l]));
const added = want.listings.filter(l => !have.has(l.languageCode)).map(l => l.languageCode);
const changed = want.listings.filter(l => { const h = have.get(l.languageCode); return h && ((h.title ?? '') !== l.title || (h.description ?? '') !== l.description); }).map(l => l.languageCode);
@ -61,15 +71,17 @@ export function productChanges(cur: Existing | undefined, want: { listings: { la
const now = new Map((opt?.regionalPricingAndAvailabilityConfigs ?? []).map(r => [r.regionCode, r]));
const prices = want.regions.flatMap(r => {
const h = now.get(r.regionCode)?.price;
return h && h.currencyCode === r.price.currencyCode && Math.round(fromMoney(h) * 100) === Math.round(fromMoney(r.price) * 100) ? [] : [`${r.regionCode} ${money(h)} -> ${money(r.price)}`];
return h && h.currencyCode === r.price.currencyCode && Math.round(fromMoney(h) * 100) === Math.round(fromMoney(r.price) * 100) ? [] : [{ region: r.regionCode, from: money(h), to: money(r.price) }];
});
const gone = [...now.keys()].filter(c => !want.regions.some(r => r.regionCode === c));
if (prices.length) owner(`prices in ${prices.length} region(s): ${prices.slice(0, 6).join(', ')}${prices.length > 6 ? ', ...' : ''}`);
if (gone.length) owner(`regions dropped: ${gone.join(', ')}`);
if (prices.length) owner(`prices in ${prices.length} region(s): ${prices.slice(0, 6).map(x => `${x.region} ${x.from} -> ${x.to}`).join(', ')}${prices.length > 6 ? ', ...' : ''}`, prices);
if (gone.length) owner(`regions dropped: ${gone.join(', ')}`, gone.map(c => ({ region: c, from: money(now.get(c)?.price), to: 'not sold' })));
const nr = opt?.newRegionsConfig;
if (money(nr?.usdPrice) !== money(want.newRegions.usdPrice) || money(nr?.eurPrice) !== money(want.newRegions.eurPrice))
owner(`other regions ${money(nr?.usdPrice)} / ${money(nr?.eurPrice)} -> ${money(want.newRegions.usdPrice)} / ${money(want.newRegions.eurPrice)}`);
if (opt?.state !== 'ACTIVE') owner(`purchase option ${opt?.state ?? 'missing'} -> ACTIVE`);
if (money(nr?.usdPrice) !== money(want.newRegions.usdPrice) || money(nr?.eurPrice) !== money(want.newRegions.eurPrice)) {
const from = `${money(nr?.usdPrice)} / ${money(nr?.eurPrice)}`, to = `${money(want.newRegions.usdPrice)} / ${money(want.newRegions.eurPrice)}`;
owner(`other regions ${from} -> ${to}`, [{ region: 'other regions', from, to }]);
}
if (opt?.state !== 'ACTIVE') text(`purchase option ${opt?.state ?? 'missing'} -> ACTIVE`);
return out;
}
@ -123,34 +135,41 @@ export async function run(ctx: Ctx): Promise<void> {
if (dry) {
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 confirms)' : 'update', detail: x.changes.map(c => c.text).join('; ') })));
: { item: x.p.id, action: x.changes.some(c => c.owner) ? 'update (owner: price change, --allow-price-changes)' : 'update', detail: x.changes.map(c => c.text).join('; ') })));
const 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)');
return;
}
// prices, regions and activation of existing products: the owner confirms the diff above first
const gated = plan.filter(x => x.cur && x.changes.some(c => c.owner));
const ok = !gated.length || await confirm(ctx, `Change prices / regions / activation of ${gated.length} existing product(s) on Google Play (${gated.map(x => x.p.id).join(', ')})? Buyers see new prices as soon as Play applies them.`);
if (!ok) warn('play', `price / region / activation changes of ${gated.length} existing product(s) skipped: confirm in a terminal (or the desktop owner action)`);
// 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 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));
const activateOk = !activate.length || await confirm(ctx, `Activate the purchase option of ${activate.length} existing product(s) on Google Play (${activate.map(x => x.p.id).join(', ')})? Buyers can buy them as soon as the app is published.`);
if (!activateOk) warn('play', `activation of ${activate.length} existing product(s) skipped: confirm with --yes or in a terminal`);
const held: HeldPrice[] = [];
for (const x of plan) {
if (!x.changes.length) continue;
const id = encodeURIComponent(x.p.id);
const v = encodeURIComponent(x.version);
const ownerChange = !!x.cur && x.changes.some(c => c.owner);
const listingChange = x.changes.some(c => !c.owner);
if (x.cur && ownerChange && !ok) {
if (!listingChange) continue;
await play.call('PATCH', `${play.API}/onetimeproducts/${id}?regionsVersion.version=${v}&updateMask=listings`, x.body);
log('play', `${x.tag}: listings updated (prices unchanged)`);
continue;
if (x.cur && ownerChange && !priceOk) {
held.push({ id: x.p.id, changes: x.changes.flatMap(c => c.prices ?? []) });
if (x.changes.some(c => c.text.startsWith('listings '))) {
await play.call('PATCH', `${play.API}/onetimeproducts/${id}?regionsVersion.version=${v}&updateMask=listings`, x.body);
log('play', `${x.tag}: listings updated (prices unchanged)`);
}
} else if (!x.cur || x.changes.some(c => c.owner || c.text.startsWith('listings '))) {
const mask = x.cur && !ownerChange ? 'listings' : 'listings,purchaseOptions';
await play.call('PATCH', `${play.API}/onetimeproducts/${id}?allowMissing=true&regionsVersion.version=${v}&updateMask=${mask}`, x.body);
log('play', `${x.tag}: ${x.cur ? 'updated' : 'created'} · ${x.regions} regions · ${x.listings} listings`);
}
const mask = x.cur && !ownerChange ? 'listings' : 'listings,purchaseOptions';
await play.call('PATCH', `${play.API}/onetimeproducts/${id}?allowMissing=true&regionsVersion.version=${v}&updateMask=${mask}`, x.body);
log('play', `${x.tag}: ${x.cur ? 'updated' : 'created'} · ${x.regions} regions · ${x.listings} listings`);
if (x.cur?.purchaseOptions?.find(o => o.purchaseOptionId === OPTION)?.state === 'ACTIVE') continue;
// a new product's option starts as DRAFT; an existing one is activated after the confirmation
if (optionOf(x)?.state === 'ACTIVE' || (x.cur && (!activateOk || !activate.includes(x)))) continue;
try {
await play.call('POST', `${play.API}/oneTimeProducts/${id}/purchaseOptions:batchUpdateStates`, {
requests: [{ activatePurchaseOptionRequest: { packageName: pkg, productId: x.p.id, purchaseOptionId: OPTION } }],
@ -160,5 +179,6 @@ export async function run(ctx: Ctx): Promise<void> {
}
const created = plan.filter(x => !x.cur && x.changes.length).map(x => x.p.id);
if (created.length) reportMissing('play', 'new products', created, (await listOneTimeProducts(play)).map(p => p.productId), { fail: false });
done('play', 'products done');
holdPriceChanges(held);
done('play', held.length ? 'products done, price changes left for the owner' : 'products done');
}

View file

@ -5,15 +5,19 @@
// the subscription, activated, plus `offers` (no acquisition targeting, offer tag = id: your app decides who sees
// them). Play has no subscription groups (an Apple concept) and no API for promo codes (Play Console > Monetize >
// Promo codes). Idempotent: compares first.
// storebound play subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--yes]
// Prints the plan first. In a terminal it asks before writing. Without a terminal (MCP, CI) and without --yes it
// creates what is new only and leaves price changes of existing base plans to a terminal run. A base plan price
// change applies to new subscribers; existing ones keep their price until you run a price migration in Play Console.
// storebound play subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--yes] [--allow-price-changes]
// Prints the plan first. In a terminal (no --yes) it asks before writing, price changes included. With --yes or
// without a terminal it writes everything new (subscriptions with their first prices, listings, base plans, offers)
// but never changes the prices of an existing base plan: that is the owner's call and needs --allow-price-changes
// (flows never add it). The run then prints "price change on <id> (<from> -> <to>) needs the owner" and exits 3
// (declined). A base plan price change applies to new subscribers; existing ones keep their price until you run a
// price migration in Play Console.
// Pure parts and sources: lib/subscriptions.ts.
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 { type Money, toMoney } from '../lib/money';
import {
allSubscriptions, basePlanId, offerId, playOfferBody, playPatchSubscription, type PlaySubscription, playSubDiff, playSubRegions,
@ -72,6 +76,7 @@ export async function run(ctx: Ctx): Promise<void> {
: [diff.listings.length ? `listings ${diff.listings.join(', ')}` : '', diff.basePlan === 'new' ? `new base plan ${basePlanId(s)}` : diff.basePlan === 'changed' ? `base plan ${basePlanId(s)}: ${diff.priceChanges.length} regional price change(s)` : '',
diff.activate ? 'activate base plan' : ''].filter(Boolean).join(' · ') || 'up to date';
log(TAG, `${step(i + 1, subs.length, s.id)}: ${what}${offerMissing ? ` · intro offer ${offerId(s)} (${s.intro!.type})` : ''}${extraMissing.length ? ` · offers ${extraMissing.join(', ')}` : ''}`);
if (diff.priceChanges.length) log(TAG, ' owner: price change of an existing base plan (--allow-price-changes)');
for (const p of diff.priceChanges.slice(0, 5)) log(TAG, ` ${p.regionCode}: ${p.from} -> ${p.to}`);
if (diff.priceChanges.length > 5) log(TAG, ` ... ${diff.priceChanges.length - 5} more`);
work.push({ s, body, diff, version: conv.regionVersion.version, offerMissing, extraMissing });
@ -81,11 +86,16 @@ export async function run(ctx: Ctx): Promise<void> {
if (!todo.length) { done(TAG, 'subscriptions up to date'); return; }
const yes = ctx.args.has('yes');
let liveOk = yes;
if (process.stdin.isTTY && !yes) {
const priced = todo.filter(w => w.diff.basePlan === 'changed' && w.diff.priceChanges.length);
let liveOk: boolean;
if (process.stdin.isTTY && !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 if (!yes) log(TAG, 'not a terminal: creating what is new only; price changes of existing base plans wait for a terminal run');
} else {
liveOk = await allowPriceChanges(ctx, priced.length, `Change the base plan prices of ${priced.length} existing subscription(s) on Google Play (${priced.map(w => w.s.id).join(', ')})? New subscribers pay the new price.`);
if (!yes && !liveOk) log(TAG, 'not a terminal: writing what is new; price changes of existing base plans wait for the owner');
}
const held: HeldPrice[] = [];
for (const { s, body, diff, version, offerMissing, extraMissing } of todo) {
const v = encodeURIComponent(version);
@ -99,8 +109,14 @@ export async function run(ctx: Ctx): Promise<void> {
log(TAG, `${s.id}: ${r.created ? 'created' : 'exists already (made by an earlier run); run again to compare it with the config'}`);
if (!r.created) continue;
} else if (diff.listings.length || diff.basePlan !== 'same') {
if (diff.basePlan === 'changed' && diff.priceChanges.length && !liveOk) warn(TAG, `${s.id}: ${diff.priceChanges.length} price change(s) skipped (run in a terminal to confirm)`);
else {
if (diff.basePlan === 'changed' && diff.priceChanges.length && !liveOk) {
held.push({ id: s.id, changes: diff.priceChanges.map(p => ({ region: p.regionCode, from: p.from, to: p.to })) });
if (diff.listings.length) {
// the listings only; the base plans as Play has them
await play.call('PATCH', `${play.API}/subscriptions/${id}?updateMask=listings&regionsVersion.version=${v}`, { ...playPatchSubscription(cur!, body), basePlans: cur!.basePlans?.map(({ state: _s, ...b }) => b) });
log(TAG, `${s.id}: listings updated (prices unchanged)`);
}
} else {
await play.call('PATCH', `${play.API}/subscriptions/${id}?updateMask=listings,basePlans&regionsVersion.version=${v}`, playPatchSubscription(cur!, body));
log(TAG, `${s.id}: updated`);
}
@ -129,5 +145,6 @@ export async function run(ctx: Ctx): Promise<void> {
if (s.intro && offerMissing) await createOffer(s.intro);
for (const o of (s.offers ?? []).filter(x => extraMissing.includes(x.id))) await createOffer(o, o);
}
done(TAG, 'subscriptions done');
holdPriceChanges(held);
done(TAG, held.length ? 'subscriptions done, price changes left for the owner' : 'subscriptions done');
}