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
This commit is contained in:
parent
4b0f9cd5b8
commit
f7ff52dfdd
2 changed files with 144 additions and 6 deletions
|
|
@ -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<void> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const bundleId = need(config.apple?.bundleId, 'apple.bundleId');
|
||||
|
|
@ -50,6 +94,15 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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<void> {
|
||||
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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
}
|
||||
|
||||
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<void> {
|
|||
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' };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
64
tests/apple-iap-drift.test.ts
Normal file
64
tests/apple-iap-drift.test.ts
Normal file
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue