apple iap: the dry run shows a live price that differs from the config (USA base and overrides) and --allow-price-changes replaces the price schedule; prices diff (the catalog diff report)

This commit is contained in:
CRTkafa 2026-10-22 20:28:16 +08:00
parent cbba936ed8
commit 4b0f9cd5b8
3 changed files with 75 additions and 21 deletions

View file

@ -1,7 +1,8 @@
// In-app purchases on App Store Connect: create missing products, localizations, price schedule (USA base + your
// overrides; Apple equalizes every other storefront) and availability (every territory + new ones). Idempotent:
// existing products and schedules are left alone (prices of an existing schedule are not changed; do that in the UI).
// storebound apple iap [--dry] [--only id1,id2]
// existing products are left alone; a price that differs from the config (USA base and overrides) is shown in the plan
// and changed only with --allow-price-changes (live prices are the owner's call; Acme 2026-10-22: the plan was silent).
// storebound apple iap [--dry] [--only id1,id2] [--allow-price-changes]
// Then `apple iap-review` for the review screenshots. First IAPs can only be submitted together with an app version,
// from the IAP page in the web UI (see the store-iap-setup skill).
// --dry reads everything (read-only) and prints one plan row per product. After a real run the store's product list
@ -18,6 +19,7 @@ import { appleTerritory, nearestPrices, samePrice } from '../lib/money';
import { createOnce, findOrCreate, getById } from '../lib/upsert';
import { ascClient, type J } from './asc';
import * as rt from '../lib/rt';
import { appleCurrent } from '../prices';
/** Product type -> InAppPurchaseType (POST /v2/inAppPurchases). */
export const APPLE_IAP_TYPE = { consumable: 'CONSUMABLE', non_consumable: 'NON_CONSUMABLE', non_renewing: 'NON_RENEWING_SUBSCRIPTION' } as const;
@ -42,6 +44,24 @@ export async function run(ctx: Ctx): Promise<void> {
throw new Error(`${territory} has no ${price} price point (nearest ${nearestPrices(seen, price).join(', ')})`);
}
const wanted = (p: (typeof products)[number]) => [{ territory: 'USA', price: p.usd }, ...(p.overrides ?? []).map(o => ({ territory: appleTerritory(o), price: o.price }))];
/** "USA 9.99 -> 12.99" per territory whose live (current, not scheduled) manual price differs from the config. */
async function priceDiffs(id: string, p: (typeof products)[number]): Promise<string[]> {
const live = (await appleCurrent(c, id, p.id, false)).rows.filter(x => !x.startDate || x.startDate <= new Date().toISOString().slice(0, 10));
return wanted(p).flatMap(w => { const cur = live.find(x => x.territory === w.territory); return cur && !samePrice(cur.price, w.price) ? [`${w.territory} ${cur.price} -> ${w.price}`] : []; });
}
async function writeSchedule(id: string, manual: { territory: string; price: number }[]): Promise<void> {
const points = await Promise.all(manual.map(m => pricePoint(id, m.territory, m.price)));
await c.asc('POST', '/v1/inAppPurchasePriceSchedules', {
data: { type: 'inAppPurchasePriceSchedules', relationships: {
inAppPurchase: { data: { type: 'inAppPurchases', id } },
baseTerritory: { data: { type: 'territories', id: 'USA' } },
manualPrices: { data: manual.map(m => ({ type: 'inAppPurchasePrices', id: '${' + m.territory + '}' })) } } },
included: manual.map((m, k) => ({ type: 'inAppPurchasePrices', id: '${' + m.territory + '}', attributes: { startDate: null },
relationships: { inAppPurchasePricePoint: { data: { type: 'inAppPurchasePricePoints', id: points[k] } } } })),
});
}
const allowPrices = ctx.args.has('allow-price-changes');
const rows: PlanRow[] = [];
const created: string[] = [];
for (const [i, p] of products.entries()) {
@ -76,17 +96,13 @@ export async function run(ctx: Ctx): Promise<void> {
const sched = await c.maybe(`/v2/inAppPurchases/${id}/iapPriceSchedule?include=manualPrices`);
if (!sched?.included?.length) {
const manual = [{ territory: 'USA', price: p.usd }, ...(p.overrides ?? []).map(o => ({ territory: appleTerritory(o), price: o.price }))];
const points = await Promise.all(manual.map(m => pricePoint(id, m.territory, m.price)));
await c.asc('POST', '/v1/inAppPurchasePriceSchedules', {
data: { type: 'inAppPurchasePriceSchedules', relationships: {
inAppPurchase: { data: { type: 'inAppPurchases', id } },
baseTerritory: { data: { type: 'territories', id: 'USA' } },
manualPrices: { data: manual.map(m => ({ type: 'inAppPurchasePrices', id: '${' + m.territory + '}' })) } } },
included: manual.map((m, k) => ({ type: 'inAppPurchasePrices', id: '${' + m.territory + '}', attributes: { startDate: null },
relationships: { inAppPurchasePricePoint: { data: { type: 'inAppPurchasePricePoints', id: points[k] } } } })),
});
const manual = wanted(p);
await writeSchedule(id, manual);
log('apple', ` prices ${manual.map(m => `${m.territory} ${m.price}`).join(', ')}`);
} else {
const diff = await priceDiffs(id, p);
if (diff.length && allowPrices) { await writeSchedule(id, wanted(p)); log('apple', ` prices changed: ${diff.join(', ')} (takes effect today; Apple equalizes the other storefronts)`); }
else if (diff.length) log('apple', ` prices differ from the config, NOT changed: ${diff.join(', ')} (live prices: re-run with --allow-price-changes after the owner's yes)`);
}
const avail = await c.maybe(`/v2/inAppPurchases/${id}/inAppPurchaseAvailability`);
@ -120,7 +136,9 @@ export async function run(ctx: Ctx): Promise<void> {
const add = locales.filter(l => !have.has(l));
const sched = await c.maybe(`/v2/inAppPurchases/${id}/iapPriceSchedule?include=manualPrices`);
const avail = await c.maybe(`/v2/inAppPurchases/${id}/inAppPurchaseAvailability`);
const parts = [add.length ? `localizations ${add.join(', ')}` : '', sched?.included?.length ? '' : `prices ${prices}`, avail?.data ? '' : `availability ${territories.length} territories + new ones`].filter(Boolean);
const diff = sched?.included?.length ? await priceDiffs(id, p) : [];
const priceNote = !sched?.included?.length ? `prices ${prices}` : diff.length ? `PRICE CHANGE ${diff.join(', ')}${allowPrices ? '' : ' (only with --allow-price-changes)'}` : '';
const parts = [add.length ? `localizations ${add.join(', ')}` : '', priceNote, avail?.data ? '' : `availability ${territories.length} territories + new ones`].filter(Boolean);
return parts.length ? { item: p.id, action: 'update', detail: `${parts.join('; ')} (state ${state ?? '?'})` } : { item: p.id, action: 'keep' };
}
}

View file

@ -107,7 +107,31 @@
"keychain",
"no-install"
],
"apple iap": [],
"apple iap": [
"all",
"allow-price-changes",
"base",
"ceiling",
"csv",
"file",
"floor",
"include-high-income",
"max-level",
"max-ratio",
"min-ratio",
"no-only-decreases",
"no-subscriptions",
"offline",
"only-decreases",
"out",
"reconvert",
"source",
"store",
"threshold",
"update-bigmac",
"with-automatic",
"write"
],
"apple iap-review": [],
"apple iap-submit": [
"new-version",
@ -144,8 +168,10 @@
"reconvert",
"source",
"store",
"threshold",
"update-bigmac",
"with-automatic"
"with-automatic",
"write"
],
"apple ppo": [
"experiment",
@ -253,8 +279,10 @@
"reconvert",
"source",
"store",
"threshold",
"update-bigmac",
"with-automatic"
"with-automatic",
"write"
],
"apple testflight": [
"build",
@ -309,8 +337,10 @@
"reconvert",
"source",
"store",
"threshold",
"update-bigmac",
"with-automatic"
"with-automatic",
"write"
],
"apps": [
"app-id",
@ -683,8 +713,10 @@
"reconvert",
"source",
"store",
"threshold",
"update-bigmac",
"with-automatic"
"with-automatic",
"write"
],
"play upload": [
"include-drafts",
@ -717,8 +749,10 @@
"reconvert",
"source",
"store",
"threshold",
"update-bigmac",
"with-automatic"
"with-automatic",
"write"
],
"privacy-manifest": [
"ios",
@ -1262,7 +1296,7 @@
"storebound apple game-center [status] [--dry] [--yes] [--json]"
],
"apple iap": [
"storebound apple iap [--dry] [--only id1,id2]"
"storebound apple iap [--dry] [--only id1,id2] [--allow-price-changes]"
],
"apple iap-review": [
"storebound apple iap-review [--dry]"

View file

@ -64,7 +64,9 @@ export async function run(ctx: Ctx): Promise<void> {
const a = ctx.args;
const sub = a._[0];
if (sub === 'ppp') return (await import('./prices-ppp')).run(ctx);
if (sub !== 'export' && sub !== 'import') throw new Error('usage: storebound prices export | import [--csv store/prices.csv] [--dry] | ppp [--base <USD>] [--dry]');
// `prices diff`: the config's prices against both stores (the same read-only report as `catalog diff`)
if (sub === 'diff') { ctx.args._.splice(0, 1, 'diff'); return (await import('./catalog')).run(ctx); }
if (sub !== 'export' && sub !== 'import') throw new Error('usage: storebound prices export | import [--csv store/prices.csv] [--dry] | diff | ppp [--base <USD>] [--dry]');
const store = a.get('store');
if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play');
const useApple = (!store || store === 'apple') && !!config.apple;