// 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. DEVELOPER_REJECTED (the owner canceled the submission * it was in) goes in as it is: seen live (2026-10-12), POST reviewSubmissionItems with that same in-app purchase version * answers 201 READY_FOR_REVIEW and the submit 200 WAITING_FOR_REVIEW; no new version is needed (--new-version is for * APPROVED or REJECTED products whose metadata changed). */ const SUBMITTABLE = new Set(['PREPARE_FOR_SUBMISSION', 'READY_FOR_REVIEW', 'DEVELOPER_REJECTED']); /** What the plan says about a DEVELOPER_REJECTED version it adds. */ export const DEV_REJECTED_NOTE = 'developer rejected, re-submitted as is'; /** Product version states for which --new-version makes a new draft version first. */ export const NEW_VERSION_FROM = new Set(['APPROVED', 'REJECTED']); 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 } /** Why an asked-for item is not added: the open draft holds it already. */ export const ALREADY_IN_DRAFT = 'already in the draft submission'; export interface SubmitState { appVersions: PAppVersion[]; iaps: PIap[]; subs: PSub[]; groups: PGroup[]; /** The submission this run uses, if one exists, and the version ids already in it: a draft (READY_FOR_REVIEW), or * one back from App Review (UNRESOLVED_ISSUES), which is resubmitted, never replaced by a second one. */ draft?: { id: string; itemVersionIds: string[]; state?: string } | null; /** Every open review submission (chooseSubmission) and what was decided. */ open?: OpenSub[]; choice?: SubmissionChoice; } export interface OpenSub { id: string; state: string; items: { id?: string; kind: string; versionId: string; state: string }[] } /** Which REJECTED items of a submission back from App Review a run marks resolved (PATCH reviewSubmissionItems * resolved: true) before it submits. Apple answers every submit with 409 "Version is not ready to be submitted yet" * while one is left (seen live for 25 minutes after an Invalid Binary rejection; retrying never helps). * version: the appStoreVersion item (apple resubmit attaches a new build); iaps: in-app purchase, subscription and * subscription group items (only on --resolve-iaps: the owner fixed what App Review asked for). */ export interface ResolveScope { version: boolean; iaps: boolean } /** The REJECTED items `scope` covers. Pure. */ export function toResolve(items: T[], scope?: ResolveScope): T[] { if (!scope) return []; return items.filter(i => i.state === 'REJECTED' && (i.kind === 'appStoreVersion' ? scope.version : scope.iaps)); } /** How to resolve a REJECTED item a run left alone. Pure. */ export function resolveHint(kind: string): string { return kind === 'appStoreVersion' ? 'attach a fixed build and mark it resolved: storebound apple resubmit --build ' : 'fix what App Review asked for, then mark it resolved with --resolve-iaps (storebound apple resubmit --build --resolve-iaps, or apple iap-submit --resolve-iaps)'; } /** What the plan does with an empty draft next to the reused submission. */ export const EMPTY_DRAFT = 'empty draft (harmless; App Store Connect keeps it, not cancellable)'; export type SubmissionChoice = | { action: 'reuse'; sub: OpenSub; why: string; strayDrafts: OpenSub[] } | { action: 'busy'; sub: OpenSub; why: string } | { action: 'create'; why: string }; /** * Which review submission to use, from the app's open ones (READY_FOR_REVIEW, UNRESOLVED_ISSUES, WAITING_FOR_REVIEW, * IN_REVIEW). Never a second one while one of them exists (seen live: a new empty draft next to a rejected * submission, then 409 ITEM_PART_OF_ANOTHER_SUBMISSION for the app version): * - one waiting for or in review: nothing can be submitted until App Review is done (busy); * - one with UNRESOLVED_ISSUES (rejected, preferably the one holding `appVersionId`): reused and resubmitted, with * only the missing items added; empty drafts next to it are strays; * - a draft (READY_FOR_REVIEW): reused; * - none: a new draft. Pure. */ export function chooseSubmission(open: OpenSub[], o: { appVersionId?: string } = {}): SubmissionChoice { const busy = open.find(x => IN_REVIEW.has(x.state)); if (busy) return { action: 'busy', sub: busy, why: `review submission ${busy.id} is ${busy.state}: nothing more can be submitted until App Review finishes it (storebound apple status --wait-review)` }; const unresolved = open.filter(x => x.state === 'UNRESOLVED_ISSUES'); const holder = unresolved.find(x => o.appVersionId && x.items.some(i => i.versionId === o.appVersionId)) ?? unresolved[0]; const drafts = open.filter(x => x.state === 'READY_FOR_REVIEW'); if (holder) return { action: 'reuse', sub: holder, strayDrafts: drafts, why: `review submission ${holder.id} has UNRESOLVED_ISSUES (back from App Review)${holder.items.some(i => i.versionId === o.appVersionId) ? ' and holds the app version' : ''}: reused and resubmitted, no new draft` }; if (drafts[0]) return { action: 'reuse', sub: drafts[0], strayDrafts: drafts.slice(1), why: `draft review submission ${drafts[0].id} reused` }; return { action: 'create', why: 'no open review submission: a new draft' }; } export interface PlanItem { kind: ItemKind; versionId: string; label: string; /** a DEVELOPER_REJECTED version added again as it is */ devRejected?: boolean } 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; appVersionId?: string; platform?: string } = {}): 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 => { const before = plan.skipped.length; pickOne(id, productState, versions, kind, type); // a never-approved first product left out while the app version goes to review: not sellable after approval (pitfall 70) const firstOfType = kind === 'subscriptionVersion' ? !s.subs.some(x => x.groupId === s.subs.find(y => y.productId === id)?.groupId && APPROVED_PRODUCT.has(x.state)) : !approvedTypes.has(type); if (plan.skipped.length > before && firstOfType && NEVER_SUBMITTED.has(productState) && plan.skipped.at(-1)?.why !== ALREADY_IN_DRAFT) leftOut.push(`${id} (${plan.skipped.at(-1)?.why})`); }; const leftOut: string[] = []; const pickOne = (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}: ${NEW_VERSION_FROM.has(v.state) ? 'add --new-version to send a new version' : 'change it in App Store Connect to send a new version'}` }); return; } if (already.has(v.id)) { plan.skipped.push({ id, why: ALREADY_IN_DRAFT }); return; } plan.items.push({ kind, versionId: v.id, label: `${id} (${typeLabel(type)}, version ${v.version ?? '?'})`, ...(v.state === 'DEVELOPER_REJECTED' ? { devRejected: true } : {}) }); 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)`, ...(v.state === 'DEVELOPER_REJECTED' ? { devRejected: true } : {}) }); } plan.firstOfType = [...firstTypes].map(typeLabel); if (o.appVersionId && leftOut.length) plan.blockers.push(`never-approved first in-app purchases would be left out of this review, and are not sellable after the version is approved (Apple reviews them only with an app version): ${leftOut.join('; ')}. Fix them first, or take them out of the config's App Store products`); const forced = o.appVersionId ? s.appVersions.find(v => v.id === o.appVersionId) : undefined; plan.needsAppVersion = !!forced || (plan.items.length > 0 && (firstTypes.size > 0 || !plan.hasApprovedVersion)); if (plan.needsAppVersion) { // resubmit: the version named by the caller; else the editable one const editable = forced ?? 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 && !forced) 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}`, ...(editable.state === 'DEVELOPER_REJECTED' ? { devRejected: true } : {}) }); } } 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: o.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; } /** The plan's word for an item it adds. Pure. */ export const addAction = (it: PlanItem): string => (it.devRejected ? `add (${DEV_REJECTED_NOTE})` : 'add'); /** Whether the run ends with a submit: new items, a submission back from App Review that only needs resubmitting, or a * draft prepared earlier (`--no-submit`) that already holds what was asked for: the app version when one is named, * else at least one of the asked-for items. Pure. */ export function willSubmit(s: SubmitState, plan: SubmitPlan, appVersionId?: string): boolean { if (plan.items.length > 0 || s.draft?.state === 'UNRESOLVED_ISSUES') return true; const held = s.draft?.itemVersionIds ?? []; if (!held.length) return false; return appVersionId ? held.includes(appVersionId) : plan.skipped.some(sk => sk.why === ALREADY_IN_DRAFT); } /** 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 ?? '?' })); // ------------------------------------------------------------------------------------------------------- resubmit /** App Store version states that come back from App Review and can be fixed and resubmitted. */ export const REJECTED_APP = new Set(['REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY', 'DEVELOPER_REJECTED']); export interface BuildStep { number: string; id: string | null; current: string | null; action: 'attach' | 'same' | 'refuse'; reason: string } export interface PlanRow { step: string; what: string; now: string; action: string } /** What a version id stands for: app version x.y.z, a product id, a subscription group. Pure. */ export function labelOf(s: SubmitState, versionId: string, kind: string): string { const av = s.appVersions.find(v => v.id === versionId); if (av) return `app version ${av.versionString}`; const p = [...s.iaps, ...s.subs].find(x => x.versions.some(v => v.id === versionId)); if (p) return p.productId; const g = s.groups.find(x => x.versions.some(v => v.id === versionId)); if (g) return `subscription group "${g.name}"`; return `${kind} ${versionId}`; } /** The dry-run table of `apple resubmit`: build, submission, every item (in it already or added), submit. Pure. */ export function resubmitRows(s: SubmitState, plan: SubmitPlan, build: BuildStep, resolve?: ResolveScope): PlanRow[] { const rows: PlanRow[] = [{ step: 'build', what: `build ${build.number}`, now: build.current ? `attached: ${build.current}` : 'none attached', action: build.action === 'refuse' ? `REFUSE: ${build.reason}` : build.reason }]; const ch = s.choice; if (ch?.action === 'busy') rows.push({ step: 'submission', what: ch.sub.id, now: ch.sub.state, action: 'nothing to do until App Review finishes' }); else if (ch?.action === 'reuse') rows.push({ step: 'submission', what: ch.sub.id, now: ch.sub.state, action: ch.sub.state === 'UNRESOLVED_ISSUES' ? 'reuse (resubmit, no new draft)' : 'reuse the draft' }); else rows.push({ step: 'submission', what: 'new draft', now: '-', action: 'create (no open submission)' }); if (ch && ch.action !== 'create') { const marked = new Set(ch.action === 'reuse' ? toResolve(ch.sub.items, resolve) : []); for (const it of ch.sub.items) rows.push({ step: 'item', what: labelOf(s, it.versionId, it.kind), now: it.state, action: marked.has(it) ? 'in it already; mark resolved' : it.state === 'REJECTED' ? `REJECTED, left: ${resolveHint(it.kind)}` : 'in it already' }); } for (const it of plan.items) rows.push({ step: 'item', what: it.label, now: it.devRejected ? 'DEVELOPER_REJECTED' : '-', action: addAction(it) }); if (ch?.action === 'reuse') for (const d of ch.strayDrafts) rows.push({ step: 'stray draft', what: d.id, now: `${d.state}, ${d.items.length} item(s)`, action: d.items.length ? 'left as it is (named)' : `left as it is: ${EMPTY_DRAFT}` }); if (ch?.action !== 'busy') rows.push({ step: 'submit', what: 'PATCH submitted: true', now: '-', action: 'send with --yes (update of an app on sale: no owner step; the first-ever release: the owner approves it once); retried while Apple says "not ready yet" and no item is left REJECTED' }); return rows; } /** Rows as aligned text columns. Pure. */ export function formatRows(rows: PlanRow[]): string { const head: PlanRow = { step: 'STEP', what: 'WHAT', now: 'NOW', action: 'ACTION' }; const all = [head, ...rows]; const w = (k: keyof PlanRow): number => Math.max(...all.map(r => r[k].length)); return all.map(r => `${r.step.padEnd(w('step'))} ${r.what.padEnd(w('what'))} ${r.now.padEnd(w('now'))} ${r.action}`).join('\n'); } // ------------------------------------------------------------------------------- first products left out of a review /** States of a product that never went through App Review and is not in one now (pitfall 70). */ export const NEVER_SUBMITTED = new Set(['READY_TO_SUBMIT', 'MISSING_METADATA', 'DEVELOPER_ACTION_NEEDED']); const REVIEW_APP = new Set(['WAITING_FOR_REVIEW', 'IN_REVIEW']); export interface FirstProductInput { iaps: { productId: string; type: string; state: string; versionIds?: string[] }[]; subs: { productId: string; groupId?: string; state: string; versionIds?: string[] }[]; /** The open review submission (draft, back from review, waiting or in review) and the version ids of its items. */ submission?: { id: string; state: string; itemVersionIds: string[] } | null; /** State of the app version that is (or will be) submitted. */ versionState?: string; /** Only these product ids count (the config's); all when absent. */ only?: string[]; } export interface FirstProductGap { productId: string; state: string; kind: 'iap' | 'subscription'; level: 'error' | 'warn'; msg: string; fix: string } /** * First in-app purchases of a type (and first subscriptions of a group) that never passed App Review and are not in * the app's open review submission. Apple only reviews them together with an app version; a version approved without * them leaves them unsellable (seen live: a starter pack stayed READY_TO_SUBMIT for two days next to a version * WAITING_FOR_REVIEW). error: an open submission (or one in review) lacks them, or one cannot go in (missing * metadata); warn: nothing is open yet, the product must go in with the version. Pure. */ export function firstProductGaps(s: FirstProductInput): FirstProductGap[] { const approvedTypes = new Set(s.iaps.filter(p => APPROVED_PRODUCT.has(p.state)).map(p => p.type)); const approvedGroups = new Set(s.subs.filter(p => APPROVED_PRODUCT.has(p.state)).map(p => p.groupId ?? '?')); const items = new Set(s.submission?.itemVersionIds ?? []); const inReview = REVIEW_APP.has(s.versionState ?? '') || REVIEW_APP.has(s.submission?.state ?? ''); const out: FirstProductGap[] = []; const consider = (productId: string, state: string, kind: FirstProductGap['kind'], versionIds: string[] = []): void => { if (s.only && !s.only.includes(productId)) return; if (!NEVER_SUBMITTED.has(state)) return; if (versionIds.some(v => items.has(v))) return; const what = kind === 'iap' ? 'first in-app purchase of its type' : 'first subscription of its group'; if (inReview) { out.push({ productId, state, kind, level: 'warn', msg: `${productId} is not in this review (${what}, never approved, ${state}): it will not be sellable after approval`, fix: 'add it with the next version, or withdraw the submission (storebound apple status --cancel-submission ) and resubmit with it (storebound apple submit --build )' }); } else if (state !== 'READY_TO_SUBMIT') { out.push({ productId, state, kind, level: 'error', msg: `${productId} (${what}, never approved) is ${state} and cannot go into the review with the app version: approved without it, it is not sellable`, fix: 'complete it first (storebound apple iap-review / apple subscriptions), then storebound apple submit --build adds it to the submission' }); } else { out.push({ productId, state, kind, level: s.submission ? 'error' : 'warn', msg: s.submission ? `${productId} (${what}, never approved, READY_TO_SUBMIT) is not in review submission ${s.submission.id}: approved without it, it is not sellable` : `${productId} (${what}, never approved, READY_TO_SUBMIT) must go into the same review submission as the app version`, fix: 'storebound apple submit --build adds it (or apple iap-submit --with-version); submitting from the App Store Connect page leaves it out unless you add it on the version page' }); } }; for (const p of s.iaps) if (!approvedTypes.has(p.type)) consider(p.productId, p.state, 'iap', p.versionIds); for (const p of s.subs) if (!approvedGroups.has(p.groupId ?? '?')) consider(p.productId, p.state, 'subscription', p.versionIds); return out; } /** How iap-review names a product's state: READY_TO_SUBMIT is never "complete" for a product that was not approved * (pitfall 70). `typeApproved`: another product of its type passed review already. Pure. */ export function productStateText(state: string, typeApproved: boolean): string { if (state !== 'READY_TO_SUBMIT') return state; return typeApproved ? 'READY_TO_SUBMIT: ready, not submitted yet (storebound apple iap-submit)' : 'READY_TO_SUBMIT: ready, not submitted yet (goes in with the next app version: storebound apple submit --build adds it)'; }