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

@ -2,6 +2,20 @@
## Unreleased
- **Existing store items are compared with the config, field by field** (pitfall 104). The dry run shows every
difference (old -> new); texts are sent, money and availability need a flag, fields Apple cannot edit are named.
- `apple iap`: price (`--allow-price-changes`), localization names and descriptions, reference name, review note,
family sharing (reported), territories.
- `apple subscriptions`: prices in the dry run, localizations, group names, reference name, review note, level,
territories; intro offers, period and family sharing reported.
- `play subscriptions`: offer phases and prices, other-regions price, regions no longer sold (all price-gated),
grace period, languages missing from the config kept unless `--prune-languages`.
- `apple appinfo`: app price (`--allow-price-changes`) and territories (removals `--allow-availability-changes`);
`apple review-details --dry` shows old -> new.
- Offer-code and win-back campaigns that differ are named; in-app event attributes patched on drafts; a removed
custom page deep link cleared; config languages without a version localization named.
- `prices diff` (the `catalog diff` report).
- Windows: no false "entitlements not referenced by CODE_SIGN_ENTITLEMENTS" warning (paths compared with forward slashes). Pitfall 103.
- **`storebound apps relink`** after a machine move: every "folder not found" entry of the app folder list is pointed at

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)');

View file

@ -61,7 +61,10 @@
"plist",
"udid"
],
"apple appinfo": [],
"apple appinfo": [
"allow-availability-changes",
"allow-price-changes"
],
"apple build": [
"allow-ssh",
"allow-stale-web",
@ -403,6 +406,8 @@
"write"
],
"check": [
"allow-availability-changes",
"allow-price-changes",
"aso",
"capacitor",
"cordova",
@ -420,6 +425,8 @@
"unity"
],
"console fill": [
"allow-availability-changes",
"allow-price-changes",
"capacitor",
"cdp",
"chrome-profile",
@ -441,6 +448,8 @@
],
"console plan": [
"all",
"allow-availability-changes",
"allow-price-changes",
"capacitor",
"cdp",
"chrome-profile",
@ -464,6 +473,8 @@
"use-chrome-session"
],
"console verify": [
"allow-availability-changes",
"allow-price-changes",
"capacitor",
"cordova",
"cordova-android",
@ -605,6 +616,8 @@
"threshold"
],
"play app-id": [
"allow-availability-changes",
"allow-price-changes",
"capacitor",
"cdp",
"chrome-profile",
@ -710,6 +723,7 @@
"offline",
"only-decreases",
"out",
"prune-languages",
"reconvert",
"source",
"store",
@ -780,6 +794,8 @@
"workspace"
],
"report": [
"allow-availability-changes",
"allow-price-changes",
"capacitor",
"cordova",
"cordova-android",
@ -985,6 +1001,7 @@
"agent",
"all",
"all-pages",
"allow-availability-changes",
"allow-existing",
"allow-price-changes",
"allow-ssh",
@ -1146,6 +1163,7 @@
"project",
"propose",
"prune",
"prune-languages",
"q",
"rating",
"react-native",
@ -1263,7 +1281,7 @@
"storebound apple adhoc export --archive <App.xcarchive> [--plist build/ExportOptions.plist] [--out build/adhoc] [--dry]"
],
"apple appinfo": [
"storebound apple appinfo [--dry] [--json]"
"storebound apple appinfo [--dry] [--json] [--allow-price-changes] [--allow-availability-changes]"
],
"apple build": [
"storebound apple build [--version 1.2.0] [--build-number N] [--scheme S] [--workspace W | --project P]"
@ -1311,9 +1329,6 @@
"apple next-build": [
"storebound apple next-build -> 42"
],
"apple offer-codes": [
"storebound apple offer-codes [--only id1,id2] [--codes] [--dry] [--yes] [--json]"
],
"apple ppo": [
"storebound apple ppo status [--json]",
"storebound apple ppo create --variant b[,c] [--name \"...\"] [--traffic 30] [--dry]",
@ -1338,9 +1353,6 @@
"apple resubmit": [
"storebound apple resubmit --build <n> [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--dry] [--yes] [--json]"
],
"apple review-details": [
"storebound apple review-details [--contact-from <Apple ID of another app>] [--dry]"
],
"apple screenshots": [
"storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--dry]",
"storebound apple screenshots --repair-stuck [--dry] (only the screenshots stuck outside COMPLETE, in place)"

View file

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

View file

@ -20,8 +20,8 @@ import { done, log, step, warn } from '../lib/log';
import { allowPriceChanges, type HeldPrice, heldSection, holdPriceChanges } from '../lib/price-gate';
import { type Money, toMoney } from '../lib/money';
import {
allSubscriptions, basePlanId, offerId, playOfferBody, playPatchSubscription, type PlaySubscription, playSubDiff, playSubRegions,
playSubscriptionBody,
allSubscriptions, basePlanId, offerId, type PlayOfferLike, playOfferBody, playOfferDiff, playPatchSubscription, type PlaySubscription, playSubDiff, playSubRegions,
playSubscriptionBody, playSubTextChanges,
} from '../lib/subscriptions';
import { subscriptionCsv } from '../apple/subscriptions';
import { createOnce } from '../lib/upsert';
@ -58,7 +58,19 @@ export async function run(ctx: Ctx): Promise<void> {
return { convertedRegionPrices: conv.convertedRegionPrices, convertedOtherRegionsPrice: conv.convertedOtherRegionsPrice, regionVersion: conv.regionVersion ?? { version: '2022/02' } };
};
const work: { s: typeof subs[number]; body: PlaySubscription; diff: ReturnType<typeof playSubDiff>; version: string; offerMissing: boolean; extraMissing: string[] }[] = [];
const prune = ctx.args.has('prune-languages');
// the offer the config would create (pay-up-front prices come from convertRegionPrices of the offer price)
const buildOffer = async (s: typeof subs[number], regions: { regionCode: string; price: Money }[], o: { type: string; usd?: number }, extra?: NonNullable<typeof s.offers>[number]) => {
let offerRegions;
if (o.type === 'pay-up-front') {
const oc = await convert(o.usd!);
offerRegions = { regions: Object.values(oc.convertedRegionPrices).map(r => ({ regionCode: r.regionCode, price: r.price })), other: oc.convertedOtherRegionsPrice };
}
return playOfferBody(pkg, s, regions, offerRegions, extra);
};
interface OfferDrift { offerId: string; phase: string[]; prices: HeldPrice['changes']; body: ReturnType<typeof playOfferBody> }
const work: { s: typeof subs[number]; body: PlaySubscription; diff: ReturnType<typeof playSubDiff>; version: string; offerMissing: boolean; extraMissing: string[]; offerDrift: OfferDrift[] }[] = [];
for (const [i, s] of subs.entries()) {
const conv = await convert(s.usd);
const regions = playSubRegions(s, conv.convertedRegionPrices, csv);
@ -67,42 +79,63 @@ export async function run(ctx: Ctx): Promise<void> {
const diff = playSubDiff(cur, body);
let offerMissing = !!s.intro;
let extraMissing = (s.offers ?? []).map(o => o.id);
const offerDrift: OfferDrift[] = [];
if ((s.intro || s.offers?.length) && cur?.basePlans?.some(b => b.basePlanId === basePlanId(s))) {
const offers = await play.call<{ subscriptionOffers?: { offerId: string }[] }>('GET', `${play.API}/subscriptions/${encodeURIComponent(s.id)}/basePlans/${basePlanId(s)}/offers`);
const have = new Set((offers.subscriptionOffers ?? []).map(o => o.offerId));
offerMissing = !!s.intro && !have.has(offerId(s));
extraMissing = extraMissing.filter(x => !have.has(x));
const offers = await play.call<{ subscriptionOffers?: PlayOfferLike[] }>('GET', `${play.API}/subscriptions/${encodeURIComponent(s.id)}/basePlans/${basePlanId(s)}/offers`);
const haveOffers = new Map((offers.subscriptionOffers ?? []).map(o => [o.offerId ?? '', o] as const));
offerMissing = !!s.intro && !haveOffers.has(offerId(s));
extraMissing = extraMissing.filter(x => !haveOffers.has(x));
// offers that exist: phases (free / paid, duration, periods) and prices per region vs what the config would create
const regionPrices = body.basePlans![0]!.regionalConfigs!.map(r => ({ regionCode: r.regionCode, price: r.price! }));
const check: [string, { type: string; usd?: number }, NonNullable<typeof s.offers>[number] | undefined][] = [];
if (s.intro && haveOffers.has(offerId(s))) check.push([offerId(s), s.intro, undefined]);
for (const o of s.offers ?? []) if (haveOffers.has(o.id)) check.push([o.id, o, o]);
for (const [oid, o, extra] of check) {
const want = await buildOffer(s, regionPrices, o, extra);
const d = playOfferDiff(haveOffers.get(oid)!, want as unknown as PlayOfferLike);
if (d.phase.length || d.prices.length) offerDrift.push({ offerId: oid, phase: d.phase, prices: d.prices, body: want });
}
}
const text = playSubTextChanges(diff, prune);
const what = diff.create ? `create, base plan ${basePlanId(s)} (${s.period}) in ${regions.length} regions`
: [diff.listings.length ? `listings ${diff.listings.join(', ')}` : '', diff.basePlan === 'new' ? `new base plan ${basePlanId(s)}` : diff.basePlan === 'changed' ? `base plan ${basePlanId(s)}: ${diff.priceChanges.length} regional price change(s)` : '',
diff.activate ? 'activate base plan' : ''].filter(Boolean).join(' · ') || 'up to date';
: [...text.filter(x => !x.startsWith('grace')), diff.basePlan === 'new' ? `new base plan ${basePlanId(s)}` : diff.basePlan === 'changed' && diff.priceChanges.length ? `base plan ${basePlanId(s)}: ${diff.priceChanges.length} regional price change(s)` : '',
...text.filter(x => x.startsWith('grace')), diff.activate ? 'activate base plan' : ''].filter(Boolean).join(' · ') || 'up to date';
log(TAG, `${step(i + 1, subs.length, s.id)}: ${what}${offerMissing ? ` · intro offer ${offerId(s)} (${s.intro!.type})` : ''}${extraMissing.length ? ` · offers ${extraMissing.join(', ')}` : ''}`);
if (diff.priceChanges.length) log(TAG, ' owner: price change of an existing base plan (--allow-price-changes)');
for (const p of diff.priceChanges.slice(0, 5)) log(TAG, ` ${p.regionCode}: ${p.from} -> ${p.to}`);
if (diff.priceChanges.length > 5) log(TAG, ` ... ${diff.priceChanges.length - 5} more`);
work.push({ s, body, diff, version: conv.regionVersion.version, offerMissing, extraMissing });
for (const d of offerDrift) {
log(TAG, ` offer ${d.offerId} differs${d.phase.length ? `: ${d.phase.join('; ')}` : ''}${d.prices.length ? ` · ${d.prices.length} price(s), owner (--allow-price-changes)` : ''}`);
for (const p of d.prices.slice(0, 5)) log(TAG, ` ${p.region}: ${p.from} -> ${p.to}`);
}
work.push({ s, body, diff, version: conv.regionVersion.version, offerMissing, extraMissing, offerDrift });
}
// money held behind --allow-price-changes: base plan prices (incl. other regions and regions dropped) and offer prices
const moneyOf = (w: typeof work[number]): HeldPrice[] => [
...(w.diff.basePlan === 'changed' && w.diff.priceChanges.length ? [{ id: w.s.id, changes: w.diff.priceChanges.map(p => ({ region: p.regionCode, from: p.from, to: p.to })) }] : []),
...w.offerDrift.filter(d => d.prices.length).map(d => ({ id: `${w.s.id} offer ${d.offerId}`, changes: d.prices })),
];
if (dry) {
const h = heldSection(work.filter(w => w.diff.basePlan === 'changed' && w.diff.priceChanges.length).map(w => ({ id: w.s.id, changes: w.diff.priceChanges.map(p => ({ region: p.regionCode, from: p.from, to: p.to })) })), ctx.args.has('allow-price-changes'));
const h = heldSection(work.flatMap(moneyOf), ctx.args.has('allow-price-changes'));
if (h) console.log(h);
done(TAG, 'subscriptions planned (dry run, nothing written)'); return;
}
const todo = work.filter(w => w.diff.create || w.diff.listings.length || w.diff.basePlan !== 'same' || w.diff.activate || w.offerMissing || w.extraMissing.length);
const todo = work.filter(w => w.diff.create || w.diff.listings.length || w.diff.basePlan !== 'same' || w.diff.activate || w.offerMissing || w.extraMissing.length || w.offerDrift.length || (prune && w.diff.listingsRemoved?.length));
if (!todo.length) { done(TAG, 'subscriptions up to date'); return; }
const yes = ctx.args.has('yes');
const priced = todo.filter(w => w.diff.basePlan === 'changed' && w.diff.priceChanges.length);
const priced = todo.filter(w => moneyOf(w).length);
// the terminal question covers creates and other updates; price changes of existing base plans need
// --allow-price-changes and their own question listing each old -> new (lib/price-gate.ts)
if (stdinIsTTY() && !yes && process.env.STOREBOUND_SERVE !== '1') {
if (!(await confirm(ctx, `Write ${todo.length} subscription(s) to Google Play? Activated base plans and offers can be bought as soon as the app is published. Price changes of existing base plans are not part of this answer (--allow-price-changes).`))) return;
}
const liveOk = await allowPriceChanges(ctx, priced.map(w => ({ id: w.s.id, changes: w.diff.priceChanges.map(p => ({ region: p.regionCode, from: p.from, to: p.to })) })),
`Change the base plan prices of ${priced.length} existing subscription(s) on Google Play? New subscribers pay the new price.`);
if (!liveOk && priced.length) log(TAG, 'writing what is new; price changes of existing base plans wait for the owner (--allow-price-changes)');
const liveOk = await allowPriceChanges(ctx, priced.flatMap(moneyOf),
`Change the prices (base plan, other regions, regions sold, offers) of ${priced.length} existing subscription(s) on Google Play? New subscribers pay the new price.`);
if (!liveOk && priced.length) log(TAG, 'writing what is new; price changes of existing base plans and offers wait for the owner (--allow-price-changes)');
const held: HeldPrice[] = [];
for (const { s, body, diff, version, offerMissing, extraMissing } of todo) {
for (const { s, body, diff, version, offerMissing, extraMissing, offerDrift } of todo) {
const v = encodeURIComponent(version);
const id = encodeURIComponent(s.id);
const cur = existing.get(s.id);
@ -113,16 +146,19 @@ export async function run(ctx: Ctx): Promise<void> {
create: () => play.call<PlaySubscription>('POST', `${play.API}/subscriptions?productId=${id}&regionsVersion.version=${v}`, body) });
log(TAG, `${s.id}: ${r.created ? 'created' : 'exists already (made by an earlier run); run again to compare it with the config'}`);
if (!r.created) continue;
} else if (diff.listings.length || diff.basePlan !== 'same') {
} else if (diff.listings.length || diff.basePlan !== 'same' || (prune && diff.listingsRemoved?.length)) {
const pruneLanguages = prune;
if (diff.basePlan === 'changed' && diff.priceChanges.length && !liveOk) {
held.push({ id: s.id, changes: diff.priceChanges.map(p => ({ region: p.regionCode, from: p.from, to: p.to })) });
if (diff.listings.length) {
// the listings only; the base plans as Play has them
await play.call('PATCH', `${play.API}/subscriptions/${id}?updateMask=listings&regionsVersion.version=${v}`, { ...playPatchSubscription(cur!, body), basePlans: cur!.basePlans?.map(({ state: _s, ...b }) => b) });
log(TAG, `${s.id}: listings updated (prices unchanged)`);
if (diff.listings.length || diff.grace || (prune && diff.listingsRemoved?.length)) {
// the text only (listings, grace period); the base plans as Play has them
const plans = cur!.basePlans?.map(({ state: _s, ...b }) => (diff.grace && b.basePlanId === basePlanId(s)
? { ...b, autoRenewingBasePlanType: { ...b.autoRenewingBasePlanType!, gracePeriodDuration: diff.grace.to } } : b));
await play.call('PATCH', `${play.API}/subscriptions/${id}?updateMask=listings${diff.grace ? ',basePlans' : ''}&regionsVersion.version=${v}`, { ...playPatchSubscription(cur!, body, { pruneLanguages }), basePlans: plans });
log(TAG, `${s.id}: ${playSubTextChanges(diff, prune).join(' · ')} updated (prices unchanged)`);
}
} else {
await play.call('PATCH', `${play.API}/subscriptions/${id}?updateMask=listings,basePlans&regionsVersion.version=${v}`, playPatchSubscription(cur!, body));
await play.call('PATCH', `${play.API}/subscriptions/${id}?updateMask=listings,basePlans&regionsVersion.version=${v}`, playPatchSubscription(cur!, body, { pruneLanguages }));
log(TAG, `${s.id}: updated`);
}
}
@ -131,14 +167,9 @@ export async function run(ctx: Ctx): Promise<void> {
log(TAG, `${s.id}: base plan ${basePlanId(s)} activated`);
}
const regions = body.basePlans![0]!.regionalConfigs!.map(r => ({ regionCode: r.regionCode, price: r.price! }));
const offerUrl = `${play.API}/subscriptions/${id}/basePlans/${basePlanId(s)}/offers`;
const createOffer = async (o: { type: string; usd?: number }, extra?: NonNullable<typeof s.offers>[number]) => {
let offerRegions;
if (o.type === 'pay-up-front') {
const oc = await convert(o.usd!);
offerRegions = { regions: Object.values(oc.convertedRegionPrices).map(r => ({ regionCode: r.regionCode, price: r.price })), other: oc.convertedOtherRegionsPrice };
}
const ob = playOfferBody(pkg, s, regions, offerRegions, extra);
const offerUrl = `${play.API}/subscriptions/${id}/basePlans/${basePlanId(s)}/offers`;
const ob = await buildOffer(s, regions, o, extra);
// offer ids are unique per base plan: a 409 or an unclear answer finds the existing offer, never a second one
const r = await createOnce({ what: `${s.id} offer ${ob.offerId}`, tag: TAG, unique: true,
find: () => getOrNull<{ state?: string }>(play, `${offerUrl}/${encodeURIComponent(ob.offerId)}`),
@ -149,6 +180,12 @@ export async function run(ctx: Ctx): Promise<void> {
};
if (s.intro && offerMissing) await createOffer(s.intro);
for (const o of (s.offers ?? []).filter(x => extraMissing.includes(x.id))) await createOffer(o, o);
// offers that exist but differ: phases (text) are updated; their prices only behind the price gate
for (const d of offerDrift) {
if (d.prices.length && !liveOk) { held.push({ id: `${s.id} offer ${d.offerId}`, changes: d.prices }); continue; }
await play.call('PATCH', `${offerUrl}/${encodeURIComponent(d.offerId)}?updateMask=phases&regionsVersion.version=${v}`, d.body);
log(TAG, `${s.id}: offer ${d.offerId} updated (${[...d.phase, ...(d.prices.length ? [`${d.prices.length} price(s)`] : [])].join('; ')})`);
}
}
holdPriceChanges(held);
done(TAG, held.length ? 'subscriptions done, price changes left for the owner' : 'subscriptions done');

View file

@ -1251,3 +1251,17 @@ that fails without the fix (see CONTRIBUTING.md).
- **Do:** update storebound; the warning was false.
- **Guard:** `readProjectEntitlements` in `cli/apple/signing.ts` compares forward-slash paths on every OS
(`tests/entitlements-ref.test.ts`).
## 104. An existing store item differs from the config and the run said nothing
- **Symptom:** `apple iap --yes` updated only missing localizations of live consumables while the config's USD prices
(12.99 / 129.99 / 649.99) differed from the store (9.99 / 99.99 / 500.00); the dry run showed no price, and changed
names and descriptions were never sent. An audit found the same class in 24 places (subscriptions, offers, app
price and availability, events, custom pages, review details).
- **Cause:** existing items were checked for existence only, not compared field by field.
- **Do:** read the dry run: every difference of an existing item is a row (old -> new). Text changes are sent;
prices, dropped regions and removed territories need `--allow-price-changes` / `--allow-availability-changes`
(the owner's call); fields Apple cannot edit (family sharing on, offer-code and win-back campaigns, intro offers,
subscription period) are named, never changed.
- **Guard:** `iapLocalizationChanges` / `iapAttributeChanges` / `availabilityChange` (`cli/apple/iap.ts`), the
subscription helpers in `cli/lib/subscriptions.ts`, `planAppInfo` / `reviewDetailDiff`, `campaignDiff`,
`winBackDiff`, `eventAttrDiff`; tests `apple-iap-drift`, `subscriptions-drift`, `appinfo-drift`, `misc-drift`.

View file

@ -66,7 +66,10 @@ describe('store list lag after create', () => {
{ match: new RegExp(`^/v1/apps/6400000101/inAppPurchasesV2\\?filter\\[productId\\]=${GEMS}`), answer: page([]) },
{ match: /^\/v2\/inAppPurchases\/i-pro\/inAppPurchaseLocalizations/, answer: page([]) },
{ match: /^\/v2\/inAppPurchases\/i-pro\/iapPriceSchedule/, answer: notFound() },
{ match: /^\/v2\/inAppPurchases\/i-pro\/inAppPurchaseAvailability/, answer: { data: { type: 'inAppPurchaseAvailabilities', id: 'av' } } },
{ match: /^\/v2\/inAppPurchases\/i-pro\/inAppPurchaseAvailability/, answer: { data: { type: 'inAppPurchaseAvailabilities', id: 'av', attributes: { availableInNewTerritories: true } } } },
{ match: /^\/v1\/inAppPurchaseAvailabilities\/av\/availableTerritories/, answer: page([{ type: 'territories', id: 'USA' }, { type: 'territories', id: 'TUR' }]) },
// the product as the store has it: the same reference name as the config, so only localizations and prices change
{ match: /^\/v2\/inAppPurchases\/i-pro(\?|$)/, answer: { data: { type: 'inAppPurchases', id: 'i-pro', attributes: { productId: PRO, name: 'No Ads', familySharable: false, state: 'MISSING_METADATA' } } } },
]);
const r = await capture(() => iap(p.ctx(['--dry'])));
expect(r.out).toContain('dry run: apple iap: 2 change(s); nothing written');

View file

@ -0,0 +1,148 @@
// Existing subscription items that differ from the config are shown in the dry run and updated (text) or held behind the
// price gate (money). Pure helpers of cli/lib/subscriptions.ts with neutral data.
import { describe, expect, test } from 'bun:test';
import type { Subscription } from '../cli/lib/config';
import { toMoney } from '../cli/lib/money';
import {
appleAvailabilityDiff, appleGroupLocDiff, appleIntroDiff, appleIntroOffers, appleLocDiff, appleSubAttrDiff, appleSubscriptionBody,
type PlayOfferLike, playOfferBody, playOfferDiff, playPatchSubscription, type PlaySubscription, playSubDiff, playSubRegions, playSubscriptionBody, playSubTextChanges,
} from '../cli/lib/subscriptions';
const LANGS = [{ id: 'en', apple: 'en-US', play: 'en-US' }, { id: 'de', apple: 'de-DE', play: 'de-DE' }] as never;
const s: Subscription = {
id: 'com.example.app.pro_monthly', period: 'P1M', level: 1, usd: 4.99, gracePeriod: 'P7D', familySharable: false, reviewNote: 'Sign in with the demo account',
listings: { en: { title: 'Pro', description: 'All features', benefits: ['A'] }, de: { title: 'Pro', description: 'Alle Funktionen' } },
intro: { type: 'free', duration: 'P1W' },
};
const conv = { US: { regionCode: 'US', price: toMoney('USD', 4.99) }, TR: { regionCode: 'TR', price: toMoney('TRY', 99.99) } };
const other = { usdPrice: toMoney('USD', 4.99), eurPrice: toMoney('EUR', 4.99) };
const want = (): PlaySubscription => playSubscriptionBody('com.example.app', s, LANGS, playSubRegions(s, conv), other);
const live = (): PlaySubscription => { const c = JSON.parse(JSON.stringify(want())) as PlaySubscription; c.basePlans![0]!.state = 'ACTIVE'; return c; };
describe('apple subscription attributes', () => {
const ok = { name: 'Pro', subscriptionPeriod: 'ONE_MONTH', familySharable: false, reviewNote: 'Sign in with the demo account', groupLevel: 1 };
test('equal: nothing', () => {
expect(appleSubAttrDiff(s, ok)).toEqual({ patch: {}, text: [], report: [] });
expect(appleSubAttrDiff(s, {})).toEqual({ patch: {}, text: [], report: [] });
});
test('name, review note and level are patched; period and family sharing only reported', () => {
const d = appleSubAttrDiff(s, { ...ok, name: 'Old', reviewNote: null, groupLevel: 2, subscriptionPeriod: 'ONE_YEAR', familySharable: true });
expect(d.patch).toEqual({ name: 'Pro', reviewNote: 'Sign in with the demo account', groupLevel: 1 });
expect(d.report.length).toBe(2);
expect(d.report[0]).toContain('ONE_YEAR');
expect(d.report[1]).toContain('familySharable');
expect(Object.keys(appleSubscriptionBody('g', s).data.attributes)).toContain('reviewNote');
});
test('a config without referenceName compares with the first listing title', () => {
expect(appleSubAttrDiff(s, { name: 'Pro' }).patch).toEqual({});
expect(appleSubAttrDiff({ ...s, referenceName: 'Pro monthly' }, { name: 'Pro' }).patch).toEqual({ name: 'Pro monthly' });
});
});
describe('apple localizations', () => {
test('subscription text', () => {
expect(appleLocDiff({ title: 'Pro', description: 'All features' }, { name: 'Pro', description: 'All features' })).toBeNull();
expect(appleLocDiff({ title: 'Pro', description: 'All features' }, { name: 'Pro', description: 'Old' })).toEqual({ description: 'All features' });
expect(appleLocDiff({ title: 'Pro', description: 'x' }, {})).toBeNull();
});
test('group name and customAppName (only when configured)', () => {
expect(appleGroupLocDiff({ name: 'Pro' }, { name: 'Pro', customAppName: 'Anything' })).toBeNull();
expect(appleGroupLocDiff({ name: 'Pro', customAppName: 'Example' }, { name: 'Old', customAppName: null })).toEqual({ name: 'Pro', customAppName: 'Example' });
});
});
describe('apple availability', () => {
test('missing territories and new territories off', () => {
expect(appleAvailabilityDiff({ territories: ['USA', 'TUR'], availableInNewTerritories: true }, ['USA', 'TUR'])).toBeNull();
expect(appleAvailabilityDiff({ territories: ['USA'], availableInNewTerritories: true }, ['USA', 'TUR', 'DEU'])).toEqual({ missing: ['TUR', 'DEU'], newTerritories: false });
expect(appleAvailabilityDiff({ territories: ['USA'], availableInNewTerritories: false }, ['USA'])).toEqual({ missing: [], newTerritories: true });
});
});
describe('apple intro offers', () => {
const resp = (mode: string, duration: string, periods: number, price?: string) => ({
data: [{ id: 'i1', attributes: { offerMode: mode, duration, numberOfPeriods: periods },
relationships: { territory: { data: { id: 'USA' } }, ...(price ? { subscriptionPricePoint: { data: { id: 'pp1' } } } : {}) } }],
included: price ? [{ id: 'pp1', type: 'subscriptionPricePoints', attributes: { customerPrice: price } }] : [],
});
test('equal or no offer yet: nothing', () => {
expect(appleIntroDiff(s, appleIntroOffers(resp('FREE_TRIAL', 'ONE_WEEK', 1)))).toEqual([]);
expect(appleIntroDiff(s, [])).toEqual([]);
});
test('differences are reported as "intro differs"', () => {
const d = appleIntroDiff(s, appleIntroOffers(resp('PAY_UP_FRONT', 'ONE_MONTH', 1)));
expect(d.length).toBe(2);
expect(d.every(x => x.startsWith('intro differs: '))).toBe(true);
const paid: Subscription = { ...s, intro: { type: 'pay-as-you-go', periods: 3, usd: 1.99 } };
expect(appleIntroDiff(paid, appleIntroOffers(resp('PAY_AS_YOU_GO', 'ONE_MONTH', 3, '0.99')))).toEqual([expect.stringContaining('price USA 0.99')]);
expect(appleIntroDiff({ ...s, intro: undefined }, appleIntroOffers(resp('FREE_TRIAL', 'ONE_WEEK', 1)))[0]).toContain('config has none');
});
});
describe('play subscription diff', () => {
test('equal: the old shape, no extra fields', () => {
expect(playSubDiff(live(), want())).toEqual({ create: false, listings: [], basePlan: 'same', priceChanges: [], activate: false });
});
test('other regions USD / EUR price is a gated price change', () => {
const cur = live();
cur.basePlans![0]!.otherRegionsConfig = { usdPrice: toMoney('USD', 3.99), eurPrice: toMoney('EUR', 4.99) };
const d = playSubDiff(cur, want());
expect(d.basePlan).toBe('changed');
expect(d.priceChanges).toEqual([{ regionCode: 'other regions', from: 'USD 399 / EUR 499', to: 'USD 499 / EUR 499' }]);
});
test('a region sold on Play but not in the config is an owner-gated "not sold" row', () => {
const cur = live();
cur.basePlans![0]!.regionalConfigs!.push({ regionCode: 'JP', newSubscriberAvailability: true, price: toMoney('JPY', 750) });
const d = playSubDiff(cur, want());
expect(d.droppedRegions).toEqual(['JP']);
expect(d.priceChanges).toEqual([{ regionCode: 'JP', from: 'JPY 75000', to: 'not sold' }]);
expect(d.basePlan).toBe('changed');
});
test('grace period is its own line and needs no price gate', () => {
const cur = live();
cur.basePlans![0]!.autoRenewingBasePlanType!.gracePeriodDuration = 'P3D';
const d = playSubDiff(cur, want());
expect(d.grace).toEqual({ from: 'P3D', to: 'P7D' });
expect(d.priceChanges).toEqual([]);
expect(playSubTextChanges(d)).toEqual(['grace P3D -> P7D']);
// a config without a grace period leaves Play's alone
const noGrace = want();
delete noGrace.basePlans![0]!.autoRenewingBasePlanType!.gracePeriodDuration;
expect(playSubDiff(cur, noGrace).grace).toBeUndefined();
});
test('languages on Play but not in the config are reported and kept unless pruned', () => {
const cur = live();
cur.listings!.push({ languageCode: 'fr-FR', title: 'Pro', description: 'Tout' });
const d = playSubDiff(cur, want());
expect(d.listingsRemoved).toEqual(['fr-FR']);
expect(d.listings).toEqual([]);
expect(playSubTextChanges(d, true)).toEqual(['listings removed: fr-FR']);
expect(playSubTextChanges(d)[0]).toContain('kept on Play');
expect(playPatchSubscription(cur, want()).listings!.map(l => l.languageCode)).toContain('fr-FR');
expect(playPatchSubscription(cur, want(), { pruneLanguages: true }).listings!.map(l => l.languageCode)).not.toContain('fr-FR');
});
});
describe('play offers that exist', () => {
const regions = [{ regionCode: 'US', price: toMoney('USD', 4.99) }, { regionCode: 'TR', price: toMoney('TRY', 99.99) }];
const body = (sub: Subscription) => playOfferBody('com.example.app', sub, regions) as unknown as PlayOfferLike;
test('equal: nothing', () => {
expect(playOfferDiff(JSON.parse(JSON.stringify(body(s))), body(s))).toEqual({ phase: [], prices: [] });
});
test('duration and periods are phase (text) changes', () => {
const cur = body({ ...s, intro: { type: 'free', duration: 'P1M' } });
const d = playOfferDiff(cur, body(s));
expect(d.phase).toEqual(['duration P1M -> P1W']);
expect(d.prices).toEqual([]);
});
test('free to discounted is a phase change, a new discount percentage is money', () => {
const payg: Subscription = { ...s, intro: { type: 'pay-as-you-go', periods: 2, usd: 2.49 } };
const cur = body(s);
const d = playOfferDiff(cur, body(payg));
expect(d.phase.some(x => x.includes('free -> -50%'))).toBe(true);
expect(d.phase.some(x => x.startsWith('periods 1 -> 2'))).toBe(true);
const cheaper = playOfferDiff(body(payg), body({ ...payg, intro: { type: 'pay-as-you-go', periods: 2, usd: 1.99 } }));
expect(cheaper.phase).toEqual([]);
expect(cheaper.prices.map(p => `${p.region} ${p.from} -> ${p.to}`)).toContain('US -50% -> -60%');
});
});