// 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] // 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). import { sellsOn } from '../lib/config'; import { APPLE_IAP_LIMITS } from '../lib/copy'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { done, log, step } from '../lib/log'; import { appleTerritory, nearestPrices, samePrice } from '../lib/money'; import { ascClient, type J } from './asc'; /** 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(', ')})`); } for (const [i, p] of products.entries()) { const tag = step(i + 1, products.length, p.id); const found = await c.asc('GET', `/v1/apps/${appId}/inAppPurchasesV2?filter[productId]=${encodeURIComponent(p.id)}`); let id: string = found.data[0]?.id; if (dry) { log('apple', `${tag}: ${id ? `exists (${found.data[0].attributes.state})` : 'missing'} (dry)`); continue; } if (!id) { const first = Object.values(p.listings)[0]!; id = (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.id; log('apple', `${tag}: created`); } else log('apple', `${tag}: exists`); const have = new Set((await c.all(`/v2/inAppPurchases/${id}/inAppPurchaseLocalizations`)).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 c.asc('POST', '/v1/inAppPurchaseLocalizations', { data: { type: 'inAppPurchaseLocalizations', attributes: { locale, name: l.title, description: l.description }, relationships: { inAppPurchaseV2: { data: { type: 'inAppPurchases', id } } } } }); log('apple', ` localization ${locale}`); } 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] } } } })), }); log('apple', ` prices ${manual.map(m => `${m.territory} ${m.price}`).join(', ')}`); } 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`); } } done('apple', dry ? 'iap checked (dry run)' : 'iap done (next: apple iap-review for the review screenshots)'); }