// Send in-app purchases and subscriptions to App Review through a review submission (the owner's action). // storebound apple iap-submit [--only id1,id2] [--with-version] [--new-version] [--resolve-iaps] [--no-submit] [--dry] [--yes] [--json] // Default: every product and subscription of the config sold on the App Store. The plan (cli/lib/review-submit.ts) // says which version of each goes in, which items are first of their type (those need an app version in the same // submission: --with-version adds the editable one, and then submitting submits the app too), and what is left out. // --new-version: approved or rejected products get a new draft version first (POST inAppPurchaseVersions / // subscriptionVersions) so changed metadata can go to review. --no-submit: only fill the draft submission; the owner // presses "Submit for Review" in App Store Connect. Without --no-submit the last step asks first (never under MCP). // The submission is the app's open one when there is one (chooseSubmission): a draft, or one back from App Review with // UNRESOLVED_ISSUES, which gets only the missing items and is resubmitted; while one waits for or is in review nothing // is sent. A new draft is created only when none is open (storebound apple resubmit uses the same steps). // --resolve-iaps: REJECTED in-app purchase / subscription items of a submission back from App Review are marked // resolved before the submit (Apple refuses the submit with "not ready yet" while one is left). A REJECTED app version // item is left to apple resubmit --build , which attaches the fixed build first. import { sellsOn } from '../lib/config'; import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; import { allSubscriptions } from '../lib/subscriptions'; import { bindIds, chooseSubmission, EMPTY_DRAFT, labelOf, type ResolveScope, resolveHint, toResolve, latestVersion, newVersionReq, type PIap, planIapSubmission, type PSub, type SubmitState, versionsOf, willSubmit } from '../lib/review-submit'; import { readJournal } from '../lib/upsert'; import { type Asc, ascClient, type J } from './asc'; import { describeReq, execute } from './jsonapi'; import { addItem, draftJournalKey, ensureDraft, leaveUnusedDraft, openSubmissions, OtherSubmissionError, resolveRejected, submitWithRetry } from './submission'; const TAG = 'iap-submit'; export async function readSubmitState(c: Asc, appId: string, want: string[], o: { appVersionId?: string; also?: string } = {}): Promise { const appVersions = (await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`)).map((v: J) => ({ id: v.id, versionString: v.attributes.versionString, state: v.attributes.appVersionState ?? v.attributes.appStoreState })); const iaps: PIap[] = []; for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) { // every product counts for "first of its type"; versions are only read for the products we submit const versions = want.includes(p.attributes.productId) ? versionsOf(await c.all(`/v2/inAppPurchases/${p.id}/versions?limit=200`)) : []; iaps.push({ id: p.id, productId: p.attributes.productId, type: p.attributes.inAppPurchaseType, state: p.attributes.state, versions }); } const subs: PSub[] = []; const groups: SubmitState['groups'] = []; for (const g of await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`)) { const members = await c.all(`/v1/subscriptionGroups/${g.id}/subscriptions?limit=200`); const wanted = members.some((s: J) => want.includes(s.attributes.productId)); groups.push({ id: g.id, name: g.attributes.referenceName, versions: wanted ? versionsOf(await c.all(`/v1/subscriptionGroups/${g.id}/versions?limit=200`)) : [] }); for (const s of members) subs.push({ id: s.id, productId: s.attributes.productId, groupId: g.id, state: s.attributes.state, versions: want.includes(s.attributes.productId) ? versionsOf(await c.all(`/v1/subscriptions/${s.id}/versions?limit=200`)) : [] }); } // every open review submission: a draft or one back from App Review is reused, never a second one next to it const open = await openSubmissions(c, appId, o.also); const choice = chooseSubmission(open, { appVersionId: o.appVersionId ?? appVersions.find((v: { state: string }) => /REJECTED|INVALID_BINARY/.test(v.state))?.id }); const draft: SubmitState['draft'] = choice.action === 'reuse' ? { id: choice.sub.id, state: choice.sub.state, itemVersionIds: choice.sub.items.map(i => i.versionId) } : null; return { appVersions, iaps, subs, groups, draft, open, choice }; } /** The requests a run would send: none when nothing is left to submit (seen live: every product already in review), * only the submit when a submission back from App Review is resubmitted as it is. */ export function planRequests(prepare: R[], plan: { items: unknown[]; submit: R }, resubmit = false): R[] { return plan.items.length || resubmit ? [...prepare, plan.submit] : []; } export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const a = ctx.args; const dry = a.has('dry'); const only = a.list('only'); const ids = [...(config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id), ...allSubscriptions(config.subscriptions).filter(x => sellsOn(config, x.sub, 'apple')).map(x => x.sub.id)] .filter(id => !only.length || only.includes(id)); for (const id of only) if (!ids.includes(id)) throw new Error(`--only ${id}: not a product or subscription of the config sold on the App Store`); if (!ids.length) throw new Error('no in-app purchases or subscriptions in the config for the App Store'); const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); const appId = await c.app(); let state = await readSubmitState(c, appId, ids); if (a.has('new-version')) { const reqs = [ ...state.iaps.filter(p => ids.includes(p.productId) && ['APPROVED', 'REJECTED', 'DEVELOPER_REJECTED'].includes(latestVersion(p.versions)?.state ?? '')).map(p => newVersionReq('iap', p.id, p.productId)), ...state.subs.filter(p => ids.includes(p.productId) && ['APPROVED', 'REJECTED', 'DEVELOPER_REJECTED'].includes(latestVersion(p.versions)?.state ?? '')).map(p => newVersionReq('sub', p.id, p.productId)), ]; if (reqs.length) { await execute(c.asc, reqs, { dry, tag: TAG }); if (!dry) state = await readSubmitState(c, appId, ids); } } await submitPlanned(ctx, c, { appId, ids, state, tag: TAG, withVersion: a.has('with-version'), resolve: { version: false, iaps: a.has('resolve-iaps') } }); } /** Lines that say which submission is used and what else is open next to it. */ export function choiceLines(state: SubmitState): string[] { const ch = state.choice; if (!ch) return []; const out = [ch.why]; if (ch.action === 'reuse') for (const d of ch.strayDrafts) out.push(`draft ${d.id} (${d.items.length} item(s)) is open next to it${d.items.length ? '' : `: ${EMPTY_DRAFT}`}`); return out; } export interface SubmitOpts { appId: string; ids: string[]; state: SubmitState; tag: string; withVersion?: boolean; /** the app version that must be in the submission (resubmit) */ appVersionId?: string; /** the caller printed its own plan (resubmit) */ quiet?: boolean; /** REJECTED items marked resolved before the submit (resubmit: the version item; --resolve-iaps: the rest) */ resolve?: ResolveScope; /** second pass after a 409 named another submission */ retried?: boolean; } /** * Plans and sends a review submission: reuses the open one (chooseSubmission), adds only the missing items, then * submits (PATCH submitted: true, retried while Apple says "not ready yet"). A draft is created only when no open * submission exists; one this run created and could not use (409 ITEM_PART_OF_ANOTHER_SUBMISSION) is left as is (an empty draft is not cancellable), * and the submission the 409 named is used instead. REJECTED items in `o.resolve` are marked resolved before the * submit. Shared by iap-submit and resubmit. */ export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise { const { root } = ctx.loaded; const a = ctx.args; const dry = a.has('dry'); const { state, tag } = o; const plan = planIapSubmission(state, o.ids, { withVersion: o.withVersion, appVersionId: o.appVersionId }); const prepare = plan.prepare.map(r => bindIds(r, { app: o.appId })); const submitting = willSubmit(state, plan); const busy = state.choice?.action === 'busy'; if (a.has('json') && !o.quiet) { printJson({ dryRun: dry, items: plan.items, skipped: plan.skipped, firstOfType: plan.firstOfType, hasApprovedVersion: plan.hasApprovedVersion, needsAppVersion: plan.needsAppVersion, blockers: plan.blockers, draft: state.draft?.id ?? null, submission: state.choice ?? null, requests: busy ? [] : planRequests(prepare, plan, submitting).map(r => describeReq(r)) }); if (dry || plan.blockers.length || busy) { if (plan.blockers.length) process.exitCode = 1; return; } } else if (!o.quiet) { for (const l of choiceLines(state)) log(tag, l); for (const it of plan.items) log(tag, `submit: ${it.label}`); for (const sk of plan.skipped) log(tag, `skip ${sk.id}: ${sk.why}`); if (plan.firstOfType.length) log(tag, `first ${plan.firstOfType.join(' / ')} of the app: needs an app version in the same submission`); for (const b of plan.blockers) warn(tag, b); } if (!a.has('json') && state.choice?.action === 'reuse') { const marked = new Set(toResolve(state.choice.sub.items, o.resolve)); for (const it of state.choice.sub.items.filter(i => i.state === 'REJECTED')) { if (marked.has(it)) log(tag, `${labelOf(state, it.versionId, it.kind)}: REJECTED, marked resolved before the submit`); else warn(tag, `${labelOf(state, it.versionId, it.kind)} is REJECTED in ${state.choice.sub.id}: Apple takes no submit while it is not resolved; ${resolveHint(it.kind)}`); } } if (busy) { done(tag, `nothing to submit: ${state.choice!.why}`); return; } if (plan.blockers.length) { process.exitCode = 1; done(tag, 'nothing sent: fix the blockers above'); return; } if (!submitting) { done(tag, 'nothing to submit'); return; } const resubmit = state.draft?.state === 'UNRESOLVED_ISSUES'; const target = state.draft ? `${resubmit ? 'review submission' : 'draft'} ${state.draft.id}` : 'a new draft submission'; if (dry) { await execute(c.asc, planRequests(prepare, plan, submitting), { dry, tag }); done(tag, plan.items.length ? `dry run: ${plan.items.length} item(s) would go into ${target}, then it is submitted` : `dry run: ${target} would be resubmitted as it is`); return; } const what = `${plan.items.length} item(s)${plan.appVersion ? `, including app version ${plan.appVersion.versionString}` : ''}`; if (plan.items.length && !(await confirm(ctx, `Add ${what} to ${target}?`))) return; // the open submission is reused, or a draft is created once, only now that none is open; the plan left out items // already in it, and an item the store says is in there already (409) is not added twice let sid = state.draft?.id; let created = false; if (!sid) ({ id: sid, created } = await ensureDraft(c, o.appId, root, tag)); try { for (const r of prepare) { if (r.path === '/v1/reviewSubmissions') continue; if (r.path === '/v1/reviewSubmissionItems') { const rels = (r.body as J).data.relationships as Record; const kind = Object.keys(rels).find(k => k !== 'reviewSubmission')!; if (await addItem(c, sid, kind, rels[kind]!.data.id, r.note ?? kind, tag, { checked: true })) log(tag, `${describeReq(bindIds(r, { submission: sid }))}: done`); continue; } await c.asc(r.method, r.path, bindIds(r, { submission: sid }).body); log(tag, `${describeReq(bindIds(r, { submission: sid }))}: done`); } } catch (e) { if (!(e instanceof OtherSubmissionError)) throw e; warn(tag, e.message.split('\n')[0]!); if (created) await leaveUnusedDraft(c, sid, tag); if (o.retried || e.otherId === '?') throw e; // use the submission the 409 named: read the state again with it and go once more log(tag, `using review submission ${e.otherId} instead`); const again = await readSubmitState(c, o.appId, o.ids, { appVersionId: o.appVersionId ?? plan.appVersion?.id, also: e.otherId }); for (const l of choiceLines(again)) log(tag, l); await submitPlanned(ctx, c, { ...o, state: again, retried: true, quiet: true }); return; } cleanStrayDrafts(root, o.appId, state, tag); // a REJECTED item blocks every submit ("not ready yet") until it is marked resolved; the console's Resubmit too const label = (it: { kind: string; versionId: string }): string => labelOf(state, it.versionId, it.kind); const verb = resubmit ? 'Resubmit' : 'Submit'; if (a.has('no-submit')) { if (resubmit) await resolveRejected(c, sid, o.resolve, tag, label); done(tag, `${resubmit ? 'review submission' : 'draft review submission'} ${sid} ready: press "${resubmit ? 'Resubmit to App Review' : 'Submit for Review'}" in App Store Connect (Apps > Distribution > App Review)`); return; } if (!(await confirm(ctx, `${verb} review submission ${sid} (${plan.items.length ? what : 'as it is'}) to App Review now?${plan.appVersion || resubmit ? ' This submits the app version too.' : ''}`))) { done(tag, `${sid} kept; submit it in App Store Connect or re-run`); return; } if (resubmit) await resolveRejected(c, sid, o.resolve, tag, label); await submitWithRetry(c, sid, tag, { resolve: o.resolve, label }); done(tag, `review submission ${sid} ${resubmit ? 'resubmitted' : 'submitted'} (storebound apple status shows its state)`); } /** Drafts next to a reused submission. An empty one is left as is: App Store Connect answers 409 "Resource is not in * cancellable state" for it (seen live) and it does no harm; one with items is named with the cancel command. */ function cleanStrayDrafts(root: string, appId: string, state: SubmitState, tag: string): void { if (state.choice?.action !== 'reuse') return; const mine = readJournal(root)[draftJournalKey(appId)]?.id; for (const d of state.choice.strayDrafts) { if (!d.items.length) { log(tag, `draft ${d.id}${d.id === mine ? ' (created by storebound)' : ''}: ${EMPTY_DRAFT}; left as it is`); continue; } warn(tag, `draft ${d.id} (${d.items.length} item(s)) is still open next to ${state.choice.sub.id}; cancel it if it is not needed: storebound apple status --cancel-submission ${d.id}`); } }