// 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 ( -> ) 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 { 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 }[]; 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, territories: string[]): Promise { 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): Promise<{ id: string; loc: string; patch: Record }[]> { const have = await c.all(`/v1/subscriptionGroups/${groupId}/subscriptionGroupLocalizations?limit=200`); const out: { id: string; loc: string; patch: Record }[] = []; 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 { 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({ 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, 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({ 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, 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({ 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, 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 { 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})`); } }