// Sending in-app purchases and subscriptions to App Review through review submissions, as a pure planner. The command // (cli/apple/iap-submit.ts) reads the state, calls `planIapSubmission`, prints the plan and only sends it after the // owner says yes in a terminal. Never an MCP tool: submitting for review is the owner's action. // // How App Store Connect does it (API 4.4.1+, OpenAPI spec 4.5.1, checked October 2026): // - Every in-app purchase, subscription and subscription group has versions (inAppPurchaseVersions, // subscriptionVersions, subscriptionGroupVersions; GET /v2/inAppPurchases/{id}/versions, /v1/subscriptions/{id}/versions, // /v1/subscriptionGroups/{id}/versions). "Use the In-App Purchase version for submitting to review." // - A version goes to App Review as an item of a review submission: POST /v1/reviewSubmissions (app; platform // optional), POST /v1/reviewSubmissionItems with inAppPurchaseVersion | subscriptionVersion | subscriptionGroupVersion // (or appStoreVersion), then PATCH /v1/reviewSubmissions/{id} { submitted: true }. // The older inAppPurchaseSubmissions / subscriptionSubmissions / subscriptionGroupSubmissions are deprecated. // https://developer.apple.com/documentation/appstoreconnectapi/app-store-connect-api-4-4-1-release-notes // https://developer.apple.com/documentation/appstoreconnectapi/review-submissions // https://developer.apple.com/documentation/appstoreconnectapi/in-app-purchase-versions // The rules (App Store Connect Help, "Submit an In-App Purchase", checked October 2026): // - "The first consumable, non-consumable, auto-renewable subscription, and non-renewing subscription In-App // Purchase of each type must be submitted with a new app version." // - Later items of an approved type go without an app version "as long as your app has at least one approved app // version at the time of submission". // - A new subscription goes together with its subscription group while the group is not approved; "each new // subscription group must be submitted with at least one of its subscriptions". // - Everything reviewed together goes into the same draft submission; up to 200 items per submission. // - In-App Purchases with content hosting can no longer be submitted. In-app purchases are not supported on Apple Watch. // https://developer.apple.com/help/app-store-connect/manage-submissions-to-app-review/submit-an-in-app-purchase import type { Req } from '../apple/jsonapi'; import { one } from '../apple/jsonapi'; export const MAX_SUBMISSION_ITEMS = 200; /** App Store version states after which the app counts as having an approved version. */ const APPROVED_APP = new Set(['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE', 'PENDING_DEVELOPER_RELEASE', 'PENDING_APPLE_RELEASE', 'PROCESSING_FOR_DISTRIBUTION', 'PROCESSING_FOR_APP_STORE', 'REPLACED_WITH_NEW_VERSION', 'ACCEPTED', 'DEVELOPER_REMOVED_FROM_SALE', 'REMOVED_FROM_SALE']); /** Product states (InAppPurchaseState / Subscription.state) that mean the product was approved once. */ const APPROVED_PRODUCT = new Set(['APPROVED', 'DEVELOPER_REMOVED_FROM_SALE', 'REMOVED_FROM_SALE']); /** Version states that can still go into a draft submission. */ const SUBMITTABLE = new Set(['PREPARE_FOR_SUBMISSION', 'READY_FOR_REVIEW']); const IN_REVIEW = new Set(['WAITING_FOR_REVIEW', 'IN_REVIEW']); export type ItemKind = 'inAppPurchaseVersion' | 'subscriptionVersion' | 'subscriptionGroupVersion' | 'appStoreVersion'; const ITEM_TYPE: Record = { inAppPurchaseVersion: 'inAppPurchaseVersions', subscriptionVersion: 'subscriptionVersions', subscriptionGroupVersion: 'subscriptionGroupVersions', appStoreVersion: 'appStoreVersions', }; export interface PVersion { id: string; version?: number; state: string } export interface PIap { id: string; productId: string; type: string; state: string; versions: PVersion[] } export interface PSub { id: string; productId: string; groupId: string; state: string; versions: PVersion[] } export interface PGroup { id: string; name: string; versions: PVersion[] } export interface PAppVersion { id: string; versionString: string; state: string } export interface SubmitState { appVersions: PAppVersion[]; iaps: PIap[]; subs: PSub[]; groups: PGroup[]; /** The app's draft review submission (state READY_FOR_REVIEW), if one exists, and the version ids already in it. */ draft?: { id: string; itemVersionIds: string[] } | null; } export interface PlanItem { kind: ItemKind; versionId: string; label: string } export interface SubmitPlan { items: PlanItem[]; /** Product ids that are left out, with the reason. */ skipped: { id: string; why: string }[]; /** Types submitted for the first time (need an app version in the same submission). */ firstOfType: string[]; hasApprovedVersion: boolean; /** An app version must be in this submission (first of a type, or no approved app version yet). */ needsAppVersion: boolean; /** The editable app version that would go in (with withVersion). */ appVersion?: PAppVersion; /** Problems that stop the submission (nothing is sent while any is left). */ blockers: string[]; /** Requests up to and including adding the items (a draft the owner can also submit in App Store Connect). */ prepare: Req[]; /** The submit request itself (the owner's action). Uses the placeholder id "${submission}" when the draft is new. */ submit: Req; } const typeLabel = (t: string): string => ({ CONSUMABLE: 'consumable', NON_CONSUMABLE: 'non-consumable', NON_RENEWING_SUBSCRIPTION: 'non-renewing subscription', AUTO_RENEWABLE: 'auto-renewable subscription' }[t] ?? t); /** The newest version of a product (highest `version`, else the last listed). */ export function latestVersion(vs: PVersion[]): PVersion | undefined { return [...vs].sort((a, b) => (a.version ?? 0) - (b.version ?? 0)).at(-1); } /** * Plan for submitting `want` (product ids of in-app purchases and subscriptions). withVersion: put the editable app * version into the same submission (needed for the first item of a type, or while the app has no approved version). */ export function planIapSubmission(s: SubmitState, want: string[], o: { withVersion?: boolean } = {}): SubmitPlan { const plan: SubmitPlan = { items: [], skipped: [], firstOfType: [], hasApprovedVersion: s.appVersions.some(v => APPROVED_APP.has(v.state)), needsAppVersion: false, blockers: [], prepare: [], submit: { method: 'PATCH', path: '/v1/reviewSubmissions/${submission}', note: 'submit for App Review', body: {} } }; const approvedTypes = new Set([...s.iaps.filter(p => APPROVED_PRODUCT.has(p.state)).map(p => p.type), ...(s.subs.some(x => APPROVED_PRODUCT.has(x.state)) ? ['AUTO_RENEWABLE'] : [])]); const firstTypes = new Set(); const already = new Set(s.draft?.itemVersionIds ?? []); const groupsNeeded = new Set(); const pick = (id: string, productState: string, versions: PVersion[], kind: ItemKind, type: string): void => { if (productState === 'MISSING_METADATA') { plan.skipped.push({ id, why: 'missing metadata (price, localization or review screenshot): storebound apple iap / apple subscriptions / apple iap-review' }); return; } if (productState === 'WAITING_FOR_UPLOAD' || productState === 'PROCESSING_CONTENT') { plan.skipped.push({ id, why: `hosted content ${productState}: content-hosted purchases can no longer be submitted` }); return; } const v = latestVersion(versions); if (!v) { plan.skipped.push({ id, why: 'no version on App Store Connect yet (create the product first: storebound apple iap / apple subscriptions)' }); return; } if (IN_REVIEW.has(v.state)) { plan.skipped.push({ id, why: `already ${v.state}` }); return; } if (!SUBMITTABLE.has(v.state)) { plan.skipped.push({ id, why: `latest version is ${v.state}: change it in App Store Connect (or --new-version) to send a new version` }); return; } if (already.has(v.id)) { plan.skipped.push({ id, why: 'already in the draft submission' }); return; } plan.items.push({ kind, versionId: v.id, label: `${id} (${typeLabel(type)}, version ${v.version ?? '?'})` }); if (!approvedTypes.has(type)) firstTypes.add(type); }; for (const id of want) { const iap = s.iaps.find(p => p.productId === id); const sub = s.subs.find(p => p.productId === id); if (iap) pick(id, iap.state, iap.versions, 'inAppPurchaseVersion', iap.type); else if (sub) { const before = plan.items.length; pick(id, sub.state, sub.versions, 'subscriptionVersion', 'AUTO_RENEWABLE'); if (plan.items.length > before) groupsNeeded.add(sub.groupId); } else plan.skipped.push({ id, why: 'not on App Store Connect (storebound apple iap / apple subscriptions creates it)' }); } for (const gid of groupsNeeded) { const g = s.groups.find(x => x.id === gid); if (!g) continue; if (g.versions.some(v => v.state === 'APPROVED') || s.subs.some(x => x.groupId === gid && APPROVED_PRODUCT.has(x.state))) continue; const v = latestVersion(g.versions); if (!v) { plan.blockers.push(`subscription group "${g.name}" has no version to submit`); continue; } if (IN_REVIEW.has(v.state) || already.has(v.id)) continue; if (!SUBMITTABLE.has(v.state)) { plan.blockers.push(`subscription group "${g.name}": version is ${v.state}`); continue; } plan.items.push({ kind: 'subscriptionGroupVersion', versionId: v.id, label: `subscription group "${g.name}" (not approved yet, goes with its subscriptions)` }); } plan.firstOfType = [...firstTypes].map(typeLabel); plan.needsAppVersion = plan.items.length > 0 && (firstTypes.size > 0 || !plan.hasApprovedVersion); if (plan.needsAppVersion) { const editable = s.appVersions.find(v => ['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY', 'READY_FOR_REVIEW'].includes(v.state)); const why = firstTypes.size ? `first ${plan.firstOfType.join(' / ')} of this app: Apple reviews it only together with a new app version` : 'the app has no approved version yet: in-app purchases go together with an app version'; if (!o.withVersion) plan.blockers.push(`${why}. Add --with-version (the editable version ${editable ? editable.versionString : '(none yet: storebound apple listing --version x.y.z --build N)'} goes into the same submission; submitting it submits the app)`); else if (!editable) plan.blockers.push(`${why}, and there is no editable app version: storebound apple listing --version x.y.z --build N first`); else if (!already.has(editable.id)) { plan.appVersion = editable; plan.items.unshift({ kind: 'appStoreVersion', versionId: editable.id, label: `app version ${editable.versionString}` }); } } if (plan.items.length + (s.draft?.itemVersionIds.length ?? 0) > MAX_SUBMISSION_ITEMS) plan.blockers.push(`more than ${MAX_SUBMISSION_ITEMS} items in one submission: use --only for a part`); const sid = s.draft?.id ?? '${submission}'; if (!s.draft && plan.items.length) plan.prepare.push({ method: 'POST', path: '/v1/reviewSubmissions', note: 'new draft review submission', body: { data: { type: 'reviewSubmissions', attributes: { platform: 'IOS' }, relationships: { app: one('apps', '${app}') } } } }); for (const it of plan.items) plan.prepare.push({ method: 'POST', path: '/v1/reviewSubmissionItems', note: it.label, body: { data: { type: 'reviewSubmissionItems', relationships: { reviewSubmission: one('reviewSubmissions', sid), [it.kind]: one(ITEM_TYPE[it.kind], it.versionId) } } } }); plan.submit = { method: 'PATCH', path: `/v1/reviewSubmissions/${sid}`, note: 'submit for App Review', body: { data: { type: 'reviewSubmissions', id: sid, attributes: { submitted: true } } } }; return plan; } /** Fills the placeholders of a planned request (app id, the id of the draft created a moment ago). Pure. */ export function bindIds(r: Req, ids: { app?: string; submission?: string }): Req { const text = JSON.stringify(r).replaceAll('${app}', ids.app ?? '${app}').replaceAll('${submission}', ids.submission ?? '${submission}'); return JSON.parse(text) as Req; } /** POST /v1/inAppPurchaseVersions | /v1/subscriptionVersions: a new draft version of an approved (or rejected) * product, "capturing its current localized metadata and review images for App Review submission". */ export function newVersionReq(kind: 'iap' | 'sub', productApiId: string, label: string): Req { return kind === 'iap' ? { method: 'POST', path: '/v1/inAppPurchaseVersions', note: `new version of ${label}`, body: { data: { type: 'inAppPurchaseVersions', relationships: { inAppPurchase: one('inAppPurchases', productApiId) } } } } : { method: 'POST', path: '/v1/subscriptionVersions', note: `new version of ${label}`, body: { data: { type: 'subscriptionVersions', relationships: { subscription: one('subscriptions', productApiId) } } } }; } /** Versions response -> PVersion list. Pure. */ export const versionsOf = (data: { id: string; attributes?: { version?: number; state?: string } }[]): PVersion[] => data.map(v => ({ id: v.id, version: v.attributes?.version, state: v.attributes?.state ?? '?' }));