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

@ -3,7 +3,7 @@
// workspace (`storebound ws status`, `storebound release status`), so all of them read an app the same way.
// Every read is a storebound child command; `exec` runs it (serve tracks it for cancellation, the CLI just awaits it).
import type { AppStatus, ProjectSummary } from './desktop-protocol';
import { appIdentity, appleProductIds, appleReleases, catalogParity, playProductIds, playReleases, releaseTrains } from './portfolio';
import { appIdentity, appleFirstProductWarnings, appleProductIds, appleReleases, catalogParity, playProductIds, playReleases, releaseTrains } from './portfolio';
import type { RunResult } from './runner';
export type Exec = (argv: string[], cwd: string) => Promise<RunResult>;
@ -45,6 +45,10 @@ export async function fillAppStatus(e: AppStatus, dir: string, o: { live: boolea
if (play?.error) e.storeErrors.android = play.error;
const ios = apple?.data ? appleReleases(apple.data) : [], android = play?.data ? playReleases(play.data) : [];
e.release = { source: 'live', ...releaseTrains(ios, android), all: [...ios, ...android] };
// first in-app purchases left out of the review: a warning on the release status and in the desktop's checks
const firstWarn = apple?.data ? appleFirstProductWarnings(apple.data) : [];
if (firstWarn.length) e.warnings = firstWarn;
if (e.check) for (const w of firstWarn) if (!e.check.warnings.includes(w)) e.check.warnings.push(w);
e.catalog = catalogParity(summary, apple?.data ? appleProductIds(apple.data) : undefined, play?.data ? playProductIds(play.data) : undefined);
}
e.ok = e.check.ok && !e.storeErrors.ios && !e.storeErrors.android && (e.catalog?.ok ?? true);

View file

@ -225,6 +225,8 @@ export interface AppStatus {
/** A store that could not be read this time, shown from the last good read (its time): couldn't refresh. */
staleStores?: { ios?: string; android?: string };
errors: string[];
/** live only: release warnings read from the stores (a first in-app purchase left out of the review, pitfall 70). */
warnings?: string[];
}
export interface PortfolioResult { apps: AppStatus[]; at: string }

View file

@ -208,3 +208,7 @@ export function catalogParity(s: ProjectSummary, apple?: string[], play?: string
ok: !onlyIos.length && !onlyAndroid.length && !missingIos.length && !missingAndroid.length,
};
}
/** The warnings of `apple status --json` about first in-app purchases left out of the review (pitfall 70). Pure. */
export const appleFirstProductWarnings = (data: unknown): string[] => (((data as J)?.firstProductGaps ?? []) as J[])
.filter(g => typeof g?.msg === 'string').map(g => `App Store: ${g.msg}; ${g.fix ?? ''}`.replace(/; $/, ''));

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)';
}

View file

@ -62,6 +62,9 @@ export interface AppleSnapshot {
availability: boolean;
iaps: { productId: string; state: string; reviewScreenshot: boolean }[];
expectedProducts: string[];
/** First in-app purchases / subscriptions that never passed review and are not in the app's review submission
* (firstProductGaps, cli/lib/review-submit.ts; pitfall 70). */
firstProductGaps?: { level: 'error' | 'warn'; msg: string; fix: string }[];
privacyUrl: UrlCheck;
/** App ID capabilities vs apple.signing.capabilities vs the iOS project's entitlements (cli/lib/signing.ts compareCapabilities). */
capabilityMismatches?: { msg: string; fix: string }[];
@ -239,6 +242,7 @@ export function appleRules(s: AppleSnapshot): Finding[] {
if (!IAP_OK.includes(i.state)) add('blocker', 'iap', `${i.productId}: ${i.state}`, i.state === 'MISSING_METADATA' ? 'storebound apple iap + apple iap-review' : 'see the review-rejections skill');
else if (!i.reviewScreenshot && i.state !== 'APPROVED') add('blocker', 'iap', `${i.productId}: no review screenshot`, 'storebound apple iap-review');
}
for (const g of s.firstProductGaps ?? []) add(g.level === 'error' ? 'blocker' : 'warn', 'iap-first', g.msg, g.fix);
if (s.iaps.length && !f.some(x => x.code === 'iap')) add('ok', 'iap', `${s.iaps.length} in-app purchases ready`);
f.push(...appleSubscriptionRules(s));