storebound/cli/lib/catalog.ts

147 lines
9.6 KiB
TypeScript

// 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:<name>" 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<Store, string[]>;
offers: Record<Store, string[]>;
}
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<string, string> = { ONE_WEEK: 'P1W', ONE_MONTH: 'P1M', TWO_MONTHS: 'P2M', THREE_MONTHS: 'P3M', SIX_MONTHS: 'P6M', ONE_YEAR: 'P1Y' };
const APPLE_KIND: Record<string, Kind> = { 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;