@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
|
@@ -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,16 @@ 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>>;
|
|
59
|
+
updateSubscription(opts: {
|
|
60
|
+
subscriptionId: string;
|
|
61
|
+
priceId: string;
|
|
62
|
+
}): Promise<{
|
|
63
|
+
status: string;
|
|
64
|
+
currentPeriodStart: Date | null;
|
|
65
|
+
currentPeriodEnd: Date | null;
|
|
66
|
+
}>;
|
|
43
67
|
createPortalSession(opts: {
|
|
44
68
|
customerId: string;
|
|
45
69
|
returnUrl: string;
|
|
@@ -59,8 +83,30 @@ interface ICounterBackend {
|
|
|
59
83
|
}
|
|
60
84
|
|
|
61
85
|
interface IBillingPlanPrice {
|
|
62
|
-
|
|
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. */
|
|
63
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;
|
|
64
110
|
}
|
|
65
111
|
interface IBillingPlanDefaults {
|
|
66
112
|
warnAt?: number;
|
|
@@ -92,6 +138,7 @@ interface IBillingConfig {
|
|
|
92
138
|
backend?: RateLimitBackendConfig;
|
|
93
139
|
};
|
|
94
140
|
notifications?: IBillingNotificationsConfig;
|
|
141
|
+
pricing?: IBillingPricingConfig;
|
|
95
142
|
}
|
|
96
143
|
declare const MESSAGE_KEYS: {
|
|
97
144
|
readonly limitWarning: "billing.limit-warning";
|
|
@@ -105,4 +152,4 @@ declare function requirePlan(plans: string | string[], store: IStoreAdapter, ctx
|
|
|
105
152
|
|
|
106
153
|
declare function withBilling(store: IStoreAdapter, config: IBillingConfig, backend: ICounterBackend): Middleware;
|
|
107
154
|
|
|
108
|
-
export { type BillingMessageKey as B, type IBillingConfig as I, MESSAGE_KEYS as M, type RateLimitBackendConfig as R, type IBillingProvider as a, type
|
|
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,16 @@ 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>>;
|
|
59
|
+
updateSubscription(opts: {
|
|
60
|
+
subscriptionId: string;
|
|
61
|
+
priceId: string;
|
|
62
|
+
}): Promise<{
|
|
63
|
+
status: string;
|
|
64
|
+
currentPeriodStart: Date | null;
|
|
65
|
+
currentPeriodEnd: Date | null;
|
|
66
|
+
}>;
|
|
43
67
|
createPortalSession(opts: {
|
|
44
68
|
customerId: string;
|
|
45
69
|
returnUrl: string;
|
|
@@ -59,8 +83,30 @@ interface ICounterBackend {
|
|
|
59
83
|
}
|
|
60
84
|
|
|
61
85
|
interface IBillingPlanPrice {
|
|
62
|
-
|
|
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. */
|
|
63
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;
|
|
64
110
|
}
|
|
65
111
|
interface IBillingPlanDefaults {
|
|
66
112
|
warnAt?: number;
|
|
@@ -92,6 +138,7 @@ interface IBillingConfig {
|
|
|
92
138
|
backend?: RateLimitBackendConfig;
|
|
93
139
|
};
|
|
94
140
|
notifications?: IBillingNotificationsConfig;
|
|
141
|
+
pricing?: IBillingPricingConfig;
|
|
95
142
|
}
|
|
96
143
|
declare const MESSAGE_KEYS: {
|
|
97
144
|
readonly limitWarning: "billing.limit-warning";
|
|
@@ -105,4 +152,4 @@ declare function requirePlan(plans: string | string[], store: IStoreAdapter, ctx
|
|
|
105
152
|
|
|
106
153
|
declare function withBilling(store: IStoreAdapter, config: IBillingConfig, backend: ICounterBackend): Middleware;
|
|
107
154
|
|
|
108
|
-
export { type BillingMessageKey as B, type IBillingConfig as I, MESSAGE_KEYS as M, type RateLimitBackendConfig as R, type IBillingProvider as a, type
|
|
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
|
|
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:
|
|
426
|
+
plan: dto
|
|
335
427
|
});
|
|
336
428
|
},
|
|
337
429
|
async create(ctx) {
|
|
@@ -588,6 +680,43 @@ function checkoutController(store, config) {
|
|
|
588
680
|
`Plan ${planName} does not support ${interval} billing`
|
|
589
681
|
);
|
|
590
682
|
}
|
|
683
|
+
const current = await subscriptions.get(subscriber.type, subscriber.id);
|
|
684
|
+
const ACTIVE = ["active", "trialing", "past_due"];
|
|
685
|
+
if (current && ACTIVE.includes(current.status)) {
|
|
686
|
+
const currentTier = plans.findByNameInConfig(current.plan, config)?.tier ?? -1;
|
|
687
|
+
const targetTier = plan.tier ?? -1;
|
|
688
|
+
if (targetTier <= currentTier) {
|
|
689
|
+
return (0, import_core3.setApiResponse)(
|
|
690
|
+
import_core3.HTTP.UNPROCESSABLE,
|
|
691
|
+
"DOWNGRADE_NOT_ALLOWED",
|
|
692
|
+
`Cannot switch from ${current.plan} to a same-or-lower tier (${planName}) mid-cycle. Upgrades only.`
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
if (current.providerSubscriptionId) {
|
|
696
|
+
const res = await config.provider.updateSubscription({
|
|
697
|
+
subscriptionId: current.providerSubscriptionId,
|
|
698
|
+
priceId: pricing.priceId
|
|
699
|
+
});
|
|
700
|
+
const upsert = {
|
|
701
|
+
subscriberType: subscriber.type,
|
|
702
|
+
subscriberId: subscriber.id,
|
|
703
|
+
plan: planName,
|
|
704
|
+
interval,
|
|
705
|
+
status: res.status,
|
|
706
|
+
providerSubscriptionId: current.providerSubscriptionId
|
|
707
|
+
};
|
|
708
|
+
if (current.providerCustomerId) upsert.providerCustomerId = current.providerCustomerId;
|
|
709
|
+
if (res.currentPeriodStart) upsert.currentPeriodStart = res.currentPeriodStart;
|
|
710
|
+
if (res.currentPeriodEnd) upsert.currentPeriodEnd = res.currentPeriodEnd;
|
|
711
|
+
await subscriptions.upsert(upsert);
|
|
712
|
+
return (0, import_core3.setApiResponse)(
|
|
713
|
+
import_core3.HTTP.OK,
|
|
714
|
+
"SUBSCRIPTION_UPGRADED",
|
|
715
|
+
"Subscription upgraded; the prorated difference was charged.",
|
|
716
|
+
{ upgraded: true, plan: planName }
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
591
720
|
const { customerId } = await config.provider.createCustomer({
|
|
592
721
|
email: ctx.user.email ?? "",
|
|
593
722
|
subscriberType: subscriber.type,
|
|
@@ -727,7 +856,7 @@ function usageController(store) {
|
|
|
727
856
|
|
|
728
857
|
// src/controllers/webhook.controller.ts
|
|
729
858
|
var import_core5 = require("@fonderie/core");
|
|
730
|
-
function webhookController(store, config) {
|
|
859
|
+
function webhookController(store, config, priceCache) {
|
|
731
860
|
const subscriptions = new SubscriptionModel(store);
|
|
732
861
|
return {
|
|
733
862
|
async handle(ctx) {
|
|
@@ -749,11 +878,15 @@ function webhookController(store, config) {
|
|
|
749
878
|
} catch {
|
|
750
879
|
return (0, import_core5.setApiResponse)(import_core5.HTTP.BAD_REQUEST, "INVALID_REQUEST", "Invalid webhook signature");
|
|
751
880
|
}
|
|
881
|
+
if (priceCache && (event.type.startsWith("price.") || event.type.startsWith("product."))) {
|
|
882
|
+
priceCache.invalidate();
|
|
883
|
+
}
|
|
752
884
|
if (event.subscription) {
|
|
885
|
+
const plan = event.type === "customer.subscription.deleted" ? event.subscription.plan : resolvePlanNameByPrice(event.subscription, config.plans) ?? event.subscription.plan;
|
|
753
886
|
await subscriptions.upsert({
|
|
754
887
|
subscriberType: event.subscription.subscriberType,
|
|
755
888
|
subscriberId: event.subscription.subscriberId,
|
|
756
|
-
plan
|
|
889
|
+
plan,
|
|
757
890
|
interval: event.subscription.interval,
|
|
758
891
|
status: event.subscription.status,
|
|
759
892
|
providerCustomerId: event.subscription.providerCustomerId,
|
|
@@ -771,11 +904,16 @@ function webhookController(store, config) {
|
|
|
771
904
|
|
|
772
905
|
// src/routes.ts
|
|
773
906
|
function buildBillingRoutes(store, config) {
|
|
774
|
-
const
|
|
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);
|
|
775
913
|
const subscription = subscriptionController(store);
|
|
776
914
|
const checkout = checkoutController(store, config);
|
|
777
915
|
const usage = usageController(store);
|
|
778
|
-
const webhook = webhookController(store, config);
|
|
916
|
+
const webhook = webhookController(store, config, priceCache);
|
|
779
917
|
return [
|
|
780
918
|
// Plans — public read-only
|
|
781
919
|
["GET", "/plans", plan.list],
|
|
@@ -998,6 +1136,9 @@ var BillingModule = class {
|
|
|
998
1136
|
}
|
|
999
1137
|
};
|
|
1000
1138
|
|
|
1139
|
+
// src/types.ts
|
|
1140
|
+
var BILLING_INTERVAL = { MONTH: "month", YEAR: "year" };
|
|
1141
|
+
|
|
1001
1142
|
// src/providers/stripe.ts
|
|
1002
1143
|
var _client = null;
|
|
1003
1144
|
async function getClient(secretKey) {
|
|
@@ -1018,6 +1159,8 @@ function normalizeSubscription(sub) {
|
|
|
1018
1159
|
subscriberType: sub.metadata?.["subscriberType"] ?? "workspace",
|
|
1019
1160
|
subscriberId: sub.metadata?.["subscriberId"] ?? "",
|
|
1020
1161
|
plan: item?.price.nickname ?? "unknown",
|
|
1162
|
+
priceLookupKey: item?.price.lookup_key ?? null,
|
|
1163
|
+
priceId: item?.price.id ?? null,
|
|
1021
1164
|
status: sub.status,
|
|
1022
1165
|
providerCustomerId: sub.customer,
|
|
1023
1166
|
providerSubscriptionId: sub.id,
|
|
@@ -1025,7 +1168,19 @@ function normalizeSubscription(sub) {
|
|
|
1025
1168
|
currentPeriodEnd: periodEnd ? new Date(periodEnd * 1e3) : /* @__PURE__ */ new Date(),
|
|
1026
1169
|
cancelAtPeriodEnd: sub.cancel_at_period_end,
|
|
1027
1170
|
trialEndsAt: sub.trial_end ? new Date(sub.trial_end * 1e3) : null,
|
|
1028
|
-
interval: item?.price.recurring?.interval ===
|
|
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
|
|
1029
1184
|
};
|
|
1030
1185
|
}
|
|
1031
1186
|
var StripeProvider = class {
|
|
@@ -1069,6 +1224,48 @@ var StripeProvider = class {
|
|
|
1069
1224
|
});
|
|
1070
1225
|
return { url: session.url ?? "" };
|
|
1071
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
|
+
}
|
|
1251
|
+
async updateSubscription(opts) {
|
|
1252
|
+
const stripe = await this.client();
|
|
1253
|
+
const sub = await stripe.subscriptions.retrieve(opts.subscriptionId);
|
|
1254
|
+
const itemId = sub.items.data[0]?.id;
|
|
1255
|
+
const updated = await stripe.subscriptions.update(opts.subscriptionId, {
|
|
1256
|
+
items: [{ id: itemId, price: opts.priceId }],
|
|
1257
|
+
proration_behavior: "always_invoice",
|
|
1258
|
+
payment_behavior: "error_if_incomplete"
|
|
1259
|
+
});
|
|
1260
|
+
const item = updated.items?.data?.[0];
|
|
1261
|
+
const cps = item?.current_period_start ?? updated.current_period_start;
|
|
1262
|
+
const cpe = item?.current_period_end ?? updated.current_period_end;
|
|
1263
|
+
return {
|
|
1264
|
+
status: updated.status,
|
|
1265
|
+
currentPeriodStart: cps ? new Date(cps * 1e3) : null,
|
|
1266
|
+
currentPeriodEnd: cpe ? new Date(cpe * 1e3) : null
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1072
1269
|
async createPortalSession(opts) {
|
|
1073
1270
|
const stripe = await this.client();
|
|
1074
1271
|
const session = await stripe.billingPortal.sessions.create({
|
|
@@ -1183,6 +1380,7 @@ function requireFeature(key) {
|
|
|
1183
1380
|
}
|
|
1184
1381
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1185
1382
|
0 && (module.exports = {
|
|
1383
|
+
BILLING_INTERVAL,
|
|
1186
1384
|
BillingModule,
|
|
1187
1385
|
DBCounterBackend,
|
|
1188
1386
|
MESSAGE_KEYS,
|