Subscriptions drift on both stores (Apple prices in the dry run, localizations, group names, attributes, territories, intro offers reported; Play offer phases and prices, other-regions price, dropped regions gated, grace period, --prune-languages); pitfall 104, CHANGELOG; test fixtures for the new reads

This commit is contained in:
CRTkafa 2026-10-22 20:49:43 +08:00
parent e381bc2256
commit abc195aee6
8 changed files with 558 additions and 59 deletions

View file

@ -20,10 +20,11 @@ import { type OfferCodeDef, sellsOn, type Subscription, type SubscriptionGroup }
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { done, log, step, warn } from '../lib/log';
import { allowPriceChanges, type HeldPrice, holdPriceChanges, withoutYes } from '../lib/price-gate';
import { allowPriceChanges, type HeldPrice, heldSection, holdPriceChanges, withoutYes } from '../lib/price-gate';
import { parsePriceCsv, type PriceRow } from '../lib/prices';
import {
allSubscriptions, appleAvailabilityBody, appleCustomCodeBody, appleOfferCodeBody, applePromoOfferBody, appleGroupBody, appleGroupLocBody, appleIntroPatch, applePoints, applePriceChanges,
allSubscriptions, appleAvailabilityBody, appleAvailabilityDiff, appleGroupLocDiff, appleGroupLocPatchBody, appleIntroDiff, appleIntroOffers, appleLocDiff, appleLocPatchBody,
type AppleAttrDiff, appleSubAttrDiff, appleSubPatchBody, appleCustomCodeBody, appleOfferCodeBody, applePromoOfferBody, appleGroupBody, appleGroupLocBody, appleIntroPatch, applePoints, applePriceChanges,
applePricesPatch, appleSubLocBody, appleSubPrices, appleSubscriptionBody, appleSubscriptionPlan, type AppleSubState, appleWantedPrices,
type ApplePoint, pickPoint,
} from '../lib/subscriptions';
@ -69,6 +70,75 @@ async function readState(c: Asc, appId: string): Promise<AppleSubState> {
const heldOf = (id: string, changes: { territory: string; from: number | null; to: number; startDate?: string | null }[]): HeldPrice =>
({ id, changes: changes.map(ch => ({ region: ch.territory, from: ch.from === null ? '-' : String(ch.from), to: `${ch.to}${ch.startDate ? ` from ${ch.startDate}` : ''}` })) });
/** Everything an existing subscription has that differs from the config (pure data; the run prints and applies it). */
export interface SubDrift {
attrs: AppleAttrDiff;
locs: { id: string; loc: string; patch: Record<string, string> }[];
availability: { missing: string[]; newTerritories: boolean } | null;
intro: string[];
}
/** Reads the live subscription (attributes, localizations, availability, introductory offers) and diffs it with the config. Read-only. */
export async function readSubDrift(c: Asc, s: Subscription, id: string, locale: Map<string, string | undefined>, territories: string[]): Promise<SubDrift> {
const live = (await c.maybe(`/v1/subscriptions/${id}`))?.data?.attributes ?? {};
const attrs = appleSubAttrDiff(s, { name: live.name, subscriptionPeriod: live.subscriptionPeriod, familySharable: live.familySharable, reviewNote: live.reviewNote, groupLevel: live.groupLevel });
const locs: SubDrift['locs'] = [];
const haveLocs = await c.all(`/v1/subscriptions/${id}/subscriptionLocalizations?limit=200`);
for (const [lang, l] of Object.entries(s.listings)) {
const loc = locale.get(lang);
const h = loc ? haveLocs.find((x: J) => x.attributes.locale === loc) : undefined;
const patch = h ? appleLocDiff(l, { name: h.attributes.name, description: h.attributes.description }) : null;
if (h && patch && loc) locs.push({ id: h.id, loc, patch });
}
let availability: SubDrift['availability'] = null;
const av = (await c.maybe(`/v1/subscriptions/${id}/subscriptionAvailability`))?.data;
if (av?.id) {
let haveT: string[] | null = null;
try { haveT = (await c.all(`/v1/subscriptionAvailabilities/${av.id}/availableTerritories?limit=200`)).map((t: J) => t.id as string); } catch { /* unreadable: left alone */ }
if (haveT) availability = appleAvailabilityDiff({ territories: haveT, availableInNewTerritories: av.attributes?.availableInNewTerritories }, territories);
}
const hasIntro = !!(await c.asc('GET', `/v1/subscriptions/${id}/introductoryOffers?limit=1`)).data?.length;
const intro = hasIntro ? appleIntroDiff(s, appleIntroOffers(await pages(c, `/v1/subscriptions/${id}/introductoryOffers?include=subscriptionPricePoint,territory&limit=200`))) : [];
return { attrs, locs, availability, intro };
}
/** Group localizations whose name / customAppName differ from the config. Read-only. */
export async function readGroupLocDrift(c: Asc, g: SubscriptionGroup, groupId: string, locale: Map<string, string | undefined>): Promise<{ id: string; loc: string; patch: Record<string, string> }[]> {
const have = await c.all(`/v1/subscriptionGroups/${groupId}/subscriptionGroupLocalizations?limit=200`);
const out: { id: string; loc: string; patch: Record<string, string> }[] = [];
for (const [lang, l] of Object.entries(g.listings)) {
const loc = locale.get(lang);
const h = loc ? have.find((x: J) => x.attributes.locale === loc) : undefined;
const patch = h ? appleGroupLocDiff(l, { name: h.attributes.name, customAppName: h.attributes.customAppName }) : null;
if (h && patch && loc) out.push({ id: h.id, loc, patch });
}
return out;
}
/** The lines the dry run prints for one subscription's drift. Pure. */
export function driftLines(id: string, d: SubDrift): string[] {
const av = d.availability;
return [
...d.attrs.text.map(x => `${id}: ${x}`), ...d.attrs.report,
...d.locs.map(l => `${id} ${l.loc}: ${Object.keys(l.patch).join(' + ')} differ from the config`),
...(av ? [`${id}: availability${av.missing.length ? ` ${av.missing.length} territories missing (${av.missing.slice(0, 5).join(', ')})` : ''}${av.newTerritories ? ' new territories off' : ''}`] : []),
...d.intro,
];
}
/** Today's prices vs the config for one existing subscription (read-only). */
async function priceState(c: Asc, s: Subscription, id: string, cur: { hasPrices: boolean } | undefined, csv: PriceRow[]) {
const usaPoints = applePoints(await pages(c, `/v1/subscriptions/${id}/pricePoints?filter[territory]=USA&include=territory&limit=200`));
const usa = pickPoint(usaPoints, 'USA', s.usd);
const equalized = applePoints(await pages(c, `/v1/subscriptionPricePoints/${usa.id}/equalizations?include=territory&limit=200`));
const known: ApplePoint[] = [usa, ...equalized];
// a subscription the first read missed (list lag) is read live, so nothing is sent twice
const hasPrices = cur ? cur.hasPrices : !!(await c.asc('GET', `/v1/subscriptions/${id}/prices?limit=1`)).data?.length;
const current = hasPrices ? appleSubPrices(await pages(c, `/v1/subscriptions/${id}/prices?include=subscriptionPricePoint,territory&limit=200`)) : [];
const changes = applePriceChanges(current, appleWantedPrices(s, equalized, csv), csv.filter(r => r.product === s.id));
return { usaPoints, known, current, changes };
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const only = ctx.args.list('only');
@ -83,7 +153,29 @@ export async function run(ctx: Ctx): Promise<void> {
const state = await readState(c, appId);
const plan = appleSubscriptionPlan(groups, config.languages, state);
for (const [i, s] of plan.entries()) log(TAG, `${step(i + 1, plan.length, s.what)}`);
if (dry) { done(TAG, `subscriptions planned (dry run, nothing written; prices are compared per territory on a real run${csv.length ? `, ${csv.length} CSV rows` : ''})`); return; }
if (dry) {
// read-only comparison of everything that exists: text, attributes, availability, intro offers and prices (old -> new)
const locale = new Map(config.languages.map(l => [l.id, l.apple] as const));
const territories: string[] = (await c.all('/v1/territories?limit=200')).map((t: J) => t.id);
const heldDry: HeldPrice[] = [];
for (const g of groups) {
const sg = state.groups.find(x => x.referenceName === g.name);
if (sg) for (const d of await readGroupLocDrift(c, g, sg.id, locale)) log(TAG, `group "${g.name}" ${d.loc}: ${Object.keys(d.patch).join(' + ')} differ from the config`);
for (const s of g.subscriptions) {
const cur = state.subscriptions.find(x => x.productId === s.id);
if (!cur) continue;
for (const l of driftLines(s.id, await readSubDrift(c, s, cur.id, locale, territories))) log(TAG, l);
try {
const { changes } = await priceState(c, s, cur.id, cur, csv);
if (changes.length) { log(TAG, `${s.id}: ${changes.length} price change(s)`); heldDry.push(heldOf(s.id, changes)); }
} catch (e) { warn(TAG, `${s.id}: prices not compared (${String((e as Error).message ?? e).slice(0, 160)})`); }
}
}
const h = heldSection(heldDry, ctx.args.has('allow-price-changes'));
if (h) console.log(h);
done(TAG, `subscriptions planned (dry run, nothing written${csv.length ? `, ${csv.length} CSV rows` : ''})`);
return;
}
const yes = ctx.args.has('yes');
// price changes of existing subscriptions: only with --allow-price-changes, their own question per subscription
@ -115,6 +207,12 @@ export async function run(ctx: Ctx): Promise<void> {
create: async () => (await c.asc('POST', '/v1/subscriptionGroupLocalizations', appleGroupLocBody(groupId, loc, l))).data });
log(TAG, ` group ${loc}`);
}
if (state.groups.some(x => x.id === groupId)) {
for (const d of await readGroupLocDrift(c, g, groupId, locale)) {
await c.asc('PATCH', `/v1/subscriptionGroupLocalizations/${d.id}`, appleGroupLocPatchBody(d.id, d.patch));
log(TAG, ` group ${d.loc}: ${Object.keys(d.patch).join(' + ')} updated`);
}
}
for (const [i, s] of g.subscriptions.entries()) {
const tag = step(i + 1, g.subscriptions.length, s.id);
const cur = state.subscriptions.find(x => x.productId === s.id);
@ -139,16 +237,18 @@ export async function run(ctx: Ctx): Promise<void> {
log(TAG, ` ${loc}`);
}
if (!cur?.availability && !(await c.maybe(`/v1/subscriptions/${id}/subscriptionAvailability`))?.data) { await c.asc('POST', '/v1/subscriptionAvailabilities', appleAvailabilityBody(id, territories)); log(TAG, ` available in ${territories.length} territories + new ones`); }
if (cur) {
// what exists but differs from the config: text and attributes are patched, availability is set again, intro offers
// and the period / family sharing are only reported (Apple cannot change them)
const d = await readSubDrift(c, s, subId, locale, territories);
if (Object.keys(d.attrs.patch).length) { await c.asc('PATCH', `/v1/subscriptions/${subId}`, appleSubPatchBody(subId, d.attrs.patch)); log(TAG, ` attributes updated: ${d.attrs.text.join(', ')}`); }
for (const l of d.locs) { await c.asc('PATCH', `/v1/subscriptionLocalizations/${l.id}`, appleLocPatchBody(l.id, l.patch)); log(TAG, ` ${l.loc}: ${Object.keys(l.patch).join(' + ')} updated`); }
if (d.availability) { await c.asc('POST', '/v1/subscriptionAvailabilities', appleAvailabilityBody(subId, territories)); log(TAG, ` availability set again: ${territories.length} territories + new ones`); }
for (const x of [...d.attrs.report, ...d.intro]) warn(TAG, x);
}
// prices: USA point -> equalizations -> overrides / CSV; only what differs from today's prices
const usaPoints = applePoints(await pages(c, `/v1/subscriptions/${id}/pricePoints?filter[territory]=USA&include=territory&limit=200`));
const usa = pickPoint(usaPoints, 'USA', s.usd);
const equalized = applePoints(await pages(c, `/v1/subscriptionPricePoints/${usa.id}/equalizations?include=territory&limit=200`));
const known: ApplePoint[] = [usa, ...equalized];
// a subscription the first read missed (list lag) is read live, so nothing is sent twice
const hasPrices = cur ? cur.hasPrices : !!(await c.asc('GET', `/v1/subscriptions/${id}/prices?limit=1`)).data?.length;
const current = hasPrices ? appleSubPrices(await pages(c, `/v1/subscriptions/${id}/prices?include=subscriptionPricePoint,territory&limit=200`)) : [];
const changes = applePriceChanges(current, appleWantedPrices(s, equalized, csv), csv.filter(r => r.product === s.id));
const { usaPoints, known, current, changes } = await priceState(c, s, id, cur, csv);
if (!changes.length) log(TAG, ' prices up to date');
else if (current.length && !(await allowPriceChanges(ctx, [heldOf(s.id, changes)], `Change the prices of the existing subscription ${s.id} on App Store Connect (${changes.length} territories)? New subscribers pay the new price.`))) {
log(TAG, ' owner: price change of an existing subscription (--allow-price-changes)');