@fonderie/billing 5.1.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,11 @@ interface IBillingEvent {
9
9
  interface INormalizedSubscription {
10
10
  subscriberType: SubscriberType;
11
11
  subscriberId: string;
12
+ /** Plan name from the price nickname (legacy fallback; prefer resolving via priceLookupKey/priceId). */
12
13
  plan: string;
14
+ /** The subscription item's price lookup_key + id — for config-based plan attribution (§16.3). */
15
+ priceLookupKey: string | null;
16
+ priceId: string | null;
13
17
  status: string;
14
18
  providerCustomerId: string;
15
19
  providerSubscriptionId: string;
@@ -19,6 +23,16 @@ interface INormalizedSubscription {
19
23
  trialEndsAt: Date | null;
20
24
  interval: 'month' | 'year';
21
25
  }
26
+ interface IResolvedPrice {
27
+ priceId: string;
28
+ lookupKey: string | null;
29
+ unitAmount: number;
30
+ currency: string;
31
+ interval: 'month' | 'year';
32
+ nickname: string | null;
33
+ productId: string;
34
+ active: boolean;
35
+ }
22
36
  interface IBillingProvider {
23
37
  name: string;
24
38
  createCustomer(opts: {
@@ -40,6 +54,8 @@ interface IBillingProvider {
40
54
  }): Promise<{
41
55
  url: string;
42
56
  }>;
57
+ resolvePriceById(priceId: string): Promise<IResolvedPrice | null>;
58
+ resolvePricesByLookupKey(lookupKeys: string[]): Promise<Map<string, IResolvedPrice>>;
43
59
  updateSubscription(opts: {
44
60
  subscriptionId: string;
45
61
  priceId: string;
@@ -67,8 +83,30 @@ interface ICounterBackend {
67
83
  }
68
84
 
69
85
  interface IBillingPlanPrice {
70
- amount: number;
86
+ /** Stable Stripe Price lookup_key, e.g. "pro_monthly". Preferred reference. */
87
+ lookupKey?: string;
88
+ /** Stripe price id. Used for hydration and as a lookup_key fallback. */
71
89
  priceId?: string;
90
+ /**
91
+ * @deprecated Display amount in cents. When pricing hydration is on and the
92
+ * price resolves from Stripe, the live amount wins; this is the fallback only.
93
+ */
94
+ amount?: number;
95
+ }
96
+ /**
97
+ * Read-through pricing: amount/currency come from Stripe (source of truth) rather
98
+ * than the duplicated `amount` above. Gated by `hydration` (kill-switch, §16.9).
99
+ * See packages/billing/docs/pricing-hydration.md.
100
+ */
101
+ interface IBillingPricingConfig {
102
+ /** Kill-switch. When false (default), use the deprecated hardcoded amount/USD path. */
103
+ hydration?: boolean;
104
+ /** Fresh-cache TTL. Default 300_000 (5m). */
105
+ cacheTtlMs?: number;
106
+ /** Serve last-cached price on a transient resolution miss (lookup_key transfer). Default 3_600_000 (1h). */
107
+ transferGraceMs?: number;
108
+ /** Max age to serve stale price during a Stripe outage before giving up. Default 86_400_000 (24h). */
109
+ maxStaleMs?: number;
72
110
  }
73
111
  interface IBillingPlanDefaults {
74
112
  warnAt?: number;
@@ -100,6 +138,7 @@ interface IBillingConfig {
100
138
  backend?: RateLimitBackendConfig;
101
139
  };
102
140
  notifications?: IBillingNotificationsConfig;
141
+ pricing?: IBillingPricingConfig;
103
142
  }
104
143
  declare const MESSAGE_KEYS: {
105
144
  readonly limitWarning: "billing.limit-warning";
@@ -113,4 +152,4 @@ declare function requirePlan(plans: string | string[], store: IStoreAdapter, ctx
113
152
 
114
153
  declare function withBilling(store: IStoreAdapter, config: IBillingConfig, backend: ICounterBackend): Middleware;
115
154
 
116
- export { type BillingMessageKey as B, type IBillingConfig as I, MESSAGE_KEYS as M, type RateLimitBackendConfig as R, type IBillingProvider as a, type IBillingEvent as b, type ICounterBackend as c, type IBillingPlan as d, type IBillingNotificationsConfig as e, type IBillingPlanDefaults as f, requirePlan as r, withBilling as w };
155
+ export { type BillingMessageKey as B, type IBillingConfig as I, MESSAGE_KEYS as M, type RateLimitBackendConfig as R, type IBillingProvider as a, type IResolvedPrice as b, type IBillingEvent as c, type ICounterBackend as d, type IBillingPlan as e, type IBillingNotificationsConfig as f, type IBillingPlanDefaults as g, type IBillingPlanPrice as h, type IBillingPricingConfig as i, requirePlan as r, withBilling as w };
@@ -9,7 +9,11 @@ interface IBillingEvent {
9
9
  interface INormalizedSubscription {
10
10
  subscriberType: SubscriberType;
11
11
  subscriberId: string;
12
+ /** Plan name from the price nickname (legacy fallback; prefer resolving via priceLookupKey/priceId). */
12
13
  plan: string;
14
+ /** The subscription item's price lookup_key + id — for config-based plan attribution (§16.3). */
15
+ priceLookupKey: string | null;
16
+ priceId: string | null;
13
17
  status: string;
14
18
  providerCustomerId: string;
15
19
  providerSubscriptionId: string;
@@ -19,6 +23,16 @@ interface INormalizedSubscription {
19
23
  trialEndsAt: Date | null;
20
24
  interval: 'month' | 'year';
21
25
  }
26
+ interface IResolvedPrice {
27
+ priceId: string;
28
+ lookupKey: string | null;
29
+ unitAmount: number;
30
+ currency: string;
31
+ interval: 'month' | 'year';
32
+ nickname: string | null;
33
+ productId: string;
34
+ active: boolean;
35
+ }
22
36
  interface IBillingProvider {
23
37
  name: string;
24
38
  createCustomer(opts: {
@@ -40,6 +54,8 @@ interface IBillingProvider {
40
54
  }): Promise<{
41
55
  url: string;
42
56
  }>;
57
+ resolvePriceById(priceId: string): Promise<IResolvedPrice | null>;
58
+ resolvePricesByLookupKey(lookupKeys: string[]): Promise<Map<string, IResolvedPrice>>;
43
59
  updateSubscription(opts: {
44
60
  subscriptionId: string;
45
61
  priceId: string;
@@ -67,8 +83,30 @@ interface ICounterBackend {
67
83
  }
68
84
 
69
85
  interface IBillingPlanPrice {
70
- amount: number;
86
+ /** Stable Stripe Price lookup_key, e.g. "pro_monthly". Preferred reference. */
87
+ lookupKey?: string;
88
+ /** Stripe price id. Used for hydration and as a lookup_key fallback. */
71
89
  priceId?: string;
90
+ /**
91
+ * @deprecated Display amount in cents. When pricing hydration is on and the
92
+ * price resolves from Stripe, the live amount wins; this is the fallback only.
93
+ */
94
+ amount?: number;
95
+ }
96
+ /**
97
+ * Read-through pricing: amount/currency come from Stripe (source of truth) rather
98
+ * than the duplicated `amount` above. Gated by `hydration` (kill-switch, §16.9).
99
+ * See packages/billing/docs/pricing-hydration.md.
100
+ */
101
+ interface IBillingPricingConfig {
102
+ /** Kill-switch. When false (default), use the deprecated hardcoded amount/USD path. */
103
+ hydration?: boolean;
104
+ /** Fresh-cache TTL. Default 300_000 (5m). */
105
+ cacheTtlMs?: number;
106
+ /** Serve last-cached price on a transient resolution miss (lookup_key transfer). Default 3_600_000 (1h). */
107
+ transferGraceMs?: number;
108
+ /** Max age to serve stale price during a Stripe outage before giving up. Default 86_400_000 (24h). */
109
+ maxStaleMs?: number;
72
110
  }
73
111
  interface IBillingPlanDefaults {
74
112
  warnAt?: number;
@@ -100,6 +138,7 @@ interface IBillingConfig {
100
138
  backend?: RateLimitBackendConfig;
101
139
  };
102
140
  notifications?: IBillingNotificationsConfig;
141
+ pricing?: IBillingPricingConfig;
103
142
  }
104
143
  declare const MESSAGE_KEYS: {
105
144
  readonly limitWarning: "billing.limit-warning";
@@ -113,4 +152,4 @@ declare function requirePlan(plans: string | string[], store: IStoreAdapter, ctx
113
152
 
114
153
  declare function withBilling(store: IStoreAdapter, config: IBillingConfig, backend: ICounterBackend): Middleware;
115
154
 
116
- export { type BillingMessageKey as B, type IBillingConfig as I, MESSAGE_KEYS as M, type RateLimitBackendConfig as R, type IBillingProvider as a, type IBillingEvent as b, type ICounterBackend as c, type IBillingPlan as d, type IBillingNotificationsConfig as e, type IBillingPlanDefaults as f, requirePlan as r, withBilling as w };
155
+ export { type BillingMessageKey as B, type IBillingConfig as I, MESSAGE_KEYS as M, type RateLimitBackendConfig as R, type IBillingProvider as a, type IResolvedPrice as b, type IBillingEvent as c, type ICounterBackend as d, type IBillingPlan as e, type IBillingNotificationsConfig as f, type IBillingPlanDefaults as g, type IBillingPlanPrice as h, type IBillingPricingConfig as i, requirePlan as r, withBilling as w };
package/dist/index.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ BILLING_INTERVAL: () => BILLING_INTERVAL,
23
24
  BillingModule: () => BillingModule,
24
25
  DBCounterBackend: () => DBCounterBackend,
25
26
  MESSAGE_KEYS: () => MESSAGE_KEYS,
@@ -86,6 +87,54 @@ var recordUsageSchema = import_zod.z.object({
86
87
  quantity: import_zod.z.number().min(0).optional()
87
88
  });
88
89
 
90
+ // src/services/price-cache.ts
91
+ var PriceCache = class {
92
+ ttl;
93
+ grace;
94
+ maxStale;
95
+ byId = /* @__PURE__ */ new Map();
96
+ inflight = /* @__PURE__ */ new Map();
97
+ constructor(opts = {}) {
98
+ this.ttl = opts.ttlMs ?? 3e5;
99
+ this.grace = opts.graceMs ?? 36e5;
100
+ this.maxStale = opts.maxStaleMs ?? 864e5;
101
+ }
102
+ async byPriceId(priceId, provider) {
103
+ const now = Date.now();
104
+ const hit = this.byId.get(priceId);
105
+ if (hit && now - hit.at < this.ttl) return { price: hit.price, stale: false };
106
+ let fresh;
107
+ try {
108
+ fresh = await this.single(priceId, () => provider.resolvePriceById(priceId));
109
+ } catch {
110
+ if (hit && now - hit.at < this.maxStale) return { price: hit.price, stale: true };
111
+ return { price: null, stale: true };
112
+ }
113
+ if (fresh) {
114
+ this.byId.set(priceId, { price: fresh, at: now });
115
+ return { price: fresh, stale: false };
116
+ }
117
+ if (hit && now - hit.at < this.grace) return { price: hit.price, stale: true };
118
+ return { price: null, stale: true };
119
+ }
120
+ invalidate(priceId) {
121
+ if (priceId) this.byId.delete(priceId);
122
+ else this.byId.clear();
123
+ }
124
+ /** Warm the cache with prices already resolved elsewhere (e.g. boot guard). */
125
+ prime(prices) {
126
+ const now = Date.now();
127
+ for (const p of prices) this.byId.set(p.priceId, { price: p, at: now });
128
+ }
129
+ single(key, run) {
130
+ const existing = this.inflight.get(key);
131
+ if (existing) return existing;
132
+ const p = run().finally(() => this.inflight.delete(key));
133
+ this.inflight.set(key, p);
134
+ return p;
135
+ }
136
+ };
137
+
89
138
  // src/controllers/plan.controller.ts
90
139
  var import_core = require("@fonderie/core");
91
140
 
@@ -96,6 +145,18 @@ function getPlans(config) {
96
145
  function getPlanByName(name, config) {
97
146
  return config.plans.find((p) => p.name.toLowerCase() === name.toLowerCase()) ?? null;
98
147
  }
148
+ function resolvePlanNameByPrice(price, plans) {
149
+ const find = (pred) => plans.find((pl) => pred(pl.monthly) || pred(pl.yearly))?.name ?? null;
150
+ if (price.lookupKey) {
151
+ const m = find((p) => p?.lookupKey === price.lookupKey);
152
+ if (m) return m;
153
+ }
154
+ if (price.priceId) {
155
+ const m = find((p) => p?.priceId === price.priceId);
156
+ if (m) return m;
157
+ }
158
+ return null;
159
+ }
99
160
  async function syncPlansToDB(config, store) {
100
161
  const plans = config.plans;
101
162
  if (plans.length === 0) return;
@@ -314,12 +375,41 @@ function toUsageRecordDTO(record) {
314
375
  }
315
376
 
316
377
  // src/controllers/plan.controller.ts
317
- function planController(store) {
378
+ async function hydratePricing(dto, plan, config, cache) {
379
+ try {
380
+ let stale = false;
381
+ const resolve = async (priceId) => {
382
+ if (!priceId) return null;
383
+ const r = await cache.byPriceId(priceId, config.provider);
384
+ if (r.stale) stale = true;
385
+ return r.price;
386
+ };
387
+ const [m, y] = await Promise.all([resolve(plan.monthlyPriceId), resolve(plan.yearlyPriceId)]);
388
+ if (m && y && m.currency !== y.currency) {
389
+ throw new Error(
390
+ `[billing] plan "${plan.name}": monthly/yearly currency mismatch (${m.currency} vs ${y.currency})`
391
+ );
392
+ }
393
+ if (m) dto.pricing.monthly = m.unitAmount;
394
+ if (y) dto.pricing.yearly = y.unitAmount;
395
+ const currency = m?.currency ?? y?.currency;
396
+ if (currency) dto.pricing.currency = currency.toUpperCase();
397
+ if (stale) dto.pricingStale = true;
398
+ } catch (err) {
399
+ console.error(`[billing] pricing hydration failed for "${plan.name}":`, err.message);
400
+ dto.pricingStale = true;
401
+ }
402
+ }
403
+ function planController(store, config, cache) {
318
404
  const plans = new PlanModel(store);
405
+ const hydrate = config.pricing?.hydration === true;
319
406
  return {
320
407
  async list(_ctx) {
321
408
  const list = await plans.list();
322
409
  const dtos = list.map(toPlanDTO);
410
+ if (hydrate) {
411
+ await Promise.all(dtos.map((dto, i) => hydratePricing(dto, list[i], config, cache)));
412
+ }
323
413
  return (0, import_core.setApiResponse)(import_core.HTTP.OK, "PLAN_LIST", `Retrieved ${list.length} workspace plans`, {
324
414
  plans: dtos
325
415
  });
@@ -330,8 +420,10 @@ function planController(store) {
330
420
  if (!id) return (0, import_core.setApiResponse)(import_core.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Plan ID required");
331
421
  const plan = await plans.findById(id);
332
422
  if (!plan) return (0, import_core.setApiResponse)(import_core.HTTP.NOT_FOUND, "NOT_FOUND", "Plan not found");
423
+ const dto = toPlanDTO(plan);
424
+ if (hydrate) await hydratePricing(dto, plan, config, cache);
333
425
  return (0, import_core.setApiResponse)(import_core.HTTP.OK, "PLAN_FETCHED", "Plan retrieved successfully.", {
334
- plan: toPlanDTO(plan)
426
+ plan: dto
335
427
  });
336
428
  },
337
429
  async create(ctx) {
@@ -764,7 +856,7 @@ function usageController(store) {
764
856
 
765
857
  // src/controllers/webhook.controller.ts
766
858
  var import_core5 = require("@fonderie/core");
767
- function webhookController(store, config) {
859
+ function webhookController(store, config, priceCache) {
768
860
  const subscriptions = new SubscriptionModel(store);
769
861
  return {
770
862
  async handle(ctx) {
@@ -786,11 +878,15 @@ function webhookController(store, config) {
786
878
  } catch {
787
879
  return (0, import_core5.setApiResponse)(import_core5.HTTP.BAD_REQUEST, "INVALID_REQUEST", "Invalid webhook signature");
788
880
  }
881
+ if (priceCache && (event.type.startsWith("price.") || event.type.startsWith("product."))) {
882
+ priceCache.invalidate();
883
+ }
789
884
  if (event.subscription) {
885
+ const plan = event.type === "customer.subscription.deleted" ? event.subscription.plan : resolvePlanNameByPrice(event.subscription, config.plans) ?? event.subscription.plan;
790
886
  await subscriptions.upsert({
791
887
  subscriberType: event.subscription.subscriberType,
792
888
  subscriberId: event.subscription.subscriberId,
793
- plan: event.subscription.plan,
889
+ plan,
794
890
  interval: event.subscription.interval,
795
891
  status: event.subscription.status,
796
892
  providerCustomerId: event.subscription.providerCustomerId,
@@ -808,11 +904,16 @@ function webhookController(store, config) {
808
904
 
809
905
  // src/routes.ts
810
906
  function buildBillingRoutes(store, config) {
811
- const plan = planController(store);
907
+ const priceCache = new PriceCache({
908
+ ttlMs: config.pricing?.cacheTtlMs,
909
+ graceMs: config.pricing?.transferGraceMs,
910
+ maxStaleMs: config.pricing?.maxStaleMs
911
+ });
912
+ const plan = planController(store, config, priceCache);
812
913
  const subscription = subscriptionController(store);
813
914
  const checkout = checkoutController(store, config);
814
915
  const usage = usageController(store);
815
- const webhook = webhookController(store, config);
916
+ const webhook = webhookController(store, config, priceCache);
816
917
  return [
817
918
  // Plans — public read-only
818
919
  ["GET", "/plans", plan.list],
@@ -1035,6 +1136,9 @@ var BillingModule = class {
1035
1136
  }
1036
1137
  };
1037
1138
 
1139
+ // src/types.ts
1140
+ var BILLING_INTERVAL = { MONTH: "month", YEAR: "year" };
1141
+
1038
1142
  // src/providers/stripe.ts
1039
1143
  var _client = null;
1040
1144
  async function getClient(secretKey) {
@@ -1055,6 +1159,8 @@ function normalizeSubscription(sub) {
1055
1159
  subscriberType: sub.metadata?.["subscriberType"] ?? "workspace",
1056
1160
  subscriberId: sub.metadata?.["subscriberId"] ?? "",
1057
1161
  plan: item?.price.nickname ?? "unknown",
1162
+ priceLookupKey: item?.price.lookup_key ?? null,
1163
+ priceId: item?.price.id ?? null,
1058
1164
  status: sub.status,
1059
1165
  providerCustomerId: sub.customer,
1060
1166
  providerSubscriptionId: sub.id,
@@ -1062,7 +1168,19 @@ function normalizeSubscription(sub) {
1062
1168
  currentPeriodEnd: periodEnd ? new Date(periodEnd * 1e3) : /* @__PURE__ */ new Date(),
1063
1169
  cancelAtPeriodEnd: sub.cancel_at_period_end,
1064
1170
  trialEndsAt: sub.trial_end ? new Date(sub.trial_end * 1e3) : null,
1065
- interval: item?.price.recurring?.interval === "year" ? "year" : "month"
1171
+ interval: item?.price.recurring?.interval === BILLING_INTERVAL.YEAR ? BILLING_INTERVAL.YEAR : BILLING_INTERVAL.MONTH
1172
+ };
1173
+ }
1174
+ function toResolvedPrice(p) {
1175
+ return {
1176
+ priceId: p.id,
1177
+ lookupKey: p.lookup_key ?? null,
1178
+ unitAmount: p.unit_amount ?? 0,
1179
+ currency: p.currency,
1180
+ interval: p.recurring?.interval === BILLING_INTERVAL.YEAR ? BILLING_INTERVAL.YEAR : BILLING_INTERVAL.MONTH,
1181
+ nickname: p.nickname ?? null,
1182
+ productId: typeof p.product === "string" ? p.product : p.product?.id ?? "",
1183
+ active: p.active ?? true
1066
1184
  };
1067
1185
  }
1068
1186
  var StripeProvider = class {
@@ -1106,6 +1224,30 @@ var StripeProvider = class {
1106
1224
  });
1107
1225
  return { url: session.url ?? "" };
1108
1226
  }
1227
+ async resolvePriceById(priceId) {
1228
+ const stripe = await this.client();
1229
+ try {
1230
+ const p = await stripe.prices.retrieve(priceId, { expand: ["product"] });
1231
+ return toResolvedPrice(p);
1232
+ } catch {
1233
+ return null;
1234
+ }
1235
+ }
1236
+ async resolvePricesByLookupKey(lookupKeys) {
1237
+ const out = /* @__PURE__ */ new Map();
1238
+ if (lookupKeys.length === 0) return out;
1239
+ const stripe = await this.client();
1240
+ const res = await stripe.prices.list({
1241
+ lookup_keys: lookupKeys,
1242
+ active: true,
1243
+ expand: ["data.product"],
1244
+ limit: 100
1245
+ });
1246
+ for (const p of res.data) {
1247
+ if (p.lookup_key) out.set(p.lookup_key, toResolvedPrice(p));
1248
+ }
1249
+ return out;
1250
+ }
1109
1251
  async updateSubscription(opts) {
1110
1252
  const stripe = await this.client();
1111
1253
  const sub = await stripe.subscriptions.retrieve(opts.subscriptionId);
@@ -1238,6 +1380,7 @@ function requireFeature(key) {
1238
1380
  }
1239
1381
  // Annotate the CommonJS export names for ESM import in node:
1240
1382
  0 && (module.exports = {
1383
+ BILLING_INTERVAL,
1241
1384
  BillingModule,
1242
1385
  DBCounterBackend,
1243
1386
  MESSAGE_KEYS,