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

@ -0,0 +1,33 @@
// Reads what `firstProductGaps` (cli/lib/review-submit.ts) needs: every in-app purchase and subscription, the app's
// open review submission with its items, and the versions of the never-approved products (to see whether they are in
// it). Used by apple status, validate and the release status / plan built on them (pitfall 70). Read-only.
import { type FirstProductGap, firstProductGaps, type FirstProductInput, NEVER_SUBMITTED } from '../lib/review-submit';
import type { Asc, J } from './asc';
import { openSubmissions } from './submission';
export async function readFirstProducts(c: Asc, appId: string, o: { iaps?: J[]; only?: string[]; versionState?: string } = {}): Promise<FirstProductInput> {
const ids = (vs: J[]): string[] => vs.map(v => v.id as string);
const iaps: FirstProductInput['iaps'] = [];
for (const p of o.iaps ?? await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) {
const state = p.attributes.state as string;
iaps.push({ productId: p.attributes.productId, type: p.attributes.inAppPurchaseType, state,
versionIds: NEVER_SUBMITTED.has(state) ? ids(await c.all(`/v2/inAppPurchases/${p.id}/versions?limit=200`)) : [] });
}
const subs: FirstProductInput['subs'] = [];
for (const g of await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`)) {
for (const s of await c.all(`/v1/subscriptionGroups/${g.id}/subscriptions?limit=200`)) {
const state = s.attributes.state as string;
subs.push({ productId: s.attributes.productId, groupId: g.id, state,
versionIds: NEVER_SUBMITTED.has(state) ? ids(await c.all(`/v1/subscriptions/${s.id}/versions?limit=200`)) : [] });
}
}
const open = await openSubmissions(c, appId);
// the submission that matters: one in review, else one back from review, else a draft holding items
const pick = open.find(x => x.state === 'WAITING_FOR_REVIEW' || x.state === 'IN_REVIEW') ?? open.find(x => x.state === 'UNRESOLVED_ISSUES') ?? open.find(x => x.items.length);
const submission = pick ? { id: pick.id, state: pick.state, itemVersionIds: pick.items.map(i => i.versionId) } : null;
return { iaps, subs, submission, versionState: o.versionState, only: o.only };
}
export async function firstProductReport(c: Asc, appId: string, o: Parameters<typeof readFirstProducts>[2] = {}): Promise<FirstProductGap[]> {
return firstProductGaps(await readFirstProducts(c, appId, o));
}

View file

@ -17,6 +17,7 @@ import { done, log, warn } from '../lib/log';
import { reportMissing } from '../lib/store-lag';
import { IMAGE_SPECS, imageInfo } from '../shots/spec';
import { ascClient, md5 } from './asc';
import { productStateText } from '../lib/review-submit';
import * as rt from '../lib/rt';
/** Every iPhone / iPad screenshot size App Store Connect accepts (portrait and landscape). */
@ -79,9 +80,11 @@ export async function run(ctx: Ctx): Promise<void> {
else log('apple', `${sku}: screenshot uploaded (${state})`);
}
// what keeps a product in MISSING_METADATA (Apple's rule: a localization, a price, availability and the screenshot)
for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) {
const all = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`);
const approvedTypes = new Set(all.filter((x: { attributes: { state: string } }) => /APPROVED|REMOVED_FROM_SALE/.test(x.attributes.state)).map((x: { attributes: { inAppPurchaseType: string } }) => x.attributes.inAppPurchaseType));
for (const p of all) {
const state: string = p.attributes.state;
if (state !== 'MISSING_METADATA') { log('apple', `${p.attributes.productId}: ${state}`); continue; }
if (state !== 'MISSING_METADATA') { log('apple', `${p.attributes.productId}: ${productStateText(state, approvedTypes.has(p.attributes.inAppPurchaseType))}`); continue; }
const lack: string[] = [];
const locs = await c.maybe(`/v2/inAppPurchases/${p.id}/inAppPurchaseLocalizations?limit=50`);
if (!locs?.data?.length) lack.push('a localization (name + description)');
@ -97,5 +100,5 @@ export async function run(ctx: Ctx): Promise<void> {
}
if (dry) console.log(dryPlan('apple iap-review', rows));
if (problems) process.exitCode = 1;
done('apple', dry ? 'iap-review dry run (nothing uploaded)' : 'iap-review done (READY_TO_SUBMIT = complete; first IAPs go to review with a version, from the IAP page)');
done('apple', dry ? 'iap-review dry run (nothing uploaded)' : 'iap-review done (READY_TO_SUBMIT = ready, not submitted yet; the first in-app purchase of a type goes to review with the next app version: storebound apple submit adds it)');
}

View file

@ -13,7 +13,9 @@
// naming what is missing where.
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { log, printJson } from '../lib/log';
import { log, printJson, warn } from '../lib/log';
import type { FirstProductGap } from '../lib/review-submit';
import { firstProductReport } from './first-products';
import { classifyReviewState, nextPollDelay, parseDuration, WAIT_EXIT } from '../lib/release';
import { EMPTY_DRAFT } from '../lib/review-submit';
import { type Asc, ascClient, type J } from './asc';
@ -79,6 +81,11 @@ export async function run(ctx: Ctx): Promise<void> {
// an empty draft: App Store Connect keeps it and refuses to cancel it; harmless
const empty = new Set<string>();
for (const s of subs.filter((x: J) => x.attributes?.state === 'READY_FOR_REVIEW')) if (!(await readItems(c, s.id)).length) empty.add(s.id);
// first products of a type that are not in the review (pitfall 70): not sellable after the version is approved
const vstate = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState;
const reviewed = versions.find((v: J) => /WAITING_FOR_REVIEW|IN_REVIEW/.test(vstate(v))) ?? versions[0];
let gaps: FirstProductGap[] = [];
try { gaps = await firstProductReport(c, appId, { iaps, versionState: reviewed ? vstate(reviewed) : undefined }); } catch (e) { log('apple', `first in-app purchases not compared with the review: ${(e as Error).message.slice(0, 200)}`); }
const itemLabel = (kind: string, id: string): string => {
const v = versions.find((x: J) => x.id === id);
return v ? `app version ${v.attributes.versionString}` : `${kind} ${id}`;
@ -102,7 +109,7 @@ export async function run(ctx: Ctx): Promise<void> {
const v = pre.get(b.relationships?.preReleaseVersion?.data?.id ?? '');
if (v) buildVersions[b.id] = v;
}
printJson({ app, versions, builds: builds.data, attachedBuilds, buildVersions, iaps, subs, phased, unresolved, emptyDrafts: [...empty], capabilityCheck: caps });
printJson({ app, versions, builds: builds.data, attachedBuilds, buildVersions, iaps, subs, phased, unresolved, emptyDrafts: [...empty], firstProductGaps: gaps, capabilityCheck: caps });
return;
}
@ -118,6 +125,7 @@ export async function run(ctx: Ctx): Promise<void> {
const newest = builds.data[0]?.attributes?.version;
console.log(` resubmit with: storebound apple resubmit --build <n>${newest ? ` (newest uploaded build: ${newest})` : ''}`);
}
for (const g of gaps) warn('apple', `${g.msg}; ${g.fix}`);
if ('error' in caps) log('apple', `capabilities not compared: ${caps.error}`);
else printCapabilityReport(caps, 'apple');
}