storebound/cli/apple/iap-submit.ts

187 lines
13 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).
// 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).
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, 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, cancelUnusedDraft, draftJournalKey, ensureDraft, openSubmissions, OtherSubmissionError, submitWithRetry } from './submission';
const TAG = 'iap-submit';
export async function readSubmitState(c: Asc, appId: string, want: string[], o: { appVersionId?: string; also?: 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`)) : [] });
}
// 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<R>(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<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);
}
}
await submitPlanned(ctx, c, { appId, ids, state, tag: TAG, withVersion: a.has('with-version') });
}
/** 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, left over from an earlier run'}`);
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;
/** 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 canceled again,
* and the submission the 409 named is used instead. Shared by iap-submit and resubmit.
*/
export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise<void> {
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 (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<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`);
}
} catch (e) {
if (!(e instanceof OtherSubmissionError)) throw e;
warn(tag, e.message.split('\n')[0]!);
if (created) await cancelUnusedDraft(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;
}
await cleanStrayDrafts(c, o.appId, root, state, tag);
const verb = resubmit ? 'Resubmit' : 'Submit';
if (a.has('no-submit')) { 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;
}
await submitWithRetry(c, sid, tag);
done(tag, `review submission ${sid} ${resubmit ? 'resubmitted' : 'submitted'} (storebound apple status shows its state)`);
}
/** Empty drafts next to a reused submission: the ones storebound created (journal) are canceled, others are named. */
async function cleanStrayDrafts(c: Asc, appId: string, root: string, state: SubmitState, tag: string): Promise<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 && d.id === mine) { await cancelUnusedDraft(c, d.id, tag); 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}`);
}
}