storebound/cli/apple/subscriptions.ts

240 lines
19 KiB
TypeScript

// Auto-renewable subscriptions on App Store Connect from `subscriptions` in the config: groups and their display names,
// subscriptions (period, level, family sharing, review note), localizations, availability (every territory + new
// ones), prices (USA price point + its equalizations, your overrides, optional CSV rows from the prices sheet incl.
// future-dated changes), introductory offers per territory and the review screenshot. Idempotent: what exists is
// left alone, prices are compared per territory and only differences are sent.
// storebound apple subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--codes] [--yes] [--allow-price-changes]
// Also promotional offers (`offers`, signed by your server at purchase time) and offer code campaigns
// (`offerCodes`). Custom codes of a campaign (redeemable by anyone who has the code) are only created with --codes
// from a terminal that confirms; one-time-use codes are generated by the owner in App Store Connect.
// Prints the plan first. In a terminal (no --yes) it asks before writing anything, price changes included. With --yes
// or without a terminal it still creates what is new, first prices included (nothing is public before App Review), but
// never changes prices of a subscription that already has them: that is the owner's call and needs
// --allow-price-changes (flows never add it). The run then prints "price change on <id> (<from> -> <to>) needs the
// owner" and exits 3 (declined), after everything else is written. preserveCurrentPrice is always true
// (existing subscribers keep their price; raising theirs is a separate step in App Store Connect).
// First subscriptions are submitted with an app version, like first in-app purchases (store-subscriptions skill).
// Pure parts and sources: lib/subscriptions.ts.
import { existsSync, readFileSync } from 'node:fs';
import { type OfferCodeDef, sellsOn, type Subscription, type SubscriptionGroup } from '../lib/config';
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 { parsePriceCsv, type PriceRow } from '../lib/prices';
import {
allSubscriptions, appleAvailabilityBody, appleCustomCodeBody, appleOfferCodeBody, applePromoOfferBody, appleGroupBody, appleGroupLocBody, appleIntroPatch, applePoints, applePriceChanges,
applePricesPatch, appleSubLocBody, appleSubPrices, appleSubscriptionBody, appleSubscriptionPlan, type AppleSubState, appleWantedPrices,
type ApplePoint, pickPoint,
} from '../lib/subscriptions';
import { pages } from '../prices';
import { createOnce, findOrCreate, getById } from '../lib/upsert';
import { type Asc, ascClient, md5, type J } from './asc';
import { stdinIsTTY } from '../lib/tty';
const TAG = 'apple';
/** CSV rows (prices sheet) of the configured subscriptions, when --csv is given. */
export function subscriptionCsv(ctx: Ctx, store: 'apple' | 'play'): PriceRow[] {
const path = ctx.args.get('csv');
if (!path) return [];
const file = ctx.loaded.resolve(path);
if (!existsSync(file)) throw new Error(`no prices CSV at ${file}`);
const { config } = ctx.loaded;
const subIds = allSubscriptions(config.subscriptions).map(x => x.sub.id);
const ids = [...(config.products ?? []).map(p => p.id), ...subIds];
return parsePriceCsv(readFileSync(file, 'utf8'), { products: ids }).filter(r => subIds.includes(r.product) && r.store === store);
}
async function readState(c: Asc, appId: string): Promise<AppleSubState> {
const state: AppleSubState = { groups: [], subscriptions: [] };
for (const g of await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`)) {
const locales = (await c.all(`/v1/subscriptionGroups/${g.id}/subscriptionGroupLocalizations?limit=200`)).map((l: J) => l.attributes.locale);
state.groups.push({ id: g.id, referenceName: g.attributes.referenceName, locales });
for (const s of await c.all(`/v1/subscriptionGroups/${g.id}/subscriptions?limit=200`)) {
const locs = (await c.all(`/v1/subscriptions/${s.id}/subscriptionLocalizations?limit=200`)).map((l: J) => l.attributes.locale);
const prices = (await c.asc('GET', `/v1/subscriptions/${s.id}/prices?limit=1`)).data?.length ?? 0;
const intro = (await c.asc('GET', `/v1/subscriptions/${s.id}/introductoryOffers?limit=1`)).data?.length ?? 0;
const avail = !!(await c.maybe(`/v1/subscriptions/${s.id}/subscriptionAvailability`))?.data;
const shot = !!(await c.maybe(`/v1/subscriptions/${s.id}/appStoreReviewScreenshot`))?.data;
const promoOffers = (await c.all(`/v1/subscriptions/${s.id}/promotionalOffers?limit=200`)).map((o: J) => o.attributes.offerCode);
const offerCodes = (await c.all(`/v1/subscriptions/${s.id}/offerCodes?limit=200`)).map((o: J) => o.attributes.name);
state.subscriptions.push({ id: s.id, productId: s.attributes.productId, groupId: g.id, state: s.attributes.state, locales: locs,
hasPrices: prices > 0, introTerritories: intro, availability: avail, reviewScreenshot: shot, promoOffers, offerCodes });
}
}
return state;
}
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}` : ''}` })) });
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const only = ctx.args.list('only');
const groups: SubscriptionGroup[] = need(config.subscriptions, 'subscriptions')
.map(g => ({ ...g, subscriptions: g.subscriptions.filter(s => (!only.length || only.includes(s.id)) && sellsOn(config, s, 'apple')) })).filter(g => g.subscriptions.length);
const dry = ctx.args.has('dry');
const csv = subscriptionCsv(ctx, 'apple');
const bundleId = need(config.apple?.bundleId, 'apple.bundleId');
const c = await ascClient(root, bundleId, config.apple?.appId);
const appId = await c.app();
log(TAG, 'reading subscription groups, subscriptions, localizations, prices, offers');
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; }
const yes = ctx.args.has('yes');
// price changes of existing subscriptions: only with --allow-price-changes, their own question per subscription
// listing each old -> new (lib/price-gate.ts); the terminal question here covers creates and other updates only
if (stdinIsTTY() && !yes && process.env.STOREBOUND_SERVE !== '1') {
if (!(await confirm(ctx, 'Create / update these subscriptions on App Store Connect? Nothing is public before App Review. Price changes of existing subscriptions are not part of this answer (--allow-price-changes).'))) return;
} else if (!yes) log(TAG, 'not a terminal: creating what is new; price changes of existing subscriptions wait for the owner');
const held: HeldPrice[] = [];
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);
for (const g of groups) {
let gid = state.groups.find(x => x.referenceName === g.name)?.id;
if (!gid) {
const r = await findOrCreate<J>({ what: `subscription group "${g.name}"`, tag: TAG, unique: true,
find: async () => (await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`)).find((x: J) => x.attributes.referenceName === g.name) ?? null,
journal: { root, key: `apple:${bundleId}:subscriptionGroup:${g.name}`, byId: id => getById(c.asc, `/v1/subscriptionGroups/${id}`) as Promise<J | null>, idOf: v => v.id },
create: async () => (await c.asc('POST', '/v1/subscriptionGroups', appleGroupBody(appId, g))).data });
gid = r.value.id as string;
log(TAG, `group "${g.name}" ${r.created ? 'created' : 'exists'}`);
}
const groupId = gid;
const gLocs = state.groups.find(x => x.id === gid)?.locales ?? (await c.all(`/v1/subscriptionGroups/${gid}/subscriptionGroupLocalizations?limit=200`)).map((l: J) => l.attributes.locale);
for (const [lang, l] of Object.entries(g.listings)) {
const loc = locale.get(lang);
if (!loc || gLocs.includes(loc)) continue;
await createOnce({ what: `group "${g.name}" ${loc}`, tag: TAG, unique: true,
find: async () => (await c.all(`/v1/subscriptionGroups/${groupId}/subscriptionGroupLocalizations?limit=200`)).find((x: J) => x.attributes.locale === loc) ?? null,
create: async () => (await c.asc('POST', '/v1/subscriptionGroupLocalizations', appleGroupLocBody(groupId, loc, l))).data });
log(TAG, ` group ${loc}`);
}
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);
let id = cur?.id;
let subLocs = cur?.locales;
if (!id) {
const r = await findOrCreate<J>({ what: `subscription ${s.id}`, tag: TAG, unique: true,
find: async () => (await c.all(`/v1/subscriptionGroups/${groupId}/subscriptions?limit=200`)).find((x: J) => x.attributes.productId === s.id) ?? null,
journal: { root, key: `apple:${bundleId}:subscription:${s.id}`, byId: x => getById(c.asc, `/v1/subscriptions/${x}`) as Promise<J | null>, idOf: v => v.id },
create: async () => (await c.asc('POST', '/v1/subscriptions', appleSubscriptionBody(groupId, s))).data });
id = r.value.id as string;
if (!r.created) subLocs = (await c.all(`/v1/subscriptions/${id}/subscriptionLocalizations?limit=200`)).map((l: J) => l.attributes.locale);
log(TAG, `${tag}: ${r.created ? 'created' : 'exists'}`);
}
const subId = id;
for (const [lang, l] of Object.entries(s.listings)) {
const loc = locale.get(lang);
if (!loc || subLocs?.includes(loc)) continue;
await createOnce({ what: `${s.id} ${loc}`, tag: TAG, unique: true,
find: async () => (await c.all(`/v1/subscriptions/${subId}/subscriptionLocalizations?limit=200`)).find((x: J) => x.attributes.locale === loc) ?? null,
create: async () => (await c.asc('POST', '/v1/subscriptionLocalizations', appleSubLocBody(subId, loc, l))).data });
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`); }
// 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));
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)');
held.push(heldOf(s.id, changes));
} else {
const resolved: { territory: string; pointId: string; startDate?: string | null }[] = [];
for (const ch of changes) {
let pt = known.find(p => p.territory === ch.territory && Math.round(p.price * 100) === Math.round(ch.to * 100));
if (!pt) {
const tp = applePoints(await pages(c, `/v1/subscriptions/${id}/pricePoints?filter[territory]=${ch.territory}&include=territory&limit=200`));
known.push(...tp);
pt = pickPoint(tp, ch.territory, ch.to);
}
resolved.push({ territory: ch.territory, pointId: pt.id, startDate: ch.startDate ?? null });
}
await c.asc('PATCH', `/v1/subscriptions/${id}`, applePricesPatch(id, resolved));
log(TAG, ` prices: ${changes.length} territor${changes.length === 1 ? 'y' : 'ies'}${current.length ? ' changed' : ' set'} (${changes.slice(0, 4).map(x => `${x.territory} ${x.to}${x.startDate ? ` from ${x.startDate}` : ''}`).join(', ')}${changes.length > 4 ? ', ...' : ''})`);
}
// offer prices: free = every territory without a price point; paid = the USA point of the offer price and its
// equalizations (one price per territory)
const offerPrices = async (o: { type: string; usd?: number }): Promise<{ territory: string; pointId?: string }[]> => {
if (o.type === 'free') return territories.map(territory => ({ territory }));
const ip = pickPoint(usaPoints, 'USA', o.usd!);
const eq = applePoints(await pages(c, `/v1/subscriptionPricePoints/${ip.id}/equalizations?include=territory&limit=200`));
return [{ territory: 'USA', pointId: ip.id }, ...eq.filter(p => p.territory !== 'USA').map(p => ({ territory: p.territory, pointId: p.id }))];
};
if (s.intro && !(cur ? cur.introTerritories : (await c.asc('GET', `/v1/subscriptions/${id}/introductoryOffers?limit=1`)).data?.length)) {
const offers = await offerPrices(s.intro);
await c.asc('PATCH', `/v1/subscriptions/${id}`, appleIntroPatch(id, s, offers));
log(TAG, ` introductory offer in ${offers.length} territories`);
}
for (const o of s.offers ?? []) {
if (cur?.promoOffers?.includes(o.id)) continue;
// offer codes (the offer's identifier) are unique per subscription: a 409 finds the existing one
const r = await createOnce({ what: `${s.id} promotional offer ${o.id}`, tag: TAG, unique: true,
find: async () => (await c.all(`/v1/subscriptions/${subId}/promotionalOffers?limit=200`)).find((x: J) => x.attributes.offerCode === o.id) ?? null,
create: async () => (await c.asc('POST', '/v1/subscriptionPromotionalOffers', applePromoOfferBody(subId, s, o, await offerPrices(o)))).data });
if (r.created) log(TAG, ` promotional offer ${o.id}`);
}
for (const oc of s.offerCodes ?? []) {
let ocId: string | undefined;
if (!cur?.offerCodes?.includes(oc.name)) {
// campaign names are not known to be unique: look right before, never send the create twice
const r = await findOrCreate<J>({ what: `${s.id} offer code campaign "${oc.name}"`, tag: TAG,
find: async () => (await c.all(`/v1/subscriptions/${subId}/offerCodes?limit=200`)).find((x: J) => x.attributes.name === oc.name) ?? null,
journal: { root, key: `apple:${bundleId}:subscriptionOfferCode:${s.id}:${oc.name}`, byId: x => getById(c.asc, `/v1/subscriptionOfferCodes/${x}`) as Promise<J | null>, idOf: v => v.id },
create: async () => (await c.asc('POST', '/v1/subscriptionOfferCodes', appleOfferCodeBody(subId, s, oc, await offerPrices(oc)))).data });
ocId = r.value.id as string;
if (r.created) log(TAG, ` offer code campaign "${oc.name}"`);
}
if (oc.customCodes?.length) await customCodes(c, ctx, subId, s, oc, ocId);
}
if (s.reviewScreenshot) {
// one screenshot per subscription: kept when Apple has these bytes (MD5) and did not fail them, else replaced
const file = resolve(s.reviewScreenshot);
const shot = cur?.reviewScreenshot || !cur ? (await c.maybe(`/v1/subscriptions/${subId}/appStoreReviewScreenshot`))?.data : null;
const ok = shot && shot.attributes?.sourceFileChecksum === md5(readFileSync(file)) && shot.attributes?.assetDeliveryState?.state !== 'FAILED';
if (!ok) {
if (shot) await c.asc('DELETE', `/v1/subscriptionAppStoreReviewScreenshots/${shot.id}`);
await c.uploadAsset('subscriptionAppStoreReviewScreenshots', { subscription: { data: { type: 'subscriptions', id: subId } } }, file);
log(TAG, ` review screenshot ${s.reviewScreenshot}`);
}
}
}
}
holdPriceChanges(held);
done(TAG, `subscriptions done${held.length ? ', price changes left for the owner' : ''} (first ones are submitted with an app version: store-subscriptions skill)`);
}
/** Custom codes of one offer code campaign: only with --codes, only after a confirmation in a terminal (anyone with
* the code can redeem it). Existing codes (same customCode) are left alone. */
async function customCodes(c: Asc, ctx: Ctx, subId: string, s: Subscription, oc: OfferCodeDef, ocId: string | undefined): Promise<void> {
if (!ctx.args.has('codes')) { log(TAG, ` ${oc.customCodes!.length} custom code(s) of "${oc.name}" not created (pass --codes from a terminal)`); return; }
if (!stdinIsTTY()) { warn(TAG, ` custom codes of "${oc.name}" need a terminal confirmation`); return; }
const id = ocId ?? (await c.all(`/v1/subscriptions/${subId}/offerCodes?limit=200`)).find((x: J) => x.attributes.name === oc.name)?.id;
if (!id) throw new Error(`${s.id}: offer code campaign "${oc.name}" not found`);
const have = new Set((await c.all(`/v1/subscriptionOfferCodes/${id}/customCodes?limit=200`)).map((x: J) => x.attributes.customCode));
const todo = oc.customCodes!.filter(cc => !have.has(cc.code));
if (!todo.length) return;
if (!(await confirm(withoutYes(ctx), `Create ${todo.length} custom offer code(s) for ${s.id} (${todo.map(x => `${x.code} x${x.count}`).join(', ')})? Anyone with a code can redeem it.`))) return;
for (const cc of todo) {
// a custom code is unique: a 409 finds the existing one
await createOnce({ what: `custom code ${cc.code}`, tag: TAG, unique: true,
find: async () => (await c.all(`/v1/subscriptionOfferCodes/${id}/customCodes?limit=200`)).find((x: J) => x.attributes.customCode === cc.code) ?? null,
create: async () => (await c.asc('POST', '/v1/subscriptionOfferCodeCustomCodes', appleCustomCodeBody(id, cc))).data });
log(TAG, ` custom code ${cc.code} (${cc.count})`);
}
}