237 lines
18 KiB
TypeScript
237 lines
18 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';
|
|
|
|
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;
|
|
}
|
|
|
|
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: known per subscription, decided at the first one (lib/price-gate.ts)
|
|
let liveOk: boolean | undefined;
|
|
if (process.stdin.isTTY && !yes && process.env.STOREBOUND_SERVE !== '1') {
|
|
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; 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 && !(liveOk ??= await allowPriceChanges(ctx, changes.length, `Change the prices of existing subscriptions on App Store Connect (first: ${s.id}, ${changes.length} territories)? New subscribers pay the new price.`))) {
|
|
log(TAG, ' owner: price change of an existing subscription (--allow-price-changes)');
|
|
held.push({ id: s.id, changes: changes.map(ch => ({ region: ch.territory, from: ch.from === null ? '-' : String(ch.from), to: `${ch.to}${ch.startDate ? ` from ${ch.startDate}` : ''}` })) });
|
|
} 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 (!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(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})`);
|
|
}
|
|
}
|