storebound/cli/apple/subscriptions.ts

340 lines
25 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, heldSection, holdPriceChanges, withoutYes } from '../lib/price-gate';
import { parsePriceCsv, type PriceRow } from '../lib/prices';
import {
allSubscriptions, appleAvailabilityBody, appleAvailabilityDiff, appleGroupLocDiff, appleGroupLocPatchBody, appleIntroDiff, appleIntroOffers, appleLocDiff, appleLocPatchBody,
type AppleAttrDiff, appleSubAttrDiff, appleSubPatchBody, 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}` : ''}` })) });
/** Everything an existing subscription has that differs from the config (pure data; the run prints and applies it). */
export interface SubDrift {
attrs: AppleAttrDiff;
locs: { id: string; loc: string; patch: Record<string, string> }[];
availability: { missing: string[]; newTerritories: boolean } | null;
intro: string[];
}
/** Reads the live subscription (attributes, localizations, availability, introductory offers) and diffs it with the config. Read-only. */
export async function readSubDrift(c: Asc, s: Subscription, id: string, locale: Map<string, string | undefined>, territories: string[]): Promise<SubDrift> {
const live = (await c.maybe(`/v1/subscriptions/${id}`))?.data?.attributes ?? {};
const attrs = appleSubAttrDiff(s, { name: live.name, subscriptionPeriod: live.subscriptionPeriod, familySharable: live.familySharable, reviewNote: live.reviewNote, groupLevel: live.groupLevel });
const locs: SubDrift['locs'] = [];
const haveLocs = await c.all(`/v1/subscriptions/${id}/subscriptionLocalizations?limit=200`);
for (const [lang, l] of Object.entries(s.listings)) {
const loc = locale.get(lang);
const h = loc ? haveLocs.find((x: J) => x.attributes.locale === loc) : undefined;
const patch = h ? appleLocDiff(l, { name: h.attributes.name, description: h.attributes.description }) : null;
if (h && patch && loc) locs.push({ id: h.id, loc, patch });
}
let availability: SubDrift['availability'] = null;
const av = (await c.maybe(`/v1/subscriptions/${id}/subscriptionAvailability`))?.data;
if (av?.id) {
let haveT: string[] | null = null;
try { haveT = (await c.all(`/v1/subscriptionAvailabilities/${av.id}/availableTerritories?limit=200`)).map((t: J) => t.id as string); } catch { /* unreadable: left alone */ }
if (haveT) availability = appleAvailabilityDiff({ territories: haveT, availableInNewTerritories: av.attributes?.availableInNewTerritories }, territories);
}
const hasIntro = !!(await c.asc('GET', `/v1/subscriptions/${id}/introductoryOffers?limit=1`)).data?.length;
const intro = hasIntro ? appleIntroDiff(s, appleIntroOffers(await pages(c, `/v1/subscriptions/${id}/introductoryOffers?include=subscriptionPricePoint,territory&limit=200`))) : [];
return { attrs, locs, availability, intro };
}
/** Group localizations whose name / customAppName differ from the config. Read-only. */
export async function readGroupLocDrift(c: Asc, g: SubscriptionGroup, groupId: string, locale: Map<string, string | undefined>): Promise<{ id: string; loc: string; patch: Record<string, string> }[]> {
const have = await c.all(`/v1/subscriptionGroups/${groupId}/subscriptionGroupLocalizations?limit=200`);
const out: { id: string; loc: string; patch: Record<string, string> }[] = [];
for (const [lang, l] of Object.entries(g.listings)) {
const loc = locale.get(lang);
const h = loc ? have.find((x: J) => x.attributes.locale === loc) : undefined;
const patch = h ? appleGroupLocDiff(l, { name: h.attributes.name, customAppName: h.attributes.customAppName }) : null;
if (h && patch && loc) out.push({ id: h.id, loc, patch });
}
return out;
}
/** The lines the dry run prints for one subscription's drift. Pure. */
export function driftLines(id: string, d: SubDrift): string[] {
const av = d.availability;
return [
...d.attrs.text.map(x => `${id}: ${x}`), ...d.attrs.report,
...d.locs.map(l => `${id} ${l.loc}: ${Object.keys(l.patch).join(' + ')} differ from the config`),
...(av ? [`${id}: availability${av.missing.length ? ` ${av.missing.length} territories missing (${av.missing.slice(0, 5).join(', ')})` : ''}${av.newTerritories ? ' new territories off' : ''}`] : []),
...d.intro,
];
}
/** Today's prices vs the config for one existing subscription (read-only). */
async function priceState(c: Asc, s: Subscription, id: string, cur: { hasPrices: boolean } | undefined, csv: PriceRow[]) {
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));
return { usaPoints, known, current, changes };
}
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) {
// read-only comparison of everything that exists: text, attributes, availability, intro offers and prices (old -> new)
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);
const heldDry: HeldPrice[] = [];
for (const g of groups) {
const sg = state.groups.find(x => x.referenceName === g.name);
if (sg) for (const d of await readGroupLocDrift(c, g, sg.id, locale)) log(TAG, `group "${g.name}" ${d.loc}: ${Object.keys(d.patch).join(' + ')} differ from the config`);
for (const s of g.subscriptions) {
const cur = state.subscriptions.find(x => x.productId === s.id);
if (!cur) continue;
for (const l of driftLines(s.id, await readSubDrift(c, s, cur.id, locale, territories))) log(TAG, l);
try {
const { changes } = await priceState(c, s, cur.id, cur, csv);
if (changes.length) { log(TAG, `${s.id}: ${changes.length} price change(s)`); heldDry.push(heldOf(s.id, changes)); }
} catch (e) { warn(TAG, `${s.id}: prices not compared (${String((e as Error).message ?? e).slice(0, 160)})`); }
}
}
const h = heldSection(heldDry, ctx.args.has('allow-price-changes'));
if (h) console.log(h);
done(TAG, `subscriptions planned (dry run, nothing written${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}`);
}
if (state.groups.some(x => x.id === groupId)) {
for (const d of await readGroupLocDrift(c, g, groupId, locale)) {
await c.asc('PATCH', `/v1/subscriptionGroupLocalizations/${d.id}`, appleGroupLocPatchBody(d.id, d.patch));
log(TAG, ` group ${d.loc}: ${Object.keys(d.patch).join(' + ')} updated`);
}
}
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`); }
if (cur) {
// what exists but differs from the config: text and attributes are patched, availability is set again, intro offers
// and the period / family sharing are only reported (Apple cannot change them)
const d = await readSubDrift(c, s, subId, locale, territories);
if (Object.keys(d.attrs.patch).length) { await c.asc('PATCH', `/v1/subscriptions/${subId}`, appleSubPatchBody(subId, d.attrs.patch)); log(TAG, ` attributes updated: ${d.attrs.text.join(', ')}`); }
for (const l of d.locs) { await c.asc('PATCH', `/v1/subscriptionLocalizations/${l.id}`, appleLocPatchBody(l.id, l.patch)); log(TAG, ` ${l.loc}: ${Object.keys(l.patch).join(' + ')} updated`); }
if (d.availability) { await c.asc('POST', '/v1/subscriptionAvailabilities', appleAvailabilityBody(subId, territories)); log(TAG, ` availability set again: ${territories.length} territories + new ones`); }
for (const x of [...d.attrs.report, ...d.intro]) warn(TAG, x);
}
// prices: USA point -> equalizations -> overrides / CSV; only what differs from today's prices
const { usaPoints, known, current, changes } = await priceState(c, s, id, cur, csv);
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})`);
}
}