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:
parent
f2b841d735
commit
e381bc2256
11 changed files with 206 additions and 10 deletions
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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)) });
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue