First in-app purchases left out of a review are caught (pitfall 70): firstProductGaps in validate / release plan (blocker) and the apple submit plan (refuses to leave a never-approved first product out); apple status (WARN + firstProductGaps in --json), release status and the desktop checks warn while a version waits for or is in review without it; iap-review says 'ready, not submitted yet' instead of 'complete'

This commit is contained in:
CRTkafa 2026-10-15 19:31:26 +08:00
parent 1c35a625e1
commit b830a99a5f
16 changed files with 288 additions and 18 deletions

View file

@ -158,6 +158,14 @@ export function planIapSubmission(s: SubmitState, want: string[], o: { withVersi
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 the draft submission') 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);
@ -190,6 +198,7 @@ export function planIapSubmission(s: SubmitState, want: string[], o: { withVersi
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) {
@ -278,3 +287,68 @@ export function formatRows(rows: PlanRow[]): string {
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)';
}