// 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 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 // is read again: a product created a moment ago can be missing from it for a minute, and `apple iap-review` would // then skip it, so that gap is named (docs/pitfalls.md). import { sellsOn } from '../lib/config'; import { APPLE_IAP_LIMITS } from '../lib/copy'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { dryPlan, type PlanRow } from '../lib/dry'; import { done, log, step } from '../lib/log'; import { LAG_HINT, reportMissing } from '../lib/store-lag'; 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; export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const bundleId = need(config.apple?.bundleId, 'apple.bundleId'); const only = ctx.args.list('only'); const products = need(config.products, 'products').filter(p => (!only.length || only.includes(p.id)) && sellsOn(config, p, 'apple')); const dry = ctx.args.has('dry'); const c = await ascClient(root, bundleId, config.apple?.appId); const appId = await c.app(); const langs = new Map(config.languages.map(l => [l.id, l])); const territories: string[] = (await c.all('/v1/territories?limit=200')).map(t => t.id); async function pricePoint(iapId: string, territory: string, price: number): Promise { const seen: number[] = []; for (const p of await c.all(`/v2/inAppPurchases/${iapId}/pricePoints?filter[territory]=${territory}&limit=200`)) { if (samePrice(p.attributes.customerPrice, price)) return p.id; seen.push(Number(p.attributes.customerPrice)); } 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()) { const tag = step(i + 1, products.length, p.id); const lookup = async (): Promise => (await c.asc('GET', `/v1/apps/${appId}/inAppPurchasesV2?filter[productId]=${encodeURIComponent(p.id)}`)).data ?.find((x: J) => x.attributes?.productId === p.id) ?? null; if (dry) { const f = await lookup(); rows.push(await planProduct(p, f?.id, f?.attributes?.state)); continue; } // product ids are unique per app: a 409 finds the existing one; the journal covers the list lag after a create const first = Object.values(p.listings)[0]!; const made = await findOrCreate({ what: `in-app purchase ${p.id}`, tag: 'apple', unique: true, find: lookup, journal: { root, key: `apple:${bundleId}:iap:${p.id}`, byId: id => getById(c.asc, `/v2/inAppPurchases/${id}`) as Promise, idOf: v => v.id }, create: async () => (await c.asc('POST', '/v2/inAppPurchases', { data: { type: 'inAppPurchases', attributes: { name: p.referenceName ?? first.title, productId: p.id, inAppPurchaseType: APPLE_IAP_TYPE[p.type], familySharable: p.familySharable ?? false, ...(p.reviewNote ? { reviewNote: p.reviewNote } : {}) }, relationships: { app: { data: { type: 'apps', id: appId } } } } })).data }); const id: string = made.value.id; if (made.created) created.push(p.id); log('apple', `${tag}: ${made.created ? 'created' : 'exists'}`); const locsOf = async () => await c.all(`/v2/inAppPurchases/${id}/inAppPurchaseLocalizations`); const have = new Set((await locsOf()).map((l: J) => l.attributes.locale)); for (const [langId, l] of Object.entries(p.listings)) { const locale = langs.get(langId)?.apple; if (!locale || have.has(locale)) continue; if ([...l.description].length > APPLE_IAP_LIMITS.description) throw new Error(`${p.id} ${locale}: description over ${APPLE_IAP_LIMITS.description} characters (Apple limit)`); await createOnce({ what: `${p.id} localization ${locale}`, tag: 'apple', unique: true, find: async () => (await locsOf()).find((x: J) => x.attributes.locale === locale) ?? null, create: async () => (await c.asc('POST', '/v1/inAppPurchaseLocalizations', { data: { type: 'inAppPurchaseLocalizations', attributes: { locale, name: l.title, description: l.description }, relationships: { inAppPurchaseV2: { data: { type: 'inAppPurchases', id } } } } })).data }); log('apple', ` localization ${locale}`); } const sched = await c.maybe(`/v2/inAppPurchases/${id}/iapPriceSchedule?include=manualPrices`); if (!sched?.included?.length) { 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`); if (!avail?.data) { await c.asc('POST', '/v1/inAppPurchaseAvailabilities', { data: { type: 'inAppPurchaseAvailabilities', attributes: { availableInNewTerritories: true }, relationships: { inAppPurchase: { data: { type: 'inAppPurchases', id } }, availableTerritories: { data: territories.map(t => ({ type: 'territories', id: t })) } } } }); log('apple', ` available in ${territories.length} territories + new ones`); } } if (dry) { console.log(dryPlan('apple iap', rows)); const absent = rows.filter(r => r.action === 'create').map(r => r.item); if (absent.length) log('apple', `not on App Store Connect: ${absent.join(', ')}. If you created them in the last minutes, ${LAG_HINT} before acting on this plan`); done('apple', 'iap checked (dry run)'); return; } if (created.length) { await rt.sleep(Number(process.env.STOREBOUND_LIST_SETTLE_MS ?? 3000)); const listed = (await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)).map((x: J) => x.attributes.productId as string); if (reportMissing('apple', 'new products', created, listed, { fail: false }).length) log('apple', 'wait a minute before apple iap-review, or it cannot see them'); } done('apple', 'iap done (next: apple iap-review for the review screenshots)'); /** What a real run would do to one product, from read-only calls. */ async function planProduct(p: (typeof products)[number], id: string | undefined, state: string | undefined): Promise { const locales = Object.keys(p.listings).map(l => langs.get(l)?.apple).filter((l): l is string => !!l); const prices = [`USA ${p.usd}`, ...(p.overrides ?? []).map(o => `${appleTerritory(o)} ${o.price}`)].join(', '); if (!id) return { item: p.id, action: 'create', detail: `${APPLE_IAP_TYPE[p.type]}; localizations ${locales.join(', ')}; prices ${prices}; ${territories.length} territories + new ones` }; const have = new Set((await c.all(`/v2/inAppPurchases/${id}/inAppPurchaseLocalizations`)).map((l: J) => l.attributes.locale)); 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 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' }; } }