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:
parent
1c35a625e1
commit
b830a99a5f
16 changed files with 288 additions and 18 deletions
|
|
@ -83,12 +83,12 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'apps_customerReviews_getToManyRelated': 'reviews',
|
||||
'apps_getCollection': 'apple/asc apple/bundle apps console/app-id console/verify lib/store-apps',
|
||||
'apps_getInstance': 'apple/appinfo apple/status apps onboard',
|
||||
'apps_inAppPurchasesV2_getToManyRelated': 'apple/iap apple/iap-review apple/iap-submit apple/offer-codes apple/promoted apple/status catalog prices validate',
|
||||
'apps_inAppPurchasesV2_getToManyRelated': 'apple/first-products apple/iap apple/iap-review apple/iap-submit apple/offer-codes apple/promoted apple/status catalog prices validate',
|
||||
'apps_perfPowerMetrics_getToManyRelated': 'health',
|
||||
'apps_promotedPurchases_getToManyRelated': 'apple/promoted',
|
||||
'apps_promotedPurchases_replaceToManyRelationship': 'lib/offers',
|
||||
'apps_reviewSubmissions_getToManyRelated': 'apple/submission',
|
||||
'apps_subscriptionGroups_getToManyRelated': 'apple/iap-submit apple/promoted apple/subscriptions apple/win-back catalog prices validate',
|
||||
'apps_subscriptionGroups_getToManyRelated': 'apple/first-products apple/iap-submit apple/promoted apple/subscriptions apple/win-back catalog prices validate',
|
||||
'apps_updateInstance': 'apple/appinfo',
|
||||
'betaAppLocalizations_createInstance': 'lib/testflight',
|
||||
'betaAppLocalizations_updateInstance': 'lib/testflight',
|
||||
|
|
@ -157,7 +157,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'inAppPurchasesV2_inAppPurchaseLocalizations_getToManyRelated': 'apple/iap apple/iap-review catalog',
|
||||
'inAppPurchasesV2_offerCodes_getToManyRelated': 'apple/offer-codes',
|
||||
'inAppPurchasesV2_pricePoints_getToManyRelated': 'apple/iap apple/offer-codes prices prices-ppp',
|
||||
'inAppPurchasesV2_versions_getToManyRelated': 'apple/iap-submit',
|
||||
'inAppPurchasesV2_versions_getToManyRelated': 'apple/first-products apple/iap-submit',
|
||||
'profiles_certificates_getToManyRelationship': 'lib/signing',
|
||||
'profiles_createInstance': 'apple/adhoc lib/signing',
|
||||
'profiles_devices_getToManyRelated': 'apple/adhoc',
|
||||
|
|
@ -180,7 +180,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'subscriptionGroups_createInstance': 'apple/subscriptions',
|
||||
'subscriptionGroups_getInstance': 'apple/subscriptions',
|
||||
'subscriptionGroups_subscriptionGroupLocalizations_getToManyRelated': 'apple/subscriptions validate',
|
||||
'subscriptionGroups_subscriptions_getToManyRelated': 'apple/iap-submit apple/promoted apple/subscriptions apple/win-back catalog prices validate',
|
||||
'subscriptionGroups_subscriptions_getToManyRelated': 'apple/first-products apple/iap-submit apple/promoted apple/subscriptions apple/win-back catalog prices validate',
|
||||
'subscriptionGroups_versions_getToManyRelated': 'apple/iap-submit',
|
||||
'subscriptionLocalizations_createInstance': 'apple/subscriptions',
|
||||
'subscriptionOfferCodeCustomCodes_createInstance': 'apple/subscriptions',
|
||||
|
|
@ -201,7 +201,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'subscriptions_subscriptionAvailability_getToOneRelated': 'apple/subscriptions',
|
||||
'subscriptions_subscriptionLocalizations_getToManyRelated': 'apple/subscriptions catalog validate',
|
||||
'subscriptions_updateInstance': 'apple/subscriptions',
|
||||
'subscriptions_versions_getToManyRelated': 'apple/iap-submit',
|
||||
'subscriptions_versions_getToManyRelated': 'apple/first-products apple/iap-submit',
|
||||
'subscriptions_winBackOffers_getToManyRelated': 'apple/win-back',
|
||||
'territories_getCollection': 'apple/appinfo apple/events apple/iap apple/offer-codes apple/subscriptions',
|
||||
'territoryAvailabilities_updateInstance': 'lib/offers',
|
||||
|
|
|
|||
33
cli/apple/first-products.ts
Normal file
33
cli/apple/first-products.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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)');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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(/; $/, ''));
|
||||
|
|
|
|||
|
|
@ -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)';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ async function status(args: Args): Promise<void> {
|
|||
const read = await pool(list, concurrency, async t => ({ t, s: await readLive(t) }));
|
||||
if (args.has('json')) {
|
||||
printJson({ apps: read.map(({ t, s }) => ({
|
||||
app: t.name, folder: t.virtual ? null : t.dir, errors: [...t.problems, ...s.errors], storeErrors: s.storeErrors,
|
||||
app: t.name, folder: t.virtual ? null : t.dir, errors: [...t.problems, ...s.errors], warnings: s.warnings ?? [], storeErrors: s.storeErrors,
|
||||
trains: trainsOf(s, all).map(({ which, train }) => ({ which, version: train.version, ios: train.ios, android: train.android,
|
||||
lanes: { ios: laneOf('ios', train.ios), android: laneOf('android', train.android) } })),
|
||||
})), at: new Date().toISOString() });
|
||||
|
|
@ -105,6 +105,7 @@ async function status(args: Args): Promise<void> {
|
|||
const unread = Object.keys(s.storeErrors).length > 0;
|
||||
if (!trains.length) rows.push([t.name, '', '', s.errors[0] ? `error: ${s.errors[0]}` : unread ? 'not read (see the warnings)' : 'no version on either store yet', '']);
|
||||
for (const { which, train } of trains) rows.push([t.name, train.version, which === 'other' ? '' : which, cell('ios', train, s), cell('android', train, s)]);
|
||||
for (const w of s.warnings ?? []) warn('release', `${t.name}: ${w}`);
|
||||
for (const p of [...t.problems, ...s.errors.slice(trains.length ? 0 : 1), ...Object.entries(s.storeErrors).map(([k, v]) => `${k}: ${v}`)]) warn('release', `${t.name}: ${p}`);
|
||||
}
|
||||
console.log(textTable(['app', 'version', 'train', 'App Store', 'Google Play'], rows));
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
import { templateBlockers } from './lib/template-answers';
|
||||
import { ascClient, type J } from './apple/asc';
|
||||
import { capabilityReport } from './apple/signing';
|
||||
import { firstProductReport } from './apple/first-products';
|
||||
import { requiredDisplayTypes } from './apple/screenshots';
|
||||
import { type Loaded, playCategoryOf, playCodes, profileOf, sellsOn } from './lib/config';
|
||||
import { reviewDemoAccount } from './lib/credentials';
|
||||
|
|
@ -82,7 +83,10 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
|
|||
const sched = await c.maybe(`/v1/apps/${appId}/appPriceSchedule?include=manualPrices`);
|
||||
const avail = await c.maybe(`/v1/apps/${appId}/appAvailabilityV2`);
|
||||
const iaps: AppleSnapshot['iaps'] = [];
|
||||
for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) {
|
||||
const iapList = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`);
|
||||
const configIds = [...(config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id), ...allSubscriptions(config.subscriptions).filter(x => sellsOn(config, x.sub, 'apple')).map(x => x.sub.id)];
|
||||
const firstProductGaps = configIds.length ? await firstProductReport(c, appId, { iaps: iapList, only: configIds, versionState: v ? (v.attributes.appVersionState ?? v.attributes.appStoreState) : undefined }) : [];
|
||||
for (const p of iapList) {
|
||||
const shot = (await c.maybe(`/v2/inAppPurchases/${p.id}/appStoreReviewScreenshot`))?.data;
|
||||
iaps.push({ productId: p.attributes.productId, state: p.attributes.state, reviewScreenshot: !!shot });
|
||||
}
|
||||
|
|
@ -111,6 +115,7 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
|
|||
requiredDisplayTypes: requiredDisplayTypes(config.apple),
|
||||
priceSchedule: !!sched?.included?.some((x: J) => x.type === 'appPrices'),
|
||||
availability: !!avail?.data,
|
||||
firstProductGaps,
|
||||
iaps, expectedProducts: (config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id),
|
||||
privacyUrl: await checkUrl(config.app.privacyPolicyUrl),
|
||||
capabilityMismatches: await capabilityReport(c, config, root).then(r => r.mismatches.map(m => ({ msg: m.msg, fix: m.fix })), e => [{ msg: `capabilities not compared: ${(e as Error).message.slice(0, 200)}`, fix: 'storebound apple signing status' }]),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue