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
|
|
@ -218,7 +218,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
writeFileSync(cerFile, Buffer.from(created.attributes.certificateContent, 'base64'), { mode: 0o600 });
|
||||
log(TAG, `certificate ${created.attributes.name ?? created.id} created (expires ${String(created.attributes.expirationDate).slice(0, 10)}); key and certificate in .storebound/signing/ (gitignored, back them up)`);
|
||||
if (a.has('keychain')) keychainImport(keyFile, cerFile, a.get('keychain') === 'true' ? null : a.get('keychain')!);
|
||||
else warn(TAG, 'xcodebuild signs with identities from the keychain: re-run with --keychain to import it there (or import the .key + .cer yourself)');
|
||||
else warn(TAG, 'xcodebuild signs with identities from the keychain: re-run with --keychain to import it there (or import the .key + .cer into the login keychain with `security import`)');
|
||||
cert = created;
|
||||
}
|
||||
for (const id of ids) {
|
||||
|
|
|
|||
|
|
@ -3,13 +3,15 @@
|
|||
// ones), prices (USA price point + its equalizations, your overrides, optional CSV rows from the prices sheet incl.
|
||||
// future-dated changes), introductory offers per territory and the review screenshot. Idempotent: what exists is
|
||||
// left alone, prices are compared per territory and only differences are sent.
|
||||
// storebound apple subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--codes] [--yes]
|
||||
// storebound apple subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--codes] [--yes] [--allow-price-changes]
|
||||
// Also promotional offers (`offers`, signed by your server at purchase time) and offer code campaigns
|
||||
// (`offerCodes`). Custom codes of a campaign (redeemable by anyone who has the code) are only created with --codes
|
||||
// from a terminal that confirms; one-time-use codes are generated by the owner in App Store Connect.
|
||||
// Prints the plan first. In a terminal it asks before writing anything. Without a terminal (MCP, CI) and without
|
||||
// --yes it still creates what is new (nothing is public before App Review), but never changes prices of a
|
||||
// subscription that already has them: that waits for the owner in a terminal. preserveCurrentPrice is always true
|
||||
// Prints the plan first. In a terminal (no --yes) it asks before writing anything, price changes included. With --yes
|
||||
// or without a terminal it still creates what is new, first prices included (nothing is public before App Review), but
|
||||
// never changes prices of a subscription that already has them: 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), after everything else is written. preserveCurrentPrice is always true
|
||||
// (existing subscribers keep their price; raising theirs is a separate step in App Store Connect).
|
||||
// First subscriptions are submitted with an app version, like first in-app purchases (store-subscriptions skill).
|
||||
// Pure parts and sources: lib/subscriptions.ts.
|
||||
|
|
@ -18,6 +20,7 @@ import { type OfferCodeDef, sellsOn, type Subscription, type SubscriptionGroup }
|
|||
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, withoutYes } from '../lib/price-gate';
|
||||
import { parsePriceCsv, type PriceRow } from '../lib/prices';
|
||||
import {
|
||||
allSubscriptions, appleAvailabilityBody, appleCustomCodeBody, appleOfferCodeBody, applePromoOfferBody, appleGroupBody, appleGroupLocBody, appleIntroPatch, applePoints, applePriceChanges,
|
||||
|
|
@ -79,11 +82,13 @@ 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');
|
||||
let liveOk = yes;
|
||||
if (process.stdin.isTTY && !yes) {
|
||||
// price changes of existing subscriptions: known per subscription, decided at the first one (lib/price-gate.ts)
|
||||
let liveOk: boolean | undefined;
|
||||
if (process.stdin.isTTY && !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;
|
||||
} else if (!yes) log(TAG, 'not a terminal: creating what is new only; price changes of existing subscriptions wait for a terminal run');
|
||||
} else if (!yes) log(TAG, 'not a terminal: creating what is new; price changes of existing subscriptions wait for the owner');
|
||||
const held: HeldPrice[] = [];
|
||||
|
||||
const locale = new Map(config.languages.map(l => [l.id, l.apple] as const));
|
||||
const territories: string[] = (await c.all('/v1/territories?limit=200')).map((t: J) => t.id);
|
||||
|
|
@ -142,8 +147,10 @@ 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) warn(TAG, ` ${changes.length} price change(s) skipped: run in a terminal to confirm`);
|
||||
else {
|
||||
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.`))) {
|
||||
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}` : ''}` })) });
|
||||
} else {
|
||||
const resolved: { territory: string; pointId: string; startDate?: string | null }[] = [];
|
||||
for (const ch of changes) {
|
||||
let pt = known.find(p => p.territory === ch.territory && Math.round(p.price * 100) === Math.round(ch.to * 100));
|
||||
|
|
@ -205,7 +212,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
}
|
||||
}
|
||||
done(TAG, 'subscriptions done (first ones are submitted with an app version: store-subscriptions skill)');
|
||||
holdPriceChanges(held);
|
||||
done(TAG, `subscriptions done${held.length ? ', price changes left for the owner' : ''} (first ones are submitted with an app version: store-subscriptions skill)`);
|
||||
}
|
||||
|
||||
/** Custom codes of one offer code campaign: only with --codes, only after a confirmation in a terminal (anyone with
|
||||
|
|
@ -218,7 +226,7 @@ async function customCodes(c: Asc, ctx: Ctx, subId: string, s: Subscription, oc:
|
|||
const have = new Set((await c.all(`/v1/subscriptionOfferCodes/${id}/customCodes?limit=200`)).map((x: J) => x.attributes.customCode));
|
||||
const todo = oc.customCodes!.filter(cc => !have.has(cc.code));
|
||||
if (!todo.length) return;
|
||||
if (!(await confirm({ ...ctx, args: { ...ctx.args, has: (n: string) => n !== 'yes' && ctx.args.has(n) } }, `Create ${todo.length} custom offer code(s) for ${s.id} (${todo.map(x => `${x.code} x${x.count}`).join(', ')})? Anyone with a code can redeem it.`))) return;
|
||||
if (!(await confirm(withoutYes(ctx), `Create ${todo.length} custom offer code(s) for ${s.id} (${todo.map(x => `${x.code} x${x.count}`).join(', ')})? Anyone with a code can redeem it.`))) return;
|
||||
for (const cc of todo) {
|
||||
// a custom code is unique: a 409 finds the existing one
|
||||
await createOnce({ what: `custom code ${cc.code}`, tag: TAG, unique: true,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue