307 lines
20 KiB
TypeScript
307 lines
20 KiB
TypeScript
// RevenueCat catalog mirror, as pure parts: the config catalog (products + subscriptions) -> the RevenueCat project
|
|
// state it implies (products per store app, entitlements with attached products, one offering with packages), the
|
|
// parity diff against what RevenueCat has, and the create / attach plan `storebound revenuecat sync` runs. No IO.
|
|
// storebound only mirrors the catalog. Runtime entitlements, receipt and purchase token validation, customer info,
|
|
// webhooks, paywalls, store server notifications and the store credentials inside RevenueCat stay RevenueCat's job.
|
|
// Sources (checked October 2026):
|
|
// REST API v2 overview (base URL, Bearer v2 secret keys, pagination: items / next_page / starting_after / limit,
|
|
// rate limits: 429 + Retry-After seconds, 60 requests per minute for project configuration)
|
|
// https://www.revenuecat.com/docs/api-v2
|
|
// Products: store_identifier (Apple: product id; Play: "<productId>:<basePlanId>" for subscriptions, the product id
|
|
// for one-time products), app_id, type (subscription | one_time | consumable | non_consumable |
|
|
// non_renewing_subscription), display_name https://www.revenuecat.com/docs/api-v2/product
|
|
// Entitlements: lookup_key, display_name, actions/attach_products { product_ids }, GET .../{id}/products
|
|
// https://www.revenuecat.com/docs/api-v2/entitlement
|
|
// Offerings: lookup_key, display_name; is_current only on update (POST /offerings/{id})
|
|
// https://www.revenuecat.com/docs/api-v2/offering
|
|
// Packages: lookup_key, display_name, position; actions/attach_products { products: [{ product_id,
|
|
// eligibility_criteria: all | google_sdk_lt_6 | google_sdk_ge_6 }] } https://www.revenuecat.com/docs/api-v2/package
|
|
// Apps: type app_store (app_store.bundle_id) | play_store (play_store.package_name)
|
|
// https://www.revenuecat.com/docs/api-v2/app
|
|
// Reserved package identifiers ($rc_weekly ... $rc_annual, $rc_lifetime): PackageType of the RevenueCat SDK
|
|
// https://github.com/RevenueCat/purchases-ios/blob/main/Sources/Purchasing/PackageType.swift
|
|
import { type ProductType, sellsOn, type ShipConfig, type SubscriptionPeriod } from './config';
|
|
import { allSubscriptions, basePlanId } from './subscriptions';
|
|
|
|
export type RcStore = 'apple' | 'play';
|
|
export type RcProductType = 'subscription' | 'one_time' | 'consumable' | 'non_consumable' | 'non_renewing_subscription';
|
|
|
|
/** What stays outside storebound (command header, diff footer, skill). */
|
|
export const RC_SCOPE = 'storebound mirrors the catalog only (products, entitlements, one offering). RevenueCat keeps: runtime entitlements, ' +
|
|
'receipt / purchase token validation, customer info, webhooks, paywalls, store server notifications setup, and the App Store / Play ' +
|
|
'credentials inside RevenueCat (in-app purchase key, service account).';
|
|
|
|
/** Reserved package identifiers by billing period (RevenueCat SDK PackageType). */
|
|
export const RC_PACKAGE: Record<SubscriptionPeriod, string> = {
|
|
P1W: '$rc_weekly', P1M: '$rc_monthly', P2M: '$rc_two_month', P3M: '$rc_three_month', P6M: '$rc_six_month', P1Y: '$rc_annual',
|
|
};
|
|
export const RC_LIFETIME = '$rc_lifetime';
|
|
const PACKAGE_NAME: Record<string, string> = {
|
|
$rc_weekly: 'Weekly', $rc_monthly: 'Monthly', $rc_two_month: 'Two months', $rc_three_month: 'Three months',
|
|
$rc_six_month: 'Six months', $rc_annual: 'Annual', $rc_lifetime: 'Lifetime',
|
|
};
|
|
|
|
/** RevenueCat product type per store. Play has no non-renewing type: config.ts sells it as a one-time product the
|
|
* app consumes when the access ends, so it is a consumable there. */
|
|
export function rcType(kind: ProductType | 'subscription', store: RcStore): RcProductType {
|
|
if (kind === 'subscription') return 'subscription';
|
|
if (kind === 'non_renewing') return store === 'apple' ? 'non_renewing_subscription' : 'consumable';
|
|
return kind;
|
|
}
|
|
|
|
export interface WantProduct { key: string; configId: string; store: RcStore; storeIdentifier: string; type: RcProductType; displayName: string }
|
|
export interface WantEntitlement { lookupKey: string; displayName: string; products: string[] }
|
|
export interface WantPackage { lookupKey: string; displayName: string; position: number; configId: string; products: string[] }
|
|
export interface RcWanted {
|
|
products: WantProduct[];
|
|
entitlements: WantEntitlement[];
|
|
offering: { lookupKey: string; displayName: string; packages: WantPackage[] };
|
|
problems: string[];
|
|
}
|
|
|
|
/** Product key used across the plan: "<store>:<store_identifier>". */
|
|
export const productKey = (store: RcStore, storeIdentifier: string): string => `${store}:${storeIdentifier}`;
|
|
|
|
/** The RevenueCat state the config implies. `platforms` on an item and app.platforms are respected. */
|
|
export function wantedRevenueCat(config: ShipConfig): RcWanted {
|
|
const rc = config.revenuecat;
|
|
const problems: string[] = [];
|
|
const products: WantProduct[] = [];
|
|
const byConfigId = new Map<string, { kind: ProductType | 'subscription'; keys: string[]; title: string; period?: SubscriptionPeriod }>();
|
|
const add = (id: string, kind: ProductType | 'subscription', title: string, item: { platforms?: ('ios' | 'android')[] }, playId: string, period?: SubscriptionPeriod) => {
|
|
const keys: string[] = [];
|
|
for (const store of ['apple', 'play'] as const) {
|
|
if (!sellsOn(config, item, store)) continue;
|
|
const storeIdentifier = store === 'apple' ? id : playId;
|
|
const key = productKey(store, storeIdentifier);
|
|
products.push({ key, configId: id, store, storeIdentifier, type: rcType(kind, store), displayName: title });
|
|
keys.push(key);
|
|
}
|
|
byConfigId.set(id, { kind, keys, title, period });
|
|
};
|
|
const firstTitle = (l: Record<string, { title: string }>, id: string) => Object.values(l)[0]?.title || id;
|
|
for (const p of config.products ?? []) add(p.id, p.type, firstTitle(p.listings, p.id), p, p.id);
|
|
const subs = allSubscriptions(config.subscriptions).map(x => x.sub);
|
|
for (const s of subs) add(s.id, 'subscription', firstTitle(s.listings, s.id), s, `${s.id}:${basePlanId(s)}`, s.period);
|
|
|
|
const unlocks = [...(config.products ?? []).filter(p => p.type !== 'consumable').map(p => p.id), ...subs.map(s => s.id)];
|
|
const ents = rc?.entitlements ?? (unlocks.length ? { pro: unlocks } : {});
|
|
const entitlements: WantEntitlement[] = [];
|
|
const inEntitlement = new Set<string>();
|
|
for (const [lookupKey, ids] of Object.entries(ents)) {
|
|
const keys: string[] = [];
|
|
for (const id of ids) {
|
|
const it = byConfigId.get(id);
|
|
if (!it) { problems.push(`revenuecat.entitlements.${lookupKey}: "${id}" is not a product or subscription in the config`); continue; }
|
|
if (it.kind === 'consumable') { problems.push(`revenuecat.entitlements.${lookupKey}: "${id}" is a consumable; consumables do not unlock an entitlement`); continue; }
|
|
inEntitlement.add(id);
|
|
keys.push(...it.keys);
|
|
}
|
|
entitlements.push({ lookupKey, displayName: lookupKey, products: keys });
|
|
}
|
|
|
|
const packages: WantPackage[] = [];
|
|
const used = new Set<string>();
|
|
const pkg = (lookupKey: string, configId: string) => {
|
|
const it = byConfigId.get(configId)!;
|
|
if (!it.keys.length) return;
|
|
used.add(lookupKey);
|
|
packages.push({ lookupKey, displayName: PACKAGE_NAME[lookupKey] ?? it.title, position: packages.length + 1, configId, products: it.keys });
|
|
};
|
|
for (const s of subs) pkg(used.has(RC_PACKAGE[s.period]) ? s.id : RC_PACKAGE[s.period], s.id);
|
|
for (const p of config.products ?? []) {
|
|
const lifetime = p.type === 'non_consumable' && inEntitlement.has(p.id) && !used.has(RC_LIFETIME) && byConfigId.get(p.id)!.keys.length > 0;
|
|
pkg(lifetime ? RC_LIFETIME : p.id, p.id);
|
|
}
|
|
const lk = rc?.offering ?? 'default';
|
|
return { products, entitlements, offering: { lookupKey: lk, displayName: lk === 'default' ? 'Default' : lk, packages }, problems };
|
|
}
|
|
|
|
// ---- RevenueCat side (as read by the command) ----
|
|
|
|
export interface RcApp { id: string; name?: string; type: string; app_store?: { bundle_id?: string } | null; play_store?: { package_name?: string } | null }
|
|
export interface RcProduct { id: string; store_identifier: string; type: string; app_id: string; display_name?: string | null; state?: string }
|
|
export interface RcEntitlement { id: string; lookup_key: string; display_name?: string; state?: string; productIds: string[] }
|
|
export interface RcPackage { id: string; lookup_key: string; display_name?: string; position?: number | null; products: { product_id: string; eligibility_criteria?: string }[] }
|
|
export interface RcOffering { id: string; lookup_key: string; display_name?: string; is_current?: boolean; state?: string; packages: RcPackage[] }
|
|
export interface RcState { products: RcProduct[]; entitlements: RcEntitlement[]; offerings: RcOffering[] }
|
|
export type RcAppIds = Partial<Record<RcStore, string>>;
|
|
|
|
/** App ids from revenuecat.apps, else the project's App Store app with the bundle id / Play app with the package name. */
|
|
export function resolveApps(config: ShipConfig, apps: RcApp[] | null): { ids: RcAppIds; problems: string[] } {
|
|
const ids: RcAppIds = {};
|
|
const problems: string[] = [];
|
|
const set = config.revenuecat?.apps ?? {};
|
|
const want: [RcStore, string | undefined, string, (a: RcApp) => boolean][] = [
|
|
['apple', config.apple?.bundleId, 'App Store', a => a.type === 'app_store' && a.app_store?.bundle_id === config.apple?.bundleId],
|
|
['play', config.play?.packageName, 'Play Store', a => a.type === 'play_store' && a.play_store?.package_name === config.play?.packageName],
|
|
];
|
|
for (const [store, ident, label, match] of want) {
|
|
if (!sellsOn(config, {}, store)) continue;
|
|
if (set[store]) { ids[store] = set[store]; continue; }
|
|
const hit = (apps ?? []).filter(match);
|
|
if (hit.length === 1) ids[store] = hit[0]!.id;
|
|
else if (hit.length > 1) problems.push(`${hit.length} ${label} apps in the project for ${ident}: set revenuecat.apps.${store}`);
|
|
else problems.push(`no ${label} app for ${ident ?? '(no id in the config)'} in the RevenueCat project: add it in RevenueCat (Project settings > Apps) or set revenuecat.apps.${store}`);
|
|
}
|
|
return { ids, problems };
|
|
}
|
|
|
|
// ---- diff ----
|
|
|
|
export type RcSection = 'app' | 'config' | 'product' | 'entitlement' | 'offering' | 'package';
|
|
export type RcStatus = 'ok' | 'missing' | 'mismatch' | 'extra' | 'info';
|
|
export interface RcRow { section: RcSection; key: string; status: RcStatus; msg: string }
|
|
|
|
/** Rows that make the project out of sync (exit 1): missing and mismatched. Extras are left alone and do not count. */
|
|
export const outOfSync = (rows: RcRow[]): number => rows.filter(r => r.status === 'missing' || r.status === 'mismatch').length;
|
|
|
|
interface Index { byKey: Map<string, RcProduct>; keyOf: Map<string, string>; storeOfApp: Map<string, RcStore> }
|
|
function index(state: RcState, apps: RcAppIds): Index {
|
|
const storeOfApp = new Map<string, RcStore>(Object.entries(apps).map(([s, id]) => [id!, s as RcStore]));
|
|
const byKey = new Map<string, RcProduct>();
|
|
const keyOf = new Map<string, string>();
|
|
for (const p of state.products) {
|
|
const s = storeOfApp.get(p.app_id);
|
|
if (!s) continue;
|
|
const k = productKey(s, p.store_identifier);
|
|
byKey.set(k, p); keyOf.set(p.id, k);
|
|
}
|
|
return { byKey, keyOf, storeOfApp };
|
|
}
|
|
const isSub = (t: string) => t === 'subscription';
|
|
/** RevenueCat type vs wanted: "one_time" (the generic one-time type) fits any one-time kind. */
|
|
const typeFits = (want: RcProductType, have: string) => have === want || (have === 'one_time' && !isSub(want));
|
|
|
|
/** Config vs RevenueCat. Products are only compared on the resolved store apps (other apps, e.g. web, are ignored). */
|
|
export function rcDiff(want: RcWanted, state: RcState, apps: RcAppIds, appProblems: string[] = []): RcRow[] {
|
|
const rows: RcRow[] = [];
|
|
const row = (section: RcSection, key: string, status: RcStatus, msg: string) => rows.push({ section, key, status, msg });
|
|
for (const p of appProblems) row('app', '-', 'missing', p);
|
|
for (const p of want.problems) row('config', '-', 'mismatch', p);
|
|
const ix = index(state, apps);
|
|
const wantedKeys = new Set(want.products.map(p => p.key));
|
|
|
|
for (const w of want.products) {
|
|
if (!apps[w.store]) continue;
|
|
const h = ix.byKey.get(w.key);
|
|
if (!h) row('product', w.key, 'missing', `${w.type} "${w.displayName}" is not in RevenueCat`);
|
|
else if (!typeFits(w.type, h.type)) row('product', w.key, 'mismatch', `type ${h.type} in RevenueCat, ${w.type} in the config (change it in RevenueCat; sync never updates)`);
|
|
else if (h.state === 'inactive') row('product', w.key, 'mismatch', 'archived in RevenueCat (unarchive it there)');
|
|
else row('product', w.key, 'ok', `${h.type}, ${h.id}`);
|
|
}
|
|
for (const [k, h] of ix.byKey) if (!wantedKeys.has(k)) row('product', k, 'extra', `in RevenueCat but not in the config (${h.type}), left alone`);
|
|
|
|
const ents = new Map(state.entitlements.map(e => [e.lookup_key, e] as const));
|
|
for (const w of want.entitlements) {
|
|
const keys = w.products.filter(k => apps[k.split(':')[0] as RcStore]);
|
|
const h = ents.get(w.lookupKey);
|
|
if (!h) { row('entitlement', w.lookupKey, 'missing', `not in RevenueCat; would unlock ${keys.length} product(s): ${keys.join(', ') || '-'}`); continue; }
|
|
if (h.state === 'inactive') row('entitlement', w.lookupKey, 'mismatch', 'archived in RevenueCat (unarchive it there)');
|
|
const attached = new Set(h.productIds.map(id => ix.keyOf.get(id)).filter((x): x is string => !!x));
|
|
const missing = keys.filter(k => !attached.has(k));
|
|
for (const k of missing) row('entitlement', `${w.lookupKey} <- ${k}`, 'missing', 'product not attached');
|
|
for (const k of attached) if (!w.products.includes(k)) row('entitlement', `${w.lookupKey} <- ${k}`, 'extra', 'attached in RevenueCat but not in the config, left alone');
|
|
if (!missing.length && h.state !== 'inactive') row('entitlement', w.lookupKey, 'ok', `${attached.size} product(s) attached`);
|
|
}
|
|
const wantedEnts = new Set(want.entitlements.map(e => e.lookupKey));
|
|
for (const e of state.entitlements) if (!wantedEnts.has(e.lookup_key)) row('entitlement', e.lookup_key, 'extra', 'in RevenueCat but not in the config, left alone');
|
|
|
|
const o = want.offering;
|
|
const ho = state.offerings.find(x => x.lookup_key === o.lookupKey);
|
|
if (!ho) row('offering', o.lookupKey, 'missing', `not in RevenueCat; ${o.packages.length} package(s): ${o.packages.map(p => p.lookupKey).join(', ') || '-'}`);
|
|
else {
|
|
if (ho.state === 'inactive') row('offering', o.lookupKey, 'mismatch', 'archived in RevenueCat (unarchive it there)');
|
|
row('offering', o.lookupKey, ho.is_current ? 'ok' : 'info', ho.is_current ? 'the current offering' : 'not the current offering (choose it in RevenueCat, or run sync --current)');
|
|
const pk = new Map(ho.packages.map(p => [p.lookup_key, p] as const));
|
|
for (const w of o.packages) {
|
|
const h = pk.get(w.lookupKey);
|
|
if (!h) { row('package', w.lookupKey, 'missing', `not in the offering; would hold ${w.products.filter(k => apps[k.split(':')[0] as RcStore]).join(', ') || '-'}`); continue; }
|
|
let bad = 0;
|
|
for (const k of w.products) {
|
|
const store = k.split(':')[0] as RcStore;
|
|
if (!apps[store]) continue;
|
|
const want = ix.byKey.get(k);
|
|
const same = h.products.map(x => state.products.find(p => p.id === x.product_id)).filter((p): p is RcProduct => !!p && p.app_id === apps[store]);
|
|
if (want && same.some(p => p.id === want.id)) continue;
|
|
bad++;
|
|
if (same.length) row('package', `${w.lookupKey} <- ${k}`, 'mismatch', `holds ${same.map(p => p.store_identifier).join(', ')} for ${store} (change it in RevenueCat; sync never detaches)`);
|
|
else row('package', `${w.lookupKey} <- ${k}`, 'missing', 'product not attached');
|
|
}
|
|
if (!bad) row('package', w.lookupKey, 'ok', `${h.products.length} product(s)`);
|
|
}
|
|
const wantedPk = new Set(o.packages.map(p => p.lookupKey));
|
|
for (const h of ho.packages) if (!wantedPk.has(h.lookup_key)) row('package', h.lookup_key, 'extra', 'in the offering but not in the config, left alone');
|
|
}
|
|
const rank: Record<RcStatus, number> = { mismatch: 0, missing: 1, extra: 2, info: 3, ok: 4 };
|
|
const order: Record<RcSection, number> = { app: 0, config: 1, product: 2, entitlement: 3, offering: 4, package: 5 };
|
|
return rows.map((r, i) => ({ r, i })).sort((a, b) => rank[a.r.status] - rank[b.r.status] || order[a.r.section] - order[b.r.section] || a.i - b.i).map(x => x.r);
|
|
}
|
|
|
|
// ---- sync plan ----
|
|
|
|
export type RcStep =
|
|
| { op: 'create-product'; key: string; body: { store_identifier: string; app_id: string; type: RcProductType; display_name: string }; what: string }
|
|
| { op: 'create-entitlement'; lookupKey: string; body: { lookup_key: string; display_name: string }; what: string }
|
|
| { op: 'attach-entitlement'; lookupKey: string; products: string[]; what: string }
|
|
| { op: 'create-offering'; lookupKey: string; body: { lookup_key: string; display_name: string }; what: string }
|
|
| { op: 'create-package'; lookupKey: string; body: { lookup_key: string; display_name: string; position: number }; what: string }
|
|
| { op: 'attach-package'; lookupKey: string; products: string[]; what: string }
|
|
| { op: 'set-current'; lookupKey: string; what: string };
|
|
|
|
/** The API takes at most 50 products per attach call. */
|
|
const chunks = <T>(a: T[], n = 50): T[][] => Array.from({ length: Math.ceil(a.length / n) }, (_, i) => a.slice(i * n, i * n + n));
|
|
|
|
/**
|
|
* Creates and attaches only, in dependency order: products, entitlements, entitlement attachments, the offering, its
|
|
* packages, package attachments, and (only with `current`) making the offering the current one. Nothing RevenueCat has
|
|
* beyond the config is deleted, detached, archived or changed. Products on a store without a resolved app are skipped.
|
|
*/
|
|
export function rcPlan(want: RcWanted, state: RcState, apps: RcAppIds, o: { current?: boolean } = {}): RcStep[] {
|
|
const ix = index(state, apps);
|
|
const steps: RcStep[] = [];
|
|
const onApp = (k: string) => !!apps[k.split(':')[0] as RcStore];
|
|
for (const w of want.products) {
|
|
if (!apps[w.store] || ix.byKey.has(w.key)) continue;
|
|
steps.push({ op: 'create-product', key: w.key, body: { store_identifier: w.storeIdentifier, app_id: apps[w.store]!, type: w.type, display_name: w.displayName },
|
|
what: `create product ${w.key} (${w.type}, "${w.displayName}")` });
|
|
}
|
|
const ents = new Map(state.entitlements.map(e => [e.lookup_key, e] as const));
|
|
const attachE: RcStep[] = [];
|
|
for (const w of want.entitlements) {
|
|
const h = ents.get(w.lookupKey);
|
|
if (!h) steps.push({ op: 'create-entitlement', lookupKey: w.lookupKey, body: { lookup_key: w.lookupKey, display_name: w.displayName }, what: `create entitlement ${w.lookupKey}` });
|
|
const attached = new Set((h?.productIds ?? []).map(id => ix.keyOf.get(id)));
|
|
const add = w.products.filter(k => onApp(k) && !attached.has(k));
|
|
for (const c of chunks(add)) attachE.push({ op: 'attach-entitlement', lookupKey: w.lookupKey, products: c, what: `attach to entitlement ${w.lookupKey}: ${c.join(', ')}` });
|
|
}
|
|
steps.push(...attachE);
|
|
const ow = want.offering;
|
|
const ho = state.offerings.find(x => x.lookup_key === ow.lookupKey);
|
|
if (ow.packages.length || ho) {
|
|
if (!ho) steps.push({ op: 'create-offering', lookupKey: ow.lookupKey, body: { lookup_key: ow.lookupKey, display_name: ow.displayName }, what: `create offering ${ow.lookupKey}` });
|
|
const pk = new Map((ho?.packages ?? []).map(p => [p.lookup_key, p] as const));
|
|
const taken = new Set((ho?.packages ?? []).map(p => p.position).filter((x): x is number => typeof x === 'number'));
|
|
let next = Math.max(0, ...taken) + 1;
|
|
const attachP: RcStep[] = [];
|
|
for (const w of ow.packages) {
|
|
const h = pk.get(w.lookupKey);
|
|
if (!h) {
|
|
const position = taken.has(w.position) ? next++ : w.position;
|
|
taken.add(position); next = Math.max(next, position + 1);
|
|
steps.push({ op: 'create-package', lookupKey: w.lookupKey, body: { lookup_key: w.lookupKey, display_name: w.displayName, position }, what: `create package ${w.lookupKey} ("${w.displayName}", position ${position})` });
|
|
}
|
|
const add = w.products.filter(k => {
|
|
if (!onApp(k)) return false;
|
|
if (!h) return true;
|
|
const app = apps[k.split(':')[0] as RcStore];
|
|
// one product per store app in a package: never attach next to (or instead of) one the owner put there
|
|
return !h.products.some(x => state.products.find(p => p.id === x.product_id)?.app_id === app);
|
|
});
|
|
for (const c of chunks(add)) attachP.push({ op: 'attach-package', lookupKey: w.lookupKey, products: c, what: `attach to package ${w.lookupKey}: ${c.join(', ')}` });
|
|
}
|
|
steps.push(...attachP);
|
|
if (o.current && !ho?.is_current) steps.push({ op: 'set-current', lookupKey: ow.lookupKey, what: `make offering ${ow.lookupKey} the current offering (changes what the apps show by default)` });
|
|
}
|
|
return steps;
|
|
}
|