storebound/cli/lib/review-submit.ts
CRT b63f2fdfbc Store operations: TestFlight wait / expire, IAP review submissions, listing pulls, release notes per language, offers, custom product pages, in-app events, reports, triage, keyword ranks, AdAttributionKit scan
- apple testflight wait --build N (progress, timeout, exit 0/1/7) and expire --keep N | --older-than D (dry run, asks)
- apple iap-submit: IAP / subscription / group versions into a review submission; first of a type only with the app
  version (--with-version), --no-submit keeps the draft; read-only MCP plan
- apple listing pull, play listing pull (diff, --write), catalog pull (config proposal, never rewritten)
- whats-new --languages / check / apply: one file per language, agent or hook translation, per-store limits
- apple offer-codes, win-back, promoted, preorder; Play pre-registration as a guide (no API)
- apple cpp and apple events: list, create, upload, submit (asks)
- reports: Apple sales / finance, Play earnings / sales into .storebound/reports with summary and CSV
- triage: TestFlight crash / feedback and Play error reports grouped by signature, device, OS
- aso rank: iTunes Search API ranks and competitors, rate limited
- ads scan: AdAttributionKit / SKAdNetwork Info.plist and source checks
- tests against recorded, sanitized answers; skills, README, CHANGELOG
2026-10-11 23:58:17 +08:00

166 lines
13 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. */
const SUBMITTABLE = new Set(['PREPARE_FOR_SUBMISSION', 'READY_FOR_REVIEW']);
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 }
export interface SubmitState {
appVersions: PAppVersion[];
iaps: PIap[];
subs: PSub[];
groups: PGroup[];
/** The app's draft review submission (state READY_FOR_REVIEW), if one exists, and the version ids already in it. */
draft?: { id: string; itemVersionIds: string[] } | null;
}
export interface PlanItem { kind: ItemKind; versionId: string; label: string }
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 } = {}): 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 => {
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}: change it in App Store Connect (or --new-version) to send a new version` }); return; }
if (already.has(v.id)) { plan.skipped.push({ id, why: 'already in the draft submission' }); return; }
plan.items.push({ kind, versionId: v.id, label: `${id} (${typeLabel(type)}, version ${v.version ?? '?'})` });
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)` });
}
plan.firstOfType = [...firstTypes].map(typeLabel);
plan.needsAppVersion = plan.items.length > 0 && (firstTypes.size > 0 || !plan.hasApprovedVersion);
if (plan.needsAppVersion) {
const editable = 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) 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}` }); }
}
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: '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;
}
/** 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 ?? '?' }));