diff --git a/cli/apple/iap.ts b/cli/apple/iap.ts index 6a580f6..0536a44 100644 --- a/cli/apple/iap.ts +++ b/cli/apple/iap.ts @@ -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 { 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 { + 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 { + 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 { 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 { 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' }; } } diff --git a/cli/data/flags.json b/cli/data/flags.json index 6c48e61..fe63096 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -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]" diff --git a/cli/prices.ts b/cli/prices.ts index 18a7d71..2964f8e 100644 --- a/cli/prices.ts +++ b/cli/prices.ts @@ -64,7 +64,9 @@ export async function run(ctx: Ctx): Promise { 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 ] [--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 ] [--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;