billsdk 0.0.1 → 0.1.1
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/adapters/drizzle/index.d.ts +1 -0
- package/dist/adapters/drizzle/index.js +3 -0
- package/dist/adapters/drizzle/index.js.map +1 -0
- package/dist/adapters/memory-adapter/index.d.ts +3 -0
- package/dist/adapters/memory-adapter/index.js +3 -0
- package/dist/adapters/memory-adapter/index.js.map +1 -0
- package/dist/adapters/payment/index.d.ts +1 -0
- package/dist/adapters/payment/index.js +3 -0
- package/dist/adapters/payment/index.js.map +1 -0
- package/dist/client/index.d.ts +133 -0
- package/dist/client/index.js +257 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/react/index.d.ts +120 -0
- package/dist/client/react/index.js +248 -0
- package/dist/client/react/index.js.map +1 -0
- package/dist/index.d.ts +25 -5
- package/dist/index.js +1293 -29
- package/dist/index.js.map +1 -1
- package/dist/integrations/next.d.ts +31 -0
- package/dist/integrations/next.js +15 -0
- package/dist/integrations/next.js.map +1 -0
- package/dist/types-Ofy1HBSd.d.ts +113 -0
- package/package.json +90 -23
- package/README.md +0 -22
- package/dist/index.d.mts +0 -8
- package/dist/index.mjs +0 -10
- package/dist/index.mjs.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,36 +1,1300 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
export * from '@billsdk/core';
|
|
2
|
+
export { drizzleAdapter } from '@billsdk/drizzle-adapter';
|
|
3
|
+
import { memoryAdapter } from '@billsdk/memory-adapter';
|
|
4
|
+
export { memoryAdapter } from '@billsdk/memory-adapter';
|
|
5
|
+
import { paymentAdapter } from '@billsdk/payment-adapter';
|
|
6
|
+
export { paymentAdapter } from '@billsdk/payment-adapter';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
|
|
9
|
+
// src/index.ts
|
|
10
|
+
var createCustomerSchema = z.object({
|
|
11
|
+
externalId: z.string().min(1),
|
|
12
|
+
email: z.string().email(),
|
|
13
|
+
name: z.string().optional(),
|
|
14
|
+
metadata: z.record(z.string(), z.unknown()).optional()
|
|
15
|
+
});
|
|
16
|
+
var getCustomerQuerySchema = z.object({
|
|
17
|
+
externalId: z.string().min(1)
|
|
18
|
+
});
|
|
19
|
+
var customerEndpoints = {
|
|
20
|
+
createCustomer: {
|
|
21
|
+
path: "/customer",
|
|
22
|
+
options: {
|
|
23
|
+
method: "POST",
|
|
24
|
+
body: createCustomerSchema
|
|
25
|
+
},
|
|
26
|
+
handler: async (context) => {
|
|
27
|
+
const { ctx, body } = context;
|
|
28
|
+
const existing = await ctx.internalAdapter.findCustomerByExternalId(
|
|
29
|
+
body.externalId
|
|
30
|
+
);
|
|
31
|
+
if (existing) {
|
|
32
|
+
return { customer: existing };
|
|
33
|
+
}
|
|
34
|
+
const customer = await ctx.internalAdapter.createCustomer(body);
|
|
35
|
+
return { customer };
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
getCustomer: {
|
|
39
|
+
path: "/customer",
|
|
40
|
+
options: {
|
|
41
|
+
method: "GET",
|
|
42
|
+
query: getCustomerQuerySchema
|
|
43
|
+
},
|
|
44
|
+
handler: async (context) => {
|
|
45
|
+
const { ctx, query } = context;
|
|
46
|
+
const customer = await ctx.internalAdapter.findCustomerByExternalId(
|
|
47
|
+
query.externalId
|
|
48
|
+
);
|
|
49
|
+
if (!customer) {
|
|
50
|
+
return { customer: null };
|
|
51
|
+
}
|
|
52
|
+
return { customer };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
9
55
|
};
|
|
10
|
-
var
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
56
|
+
var checkFeatureQuerySchema = z.object({
|
|
57
|
+
customerId: z.string().min(1),
|
|
58
|
+
feature: z.string().min(1)
|
|
59
|
+
});
|
|
60
|
+
var listFeaturesQuerySchema = z.object({
|
|
61
|
+
customerId: z.string().min(1)
|
|
62
|
+
});
|
|
63
|
+
var featureEndpoints = {
|
|
64
|
+
checkFeature: {
|
|
65
|
+
path: "/features/check",
|
|
66
|
+
options: {
|
|
67
|
+
method: "GET",
|
|
68
|
+
query: checkFeatureQuerySchema
|
|
69
|
+
},
|
|
70
|
+
handler: async (context) => {
|
|
71
|
+
const { ctx, query } = context;
|
|
72
|
+
const result = await ctx.internalAdapter.checkFeatureAccess(
|
|
73
|
+
query.customerId,
|
|
74
|
+
query.feature
|
|
75
|
+
);
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
listFeatures: {
|
|
80
|
+
path: "/features",
|
|
81
|
+
options: {
|
|
82
|
+
method: "GET",
|
|
83
|
+
query: listFeaturesQuerySchema
|
|
84
|
+
},
|
|
85
|
+
handler: async (context) => {
|
|
86
|
+
const { ctx, query } = context;
|
|
87
|
+
const customer = await ctx.internalAdapter.findCustomerByExternalId(
|
|
88
|
+
query.customerId
|
|
89
|
+
);
|
|
90
|
+
if (!customer) {
|
|
91
|
+
return { features: [] };
|
|
92
|
+
}
|
|
93
|
+
const subscription = await ctx.internalAdapter.findSubscriptionByCustomerId(customer.id);
|
|
94
|
+
if (!subscription) {
|
|
95
|
+
return { features: [] };
|
|
96
|
+
}
|
|
97
|
+
const featureCodes = ctx.internalAdapter.getPlanFeatures(
|
|
98
|
+
subscription.planCode
|
|
99
|
+
);
|
|
100
|
+
const features = featureCodes.map((code) => {
|
|
101
|
+
const feature = ctx.internalAdapter.findFeatureByCode(code);
|
|
102
|
+
return {
|
|
103
|
+
code,
|
|
104
|
+
name: feature?.name ?? code,
|
|
105
|
+
type: feature?.type ?? "boolean",
|
|
106
|
+
enabled: true
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
return { features };
|
|
110
|
+
}
|
|
15
111
|
}
|
|
16
|
-
return to;
|
|
17
112
|
};
|
|
18
|
-
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
113
|
|
|
20
|
-
// src/
|
|
21
|
-
var
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
114
|
+
// src/api/routes/health.ts
|
|
115
|
+
var healthEndpoint = {
|
|
116
|
+
health: {
|
|
117
|
+
path: "/health",
|
|
118
|
+
options: {
|
|
119
|
+
method: "GET"
|
|
120
|
+
},
|
|
121
|
+
handler: async () => {
|
|
122
|
+
return {
|
|
123
|
+
status: "ok",
|
|
124
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
125
|
+
version: "0.1.0"
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
var planEndpoints = {
|
|
131
|
+
listPlans: {
|
|
132
|
+
path: "/plans",
|
|
133
|
+
options: {
|
|
134
|
+
method: "GET"
|
|
135
|
+
},
|
|
136
|
+
handler: async (context) => {
|
|
137
|
+
const { ctx } = context;
|
|
138
|
+
const plans = ctx.internalAdapter.listPlans({ includePrivate: false });
|
|
139
|
+
return { plans };
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
getPlan: {
|
|
143
|
+
path: "/plan",
|
|
144
|
+
options: {
|
|
145
|
+
method: "GET",
|
|
146
|
+
query: z.object({
|
|
147
|
+
code: z.string()
|
|
148
|
+
})
|
|
149
|
+
},
|
|
150
|
+
handler: async (context) => {
|
|
151
|
+
const { ctx, query } = context;
|
|
152
|
+
const plan = ctx.internalAdapter.findPlanByCode(query.code);
|
|
153
|
+
if (!plan) {
|
|
154
|
+
return { plan: null };
|
|
155
|
+
}
|
|
156
|
+
return { plan };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
var getSubscriptionQuerySchema = z.object({
|
|
161
|
+
customerId: z.string().min(1)
|
|
162
|
+
});
|
|
163
|
+
var createSubscriptionSchema = z.object({
|
|
164
|
+
customerId: z.string().min(1),
|
|
165
|
+
planCode: z.string().min(1),
|
|
166
|
+
interval: z.enum(["monthly", "yearly"]).optional().default("monthly"),
|
|
167
|
+
successUrl: z.string().url().optional(),
|
|
168
|
+
cancelUrl: z.string().url().optional()
|
|
25
169
|
});
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return "Billsdk package reserved";
|
|
30
|
-
}
|
|
31
|
-
// Annotate the CommonJS export names for ESM import in node:
|
|
32
|
-
0 && (module.exports = {
|
|
33
|
-
billsdk,
|
|
34
|
-
version
|
|
170
|
+
var cancelSubscriptionSchema = z.object({
|
|
171
|
+
customerId: z.string().min(1),
|
|
172
|
+
cancelAt: z.enum(["period_end", "immediately"]).optional().default("period_end")
|
|
35
173
|
});
|
|
174
|
+
var subscriptionEndpoints = {
|
|
175
|
+
getSubscription: {
|
|
176
|
+
path: "/subscription",
|
|
177
|
+
options: {
|
|
178
|
+
method: "GET",
|
|
179
|
+
query: getSubscriptionQuerySchema
|
|
180
|
+
},
|
|
181
|
+
handler: async (context) => {
|
|
182
|
+
const { ctx, query } = context;
|
|
183
|
+
const customer = await ctx.internalAdapter.findCustomerByExternalId(
|
|
184
|
+
query.customerId
|
|
185
|
+
);
|
|
186
|
+
if (!customer) {
|
|
187
|
+
return { subscription: null };
|
|
188
|
+
}
|
|
189
|
+
const subscription = await ctx.internalAdapter.findSubscriptionByCustomerId(customer.id);
|
|
190
|
+
if (!subscription) {
|
|
191
|
+
return { subscription: null };
|
|
192
|
+
}
|
|
193
|
+
const plan = ctx.internalAdapter.findPlanByCode(subscription.planCode);
|
|
194
|
+
const price = plan ? ctx.internalAdapter.getPlanPrice(
|
|
195
|
+
subscription.planCode,
|
|
196
|
+
subscription.interval
|
|
197
|
+
) : null;
|
|
198
|
+
return {
|
|
199
|
+
subscription,
|
|
200
|
+
plan,
|
|
201
|
+
price
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
createSubscription: {
|
|
206
|
+
path: "/subscription",
|
|
207
|
+
options: {
|
|
208
|
+
method: "POST",
|
|
209
|
+
body: createSubscriptionSchema
|
|
210
|
+
},
|
|
211
|
+
handler: async (context) => {
|
|
212
|
+
const { ctx, body } = context;
|
|
213
|
+
if (!ctx.paymentAdapter) {
|
|
214
|
+
throw new Error("Payment adapter not configured");
|
|
215
|
+
}
|
|
216
|
+
const customer = await ctx.internalAdapter.findCustomerByExternalId(
|
|
217
|
+
body.customerId
|
|
218
|
+
);
|
|
219
|
+
if (!customer) {
|
|
220
|
+
throw new Error("Customer not found");
|
|
221
|
+
}
|
|
222
|
+
const plan = ctx.internalAdapter.findPlanByCode(body.planCode);
|
|
223
|
+
if (!plan) {
|
|
224
|
+
throw new Error("Plan not found");
|
|
225
|
+
}
|
|
226
|
+
const price = ctx.internalAdapter.getPlanPrice(
|
|
227
|
+
body.planCode,
|
|
228
|
+
body.interval
|
|
229
|
+
);
|
|
230
|
+
if (!price) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`No price found for plan ${body.planCode} with interval ${body.interval}`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const subscription = await ctx.internalAdapter.createSubscription({
|
|
236
|
+
customerId: customer.id,
|
|
237
|
+
planCode: body.planCode,
|
|
238
|
+
interval: body.interval,
|
|
239
|
+
status: "pending_payment",
|
|
240
|
+
trialDays: price.trialDays
|
|
241
|
+
});
|
|
242
|
+
const result = await ctx.paymentAdapter.processPayment({
|
|
243
|
+
customer: {
|
|
244
|
+
id: customer.id,
|
|
245
|
+
email: customer.email,
|
|
246
|
+
providerCustomerId: customer.providerCustomerId
|
|
247
|
+
},
|
|
248
|
+
plan: {
|
|
249
|
+
code: plan.code,
|
|
250
|
+
name: plan.name
|
|
251
|
+
},
|
|
252
|
+
price: {
|
|
253
|
+
amount: price.amount,
|
|
254
|
+
currency: price.currency,
|
|
255
|
+
interval: price.interval
|
|
256
|
+
},
|
|
257
|
+
subscription: {
|
|
258
|
+
id: subscription.id
|
|
259
|
+
},
|
|
260
|
+
successUrl: body.successUrl,
|
|
261
|
+
cancelUrl: body.cancelUrl,
|
|
262
|
+
metadata: {
|
|
263
|
+
subscriptionId: subscription.id,
|
|
264
|
+
customerId: customer.id
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
if (result.status === "active") {
|
|
268
|
+
const existingSubscriptions = await ctx.internalAdapter.listSubscriptions(customer.id);
|
|
269
|
+
for (const existing of existingSubscriptions) {
|
|
270
|
+
if (existing.id !== subscription.id && (existing.status === "active" || existing.status === "trialing")) {
|
|
271
|
+
await ctx.internalAdapter.cancelSubscription(existing.id);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const activeSubscription = await ctx.internalAdapter.updateSubscription(
|
|
275
|
+
subscription.id,
|
|
276
|
+
{ status: "active" }
|
|
277
|
+
);
|
|
278
|
+
if (result.providerCustomerId && !customer.providerCustomerId) {
|
|
279
|
+
await ctx.internalAdapter.updateCustomer(customer.id, {
|
|
280
|
+
providerCustomerId: result.providerCustomerId
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
subscription: activeSubscription ?? {
|
|
285
|
+
...subscription,
|
|
286
|
+
status: "active"
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (result.status === "pending") {
|
|
291
|
+
await ctx.internalAdapter.updateSubscription(subscription.id, {
|
|
292
|
+
providerCheckoutSessionId: result.sessionId
|
|
293
|
+
});
|
|
294
|
+
if (result.providerCustomerId && !customer.providerCustomerId) {
|
|
295
|
+
await ctx.internalAdapter.updateCustomer(customer.id, {
|
|
296
|
+
providerCustomerId: result.providerCustomerId
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
subscription,
|
|
301
|
+
redirectUrl: result.redirectUrl
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
await ctx.internalAdapter.updateSubscription(subscription.id, {
|
|
305
|
+
status: "canceled"
|
|
306
|
+
});
|
|
307
|
+
throw new Error(result.error);
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
cancelSubscription: {
|
|
311
|
+
path: "/subscription/cancel",
|
|
312
|
+
options: {
|
|
313
|
+
method: "POST",
|
|
314
|
+
body: cancelSubscriptionSchema
|
|
315
|
+
},
|
|
316
|
+
handler: async (context) => {
|
|
317
|
+
const { ctx, body } = context;
|
|
318
|
+
const customer = await ctx.internalAdapter.findCustomerByExternalId(
|
|
319
|
+
body.customerId
|
|
320
|
+
);
|
|
321
|
+
if (!customer) {
|
|
322
|
+
throw new Error("Customer not found");
|
|
323
|
+
}
|
|
324
|
+
const subscription = await ctx.internalAdapter.findSubscriptionByCustomerId(customer.id);
|
|
325
|
+
if (!subscription) {
|
|
326
|
+
throw new Error("No active subscription found");
|
|
327
|
+
}
|
|
328
|
+
if (body.cancelAt === "immediately") {
|
|
329
|
+
const canceled2 = await ctx.internalAdapter.cancelSubscription(
|
|
330
|
+
subscription.id
|
|
331
|
+
);
|
|
332
|
+
return { subscription: canceled2, canceledImmediately: true };
|
|
333
|
+
}
|
|
334
|
+
const canceled = await ctx.internalAdapter.cancelSubscription(
|
|
335
|
+
subscription.id,
|
|
336
|
+
subscription.currentPeriodEnd
|
|
337
|
+
);
|
|
338
|
+
return {
|
|
339
|
+
subscription: canceled,
|
|
340
|
+
canceledImmediately: false,
|
|
341
|
+
accessUntil: subscription.currentPeriodEnd
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// src/api/routes/webhook.ts
|
|
348
|
+
var webhookEndpoints = {
|
|
349
|
+
handleWebhook: {
|
|
350
|
+
path: "/webhook",
|
|
351
|
+
options: {
|
|
352
|
+
method: "POST"
|
|
353
|
+
},
|
|
354
|
+
handler: async (context) => {
|
|
355
|
+
const { ctx, request } = context;
|
|
356
|
+
if (!ctx.paymentAdapter) {
|
|
357
|
+
throw new Error("Payment adapter not configured");
|
|
358
|
+
}
|
|
359
|
+
if (!ctx.paymentAdapter.confirmPayment) {
|
|
360
|
+
ctx.logger.debug("Payment adapter does not support confirmPayment");
|
|
361
|
+
return { received: true };
|
|
362
|
+
}
|
|
363
|
+
const result = await ctx.paymentAdapter.confirmPayment(request);
|
|
364
|
+
if (!result) {
|
|
365
|
+
ctx.logger.debug("Webhook event acknowledged but not processed");
|
|
366
|
+
return { received: true };
|
|
367
|
+
}
|
|
368
|
+
ctx.logger.debug("Payment confirmation received", {
|
|
369
|
+
subscriptionId: result.subscriptionId,
|
|
370
|
+
status: result.status
|
|
371
|
+
});
|
|
372
|
+
if (result.status === "active") {
|
|
373
|
+
const subscription = await ctx.internalAdapter.findSubscriptionById(
|
|
374
|
+
result.subscriptionId
|
|
375
|
+
);
|
|
376
|
+
if (subscription) {
|
|
377
|
+
const existingSubscriptions = await ctx.internalAdapter.listSubscriptions(
|
|
378
|
+
subscription.customerId
|
|
379
|
+
);
|
|
380
|
+
for (const existing of existingSubscriptions) {
|
|
381
|
+
if (existing.id !== subscription.id && (existing.status === "active" || existing.status === "trialing")) {
|
|
382
|
+
await ctx.internalAdapter.cancelSubscription(existing.id);
|
|
383
|
+
ctx.logger.info("Canceled previous subscription", {
|
|
384
|
+
subscriptionId: existing.id,
|
|
385
|
+
planCode: existing.planCode
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
await ctx.internalAdapter.updateSubscription(subscription.id, {
|
|
390
|
+
status: "active",
|
|
391
|
+
providerSubscriptionId: result.providerSubscriptionId
|
|
392
|
+
});
|
|
393
|
+
if (result.providerCustomerId) {
|
|
394
|
+
const customer = await ctx.internalAdapter.findCustomerById(
|
|
395
|
+
subscription.customerId
|
|
396
|
+
);
|
|
397
|
+
if (customer && !customer.providerCustomerId) {
|
|
398
|
+
await ctx.internalAdapter.updateCustomer(customer.id, {
|
|
399
|
+
providerCustomerId: result.providerCustomerId
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
ctx.logger.info("Subscription activated via webhook", {
|
|
404
|
+
subscriptionId: subscription.id,
|
|
405
|
+
providerSubscriptionId: result.providerSubscriptionId
|
|
406
|
+
});
|
|
407
|
+
} else {
|
|
408
|
+
ctx.logger.warn("Subscription not found for confirmation", {
|
|
409
|
+
subscriptionId: result.subscriptionId
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
} else if (result.status === "failed") {
|
|
413
|
+
const subscription = await ctx.internalAdapter.findSubscriptionById(
|
|
414
|
+
result.subscriptionId
|
|
415
|
+
);
|
|
416
|
+
if (subscription) {
|
|
417
|
+
await ctx.internalAdapter.updateSubscription(subscription.id, {
|
|
418
|
+
status: "canceled"
|
|
419
|
+
});
|
|
420
|
+
ctx.logger.warn("Payment failed, subscription canceled", {
|
|
421
|
+
subscriptionId: subscription.id
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return { received: true };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
// src/api/router.ts
|
|
431
|
+
function getEndpoints(ctx) {
|
|
432
|
+
const baseEndpoints = {
|
|
433
|
+
...healthEndpoint,
|
|
434
|
+
...customerEndpoints,
|
|
435
|
+
...planEndpoints,
|
|
436
|
+
...subscriptionEndpoints,
|
|
437
|
+
...featureEndpoints,
|
|
438
|
+
...webhookEndpoints
|
|
439
|
+
};
|
|
440
|
+
let allEndpoints = { ...baseEndpoints };
|
|
441
|
+
for (const plugin of ctx.plugins) {
|
|
442
|
+
if (plugin.endpoints) {
|
|
443
|
+
allEndpoints = { ...allEndpoints, ...plugin.endpoints };
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return allEndpoints;
|
|
447
|
+
}
|
|
448
|
+
function parseUrl(url, basePath) {
|
|
449
|
+
const urlObj = new URL(url);
|
|
450
|
+
let path = urlObj.pathname;
|
|
451
|
+
if (path.startsWith(basePath)) {
|
|
452
|
+
path = path.slice(basePath.length);
|
|
453
|
+
}
|
|
454
|
+
if (!path.startsWith("/")) {
|
|
455
|
+
path = `/${path}`;
|
|
456
|
+
}
|
|
457
|
+
return { path, query: urlObj.searchParams };
|
|
458
|
+
}
|
|
459
|
+
function queryToObject(query) {
|
|
460
|
+
const obj = {};
|
|
461
|
+
query.forEach((value, key) => {
|
|
462
|
+
obj[key] = value;
|
|
463
|
+
});
|
|
464
|
+
return obj;
|
|
465
|
+
}
|
|
466
|
+
function jsonResponse(data, status = 200) {
|
|
467
|
+
return new Response(JSON.stringify(data), {
|
|
468
|
+
status,
|
|
469
|
+
headers: {
|
|
470
|
+
"Content-Type": "application/json"
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
function errorResponse(code, message, status = 400) {
|
|
475
|
+
return jsonResponse({ error: { code, message } }, status);
|
|
476
|
+
}
|
|
477
|
+
function createRouter(ctx) {
|
|
478
|
+
const endpoints = getEndpoints(ctx);
|
|
479
|
+
const handler = async (request) => {
|
|
480
|
+
const method = request.method.toUpperCase();
|
|
481
|
+
const { path, query } = parseUrl(request.url, ctx.basePath);
|
|
482
|
+
ctx.logger.debug(`${method} ${path}`);
|
|
483
|
+
if (ctx.options.hooks.before) {
|
|
484
|
+
const result = await ctx.options.hooks.before({
|
|
485
|
+
request,
|
|
486
|
+
path,
|
|
487
|
+
method
|
|
488
|
+
});
|
|
489
|
+
if (result instanceof Response) {
|
|
490
|
+
return result;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
for (const plugin of ctx.plugins) {
|
|
494
|
+
if (plugin.hooks?.before) {
|
|
495
|
+
for (const hook of plugin.hooks.before) {
|
|
496
|
+
if (hook.matcher({ path, method })) {
|
|
497
|
+
const result = await hook.handler({
|
|
498
|
+
request,
|
|
499
|
+
path,
|
|
500
|
+
method,
|
|
501
|
+
billingContext: ctx
|
|
502
|
+
});
|
|
503
|
+
if (result instanceof Response) {
|
|
504
|
+
return result;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const endpointKey = Object.keys(endpoints).find((key) => {
|
|
511
|
+
const endpoint2 = endpoints[key];
|
|
512
|
+
if (!endpoint2) return false;
|
|
513
|
+
if (endpoint2.options.method !== method) return false;
|
|
514
|
+
return endpoint2.path === path;
|
|
515
|
+
});
|
|
516
|
+
if (!endpointKey) {
|
|
517
|
+
return errorResponse(
|
|
518
|
+
"NOT_FOUND",
|
|
519
|
+
`No endpoint found for ${method} ${path}`,
|
|
520
|
+
404
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
const endpoint = endpoints[endpointKey];
|
|
524
|
+
try {
|
|
525
|
+
const requestForHandler = request.clone();
|
|
526
|
+
let body;
|
|
527
|
+
if (["POST", "PUT", "PATCH"].includes(method) && endpoint.options.body) {
|
|
528
|
+
try {
|
|
529
|
+
const text = await request.text();
|
|
530
|
+
if (text) {
|
|
531
|
+
body = JSON.parse(text);
|
|
532
|
+
}
|
|
533
|
+
} catch {
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (endpoint.options.body && body) {
|
|
537
|
+
const result = endpoint.options.body.safeParse(body);
|
|
538
|
+
if (!result.success) {
|
|
539
|
+
const issues = "issues" in result.error ? result.error.issues : [];
|
|
540
|
+
return errorResponse(
|
|
541
|
+
"VALIDATION_ERROR",
|
|
542
|
+
issues.map((e) => e.message).join(", "),
|
|
543
|
+
400
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
body = result.data;
|
|
547
|
+
}
|
|
548
|
+
let queryObj = queryToObject(query);
|
|
549
|
+
if (endpoint.options.query) {
|
|
550
|
+
const result = endpoint.options.query.safeParse(queryObj);
|
|
551
|
+
if (!result.success) {
|
|
552
|
+
const issues = "issues" in result.error ? result.error.issues : [];
|
|
553
|
+
return errorResponse(
|
|
554
|
+
"VALIDATION_ERROR",
|
|
555
|
+
issues.map((e) => e.message).join(", "),
|
|
556
|
+
400
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
queryObj = result.data;
|
|
560
|
+
}
|
|
561
|
+
const endpointContext = {
|
|
562
|
+
request: requestForHandler,
|
|
563
|
+
body,
|
|
564
|
+
query: queryObj,
|
|
565
|
+
headers: requestForHandler.headers,
|
|
566
|
+
params: {},
|
|
567
|
+
ctx
|
|
568
|
+
// Add billing context
|
|
569
|
+
};
|
|
570
|
+
const response = await endpoint.handler(endpointContext);
|
|
571
|
+
let finalResponse = jsonResponse(response);
|
|
572
|
+
for (const plugin of ctx.plugins) {
|
|
573
|
+
if (plugin.hooks?.after) {
|
|
574
|
+
for (const hook of plugin.hooks.after) {
|
|
575
|
+
if (hook.matcher({ path, method })) {
|
|
576
|
+
const result = await hook.handler({
|
|
577
|
+
request,
|
|
578
|
+
path,
|
|
579
|
+
method,
|
|
580
|
+
billingContext: ctx
|
|
581
|
+
});
|
|
582
|
+
if (result instanceof Response) {
|
|
583
|
+
finalResponse = result;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (ctx.options.hooks.after) {
|
|
590
|
+
const result = await ctx.options.hooks.after({
|
|
591
|
+
request,
|
|
592
|
+
path,
|
|
593
|
+
method
|
|
594
|
+
});
|
|
595
|
+
if (result instanceof Response) {
|
|
596
|
+
finalResponse = result;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return finalResponse;
|
|
600
|
+
} catch (error) {
|
|
601
|
+
ctx.logger.error("Endpoint error", error);
|
|
602
|
+
if (error instanceof Error) {
|
|
603
|
+
return errorResponse("INTERNAL_ERROR", error.message, 500);
|
|
604
|
+
}
|
|
605
|
+
return errorResponse(
|
|
606
|
+
"INTERNAL_ERROR",
|
|
607
|
+
"An unexpected error occurred",
|
|
608
|
+
500
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
return { handler, endpoints };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// src/db/field.ts
|
|
616
|
+
function defineField(attribute) {
|
|
617
|
+
return {
|
|
618
|
+
required: true,
|
|
619
|
+
input: true,
|
|
620
|
+
returned: true,
|
|
621
|
+
...attribute
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
function defineTable(fields) {
|
|
625
|
+
return { fields };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// src/db/schema.ts
|
|
629
|
+
var generateId = () => crypto.randomUUID();
|
|
630
|
+
var billingSchema = {
|
|
631
|
+
customer: defineTable({
|
|
632
|
+
id: defineField({
|
|
633
|
+
type: "string",
|
|
634
|
+
primaryKey: true,
|
|
635
|
+
defaultValue: generateId,
|
|
636
|
+
input: false
|
|
637
|
+
}),
|
|
638
|
+
externalId: defineField({
|
|
639
|
+
type: "string",
|
|
640
|
+
unique: true,
|
|
641
|
+
index: true
|
|
642
|
+
}),
|
|
643
|
+
email: defineField({
|
|
644
|
+
type: "string",
|
|
645
|
+
index: true
|
|
646
|
+
}),
|
|
647
|
+
name: defineField({
|
|
648
|
+
type: "string",
|
|
649
|
+
required: false
|
|
650
|
+
}),
|
|
651
|
+
providerCustomerId: defineField({
|
|
652
|
+
type: "string",
|
|
653
|
+
required: false,
|
|
654
|
+
index: true
|
|
655
|
+
}),
|
|
656
|
+
metadata: defineField({
|
|
657
|
+
type: "json",
|
|
658
|
+
required: false
|
|
659
|
+
}),
|
|
660
|
+
createdAt: defineField({
|
|
661
|
+
type: "date",
|
|
662
|
+
defaultValue: () => /* @__PURE__ */ new Date(),
|
|
663
|
+
input: false
|
|
664
|
+
}),
|
|
665
|
+
updatedAt: defineField({
|
|
666
|
+
type: "date",
|
|
667
|
+
defaultValue: () => /* @__PURE__ */ new Date(),
|
|
668
|
+
input: false
|
|
669
|
+
})
|
|
670
|
+
}),
|
|
671
|
+
subscription: defineTable({
|
|
672
|
+
id: defineField({
|
|
673
|
+
type: "string",
|
|
674
|
+
primaryKey: true,
|
|
675
|
+
defaultValue: generateId,
|
|
676
|
+
input: false
|
|
677
|
+
}),
|
|
678
|
+
customerId: defineField({
|
|
679
|
+
type: "string",
|
|
680
|
+
index: true,
|
|
681
|
+
references: {
|
|
682
|
+
model: "customer",
|
|
683
|
+
field: "id",
|
|
684
|
+
onDelete: "cascade"
|
|
685
|
+
}
|
|
686
|
+
}),
|
|
687
|
+
// Plan code from config (not a foreign key)
|
|
688
|
+
planCode: defineField({
|
|
689
|
+
type: "string",
|
|
690
|
+
index: true
|
|
691
|
+
}),
|
|
692
|
+
// Billing interval
|
|
693
|
+
interval: defineField({
|
|
694
|
+
type: "string",
|
|
695
|
+
// "monthly" | "yearly"
|
|
696
|
+
defaultValue: "monthly"
|
|
697
|
+
}),
|
|
698
|
+
status: defineField({
|
|
699
|
+
type: "string",
|
|
700
|
+
// SubscriptionStatus
|
|
701
|
+
defaultValue: "active"
|
|
702
|
+
}),
|
|
703
|
+
providerSubscriptionId: defineField({
|
|
704
|
+
type: "string",
|
|
705
|
+
required: false,
|
|
706
|
+
index: true
|
|
707
|
+
}),
|
|
708
|
+
providerCheckoutSessionId: defineField({
|
|
709
|
+
type: "string",
|
|
710
|
+
required: false,
|
|
711
|
+
index: true
|
|
712
|
+
}),
|
|
713
|
+
currentPeriodStart: defineField({
|
|
714
|
+
type: "date",
|
|
715
|
+
defaultValue: () => /* @__PURE__ */ new Date()
|
|
716
|
+
}),
|
|
717
|
+
currentPeriodEnd: defineField({
|
|
718
|
+
type: "date"
|
|
719
|
+
}),
|
|
720
|
+
canceledAt: defineField({
|
|
721
|
+
type: "date",
|
|
722
|
+
required: false
|
|
723
|
+
}),
|
|
724
|
+
cancelAt: defineField({
|
|
725
|
+
type: "date",
|
|
726
|
+
required: false
|
|
727
|
+
}),
|
|
728
|
+
trialStart: defineField({
|
|
729
|
+
type: "date",
|
|
730
|
+
required: false
|
|
731
|
+
}),
|
|
732
|
+
trialEnd: defineField({
|
|
733
|
+
type: "date",
|
|
734
|
+
required: false
|
|
735
|
+
}),
|
|
736
|
+
metadata: defineField({
|
|
737
|
+
type: "json",
|
|
738
|
+
required: false
|
|
739
|
+
}),
|
|
740
|
+
createdAt: defineField({
|
|
741
|
+
type: "date",
|
|
742
|
+
defaultValue: () => /* @__PURE__ */ new Date(),
|
|
743
|
+
input: false
|
|
744
|
+
}),
|
|
745
|
+
updatedAt: defineField({
|
|
746
|
+
type: "date",
|
|
747
|
+
defaultValue: () => /* @__PURE__ */ new Date(),
|
|
748
|
+
input: false
|
|
749
|
+
})
|
|
750
|
+
})
|
|
751
|
+
};
|
|
752
|
+
function getBillingSchema() {
|
|
753
|
+
return billingSchema;
|
|
754
|
+
}
|
|
755
|
+
var TABLES = {
|
|
756
|
+
CUSTOMER: "customer",
|
|
757
|
+
SUBSCRIPTION: "subscription"
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
// src/db/internal-adapter.ts
|
|
761
|
+
function planConfigToPlan(config) {
|
|
762
|
+
return {
|
|
763
|
+
code: config.code,
|
|
764
|
+
name: config.name,
|
|
765
|
+
description: config.description,
|
|
766
|
+
isPublic: config.isPublic ?? true,
|
|
767
|
+
prices: config.prices.map((p) => ({
|
|
768
|
+
amount: p.amount,
|
|
769
|
+
currency: p.currency ?? "usd",
|
|
770
|
+
interval: p.interval,
|
|
771
|
+
trialDays: p.trialDays
|
|
772
|
+
})),
|
|
773
|
+
features: config.features ?? []
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
function featureConfigToFeature(config) {
|
|
777
|
+
return {
|
|
778
|
+
code: config.code,
|
|
779
|
+
name: config.name,
|
|
780
|
+
type: config.type ?? "boolean"
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
function createInternalAdapter(adapter, plans = [], features = []) {
|
|
784
|
+
const plansByCode = /* @__PURE__ */ new Map();
|
|
785
|
+
for (const config of plans) {
|
|
786
|
+
plansByCode.set(config.code, planConfigToPlan(config));
|
|
787
|
+
}
|
|
788
|
+
const featuresByCode = /* @__PURE__ */ new Map();
|
|
789
|
+
for (const config of features) {
|
|
790
|
+
featuresByCode.set(config.code, featureConfigToFeature(config));
|
|
791
|
+
}
|
|
792
|
+
return {
|
|
793
|
+
// Customer operations (DB)
|
|
794
|
+
async createCustomer(data) {
|
|
795
|
+
const now = /* @__PURE__ */ new Date();
|
|
796
|
+
return adapter.create({
|
|
797
|
+
model: TABLES.CUSTOMER,
|
|
798
|
+
data: {
|
|
799
|
+
...data,
|
|
800
|
+
createdAt: now,
|
|
801
|
+
updatedAt: now
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
},
|
|
805
|
+
async findCustomerById(id) {
|
|
806
|
+
return adapter.findOne({
|
|
807
|
+
model: TABLES.CUSTOMER,
|
|
808
|
+
where: [{ field: "id", operator: "eq", value: id }]
|
|
809
|
+
});
|
|
810
|
+
},
|
|
811
|
+
async findCustomerByExternalId(externalId) {
|
|
812
|
+
return adapter.findOne({
|
|
813
|
+
model: TABLES.CUSTOMER,
|
|
814
|
+
where: [{ field: "externalId", operator: "eq", value: externalId }]
|
|
815
|
+
});
|
|
816
|
+
},
|
|
817
|
+
async updateCustomer(id, data) {
|
|
818
|
+
return adapter.update({
|
|
819
|
+
model: TABLES.CUSTOMER,
|
|
820
|
+
where: [{ field: "id", operator: "eq", value: id }],
|
|
821
|
+
update: { ...data, updatedAt: /* @__PURE__ */ new Date() }
|
|
822
|
+
});
|
|
823
|
+
},
|
|
824
|
+
async deleteCustomer(id) {
|
|
825
|
+
await adapter.delete({
|
|
826
|
+
model: TABLES.CUSTOMER,
|
|
827
|
+
where: [{ field: "id", operator: "eq", value: id }]
|
|
828
|
+
});
|
|
829
|
+
},
|
|
830
|
+
async listCustomers(options) {
|
|
831
|
+
return adapter.findMany({
|
|
832
|
+
model: TABLES.CUSTOMER,
|
|
833
|
+
limit: options?.limit,
|
|
834
|
+
offset: options?.offset,
|
|
835
|
+
sortBy: { field: "createdAt", direction: "desc" }
|
|
836
|
+
});
|
|
837
|
+
},
|
|
838
|
+
// Plan operations (from config - synchronous)
|
|
839
|
+
findPlanByCode(code) {
|
|
840
|
+
return plansByCode.get(code) ?? null;
|
|
841
|
+
},
|
|
842
|
+
listPlans(options) {
|
|
843
|
+
const allPlans = Array.from(plansByCode.values());
|
|
844
|
+
if (options?.includePrivate) {
|
|
845
|
+
return allPlans;
|
|
846
|
+
}
|
|
847
|
+
return allPlans.filter((p) => p.isPublic);
|
|
848
|
+
},
|
|
849
|
+
getPlanPrice(planCode, interval) {
|
|
850
|
+
const plan = plansByCode.get(planCode);
|
|
851
|
+
if (!plan) return null;
|
|
852
|
+
return plan.prices.find((p) => p.interval === interval) ?? plan.prices[0] ?? null;
|
|
853
|
+
},
|
|
854
|
+
// Feature operations (from config - synchronous)
|
|
855
|
+
findFeatureByCode(code) {
|
|
856
|
+
return featuresByCode.get(code) ?? null;
|
|
857
|
+
},
|
|
858
|
+
listFeatures() {
|
|
859
|
+
return Array.from(featuresByCode.values());
|
|
860
|
+
},
|
|
861
|
+
getPlanFeatures(planCode) {
|
|
862
|
+
const plan = plansByCode.get(planCode);
|
|
863
|
+
return plan?.features ?? [];
|
|
864
|
+
},
|
|
865
|
+
// Subscription operations (DB)
|
|
866
|
+
async createSubscription(data) {
|
|
867
|
+
const now = /* @__PURE__ */ new Date();
|
|
868
|
+
const interval = data.interval ?? "monthly";
|
|
869
|
+
const currentPeriodEnd = new Date(now);
|
|
870
|
+
if (interval === "yearly") {
|
|
871
|
+
currentPeriodEnd.setFullYear(currentPeriodEnd.getFullYear() + 1);
|
|
872
|
+
} else if (interval === "quarterly") {
|
|
873
|
+
currentPeriodEnd.setMonth(currentPeriodEnd.getMonth() + 3);
|
|
874
|
+
} else {
|
|
875
|
+
currentPeriodEnd.setMonth(currentPeriodEnd.getMonth() + 1);
|
|
876
|
+
}
|
|
877
|
+
let trialStart;
|
|
878
|
+
let trialEnd;
|
|
879
|
+
let status = data.status ?? "pending_payment";
|
|
880
|
+
if (data.trialDays && data.trialDays > 0) {
|
|
881
|
+
trialStart = now;
|
|
882
|
+
trialEnd = new Date(now);
|
|
883
|
+
trialEnd.setDate(trialEnd.getDate() + data.trialDays);
|
|
884
|
+
status = "trialing";
|
|
885
|
+
}
|
|
886
|
+
return adapter.create({
|
|
887
|
+
model: TABLES.SUBSCRIPTION,
|
|
888
|
+
data: {
|
|
889
|
+
customerId: data.customerId,
|
|
890
|
+
planCode: data.planCode,
|
|
891
|
+
interval,
|
|
892
|
+
status,
|
|
893
|
+
providerSubscriptionId: data.providerSubscriptionId,
|
|
894
|
+
providerCheckoutSessionId: data.providerCheckoutSessionId,
|
|
895
|
+
currentPeriodStart: now,
|
|
896
|
+
currentPeriodEnd: trialEnd ?? currentPeriodEnd,
|
|
897
|
+
trialStart,
|
|
898
|
+
trialEnd,
|
|
899
|
+
metadata: data.metadata,
|
|
900
|
+
createdAt: now,
|
|
901
|
+
updatedAt: now
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
},
|
|
905
|
+
async findSubscriptionById(id) {
|
|
906
|
+
return adapter.findOne({
|
|
907
|
+
model: TABLES.SUBSCRIPTION,
|
|
908
|
+
where: [{ field: "id", operator: "eq", value: id }]
|
|
909
|
+
});
|
|
910
|
+
},
|
|
911
|
+
async findSubscriptionByCustomerId(customerId) {
|
|
912
|
+
return adapter.findOne({
|
|
913
|
+
model: TABLES.SUBSCRIPTION,
|
|
914
|
+
where: [
|
|
915
|
+
{ field: "customerId", operator: "eq", value: customerId },
|
|
916
|
+
{
|
|
917
|
+
field: "status",
|
|
918
|
+
operator: "in",
|
|
919
|
+
value: ["active", "trialing", "past_due", "pending_payment"]
|
|
920
|
+
}
|
|
921
|
+
]
|
|
922
|
+
});
|
|
923
|
+
},
|
|
924
|
+
async findSubscriptionByProviderSessionId(sessionId) {
|
|
925
|
+
return adapter.findOne({
|
|
926
|
+
model: TABLES.SUBSCRIPTION,
|
|
927
|
+
where: [
|
|
928
|
+
{
|
|
929
|
+
field: "providerCheckoutSessionId",
|
|
930
|
+
operator: "eq",
|
|
931
|
+
value: sessionId
|
|
932
|
+
}
|
|
933
|
+
]
|
|
934
|
+
});
|
|
935
|
+
},
|
|
936
|
+
async updateSubscription(id, data) {
|
|
937
|
+
return adapter.update({
|
|
938
|
+
model: TABLES.SUBSCRIPTION,
|
|
939
|
+
where: [{ field: "id", operator: "eq", value: id }],
|
|
940
|
+
update: { ...data, updatedAt: /* @__PURE__ */ new Date() }
|
|
941
|
+
});
|
|
942
|
+
},
|
|
943
|
+
async cancelSubscription(id, cancelAt) {
|
|
944
|
+
const now = /* @__PURE__ */ new Date();
|
|
945
|
+
return adapter.update({
|
|
946
|
+
model: TABLES.SUBSCRIPTION,
|
|
947
|
+
where: [{ field: "id", operator: "eq", value: id }],
|
|
948
|
+
update: {
|
|
949
|
+
status: cancelAt ? "active" : "canceled",
|
|
950
|
+
canceledAt: now,
|
|
951
|
+
cancelAt: cancelAt ?? now,
|
|
952
|
+
updatedAt: now
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
},
|
|
956
|
+
async listSubscriptions(customerId) {
|
|
957
|
+
return adapter.findMany({
|
|
958
|
+
model: TABLES.SUBSCRIPTION,
|
|
959
|
+
where: [{ field: "customerId", operator: "eq", value: customerId }],
|
|
960
|
+
sortBy: { field: "createdAt", direction: "desc" }
|
|
961
|
+
});
|
|
962
|
+
},
|
|
963
|
+
// Feature access check
|
|
964
|
+
async checkFeatureAccess(customerId, featureCode) {
|
|
965
|
+
const customer = await adapter.findOne({
|
|
966
|
+
model: TABLES.CUSTOMER,
|
|
967
|
+
where: [{ field: "externalId", operator: "eq", value: customerId }]
|
|
968
|
+
});
|
|
969
|
+
if (!customer) {
|
|
970
|
+
return { allowed: false };
|
|
971
|
+
}
|
|
972
|
+
const subscription = await adapter.findOne({
|
|
973
|
+
model: TABLES.SUBSCRIPTION,
|
|
974
|
+
where: [
|
|
975
|
+
{ field: "customerId", operator: "eq", value: customer.id },
|
|
976
|
+
{ field: "status", operator: "in", value: ["active", "trialing"] }
|
|
977
|
+
]
|
|
978
|
+
});
|
|
979
|
+
if (!subscription) {
|
|
980
|
+
return { allowed: false };
|
|
981
|
+
}
|
|
982
|
+
const planFeatures = this.getPlanFeatures(subscription.planCode);
|
|
983
|
+
return { allowed: planFeatures.includes(featureCode) };
|
|
984
|
+
}
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// src/context/create-context.ts
|
|
989
|
+
function createLogger(options) {
|
|
990
|
+
const level = options?.level ?? "info";
|
|
991
|
+
const disabled = options?.disabled ?? false;
|
|
992
|
+
const levels = ["debug", "info", "warn", "error"];
|
|
993
|
+
const currentLevelIndex = levels.indexOf(level);
|
|
994
|
+
const shouldLog = (logLevel) => {
|
|
995
|
+
if (disabled) return false;
|
|
996
|
+
return levels.indexOf(logLevel) >= currentLevelIndex;
|
|
997
|
+
};
|
|
998
|
+
return {
|
|
999
|
+
debug: (message, ...args) => {
|
|
1000
|
+
if (shouldLog("debug")) console.debug(`[billsdk] ${message}`, ...args);
|
|
1001
|
+
},
|
|
1002
|
+
info: (message, ...args) => {
|
|
1003
|
+
if (shouldLog("info")) console.info(`[billsdk] ${message}`, ...args);
|
|
1004
|
+
},
|
|
1005
|
+
warn: (message, ...args) => {
|
|
1006
|
+
if (shouldLog("warn")) console.warn(`[billsdk] ${message}`, ...args);
|
|
1007
|
+
},
|
|
1008
|
+
error: (message, ...args) => {
|
|
1009
|
+
if (shouldLog("error")) console.error(`[billsdk] ${message}`, ...args);
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
function resolveOptions(options, adapter) {
|
|
1014
|
+
return {
|
|
1015
|
+
database: adapter,
|
|
1016
|
+
payment: options.payment,
|
|
1017
|
+
basePath: options.basePath ?? "/api/billing",
|
|
1018
|
+
secret: options.secret ?? generateDefaultSecret(),
|
|
1019
|
+
plans: options.plans,
|
|
1020
|
+
features: options.features,
|
|
1021
|
+
plugins: options.plugins ?? [],
|
|
1022
|
+
hooks: options.hooks ?? {},
|
|
1023
|
+
logger: {
|
|
1024
|
+
level: options.logger?.level ?? "info",
|
|
1025
|
+
disabled: options.logger?.disabled ?? false
|
|
1026
|
+
}
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
function generateDefaultSecret() {
|
|
1030
|
+
return "billsdk-development-secret-change-in-production";
|
|
1031
|
+
}
|
|
1032
|
+
async function createBillingContext(adapter, options) {
|
|
1033
|
+
const resolvedOptions = resolveOptions(options, adapter);
|
|
1034
|
+
const logger = createLogger(options.logger);
|
|
1035
|
+
const plugins = resolvedOptions.plugins;
|
|
1036
|
+
let schema = getBillingSchema();
|
|
1037
|
+
for (const plugin of plugins) {
|
|
1038
|
+
if (plugin.schema) {
|
|
1039
|
+
schema = { ...schema, ...plugin.schema };
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
const internalAdapter = createInternalAdapter(
|
|
1043
|
+
adapter,
|
|
1044
|
+
options.plans ?? [],
|
|
1045
|
+
options.features ?? []
|
|
1046
|
+
);
|
|
1047
|
+
const context = {
|
|
1048
|
+
options: resolvedOptions,
|
|
1049
|
+
basePath: resolvedOptions.basePath,
|
|
1050
|
+
adapter,
|
|
1051
|
+
paymentAdapter: options.payment,
|
|
1052
|
+
internalAdapter,
|
|
1053
|
+
schema,
|
|
1054
|
+
plugins,
|
|
1055
|
+
logger,
|
|
1056
|
+
secret: resolvedOptions.secret,
|
|
1057
|
+
hasPlugin(id) {
|
|
1058
|
+
return plugins.some((p) => p.id === id);
|
|
1059
|
+
},
|
|
1060
|
+
getPlugin(id) {
|
|
1061
|
+
const plugin = plugins.find((p) => p.id === id);
|
|
1062
|
+
return plugin ?? null;
|
|
1063
|
+
},
|
|
1064
|
+
generateId() {
|
|
1065
|
+
return crypto.randomUUID();
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
for (const plugin of plugins) {
|
|
1069
|
+
if (plugin.init) {
|
|
1070
|
+
await plugin.init(context);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
logger.debug("BillingContext created", {
|
|
1074
|
+
basePath: context.basePath,
|
|
1075
|
+
plugins: plugins.map((p) => p.id),
|
|
1076
|
+
hasPaymentAdapter: !!context.paymentAdapter,
|
|
1077
|
+
plans: options.plans?.length ?? 0,
|
|
1078
|
+
features: options.features?.length ?? 0
|
|
1079
|
+
});
|
|
1080
|
+
return context;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// src/billsdk/base.ts
|
|
1084
|
+
var BASE_ERROR_CODES = {
|
|
1085
|
+
CUSTOMER_NOT_FOUND: "CUSTOMER_NOT_FOUND",
|
|
1086
|
+
PLAN_NOT_FOUND: "PLAN_NOT_FOUND",
|
|
1087
|
+
SUBSCRIPTION_NOT_FOUND: "SUBSCRIPTION_NOT_FOUND",
|
|
1088
|
+
FEATURE_NOT_FOUND: "FEATURE_NOT_FOUND",
|
|
1089
|
+
PAYMENT_ADAPTER_NOT_CONFIGURED: "PAYMENT_ADAPTER_NOT_CONFIGURED",
|
|
1090
|
+
INVALID_REQUEST: "INVALID_REQUEST",
|
|
1091
|
+
INTERNAL_ERROR: "INTERNAL_ERROR"
|
|
1092
|
+
};
|
|
1093
|
+
function createAPI(contextPromise) {
|
|
1094
|
+
return {
|
|
1095
|
+
async getCustomer(params) {
|
|
1096
|
+
const ctx = await contextPromise;
|
|
1097
|
+
return ctx.internalAdapter.findCustomerByExternalId(params.externalId);
|
|
1098
|
+
},
|
|
1099
|
+
async createCustomer(data) {
|
|
1100
|
+
const ctx = await contextPromise;
|
|
1101
|
+
return ctx.internalAdapter.createCustomer(data);
|
|
1102
|
+
},
|
|
1103
|
+
async listPlans() {
|
|
1104
|
+
const ctx = await contextPromise;
|
|
1105
|
+
return ctx.internalAdapter.listPlans();
|
|
1106
|
+
},
|
|
1107
|
+
async getPlan(params) {
|
|
1108
|
+
const ctx = await contextPromise;
|
|
1109
|
+
return ctx.internalAdapter.findPlanByCode(params.code);
|
|
1110
|
+
},
|
|
1111
|
+
async getSubscription(params) {
|
|
1112
|
+
const ctx = await contextPromise;
|
|
1113
|
+
const customer = await ctx.internalAdapter.findCustomerByExternalId(
|
|
1114
|
+
params.customerId
|
|
1115
|
+
);
|
|
1116
|
+
if (!customer) return null;
|
|
1117
|
+
return ctx.internalAdapter.findSubscriptionByCustomerId(customer.id);
|
|
1118
|
+
},
|
|
1119
|
+
async createSubscription(params) {
|
|
1120
|
+
const ctx = await contextPromise;
|
|
1121
|
+
if (!ctx.paymentAdapter) {
|
|
1122
|
+
throw new Error("Payment adapter not configured");
|
|
1123
|
+
}
|
|
1124
|
+
const customer = await ctx.internalAdapter.findCustomerByExternalId(
|
|
1125
|
+
params.customerId
|
|
1126
|
+
);
|
|
1127
|
+
if (!customer) {
|
|
1128
|
+
throw new Error("Customer not found");
|
|
1129
|
+
}
|
|
1130
|
+
const plan = ctx.internalAdapter.findPlanByCode(params.planCode);
|
|
1131
|
+
if (!plan) {
|
|
1132
|
+
throw new Error("Plan not found");
|
|
1133
|
+
}
|
|
1134
|
+
const interval = params.interval ?? "monthly";
|
|
1135
|
+
const price = ctx.internalAdapter.getPlanPrice(params.planCode, interval);
|
|
1136
|
+
if (!price) {
|
|
1137
|
+
throw new Error(
|
|
1138
|
+
`No price found for plan ${params.planCode} with interval ${interval}`
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
const subscription = await ctx.internalAdapter.createSubscription({
|
|
1142
|
+
customerId: customer.id,
|
|
1143
|
+
planCode: params.planCode,
|
|
1144
|
+
interval,
|
|
1145
|
+
status: "pending_payment",
|
|
1146
|
+
trialDays: price.trialDays
|
|
1147
|
+
});
|
|
1148
|
+
const result = await ctx.paymentAdapter.processPayment({
|
|
1149
|
+
customer: {
|
|
1150
|
+
id: customer.id,
|
|
1151
|
+
email: customer.email,
|
|
1152
|
+
providerCustomerId: customer.providerCustomerId
|
|
1153
|
+
},
|
|
1154
|
+
plan: { code: plan.code, name: plan.name },
|
|
1155
|
+
price: {
|
|
1156
|
+
amount: price.amount,
|
|
1157
|
+
currency: price.currency,
|
|
1158
|
+
interval: price.interval
|
|
1159
|
+
},
|
|
1160
|
+
subscription: { id: subscription.id },
|
|
1161
|
+
successUrl: params.successUrl,
|
|
1162
|
+
cancelUrl: params.cancelUrl,
|
|
1163
|
+
metadata: { subscriptionId: subscription.id }
|
|
1164
|
+
});
|
|
1165
|
+
if (result.status === "active") {
|
|
1166
|
+
const existingSubscriptions = await ctx.internalAdapter.listSubscriptions(customer.id);
|
|
1167
|
+
for (const existing of existingSubscriptions) {
|
|
1168
|
+
if (existing.id !== subscription.id && (existing.status === "active" || existing.status === "trialing")) {
|
|
1169
|
+
await ctx.internalAdapter.cancelSubscription(existing.id);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
const activeSubscription = await ctx.internalAdapter.updateSubscription(
|
|
1173
|
+
subscription.id,
|
|
1174
|
+
{ status: "active" }
|
|
1175
|
+
);
|
|
1176
|
+
if (result.providerCustomerId && !customer.providerCustomerId) {
|
|
1177
|
+
await ctx.internalAdapter.updateCustomer(customer.id, {
|
|
1178
|
+
providerCustomerId: result.providerCustomerId
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
return {
|
|
1182
|
+
subscription: activeSubscription ?? {
|
|
1183
|
+
...subscription,
|
|
1184
|
+
status: "active"
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
if (result.status === "pending") {
|
|
1189
|
+
await ctx.internalAdapter.updateSubscription(subscription.id, {
|
|
1190
|
+
providerCheckoutSessionId: result.sessionId
|
|
1191
|
+
});
|
|
1192
|
+
if (result.providerCustomerId && !customer.providerCustomerId) {
|
|
1193
|
+
await ctx.internalAdapter.updateCustomer(customer.id, {
|
|
1194
|
+
providerCustomerId: result.providerCustomerId
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
return {
|
|
1198
|
+
subscription,
|
|
1199
|
+
redirectUrl: result.redirectUrl
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
await ctx.internalAdapter.updateSubscription(subscription.id, {
|
|
1203
|
+
status: "canceled"
|
|
1204
|
+
});
|
|
1205
|
+
throw new Error(result.error);
|
|
1206
|
+
},
|
|
1207
|
+
async cancelSubscription(params) {
|
|
1208
|
+
const ctx = await contextPromise;
|
|
1209
|
+
const customer = await ctx.internalAdapter.findCustomerByExternalId(
|
|
1210
|
+
params.customerId
|
|
1211
|
+
);
|
|
1212
|
+
if (!customer) {
|
|
1213
|
+
return null;
|
|
1214
|
+
}
|
|
1215
|
+
const subscription = await ctx.internalAdapter.findSubscriptionByCustomerId(customer.id);
|
|
1216
|
+
if (!subscription) {
|
|
1217
|
+
return null;
|
|
1218
|
+
}
|
|
1219
|
+
const cancelAt = params.cancelAt ?? "period_end";
|
|
1220
|
+
if (cancelAt === "immediately") {
|
|
1221
|
+
return ctx.internalAdapter.cancelSubscription(subscription.id);
|
|
1222
|
+
}
|
|
1223
|
+
return ctx.internalAdapter.cancelSubscription(
|
|
1224
|
+
subscription.id,
|
|
1225
|
+
subscription.currentPeriodEnd
|
|
1226
|
+
);
|
|
1227
|
+
},
|
|
1228
|
+
async checkFeature(params) {
|
|
1229
|
+
const ctx = await contextPromise;
|
|
1230
|
+
return ctx.internalAdapter.checkFeatureAccess(
|
|
1231
|
+
params.customerId,
|
|
1232
|
+
params.feature
|
|
1233
|
+
);
|
|
1234
|
+
},
|
|
1235
|
+
async listFeatures(params) {
|
|
1236
|
+
const ctx = await contextPromise;
|
|
1237
|
+
const customer = await ctx.internalAdapter.findCustomerByExternalId(
|
|
1238
|
+
params.customerId
|
|
1239
|
+
);
|
|
1240
|
+
if (!customer) return [];
|
|
1241
|
+
const subscription = await ctx.internalAdapter.findSubscriptionByCustomerId(customer.id);
|
|
1242
|
+
if (!subscription) return [];
|
|
1243
|
+
const featureCodes = ctx.internalAdapter.getPlanFeatures(
|
|
1244
|
+
subscription.planCode
|
|
1245
|
+
);
|
|
1246
|
+
return featureCodes.map((code) => {
|
|
1247
|
+
const feature = ctx.internalAdapter.findFeatureByCode(code);
|
|
1248
|
+
return feature ? {
|
|
1249
|
+
code: feature.code,
|
|
1250
|
+
name: feature.name,
|
|
1251
|
+
enabled: true
|
|
1252
|
+
} : { code, name: code, enabled: true };
|
|
1253
|
+
});
|
|
1254
|
+
},
|
|
1255
|
+
async health() {
|
|
1256
|
+
return {
|
|
1257
|
+
status: "ok",
|
|
1258
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
async function init(options) {
|
|
1264
|
+
const database = options.database ?? memoryAdapter();
|
|
1265
|
+
const payment = options.payment ?? paymentAdapter();
|
|
1266
|
+
return createBillingContext(database, { ...options, payment });
|
|
1267
|
+
}
|
|
1268
|
+
function createBillSDK(options) {
|
|
1269
|
+
const contextPromise = init(options);
|
|
1270
|
+
const errorCodes = {};
|
|
1271
|
+
for (const plugin of options.plugins ?? []) {
|
|
1272
|
+
if (plugin.$ERROR_CODES) {
|
|
1273
|
+
Object.assign(errorCodes, plugin.$ERROR_CODES);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
const handler = async (request) => {
|
|
1277
|
+
const ctx = await contextPromise;
|
|
1278
|
+
const { handler: routeHandler } = createRouter(ctx);
|
|
1279
|
+
return routeHandler(request);
|
|
1280
|
+
};
|
|
1281
|
+
const api = createAPI(contextPromise);
|
|
1282
|
+
return {
|
|
1283
|
+
handler,
|
|
1284
|
+
api,
|
|
1285
|
+
options,
|
|
1286
|
+
$context: contextPromise,
|
|
1287
|
+
$Infer: {},
|
|
1288
|
+
$ERROR_CODES: {
|
|
1289
|
+
...BASE_ERROR_CODES,
|
|
1290
|
+
...errorCodes
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
function billsdk(options) {
|
|
1295
|
+
return createBillSDK(options);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
export { billsdk };
|
|
1299
|
+
//# sourceMappingURL=index.js.map
|
|
36
1300
|
//# sourceMappingURL=index.js.map
|