144 lines
11 KiB
TypeScript
144 lines
11 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 { done, log, step } 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;
|
|
|
|
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}`] : []; });
|
|
}
|
|
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[] = [];
|
|
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; }
|
|
// 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 have = new Set((await locsOf()).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}`);
|
|
}
|
|
|
|
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`);
|
|
if (!avail?.data) {
|
|
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`);
|
|
}
|
|
}
|
|
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');
|
|
}
|
|
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 have = new Set((await c.all(`/v2/inAppPurchases/${id}/inAppPurchaseLocalizations`)).map((l: J) => l.attributes.locale));
|
|
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);
|
|
return parts.length ? { item: p.id, action: 'update', detail: `${parts.join('; ')} (state ${state ?? '?'})` } : { item: p.id, action: 'keep' };
|
|
}
|
|
}
|