@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-BvYwTSIF.d.ts → index-CBhthuMn.d.ts} +41 -2
- package/dist/{index-CjwZwEVR.d.cts → index-CS1QagwE.d.cts} +41 -2
- package/dist/index.cjs +150 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -4
- package/dist/index.d.ts +8 -4
- package/dist/index.js +149 -7
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs.map +1 -1
- package/dist/middlewares/index.d.cts +1 -1
- package/dist/middlewares/index.d.ts +1 -1
- package/dist/middlewares/index.js.map +1 -1
- package/dist/types.cjs +12 -0
- package/dist/types.cjs.map +1 -1
- package/dist/types.d.cts +6 -1
- package/dist/types.d.ts +6 -1
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
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
|
|
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:
|
|
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
|
|
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
|
|
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 ===
|
|
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,
|