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' };
}
}