@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.
package/dist/index.js CHANGED
@@ -42,6 +42,54 @@ var recordUsageSchema = z.object({
42
42
  quantity: z.number().min(0).optional()
43
43
  });
44
44
 
45
+ // src/services/price-cache.ts
46
+ var PriceCache = class {
47
+ ttl;
48
+ grace;
49
+ maxStale;
50
+ byId = /* @__PURE__ */ new Map();
51
+ inflight = /* @__PURE__ */ new Map();
52
+ constructor(opts = {}) {
53
+ this.ttl = opts.ttlMs ?? 3e5;
54
+ this.grace = opts.graceMs ?? 36e5;
55
+ this.maxStale = opts.maxStaleMs ?? 864e5;
56
+ }
57
+ async byPriceId(priceId, provider) {
58
+ const now = Date.now();
59
+ const hit = this.byId.get(priceId);
60
+ if (hit && now - hit.at < this.ttl) return { price: hit.price, stale: false };
61
+ let fresh;
62
+ try {
63
+ fresh = await this.single(priceId, () => provider.resolvePriceById(priceId));
64
+ } catch {
65
+ if (hit && now - hit.at < this.maxStale) return { price: hit.price, stale: true };
66
+ return { price: null, stale: true };
67
+ }
68
+ if (fresh) {
69
+ this.byId.set(priceId, { price: fresh, at: now });
70
+ return { price: fresh, stale: false };
71
+ }
72
+ if (hit && now - hit.at < this.grace) return { price: hit.price, stale: true };
73
+ return { price: null, stale: true };
74
+ }
75
+ invalidate(priceId) {
76
+ if (priceId) this.byId.delete(priceId);
77
+ else this.byId.clear();
78
+ }
79
+ /** Warm the cache with prices already resolved elsewhere (e.g. boot guard). */
80
+ prime(prices) {
81
+ const now = Date.now();
82
+ for (const p of prices) this.byId.set(p.priceId, { price: p, at: now });
83
+ }
84
+ single(key, run) {
85
+ const existing = this.inflight.get(key);
86
+ if (existing) return existing;
87
+ const p = run().finally(() => this.inflight.delete(key));
88
+ this.inflight.set(key, p);
89
+ return p;
90
+ }
91
+ };
92
+
45
93
  // src/controllers/plan.controller.ts
46
94
  import { setApiResponse, HTTP, stringOrEmpty, numberOrZero } from "@fonderie/core";
47
95
 
