// 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] // 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 it asks before writing anything. Without a terminal (MCP, CI) and without // --yes it still creates what is new (nothing is public before App Review), but never changes prices of a // subscription that already has them: that waits for the owner in a terminal. 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 { 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'; 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; } 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) { 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'); let liveOk = yes; if (process.stdin.isTTY && !yes) { if (!(await confirm(ctx, 'Create / update these subscriptions on App Store Connect? Nothing is public before App Review, but price changes of existing subscriptions apply to new subscribers right away.'))) return; liveOk = true; } else if (!yes) log(TAG, 'not a terminal: creating what is new only; price changes of existing subscriptions wait for a terminal run'); 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}`); } 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`); } // 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 && !liveOk) warn(TAG, ` ${changes.length} price change(s) skipped: run in a terminal to confirm`); 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}`); } } } } done(TAG, 'subscriptions done (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 (!process.stdin.isTTY) { 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({ ...ctx, args: { ...ctx.args, has: (n: string) => n !== 'yes' && ctx.args.has(n) } }, `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})`); } }