storebound/cli/apple/iap-submit.ts
2026-10-12 04:00:58 +08:00

119 lines
8.5 KiB
TypeScript

// 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<SubmitState> {
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<R>(prepare: R[], plan: { items: unknown[]; submit: R }): R[] {
return plan.items.length ? [...prepare, plan.submit] : [];
}
export async function run(ctx: Ctx): Promise<void> {
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<string, { data: { id: string } }>;
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)`);
}