@fonderie/billing 5.0.2 → 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) {
@@ -544,6 +635,43 @@ function checkoutController(store, config) {
544
635
  `Plan ${planName} does not support ${interval} billing`
545
636
  );
546
637
  }
638
+ const current = await subscriptions.get(subscriber.type, subscriber.id);
639
+ const ACTIVE = ["active", "trialing", "past_due"];
640
+ if (current && ACTIVE.includes(current.status)) {
641
+ const currentTier = plans.findByNameInConfig(current.plan, config)?.tier ?? -1;
642
+ const targetTier = plan.tier ?? -1;
643
+ if (targetTier <= currentTier) {
644
+ return setApiResponse3(
645
+ HTTP3.UNPROCESSABLE,
646
+ "DOWNGRADE_NOT_ALLOWED",
647
+ `Cannot switch from ${current.plan} to a same-or-lower tier (${planName}) mid-cycle. Upgrades only.`
648
+ );
649
+ }
650
+ if (current.providerSubscriptionId) {
651
+ const res = await config.provider.updateSubscription({
652
+ subscriptionId: current.providerSubscriptionId,
653
+ priceId: pricing.priceId
654
+ });
655
+ const upsert = {
656
+ subscriberType: subscriber.type,
657
+ subscriberId: subscriber.id,
658
+ plan: planName,
659
+ interval,
660
+ status: res.status,
661
+ providerSubscriptionId: current.providerSubscriptionId
662
+ };
663
+ if (current.providerCustomerId) upsert.providerCustomerId = current.providerCustomerId;
664
+ if (res.currentPeriodStart) upsert.currentPeriodStart = res.currentPeriodStart;
665
+ if (res.currentPeriodEnd) upsert.currentPeriodEnd = res.currentPeriodEnd;
666
+ await subscriptions.upsert(upsert);
667
+ return setApiResponse3(
668
+ HTTP3.OK,
669
+ "SUBSCRIPTION_UPGRADED",
670
+ "Subscription upgraded; the prorated difference was charged.",
671
+ { upgraded: true, plan: planName }
672
+ );
673
+ }
674
+ }
547
675
  const { customerId } = await config.provider.createCustomer({
548
676
  email: ctx.user.email ?? "",
549
677
  subscriberType: subscriber.type,
@@ -683,7 +811,7 @@ function usageController(store) {
683
811
 
684
812
  // src/controllers/webhook.controller.ts
685
813
  import { setApiResponse as setApiResponse5, HTTP as HTTP5 } from "@fonderie/core";
686
- function webhookController(store, config) {
814
+ function webhookController(store, config, priceCache) {
687
815
  const subscriptions = new SubscriptionModel(store);
688
816
  return {
689
817
  async handle(ctx) {
@@ -705,11 +833,15 @@ function webhookController(store, config) {
705
833
  } catch {
706
834
  return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_REQUEST", "Invalid webhook signature");
707
835
  }
836
+ if (priceCache && (event.type.startsWith("price.") || event.type.startsWith("product."))) {
837
+ priceCache.invalidate();
838
+ }
708
839
  if (event.subscription) {
840
+ const plan = event.type === "customer.subscription.deleted" ? event.subscription.plan : resolvePlanNameByPrice(event.subscription, config.plans) ?? event.subscription.plan;
709
841
  await subscriptions.upsert({
710
842
  subscriberType: event.subscription.subscriberType,
711
843
  subscriberId: event.subscription.subscriberId,
712
- plan: event.subscription.plan,
844
+ plan,
713
845
  interval: event.subscription.interval,
714
846
  status: event.subscription.status,
715
847
  providerCustomerId: event.subscription.providerCustomerId,
@@ -727,11 +859,16 @@ function webhookController(store, config) {
727
859
 
728
860
  // src/routes.ts
729
861
  function buildBillingRoutes(store, config) {
730
- 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);
731
868
  const subscription = subscriptionController(store);
732
869
  const checkout = checkoutController(store, config);
733
870
  const usage = usageController(store);
734
- const webhook = webhookController(store, config);
871
+ const webhook = webhookController(store, config, priceCache);
735
872
  return [
736
873
  // Plans — public read-only
737
874
  ["GET", "/plans", plan.list],
@@ -954,6 +1091,9 @@ var BillingModule = class {
954
1091
  }
955
1092
  };
956
1093
 
1094
+ // src/types.ts
1095
+ var BILLING_INTERVAL = { MONTH: "month", YEAR: "year" };
1096
+
957
1097
  // src/providers/stripe.ts
958
1098
  var _client = null;
959
1099
  async function getClient(secretKey) {
@@ -974,6 +1114,8 @@ function normalizeSubscription(sub) {
974
1114
  subscriberType: sub.metadata?.["subscriberType"] ?? "workspace",
975
1115
  subscriberId: sub.metadata?.["subscriberId"] ?? "",
976
1116
  plan: item?.price.nickname ?? "unknown",
1117
+ priceLookupKey: item?.price.lookup_key ?? null,
1118
+ priceId: item?.price.id ?? null,
977
1119
  status: sub.status,
978
1120
  providerCustomerId: sub.customer,
979
1121
  providerSubscriptionId: sub.id,
@@ -981,7 +1123,19 @@ function normalizeSubscription(sub) {
981
1123
  currentPeriodEnd: periodEnd ? new Date(periodEnd * 1e3) : /* @__PURE__ */ new Date(),
982
1124
  cancelAtPeriodEnd: sub.cancel_at_period_end,
983
1125
  trialEndsAt: sub.trial_end ? new Date(sub.trial_end * 1e3) : null,
984
- 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
985
1139
  };
986
1140
  }
987
1141
  var StripeProvider = class {
@@ -1025,6 +1179,48 @@ var StripeProvider = class {
1025
1179
  });
1026
1180
  return { url: session.url ?? "" };
1027
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
+ }
1206
+ async updateSubscription(opts) {
1207
+ const stripe = await this.client();
1208
+ const sub = await stripe.subscriptions.retrieve(opts.subscriptionId);
1209
+ const itemId = sub.items.data[0]?.id;
1210
+ const updated = await stripe.subscriptions.update(opts.subscriptionId, {
1211
+ items: [{ id: itemId, price: opts.priceId }],
1212
+ proration_behavior: "always_invoice",
1213
+ payment_behavior: "error_if_incomplete"
1214
+ });
1215
+ const item = updated.items?.data?.[0];
1216
+ const cps = item?.current_period_start ?? updated.current_period_start;
1217
+ const cpe = item?.current_period_end ?? updated.current_period_end;
1218
+ return {
1219
+ status: updated.status,
1220
+ currentPeriodStart: cps ? new Date(cps * 1e3) : null,
1221
+ currentPeriodEnd: cpe ? new Date(cpe * 1e3) : null
1222
+ };
1223
+ }
1028
1224
  async createPortalSession(opts) {
1029
1225
  const stripe = await this.client();
1030
1226
  const session = await stripe.billingPortal.sessions.create({
@@ -1138,6 +1334,7 @@ function requireFeature(key) {
1138
1334
  };
1139
1335
  }
1140
1336
  export {
1337
+ BILLING_INTERVAL,
1141
1338
  BillingModule,
1142
1339
  DBCounterBackend,
1143
1340
  MESSAGE_KEYS,