Drift on existing items: offer-code and win-back campaigns that differ are named (Apple cannot edit them), in-app event attributes patched on drafts, a removed custom page deep link cleared, config languages without a version localization named, data safety dry run says Play has no read API; appinfo reads /v1/appPriceSchedules/{id}/manualPrices; a new Play language without an icon warns instead of stopping; fake store lists for the new reads

This commit is contained in:
CRTkafa 2026-10-22 20:38:54 +08:00
parent f2b841d735
commit e381bc2256
11 changed files with 206 additions and 10 deletions

View file

@ -98,7 +98,7 @@ export async function planAppInfo(ctx: Ctx, c: Awaited<ReturnType<typeof ascClie
freePricePointId = await findFree();
items.push(planItem('price', null, { price: 'Free', baseTerritory: 'USA' }));
} else {
const now = currentBasePrice(await c.maybe(`/v1/apps/${appId}/appPriceSchedule/manualPrices?include=appPricePoint&limit=50`));
const now = currentBasePrice(await c.maybe(`/v1/appPriceSchedules/${appId}/manualPrices?include=appPricePoint&limit=50`));
if (now === null) items.push(planItem('price', { attributes: { schedule: 'exists' } }, { schedule: 'exists' }));
else {
const it = planItem('price', { attributes: { price: now } }, { price: 'Free' });

View file

@ -11,7 +11,7 @@ import { existsSync } from 'node:fs';
import { join } from 'node:path';
import type { Ctx } from '../lib/ctx';
import { byAppleLocale, confirm, need } from '../lib/ctx';
import { CPP_EDITABLE, CPP_LIMITS, cppCreateBody, cppLocales, type CustomProductPageDef, planCppLocales, reviewItemReq } from '../lib/cpp-events';
import { CPP_EDITABLE, CPP_LIMITS, cppCreateBody, cppDeepLinkReq, cppLocales, type CustomProductPageDef, extraCppLocales, planCppLocales, reviewItemReq } from '../lib/cpp-events';
import { done, log, printJson, warn } from '../lib/log';
import { textTable } from '../lib/table';
import { numberedImages } from '../play/images';
@ -73,8 +73,10 @@ export async function run(ctx: Ctx): Promise<void> {
if (!page.version || !CPP_EDITABLE.has(page.version.state)) { log(TAG, `"${d.name}": version ${page.version?.state ?? 'missing'}, not editable (a new version: App Store Connect)`); continue; }
const have = (await c.all(`/v1/appCustomProductPageVersions/${page.version.id}/appCustomProductPageLocalizations?limit=200`)).map((l: J) => ({ id: l.id, locale: l.attributes.locale, promotionalText: l.attributes.promotionalText }));
reqs.push(...planCppLocales(page.version.id, have, locs));
if ((d.deepLink ?? null) !== (page.version.deepLink ?? null) && d.deepLink) reqs.push({ method: 'PATCH', path: `/v1/appCustomProductPageVersions/${page.version.id}`, note: `"${d.name}" deep link`,
body: { data: { type: 'appCustomProductPageVersions', id: page.version.id, attributes: { deepLink: d.deepLink } } } });
const extra = extraCppLocales(have.map(h => h.locale), locs.map(l => l.locale));
if (extra.length) warn(TAG, `"${d.name}": locale(s) ${extra.join(', ')} are on the page but not in the config languages (not removed)`);
const dl = cppDeepLinkReq(page.version.id, d.name, d.deepLink, page.version.deepLink);
if (dl) reqs.push(dl);
}
if (pages.length + reqs.filter(r => r.path === '/v1/appCustomProductPages').length > CPP_LIMITS.pages) throw new Error(`more than ${CPP_LIMITS.pages} custom product pages`);
if (a.has('json')) printJson({ dryRun: dry, requests: reqs.map(r => describeReq(r)) });

View file

@ -9,7 +9,7 @@
import { existsSync, readFileSync } from 'node:fs';
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { type AppEventDef, checkEventMedia, EVENT_EDITABLE, EVENT_LIMITS, eventCreateBody, planEventLocales, reviewItemReq } from '../lib/cpp-events';
import { type AppEventDef, checkEventMedia, EVENT_EDITABLE, EVENT_LIMITS, eventAttrDiff, eventCreateBody, eventPatchReq, planEventLocales, reviewItemReq } from '../lib/cpp-events';
import { done, log, printJson, warn } from '../lib/log';
import { textTable } from '../lib/table';
import { imageInfo } from '../shots/spec';
@ -59,7 +59,14 @@ export async function run(ctx: Ctx): Promise<void> {
const primary = localeOf(d.primaryLanguage ?? '') ?? want[0]?.locale ?? 'en-US';
const ev = events.find((e: J) => e.attributes.referenceName === d.referenceName);
if (!ev) { created.push({ def: d, index: reqs.length }); reqs.push({ method: 'POST', path: '/v1/appEvents', note: `event "${d.referenceName}" (${d.badge}, ${d.schedule.eventStart} to ${d.schedule.eventEnd})`, body: eventCreateBody(appId, d, primary, d.schedule.territories ?? territories) }); continue; }
if (!EVENT_EDITABLE.has(ev.attributes.eventState)) { log(TAG, `"${d.referenceName}": ${ev.attributes.eventState}, not editable`); continue; }
const drift = eventAttrDiff(d, ev.attributes);
if (!EVENT_EDITABLE.has(ev.attributes.eventState)) {
if (drift.lines.length) warn(TAG, `"${d.referenceName}" is ${ev.attributes.eventState} and differs from the config: ${drift.lines.join('; ')} (not editable: change it in App Store Connect)`);
else log(TAG, `"${d.referenceName}": ${ev.attributes.eventState}, not editable`);
continue;
}
const patch = eventPatchReq(ev.id, d.referenceName, drift.attrs, drift.lines);
if (patch) reqs.push(patch);
const have = (await c.all(`/v1/appEvents/${ev.id}/localizations?limit=200`)).map((l: J) => ({ id: l.id, ...l.attributes }));
reqs.push(...planEventLocales(ev.id, have, want));
}

View file

@ -21,6 +21,26 @@ import { findOrCreate, getById } from '../lib/upsert';
const TAG = 'offer-codes';
/** Differences between an existing offer code campaign and the config (Apple does not allow editing a campaign). Pure. */
export function campaignDiff(cur: { eligibility: string[]; prices: { territory: string; pointId?: string }[] },
want: { eligibility: string[]; free: boolean; prices: { territory: string; pointId?: string }[] }): string[] {
const out: string[] = [];
const set = (x: string[]) => [...new Set(x)].sort();
const a = set(cur.eligibility), b = set(want.eligibility);
if (a.join() !== b.join()) out.push(`eligibility ${a.join('+') || '(none)'} -> ${b.join('+') || '(none)'}`);
const curFree = cur.prices.length > 0 && cur.prices.every(x => !x.pointId);
if (cur.prices.length && curFree !== want.free) out.push(`mode ${curFree ? 'free' : 'paid'} -> ${want.free ? 'free' : 'paid'}`);
const ct = set(cur.prices.map(x => x.territory)), wt = set(want.prices.map(x => x.territory));
const gone = ct.filter(t => !wt.includes(t)), added = wt.filter(t => !ct.includes(t));
if (gone.length || added.length) out.push(`territories ${added.length ? `+${added.length} (${added.slice(0, 5).join(',')}${added.length > 5 ? ',...' : ''})` : ''}${gone.length ? ` -${gone.length} (${gone.slice(0, 5).join(',')}${gone.length > 5 ? ',...' : ''})` : ''}`.replace('territories ', 'territories '));
if (!want.free && !curFree) {
const have = new Map(cur.prices.map(x => [x.territory, x.pointId] as const));
const diff = want.prices.filter(x => have.has(x.territory) && have.get(x.territory) !== x.pointId).map(x => x.territory);
if (diff.length) out.push(`prices differ in ${diff.length} territor${diff.length === 1 ? 'y' : 'ies'} (${diff.slice(0, 5).join(',')}${diff.length > 5 ? ',...' : ''})`);
}
return out;
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const a = ctx.args;
@ -71,7 +91,13 @@ export async function run(ctx: Ctx): Promise<void> {
log(TAG, `${describeReq(req)}: ${made.created ? 'done' : 'exists already'}`);
if (made.created) active++;
}
} else log(TAG, `${p.id}: campaign "${oc.name}" exists (${cur.attributes.active ? 'active' : 'inactive'}, ${cur.attributes.productionCodeCount ?? 0} production codes)`);
} else {
log(TAG, `${p.id}: campaign "${oc.name}" exists (${cur.attributes.active ? 'active' : 'inactive'}, ${cur.attributes.productionCodeCount ?? 0} production codes)`);
const curPrices = (await c.all(`/v1/inAppPurchaseOfferCodes/${cur.id}/prices?include=territory,pricePoint&limit=200`))
.map((x: J) => ({ territory: x.relationships?.territory?.data?.id as string, pointId: x.relationships?.pricePoint?.data?.id as string | undefined })).filter((x: { territory?: string }) => !!x.territory);
const diffs = campaignDiff({ eligibility: cur.attributes.customerEligibilities ?? [], prices: curPrices }, { eligibility: oc.eligibility, free: !!oc.free, prices: await prices(oc) });
if (diffs.length) warn(TAG, `${p.id}: campaign ${oc.name} differs from the config: ${diffs.join('; ')} (Apple does not allow editing: create a new campaign with another name)`);
}
const haveCustom = new Set(cur ? (await c.all(`/v1/inAppPurchaseOfferCodes/${cur.id}/customCodes?limit=200`)).map((x: J) => x.attributes.customCode) : []);
for (const cc of oc.customCodes ?? []) if (!haveCustom.has(cc.code)) codeReqs.push({ req: iapCustomCodeReq(id ?? '${new campaign}', cc), product: p.id, campaign: oc.name, oneTime: false });
// one-time batches are not matched one by one: a batch is created when the campaign has fewer batches than configured

View file

@ -103,6 +103,9 @@ export async function run(ctx: Ctx): Promise<void> {
const langOf = byAppleLocale(config.languages);
const locs = await c.all(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`);
const noLoc = missingVersionLocales(config.languages, locs.map((l: J) => l.attributes.locale as string)).filter(l => !only.length || only.includes(l));
for (const l of noLoc) warn('apple', `${l}: in the config languages but the version has no localization (run apple listing first)`);
const syncSet = (locId: string, sets: J[], type: string, files: string[], tag: string) =>
syncScreenshotSet(c, { rel: 'appStoreVersionLocalization', type: 'appStoreVersionLocalizations', id: locId }, sets, type, files, tag, ctx.args.has('dry'));
@ -168,3 +171,7 @@ async function repairStuck(ctx: Ctx, c: Asc, root: string, locs: J[], src: strin
if (held) log('apple', `${held} screenshot(s) still processing (under 5 min): left alone; run validate again in a few minutes`);
done('apple', total ? `${dry ? 'dry run: would replace' : 'replaced'} ${total} stuck screenshot(s)` : 'no stuck screenshots');
}
/** App Store locales of the config languages that the version has no localization for. Pure. */
export const missingVersionLocales = (langs: { apple?: string }[], have: string[]): string[] =>
[...new Set(langs.flatMap(l => (l.apple ? [l.apple] : [])))].filter(l => !have.includes(l));

View file

@ -8,8 +8,8 @@
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log';
import { type TerritoryPoint, validateWinBack, winBackBody } from '../lib/offers';
import { allSubscriptions, applePoints, pickPoint } from '../lib/subscriptions';
import { type TerritoryPoint, validateWinBack, type WinBackDef, winBackBody } from '../lib/offers';
import { allSubscriptions, APPLE_OFFER_MODE, applePoints, pickPoint } from '../lib/subscriptions';
import { textTable } from '../lib/table';
import { pages } from '../prices';
import { ascClient, type J } from './asc';
@ -17,6 +17,17 @@ import { describeReq, execute, type Req } from './jsonapi';
const TAG = 'win-back';
/** Differences between an existing win-back offer and the config (mode, price, dates). No PATCH exists for them. `usaPrice` = the offer's USA customer price (undefined: not read, not compared). Pure. */
export function winBackDiff(have: { offerMode?: string; startDate?: string | null; endDate?: string | null }, usaPrice: number | undefined, want: WinBackDef): string[] {
const out: string[] = [];
const mode = APPLE_OFFER_MODE[want.type];
if (have.offerMode && have.offerMode !== mode) out.push(`mode ${have.offerMode} -> ${mode}`);
if (want.type !== 'free' && usaPrice !== undefined && want.usd !== undefined && Math.abs(usaPrice - want.usd) > 0.005) out.push(`USA price ${usaPrice} -> ${want.usd}`);
if ((have.startDate ?? '').slice(0, 10) !== want.start) out.push(`start ${have.startDate ?? '(none)'} -> ${want.start}`);
if ((have.endDate ?? '').slice(0, 10) !== (want.end ?? '')) out.push(`end ${have.endDate ?? '(none)'} -> ${want.end ?? '(none)'}`);
return out;
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded;
const a = ctx.args;
@ -39,6 +50,21 @@ export async function run(ctx: Ctx): Promise<void> {
if (!remote) { skipped.push({ id: s.id, why: 'not on App Store Connect (storebound apple subscriptions)' }); continue; }
const existing = await c.all(`/v1/subscriptions/${remote.id}/winBackOffers?limit=200`);
for (const o of existing) rows.push([s.id, o.attributes.offerId, o.attributes.offerMode, o.attributes.startDate ?? '', o.attributes.endDate ?? '', o.attributes.priority ?? '']);
for (const o of s.winBack!) {
const x = existing.find((e: J) => e.attributes.offerId === o.id);
if (!x) continue;
let usaPrice: number | undefined;
if (o.type !== 'free') {
const pr = await pages(c, `/v1/winBackOffers/${x.id}/prices?include=territory,subscriptionPricePoint&limit=200`);
const usa = pr.data.find((r: J) => r.relationships?.territory?.data?.id === 'USA');
const pid = usa?.relationships?.subscriptionPricePoint?.data?.id;
const inc = pid ? pr.included.find((i: J) => i.id === pid) : undefined;
const cp = Number(inc?.attributes?.customerPrice);
if (Number.isFinite(cp)) usaPrice = cp;
}
const diffs = winBackDiff(x.attributes, usaPrice, o);
if (diffs.length) warn(TAG, `${s.id}: win-back "${o.id}" differs from the config: ${diffs.join('; ')} (Apple does not allow editing: create a new offer with another id)`);
}
const missing = s.winBack!.filter(o => !existing.some((x: J) => x.attributes.offerId === o.id));
if (!missing.length) continue;
if (remote.attributes.state !== 'APPROVED') { skipped.push({ id: s.id, why: `subscription is ${remote.attributes.state}: win-back offers need an approved subscription` }); continue; }

View file

@ -200,3 +200,41 @@ export const reviewItemReq = (submissionId: string, kind: 'appCustomProductPageV
method: 'POST', path: '/v1/reviewSubmissionItems', note: label,
body: { data: { type: 'reviewSubmissionItems', relationships: { reviewSubmission: one('reviewSubmissions', submissionId), [kind]: one(kind === 'appEvent' ? 'appEvents' : 'appCustomProductPageVersions', id) } } },
});
/** Event attributes that differ from the config: `attrs` = the PATCH attributes (empty when nothing differs), `lines` = readable. Pure. */
export function eventAttrDiff(ev: AppEventDef, now: { badge?: string; priority?: string; purpose?: string; deepLink?: string | null; territorySchedules?: { territories?: string[]; publishStart?: string; eventStart?: string; eventEnd?: string }[] }): { attrs: Record<string, unknown>; lines: string[] } {
const attrs: Record<string, unknown> = {};
const lines: string[] = [];
const same = (a?: string, b?: string) => (a && b ? Date.parse(a) === Date.parse(b) : a === b);
if (now.badge !== undefined && now.badge !== ev.badge) { attrs.badge = ev.badge; lines.push(`badge ${now.badge} -> ${ev.badge}`); }
const pri = (ev.priority ?? 'normal').toUpperCase();
if (now.priority !== undefined && now.priority !== pri) { attrs.priority = pri; lines.push(`priority ${now.priority} -> ${pri}`); }
const purpose = ev.purpose ?? 'APPROPRIATE_FOR_ALL_USERS';
if (now.purpose !== undefined && now.purpose !== purpose) { attrs.purpose = purpose; lines.push(`purpose ${now.purpose} -> ${purpose}`); }
const s = now.territorySchedules?.[0];
const sch: string[] = [];
if (!same(s?.publishStart, ev.schedule.publishStart)) sch.push(`publishStart ${s?.publishStart ?? '(none)'} -> ${ev.schedule.publishStart}`);
if (!same(s?.eventStart, ev.schedule.eventStart)) sch.push(`eventStart ${s?.eventStart ?? '(none)'} -> ${ev.schedule.eventStart}`);
if (!same(s?.eventEnd, ev.schedule.eventEnd)) sch.push(`eventEnd ${s?.eventEnd ?? '(none)'} -> ${ev.schedule.eventEnd}`);
const wt = ev.schedule.territories;
if (wt && [...new Set(s?.territories ?? [])].sort().join() !== [...new Set(wt)].sort().join()) sch.push(`territories ${(s?.territories ?? []).length} -> ${wt.length}`);
if (sch.length) {
attrs.territorySchedules = [{ territories: wt ?? s?.territories ?? [], publishStart: ev.schedule.publishStart, eventStart: ev.schedule.eventStart, eventEnd: ev.schedule.eventEnd }];
lines.push(...sch);
}
return { attrs, lines };
}
/** PATCH /v1/appEvents/{id} with the changed attributes, null when none. */
export const eventPatchReq = (eventId: string, name: string, attrs: Record<string, unknown>, lines: string[]): Req | null =>
Object.keys(attrs).length ? { method: 'PATCH', path: `/v1/appEvents/${eventId}`, note: `event "${name}": ${lines.join('; ')}`, body: { data: { type: 'appEvents', id: eventId, attributes: attrs } } } : null;
/** PATCH of a page version's deep link: set, change, or clear (config has none, the page has one). Null when equal. Pure. */
export function cppDeepLinkReq(versionId: string, name: string, want: string | undefined, have: string | null | undefined): Req | null {
if ((want ?? null) === (have ?? null)) return null;
return { method: 'PATCH', path: `/v1/appCustomProductPageVersions/${versionId}`, note: want ? `"${name}" deep link` : `"${name}" deep link removed`,
body: { data: { type: 'appCustomProductPageVersions', id: versionId, attributes: { deepLink: want ?? null } } } };
}
/** Locales on a page version that the config has no language for. Pure. */
export const extraCppLocales = (have: string[], want: string[]): string[] => have.filter(l => !want.includes(l));

View file

@ -50,6 +50,10 @@ export function changeLines(changes: DataSafetyChange[]): string[] {
return [...(acc.length ? [' account and deletion answers:', ...acc.map(line)] : []), ...(rest.length ? [' data types and other answers:', ...rest.map(line)] : [])];
}
/** The dry-run line about the state it compares with. Pure. */
export const dataSafetyDryNote = (last: { at: string } | null): string =>
`Play has no read API for the Data safety form: this compares with the last upload from this project (${DATA_SAFETY_HASH} sha256${last ? `, uploaded ${last.at.slice(0, 16).replace('T', ' ')} UTC` : ', none recorded'}), not with the live form; --force re-sends the CSV`;
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const ds = need(config.dataSafety, 'dataSafety');
@ -69,6 +73,7 @@ export async function run(ctx: Ctx): Promise<void> {
if (ctx.args.has('dry')) {
const upFile = join(root, DATA_SAFETY_UPLOADED);
const before = existsSync(upFile) ? readFileSync(upFile, 'utf8') : null;
log('play', dataSafetyDryNote(last));
const changes = dataSafetyDiff(before, rows);
log('play', `changes against ${before ? `the last uploaded CSV (${DATA_SAFETY_UPLOADED}; Play cannot read the form back)` : 'nothing (no upload recorded: every answer is new)'}: ${changes.length} answer(s)`);
for (const l of changeLines(changes)) console.log(l);

View file

@ -171,7 +171,7 @@ export async function run(ctx: Ctx): Promise<void> {
// a language Play does not have yet: its edit only commits with a hi-res icon (pitfall 102)
if (!now.some(n => n.language === b.language)) {
const graphics = firstGraphics(ctx, b.language!);
if (!graphics.some(([t]) => t === 'icon')) throw new Error(`${b.language}: a new Play language needs the 512x512 icon in the same edit (play.icon or store/icons/play-512.png): storebound icons makes it`);
if (!graphics.some(([t]) => t === 'icon')) warn('play', `${b.language}: no 512x512 icon to send with this new language (play.icon or store/icons/play-512.png; storebound icons makes it): Play may refuse the commit with 403 "no high resolution icon"`);
for (const [type, f] of graphics) {
await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/${b.language}/${type}?uploadType=media`, new Uint8Array(readFileSync(f)), { 'Content-Type': /\.png$/i.test(f) ? 'image/png' : 'image/jpeg' });
log('play', `${b.language}: ${type} uploaded with the new listing`);