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:
parent
b5b8f2da9d
commit
e01a27d7b7
33 changed files with 414 additions and 117 deletions
|
|
@ -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®ionsVersion.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®ionsVersion.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');
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue