@basaltkit/subscriptions 1.0.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/LICENSE +21 -0
- package/README.md +374 -0
- package/dist/index.d.ts +476 -0
- package/dist/index.js +718 -0
- package/package.json +54 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
// src/plans.ts
|
|
2
|
+
import { BasaltError } from "@basaltkit/core";
|
|
3
|
+
var UnknownPlanError = class extends BasaltError {
|
|
4
|
+
constructor(plan) {
|
|
5
|
+
super("BILLING_UNKNOWN_PLAN", `Plan "${plan}" is not defined in definePlans().`);
|
|
6
|
+
}
|
|
7
|
+
};
|
|
8
|
+
function meter(limit) {
|
|
9
|
+
return { meter: true, limit };
|
|
10
|
+
}
|
|
11
|
+
function definePlans(plans) {
|
|
12
|
+
return plans;
|
|
13
|
+
}
|
|
14
|
+
function planPrice(plan, period) {
|
|
15
|
+
if (plan.price === "custom") return "custom";
|
|
16
|
+
return typeof plan.price === "number" ? plan.price : plan.price[period];
|
|
17
|
+
}
|
|
18
|
+
function featureLimit(value) {
|
|
19
|
+
if (value === void 0 || value === false) return 0;
|
|
20
|
+
if (value === true) return Number.POSITIVE_INFINITY;
|
|
21
|
+
return typeof value === "number" ? value : value.limit;
|
|
22
|
+
}
|
|
23
|
+
function isMeter(value) {
|
|
24
|
+
return typeof value === "object" && value !== null && value.meter === true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// src/stores.ts
|
|
28
|
+
var MemorySubscriptionStore = class {
|
|
29
|
+
records = /* @__PURE__ */ new Map();
|
|
30
|
+
async get(billableId) {
|
|
31
|
+
return this.records.get(billableId) ?? null;
|
|
32
|
+
}
|
|
33
|
+
async save(record) {
|
|
34
|
+
this.records.set(record.billableId, { ...record });
|
|
35
|
+
}
|
|
36
|
+
async all() {
|
|
37
|
+
return [...this.records.values()].map((record) => ({ ...record }));
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
var MemoryWebhookStore = class {
|
|
41
|
+
seen = /* @__PURE__ */ new Set();
|
|
42
|
+
async markProcessed(id) {
|
|
43
|
+
if (this.seen.has(id)) return false;
|
|
44
|
+
this.seen.add(id);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
async release(id) {
|
|
48
|
+
this.seen.delete(id);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
var MemoryUsageStore = class {
|
|
52
|
+
counters = /* @__PURE__ */ new Map();
|
|
53
|
+
key(billableId, feature, periodKey) {
|
|
54
|
+
return `${billableId}::${feature}::${periodKey}`;
|
|
55
|
+
}
|
|
56
|
+
async get(billableId, feature, periodKey) {
|
|
57
|
+
return this.counters.get(this.key(billableId, feature, periodKey)) ?? 0;
|
|
58
|
+
}
|
|
59
|
+
async increment(billableId, feature, periodKey, amount) {
|
|
60
|
+
const key = this.key(billableId, feature, periodKey);
|
|
61
|
+
const total = (this.counters.get(key) ?? 0) + amount;
|
|
62
|
+
this.counters.set(key, total);
|
|
63
|
+
return total;
|
|
64
|
+
}
|
|
65
|
+
async consume(billableId, feature, periodKey, amount, limit) {
|
|
66
|
+
const key = this.key(billableId, feature, periodKey);
|
|
67
|
+
const current = this.counters.get(key) ?? 0;
|
|
68
|
+
if (current + amount > limit) return { applied: false, used: current };
|
|
69
|
+
const total = current + amount;
|
|
70
|
+
this.counters.set(key, total);
|
|
71
|
+
return { applied: true, used: total };
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// src/drivers/redis-usage.ts
|
|
76
|
+
var CONSUME_SCRIPT = `
|
|
77
|
+
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
|
|
78
|
+
local amount = tonumber(ARGV[1])
|
|
79
|
+
local limit = tonumber(ARGV[2])
|
|
80
|
+
local ttl = tonumber(ARGV[3])
|
|
81
|
+
if current + amount > limit then
|
|
82
|
+
return {0, current}
|
|
83
|
+
end
|
|
84
|
+
local total = redis.call('INCRBY', KEYS[1], amount)
|
|
85
|
+
if ttl > 0 then
|
|
86
|
+
redis.call('EXPIRE', KEYS[1], ttl)
|
|
87
|
+
end
|
|
88
|
+
return {1, total}
|
|
89
|
+
`.trim();
|
|
90
|
+
var RedisUsageStore = class {
|
|
91
|
+
constructor(redis, options = {}) {
|
|
92
|
+
this.redis = redis;
|
|
93
|
+
this.prefix = options.prefix ?? "basalt:usage";
|
|
94
|
+
this.ttlSeconds = options.ttlSeconds ?? 60 * 24 * 60 * 60;
|
|
95
|
+
}
|
|
96
|
+
redis;
|
|
97
|
+
prefix;
|
|
98
|
+
ttlSeconds;
|
|
99
|
+
key(billableId, feature, periodKey) {
|
|
100
|
+
return `${this.prefix}:${billableId}:${feature}:${periodKey}`;
|
|
101
|
+
}
|
|
102
|
+
ttlFor(periodKey) {
|
|
103
|
+
return periodKey === "lifetime" ? 0 : this.ttlSeconds;
|
|
104
|
+
}
|
|
105
|
+
async get(billableId, feature, periodKey) {
|
|
106
|
+
const raw = await this.redis.get(this.key(billableId, feature, periodKey));
|
|
107
|
+
return raw === null ? 0 : Number(raw);
|
|
108
|
+
}
|
|
109
|
+
async increment(billableId, feature, periodKey, amount) {
|
|
110
|
+
const reply = await this.redis.eval(
|
|
111
|
+
CONSUME_SCRIPT,
|
|
112
|
+
1,
|
|
113
|
+
this.key(billableId, feature, periodKey),
|
|
114
|
+
amount,
|
|
115
|
+
Number.MAX_SAFE_INTEGER,
|
|
116
|
+
this.ttlFor(periodKey)
|
|
117
|
+
);
|
|
118
|
+
return Number(reply[1]);
|
|
119
|
+
}
|
|
120
|
+
async consume(billableId, feature, periodKey, amount, limit) {
|
|
121
|
+
const reply = await this.redis.eval(
|
|
122
|
+
CONSUME_SCRIPT,
|
|
123
|
+
1,
|
|
124
|
+
this.key(billableId, feature, periodKey),
|
|
125
|
+
amount,
|
|
126
|
+
limit,
|
|
127
|
+
this.ttlFor(periodKey)
|
|
128
|
+
);
|
|
129
|
+
return { applied: Number(reply[0]) === 1, used: Number(reply[1]) };
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// src/drivers/redis-webhook.ts
|
|
134
|
+
var RedisWebhookStore = class {
|
|
135
|
+
constructor(redis, options = {}) {
|
|
136
|
+
this.redis = redis;
|
|
137
|
+
this.prefix = options.prefix ?? "basalt:webhook";
|
|
138
|
+
this.ttlSeconds = options.ttlSeconds ?? 7 * 24 * 60 * 60;
|
|
139
|
+
}
|
|
140
|
+
redis;
|
|
141
|
+
prefix;
|
|
142
|
+
ttlSeconds;
|
|
143
|
+
key(id) {
|
|
144
|
+
return `${this.prefix}:${id}`;
|
|
145
|
+
}
|
|
146
|
+
async markProcessed(id) {
|
|
147
|
+
const result = await this.redis.set(this.key(id), "1", "EX", this.ttlSeconds, "NX");
|
|
148
|
+
return result === "OK";
|
|
149
|
+
}
|
|
150
|
+
async release(id) {
|
|
151
|
+
await this.redis.del(this.key(id));
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// src/gateway.ts
|
|
156
|
+
import { BasaltError as BasaltError2 } from "@basaltkit/core";
|
|
157
|
+
var WebhookInvalidError = class extends BasaltError2 {
|
|
158
|
+
status = 400;
|
|
159
|
+
constructor() {
|
|
160
|
+
super("BILLING_WEBHOOK_INVALID", "Webhook signature verification failed.");
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
var FakeBillingGateway = class {
|
|
164
|
+
name = "fake";
|
|
165
|
+
created = [];
|
|
166
|
+
canceled = [];
|
|
167
|
+
checkouts = [];
|
|
168
|
+
portals = [];
|
|
169
|
+
swaps = [];
|
|
170
|
+
counter = 0;
|
|
171
|
+
async createSubscription(input) {
|
|
172
|
+
this.created.push(input);
|
|
173
|
+
return { gatewayRef: `fake_sub_${++this.counter}` };
|
|
174
|
+
}
|
|
175
|
+
async cancelSubscription(gatewayRef, options) {
|
|
176
|
+
this.canceled.push({ gatewayRef, atPeriodEnd: options.atPeriodEnd });
|
|
177
|
+
}
|
|
178
|
+
async createCheckoutSession(input) {
|
|
179
|
+
this.checkouts.push(input);
|
|
180
|
+
const id = `fake_cs_${++this.counter}`;
|
|
181
|
+
return { url: `https://fake.test/checkout/${id}`, id };
|
|
182
|
+
}
|
|
183
|
+
async createPortalSession(input) {
|
|
184
|
+
this.portals.push(input);
|
|
185
|
+
return { url: `https://fake.test/portal/${input.billableId}` };
|
|
186
|
+
}
|
|
187
|
+
async swapSubscription(gatewayRef, input) {
|
|
188
|
+
this.swaps.push({ gatewayRef, input });
|
|
189
|
+
}
|
|
190
|
+
verifyWebhook(rawBody, signature) {
|
|
191
|
+
if (signature !== "valid") throw new WebhookInvalidError();
|
|
192
|
+
return JSON.parse(rawBody);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// src/drivers/stripe.ts
|
|
197
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
198
|
+
import { BasaltError as BasaltError3 } from "@basaltkit/core";
|
|
199
|
+
var StripeRequestError = class extends BasaltError3 {
|
|
200
|
+
constructor(httpStatus, message) {
|
|
201
|
+
super("BILLING_GATEWAY_ERROR", `Stripe request failed (${httpStatus}): ${message}`);
|
|
202
|
+
this.httpStatus = httpStatus;
|
|
203
|
+
}
|
|
204
|
+
httpStatus;
|
|
205
|
+
};
|
|
206
|
+
var EVENT_MAP = {
|
|
207
|
+
"customer.subscription.deleted": "subscription.canceled",
|
|
208
|
+
"invoice.payment_failed": "payment.failed",
|
|
209
|
+
"invoice.paid": "payment.succeeded",
|
|
210
|
+
"invoice.payment_succeeded": "payment.succeeded"
|
|
211
|
+
};
|
|
212
|
+
var StripeBillingGateway = class {
|
|
213
|
+
constructor(options) {
|
|
214
|
+
this.options = options;
|
|
215
|
+
this.fetch = options.fetch ?? globalThis.fetch;
|
|
216
|
+
this.now = options.now ?? Date.now;
|
|
217
|
+
this.tolerance = options.tolerance ?? 300;
|
|
218
|
+
this.apiBase = options.apiBase ?? "https://api.stripe.com";
|
|
219
|
+
this.resolveBillableId = options.resolveBillableId ?? ((event) => event?.data?.object?.metadata?.["billableId"]);
|
|
220
|
+
}
|
|
221
|
+
options;
|
|
222
|
+
name = "stripe";
|
|
223
|
+
fetch;
|
|
224
|
+
now;
|
|
225
|
+
tolerance;
|
|
226
|
+
apiBase;
|
|
227
|
+
resolveBillableId;
|
|
228
|
+
async createSubscription(input) {
|
|
229
|
+
const customer = await this.options.customerId(input.billableId);
|
|
230
|
+
const price = this.options.priceId(input.plan, input.period);
|
|
231
|
+
const created = await this.request("POST", "/v1/subscriptions", {
|
|
232
|
+
customer,
|
|
233
|
+
"items[0][price]": price,
|
|
234
|
+
"metadata[billableId]": input.billableId,
|
|
235
|
+
...input.trialDays !== void 0 ? { trial_period_days: String(input.trialDays) } : {}
|
|
236
|
+
});
|
|
237
|
+
return { gatewayRef: String(created.id) };
|
|
238
|
+
}
|
|
239
|
+
async cancelSubscription(gatewayRef, options) {
|
|
240
|
+
if (options.atPeriodEnd) {
|
|
241
|
+
await this.request("POST", `/v1/subscriptions/${gatewayRef}`, {
|
|
242
|
+
cancel_at_period_end: "true"
|
|
243
|
+
});
|
|
244
|
+
} else {
|
|
245
|
+
await this.request("DELETE", `/v1/subscriptions/${gatewayRef}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async createCheckoutSession(input) {
|
|
249
|
+
const customer = await this.options.customerId(input.billableId);
|
|
250
|
+
const created = await this.request("POST", "/v1/checkout/sessions", {
|
|
251
|
+
mode: "subscription",
|
|
252
|
+
customer,
|
|
253
|
+
"line_items[0][price]": this.options.priceId(input.plan, input.period),
|
|
254
|
+
"line_items[0][quantity]": "1",
|
|
255
|
+
success_url: input.successUrl,
|
|
256
|
+
cancel_url: input.cancelUrl,
|
|
257
|
+
"subscription_data[metadata][billableId]": input.billableId,
|
|
258
|
+
...input.trialDays !== void 0 ? { "subscription_data[trial_period_days]": String(input.trialDays) } : {}
|
|
259
|
+
});
|
|
260
|
+
return { url: String(created.url), id: String(created.id) };
|
|
261
|
+
}
|
|
262
|
+
async createPortalSession(input) {
|
|
263
|
+
const customer = await this.options.customerId(input.billableId);
|
|
264
|
+
const created = await this.request("POST", "/v1/billing_portal/sessions", {
|
|
265
|
+
customer,
|
|
266
|
+
return_url: input.returnUrl
|
|
267
|
+
});
|
|
268
|
+
return { url: String(created.url) };
|
|
269
|
+
}
|
|
270
|
+
async swapSubscription(gatewayRef, input) {
|
|
271
|
+
const sub = await this.request("GET", `/v1/subscriptions/${gatewayRef}`);
|
|
272
|
+
const itemId = sub.items?.data?.[0]?.id;
|
|
273
|
+
if (!itemId) throw new StripeRequestError(500, "subscription has no line item to update");
|
|
274
|
+
await this.request("POST", `/v1/subscriptions/${gatewayRef}`, {
|
|
275
|
+
"items[0][id]": itemId,
|
|
276
|
+
"items[0][price]": this.options.priceId(input.plan, input.period),
|
|
277
|
+
proration_behavior: input.prorationBehavior ?? "create_prorations"
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
verifyWebhook(rawBody, signature) {
|
|
281
|
+
if (!signature) throw new WebhookInvalidError();
|
|
282
|
+
const parts = Object.fromEntries(
|
|
283
|
+
signature.split(",").map((pair) => {
|
|
284
|
+
const index = pair.indexOf("=");
|
|
285
|
+
return [pair.slice(0, index), pair.slice(index + 1)];
|
|
286
|
+
})
|
|
287
|
+
);
|
|
288
|
+
const timestamp = Number(parts.t);
|
|
289
|
+
if (!Number.isFinite(timestamp) || !parts.v1) throw new WebhookInvalidError();
|
|
290
|
+
const expected = createHmac("sha256", this.options.webhookSecret).update(`${timestamp}.${rawBody}`).digest("hex");
|
|
291
|
+
const received = parts.v1;
|
|
292
|
+
const a = Buffer.from(expected);
|
|
293
|
+
const b = Buffer.from(received);
|
|
294
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) throw new WebhookInvalidError();
|
|
295
|
+
if (Math.abs(this.now() / 1e3 - timestamp) > this.tolerance) throw new WebhookInvalidError();
|
|
296
|
+
let event;
|
|
297
|
+
try {
|
|
298
|
+
event = JSON.parse(rawBody);
|
|
299
|
+
} catch {
|
|
300
|
+
throw new WebhookInvalidError();
|
|
301
|
+
}
|
|
302
|
+
const type = event.type ? EVENT_MAP[event.type] : void 0;
|
|
303
|
+
if (!type || !event.id) return null;
|
|
304
|
+
const billableId = this.resolveBillableId(event);
|
|
305
|
+
if (!billableId) return null;
|
|
306
|
+
const obj = event.data?.object;
|
|
307
|
+
const gatewayRef = obj?.subscription ?? obj?.id;
|
|
308
|
+
return { id: event.id, type, billableId, ...gatewayRef ? { gatewayRef } : {} };
|
|
309
|
+
}
|
|
310
|
+
async request(method, path, body) {
|
|
311
|
+
const response = await this.fetch(`${this.apiBase}${path}`, {
|
|
312
|
+
method,
|
|
313
|
+
headers: {
|
|
314
|
+
authorization: `Bearer ${this.options.secretKey}`,
|
|
315
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
316
|
+
},
|
|
317
|
+
...body ? { body: formEncode(body) } : {}
|
|
318
|
+
});
|
|
319
|
+
const text = await response.text();
|
|
320
|
+
const json = text ? JSON.parse(text) : {};
|
|
321
|
+
if (!response.ok) {
|
|
322
|
+
const message = json.error?.message ?? text ?? "unknown error";
|
|
323
|
+
throw new StripeRequestError(response.status, message);
|
|
324
|
+
}
|
|
325
|
+
return json;
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
function formEncode(data) {
|
|
329
|
+
return Object.entries(data).map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join("&");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/subscriptions.ts
|
|
333
|
+
import { BasaltError as BasaltError4, parseDuration } from "@basaltkit/core";
|
|
334
|
+
var NotSubscribedError = class extends BasaltError4 {
|
|
335
|
+
status = 402;
|
|
336
|
+
constructor() {
|
|
337
|
+
super("BILLING_SUBSCRIPTION_REQUIRED", "An active subscription is required.");
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
var FeatureUnavailableError = class extends BasaltError4 {
|
|
341
|
+
status = 403;
|
|
342
|
+
constructor(feature) {
|
|
343
|
+
super("BILLING_FEATURE_UNAVAILABLE", `The feature "${feature}" is not available on this plan.`);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
var QuotaExceededError = class extends BasaltError4 {
|
|
347
|
+
status = 402;
|
|
348
|
+
constructor(feature, remaining) {
|
|
349
|
+
super(
|
|
350
|
+
"BILLING_QUOTA_EXCEEDED",
|
|
351
|
+
`Quota exceeded for "${feature}" (remaining: ${remaining}).`
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
var GatewayUnsupportedError = class extends BasaltError4 {
|
|
356
|
+
status = 501;
|
|
357
|
+
constructor(capability) {
|
|
358
|
+
super("BILLING_GATEWAY_UNSUPPORTED", `The billing gateway does not support "${capability}".`);
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
var currentMeterPeriod = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 7);
|
|
362
|
+
var Subscriptions = class {
|
|
363
|
+
plans;
|
|
364
|
+
store;
|
|
365
|
+
usage;
|
|
366
|
+
gateway;
|
|
367
|
+
fallbackPlan;
|
|
368
|
+
hooks;
|
|
369
|
+
webhooks;
|
|
370
|
+
constructor(options) {
|
|
371
|
+
this.plans = options.plans;
|
|
372
|
+
this.store = options.store ?? new MemorySubscriptionStore();
|
|
373
|
+
this.usage = options.usage ?? new MemoryUsageStore();
|
|
374
|
+
this.gateway = options.gateway;
|
|
375
|
+
this.webhooks = options.webhooks ?? new MemoryWebhookStore();
|
|
376
|
+
this.fallbackPlan = options.fallbackPlan;
|
|
377
|
+
this.hooks = options.hooks;
|
|
378
|
+
if (options.fallbackPlan) this.plan(options.fallbackPlan);
|
|
379
|
+
}
|
|
380
|
+
plan(name) {
|
|
381
|
+
const plan = this.plans[name];
|
|
382
|
+
if (!plan) throw new UnknownPlanError(name);
|
|
383
|
+
return plan;
|
|
384
|
+
}
|
|
385
|
+
async subscribe(billableId, planName, options = {}) {
|
|
386
|
+
const plan = this.plan(planName);
|
|
387
|
+
const period = options.period ?? "monthly";
|
|
388
|
+
const price = planPrice(plan, period);
|
|
389
|
+
const record = {
|
|
390
|
+
billableId,
|
|
391
|
+
plan: planName,
|
|
392
|
+
period,
|
|
393
|
+
status: plan.trial ? "trialing" : "active",
|
|
394
|
+
...plan.trial ? { trialEndsAt: Date.now() + parseDuration(plan.trial) } : {}
|
|
395
|
+
};
|
|
396
|
+
if (this.gateway && typeof price === "number" && price > 0) {
|
|
397
|
+
const trialDays = plan.trial ? Math.max(1, Math.ceil(parseDuration(plan.trial) / 864e5)) : void 0;
|
|
398
|
+
const { gatewayRef } = await this.gateway.createSubscription({
|
|
399
|
+
billableId,
|
|
400
|
+
plan: planName,
|
|
401
|
+
period,
|
|
402
|
+
price,
|
|
403
|
+
...trialDays !== void 0 ? { trialDays } : {}
|
|
404
|
+
});
|
|
405
|
+
record.gatewayRef = gatewayRef;
|
|
406
|
+
}
|
|
407
|
+
await this.store.save(record);
|
|
408
|
+
await this.hooks?.emit("billing:subscribed", { subscription: record });
|
|
409
|
+
return record;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Starts a hosted Checkout flow for a paid plan. Records the intended
|
|
413
|
+
* subscription locally as `incomplete` — it becomes `active` when the
|
|
414
|
+
* gateway confirms payment via webhook (`payment.succeeded`). Returns the
|
|
415
|
+
* URL to redirect the customer to.
|
|
416
|
+
*/
|
|
417
|
+
async checkout(billableId, planName, options) {
|
|
418
|
+
const plan = this.plan(planName);
|
|
419
|
+
if (!this.gateway?.createCheckoutSession) throw new GatewayUnsupportedError("checkout");
|
|
420
|
+
const period = options.period ?? "monthly";
|
|
421
|
+
const trialDays = plan.trial ? Math.max(1, Math.ceil(parseDuration(plan.trial) / 864e5)) : void 0;
|
|
422
|
+
const session = await this.gateway.createCheckoutSession({
|
|
423
|
+
billableId,
|
|
424
|
+
plan: planName,
|
|
425
|
+
period,
|
|
426
|
+
successUrl: options.successUrl,
|
|
427
|
+
cancelUrl: options.cancelUrl,
|
|
428
|
+
...trialDays !== void 0 ? { trialDays } : {}
|
|
429
|
+
});
|
|
430
|
+
const record = { billableId, plan: planName, period, status: "incomplete" };
|
|
431
|
+
await this.store.save(record);
|
|
432
|
+
await this.hooks?.emit("billing:checkout_started", { billableId, plan: planName, url: session.url });
|
|
433
|
+
return { url: session.url };
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Opens a Customer Portal session for self-service billing (update card,
|
|
437
|
+
* change plan, cancel). Returns the URL to redirect the customer to.
|
|
438
|
+
*/
|
|
439
|
+
async portal(billableId, options) {
|
|
440
|
+
if (!this.gateway?.createPortalSession) throw new GatewayUnsupportedError("portal");
|
|
441
|
+
return this.gateway.createPortalSession({ billableId, returnUrl: options.returnUrl });
|
|
442
|
+
}
|
|
443
|
+
async get(billableId) {
|
|
444
|
+
return this.store.get(billableId);
|
|
445
|
+
}
|
|
446
|
+
/** Active = status active, or trialing with the trial still running. */
|
|
447
|
+
async subscribed(billableId, plan) {
|
|
448
|
+
const record = await this.store.get(billableId);
|
|
449
|
+
if (!record) return false;
|
|
450
|
+
if (plan && record.plan !== plan) return false;
|
|
451
|
+
return this.isActive(record);
|
|
452
|
+
}
|
|
453
|
+
async onTrial(billableId) {
|
|
454
|
+
const record = await this.store.get(billableId);
|
|
455
|
+
return record?.status === "trialing" && record.trialEndsAt !== void 0 && record.trialEndsAt > Date.now();
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Changes the plan on an active subscription. When the subscription is
|
|
459
|
+
* gateway-backed, the change is pushed to the gateway with proration so the
|
|
460
|
+
* customer is credited/charged the mid-cycle difference (pass
|
|
461
|
+
* `{ prorate: false }` to switch at the next renewal with no immediate
|
|
462
|
+
* settlement).
|
|
463
|
+
*/
|
|
464
|
+
async swap(billableId, planName, options = {}) {
|
|
465
|
+
const record = await this.store.get(billableId);
|
|
466
|
+
if (!record || !this.isActive(record)) throw new NotSubscribedError();
|
|
467
|
+
this.plan(planName);
|
|
468
|
+
const from = record.plan;
|
|
469
|
+
if (record.gatewayRef && this.gateway?.swapSubscription) {
|
|
470
|
+
await this.gateway.swapSubscription(record.gatewayRef, {
|
|
471
|
+
plan: planName,
|
|
472
|
+
period: record.period,
|
|
473
|
+
prorationBehavior: options.prorate === false ? "none" : "create_prorations"
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
record.plan = planName;
|
|
477
|
+
await this.store.save(record);
|
|
478
|
+
await this.hooks?.emit("billing:swapped", { subscription: record, from });
|
|
479
|
+
return record;
|
|
480
|
+
}
|
|
481
|
+
async cancel(billableId, options = {}) {
|
|
482
|
+
const record = await this.store.get(billableId);
|
|
483
|
+
if (!record) throw new NotSubscribedError();
|
|
484
|
+
const atPeriodEnd = options.atPeriodEnd ?? true;
|
|
485
|
+
if (record.gatewayRef) {
|
|
486
|
+
await this.gateway?.cancelSubscription(record.gatewayRef, { atPeriodEnd });
|
|
487
|
+
}
|
|
488
|
+
if (atPeriodEnd) {
|
|
489
|
+
record.cancelAtPeriodEnd = true;
|
|
490
|
+
} else {
|
|
491
|
+
record.status = "canceled";
|
|
492
|
+
record.canceledAt = Date.now();
|
|
493
|
+
}
|
|
494
|
+
await this.store.save(record);
|
|
495
|
+
await this.hooks?.emit("billing:canceled", { subscription: record });
|
|
496
|
+
return record;
|
|
497
|
+
}
|
|
498
|
+
async resume(billableId) {
|
|
499
|
+
const record = await this.store.get(billableId);
|
|
500
|
+
if (!record || record.status === "canceled") throw new NotSubscribedError();
|
|
501
|
+
record.cancelAtPeriodEnd = false;
|
|
502
|
+
await this.store.save(record);
|
|
503
|
+
return record;
|
|
504
|
+
}
|
|
505
|
+
/** Feature checks and consumption, Soulbscription-style. */
|
|
506
|
+
features(billableId) {
|
|
507
|
+
const resolve = async () => {
|
|
508
|
+
const record = await this.store.get(billableId);
|
|
509
|
+
if (record && this.isActive(record)) return this.plan(record.plan);
|
|
510
|
+
return this.fallbackPlan ? this.plan(this.fallbackPlan) : null;
|
|
511
|
+
};
|
|
512
|
+
const periodKey = (plan, feature) => isMeter(plan.features[feature]) ? currentMeterPeriod() : "lifetime";
|
|
513
|
+
return {
|
|
514
|
+
can: async (feature) => {
|
|
515
|
+
const plan = await resolve();
|
|
516
|
+
return plan !== null && featureLimit(plan.features[feature]) > 0;
|
|
517
|
+
},
|
|
518
|
+
limit: async (feature) => {
|
|
519
|
+
const plan = await resolve();
|
|
520
|
+
return plan ? featureLimit(plan.features[feature]) : 0;
|
|
521
|
+
},
|
|
522
|
+
usage: async (feature) => {
|
|
523
|
+
const plan = await resolve();
|
|
524
|
+
if (!plan) return 0;
|
|
525
|
+
return this.usage.get(billableId, feature, periodKey(plan, feature));
|
|
526
|
+
},
|
|
527
|
+
remaining: async (feature) => {
|
|
528
|
+
const plan = await resolve();
|
|
529
|
+
if (!plan) return 0;
|
|
530
|
+
const limit = featureLimit(plan.features[feature]);
|
|
531
|
+
if (limit === Number.POSITIVE_INFINITY) return limit;
|
|
532
|
+
const used = await this.usage.get(billableId, feature, periodKey(plan, feature));
|
|
533
|
+
return Math.max(0, limit - used);
|
|
534
|
+
},
|
|
535
|
+
consume: async (feature, amount = 1) => {
|
|
536
|
+
const plan = await resolve();
|
|
537
|
+
if (!plan) throw new FeatureUnavailableError(feature);
|
|
538
|
+
const limit = featureLimit(plan.features[feature]);
|
|
539
|
+
if (limit === 0) throw new FeatureUnavailableError(feature);
|
|
540
|
+
const key = periodKey(plan, feature);
|
|
541
|
+
if (limit === Number.POSITIVE_INFINITY) {
|
|
542
|
+
return this.usage.increment(billableId, feature, key, amount);
|
|
543
|
+
}
|
|
544
|
+
const result = await this.usage.consume(billableId, feature, key, amount, limit);
|
|
545
|
+
if (!result.applied) {
|
|
546
|
+
throw new QuotaExceededError(feature, Math.max(0, limit - result.used));
|
|
547
|
+
}
|
|
548
|
+
return result.used;
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Applies a gateway webhook: idempotent by event id, updates local state
|
|
554
|
+
* and emits domain hooks. Local state is the read model — feature checks
|
|
555
|
+
* never call the gateway.
|
|
556
|
+
*/
|
|
557
|
+
async handleWebhook(event) {
|
|
558
|
+
const fresh = await this.webhooks.markProcessed(event.id);
|
|
559
|
+
if (!fresh) return false;
|
|
560
|
+
try {
|
|
561
|
+
const record = await this.store.get(event.billableId);
|
|
562
|
+
if (record) {
|
|
563
|
+
if (event.gatewayRef && !record.gatewayRef) record.gatewayRef = event.gatewayRef;
|
|
564
|
+
if (event.type === "subscription.canceled") {
|
|
565
|
+
record.status = "canceled";
|
|
566
|
+
record.canceledAt = Date.now();
|
|
567
|
+
} else if (event.type === "payment.failed") {
|
|
568
|
+
record.status = "past_due";
|
|
569
|
+
} else if (event.type === "payment.succeeded") {
|
|
570
|
+
record.status = "active";
|
|
571
|
+
record.cancelAtPeriodEnd = false;
|
|
572
|
+
}
|
|
573
|
+
await this.store.save(record);
|
|
574
|
+
}
|
|
575
|
+
} catch (error) {
|
|
576
|
+
await this.webhooks.release(event.id);
|
|
577
|
+
throw error;
|
|
578
|
+
}
|
|
579
|
+
await this.hooks?.emit("billing:webhook", { event });
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Maintenance (run from the scheduler): settles expired local trials.
|
|
584
|
+
* Gateway-backed trials are settled by the gateway's webhook, not here.
|
|
585
|
+
*/
|
|
586
|
+
async expireTrials() {
|
|
587
|
+
const expired = [];
|
|
588
|
+
for (const record of await this.store.all()) {
|
|
589
|
+
if (record.status === "trialing" && record.trialEndsAt !== void 0 && record.trialEndsAt <= Date.now() && record.gatewayRef === void 0) {
|
|
590
|
+
const price = planPrice(this.plan(record.plan), record.period);
|
|
591
|
+
record.status = typeof price === "number" && price === 0 ? "active" : "past_due";
|
|
592
|
+
await this.store.save(record);
|
|
593
|
+
expired.push(record);
|
|
594
|
+
await this.hooks?.emit("billing:trial_expired", { subscription: record });
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return expired;
|
|
598
|
+
}
|
|
599
|
+
isActive(record) {
|
|
600
|
+
if (record.status === "active") return true;
|
|
601
|
+
return record.status === "trialing" && record.trialEndsAt !== void 0 && record.trialEndsAt > Date.now();
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
// src/plugin.ts
|
|
606
|
+
import { createToken, ctx, definePlugin, ensureMetadata } from "@basaltkit/core";
|
|
607
|
+
import { route } from "@basaltkit/fastify";
|
|
608
|
+
import { z } from "zod";
|
|
609
|
+
var SUBSCRIPTIONS = createToken("subscriptions");
|
|
610
|
+
function subscriptionsPlugin(options) {
|
|
611
|
+
return definePlugin({
|
|
612
|
+
name: "basalt:subscriptions",
|
|
613
|
+
register({ container, hooks }) {
|
|
614
|
+
container.singleton(SUBSCRIPTIONS, () => new Subscriptions({ ...options, hooks }));
|
|
615
|
+
const guard = async ({ route: definition, context, container: c }) => {
|
|
616
|
+
const requiredSubscription = definition.meta?.["subscribed"];
|
|
617
|
+
const requiredFeature = definition.meta?.["feature"];
|
|
618
|
+
if (requiredSubscription === void 0 && requiredFeature === void 0) return;
|
|
619
|
+
const subscriptions2 = c.get(SUBSCRIPTIONS);
|
|
620
|
+
const billableId = context.tenant?.id;
|
|
621
|
+
if (requiredSubscription !== void 0) {
|
|
622
|
+
if (!billableId) throw new NotSubscribedError();
|
|
623
|
+
const plan = typeof requiredSubscription === "string" ? requiredSubscription : void 0;
|
|
624
|
+
if (!await subscriptions2.subscribed(billableId, plan)) {
|
|
625
|
+
throw new NotSubscribedError();
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (typeof requiredFeature === "string") {
|
|
629
|
+
if (!billableId || !await subscriptions2.features(billableId).can(requiredFeature)) {
|
|
630
|
+
throw new FeatureUnavailableError(requiredFeature);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
ensureMetadata(container).add("http:guards", guard);
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
var billable = () => {
|
|
639
|
+
const id = ctx().tenant?.id;
|
|
640
|
+
if (!id) throw new NotSubscribedError();
|
|
641
|
+
return id;
|
|
642
|
+
};
|
|
643
|
+
var subscriptions = () => ctx().container.get(SUBSCRIPTIONS);
|
|
644
|
+
function billingRoutes(options) {
|
|
645
|
+
const portalReturn = options.portalReturnUrl ?? options.successUrl;
|
|
646
|
+
return [
|
|
647
|
+
route({
|
|
648
|
+
method: "POST",
|
|
649
|
+
url: "/billing/checkout",
|
|
650
|
+
body: z.object({
|
|
651
|
+
plan: z.string(),
|
|
652
|
+
period: z.enum(["monthly", "yearly"]).optional(),
|
|
653
|
+
successUrl: z.string().url().optional(),
|
|
654
|
+
cancelUrl: z.string().url().optional()
|
|
655
|
+
}),
|
|
656
|
+
async handler({ body }) {
|
|
657
|
+
return subscriptions().checkout(billable(), body.plan, {
|
|
658
|
+
successUrl: body.successUrl ?? options.successUrl,
|
|
659
|
+
cancelUrl: body.cancelUrl ?? options.cancelUrl,
|
|
660
|
+
...body.period !== void 0 ? { period: body.period } : {}
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}),
|
|
664
|
+
route({
|
|
665
|
+
method: "POST",
|
|
666
|
+
url: "/billing/portal",
|
|
667
|
+
body: z.object({ returnUrl: z.string().url().optional() }).optional(),
|
|
668
|
+
async handler({ body }) {
|
|
669
|
+
return subscriptions().portal(billable(), { returnUrl: body?.returnUrl ?? portalReturn });
|
|
670
|
+
}
|
|
671
|
+
})
|
|
672
|
+
];
|
|
673
|
+
}
|
|
674
|
+
function billingWebhookRoute(gateway) {
|
|
675
|
+
return route({
|
|
676
|
+
method: "POST",
|
|
677
|
+
url: "/billing/webhook",
|
|
678
|
+
body: z.unknown(),
|
|
679
|
+
async handler({ request, reply }) {
|
|
680
|
+
const rawBody = typeof request.body === "string" ? request.body : JSON.stringify(request.body);
|
|
681
|
+
const header = request.headers["stripe-signature"] ?? request.headers["x-billing-signature"];
|
|
682
|
+
const event = gateway.verifyWebhook(
|
|
683
|
+
rawBody,
|
|
684
|
+
Array.isArray(header) ? header[0] : header
|
|
685
|
+
);
|
|
686
|
+
const subscriptions2 = ctx().container.get(SUBSCRIPTIONS);
|
|
687
|
+
if (!event) return reply.code(200).send({ received: true, ignored: true });
|
|
688
|
+
const applied = await subscriptions2.handleWebhook(event);
|
|
689
|
+
return reply.code(200).send({ received: true, duplicate: !applied });
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
export {
|
|
694
|
+
FakeBillingGateway,
|
|
695
|
+
FeatureUnavailableError,
|
|
696
|
+
GatewayUnsupportedError,
|
|
697
|
+
MemorySubscriptionStore,
|
|
698
|
+
MemoryUsageStore,
|
|
699
|
+
MemoryWebhookStore,
|
|
700
|
+
NotSubscribedError,
|
|
701
|
+
QuotaExceededError,
|
|
702
|
+
RedisUsageStore,
|
|
703
|
+
RedisWebhookStore,
|
|
704
|
+
SUBSCRIPTIONS,
|
|
705
|
+
StripeBillingGateway,
|
|
706
|
+
StripeRequestError,
|
|
707
|
+
Subscriptions,
|
|
708
|
+
UnknownPlanError,
|
|
709
|
+
WebhookInvalidError,
|
|
710
|
+
billingRoutes,
|
|
711
|
+
billingWebhookRoute,
|
|
712
|
+
definePlans,
|
|
713
|
+
featureLimit,
|
|
714
|
+
isMeter,
|
|
715
|
+
meter,
|
|
716
|
+
planPrice,
|
|
717
|
+
subscriptionsPlugin
|
|
718
|
+
};
|