@@ -52,6 +100,18 @@ function getPlans(config) {
52
100
  function getPlanByName(name, config) {
53
101
  return config.plans.find((p) => p.name.toLowerCase() === name.toLowerCase()) ?? null;
54
102
  }
103
+ function resolvePlanNameByPrice(price, plans) {
104
+ const find = (pred) => plans.find((pl) => pred(pl.monthly) || pred(pl.yearly))?.name ?? null;
105
+ if (price.lookupKey) {
106
+ const m = find((p) => p?.lookupKey === price.lookupKey);
107
+ if (m) return m;
108
+ }
109
+ if (price.priceId) {
110
+ const m = find((p) => p?.priceId === price.priceId);
111
+ if (m) return m;
112
+ }
113
+ return null;
114
+ }
55
115
  async function syncPlansToDB(config, store) {
56
116
  const plans = config.plans;
57
117
  if (plans.length === 0) return;
@@ -270,12 +330,41 @@ function toUsageRecordDTO(record) {
270
330
  }
271
331
 
272
332
  // src/controllers/plan.controller.ts
273
- function planController(store) {
333
+ async function hydratePricing(dto, plan, config, cache) {
334
+ try {
335
+ let stale = false;
336
+ const resolve = async (priceId) => {
337
+ if (!priceId) return null;
338
+ const r = await cache.byPriceId(priceId, config.provider);
339
+ if (r.stale) stale = true;
340
+ return r.price;
341
+ };
342
+ const [m, y] = await Promise.all([resolve(plan.monthlyPriceId), resolve(plan.yearlyPriceId)]);
343
+ if (m && y && m.currency !== y.currency) {
344
+ throw new Error(
345
+ `[billing] plan "${plan.name}": monthly/yearly currency mismatch (${m.currency} vs ${y.currency})`
346
+ );
347
+ }
348
+ if (m) dto.pricing.monthly = m.unitAmount;
349
+ if (y) dto.pricing.yearly = y.unitAmount;
350
+ const currency = m?.currency ?? y?.currency;
351
+ if (currency) dto.pricing.currency = currency.toUpperCase();
352
+ if (stale) dto.pricingStale = true;
353
+ } catch (err) {
354
+ console.error(`[billing] pricing hydration failed for "${plan.name}":`, err.message);
355
+ dto.pricingStale = true;
356
+ }
357
+ }
358
+ function planController(store, config, cache) {
274
359
  const plans = new PlanModel(store);
360
+ const hydrate = config.pricing?.hydration === true;
275
361
  return {
276
362
  async list(_ctx) {
277
363
  const list = await plans.list();
278
364
  const dtos = list.map(toPlanDTO);
365
+ if (hydrate) {
366
+ await Promise.all(dtos.map((dto, i) => hydratePricing(dto, list[i], config, cache)));
367
+ }
279
368
  return setApiResponse(HTTP.OK, "PLAN_LIST", `Retrieved ${list.length} workspace plans`, {
280
369
  plans: dtos
281
370
  });
@@ -286,8 +375,10 @@ function planController(store) {
286
375
  if (!id) return setApiResponse(HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Plan ID required");
287
376
  const plan = await plans.findById(id);
288
377
  if (!plan) return setApiResponse(HTTP.NOT_FOUND, "NOT_FOUND", "Plan not found");
378
+ const dto = toPlanDTO(plan);
379
+ if (hydrate) await hydratePricing(dto, plan, config, cache);
289
380
  return setApiResponse(HTTP.OK, "PLAN_FETCHED", "Plan retrieved successfully.", {
290
- plan: toPlanDTO(plan)
381
+ plan: dto
291
382
  });
292
383
  },
293
384
  async create(ctx) {
@@ -720,7 +811,7 @@ function usageController(store) {
720
811
 
721
812
  // src/controllers/webhook.controller.ts
722
813
  import { setApiResponse as setApiResponse5, HTTP as HTTP5 } from "@fonderie/core";
723
- function webhookController(store, config) {
814
+ function webhookController(store, config, priceCache) {
724
815
  const subscriptions = new SubscriptionModel(store);
725
816
  return {
726
817
  async handle(ctx) {
@@ -742,11 +833,15 @@ function webhookController(store, config) {
742
833
  } catch {
743
834
  return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_REQUEST", "Invalid webhook signature");
744
835
  }
836
+ if (priceCache && (event.type.startsWith("price.") || event.type.startsWith("product."))) {
837
+ priceCache.invalidate();
838
+ }
745
839
  if (event.subscription) {
840
+ const plan = event.type === "customer.subscription.deleted" ? event.subscription.plan : resolvePlanNameByPrice(event.subscription, config.plans) ?? event.subscription.plan;
746
841
  await subscriptions.upsert({
747
842
  subscriberType: event.subscription.subscriberType,
748
843
  subscriberId: event.subscription.subscriberId,
749
- plan: event.subscription.plan,
844
+ plan,
750
845
  interval: event.subscription.interval,
751
846
  status: event.subscription.status,
752
847
  providerCustomerId: event.subscription.providerCustomerId,
@@ -764,11 +859,16 @@ function webhookController(store, config) {
764
859
 
765
860
  // src/routes.ts
766
861
  function buildBillingRoutes(store, config) {
767
- const plan = planController(store);
862
+ const priceCache = new PriceCache({
863
+ ttlMs: config.pricing?.cacheTtlMs,
864
+ graceMs: config.pricing?.transferGraceMs,
865
+ maxStaleMs: config.pricing?.maxStaleMs
866
+ });
867
+ const plan = planController(store, config, priceCache);
768
868
  const subscription = subscriptionController(store);
769
869
  const checkout = checkoutController(store, config);
770
870
  const usage = usageController(store);
771
- const webhook = webhookController(store, config);
871
+ const webhook = webhookController(store, config, priceCache);
772
872
  return [
773
873
  // Plans — public read-only
774
874
  ["GET", "/plans", plan.list],
@@ -991,6 +1091,9 @@ var BillingModule = class {
991
1091
  }
992
1092
  };
993
1093
 
1094
+ // src/types.ts
1095
+ var BILLING_INTERVAL = { MONTH: "month", YEAR: "year" };
1096
+
994
1097
  // src/providers/stripe.ts
995
1098
  var _client = null;
996
1099
  async function getClient(secretKey) {
@@ -1011,6 +1114,8 @@ function normalizeSubscription(sub) {
1011
1114
  subscriberType: sub.metadata?.["subscriberType"] ?? "workspace",
1012
1115
  subscriberId: sub.metadata?.["subscriberId"] ?? "",
1013
1116
  plan: item?.price.nickname ?? "unknown",
1117
+ priceLookupKey: item?.price.lookup_key ?? null,
1118
+ priceId: item?.price.id ?? null,
1014
1119
  status: sub.status,
1015
1120
  providerCustomerId: sub.customer,
1016
1121
  providerSubscriptionId: sub.id,
@@ -1018,7 +1123,19 @@ function normalizeSubscription(sub) {
1018
1123
  currentPeriodEnd: periodEnd ? new Date(periodEnd * 1e3) : /* @__PURE__ */ new Date(),
1019
1124
  cancelAtPeriodEnd: sub.cancel_at_period_end,
1020
1125
  trialEndsAt: sub.trial_end ? new Date(sub.trial_end * 1e3) : null,
1021
- interval: item?.price.recurring?.interval === "year" ? "year" : "month"
1126
+ interval: item?.price.recurring?.interval === BILLING_INTERVAL.YEAR ? BILLING_INTERVAL.YEAR : BILLING_INTERVAL.MONTH
1127
+ };
1128
+ }
1129
+ function toResolvedPrice(p) {
1130
+ return {
1131
+ priceId: p.id,
1132
+ lookupKey: p.lookup_key ?? null,
1133
+ unitAmount: p.unit_amount ?? 0,
1134
+ currency: p.currency,
1135
+ interval: p.recurring?.interval === BILLING_INTERVAL.YEAR ? BILLING_INTERVAL.YEAR : BILLING_INTERVAL.MONTH,
1136
+ nickname: p.nickname ?? null,
1137
+ productId: typeof p.product === "string" ? p.product : p.product?.id ?? "",
1138
+ active: p.active ?? true
1022
1139
  };
1023
1140
  }
1024
1141
  var StripeProvider = class {
@@ -1062,6 +1179,30 @@ var StripeProvider = class {
1062
1179
  });
1063
1180
  return { url: session.url ?? "" };
1064
1181
  }
1182
+ async resolvePriceById(priceId) {
1183
+ const stripe = await this.client();
1184
+ try {
1185
+ const p = await stripe.prices.retrieve(priceId, { expand: ["product"] });
1186
+ return toResolvedPrice(p);
1187
+ } catch {
1188
+ return null;
1189
+ }
1190
+ }
1191
+ async resolvePricesByLookupKey(lookupKeys) {
1192
+ const out = /* @__PURE__ */ new Map();
1193
+ if (lookupKeys.length === 0) return out;
1194
+ const stripe = await this.client();
1195
+ const res = await stripe.prices.list({
1196
+ lookup_keys: lookupKeys,
1197
+ active: true,
1198
+ expand: ["data.product"],
1199
+ limit: 100
1200
+ });
1201
+ for (const p of res.data) {
1202
+ if (p.lookup_key) out.set(p.lookup_key, toResolvedPrice(p));
1203
+ }
1204
+ return out;
1205
+ }
1065
1206
  async updateSubscription(opts) {
1066
1207
  const stripe = await this.client();
1067
1208
  const sub = await stripe.subscriptions.retrieve(opts.subscriptionId);
@@ -1193,6 +1334,7 @@ function requireFeature(key) {
1193
1334
  };
1194
1335
  }
1195
1336
  export {
1337
+ BILLING_INTERVAL,
1196
1338
  BillingModule,
1197
1339
  DBCounterBackend,
1198
1340
  MESSAGE_KEYS,