From abc195aee60c43eef402833e463437339065b358 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 20:49:43 +0800 Subject: [PATCH] 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 --- CHANGELOG.md | 14 +++ cli/apple/subscriptions.ts | 122 +++++++++++++++++-- cli/data/flags.json | 28 +++-- cli/lib/subscriptions.ts | 189 ++++++++++++++++++++++++++++-- cli/play/subscriptions.ts | 97 ++++++++++----- skills/store-pitfalls/SKILL.md | 14 +++ tests/pitfalls.test.ts | 5 +- tests/subscriptions-drift.test.ts | 148 +++++++++++++++++++++++ 8 files changed, 558 insertions(+), 59 deletions(-) create mode 100644 tests/subscriptions-drift.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d222a34..61b6a28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cli/apple/subscriptions.ts b/cli/apple/subscriptions.ts index b9250da..d892670 100644 --- a/cli/apple/subscriptions.ts +++ b/cli/apple/subscriptions.ts @@ -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 { 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 }[]; + 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, territories: string[]): Promise { + 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): Promise<{ id: string; loc: string; patch: Record }[]> { + const have = await c.all(`/v1/subscriptionGroups/${groupId}/subscriptionGroupLocalizations?limit=200`); + const out: { id: string; loc: string; patch: Record }[] = []; + 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 { const { config, root, resolve } = ctx.loaded; const only = ctx.args.list('only'); @@ -83,7 +153,29 @@ export async function run(ctx: Ctx): Promise { 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 { 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 { 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)'); diff --git a/cli/data/flags.json b/cli/data/flags.json index fe63096..f581df0 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -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 [--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 [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--dry] [--yes] [--json]" ], - "apple review-details": [ - "storebound apple review-details [--contact-from ] [--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)" diff --git a/cli/lib/subscriptions.ts b/cli/lib/subscriptions.ts index 70ac790..08ffdec 100644 --- a/cli/lib/subscriptions.ts +++ b/cli/lib/subscriptions.ts @@ -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; 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) => ({ 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 | null { + const patch: Record = {}; + 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) => ({ 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 | null { + const patch: Record = {}; + 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) => ({ 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 = (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[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[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 | 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 ---- diff --git a/cli/play/subscriptions.ts b/cli/play/subscriptions.ts index 65f7d85..ab037b0 100644 --- a/cli/play/subscriptions.ts +++ b/cli/play/subscriptions.ts @@ -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 { return { convertedRegionPrices: conv.convertedRegionPrices, convertedOtherRegionsPrice: conv.convertedOtherRegionsPrice, regionVersion: conv.regionVersion ?? { version: '2022/02' } }; }; - const work: { s: typeof subs[number]; body: PlaySubscription; diff: ReturnType; 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[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 } + const work: { s: typeof subs[number]; body: PlaySubscription; diff: ReturnType; 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 { 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[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 { create: () => play.call('POST', `${play.API}/subscriptions?productId=${id}®ionsVersion.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®ionsVersion.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' : ''}®ionsVersion.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®ionsVersion.version=${v}`, playPatchSubscription(cur!, body)); + await play.call('PATCH', `${play.API}/subscriptions/${id}?updateMask=listings,basePlans®ionsVersion.version=${v}`, playPatchSubscription(cur!, body, { pruneLanguages })); log(TAG, `${s.id}: updated`); } } @@ -131,14 +167,9 @@ export async function run(ctx: Ctx): Promise { 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[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 { }; 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®ionsVersion.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'); diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 6f89dc2..c44253a 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -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`. diff --git a/tests/pitfalls.test.ts b/tests/pitfalls.test.ts index 1d2ba3e..a75c6c6 100644 --- a/tests/pitfalls.test.ts +++ b/tests/pitfalls.test.ts @@ -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'); diff --git a/tests/subscriptions-drift.test.ts b/tests/subscriptions-drift.test.ts new file mode 100644 index 0000000..0145cb2 --- /dev/null +++ b/tests/subscriptions-drift.test.ts @@ -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%'); + }); +});