storebound/cli/lib/subscriptions.ts
CRT 236977940e flows, listing translate, scan 2.2 + --originality, prices export with subscriptions, site sections
- storebound flow list|validate|run: repo-local workflows (store/workflows/*.yml, storebound.workflows.*), writes dry unless --apply, owner steps never run, --from / --resume; init templates for app and game; MCP flow_list + dry flow_run; release-flows skill
- storebound listing translate|check|reviewed: per-language listing drafts with store limits and keyword rules, agent or --translate hook, marked until reviewed; MCP listing_translate / listing_check; listing-localization skill
- scan: Apple 2.2 beta/demo/test wording rule; scan --originality [--against]
- prices export includes subscriptions; prices import skips their rows
- site: rejection scan section, Desktop app section with fixture-mode screenshots, Everything else grid, FAQ 4.3 answer
- README, CHANGELOG, adapters, API tables
2026-10-12 01:24:21 +08:00

520 lines
37 KiB
TypeScript

// Auto-renewable subscriptions on both stores, as pure parts: config checks, the period / offer mappings, the App
// Store Connect request bodies and plan, and the Google Play subscription / base plan / offer bodies and diff.
// The commands (cli/apple/subscriptions.ts, cli/play/subscriptions.ts) read the current state, call these, print the
// plan and only then write.
// Sources (checked October 2026):
// Apple: subscription groups, group localizations, subscriptions, subscription localizations, price points and their
// equalizations, subscription prices, introductory offers, availability, review screenshots.
// https://developer.apple.com/documentation/appstoreconnectapi/subscription-groups
// https://developer.apple.com/documentation/appstoreconnectapi/subscriptions
// https://developer.apple.com/documentation/appstoreconnectapi/subscription-prices
// https://developer.apple.com/documentation/appstoreconnectapi/subscription-price-points
// https://developer.apple.com/documentation/appstoreconnectapi/subscription-introductory-offers
// https://developer.apple.com/documentation/appstoreconnectapi/subscription-availabilities
// Google Play: monetization.subscriptions (create / patch / list), basePlans:activate, basePlans.offers (create,
// activate), pricing:convertRegionPrices.
// https://developers.google.com/android-publisher/api-ref/rest/v3/monetization.subscriptions
// https://developers.google.com/android-publisher/api-ref/rest/v3/monetization.subscriptions.basePlans
// https://developers.google.com/android-publisher/api-ref/rest/v3/monetization.subscriptions.basePlans.offers
import type { IntroOffer, Language, OfferCodeDef, PriceOverride, Subscription, SubscriptionGroup, SubscriptionOfferDef, SubscriptionPeriod } from './config';
import { playCodes } from './config';
import { ALPHA3, fromMoney, type Money, toMoney } from './money';
import { alpha2, type PriceRow } from './prices';
// ---- mappings ----
export const PERIODS: readonly SubscriptionPeriod[] = ['P1W', 'P1M', 'P2M', 'P3M', 'P6M', 'P1Y'];
/** Subscription.subscriptionPeriod on App Store Connect. */
export const APPLE_PERIOD: Record<SubscriptionPeriod, string> = {
P1W: 'ONE_WEEK', P1M: 'ONE_MONTH', P2M: 'TWO_MONTHS', P3M: 'THREE_MONTHS', P6M: 'SIX_MONTHS', P1Y: 'ONE_YEAR',
};
/** SubscriptionOfferDuration on App Store Connect (introductory offers). */
export const APPLE_OFFER_DURATION: Record<string, string> = {
P3D: 'THREE_DAYS', P1W: 'ONE_WEEK', P2W: 'TWO_WEEKS', P1M: 'ONE_MONTH', P2M: 'TWO_MONTHS', P3M: 'THREE_MONTHS', P6M: 'SIX_MONTHS', P1Y: 'ONE_YEAR',
};
/** SubscriptionOfferMode on App Store Connect. */
export const APPLE_OFFER_MODE = { 'free': 'FREE_TRIAL', 'pay-as-you-go': 'PAY_AS_YOU_GO', 'pay-up-front': 'PAY_UP_FRONT' } as const;
/** Default Play base plan id per period. */
export const DEFAULT_BASE_PLAN: Record<SubscriptionPeriod, string> = {
P1W: 'weekly', P1M: 'monthly', P2M: 'every-2-months', P3M: 'quarterly', P6M: 'every-6-months', P1Y: 'yearly',
};
/** Play subscription listing limits: description 200 and at most 4 benefits (Subscription resource reference);
* 40 characters per benefit is the Play Console field limit (not in the API reference). */
export const PLAY_SUB_LIMITS = { description: 200, benefits: 4, benefit: 40 } as const;
/** Apple subscription display name / description (same limits as other in-app purchases: 30 / 45). */
export const APPLE_SUB_LIMITS = { name: 30, description: 45 } as const;
const PRODUCT_RE = /^[a-z0-9][a-z0-9._]*$/;
const PLAY_ID_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
const GRACE = ['P3D', 'P7D', 'P14D', 'P30D'];
const len = (s: string): number => [...s].length;
export const basePlanId = (s: Subscription): string => s.basePlanId ?? DEFAULT_BASE_PLAN[s.period];
export const offerId = (s: Subscription): string => s.intro?.playOfferId ?? 'intro';
/** Any offer's phase length: free / pay-up-front as configured, pay-as-you-go = one subscription period. */
export const offerDuration = (s: Subscription, o: { type: string; duration?: string } | undefined): string =>
(o?.type === 'pay-as-you-go' ? o.duration ?? s.period : o?.duration ?? '');
/** Intro offer duration. */
export const introDuration = (s: Subscription): string => offerDuration(s, s.intro);
type OfferLike = Pick<IntroOffer, 'type' | 'duration' | 'periods' | 'usd'>;
/** Checks shared by intro offers, promotional offers and offer codes. */
function validateOffer(at: string, s: Subscription, o: OfferLike): string[] {
const e: string[] = [];
if (!(o.type in APPLE_OFFER_MODE)) e.push(`${at}.type: free | pay-as-you-go | pay-up-front`);
if (o.type === 'pay-as-you-go') {
if (!(Number.isInteger(o.periods) && o.periods! >= 1 && o.periods! <= 12)) e.push(`${at}.periods: 1-12 discounted periods`);
if (o.duration !== undefined && o.duration !== s.period) e.push(`${at}.duration: pay-as-you-go runs in the subscription period (${s.period}); leave it out`);
} else if (!o.duration || !APPLE_OFFER_DURATION[o.duration]) e.push(`${at}.duration: one of ${Object.keys(APPLE_OFFER_DURATION).join(', ')}`);
if (o.type === 'free' && o.usd !== undefined) e.push(`${at}.usd: a free offer has no price`);
if (o.type !== 'free') {
if (!(typeof o.usd === 'number' && o.usd > 0)) e.push(`${at}.usd: positive number`);
else if (o.type === 'pay-as-you-go' && o.usd >= s.usd) e.push(`${at}.usd: must be below the regular price ${s.usd}`);
}
return e;
}
/** Every subscription of every group, with its group. */
export const allSubscriptions = (groups: SubscriptionGroup[] | undefined): { group: SubscriptionGroup; sub: Subscription }[] =>
(groups ?? []).flatMap(group => group.subscriptions.map(sub => ({ group, sub })));
/** Problems with `subscriptions` in the config (empty = fine). `langIds` = language ids, `productIds` = one-time
* product ids (a subscription may not reuse one). */
export function validateSubscriptions(groups: unknown, langIds: Set<string>, productIds: Set<string>): string[] {
const e: string[] = [];
if (groups === undefined) return e;
if (!Array.isArray(groups)) return ['subscriptions: a list of groups'];
const seen = new Set<string>();
const groupNames = new Set<string>();
for (const [gi, g] of (groups as SubscriptionGroup[]).entries()) {
const at = `subscriptions[${gi}]`;
if (!g?.name) e.push(`${at}.name: missing (Apple subscription group reference name)`);
else if (groupNames.has(g.name)) e.push(`${at}.name: duplicate "${g.name}"`);
groupNames.add(g?.name);
if (!g?.listings || !Object.keys(g.listings).length) e.push(`${at}.listings: at least one language (group display name)`);
for (const [lang, l] of Object.entries(g?.listings ?? {})) {
if (!langIds.has(lang)) e.push(`${at}.listings.${lang}: not in languages`);
if (!l?.name) e.push(`${at}.listings.${lang}.name: missing`);
}
if (!Array.isArray(g?.subscriptions) || !g.subscriptions.length) { e.push(`${at}.subscriptions: at least one`); continue; }
const levels = new Set<number>();
for (const [si, s] of g.subscriptions.entries()) {
const sat = `${at}.subscriptions[${si}]`;
if (!PRODUCT_RE.test(s.id ?? '') || s.id.length > 40) e.push(`${sat}.id: 1-40 lowercase letters, digits, "." and "_", starting with a letter or digit (Play rule)`);
if (seen.has(s.id)) e.push(`${sat}.id: duplicate "${s.id}"`);
if (productIds.has(s.id)) e.push(`${sat}.id: "${s.id}" is already a one-time product`);
seen.add(s.id);
if (!PERIODS.includes(s.period)) e.push(`${sat}.period: one of ${PERIODS.join(', ')}`);
if (!(Number.isInteger(s.level) && s.level >= 1)) e.push(`${sat}.level: integer from 1 (1 = highest level of service)`);
else if (levels.has(s.level)) e.push(`${sat}.level: ${s.level} used twice in the group`);
levels.add(s.level);
if (!(typeof s.usd === 'number' && s.usd > 0)) e.push(`${sat}.usd: positive number`);
if (s.basePlanId !== undefined && !PLAY_ID_RE.test(s.basePlanId)) e.push(`${sat}.basePlanId: lowercase letters, digits and "-", max 63`);
if (s.gracePeriod !== undefined && !GRACE.includes(s.gracePeriod)) e.push(`${sat}.gracePeriod: one of ${GRACE.join(', ')}`);
for (const o of s.overrides ?? []) {
if (!/^[A-Z]{2}$/.test(o.region)) e.push(`${sat}.overrides: region "${o.region}" must be ISO alpha-2 (TR)`);
if (!/^[A-Z]{3}$/.test(o.currency)) e.push(`${sat}.overrides: currency "${o.currency}" must be ISO 4217 (TRY)`);
if (!(o.price > 0)) e.push(`${sat}.overrides: price must be positive`);
}
if (!s.listings || !Object.keys(s.listings).length) e.push(`${sat}.listings: at least one language`);
for (const [lang, l] of Object.entries(s.listings ?? {})) {
const lat = `${sat}.listings.${lang}`;
if (!langIds.has(lang)) e.push(`${lat}: not in languages`);
if (!l?.title) e.push(`${lat}.title: missing`);
else if (len(l.title) > APPLE_SUB_LIMITS.name) e.push(`${lat}.title: over ${APPLE_SUB_LIMITS.name} characters (App Store limit)`);
if (!l?.description) e.push(`${lat}.description: missing`);
else if (len(l.description) > APPLE_SUB_LIMITS.description) e.push(`${lat}.description: over ${APPLE_SUB_LIMITS.description} characters (App Store limit)`);
if ((l?.benefits?.length ?? 0) > PLAY_SUB_LIMITS.benefits) e.push(`${lat}.benefits: at most ${PLAY_SUB_LIMITS.benefits} (Play)`);
for (const b of l?.benefits ?? []) if (len(b) > PLAY_SUB_LIMITS.benefit) e.push(`${lat}.benefits: "${b.slice(0, 20)}..." over ${PLAY_SUB_LIMITS.benefit} characters (Play)`);
}
if (s.intro) {
e.push(...validateOffer(`${sat}.intro`, s, s.intro));
if (s.intro.playOfferId !== undefined && !PLAY_ID_RE.test(s.intro.playOfferId)) e.push(`${sat}.intro.playOfferId: lowercase letters, digits and "-", max 63`);
}
const offerIds = new Set<string>(s.intro ? [offerId(s)] : []);
for (const [oi, o] of (s.offers ?? []).entries()) {
const oat = `${sat}.offers[${oi}]`;
if (!PLAY_ID_RE.test(o.id ?? '')) e.push(`${oat}.id: lowercase letters, digits and "-", max 63 (Play offer id, Apple offer code)`);
if (offerIds.has(o.id)) e.push(`${oat}.id: duplicate offer id "${o.id}"`);
offerIds.add(o.id);
e.push(...validateOffer(oat, s, o));
}
const codeNames = new Set<string>();
for (const [ci, oc] of (s.offerCodes ?? []).entries()) {
const cat = `${sat}.offerCodes[${ci}]`;
if (!oc.name) e.push(`${cat}.name: missing`);
if (codeNames.has(oc.name)) e.push(`${cat}.name: duplicate "${oc.name}"`);
codeNames.add(oc.name);
if (!Array.isArray(oc.eligibility) || !oc.eligibility.length || !oc.eligibility.every(x => ['NEW', 'EXISTING', 'EXPIRED'].includes(x))) e.push(`${cat}.eligibility: a list of NEW | EXISTING | EXPIRED`);
e.push(...validateOffer(cat, s, oc));
for (const cc of oc.customCodes ?? []) {
if (!/^[A-Z0-9]{1,64}$/.test(cc.code ?? '')) e.push(`${cat}.customCodes: "${cc.code}" must be upper-case letters and digits`);
if (!(Number.isInteger(cc.count) && cc.count >= 1)) e.push(`${cat}.customCodes ${cc.code}: count must be a positive integer`);
if (cc.expires !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(cc.expires)) e.push(`${cat}.customCodes ${cc.code}: expires YYYY-MM-DD`);
}
}
if (s.platforms !== undefined && !(Array.isArray(s.platforms) && s.platforms.length && s.platforms.every(x => x === 'ios' || x === 'android'))) e.push(`${sat}.platforms: a list of ios | android`);
}
}
return e;
}
// ---- Apple: request bodies ----
const rel = (type: string, id: string) => ({ data: { type, id } });
export const appleGroupBody = (appId: string, g: SubscriptionGroup) =>
({ data: { type: 'subscriptionGroups', attributes: { referenceName: g.name }, relationships: { app: rel('apps', appId) } } });
export const appleGroupLocBody = (groupId: string, locale: string, l: { name: string; customAppName?: string }) =>
({ data: { type: 'subscriptionGroupLocalizations', attributes: { locale, name: l.name, ...(l.customAppName ? { customAppName: l.customAppName } : {}) },
relationships: { subscriptionGroup: rel('subscriptionGroups', groupId) } } });
export function appleSubscriptionBody(groupId: string, s: Subscription) {
const first = Object.values(s.listings)[0]!;
return { data: { type: 'subscriptions', attributes: {
name: s.referenceName ?? first.title, productId: s.id, subscriptionPeriod: APPLE_PERIOD[s.period], groupLevel: s.level,
familySharable: s.familySharable ?? false, ...(s.reviewNote ? { reviewNote: s.reviewNote } : {}),
}, relationships: { group: rel('subscriptionGroups', groupId) } } };
}
export const appleSubLocBody = (subId: string, locale: string, l: { title: string; description: string }) =>
({ data: { type: 'subscriptionLocalizations', attributes: { locale, name: l.title, description: l.description },
relationships: { subscription: rel('subscriptions', subId) } } });
export const appleAvailabilityBody = (subId: string, territories: string[]) =>
({ data: { type: 'subscriptionAvailabilities', attributes: { availableInNewTerritories: true },
relationships: { subscription: rel('subscriptions', subId), availableTerritories: { data: territories.map(id => ({ type: 'territories', id })) } } } });
/**
* Prices for many territories in one request: PATCH /v1/subscriptions/{id} with `prices` and the inline-created
* subscriptionPrices in `included` (SubscriptionUpdateRequest). startDate null = now, a date = a scheduled change;
* preserveCurrentPrice true keeps existing subscribers on their current price (a price increase for them is a
* separate, consent-bound step in App Store Connect).
*/
export function applePricesPatch(subId: string, prices: { territory: string; pointId: string; startDate?: string | null }[], o: { preserveCurrentPrice?: boolean } = {}) {
const ids = prices.map((_, i) => `\${price-${i}}`);
return {
data: { type: 'subscriptions', id: subId, relationships: { prices: { data: ids.map(id => ({ type: 'subscriptionPrices', id })) } } },
included: prices.map((p, i) => ({ type: 'subscriptionPrices', id: ids[i], attributes: { startDate: p.startDate ?? null, preserveCurrentPrice: o.preserveCurrentPrice ?? true },
relationships: { subscriptionPricePoint: rel('subscriptionPricePoints', p.pointId), territory: rel('territories', p.territory) } })),
};
}
/** Introductory offers for many territories in one request (PATCH /v1/subscriptions/{id}, `introductoryOffers` +
* inline creates). A free trial has no price point; a paid offer needs one per territory. */
export function appleIntroPatch(subId: string, s: Subscription, territories: { territory: string; pointId?: string }[]) {
const o = s.intro!;
const ids = territories.map((_, i) => `\${intro-${i}}`);
return {
data: { type: 'subscriptions', id: subId, relationships: { introductoryOffers: { data: ids.map(id => ({ type: 'subscriptionIntroductoryOffers', id })) } } },
included: territories.map((t, i) => {
if (o.type !== 'free' && !t.pointId) throw new Error(`${s.id}: paid introductory offer in ${t.territory} needs a price point`);
return { type: 'subscriptionIntroductoryOffers', id: ids[i], attributes: {
duration: APPLE_OFFER_DURATION[introDuration(s)], offerMode: APPLE_OFFER_MODE[o.type],
numberOfPeriods: o.type === 'pay-as-you-go' ? o.periods! : 1, startDate: null, endDate: null,
}, relationships: { territory: rel('territories', t.territory), ...(o.type !== 'free' ? { subscriptionPricePoint: rel('subscriptionPricePoints', t.pointId!) } : {}) } };
}),
};
}
/** Promotional offer (Apple subscriptionPromotionalOffers): one price entry per territory; a free offer sends the
* territory without a price point (the schema marks the price point optional; not verified against a live account). */
export function applePromoOfferBody(subId: string, s: Subscription, o: SubscriptionOfferDef, prices: { territory: string; pointId?: string }[]) {
const ids = prices.map((_, i) => `\${promo-${i}}`);
return {
data: { type: 'subscriptionPromotionalOffers', attributes: { name: o.name ?? o.id, offerCode: o.id, duration: APPLE_OFFER_DURATION[offerDuration(s, o)],
offerMode: APPLE_OFFER_MODE[o.type], numberOfPeriods: o.type === 'pay-as-you-go' ? o.periods! : 1 },
relationships: { subscription: rel('subscriptions', subId), prices: { data: ids.map(id => ({ type: 'subscriptionPromotionalOfferPrices', id })) } } },
included: prices.map((p, i) => offerPrice('subscriptionPromotionalOfferPrices', ids[i]!, o, p, s.id)),
};
}
/** Offer code campaign (Apple subscriptionOfferCodes) with its per-territory prices. */
export function appleOfferCodeBody(subId: string, s: Subscription, oc: OfferCodeDef, prices: { territory: string; pointId?: string }[]) {
const ids = prices.map((_, i) => `\${code-${i}}`);
return {
data: { type: 'subscriptionOfferCodes', attributes: { name: oc.name, customerEligibilities: oc.eligibility,
offerEligibility: oc.stackWithIntro ? 'STACK_WITH_INTRO_OFFERS' : 'REPLACE_INTRO_OFFERS', duration: APPLE_OFFER_DURATION[offerDuration(s, oc)],
offerMode: APPLE_OFFER_MODE[oc.type], numberOfPeriods: oc.type === 'pay-as-you-go' ? oc.periods! : 1 },
relationships: { subscription: rel('subscriptions', subId), prices: { data: ids.map(id => ({ type: 'subscriptionOfferCodePrices', id })) } } },
included: prices.map((p, i) => offerPrice('subscriptionOfferCodePrices', ids[i]!, oc, p, s.id)),
};
}
function offerPrice(type: string, id: string, o: OfferLike, p: { territory: string; pointId?: string }, product: string) {
if (o.type !== 'free' && !p.pointId) throw new Error(`${product}: paid offer in ${p.territory} needs a price point`);
return { type, id, relationships: { territory: rel('territories', p.territory), ...(p.pointId ? { subscriptionPricePoint: rel('subscriptionPricePoints', p.pointId) } : {}) } };
}
/** Custom code of an offer code campaign (POST /v1/subscriptionOfferCodeCustomCodes). */
export const appleCustomCodeBody = (offerCodeId: string, cc: { code: string; count: number; expires?: string }) =>
({ data: { type: 'subscriptionOfferCodeCustomCodes', attributes: { customCode: cc.code, numberOfCodes: cc.count, ...(cc.expires ? { expirationDate: cc.expires } : {}) },
relationships: { offerCode: rel('subscriptionOfferCodes', offerCodeId) } } });
// ---- Apple: prices ----
export interface ApplePoint { id: string; territory: string; price: number }
/**
* The manual price per territory: the USA point's equalizations for every territory, then the config overrides, then
* CSV rows without start_date (CSV wins). Returns territory -> wanted price. Pure; points are looked up by the caller.
*/
export function appleWantedPrices(s: Subscription, equalized: ApplePoint[], csv: PriceRow[] = []): Map<string, number> {
const out = new Map<string, number>([['USA', s.usd]]);
for (const p of equalized) if (p.territory !== 'USA') out.set(p.territory, p.price);
for (const o of s.overrides ?? []) out.set(territoryOf(o), o.price);
for (const r of csv) if (r.product === s.id && r.store === 'apple' && !r.startDate && r.kind !== 'auto') out.set(r.territory ?? ALPHA3[r.region] ?? r.region, r.price);
return out;
}
function territoryOf(o: PriceOverride): string {
const t = o.territory ?? ALPHA3[o.region];
if (!t) throw new Error(`no App Store territory known for region ${o.region}; set "territory" (alpha-3)`);
return t;
}
const cents = (n: number): number => Math.round(n * 100);
export interface PriceChange { territory: string; from: number | null; to: number; startDate?: string }
/** Wanted prices (now) + CSV scheduled rows vs the current prices: only what differs. `current` = prices in effect now
* (startDate null or in the past) and scheduled ones (future startDate). */
export function applePriceChanges(current: { territory: string; price: number; startDate?: string | null }[], wanted: Map<string, number>,
scheduled: PriceRow[] = []): PriceChange[] {
const now = new Map(current.filter(c => !c.startDate).map(c => [c.territory, c.price] as const));
const future = new Set(current.filter(c => c.startDate).map(c => `${c.territory}|${c.startDate}|${cents(c.price)}`));
const out: PriceChange[] = [];
for (const [territory, to] of [...wanted.entries()].sort(([a], [b]) => (a === 'USA' ? -1 : b === 'USA' ? 1 : a.localeCompare(b)))) {
const from = now.get(territory) ?? null;
if (from === null || cents(from) !== cents(to)) out.push({ territory, from, to });
}
for (const r of scheduled.filter(x => x.store === 'apple' && x.startDate)) {
const territory = r.territory ?? ALPHA3[r.region] ?? r.region;
if (!future.has(`${territory}|${r.startDate}|${cents(r.price)}`)) out.push({ territory, from: now.get(territory) ?? null, to: r.price, startDate: r.startDate });
}
return out;
}
// ---- Apple: plan ----
export interface AppleSubState {
groups: { id: string; referenceName: string; locales: string[] }[];
subscriptions: { id: string; productId: string; groupId: string; state?: string; locales: string[]; hasPrices: boolean; introTerritories: number; availability: boolean; reviewScreenshot: boolean;
/** offerCode of each promotional offer; name of each offer code campaign (optional for older fixtures). */
promoOffers?: string[]; offerCodes?: string[] }[];
}
export interface AppleStep { group: string; product?: string; kind: 'group' | 'group-loc' | 'sub' | 'sub-loc' | 'availability' | 'prices' | 'intro' | 'promo' | 'offer-code' | 'review-shot'; what: string }
/** What `apple subscriptions` would do, in order (prices are refined by the command once the price points are known). */
export function appleSubscriptionPlan(groups: SubscriptionGroup[], langs: Language[], state: AppleSubState): AppleStep[] {
const steps: AppleStep[] = [];
const locale = new Map(langs.map(l => [l.id, l.apple] as const));
for (const g of groups) {
const cur = state.groups.find(x => x.referenceName === g.name);
if (!cur) steps.push({ group: g.name, kind: 'group', what: `create subscription group "${g.name}"` });
for (const [lang, l] of Object.entries(g.listings)) {
const loc = locale.get(lang);
if (loc && !cur?.locales.includes(loc)) steps.push({ group: g.name, kind: 'group-loc', what: `group ${loc}: "${l.name}"` });
}
for (const s of [...g.subscriptions].sort((a, b) => a.level - b.level)) {
const sc = state.subscriptions.find(x => x.productId === s.id);
if (sc && cur && sc.groupId !== cur.id) throw new Error(`${s.id} exists in another subscription group on App Store Connect (a subscription cannot move between groups)`);
const p = { group: g.name, product: s.id };
if (!sc) steps.push({ ...p, kind: 'sub', what: `create ${s.id} (${APPLE_PERIOD[s.period]}, level ${s.level})` });
for (const [lang, l] of Object.entries(s.listings)) {
const loc = locale.get(lang);
if (loc && !sc?.locales.includes(loc)) steps.push({ ...p, kind: 'sub-loc', what: `${s.id} ${loc}: "${l.title}"` });
}
if (!sc?.availability) steps.push({ ...p, kind: 'availability', what: `${s.id}: available in every territory + new ones` });
steps.push({ ...p, kind: 'prices', what: sc?.hasPrices ? `${s.id}: compare prices (USA ${s.usd} base)` : `${s.id}: prices from USA ${s.usd} (equalized) + ${(s.overrides ?? []).length} override(s)` });
if (s.intro && !(sc?.introTerritories)) steps.push({ ...p, kind: 'intro', what: `${s.id}: introductory offer ${APPLE_OFFER_MODE[s.intro.type]} ${introDuration(s)}${s.intro.type === 'pay-as-you-go' ? ` x${s.intro.periods}` : ''}${s.intro.usd ? ` at USA ${s.intro.usd}` : ''}` });
for (const o of s.offers ?? []) if (!sc?.promoOffers?.includes(o.id)) steps.push({ ...p, kind: 'promo', what: `${s.id}: promotional offer ${o.id} (${APPLE_OFFER_MODE[o.type]} ${offerDuration(s, o)})` });
for (const oc of s.offerCodes ?? []) if (!sc?.offerCodes?.includes(oc.name)) steps.push({ ...p, kind: 'offer-code', what: `${s.id}: offer code campaign "${oc.name}" (${oc.eligibility.join('/')}, ${APPLE_OFFER_MODE[oc.type]})${oc.customCodes?.length ? ` + ${oc.customCodes.length} custom code(s) with --codes` : ''}` });
if (s.reviewScreenshot && !sc?.reviewScreenshot) steps.push({ ...p, kind: 'review-shot', what: `${s.id}: review screenshot ${s.reviewScreenshot}` });
}
}
return steps;
}
// ---- Play ----
export interface PlayRegionPrice { regionCode: string; price: Money }
/** Regional base plan prices: converted (convertRegionPrices of the USD price), then overrides, then CSV rows. */
export function playSubRegions(s: Subscription, converted: Record<string, { regionCode: string; price: Money }>, csv: PriceRow[] = []): PlayRegionPrice[] {
const by = new Map(Object.values(converted).map(r => [r.regionCode, r.price] as const));
for (const o of s.overrides ?? []) {
if (!by.has(o.region)) throw new Error(`${s.id}: Play does not sell in region ${o.region}`);
by.set(o.region, toMoney(o.currency, o.price));
}
for (const r of csv) {
if (r.product !== s.id || r.store !== 'play' || r.kind === 'auto') continue;
const known = by.get(r.region);
if (!known) throw new Error(`${s.id}: Play does not sell in region ${r.region}`);
if (known.currencyCode !== r.currency) throw new Error(`${s.id} ${r.region}: Play prices this region in ${known.currencyCode}, the CSV says ${r.currency}`);
by.set(r.region, toMoney(r.currency, r.price));
}
return [...by.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([regionCode, price]) => ({ regionCode, price }));
}
export interface PlaySubscription {
packageName?: string;
productId: string;
listings?: { languageCode: string; title: string; description?: string; benefits?: string[] }[];
basePlans?: {
basePlanId: string; state?: string;
autoRenewingBasePlanType?: { billingPeriodDuration: string; gracePeriodDuration?: string; resubscribeState?: string; legacyCompatible?: boolean; [k: string]: unknown };
regionalConfigs?: { regionCode: string; newSubscriberAvailability?: boolean; price?: Money }[];
otherRegionsConfig?: { usdPrice: Money; eurPrice: Money; newSubscriberAvailability?: boolean };
[k: string]: unknown;
}[];
[k: string]: unknown;
}
/** Subscription resource for create / patch: listings per Play language, one auto-renewing base plan. */
export function playSubscriptionBody(pkg: string, s: Subscription, langs: Language[], regions: PlayRegionPrice[], other: { usdPrice: Money; eurPrice: Money }): PlaySubscription {
const byId = new Map(langs.map(l => [l.id, l] as const));
const listings = Object.entries(s.listings).flatMap(([id, l]) => {
const lang = byId.get(id);
return lang ? playCodes(lang).map(languageCode => ({ languageCode, title: l.title, description: l.description, ...(l.benefits?.length ? { benefits: l.benefits } : {}) })) : [];
});
if (!listings.length) throw new Error(`${s.id}: no listing language has a Play code`);
return {
packageName: pkg, productId: s.id, listings,
basePlans: [{
basePlanId: basePlanId(s),
autoRenewingBasePlanType: { billingPeriodDuration: s.period, ...(s.gracePeriod ? { gracePeriodDuration: s.gracePeriod } : {}), resubscribeState: 'RESUBSCRIBE_STATE_ACTIVE', legacyCompatible: true },
regionalConfigs: regions.map(r => ({ regionCode: r.regionCode, newSubscriberAvailability: true, price: r.price })),
otherRegionsConfig: { usdPrice: other.usdPrice, eurPrice: other.eurPrice, newSubscriberAvailability: true },
}],
};
}
/**
* An offer on the base plan: the introductory offer (for people who never had this subscription), or with `extra`
* one of `offers` (no acquisition targeting, offer tag = its id; your app decides who gets it). free = free trial phase;
* pay-as-you-go = `periods` phases at a relative discount (so it follows every regional price, overrides included);
* pay-up-front = one phase priced from convertRegionPrices of the offer price (`offerRegions`).
*/
export function playOfferBody(pkg: string, s: Subscription, regions: PlayRegionPrice[], offerRegions?: { regions: PlayRegionPrice[]; other: { usdPrice: Money; eurPrice: Money } },
extra?: SubscriptionOfferDef) {
const o: OfferLike = extra ?? s.intro!;
const codes = regions.map(r => r.regionCode);
let regionalConfigs: Record<string, unknown>[];
let otherRegionsConfig: Record<string, unknown>;
if (o.type === 'free') {
regionalConfigs = codes.map(regionCode => ({ regionCode, free: {} }));
otherRegionsConfig = { free: {} };
} else if (o.type === 'pay-as-you-go') {
const relativeDiscount = Math.round((1 - o.usd! / s.usd) * 100) / 100;
regionalConfigs = codes.map(regionCode => ({ regionCode, relativeDiscount }));
otherRegionsConfig = { relativeDiscount };
} else {
if (!offerRegions) throw new Error(`${s.id}: pay-up-front offer needs converted offer prices`);
const by = new Map(offerRegions.regions.map(r => [r.regionCode, r.price] as const));
regionalConfigs = codes.filter(c => by.has(c)).map(regionCode => ({ regionCode, price: by.get(regionCode) }));
otherRegionsConfig = { otherRegionsPrices: { usdPrice: offerRegions.other.usdPrice, eurPrice: offerRegions.other.eurPrice } };
}
return {
packageName: pkg, productId: s.id, basePlanId: basePlanId(s), offerId: extra ? extra.id : offerId(s),
phases: [{ recurrenceCount: o.type === 'pay-as-you-go' ? o.periods! : 1, duration: offerDuration(s, o), regionalConfigs, otherRegionsConfig }],
// intro: people who never had this subscription; extra offers: no targeting, your app picks them by offer tag
...(extra ? { offerTags: [{ tag: extra.id }] } : { targeting: { acquisitionRule: { scope: { thisSubscription: {} } } } }),
regionalConfigs: codes.map(regionCode => ({ regionCode, newSubscriberAvailability: true })),
otherRegionsConfig: { otherRegionsNewSubscriberAvailability: true },
};
}
const moneyKey = (m?: Money): string => (m ? `${m.currencyCode} ${cents(fromMoney(m))}` : '-');
export interface PlaySubDiff { create: boolean; listings: string[]; basePlan: 'new' | 'same' | 'changed'; priceChanges: { regionCode: string; from: string; to: string }[]; activate: boolean }
/** Current Play subscription (or null) vs the wanted body: what a patch would change. Price changes on an existing
* base plan apply to new subscribers; existing ones keep their price until you run a price migration in the Console. */
export function playSubDiff(cur: PlaySubscription | null, want: PlaySubscription): PlaySubDiff {
const wb = want.basePlans![0]!;
if (!cur) return { create: true, listings: (want.listings ?? []).map(l => l.languageCode), basePlan: 'new', priceChanges: [], activate: true };
const curL = new Map((cur.listings ?? []).map(l => [l.languageCode, l] as const));
const listings = (want.listings ?? []).filter(l => {
const c = curL.get(l.languageCode);
return !c || c.title !== l.title || (c.description ?? '') !== (l.description ?? '') || JSON.stringify(c.benefits ?? []) !== JSON.stringify(l.benefits ?? []);
}).map(l => l.languageCode);
const cb = cur.basePlans?.find(b => b.basePlanId === wb.basePlanId);
if (!cb) return { create: false, listings, basePlan: 'new', priceChanges: [], activate: true };
const cr = new Map((cb.regionalConfigs ?? []).map(r => [r.regionCode, r.price] as const));
const priceChanges = (wb.regionalConfigs ?? []).filter(r => moneyKey(cr.get(r.regionCode)) !== moneyKey(r.price))
.map(r => ({ regionCode: r.regionCode, from: moneyKey(cr.get(r.regionCode)), to: moneyKey(r.price) }));
const typeSame = cb.autoRenewingBasePlanType?.billingPeriodDuration === wb.autoRenewingBasePlanType?.billingPeriodDuration
&& (cb.autoRenewingBasePlanType?.gracePeriodDuration ?? '') === (wb.autoRenewingBasePlanType?.gracePeriodDuration ?? cb.autoRenewingBasePlanType?.gracePeriodDuration ?? '');
if (cb.autoRenewingBasePlanType && cb.autoRenewingBasePlanType.billingPeriodDuration !== wb.autoRenewingBasePlanType?.billingPeriodDuration)
throw new Error(`${want.productId}: base plan ${wb.basePlanId} bills every ${cb.autoRenewingBasePlanType.billingPeriodDuration} on Play, the config says ${wb.autoRenewingBasePlanType?.billingPeriodDuration} (a billing period cannot change; use a new basePlanId)`);
return { create: false, listings, basePlan: priceChanges.length || !typeSame ? 'changed' : 'same', priceChanges, activate: cb.state !== 'ACTIVE' };
}
/** The patch body keeps base plans the config does not manage (Play would otherwise drop them) and output-only fields out. */
export function playPatchSubscription(cur: PlaySubscription, want: PlaySubscription): PlaySubscription {
const wb = want.basePlans![0]!;
const keep = (cur.basePlans ?? []).filter(b => b.basePlanId !== wb.basePlanId).map(({ state: _s, ...b }) => b);
const curMine = cur.basePlans?.find(b => b.basePlanId === wb.basePlanId);
const { state: _st, ...mine } = { ...(curMine ?? {}), ...wb, autoRenewingBasePlanType: { ...(curMine?.autoRenewingBasePlanType ?? {}), ...wb.autoRenewingBasePlanType! } };
return { ...want, basePlans: [...keep, mine as NonNullable<PlaySubscription['basePlans']>[number]] };
}
// ---- Apple: reading responses ----
interface JsonApi { data?: { id: string; type?: string; attributes?: Record<string, unknown>; relationships?: Record<string, { data?: { id: string; type?: string } | null }> }[]; included?: { id: string; type: string; attributes?: Record<string, unknown> }[] }
/** Price points (GET /v1/subscriptions/{id}/pricePoints or .../equalizations, include=territory) -> points. */
export function applePoints(resp: JsonApi): ApplePoint[] {
return (resp.data ?? []).flatMap(p => {
const territory = p.relationships?.territory?.data?.id;
const price = Number(p.attributes?.customerPrice);
return territory && !Number.isNaN(price) ? [{ id: p.id, territory, price }] : [];
});
}
/** GET /v1/subscriptions/{id}/prices?include=subscriptionPricePoint,territory -> per territory the price in effect
* today (the latest start date not after `today`; startDate null) plus scheduled future prices (startDate set). */
export function appleSubPrices(resp: JsonApi, today = new Date().toISOString().slice(0, 10)): { territory: string; price: number; startDate: string | null; pointId: string; currency?: string }[] {
const points = new Map((resp.included ?? []).filter(x => x.type === 'subscriptionPricePoints').map(x => [x.id, Number(x.attributes?.customerPrice)] as const));
const currency = new Map((resp.included ?? []).filter(x => x.type === 'territories').map(x => [x.id, x.attributes?.currency as string | undefined] as const));
const now = new Map<string, { territory: string; price: number; startDate: string | null; pointId: string; since: string; currency?: string }>();
const future: { territory: string; price: number; startDate: string | null; pointId: string; currency?: string }[] = [];
for (const p of resp.data ?? []) {
const territory = p.relationships?.territory?.data?.id;
const pointId = p.relationships?.subscriptionPricePoint?.data?.id ?? '';
const price = points.get(pointId);
const start = (p.attributes?.startDate as string | null | undefined) ?? '';
if (!territory || price === undefined || Number.isNaN(price)) continue;
const cc = currency.get(territory);
if (start > today) { future.push({ territory, price, pointId, startDate: start, ...(cc ? { currency: cc } : {}) }); continue; }
const cur = now.get(territory);
if (!cur || start >= cur.since) now.set(territory, { territory, price, pointId, startDate: null, since: start, ...(cc ? { currency: cc } : {}) });
}
return [...[...now.values()].map(({ since: _s, ...x }) => x), ...future];
}
/** Price point with exactly this customer price (cents), or a helpful error with the nearest ones. */
export function pickPoint(points: ApplePoint[], territory: string, price: number): ApplePoint {
const inT = points.filter(p => p.territory === territory);
const hit = inT.find(p => cents(p.price) === cents(price));
if (hit) return hit;
const near = [...inT].sort((a, b) => Math.abs(a.price - price) - Math.abs(b.price - price)).slice(0, 3).map(p => p.price);
throw new Error(`${territory} has no subscription price point ${price}${near.length ? ` (nearest ${near.join(', ')})` : ''}`);
}
// ---- the prices sheet (storebound prices export) ----
/** App Store prices of one subscription (in effect today + scheduled) as price sheet rows. Pure. */
export function appleSubPriceRows(product: string, resp: JsonApi, today?: string): PriceRow[] {
return appleSubPrices(resp, today).map(p => ({ product, store: 'apple' as const, region: alpha2(p.territory), territory: p.territory, currency: p.currency ?? '', price: p.price, ...(p.startDate ? { startDate: p.startDate } : {}) }));
}
/** Google Play regional prices of one subscription's base plan as price sheet rows. Pure. */
export function playSubPriceRows(sub: PlaySubscription, basePlan: string): PriceRow[] {
const bp = sub.basePlans?.find(b => b.basePlanId === basePlan) ?? sub.basePlans?.[0];
return (bp?.regionalConfigs ?? []).filter(r => r.price).map(r => ({ product: sub.productId, store: 'play' as const, region: r.regionCode, currency: r.price!.currencyCode, price: fromMoney(r.price!) }));
}