Subscriptions drift on both stores (Apple prices in the dry run, localizations, group names, attributes, territories, intro offers reported; Play offer phases and prices, other-regions price, dropped regions gated, grace period, --prune-languages); pitfall 104, CHANGELOG; test fixtures for the new reads
This commit is contained in:
parent
e381bc2256
commit
abc195aee6
8 changed files with 558 additions and 59 deletions
14
CHANGELOG.md
14
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
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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 ----
|
||||
|
|
|
|||
|
|
@ -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}®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<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®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');
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
148
tests/subscriptions-drift.test.ts
Normal file
148
tests/subscriptions-drift.test.ts
Normal 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%');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue