// 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 = { 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 = { 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 = { 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; /** 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, productIds: Set): string[] { const e: string[] = []; if (groups === undefined) return e; if (!Array.isArray(groups)) return ['subscriptions: a list of groups']; const seen = new Set(); const groupNames = new Set(); 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(); 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(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(); 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 { const out = new Map([['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, 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: what exists vs the config (drift) ---- export interface AppleSubAttrs { name?: string; subscriptionPeriod?: string; familySharable?: boolean; reviewNote?: string | null; groupLevel?: number } /** `patch` = attributes PATCH /v1/subscriptions/{id} can change, `text` = lines for those, `report` = differences Apple cannot change. */ export interface AppleAttrDiff { patch: Record; text: string[]; report: string[] } /** Subscription attributes on App Store Connect vs the config. Name (reference name), review note and group level are * patched; the period and family sharing cannot change after creation and are only reported. An attribute the read * did not return is left alone. Pure. */ export function appleSubAttrDiff(s: Subscription, cur: AppleSubAttrs): AppleAttrDiff { const out: AppleAttrDiff = { patch: {}, text: [], report: [] }; const want = APPLE_PERIOD[s.period]; if (cur.subscriptionPeriod !== undefined && cur.subscriptionPeriod !== want) out.report.push(`${s.id}: period is ${cur.subscriptionPeriod} on App Store Connect, the config says ${want} (a period cannot change; use a new product id)`); const fam = s.familySharable ?? false; if (cur.familySharable !== undefined && cur.familySharable !== fam) out.report.push(`${s.id}: familySharable is ${cur.familySharable} on App Store Connect, the config says ${fam} (cannot be changed through the API)`); const name = s.referenceName ?? Object.values(s.listings)[0]?.title; if (name !== undefined && cur.name !== undefined && cur.name !== name) { out.patch.name = name; out.text.push(`reference name "${cur.name}" -> "${name}"`); } if (s.reviewNote !== undefined && cur.reviewNote !== undefined && (cur.reviewNote ?? '') !== s.reviewNote) { out.patch.reviewNote = s.reviewNote; out.text.push('review note changed'); } if (cur.groupLevel !== undefined && cur.groupLevel !== s.level) { out.patch.groupLevel = s.level; out.text.push(`level ${cur.groupLevel} -> ${s.level}`); } return out; } export const appleSubPatchBody = (subId: string, attributes: Record) => ({ data: { type: 'subscriptions', id: subId, attributes } }); /** Subscription localization text vs the config: the attributes to PATCH, or null when equal (or not returned). Pure. */ export function appleLocDiff(want: { title: string; description: string }, cur: { name?: string; description?: string }): Record | null { const patch: Record = {}; if (cur.name !== undefined && cur.name !== want.title) patch.name = want.title; if (cur.description !== undefined && cur.description !== want.description) patch.description = want.description; return Object.keys(patch).length ? patch : null; } export const appleLocPatchBody = (locId: string, attributes: Record) => ({ data: { type: 'subscriptionLocalizations', id: locId, attributes } }); /** Subscription group localization (name, customAppName when configured) vs the config. Pure. */ export function appleGroupLocDiff(want: { name: string; customAppName?: string }, cur: { name?: string; customAppName?: string | null }): Record | null { const patch: Record = {}; if (cur.name !== undefined && cur.name !== want.name) patch.name = want.name; if (want.customAppName !== undefined && (cur.customAppName ?? '') !== want.customAppName) patch.customAppName = want.customAppName; return Object.keys(patch).length ? patch : null; } export const appleGroupLocPatchBody = (locId: string, attributes: Record) => ({ data: { type: 'subscriptionGroupLocalizations', id: locId, attributes } }); /** Subscription availability vs "every territory + new ones": territories missing, availableInNewTerritories off. null = same. Pure. */ export function appleAvailabilityDiff(cur: { territories: string[]; availableInNewTerritories?: boolean }, wanted: string[]): { missing: string[]; newTerritories: boolean } | null { const have = new Set(cur.territories); const missing = wanted.filter(t => !have.has(t)); const newTerritories = cur.availableInNewTerritories === false; return missing.length || newTerritories ? { missing, newTerritories } : null; } export interface AppleIntroOffer { territory: string; mode?: string; duration?: string; periods?: number; price?: number } /** GET /v1/subscriptions/{id}/introductoryOffers?include=subscriptionPricePoint,territory -> offers per territory. */ export function appleIntroOffers(resp: JsonApi): AppleIntroOffer[] { const points = new Map((resp.included ?? []).filter(x => x.type === 'subscriptionPricePoints').map(x => [x.id, Number(x.attributes?.customerPrice)] as const)); return (resp.data ?? []).map(o => { const pid = o.relationships?.subscriptionPricePoint?.data?.id; const price = pid ? points.get(pid) : undefined; return { territory: o.relationships?.territory?.data?.id ?? '', mode: o.attributes?.offerMode as string | undefined, duration: o.attributes?.duration as string | undefined, periods: o.attributes?.numberOfPeriods as number | undefined, ...(price !== undefined && !Number.isNaN(price) ? { price } : {}), }; }); } /** Introductory offers on App Store Connect vs the config (Apple cannot PATCH them: report only), as `intro differs: ...` * lines. A subscription without any offer yet is created by the run, not a difference. Pure. */ export function appleIntroDiff(s: Subscription, cur: AppleIntroOffer[]): string[] { if (!cur.length) return []; if (!s.intro) return [`intro differs: ${s.id} has an introductory offer on App Store Connect, the config has none`]; const o = s.intro; const out: string[] = []; const distinct = (f: (x: AppleIntroOffer) => T | undefined): T[] => [...new Set(cur.map(f).filter((x): x is T => x !== undefined))]; const check = (what: string, have: (string | number)[], want: string | number) => { if (have.some(h => h !== want)) out.push(`intro differs: ${s.id} ${what} ${have.join('/')} on App Store Connect, ${want} in the config`); }; check('mode', distinct(x => x.mode), APPLE_OFFER_MODE[o.type]); check('duration', distinct(x => x.duration), APPLE_OFFER_DURATION[introDuration(s)] ?? introDuration(s)); check('periods', distinct(x => x.periods), o.type === 'pay-as-you-go' ? o.periods ?? 1 : 1); if (o.type !== 'free' && o.usd !== undefined) { const usa = cur.find(x => x.territory === 'USA')?.price; if (usa !== undefined && cents(usa) !== cents(o.usd)) out.push(`intro differs: ${s.id} price USA ${usa} on App Store Connect, ${o.usd} in the config`); } 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, 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[]; let otherRegionsConfig: Record; 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; /** Languages on Play that the config does not list (a PATCH drops them only with --prune-languages). Set only when there are some. */ listingsRemoved?: string[]; /** Grace period on Play vs the config. Set only when it differs. */ grace?: { from: string; to: string }; /** Regions sold on Play that the config no longer lists (also one "not sold" row in priceChanges, owner-gated). */ droppedRegions?: string[]; } /** 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. * Money (regional prices, the other-regions USD / EUR price, regions no longer sold) all land in `priceChanges`, so * the price gate holds them; text (listings, grace period) is its own field. */ 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 wantCodes = new Set((want.listings ?? []).map(l => l.languageCode)); const removed = [...curL.keys()].filter(c => !wantCodes.has(c)); const extras = removed.length ? { listingsRemoved: removed } : {}; const cb = cur.basePlans?.find(b => b.basePlanId === wb.basePlanId); if (!cb) return { create: false, listings, basePlan: 'new', priceChanges: [], activate: true, ...extras }; 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 wantRegions = new Set((wb.regionalConfigs ?? []).map(r => r.regionCode)); const gone = [...cr.keys()].filter(c => !wantRegions.has(c)); for (const c of gone) priceChanges.push({ regionCode: c, from: moneyKey(cr.get(c)), to: 'not sold' }); const co = cb.otherRegionsConfig, wo = wb.otherRegionsConfig; if (wo && (moneyKey(co?.usdPrice) !== moneyKey(wo.usdPrice) || moneyKey(co?.eurPrice) !== moneyKey(wo.eurPrice))) priceChanges.push({ regionCode: 'other regions', from: `${moneyKey(co?.usdPrice)} / ${moneyKey(co?.eurPrice)}`, to: `${moneyKey(wo.usdPrice)} / ${moneyKey(wo.eurPrice)}` }); const curGrace = cb.autoRenewingBasePlanType?.gracePeriodDuration ?? ''; const wantGrace = wb.autoRenewingBasePlanType?.gracePeriodDuration ?? ''; // a config without a grace period leaves the one on Play alone const graceChanged = wantGrace !== '' && wantGrace !== curGrace; 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 || graceChanged ? 'changed' : 'same', priceChanges, activate: cb.state !== 'ACTIVE', ...extras, ...(graceChanged ? { grace: { from: curGrace || '-', to: wantGrace } } : {}), ...(gone.length ? { droppedRegions: gone } : {}), }; } /** The patch body keeps base plans the config does not manage (Play would otherwise drop them) and output-only fields out. * Languages on Play that the config does not list stay in the body unless `pruneLanguages`. */ export function playPatchSubscription(cur: PlaySubscription, want: PlaySubscription, o: { pruneLanguages?: boolean } = {}): 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! } }; const wantCodes = new Set((want.listings ?? []).map(l => l.languageCode)); const kept = o.pruneLanguages ? [] : (cur.listings ?? []).filter(l => !wantCodes.has(l.languageCode)); return { ...want, ...(kept.length ? { listings: [...(want.listings ?? []), ...kept] } : {}), basePlans: [...keep, mine as NonNullable[number]] }; } /** The text side of a Play subscription diff (no money; empty = nothing). Pure. */ export function playSubTextChanges(d: PlaySubDiff, prune = false): string[] { return [ d.listings.length ? `listings ${d.listings.join(', ')}` : '', d.listingsRemoved?.length ? (prune ? `listings removed: ${d.listingsRemoved.join(', ')}` : `listings removed from the config: ${d.listingsRemoved.join(', ')} (kept on Play; --prune-languages removes them)`) : '', d.grace ? `grace ${d.grace.from} -> ${d.grace.to}` : '', ].filter(Boolean); } // ---- Play: offers already on the base plan ---- export interface PlayOfferLike { offerId?: string; state?: string; phases?: { recurrenceCount?: number; duration?: string; regionalConfigs?: { regionCode: string; free?: unknown; relativeDiscount?: number; absoluteDiscount?: Money; price?: Money }[]; otherRegionsConfig?: { free?: unknown; relativeDiscount?: number; absoluteDiscount?: unknown; otherRegionsPrices?: { usdPrice?: Money; eurPrice?: Money } } }[]; [k: string]: unknown; } type Entry = Record | undefined; /** "free", "-50%", "-USD 100" or "USD 299": the kind (free / discount / price) and the shown value of one phase entry. */ function phaseEntry(c: Entry): { kind: string; text: string } { if (!c) return { kind: '-', text: '-' }; if (c.free !== undefined) return { kind: 'free', text: 'free' }; if (typeof c.relativeDiscount === 'number') return { kind: 'discount', text: `-${Math.round(c.relativeDiscount * 100)}%` }; if (c.absoluteDiscount) return { kind: 'absolute', text: `-${moneyKey(c.absoluteDiscount as Money)}` }; if (c.price) return { kind: 'price', text: moneyKey(c.price as Money) }; const o = c.otherRegionsPrices as { usdPrice?: Money; eurPrice?: Money } | undefined; if (o) return { kind: 'price', text: `${moneyKey(o.usdPrice)} / ${moneyKey(o.eurPrice)}` }; return { kind: '-', text: '-' }; } export interface PlayOfferDrift { phase: string[]; prices: { region: string; from: string; to: string }[] } /** Offer on Play vs the offer the config would create: `phase` = free / paid kind, duration, periods (text), `prices` = * per region the discount, price or other-regions price (money: only behind the price gate). Pure. */ export function playOfferDiff(cur: PlayOfferLike, want: PlayOfferLike): PlayOfferDrift { const out: PlayOfferDrift = { phase: [], prices: [] }; const cp = cur.phases ?? [], wp = want.phases ?? []; if (cp.length !== wp.length) out.phase.push(`${cp.length} phase(s) -> ${wp.length}`); for (const [i, w] of wp.entries()) { const c = cp[i]; if (!c) continue; const at = wp.length > 1 ? `phase ${i + 1} ` : ''; if ((c.duration ?? '') !== (w.duration ?? '')) out.phase.push(`${at}duration ${c.duration ?? '-'} -> ${w.duration ?? '-'}`); if ((c.recurrenceCount ?? 1) !== (w.recurrenceCount ?? 1)) out.phase.push(`${at}periods ${c.recurrenceCount ?? 1} -> ${w.recurrenceCount ?? 1}`); const cm = new Map((c.regionalConfigs ?? []).map(r => [r.regionCode, r as Entry] as const)); const rows: [string, Entry, Entry][] = (w.regionalConfigs ?? []).map(r => [r.regionCode, cm.get(r.regionCode), r as Entry]); rows.push(['other regions', c.otherRegionsConfig as Entry, w.otherRegionsConfig as Entry]); for (const [region, cc, wc] of rows) { const a = phaseEntry(cc), b = phaseEntry(wc); if (a.text === b.text) continue; if (a.kind !== b.kind && a.kind !== '-') out.phase.push(`${at}${region} ${a.text} -> ${b.text}`); else out.prices.push({ region, from: a.text, to: b.text }); } } return out; } // ---- Apple: reading responses ---- interface JsonApi { data?: { id: string; type?: string; attributes?: Record; relationships?: Record }[]; included?: { id: string; type: string; attributes?: Record }[] } /** 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(); 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!) })); }