96 lines
7.2 KiB
TypeScript
96 lines
7.2 KiB
TypeScript
// Win-back offers for lapsed subscribers from subscriptions[].subscriptions[].winBack.
|
|
// storebound apple win-back [--only sub1,sub2] [--dry] [--yes] [--json]
|
|
// Lists what each subscription has (GET /v1/subscriptions/{id}/winBackOffers) and creates the configured offers that
|
|
// are missing (by offer id). A win-back offer goes live by itself on its start date (Manage Subscriptions, the
|
|
// in-app offer sheet, the App Store when promoted), so creating one is the owner's step: dry run first, then a
|
|
// terminal confirmation; never an MCP tool. The subscription must be approved; reference name, offer id and price
|
|
// cannot be changed afterwards (App Store Connect Help). Pure parts and sources: cli/lib/offers.ts.
|
|
import type { Ctx } from '../lib/ctx';
|
|
import { confirm, need } from '../lib/ctx';
|
|
import { done, log, printJson, warn } from '../lib/log';
|
|
import { type TerritoryPoint, validateWinBack, type WinBackDef, winBackBody } from '../lib/offers';
|
|
import { allSubscriptions, APPLE_OFFER_MODE, applePoints, pickPoint } from '../lib/subscriptions';
|
|
import { textTable } from '../lib/table';
|
|
import { pages } from '../prices';
|
|
import { ascClient, type J } from './asc';
|
|
import { describeReq, execute, type Req } from './jsonapi';
|
|
|
|
const TAG = 'win-back';
|
|
|
|
/** Differences between an existing win-back offer and the config (mode, price, dates). No PATCH exists for them. `usaPrice` = the offer's USA customer price (undefined: not read, not compared). Pure. */
|
|
export function winBackDiff(have: { offerMode?: string; startDate?: string | null; endDate?: string | null }, usaPrice: number | undefined, want: WinBackDef): string[] {
|
|
const out: string[] = [];
|
|
const mode = APPLE_OFFER_MODE[want.type];
|
|
if (have.offerMode && have.offerMode !== mode) out.push(`mode ${have.offerMode} -> ${mode}`);
|
|
if (want.type !== 'free' && usaPrice !== undefined && want.usd !== undefined && Math.abs(usaPrice - want.usd) > 0.005) out.push(`USA price ${usaPrice} -> ${want.usd}`);
|
|
if ((have.startDate ?? '').slice(0, 10) !== want.start) out.push(`start ${have.startDate ?? '(none)'} -> ${want.start}`);
|
|
if ((have.endDate ?? '').slice(0, 10) !== (want.end ?? '')) out.push(`end ${have.endDate ?? '(none)'} -> ${want.end ?? '(none)'}`);
|
|
return out;
|
|
}
|
|
|
|
export async function run(ctx: Ctx): Promise<void> {
|
|
const { config, root } = ctx.loaded;
|
|
const a = ctx.args;
|
|
const dry = a.has('dry');
|
|
const only = a.list('only');
|
|
const subs = allSubscriptions(config.subscriptions).map(x => x.sub).filter(s => s.winBack?.length && (!only.length || only.includes(s.id)));
|
|
if (!subs.length) throw new Error('no subscriptions with winBack offers (subscriptions[].subscriptions[].winBack in storebound.config.ts)');
|
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
|
const appId = await c.app();
|
|
const onStore = new Map<string, J>();
|
|
for (const g of await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`))
|
|
for (const s of await c.all(`/v1/subscriptionGroups/${g.id}/subscriptions?limit=200`)) onStore.set(s.attributes.productId, s);
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const reqs: Req[] = [];
|
|
const rows: (string | number)[][] = [];
|
|
const skipped: { id: string; why: string }[] = [];
|
|
|
|
for (const s of subs) {
|
|
const remote = onStore.get(s.id);
|
|
if (!remote) { skipped.push({ id: s.id, why: 'not on App Store Connect (storebound apple subscriptions)' }); continue; }
|
|
const existing = await c.all(`/v1/subscriptions/${remote.id}/winBackOffers?limit=200`);
|
|
for (const o of existing) rows.push([s.id, o.attributes.offerId, o.attributes.offerMode, o.attributes.startDate ?? '', o.attributes.endDate ?? '', o.attributes.priority ?? '']);
|
|
for (const o of s.winBack!) {
|
|
const x = existing.find((e: J) => e.attributes.offerId === o.id);
|
|
if (!x) continue;
|
|
let usaPrice: number | undefined;
|
|
if (o.type !== 'free') {
|
|
const pr = await pages(c, `/v1/winBackOffers/${x.id}/prices?include=territory,subscriptionPricePoint&limit=200`);
|
|
const usa = pr.data.find((r: J) => r.relationships?.territory?.data?.id === 'USA');
|
|
const pid = usa?.relationships?.subscriptionPricePoint?.data?.id;
|
|
const inc = pid ? pr.included.find((i: J) => i.id === pid) : undefined;
|
|
const cp = Number(inc?.attributes?.customerPrice);
|
|
if (Number.isFinite(cp)) usaPrice = cp;
|
|
}
|
|
const diffs = winBackDiff(x.attributes, usaPrice, o);
|
|
if (diffs.length) warn(TAG, `${s.id}: win-back "${o.id}" differs from the config: ${diffs.join('; ')} (Apple does not allow editing: create a new offer with another id)`);
|
|
}
|
|
const missing = s.winBack!.filter(o => !existing.some((x: J) => x.attributes.offerId === o.id));
|
|
if (!missing.length) continue;
|
|
if (remote.attributes.state !== 'APPROVED') { skipped.push({ id: s.id, why: `subscription is ${remote.attributes.state}: win-back offers need an approved subscription` }); continue; }
|
|
const problems = validateWinBack(s.id, missing, today);
|
|
if (problems.length) { skipped.push({ id: s.id, why: problems.join('; ') }); continue; }
|
|
let usa: ReturnType<typeof applePoints> | null = null;
|
|
for (const o of missing) {
|
|
let prices: TerritoryPoint[];
|
|
if (o.type === 'free') prices = (await c.all(`/v1/subscriptions/${remote.id}/prices?include=territory&limit=200`)).map((x: J) => ({ territory: x.relationships?.territory?.data?.id })).filter((x: TerritoryPoint) => !!x.territory);
|
|
else {
|
|
usa ??= applePoints(await pages(c, `/v1/subscriptions/${remote.id}/pricePoints?filter[territory]=USA&include=territory&limit=200`));
|
|
const point = pickPoint(usa, 'USA', o.usd!);
|
|
const eq = applePoints(await pages(c, `/v1/subscriptionPricePoints/${point.id}/equalizations?include=territory&limit=200`));
|
|
prices = [{ territory: 'USA', pointId: point.id }, ...eq.filter(x => x.territory !== 'USA').map(x => ({ territory: x.territory, pointId: x.id }))];
|
|
}
|
|
reqs.push({ method: 'POST', path: '/v1/winBackOffers', body: winBackBody(remote.id, s.period, o, prices),
|
|
note: `${s.id}: win-back "${o.id}" ${o.type}${o.usd ? ` USD ${o.usd}` : ''}, from ${o.start}${o.end ? ` to ${o.end}` : ''}, ${prices.length} territories` });
|
|
}
|
|
}
|
|
if (a.has('json')) printJson({ dryRun: dry, existing: rows.map(r => ({ subscription: r[0], offerId: r[1], mode: r[2], start: r[3], end: r[4], priority: r[5] })), create: reqs.map(r => describeReq(r)), skipped });
|
|
else if (rows.length) console.log(textTable(['subscription', 'offer', 'mode', 'start', 'end', 'priority'], rows));
|
|
for (const s of skipped) warn(TAG, `${s.id}: ${s.why}`);
|
|
if (!reqs.length) { done(TAG, 'no win-back offer to create'); return; }
|
|
if (dry) { await execute(c.asc, reqs, { dry, tag: TAG }); done(TAG, `dry run: ${reqs.length} win-back offer(s) would be created`); return; }
|
|
if (!(await confirm(ctx, `Create ${reqs.length} win-back offer(s)? Each goes live by itself on its start date; reference name, offer id and price cannot be changed afterwards.`))) return;
|
|
await execute(c.asc, reqs, { dry: false, tag: TAG });
|
|
log(TAG, 'the redemption URL of each offer is in App Store Connect (Subscriptions > the subscription > Win-Back Offers)');
|
|
done(TAG, `${reqs.length} win-back offer(s) created`);
|
|
}
|