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

@ -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