// 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] [--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). 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, latestVersion, newVersionReq, type PIap, planIapSubmission, type PSub, type SubmitState, versionsOf } from '../lib/review-submit'; import { type Asc, ascClient, type J } from './asc'; import { describeReq, execute } from './jsonapi'; import { addItem, draftSubmission } from './submission'; const TAG = 'iap-submit'; export async function readSubmitState(c: Asc, appId: string, want: 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`)) : [] }); } const drafts = await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&filter[platform]=IOS&filter[state]=READY_FOR_REVIEW&limit=10`); let draft: SubmitState['draft'] = null; if (drafts[0]) { const items = await c.all(`/v1/reviewSubmissions/${drafts[0].id}/items?include=appStoreVersion,inAppPurchaseVersion,subscriptionVersion,subscriptionGroupVersion&limit=200`); const ids = items.flatMap((it: J) => Object.values(it.relationships ?? {}).map((r: J) => r?.data?.id).filter(Boolean)) as string[]; draft = { id: drafts[0].id, itemVersionIds: ids }; } return { appVersions, iaps, subs, groups, draft }; } /** The requests a run would send: none when nothing is left to submit (seen live: every product already in review). */ export function planRequests(prepare: R[], plan: { items: unknown[]; submit: R }): R[] { return plan.items.length ? [...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); } } const plan = planIapSubmission(state, ids, { withVersion: a.has('with-version') }); const prepare = plan.prepare.map(r => bindIds(r, { app: appId })); if (a.has('json')) { 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, requests: planRequests(prepare, plan).map(r => describeReq(r)) }); if (dry || plan.blockers.length) { if (plan.blockers.length) process.exitCode = 1; return; } } else { for (const it of plan.items) log(TAG, `submit: ${it.label}`); for (const s of plan.skipped) log(TAG, `skip ${s.id}: ${s.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 (plan.blockers.length) { process.exitCode = 1; done(TAG, 'nothing sent: fix the blockers above'); return; } if (!plan.items.length) { done(TAG, 'nothing to submit'); return; } if (dry) { await execute(c.asc, planRequests(prepare, plan), { dry, tag: TAG }); done(TAG, `dry run: ${plan.items.length} item(s) would go into ${state.draft ? `draft ${state.draft.id}` : 'a new draft submission'}`); return; } const what = `${plan.items.length} item(s)${plan.appVersion ? `, including app version ${plan.appVersion.versionString}` : ''}`; if (!(await confirm(ctx, `Add ${what} to ${state.draft ? 'the draft' : 'a new draft'} review submission?`))) return; // the draft is reused (or created once); the plan left out items already in it (readSubmitState read them), and an // item the store says is in there already (409) is not added twice (cli/apple/submission.ts) const sid = state.draft?.id ?? await draftSubmission(c, appId, root, TAG); 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`); } if (a.has('no-submit')) { done(TAG, `draft review submission ${sid} ready: press "Submit for Review" in App Store Connect (Apps > Distribution > App Review)`); return; } if (!(await confirm(ctx, `Submit review submission ${sid} (${what}) to App Review now?${plan.appVersion ? ' This submits the app version too.' : ''}`))) { done(TAG, `draft ${sid} kept; submit it in App Store Connect or re-run`); return; } await c.asc('PATCH', `/v1/reviewSubmissions/${sid}`, bindIds(plan.submit, { submission: sid }).body); done(TAG, `review submission ${sid} submitted (storebound apple status shows its state)`); }