364 lines
28 KiB
TypeScript
364 lines
28 KiB
TypeScript
// 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<ItemKind, string> = {
|
|
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<T extends { kind: string; state: string }>(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 <n>'
|
|
: 'fix what App Review asked for, then mark it resolved with --resolve-iaps (storebound apple resubmit --build <n> --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<string>();
|
|
const already = new Set(s.draft?.itemVersionIds ?? []);
|
|
const groupsNeeded = new Set<string>();
|
|
|
|
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 <id>) and resubmit with it (storebound apple submit --build <n>)' });
|
|
} 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 <n> 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 <n> 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 <n> adds it)';
|
|
}
|