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:
parent
e381bc2256
commit
abc195aee6
8 changed files with 558 additions and 59 deletions
|
|
@ -297,6 +297,92 @@ export function applePriceChanges(current: { territory: string; price: number; s
|
|||
return out;
|
||||
}
|
||||
|
||||
// ---- Apple: what exists vs the config (drift) ----
|
||||
|
||||
export interface AppleSubAttrs { name?: string; subscriptionPeriod?: string; familySharable?: boolean; reviewNote?: string | null; groupLevel?: number }
|
||||
/** `patch` = attributes PATCH /v1/subscriptions/{id} can change, `text` = lines for those, `report` = differences Apple cannot change. */
|
||||
export interface AppleAttrDiff { patch: Record<string, string | number>; text: string[]; report: string[] }
|
||||
|
||||
/** Subscription attributes on App Store Connect vs the config. Name (reference name), review note and group level are
|
||||
* patched; the period and family sharing cannot change after creation and are only reported. An attribute the read
|
||||
* did not return is left alone. Pure. */
|
||||
export function appleSubAttrDiff(s: Subscription, cur: AppleSubAttrs): AppleAttrDiff {
|
||||
const out: AppleAttrDiff = { patch: {}, text: [], report: [] };
|
||||
const want = APPLE_PERIOD[s.period];
|
||||
if (cur.subscriptionPeriod !== undefined && cur.subscriptionPeriod !== want) out.report.push(`${s.id}: period is ${cur.subscriptionPeriod} on App Store Connect, the config says ${want} (a period cannot change; use a new product id)`);
|
||||
const fam = s.familySharable ?? false;
|
||||
if (cur.familySharable !== undefined && cur.familySharable !== fam) out.report.push(`${s.id}: familySharable is ${cur.familySharable} on App Store Connect, the config says ${fam} (cannot be changed through the API)`);
|
||||
const name = s.referenceName ?? Object.values(s.listings)[0]?.title;
|
||||
if (name !== undefined && cur.name !== undefined && cur.name !== name) { out.patch.name = name; out.text.push(`reference name "${cur.name}" -> "${name}"`); }
|
||||
if (s.reviewNote !== undefined && cur.reviewNote !== undefined && (cur.reviewNote ?? '') !== s.reviewNote) { out.patch.reviewNote = s.reviewNote; out.text.push('review note changed'); }
|
||||
if (cur.groupLevel !== undefined && cur.groupLevel !== s.level) { out.patch.groupLevel = s.level; out.text.push(`level ${cur.groupLevel} -> ${s.level}`); }
|
||||
return out;
|
||||
}
|
||||
|
||||
export const appleSubPatchBody = (subId: string, attributes: Record<string, string | number>) => ({ data: { type: 'subscriptions', id: subId, attributes } });
|
||||
|
||||
/** Subscription localization text vs the config: the attributes to PATCH, or null when equal (or not returned). Pure. */
|
||||
export function appleLocDiff(want: { title: string; description: string }, cur: { name?: string; description?: string }): Record<string, string> | null {
|
||||
const patch: Record<string, string> = {};
|
||||
if (cur.name !== undefined && cur.name !== want.title) patch.name = want.title;
|
||||
if (cur.description !== undefined && cur.description !== want.description) patch.description = want.description;
|
||||
return Object.keys(patch).length ? patch : null;
|
||||
}
|
||||
export const appleLocPatchBody = (locId: string, attributes: Record<string, string>) => ({ data: { type: 'subscriptionLocalizations', id: locId, attributes } });
|
||||
|
||||
/** Subscription group localization (name, customAppName when configured) vs the config. Pure. */
|
||||
export function appleGroupLocDiff(want: { name: string; customAppName?: string }, cur: { name?: string; customAppName?: string | null }): Record<string, string> | null {
|
||||
const patch: Record<string, string> = {};
|
||||
if (cur.name !== undefined && cur.name !== want.name) patch.name = want.name;
|
||||
if (want.customAppName !== undefined && (cur.customAppName ?? '') !== want.customAppName) patch.customAppName = want.customAppName;
|
||||
return Object.keys(patch).length ? patch : null;
|
||||
}
|
||||
export const appleGroupLocPatchBody = (locId: string, attributes: Record<string, string>) => ({ data: { type: 'subscriptionGroupLocalizations', id: locId, attributes } });
|
||||
|
||||
/** Subscription availability vs "every territory + new ones": territories missing, availableInNewTerritories off. null = same. Pure. */
|
||||
export function appleAvailabilityDiff(cur: { territories: string[]; availableInNewTerritories?: boolean }, wanted: string[]): { missing: string[]; newTerritories: boolean } | null {
|
||||
const have = new Set(cur.territories);
|
||||
const missing = wanted.filter(t => !have.has(t));
|
||||
const newTerritories = cur.availableInNewTerritories === false;
|
||||
return missing.length || newTerritories ? { missing, newTerritories } : null;
|
||||
}
|
||||
|
||||
export interface AppleIntroOffer { territory: string; mode?: string; duration?: string; periods?: number; price?: number }
|
||||
|
||||
/** GET /v1/subscriptions/{id}/introductoryOffers?include=subscriptionPricePoint,territory -> offers per territory. */
|
||||
export function appleIntroOffers(resp: JsonApi): AppleIntroOffer[] {
|
||||
const points = new Map((resp.included ?? []).filter(x => x.type === 'subscriptionPricePoints').map(x => [x.id, Number(x.attributes?.customerPrice)] as const));
|
||||
return (resp.data ?? []).map(o => {
|
||||
const pid = o.relationships?.subscriptionPricePoint?.data?.id;
|
||||
const price = pid ? points.get(pid) : undefined;
|
||||
return {
|
||||
territory: o.relationships?.territory?.data?.id ?? '', mode: o.attributes?.offerMode as string | undefined, duration: o.attributes?.duration as string | undefined,
|
||||
periods: o.attributes?.numberOfPeriods as number | undefined, ...(price !== undefined && !Number.isNaN(price) ? { price } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Introductory offers on App Store Connect vs the config (Apple cannot PATCH them: report only), as `intro differs: ...`
|
||||
* lines. A subscription without any offer yet is created by the run, not a difference. Pure. */
|
||||
export function appleIntroDiff(s: Subscription, cur: AppleIntroOffer[]): string[] {
|
||||
if (!cur.length) return [];
|
||||
if (!s.intro) return [`intro differs: ${s.id} has an introductory offer on App Store Connect, the config has none`];
|
||||
const o = s.intro;
|
||||
const out: string[] = [];
|
||||
const distinct = <T>(f: (x: AppleIntroOffer) => T | undefined): T[] => [...new Set(cur.map(f).filter((x): x is T => x !== undefined))];
|
||||
const check = (what: string, have: (string | number)[], want: string | number) => {
|
||||
if (have.some(h => h !== want)) out.push(`intro differs: ${s.id} ${what} ${have.join('/')} on App Store Connect, ${want} in the config`);
|
||||
};
|
||||
check('mode', distinct(x => x.mode), APPLE_OFFER_MODE[o.type]);
|
||||
check('duration', distinct(x => x.duration), APPLE_OFFER_DURATION[introDuration(s)] ?? introDuration(s));
|
||||
check('periods', distinct(x => x.periods), o.type === 'pay-as-you-go' ? o.periods ?? 1 : 1);
|
||||
if (o.type !== 'free' && o.usd !== undefined) {
|
||||
const usa = cur.find(x => x.territory === 'USA')?.price;
|
||||
if (usa !== undefined && cents(usa) !== cents(o.usd)) out.push(`intro differs: ${s.id} price USA ${usa} on App Store Connect, ${o.usd} in the config`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- Apple: plan ----
|
||||
|
||||
export interface AppleSubState {
|
||||
|
|
@ -430,10 +516,20 @@ export function playOfferBody(pkg: string, s: Subscription, regions: PlayRegionP
|
|||
|
||||
const moneyKey = (m?: Money): string => (m ? `${m.currencyCode} ${cents(fromMoney(m))}` : '-');
|
||||
|
||||
export interface PlaySubDiff { create: boolean; listings: string[]; basePlan: 'new' | 'same' | 'changed'; priceChanges: { regionCode: string; from: string; to: string }[]; activate: boolean }
|
||||
export interface PlaySubDiff {
|
||||
create: boolean; listings: string[]; basePlan: 'new' | 'same' | 'changed'; priceChanges: { regionCode: string; from: string; to: string }[]; activate: boolean;
|
||||
/** Languages on Play that the config does not list (a PATCH drops them only with --prune-languages). Set only when there are some. */
|
||||
listingsRemoved?: string[];
|
||||
/** Grace period on Play vs the config. Set only when it differs. */
|
||||
grace?: { from: string; to: string };
|
||||
/** Regions sold on Play that the config no longer lists (also one "not sold" row in priceChanges, owner-gated). */
|
||||
droppedRegions?: string[];
|
||||
}
|
||||
|
||||
/** Current Play subscription (or null) vs the wanted body: what a patch would change. Price changes on an existing
|
||||
* base plan apply to new subscribers; existing ones keep their price until you run a price migration in the Console. */
|
||||
* base plan apply to new subscribers; existing ones keep their price until you run a price migration in the Console.
|
||||
* Money (regional prices, the other-regions USD / EUR price, regions no longer sold) all land in `priceChanges`, so
|
||||
* the price gate holds them; text (listings, grace period) is its own field. */
|
||||
export function playSubDiff(cur: PlaySubscription | null, want: PlaySubscription): PlaySubDiff {
|
||||
const wb = want.basePlans![0]!;
|
||||
if (!cur) return { create: true, listings: (want.listings ?? []).map(l => l.languageCode), basePlan: 'new', priceChanges: [], activate: true };
|
||||
|
|
@ -442,25 +538,100 @@ export function playSubDiff(cur: PlaySubscription | null, want: PlaySubscription
|
|||
const c = curL.get(l.languageCode);
|
||||
return !c || c.title !== l.title || (c.description ?? '') !== (l.description ?? '') || JSON.stringify(c.benefits ?? []) !== JSON.stringify(l.benefits ?? []);
|
||||
}).map(l => l.languageCode);
|
||||
const wantCodes = new Set((want.listings ?? []).map(l => l.languageCode));
|
||||
const removed = [...curL.keys()].filter(c => !wantCodes.has(c));
|
||||
const extras = removed.length ? { listingsRemoved: removed } : {};
|
||||
const cb = cur.basePlans?.find(b => b.basePlanId === wb.basePlanId);
|
||||
if (!cb) return { create: false, listings, basePlan: 'new', priceChanges: [], activate: true };
|
||||
if (!cb) return { create: false, listings, basePlan: 'new', priceChanges: [], activate: true, ...extras };
|
||||
const cr = new Map((cb.regionalConfigs ?? []).map(r => [r.regionCode, r.price] as const));
|
||||
const priceChanges = (wb.regionalConfigs ?? []).filter(r => moneyKey(cr.get(r.regionCode)) !== moneyKey(r.price))
|
||||
.map(r => ({ regionCode: r.regionCode, from: moneyKey(cr.get(r.regionCode)), to: moneyKey(r.price) }));
|
||||
const typeSame = cb.autoRenewingBasePlanType?.billingPeriodDuration === wb.autoRenewingBasePlanType?.billingPeriodDuration
|
||||
&& (cb.autoRenewingBasePlanType?.gracePeriodDuration ?? '') === (wb.autoRenewingBasePlanType?.gracePeriodDuration ?? cb.autoRenewingBasePlanType?.gracePeriodDuration ?? '');
|
||||
const wantRegions = new Set((wb.regionalConfigs ?? []).map(r => r.regionCode));
|
||||
const gone = [...cr.keys()].filter(c => !wantRegions.has(c));
|
||||
for (const c of gone) priceChanges.push({ regionCode: c, from: moneyKey(cr.get(c)), to: 'not sold' });
|
||||
const co = cb.otherRegionsConfig, wo = wb.otherRegionsConfig;
|
||||
if (wo && (moneyKey(co?.usdPrice) !== moneyKey(wo.usdPrice) || moneyKey(co?.eurPrice) !== moneyKey(wo.eurPrice)))
|
||||
priceChanges.push({ regionCode: 'other regions', from: `${moneyKey(co?.usdPrice)} / ${moneyKey(co?.eurPrice)}`, to: `${moneyKey(wo.usdPrice)} / ${moneyKey(wo.eurPrice)}` });
|
||||
const curGrace = cb.autoRenewingBasePlanType?.gracePeriodDuration ?? '';
|
||||
const wantGrace = wb.autoRenewingBasePlanType?.gracePeriodDuration ?? '';
|
||||
// a config without a grace period leaves the one on Play alone
|
||||
const graceChanged = wantGrace !== '' && wantGrace !== curGrace;
|
||||
if (cb.autoRenewingBasePlanType && cb.autoRenewingBasePlanType.billingPeriodDuration !== wb.autoRenewingBasePlanType?.billingPeriodDuration)
|
||||
throw new Error(`${want.productId}: base plan ${wb.basePlanId} bills every ${cb.autoRenewingBasePlanType.billingPeriodDuration} on Play, the config says ${wb.autoRenewingBasePlanType?.billingPeriodDuration} (a billing period cannot change; use a new basePlanId)`);
|
||||
return { create: false, listings, basePlan: priceChanges.length || !typeSame ? 'changed' : 'same', priceChanges, activate: cb.state !== 'ACTIVE' };
|
||||
return {
|
||||
create: false, listings, basePlan: priceChanges.length || graceChanged ? 'changed' : 'same', priceChanges, activate: cb.state !== 'ACTIVE', ...extras,
|
||||
...(graceChanged ? { grace: { from: curGrace || '-', to: wantGrace } } : {}), ...(gone.length ? { droppedRegions: gone } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The patch body keeps base plans the config does not manage (Play would otherwise drop them) and output-only fields out. */
|
||||
export function playPatchSubscription(cur: PlaySubscription, want: PlaySubscription): PlaySubscription {
|
||||
/** The patch body keeps base plans the config does not manage (Play would otherwise drop them) and output-only fields out.
|
||||
* Languages on Play that the config does not list stay in the body unless `pruneLanguages`. */
|
||||
export function playPatchSubscription(cur: PlaySubscription, want: PlaySubscription, o: { pruneLanguages?: boolean } = {}): PlaySubscription {
|
||||
const wb = want.basePlans![0]!;
|
||||
const keep = (cur.basePlans ?? []).filter(b => b.basePlanId !== wb.basePlanId).map(({ state: _s, ...b }) => b);
|
||||
const curMine = cur.basePlans?.find(b => b.basePlanId === wb.basePlanId);
|
||||
const { state: _st, ...mine } = { ...(curMine ?? {}), ...wb, autoRenewingBasePlanType: { ...(curMine?.autoRenewingBasePlanType ?? {}), ...wb.autoRenewingBasePlanType! } };
|
||||
return { ...want, basePlans: [...keep, mine as NonNullable<PlaySubscription['basePlans']>[number]] };
|
||||
const wantCodes = new Set((want.listings ?? []).map(l => l.languageCode));
|
||||
const kept = o.pruneLanguages ? [] : (cur.listings ?? []).filter(l => !wantCodes.has(l.languageCode));
|
||||
return { ...want, ...(kept.length ? { listings: [...(want.listings ?? []), ...kept] } : {}), basePlans: [...keep, mine as NonNullable<PlaySubscription['basePlans']>[number]] };
|
||||
}
|
||||
|
||||
/** The text side of a Play subscription diff (no money; empty = nothing). Pure. */
|
||||
export function playSubTextChanges(d: PlaySubDiff, prune = false): string[] {
|
||||
return [
|
||||
d.listings.length ? `listings ${d.listings.join(', ')}` : '',
|
||||
d.listingsRemoved?.length ? (prune ? `listings removed: ${d.listingsRemoved.join(', ')}` : `listings removed from the config: ${d.listingsRemoved.join(', ')} (kept on Play; --prune-languages removes them)`) : '',
|
||||
d.grace ? `grace ${d.grace.from} -> ${d.grace.to}` : '',
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
// ---- Play: offers already on the base plan ----
|
||||
|
||||
export interface PlayOfferLike {
|
||||
offerId?: string; state?: string;
|
||||
phases?: { recurrenceCount?: number; duration?: string; regionalConfigs?: { regionCode: string; free?: unknown; relativeDiscount?: number; absoluteDiscount?: Money; price?: Money }[];
|
||||
otherRegionsConfig?: { free?: unknown; relativeDiscount?: number; absoluteDiscount?: unknown; otherRegionsPrices?: { usdPrice?: Money; eurPrice?: Money } } }[];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
type Entry = Record<string, unknown> | undefined;
|
||||
/** "free", "-50%", "-USD 100" or "USD 299": the kind (free / discount / price) and the shown value of one phase entry. */
|
||||
function phaseEntry(c: Entry): { kind: string; text: string } {
|
||||
if (!c) return { kind: '-', text: '-' };
|
||||
if (c.free !== undefined) return { kind: 'free', text: 'free' };
|
||||
if (typeof c.relativeDiscount === 'number') return { kind: 'discount', text: `-${Math.round(c.relativeDiscount * 100)}%` };
|
||||
if (c.absoluteDiscount) return { kind: 'absolute', text: `-${moneyKey(c.absoluteDiscount as Money)}` };
|
||||
if (c.price) return { kind: 'price', text: moneyKey(c.price as Money) };
|
||||
const o = c.otherRegionsPrices as { usdPrice?: Money; eurPrice?: Money } | undefined;
|
||||
if (o) return { kind: 'price', text: `${moneyKey(o.usdPrice)} / ${moneyKey(o.eurPrice)}` };
|
||||
return { kind: '-', text: '-' };
|
||||
}
|
||||
|
||||
export interface PlayOfferDrift { phase: string[]; prices: { region: string; from: string; to: string }[] }
|
||||
|
||||
/** Offer on Play vs the offer the config would create: `phase` = free / paid kind, duration, periods (text), `prices` =
|
||||
* per region the discount, price or other-regions price (money: only behind the price gate). Pure. */
|
||||
export function playOfferDiff(cur: PlayOfferLike, want: PlayOfferLike): PlayOfferDrift {
|
||||
const out: PlayOfferDrift = { phase: [], prices: [] };
|
||||
const cp = cur.phases ?? [], wp = want.phases ?? [];
|
||||
if (cp.length !== wp.length) out.phase.push(`${cp.length} phase(s) -> ${wp.length}`);
|
||||
for (const [i, w] of wp.entries()) {
|
||||
const c = cp[i];
|
||||
if (!c) continue;
|
||||
const at = wp.length > 1 ? `phase ${i + 1} ` : '';
|
||||
if ((c.duration ?? '') !== (w.duration ?? '')) out.phase.push(`${at}duration ${c.duration ?? '-'} -> ${w.duration ?? '-'}`);
|
||||
if ((c.recurrenceCount ?? 1) !== (w.recurrenceCount ?? 1)) out.phase.push(`${at}periods ${c.recurrenceCount ?? 1} -> ${w.recurrenceCount ?? 1}`);
|
||||
const cm = new Map((c.regionalConfigs ?? []).map(r => [r.regionCode, r as Entry] as const));
|
||||
const rows: [string, Entry, Entry][] = (w.regionalConfigs ?? []).map(r => [r.regionCode, cm.get(r.regionCode), r as Entry]);
|
||||
rows.push(['other regions', c.otherRegionsConfig as Entry, w.otherRegionsConfig as Entry]);
|
||||
for (const [region, cc, wc] of rows) {
|
||||
const a = phaseEntry(cc), b = phaseEntry(wc);
|
||||
if (a.text === b.text) continue;
|
||||
if (a.kind !== b.kind && a.kind !== '-') out.phase.push(`${at}${region} ${a.text} -> ${b.text}`);
|
||||
else out.prices.push({ region, from: a.text, to: b.text });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- Apple: reading responses ----
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue