// Cross-store catalog parity, as pure parts: one product definition (products + subscriptions in the config) against // what exists on the App Store and on Google Play. `storebound catalog diff` collects the store side (read-only) and // prints these rows. Kinds compared: Apple CONSUMABLE / NON_CONSUMABLE / NON_RENEWING_SUBSCRIPTION / auto-renewable // subscriptions; Play one-time products (every non-subscription kind) and subscriptions (base plan billing period). import { type ShipConfig, playCodes, type ProductType, sellsOn, type Platform } from './config'; import { ALPHA3 } from './money'; import { allSubscriptions, offerId } from './subscriptions'; export type Kind = ProductType | 'subscription'; export type Store = 'apple' | 'play'; /** One product as a store has it. Prices: what applies today, one per region (ISO alpha-2), in the local currency. */ export interface StoreItem { id: string; /** Apple: the IAP type or subscription; Play: 'one_time' or 'subscription'. */ kind: Kind | 'one_time'; state?: string; /** Subscriptions: ISO period (P1M ...); Apple level inside the group. */ period?: string; level?: number; locales: string[]; prices: { region: string; currency: string; price: number }[]; /** Offer ids (Play offer ids; Apple: "intro" when there is an introductory offer, promotional offer codes, * "code:" for offer code campaigns). */ offers?: string[]; /** Apple in-app purchases: the price schedule's base territory (region, currency) and its price today. */ base?: { region: string; currency: string; price: number }; /** The store's own USD price for regions without one of their own (Play newRegionsConfig / otherRegionsConfig). */ usd?: number; /** Apple subscriptions: the group's reference name. */ group?: string; } export interface WantItem { id: string; kind: Kind; stores: Store[]; period?: string; level?: number; usd: number; overrides: { region: string; currency: string; price: number }[]; locales: Record; offers: Record; } export interface CatalogRow { id: string; store: Store; level: 'error' | 'warn' | 'ok'; code: string; msg: string } export interface ParityRow { id: string; region: string; currency: string; apple: number; play: number; diffPct: number } const APPLE_PERIOD: Record = { ONE_WEEK: 'P1W', ONE_MONTH: 'P1M', TWO_MONTHS: 'P2M', THREE_MONTHS: 'P3M', SIX_MONTHS: 'P6M', ONE_YEAR: 'P1Y' }; const APPLE_KIND: Record = { CONSUMABLE: 'consumable', NON_CONSUMABLE: 'non_consumable', NON_RENEWING_SUBSCRIPTION: 'non_renewing' }; /** App Store Connect subscriptionPeriod / inAppPurchaseType -> catalog values. */ export const fromApplePeriod = (p: string | undefined): string | undefined => (p ? APPLE_PERIOD[p] ?? p : undefined); export const fromAppleType = (t: string | undefined): Kind | 'one_time' => APPLE_KIND[t ?? ''] ?? 'one_time'; /** The config's catalog per store (products + subscriptions, `platforms` respected). */ export function wantedCatalog(config: ShipConfig): WantItem[] { const langs = config.languages; const appleLoc = (ids: string[]) => ids.map(id => langs.find(l => l.id === id)?.apple).filter((x): x is string => !!x); const playLoc = (ids: string[]) => ids.flatMap(id => { const l = langs.find(x => x.id === id); return l ? playCodes(l) : []; }); const stores = (item: { platforms?: Platform[] }): Store[] => (['apple', 'play'] as const).filter(s => sellsOn(config, item, s)); const out: WantItem[] = []; for (const p of config.products ?? []) { const ids = Object.keys(p.listings); out.push({ id: p.id, kind: p.type, stores: stores(p), usd: p.usd, overrides: (p.overrides ?? []).map(o => ({ region: o.region, currency: o.currency, price: o.price })), locales: { apple: appleLoc(ids), play: playLoc(ids) }, offers: { apple: [], play: [] } }); } for (const { sub } of allSubscriptions(config.subscriptions)) { const ids = Object.keys(sub.listings); out.push({ id: sub.id, kind: 'subscription', stores: stores(sub), period: sub.period, level: sub.level, usd: sub.usd, overrides: (sub.overrides ?? []).map(o => ({ region: o.region, currency: o.currency, price: o.price })), locales: { apple: appleLoc(ids), play: playLoc(ids) }, offers: { apple: [...(sub.intro ? ['intro'] : []), ...(sub.offers ?? []).map(o => o.id), ...(sub.offerCodes ?? []).map(o => `code:${o.name}`)], play: [...(sub.intro ? [offerId(sub)] : []), ...(sub.offers ?? []).map(o => o.id)], } }); } return out; } const cents = (n: number) => Math.round(n * 100); const fmt = (n: number) => (Math.round(n * 100) / 100).toFixed(2); /** * Config vs both stores. `state.apple` / `state.play` null = that store was not read (no section / --store). Rows: * missing (error), extra on the store (warn), kind / billing period (error), Apple level (warn), base price (US) and * override regions (error), missing localizations and offers (warn). Parity: regions both stores price in the same * currency, where the prices differ by more than `threshold` (default 10 %). */ export function catalogDiff(want: WantItem[], state: { apple: StoreItem[] | null; play: StoreItem[] | null }, o: { threshold?: number } = {}): { rows: CatalogRow[]; parity: ParityRow[] } { const rows: CatalogRow[] = []; const parity: ParityRow[] = []; const add = (id: string, store: Store, level: CatalogRow['level'], code: string, msg: string) => rows.push({ id, store, level, code, msg }); const threshold = o.threshold ?? 0.1; for (const store of ['apple', 'play'] as const) { const items = state[store]; if (!items) continue; const have = new Map(items.map(i => [i.id, i] as const)); for (const w of want.filter(x => x.stores.includes(store))) { const h = have.get(w.id); if (!h) { add(w.id, store, 'error', 'missing', `not on ${store === 'apple' ? 'App Store Connect' : 'Google Play'} (storebound ${store} ${w.kind === 'subscription' ? 'subscriptions' : store === 'apple' ? 'iap' : 'products'})`); continue; } const kindOk = store === 'play' ? (w.kind === 'subscription') === (h.kind === 'subscription') : h.kind === w.kind; if (!kindOk) { add(w.id, store, 'error', 'kind', `${h.kind} on the store, ${w.kind} in the config (a product type cannot change: use a new id)`); continue; } if (w.period && h.period && w.period !== h.period) add(w.id, store, 'error', 'period', `bills every ${h.period} on the store, ${w.period} in the config`); if (store === 'apple' && w.level !== undefined && h.level !== undefined && w.level !== h.level) add(w.id, store, 'warn', 'level', `group level ${h.level} on the store, ${w.level} in the config`); const price = new Map(h.prices.map(p => [p.region, p] as const)); const us = price.get('US'); if (!h.prices.length) add(w.id, store, 'error', 'price', 'no prices on the store'); else if (!us) add(w.id, store, 'warn', 'price', 'no US price on the store'); else if (cents(us.price) !== cents(w.usd)) add(w.id, store, 'error', 'price', `US ${fmt(us.price)} USD on the store, ${fmt(w.usd)} in the config`); for (const ov of w.overrides) { const p = price.get(ov.region); if (!p) add(w.id, store, 'error', 'price', `${ov.region}: no price on the store, ${ov.price} ${ov.currency} in the config`); else if (p.currency !== ov.currency || cents(p.price) !== cents(ov.price)) add(w.id, store, 'error', 'price', `${ov.region}: ${fmt(p.price)} ${p.currency} on the store, ${fmt(ov.price)} ${ov.currency} in the config`); } const missingLoc = w.locales[store].filter(l => !h.locales.includes(l)); if (missingLoc.length) add(w.id, store, 'warn', 'locale', `no ${store === 'apple' ? 'localization' : 'listing'} for ${missingLoc.join(', ')}`); const missingOffers = w.offers[store].filter(x => !(h.offers ?? []).includes(x)); if (missingOffers.length) add(w.id, store, 'warn', 'offer', `offers missing: ${missingOffers.join(', ')}`); if (!rows.some(r => r.id === w.id && r.store === store)) add(w.id, store, 'ok', 'ok', `${w.kind}${w.period ? ` ${w.period}` : ''}, ${h.prices.length} region prices${h.state ? `, ${h.state}` : ''}`); } const known = new Set(want.map(w => w.id)); for (const h of items) if (!known.has(h.id)) add(h.id, store, 'warn', 'extra', `on the store but not in the config (${h.kind}${h.state ? `, ${h.state}` : ''})`); } if (state.apple && state.play) { const a = new Map(state.apple.map(i => [i.id, i] as const)); const p = new Map(state.play.map(i => [i.id, i] as const)); for (const w of want) { const ai = a.get(w.id), pi = p.get(w.id); if (!ai || !pi) continue; const pp = new Map(pi.prices.map(x => [x.region, x] as const)); for (const ap of ai.prices) { const q = pp.get(ap.region); if (!q || q.currency !== ap.currency || !(ap.price > 0) || !(q.price > 0)) continue; const diff = Math.abs(ap.price - q.price) / Math.max(ap.price, q.price); if (diff > threshold) parity.push({ id: w.id, region: ap.region, currency: ap.currency, apple: ap.price, play: q.price, diffPct: Math.round(diff * 1000) / 10 }); } } } const rank = { error: 0, warn: 1, ok: 2 } as const; rows.sort((x, y) => rank[x.level] - rank[y.level] || x.id.localeCompare(y.id) || x.store.localeCompare(y.store)); parity.sort((x, y) => y.diffPct - x.diffPct || x.id.localeCompare(y.id) || x.region.localeCompare(y.region)); return { rows, parity }; } /** Apple territory (alpha-3) -> region (alpha-2). */ const A2 = Object.fromEntries(Object.entries(ALPHA3).map(([a2, a3]) => [a3, a2])); export const regionOf = (territory: string): string => A2[territory] ?? territory;