@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-CKzM1gyJ.d.ts → index-CBhthuMn.d.ts} +49 -2
- package/dist/{index-Ce_BMy1V.d.cts → index-CS1QagwE.d.cts} +49 -2
- package/dist/index.cjs +205 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +16 -4
- package/dist/index.d.ts +16 -4
- package/dist/index.js +204 -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) {
|
|
@@ -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
|
|
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
|
|
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 ===
|
|
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,
|