storebound/cli/apple/iap.ts

242 lines
18 KiB
TypeScript

// In-app purchases on App Store Connect: create missing products, localizations, price schedule (USA base + your
// overrides; Apple equalizes every other storefront) and availability (every territory + new ones). Idempotent:
// existing products are left alone; a price that differs from the config (USA base and overrides) is shown in the plan
// and changed only with --allow-price-changes (live prices are the owner's call; Acme 2026-10-22: the plan was silent).
// storebound apple iap [--dry] [--only id1,id2] [--allow-price-changes]
// Then `apple iap-review` for the review screenshots. First IAPs can only be submitted together with an app version,
// from the IAP page in the web UI (see the store-iap-setup skill).
// --dry reads everything (read-only) and prints one plan row per product. After a real run the store's product list
// is read again: a product created a moment ago can be missing from it for a minute, and `apple iap-review` would
// then skip it, so that gap is named (docs/pitfalls.md).
import { sellsOn } from '../lib/config';
import { APPLE_IAP_LIMITS } from '../lib/copy';
import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
import { dryPlan, type PlanRow } from '../lib/dry';
import { isLockedLocState, isUnmodifiableError, lockedLocNote } from '../lib/loc-lock';
import { done, log, step, warn } from '../lib/log';
import { LAG_HINT, reportMissing } from '../lib/store-lag';
import { appleTerritory, nearestPrices, samePrice } from '../lib/money';
import { createOnce, findOrCreate, getById } from '../lib/upsert';
import { ascClient, type J } from './asc';
import * as rt from '../lib/rt';
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; state?: string };
/** `locked`: the localization is live (APPROVED / ACTIVE) and Apple refuses the edit; the note says so. */
export type IapLocalizationChange = { locale: string; id: string; attrs: { name?: string; description?: string }; note: string; locked?: 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) continue;
const locked = isLockedLocState(s.state) ? s.state : undefined;
out.push({ locale: w.locale, id: s.id, attrs, note: `localization ${w.locale}: ${locked ? `${lockedLocNote(locked)}: ` : ''}${parts.join(', ')}`, ...(locked ? { locked } : {}) });
}
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');
const only = ctx.args.list('only');
const products = need(config.products, 'products').filter(p => (!only.length || only.includes(p.id)) && sellsOn(config, p, 'apple'));
const dry = ctx.args.has('dry');
const c = await ascClient(root, bundleId, config.apple?.appId);
const appId = await c.app();
const langs = new Map(config.languages.map(l => [l.id, l]));
const territories: string[] = (await c.all('/v1/territories?limit=200')).map(t => t.id);
async function pricePoint(iapId: string, territory: string, price: number): Promise<string> {
const seen: number[] = [];
for (const p of await c.all(`/v2/inAppPurchases/${iapId}/pricePoints?filter[territory]=${territory}&limit=200`)) {
if (samePrice(p.attributes.customerPrice, price)) return p.id;
seen.push(Number(p.attributes.customerPrice));
}
throw new Error(`${territory} has no ${price} price point (nearest ${nearestPrices(seen, price).join(', ')})`);
}
const wanted = (p: (typeof products)[number]) => [{ territory: 'USA', price: p.usd }, ...(p.overrides ?? []).map(o => ({ territory: appleTerritory(o), price: o.price }))];
/** "USA 9.99 -> 12.99" per territory whose live (current, not scheduled) manual price differs from the config. */
async function priceDiffs(id: string, p: (typeof products)[number]): Promise<string[]> {
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, state: x.attributes.state as string | undefined });
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', {
data: { type: 'inAppPurchasePriceSchedules', relationships: {
inAppPurchase: { data: { type: 'inAppPurchases', id } },
baseTerritory: { data: { type: 'territories', id: 'USA' } },
manualPrices: { data: manual.map(m => ({ type: 'inAppPurchasePrices', id: '${' + m.territory + '}' })) } } },
included: manual.map((m, k) => ({ type: 'inAppPurchasePrices', id: '${' + m.territory + '}', attributes: { startDate: null },
relationships: { inAppPurchasePricePoint: { data: { type: 'inAppPurchasePricePoints', id: points[k] } } } })),
});
}
const allowPrices = ctx.args.has('allow-price-changes');
const rows: PlanRow[] = [];
const created: string[] = [];
const results: { id: string; ok: boolean; text: string }[] = [];
let skippedLocs = 0;
for (const [i, p] of products.entries()) {
const tag = step(i + 1, products.length, p.id);
const lookup = async (): Promise<J | null> => (await c.asc('GET', `/v1/apps/${appId}/inAppPurchasesV2?filter[productId]=${encodeURIComponent(p.id)}`)).data
?.find((x: J) => x.attributes?.productId === p.id) ?? null;
if (dry) { const f = await lookup(); rows.push(await planProduct(p, f?.id, f?.attributes?.state)); continue; }
try {
// product ids are unique per app: a 409 finds the existing one; the journal covers the list lag after a create
const first = Object.values(p.listings)[0]!;
const made = await findOrCreate<J>({ what: `in-app purchase ${p.id}`, tag: 'apple', unique: true, find: lookup,
journal: { root, key: `apple:${bundleId}:iap:${p.id}`, byId: id => getById(c.asc, `/v2/inAppPurchases/${id}`) as Promise<J | null>, idOf: v => v.id },
create: async () => (await c.asc('POST', '/v2/inAppPurchases', { data: { type: 'inAppPurchases',
attributes: { name: p.referenceName ?? first.title, productId: p.id, inAppPurchaseType: APPLE_IAP_TYPE[p.type],
familySharable: p.familySharable ?? false, ...(p.reviewNote ? { reviewNote: p.reviewNote } : {}) },
relationships: { app: { data: { type: 'apps', id: appId } } } } })).data });
const id: string = made.value.id;
if (made.created) created.push(p.id);
log('apple', `${tag}: ${made.created ? 'created' : 'exists'}`);
const locsOf = async () => await c.all(`/v2/inAppPurchases/${id}/inAppPurchaseLocalizations`);
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;
if ([...l.description].length > APPLE_IAP_LIMITS.description) throw new Error(`${p.id} ${locale}: description over ${APPLE_IAP_LIMITS.description} characters (Apple limit)`);
await createOnce({ what: `${p.id} localization ${locale}`, tag: 'apple', unique: true,
find: async () => (await locsOf()).find((x: J) => x.attributes.locale === locale) ?? null,
create: async () => (await c.asc('POST', '/v1/inAppPurchaseLocalizations', { data: { type: 'inAppPurchaseLocalizations', attributes: { locale, name: l.title, description: l.description },
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))) {
if (ch.locked) { warn('apple', ` ${ch.note}; skipped`); skippedLocs++; continue; }
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)`);
try { await c.asc('PATCH', `/v1/inAppPurchaseLocalizations/${ch.id}`, { data: { type: 'inAppPurchaseLocalizations', id: ch.id, attributes: ch.attrs } }); } catch (e) {
if (!isUnmodifiableError(e)) throw e;
warn('apple', ` localization ${ch.locale}: ${lockedLocNote('ACTIVE')}; skipped`); skippedLocs++; continue;
}
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) {
const manual = wanted(p);
await writeSchedule(id, manual);
log('apple', ` prices ${manual.map(m => `${m.territory} ${m.price}`).join(', ')}`);
} else {
const diff = await priceDiffs(id, p);
if (diff.length && allowPrices) { await writeSchedule(id, wanted(p)); log('apple', ` prices changed: ${diff.join(', ')} (takes effect today; Apple equalizes the other storefronts)`); }
else if (diff.length) log('apple', ` prices differ from the config, NOT changed: ${diff.join(', ')} (live prices: re-run with --allow-price-changes after the owner's yes)`);
}
const avail = await c.maybe(`/v2/inAppPurchases/${id}/inAppPurchaseAvailability`);
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', ` ${avail?.data ? availDiff.note : `available in ${territories.length} territories + new ones`}`);
}
results.push({ id: p.id, ok: true, text: 'ok' });
} catch (e) {
const msg = (e as Error).message.replace(/\s+/g, ' ').slice(0, 300);
warn('apple', `${tag}: FAILED: ${msg}; going on with the next product`);
results.push({ id: p.id, ok: false, text: msg });
}
}
if (dry) {
console.log(dryPlan('apple iap', rows));
const absent = rows.filter(r => r.action === 'create').map(r => r.item);
if (absent.length) log('apple', `not on App Store Connect: ${absent.join(', ')}. If you created them in the last minutes, ${LAG_HINT} before acting on this plan`);
done('apple', 'iap checked (dry run)');
return;
}
if (created.length) {
await rt.sleep(Number(process.env.STOREBOUND_LIST_SETTLE_MS ?? 3000));
const listed = (await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)).map((x: J) => x.attributes.productId as string);
if (reportMissing('apple', 'new products', created, listed, { fail: false }).length) log('apple', 'wait a minute before apple iap-review, or it cannot see them');
}
const failed = results.filter(r => !r.ok);
if (skippedLocs) warn('apple', `${skippedLocs} localization edit(s) skipped: the localization is live (APPROVED / ACTIVE). The text changes with the next review of the product, or add a new localization.`);
if (failed.length) {
process.exitCode = 1;
warn('apple', `${failed.length} of ${results.length} product(s) failed (${failed.map(f => f.id).join(', ')}): ${failed.map(f => `${f.id}: ${f.text}`).join(' | ')}. The others are written. Fix the cause, run \`storebound apple iap --dry\`, then again with --yes.`);
return;
}
done('apple', 'iap done (next: apple iap-review for the review screenshots)');
/** What a real run would do to one product, from read-only calls. */
async function planProduct(p: (typeof products)[number], id: string | undefined, state: string | undefined): Promise<PlanRow> {
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 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 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, ...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' };
}
}