From f7ff52dfddc0e47f9fe0a3c10f047a4b45af3fbb Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 20:32:52 +0800 Subject: [PATCH] apple iap: existing products are compared with the config: localization name / description, reference name, review note (patched), family sharing (reported; on only), availability territories; all shown in the dry plan, one line each --- cli/apple/iap.ts | 86 ++++++++++++++++++++++++++++++++--- tests/apple-iap-drift.test.ts | 64 ++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 tests/apple-iap-drift.test.ts diff --git a/cli/apple/iap.ts b/cli/apple/iap.ts index 0536a44..39cc4f7 100644 --- a/cli/apple/iap.ts +++ b/cli/apple/iap.ts @@ -24,6 +24,50 @@ import { appleCurrent } from '../prices'; /** Product type -> InAppPurchaseType (POST /v2/inAppPurchases). */ export const APPLE_IAP_TYPE = { consumable: 'CONSUMABLE', non_consumable: 'NON_CONSUMABLE', non_renewing: 'NON_RENEWING_SUBSCRIPTION' } as const; +// one plan line per change: store texts carry line breaks, which broke the dry-run table +const clip = (s: string, n = 40): string => { const a = [...s.replace(/\s+/g, ' ').trim()]; return a.length > n ? `${a.slice(0, n - 1).join('')}…` : a.join(''); }; + +export type IapLocalization = { locale: string; id?: string; name: string; description: string }; +export type IapLocalizationChange = { locale: string; id: string; attrs: { name?: string; description?: string }; note: string }; +/** Existing locales whose name/description differ from the config: the attributes to PATCH and a short plan note. */ +export function iapLocalizationChanges(store: IapLocalization[], wanted: IapLocalization[]): IapLocalizationChange[] { + const out: IapLocalizationChange[] = []; + for (const w of wanted) { + const s = store.find(x => x.locale === w.locale); + if (!s?.id) continue; + const attrs: { name?: string; description?: string } = {}; + const parts: string[] = []; + if ((s.name ?? '') !== w.name) { attrs.name = w.name; parts.push(`name "${clip(s.name ?? '')}" -> "${clip(w.name)}"`); } + if ((s.description ?? '') !== w.description) { attrs.description = w.description; parts.push(`description "${clip(s.description ?? '')}" -> "${clip(w.description)}"`); } + if (parts.length) out.push({ locale: w.locale, id: s.id, attrs, note: `localization ${w.locale}: ${parts.join(', ')}` }); + } + return out; +} + +export type IapAttributes = { name?: string; reviewNote?: string; familySharable?: boolean }; +export type IapAttributeChanges = { patch: { name?: string; reviewNote?: string; familySharable?: true }; notes: string[]; familyMismatch: string | null }; +/** Product attributes: referenceName (`name`), reviewNote (only when the config sets one), familySharable (on only; Apple cannot turn it off). */ +export function iapAttributeChanges(store: IapAttributes, wanted: IapAttributes): IapAttributeChanges { + const patch: IapAttributeChanges['patch'] = {}; + const notes: string[] = []; + let familyMismatch: string | null = null; + if (wanted.name !== undefined && (store.name ?? '') !== wanted.name) { patch.name = wanted.name; notes.push(`referenceName "${clip(store.name ?? '')}" -> "${clip(wanted.name)}"`); } + if (wanted.reviewNote !== undefined && (store.reviewNote ?? '') !== wanted.reviewNote) { patch.reviewNote = wanted.reviewNote; notes.push(`reviewNote "${clip(store.reviewNote ?? '')}" -> "${clip(wanted.reviewNote)}"`); } + const wantFam = wanted.familySharable ?? false, haveFam = store.familySharable ?? false; + if (wantFam && !haveFam) { patch.familySharable = true; notes.push('familySharable false -> true (Apple cannot turn it off again)'); } + else if (!wantFam && haveFam) familyMismatch = 'familySharable is on in the store but off in the config (Apple cannot turn it off; not changed)'; + return { patch, notes, familyMismatch }; +} + +/** Availability: null when the store covers every wanted territory and new ones; else "N -> M" (a POST replaces it). */ +export function availabilityChange(store: { territories: string[]; newTerritories: boolean } | null, territories: string[]): { from: number; to: number; note: string } | null { + const have = new Set(store?.territories ?? []); + const missing = territories.filter(t => !have.has(t)); + if (store && !missing.length && store.newTerritories) return null; + const from = store ? have.size : 0; + return { from, to: territories.length, note: `availability: ${from} -> ${territories.length} territories${store && !store.newTerritories ? ' (+ new territories on)' : ''}` }; +} + export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const bundleId = need(config.apple?.bundleId, 'apple.bundleId'); @@ -50,6 +94,15 @@ export async function run(ctx: Ctx): Promise { const live = (await appleCurrent(c, id, p.id, false)).rows.filter(x => !x.startDate || x.startDate <= new Date().toISOString().slice(0, 10)); return wanted(p).flatMap(w => { const cur = live.find(x => x.territory === w.territory); return cur && !samePrice(cur.price, w.price) ? [`${w.territory} ${cur.price} -> ${w.price}`] : []; }); } + const locOf = (x: J) => ({ locale: x.attributes.locale as string, name: (x.attributes.name ?? '') as string, description: (x.attributes.description ?? '') as string }); + const wantedLocs = (p: (typeof products)[number]) => Object.entries(p.listings).flatMap(([k, l]) => { const locale = langs.get(k)?.apple; return locale ? [{ locale, name: l.title, description: l.description }] : []; }); + const wantedAttrs = (p: (typeof products)[number]): IapAttributes => ({ name: p.referenceName ?? Object.values(p.listings)[0]!.title, reviewNote: p.reviewNote, familySharable: p.familySharable }); + /** The store's availability as territory ids + the new-territories flag (null when there is none). */ + async function readAvail(avail: J | null | undefined): Promise<{ territories: string[]; newTerritories: boolean } | null> { + if (!avail?.data) return null; + const ts = (await c.all(`/v1/inAppPurchaseAvailabilities/${avail.data.id}/availableTerritories?limit=200`)).map((t: J) => t.id as string); + return { territories: ts, newTerritories: avail.data.attributes?.availableInNewTerritories === true }; + } async function writeSchedule(id: string, manual: { territory: string; price: number }[]): Promise { const points = await Promise.all(manual.map(m => pricePoint(id, m.territory, m.price))); await c.asc('POST', '/v1/inAppPurchasePriceSchedules', { @@ -82,7 +135,8 @@ export async function run(ctx: Ctx): Promise { log('apple', `${tag}: ${made.created ? 'created' : 'exists'}`); const locsOf = async () => await c.all(`/v2/inAppPurchases/${id}/inAppPurchaseLocalizations`); - const have = new Set((await locsOf()).map((l: J) => l.attributes.locale)); + const storeLocs = await locsOf(); + const have = new Set(storeLocs.map((l: J) => l.attributes.locale)); for (const [langId, l] of Object.entries(p.listings)) { const locale = langs.get(langId)?.apple; if (!locale || have.has(locale)) continue; @@ -93,6 +147,22 @@ export async function run(ctx: Ctx): Promise { relationships: { inAppPurchaseV2: { data: { type: 'inAppPurchases', id } } } } })).data }); log('apple', ` localization ${locale}`); } + for (const ch of iapLocalizationChanges(storeLocs.map((x: J) => ({ id: x.id, ...locOf(x) })), wantedLocs(p))) { + const l = Object.entries(p.listings).find(([k]) => langs.get(k)?.apple === ch.locale)![1]; + if ([...l.description].length > APPLE_IAP_LIMITS.description) throw new Error(`${p.id} ${ch.locale}: description over ${APPLE_IAP_LIMITS.description} characters (Apple limit)`); + if ([...l.title].length > APPLE_IAP_LIMITS.name) throw new Error(`${p.id} ${ch.locale}: name over ${APPLE_IAP_LIMITS.name} characters (Apple limit)`); + await c.asc('PATCH', `/v1/inAppPurchaseLocalizations/${ch.id}`, { data: { type: 'inAppPurchaseLocalizations', id: ch.id, attributes: ch.attrs } }); + log('apple', ` ${ch.note}`); + } + if (!made.created) { + const cur = (await c.asc('GET', `/v2/inAppPurchases/${id}`)).data?.attributes ?? {}; + const ac = iapAttributeChanges(cur, wantedAttrs(p)); + if (Object.keys(ac.patch).length) { + await c.asc('PATCH', `/v2/inAppPurchases/${id}`, { data: { type: 'inAppPurchases', id, attributes: ac.patch } }); + log('apple', ` product updated: ${ac.notes.join(', ')}`); + } + if (ac.familyMismatch) log('apple', ` ${ac.familyMismatch}`); + } const sched = await c.maybe(`/v2/inAppPurchases/${id}/iapPriceSchedule?include=manualPrices`); if (!sched?.included?.length) { @@ -106,11 +176,12 @@ export async function run(ctx: Ctx): Promise { } const avail = await c.maybe(`/v2/inAppPurchases/${id}/inAppPurchaseAvailability`); - if (!avail?.data) { + const availDiff = availabilityChange(await readAvail(avail), territories); + if (availDiff) { await c.asc('POST', '/v1/inAppPurchaseAvailabilities', { data: { type: 'inAppPurchaseAvailabilities', attributes: { availableInNewTerritories: true }, relationships: { inAppPurchase: { data: { type: 'inAppPurchases', id } }, availableTerritories: { data: territories.map(t => ({ type: 'territories', id: t })) } } } }); - log('apple', ` available in ${territories.length} territories + new ones`); + log('apple', ` ${avail?.data ? availDiff.note : `available in ${territories.length} territories + new ones`}`); } } if (dry) { @@ -132,13 +203,16 @@ export async function run(ctx: Ctx): Promise { const locales = Object.keys(p.listings).map(l => langs.get(l)?.apple).filter((l): l is string => !!l); const prices = [`USA ${p.usd}`, ...(p.overrides ?? []).map(o => `${appleTerritory(o)} ${o.price}`)].join(', '); if (!id) return { item: p.id, action: 'create', detail: `${APPLE_IAP_TYPE[p.type]}; localizations ${locales.join(', ')}; prices ${prices}; ${territories.length} territories + new ones` }; - const have = new Set((await c.all(`/v2/inAppPurchases/${id}/inAppPurchaseLocalizations`)).map((l: J) => l.attributes.locale)); + const storeLocs = await c.all(`/v2/inAppPurchases/${id}/inAppPurchaseLocalizations`); + const have = new Set(storeLocs.map((l: J) => l.attributes.locale)); + const locChanges = iapLocalizationChanges(storeLocs.map((x: J) => ({ id: x.id, ...locOf(x) })), wantedLocs(p)); + const ac = iapAttributeChanges((await c.asc('GET', `/v2/inAppPurchases/${id}`)).data?.attributes ?? {}, wantedAttrs(p)); + const availDiff = availabilityChange(await readAvail(await c.maybe(`/v2/inAppPurchases/${id}/inAppPurchaseAvailability`)), territories); const add = locales.filter(l => !have.has(l)); const sched = await c.maybe(`/v2/inAppPurchases/${id}/iapPriceSchedule?include=manualPrices`); - const avail = await c.maybe(`/v2/inAppPurchases/${id}/inAppPurchaseAvailability`); const diff = sched?.included?.length ? await priceDiffs(id, p) : []; const priceNote = !sched?.included?.length ? `prices ${prices}` : diff.length ? `PRICE CHANGE ${diff.join(', ')}${allowPrices ? '' : ' (only with --allow-price-changes)'}` : ''; - const parts = [add.length ? `localizations ${add.join(', ')}` : '', priceNote, avail?.data ? '' : `availability ${territories.length} territories + new ones`].filter(Boolean); + const parts = [add.length ? `localizations ${add.join(', ')}` : '', priceNote, ...locChanges.map(x => x.note), ...ac.notes, ac.familyMismatch ? `MISMATCH ${ac.familyMismatch}` : '', availDiff?.note ?? ''].filter(Boolean); return parts.length ? { item: p.id, action: 'update', detail: `${parts.join('; ')} (state ${state ?? '?'})` } : { item: p.id, action: 'keep' }; } } diff --git a/tests/apple-iap-drift.test.ts b/tests/apple-iap-drift.test.ts new file mode 100644 index 0000000..cd10d4b --- /dev/null +++ b/tests/apple-iap-drift.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'bun:test'; +import { availabilityChange, iapAttributeChanges, iapLocalizationChanges } from '../cli/apple/iap'; + +describe('iapLocalizationChanges', () => { + const store = [{ id: 'l1', locale: 'en-US', name: 'Coins', description: 'A pile of coins' }, { id: 'l2', locale: 'de-DE', name: 'Muenzen', description: 'Viele' }]; + test('no difference means no change', () => { + expect(iapLocalizationChanges(store, [{ locale: 'en-US', name: 'Coins', description: 'A pile of coins' }])).toEqual([]); + }); + test('only the changed attribute is patched', () => { + const r = iapLocalizationChanges(store, [{ locale: 'en-US', name: 'Coins', description: 'More coins' }]); + expect(r).toHaveLength(1); + expect(r[0]!.id).toBe('l1'); + expect(r[0]!.attrs).toEqual({ description: 'More coins' }); + expect(r[0]!.note).toBe('localization en-US: description "A pile of coins" -> "More coins"'); + }); + test('name and description, long text truncated, missing locales skipped', () => { + const long = 'x'.repeat(100); + const r = iapLocalizationChanges(store, [{ locale: 'de-DE', name: 'Gold', description: long }, { locale: 'fr-FR', name: 'a', description: 'b' }]); + expect(r).toHaveLength(1); + expect(r[0]!.attrs).toEqual({ name: 'Gold', description: long }); + expect(r[0]!.note).toContain('name "Muenzen" -> "Gold"'); + expect(r[0]!.note.length).toBeLessThan(120); + }); +}); + +describe('iapAttributeChanges', () => { + test('equal attributes: nothing', () => { + const r = iapAttributeChanges({ name: 'com.example.coins', reviewNote: 'n', familySharable: false }, { name: 'com.example.coins', reviewNote: 'n', familySharable: false }); + expect(r.patch).toEqual({}); + expect(r.notes).toEqual([]); + expect(r.familyMismatch).toBeNull(); + }); + test('referenceName and reviewNote differ', () => { + const r = iapAttributeChanges({ name: 'Old', reviewNote: 'a' }, { name: 'New', reviewNote: 'b' }); + expect(r.patch).toEqual({ name: 'New', reviewNote: 'b' }); + expect(r.notes).toHaveLength(2); + }); + test('reviewNote not set in the config is not compared', () => { + expect(iapAttributeChanges({ name: 'A', reviewNote: 'kept' }, { name: 'A' }).patch).toEqual({}); + }); + test('familySharable can be turned on, never off', () => { + expect(iapAttributeChanges({ name: 'A', familySharable: false }, { name: 'A', familySharable: true }).patch).toEqual({ familySharable: true }); + const off = iapAttributeChanges({ name: 'A', familySharable: true }, { name: 'A', familySharable: false }); + expect(off.patch).toEqual({}); + expect(off.familyMismatch).toContain('familySharable'); + }); +}); + +describe('availabilityChange', () => { + const all = ['USA', 'DEU', 'FRA']; + test('complete availability with new territories: no change', () => { + expect(availabilityChange({ territories: ['USA', 'DEU', 'FRA', 'ITA'], newTerritories: true }, all)).toBeNull(); + }); + test('fewer territories', () => { + const r = availabilityChange({ territories: ['USA'], newTerritories: true }, all); + expect(r?.note).toBe('availability: 1 -> 3 territories'); + }); + test('new territories off', () => { + expect(availabilityChange({ territories: all, newTerritories: false }, all)?.note).toContain('new territories on'); + }); + test('no availability at all', () => { + expect(availabilityChange(null, all)).toMatchObject({ from: 0, to: 3 }); + }); +});