billsdk 0.1.4 → 0.2.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/README.md +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +725 -361
- package/dist/index.js.map +1 -1
- package/dist/integrations/next.d.ts +1 -5
- package/dist/integrations/next.js +1 -2
- package/dist/integrations/next.js.map +1 -1
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { memoryAdapter } from '@billsdk/memory-adapter';
|
|
|
5
5
|
import { paymentAdapter } from '@billsdk/payment-adapter';
|
|
6
6
|
export { paymentAdapter } from '@billsdk/payment-adapter';
|
|
7
7
|
import { z } from 'zod';
|
|
8
|
+
import { getBillingSchema, TABLES } from '@billsdk/core/db';
|
|
8
9
|
|
|
9
10
|
// src/index.ts
|
|
10
11
|
var createCustomerSchema = z.object({
|
|
@@ -127,6 +128,51 @@ var healthEndpoint = {
|
|
|
127
128
|
}
|
|
128
129
|
}
|
|
129
130
|
};
|
|
131
|
+
var listPaymentsQuerySchema = z.object({
|
|
132
|
+
customerId: z.string().min(1),
|
|
133
|
+
limit: z.coerce.number().positive().max(100).optional().default(25),
|
|
134
|
+
offset: z.coerce.number().nonnegative().optional().default(0)
|
|
135
|
+
});
|
|
136
|
+
var getPaymentQuerySchema = z.object({
|
|
137
|
+
paymentId: z.string().min(1)
|
|
138
|
+
});
|
|
139
|
+
var paymentEndpoints = {
|
|
140
|
+
listPayments: {
|
|
141
|
+
path: "/payments",
|
|
142
|
+
options: {
|
|
143
|
+
method: "GET",
|
|
144
|
+
query: listPaymentsQuerySchema
|
|
145
|
+
},
|
|
146
|
+
handler: async (context) => {
|
|
147
|
+
const { ctx, query } = context;
|
|
148
|
+
const customer = await ctx.internalAdapter.findCustomerByExternalId(
|
|
149
|
+
query.customerId
|
|
150
|
+
);
|
|
151
|
+
if (!customer) {
|
|
152
|
+
return { payments: [] };
|
|
153
|
+
}
|
|
154
|
+
const payments = await ctx.internalAdapter.listPayments(customer.id, {
|
|
155
|
+
limit: query.limit,
|
|
156
|
+
offset: query.offset
|
|
157
|
+
});
|
|
158
|
+
return { payments };
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
getPayment: {
|
|
162
|
+
path: "/payment",
|
|
163
|
+
options: {
|
|
164
|
+
method: "GET",
|
|
165
|
+
query: getPaymentQuerySchema
|
|
166
|
+
},
|
|
167
|
+
handler: async (context) => {
|
|
168
|
+
const { ctx, query } = context;
|
|
169
|
+
const payment = await ctx.internalAdapter.findPaymentById(
|
|
170
|
+
query.paymentId
|
|
171
|
+
);
|
|
172
|
+
return { payment };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
130
176
|
var planEndpoints = {
|
|
131
177
|
listPlans: {
|
|
132
178
|
path: "/plans",
|
|
@@ -157,6 +203,565 @@ var planEndpoints = {
|
|
|
157
203
|
}
|
|
158
204
|
}
|
|
159
205
|
};
|
|
206
|
+
|
|
207
|
+
// src/logic/payment-failed-service.ts
|
|
208
|
+
async function handlePaymentFailed(ctx, params) {
|
|
209
|
+
const { subscriptionId, error } = params;
|
|
210
|
+
const subscription = await ctx.internalAdapter.findSubscriptionById(subscriptionId);
|
|
211
|
+
if (!subscription) {
|
|
212
|
+
throw new Error("Subscription not found");
|
|
213
|
+
}
|
|
214
|
+
ctx.logger.info("Payment failed, marking subscription as past_due", {
|
|
215
|
+
subscriptionId: subscription.id,
|
|
216
|
+
error
|
|
217
|
+
});
|
|
218
|
+
const updatedSubscription = await ctx.internalAdapter.updateSubscription(
|
|
219
|
+
subscription.id,
|
|
220
|
+
{ status: "past_due" }
|
|
221
|
+
);
|
|
222
|
+
return {
|
|
223
|
+
subscription: updatedSubscription ?? {
|
|
224
|
+
...subscription,
|
|
225
|
+
status: "past_due"
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// src/logic/refund-service.ts
|
|
231
|
+
async function createRefund(ctx, params) {
|
|
232
|
+
const { paymentId, amount, reason } = params;
|
|
233
|
+
if (!ctx.paymentAdapter?.refund) {
|
|
234
|
+
throw new Error("Payment adapter does not support refunds");
|
|
235
|
+
}
|
|
236
|
+
const payment = await ctx.internalAdapter.findPaymentById(paymentId);
|
|
237
|
+
if (!payment) {
|
|
238
|
+
throw new Error("Payment not found");
|
|
239
|
+
}
|
|
240
|
+
if (payment.status !== "succeeded") {
|
|
241
|
+
throw new Error(`Cannot refund payment with status "${payment.status}"`);
|
|
242
|
+
}
|
|
243
|
+
const alreadyRefunded = payment.refundedAmount ?? 0;
|
|
244
|
+
const remainingAmount = payment.amount - alreadyRefunded;
|
|
245
|
+
if (remainingAmount <= 0) {
|
|
246
|
+
throw new Error("Payment has already been fully refunded");
|
|
247
|
+
}
|
|
248
|
+
const refundAmount = amount ?? remainingAmount;
|
|
249
|
+
if (refundAmount > remainingAmount) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`Cannot refund ${refundAmount}. Only ${remainingAmount} is available for refund.`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (!payment.providerPaymentId) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
"Payment does not have a provider payment ID. Cannot process refund."
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
const result = await ctx.paymentAdapter.refund({
|
|
260
|
+
providerPaymentId: payment.providerPaymentId,
|
|
261
|
+
amount: refundAmount,
|
|
262
|
+
reason
|
|
263
|
+
});
|
|
264
|
+
if (result.status === "failed") {
|
|
265
|
+
throw new Error(result.error ?? "Refund failed");
|
|
266
|
+
}
|
|
267
|
+
const newRefundedAmount = alreadyRefunded + refundAmount;
|
|
268
|
+
const newStatus = newRefundedAmount >= payment.amount ? "refunded" : "succeeded";
|
|
269
|
+
await ctx.internalAdapter.updatePayment(payment.id, {
|
|
270
|
+
status: newStatus,
|
|
271
|
+
refundedAmount: newRefundedAmount
|
|
272
|
+
});
|
|
273
|
+
const refundPayment = await ctx.internalAdapter.createPayment({
|
|
274
|
+
customerId: payment.customerId,
|
|
275
|
+
subscriptionId: payment.subscriptionId ?? void 0,
|
|
276
|
+
type: "refund",
|
|
277
|
+
status: "succeeded",
|
|
278
|
+
amount: -refundAmount,
|
|
279
|
+
// Negative to indicate refund
|
|
280
|
+
currency: payment.currency,
|
|
281
|
+
providerPaymentId: result.providerRefundId,
|
|
282
|
+
metadata: {
|
|
283
|
+
originalPaymentId: payment.id,
|
|
284
|
+
reason
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
ctx.logger.info("Refund processed", {
|
|
288
|
+
originalPaymentId: payment.id,
|
|
289
|
+
refundPaymentId: refundPayment.id,
|
|
290
|
+
amount: refundAmount
|
|
291
|
+
});
|
|
292
|
+
if (payment.subscriptionId) {
|
|
293
|
+
const subscription = await ctx.internalAdapter.findSubscriptionById(
|
|
294
|
+
payment.subscriptionId
|
|
295
|
+
);
|
|
296
|
+
if (subscription && subscription.status !== "canceled") {
|
|
297
|
+
ctx.logger.info("Refund includes subscription cancellation", {
|
|
298
|
+
subscriptionId: subscription.id,
|
|
299
|
+
paymentId
|
|
300
|
+
});
|
|
301
|
+
await ctx.internalAdapter.cancelSubscription(subscription.id);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
refund: refundPayment,
|
|
306
|
+
originalPayment: {
|
|
307
|
+
...payment,
|
|
308
|
+
status: newStatus,
|
|
309
|
+
refundedAmount: newRefundedAmount
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// src/logic/proration.ts
|
|
315
|
+
function daysBetween(start, end) {
|
|
316
|
+
const msPerDay = 1e3 * 60 * 60 * 24;
|
|
317
|
+
return Math.ceil((end.getTime() - start.getTime()) / msPerDay);
|
|
318
|
+
}
|
|
319
|
+
function calculateProration(params) {
|
|
320
|
+
const {
|
|
321
|
+
oldPlanAmount,
|
|
322
|
+
newPlanAmount,
|
|
323
|
+
currentPeriodStart,
|
|
324
|
+
currentPeriodEnd,
|
|
325
|
+
changeDate = /* @__PURE__ */ new Date()
|
|
326
|
+
} = params;
|
|
327
|
+
const totalDays = daysBetween(currentPeriodStart, currentPeriodEnd);
|
|
328
|
+
const daysRemaining = daysBetween(changeDate, currentPeriodEnd);
|
|
329
|
+
const effectiveDaysRemaining = Math.max(
|
|
330
|
+
0,
|
|
331
|
+
Math.min(daysRemaining, totalDays)
|
|
332
|
+
);
|
|
333
|
+
const credit = Math.round(
|
|
334
|
+
oldPlanAmount / totalDays * effectiveDaysRemaining
|
|
335
|
+
);
|
|
336
|
+
const charge = Math.round(
|
|
337
|
+
newPlanAmount / totalDays * effectiveDaysRemaining
|
|
338
|
+
);
|
|
339
|
+
const netAmount = charge - credit;
|
|
340
|
+
return {
|
|
341
|
+
credit,
|
|
342
|
+
charge,
|
|
343
|
+
netAmount,
|
|
344
|
+
daysRemaining: effectiveDaysRemaining,
|
|
345
|
+
totalDays
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// src/logic/subscription-service.ts
|
|
350
|
+
async function createSubscription(ctx, params) {
|
|
351
|
+
const {
|
|
352
|
+
customerId,
|
|
353
|
+
planCode,
|
|
354
|
+
interval = "monthly",
|
|
355
|
+
successUrl,
|
|
356
|
+
cancelUrl
|
|
357
|
+
} = params;
|
|
358
|
+
if (!ctx.paymentAdapter) {
|
|
359
|
+
throw new Error("Payment adapter not configured");
|
|
360
|
+
}
|
|
361
|
+
const customer = await ctx.internalAdapter.findCustomerByExternalId(customerId);
|
|
362
|
+
if (!customer) {
|
|
363
|
+
throw new Error("Customer not found");
|
|
364
|
+
}
|
|
365
|
+
const plan = ctx.internalAdapter.findPlanByCode(planCode);
|
|
366
|
+
if (!plan) {
|
|
367
|
+
throw new Error("Plan not found");
|
|
368
|
+
}
|
|
369
|
+
const price = ctx.internalAdapter.getPlanPrice(planCode, interval);
|
|
370
|
+
if (!price) {
|
|
371
|
+
throw new Error(
|
|
372
|
+
`No price found for plan ${planCode} with interval ${interval}`
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
const subscription = await ctx.internalAdapter.createSubscription({
|
|
376
|
+
customerId: customer.id,
|
|
377
|
+
planCode,
|
|
378
|
+
interval,
|
|
379
|
+
status: "pending_payment",
|
|
380
|
+
trialDays: price.trialDays
|
|
381
|
+
});
|
|
382
|
+
const result = await ctx.paymentAdapter.processPayment({
|
|
383
|
+
customer: {
|
|
384
|
+
id: customer.id,
|
|
385
|
+
email: customer.email,
|
|
386
|
+
providerCustomerId: customer.providerCustomerId
|
|
387
|
+
},
|
|
388
|
+
plan: {
|
|
389
|
+
code: plan.code,
|
|
390
|
+
name: plan.name
|
|
391
|
+
},
|
|
392
|
+
price: {
|
|
393
|
+
amount: price.amount,
|
|
394
|
+
currency: price.currency,
|
|
395
|
+
interval: price.interval
|
|
396
|
+
},
|
|
397
|
+
subscription: {
|
|
398
|
+
id: subscription.id
|
|
399
|
+
},
|
|
400
|
+
successUrl,
|
|
401
|
+
cancelUrl,
|
|
402
|
+
metadata: {
|
|
403
|
+
subscriptionId: subscription.id,
|
|
404
|
+
customerId: customer.id
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
if (result.status === "active") {
|
|
408
|
+
const existingSubscriptions = await ctx.internalAdapter.listSubscriptions(
|
|
409
|
+
customer.id
|
|
410
|
+
);
|
|
411
|
+
for (const existing of existingSubscriptions) {
|
|
412
|
+
if (existing.id !== subscription.id && (existing.status === "active" || existing.status === "trialing")) {
|
|
413
|
+
await ctx.internalAdapter.cancelSubscription(existing.id);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const activeSubscription = await ctx.internalAdapter.updateSubscription(
|
|
417
|
+
subscription.id,
|
|
418
|
+
{ status: "active" }
|
|
419
|
+
);
|
|
420
|
+
if (result.providerCustomerId && !customer.providerCustomerId) {
|
|
421
|
+
await ctx.internalAdapter.updateCustomer(customer.id, {
|
|
422
|
+
providerCustomerId: result.providerCustomerId
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
if (price.amount > 0) {
|
|
426
|
+
await ctx.internalAdapter.createPayment({
|
|
427
|
+
customerId: customer.id,
|
|
428
|
+
subscriptionId: subscription.id,
|
|
429
|
+
type: "subscription",
|
|
430
|
+
status: "succeeded",
|
|
431
|
+
amount: price.amount,
|
|
432
|
+
currency: price.currency,
|
|
433
|
+
metadata: {
|
|
434
|
+
planCode: plan.code,
|
|
435
|
+
interval
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
subscription: activeSubscription ?? {
|
|
441
|
+
...subscription,
|
|
442
|
+
status: "active"
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
if (result.status === "pending") {
|
|
447
|
+
await ctx.internalAdapter.updateSubscription(subscription.id, {
|
|
448
|
+
providerCheckoutSessionId: result.sessionId
|
|
449
|
+
});
|
|
450
|
+
if (result.providerCustomerId && !customer.providerCustomerId) {
|
|
451
|
+
await ctx.internalAdapter.updateCustomer(customer.id, {
|
|
452
|
+
providerCustomerId: result.providerCustomerId
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
return {
|
|
456
|
+
subscription,
|
|
457
|
+
redirectUrl: result.redirectUrl
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
await ctx.internalAdapter.updateSubscription(subscription.id, {
|
|
461
|
+
status: "canceled"
|
|
462
|
+
});
|
|
463
|
+
throw new Error(result.error);
|
|
464
|
+
}
|
|
465
|
+
async function cancelSubscription(ctx, params) {
|
|
466
|
+
const { customerId, cancelAt = "period_end" } = params;
|
|
467
|
+
const customer = await ctx.internalAdapter.findCustomerByExternalId(customerId);
|
|
468
|
+
if (!customer) {
|
|
469
|
+
throw new Error("Customer not found");
|
|
470
|
+
}
|
|
471
|
+
const subscription = await ctx.internalAdapter.findSubscriptionByCustomerId(
|
|
472
|
+
customer.id
|
|
473
|
+
);
|
|
474
|
+
if (!subscription) {
|
|
475
|
+
throw new Error("No active subscription found");
|
|
476
|
+
}
|
|
477
|
+
if (cancelAt === "immediately") {
|
|
478
|
+
const canceled2 = await ctx.internalAdapter.cancelSubscription(
|
|
479
|
+
subscription.id
|
|
480
|
+
);
|
|
481
|
+
return { subscription: canceled2, canceledImmediately: true };
|
|
482
|
+
}
|
|
483
|
+
const canceled = await ctx.internalAdapter.cancelSubscription(
|
|
484
|
+
subscription.id,
|
|
485
|
+
subscription.currentPeriodEnd
|
|
486
|
+
);
|
|
487
|
+
return {
|
|
488
|
+
subscription: canceled,
|
|
489
|
+
canceledImmediately: false,
|
|
490
|
+
accessUntil: subscription.currentPeriodEnd
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
async function changeSubscription(ctx, params) {
|
|
494
|
+
const { customerId, newPlanCode, prorate = true } = params;
|
|
495
|
+
const customer = await ctx.internalAdapter.findCustomerByExternalId(customerId);
|
|
496
|
+
if (!customer) {
|
|
497
|
+
throw new Error("Customer not found");
|
|
498
|
+
}
|
|
499
|
+
const subscription = await ctx.internalAdapter.findSubscriptionByCustomerId(
|
|
500
|
+
customer.id
|
|
501
|
+
);
|
|
502
|
+
if (!subscription) {
|
|
503
|
+
throw new Error("No active subscription found");
|
|
504
|
+
}
|
|
505
|
+
if (subscription.planCode === newPlanCode) {
|
|
506
|
+
throw new Error("Already on this plan");
|
|
507
|
+
}
|
|
508
|
+
const oldPlan = ctx.internalAdapter.findPlanByCode(subscription.planCode);
|
|
509
|
+
const oldPrice = ctx.internalAdapter.getPlanPrice(
|
|
510
|
+
subscription.planCode,
|
|
511
|
+
subscription.interval
|
|
512
|
+
);
|
|
513
|
+
if (!oldPrice) {
|
|
514
|
+
throw new Error("Current plan price not found");
|
|
515
|
+
}
|
|
516
|
+
const newPlan = ctx.internalAdapter.findPlanByCode(newPlanCode);
|
|
517
|
+
if (!newPlan) {
|
|
518
|
+
throw new Error("New plan not found");
|
|
519
|
+
}
|
|
520
|
+
const newPrice = ctx.internalAdapter.getPlanPrice(
|
|
521
|
+
newPlanCode,
|
|
522
|
+
subscription.interval
|
|
523
|
+
);
|
|
524
|
+
if (!newPrice) {
|
|
525
|
+
throw new Error(
|
|
526
|
+
`No price found for plan ${newPlanCode} with interval ${subscription.interval}`
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
let payment = null;
|
|
530
|
+
if (prorate) {
|
|
531
|
+
const prorationResult = calculateProration({
|
|
532
|
+
oldPlanAmount: oldPrice.amount,
|
|
533
|
+
newPlanAmount: newPrice.amount,
|
|
534
|
+
currentPeriodStart: subscription.currentPeriodStart,
|
|
535
|
+
currentPeriodEnd: subscription.currentPeriodEnd,
|
|
536
|
+
changeDate: /* @__PURE__ */ new Date()
|
|
537
|
+
});
|
|
538
|
+
ctx.logger.info("Proration calculated", {
|
|
539
|
+
credit: prorationResult.credit,
|
|
540
|
+
charge: prorationResult.charge,
|
|
541
|
+
netAmount: prorationResult.netAmount,
|
|
542
|
+
daysRemaining: prorationResult.daysRemaining
|
|
543
|
+
});
|
|
544
|
+
if (prorationResult.netAmount > 0) {
|
|
545
|
+
if (!ctx.paymentAdapter?.charge) {
|
|
546
|
+
throw new Error(
|
|
547
|
+
"Payment adapter does not support direct charging. Cannot process upgrade."
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
if (!customer.providerCustomerId) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
"Customer does not have a saved payment method. Cannot process upgrade."
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
const chargeResult = await ctx.paymentAdapter.charge({
|
|
556
|
+
customer: {
|
|
557
|
+
id: customer.id,
|
|
558
|
+
email: customer.email,
|
|
559
|
+
providerCustomerId: customer.providerCustomerId
|
|
560
|
+
},
|
|
561
|
+
amount: prorationResult.netAmount,
|
|
562
|
+
currency: newPrice.currency,
|
|
563
|
+
description: `Upgrade from ${oldPlan?.name ?? subscription.planCode} to ${newPlan.name}`,
|
|
564
|
+
metadata: {
|
|
565
|
+
subscriptionId: subscription.id,
|
|
566
|
+
customerId: customer.id,
|
|
567
|
+
type: "upgrade",
|
|
568
|
+
oldPlanCode: subscription.planCode,
|
|
569
|
+
newPlanCode
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
if (chargeResult.status === "failed") {
|
|
573
|
+
throw new Error(chargeResult.error ?? "Charge failed");
|
|
574
|
+
}
|
|
575
|
+
payment = await ctx.internalAdapter.createPayment({
|
|
576
|
+
customerId: customer.id,
|
|
577
|
+
subscriptionId: subscription.id,
|
|
578
|
+
type: "upgrade",
|
|
579
|
+
status: "succeeded",
|
|
580
|
+
amount: prorationResult.netAmount,
|
|
581
|
+
currency: newPrice.currency,
|
|
582
|
+
providerPaymentId: chargeResult.providerPaymentId,
|
|
583
|
+
metadata: {
|
|
584
|
+
oldPlanCode: subscription.planCode,
|
|
585
|
+
newPlanCode,
|
|
586
|
+
proration: {
|
|
587
|
+
credit: prorationResult.credit,
|
|
588
|
+
charge: prorationResult.charge
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const updatedSubscription = await ctx.internalAdapter.updateSubscription(
|
|
595
|
+
subscription.id,
|
|
596
|
+
{ planCode: newPlanCode }
|
|
597
|
+
);
|
|
598
|
+
return {
|
|
599
|
+
subscription: updatedSubscription,
|
|
600
|
+
previousPlan: oldPlan,
|
|
601
|
+
newPlan,
|
|
602
|
+
payment
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// src/logic/trial-end-service.ts
|
|
607
|
+
async function handleTrialEnd(ctx, params) {
|
|
608
|
+
const { subscriptionId } = params;
|
|
609
|
+
const subscription = await ctx.internalAdapter.findSubscriptionById(subscriptionId);
|
|
610
|
+
if (!subscription) {
|
|
611
|
+
throw new Error("Subscription not found");
|
|
612
|
+
}
|
|
613
|
+
const customer = await ctx.internalAdapter.findCustomerById(
|
|
614
|
+
subscription.customerId
|
|
615
|
+
);
|
|
616
|
+
if (!customer) {
|
|
617
|
+
throw new Error("Customer not found");
|
|
618
|
+
}
|
|
619
|
+
const plan = ctx.internalAdapter.findPlanByCode(subscription.planCode);
|
|
620
|
+
ctx.logger.info("Processing trial end", {
|
|
621
|
+
subscriptionId: subscription.id,
|
|
622
|
+
customerId: customer.id,
|
|
623
|
+
planCode: plan?.code
|
|
624
|
+
});
|
|
625
|
+
if (!customer.providerCustomerId) {
|
|
626
|
+
ctx.logger.info("No payment method, canceling subscription", {
|
|
627
|
+
subscriptionId: subscription.id
|
|
628
|
+
});
|
|
629
|
+
const canceledSubscription = await ctx.internalAdapter.cancelSubscription(
|
|
630
|
+
subscription.id
|
|
631
|
+
);
|
|
632
|
+
return {
|
|
633
|
+
subscription: canceledSubscription ?? {
|
|
634
|
+
...subscription,
|
|
635
|
+
status: "canceled"
|
|
636
|
+
},
|
|
637
|
+
converted: false
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
ctx.logger.info("Payment method exists, activating subscription", {
|
|
641
|
+
subscriptionId: subscription.id
|
|
642
|
+
});
|
|
643
|
+
const activeSubscription = await ctx.internalAdapter.updateSubscription(
|
|
644
|
+
subscription.id,
|
|
645
|
+
{ status: "active" }
|
|
646
|
+
);
|
|
647
|
+
return {
|
|
648
|
+
subscription: activeSubscription ?? { ...subscription, status: "active" },
|
|
649
|
+
converted: true
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// src/logic/behaviors/defaults.ts
|
|
654
|
+
var defaultBehaviors = {
|
|
655
|
+
/**
|
|
656
|
+
* Default onRefund behavior: Delegates to refund service.
|
|
657
|
+
*
|
|
658
|
+
* The service handles all business logic:
|
|
659
|
+
* - Process the refund via payment adapter
|
|
660
|
+
* - Cancel the associated subscription (BillSDK opinionated default)
|
|
661
|
+
*
|
|
662
|
+
* Override this behavior if you want different logic (e.g., refund without cancel).
|
|
663
|
+
*/
|
|
664
|
+
onRefund: async (ctx, params) => {
|
|
665
|
+
return createRefund(ctx, {
|
|
666
|
+
paymentId: params.paymentId,
|
|
667
|
+
amount: params.amount,
|
|
668
|
+
reason: params.reason
|
|
669
|
+
});
|
|
670
|
+
},
|
|
671
|
+
/**
|
|
672
|
+
* Default onPaymentFailed behavior: Delegates to payment-failed service.
|
|
673
|
+
*
|
|
674
|
+
* The service handles all business logic:
|
|
675
|
+
* - Find subscription
|
|
676
|
+
* - Mark as past_due
|
|
677
|
+
*
|
|
678
|
+
* Override this behavior if you want different logic (e.g., immediate cancel).
|
|
679
|
+
*/
|
|
680
|
+
onPaymentFailed: async (ctx, params) => {
|
|
681
|
+
return handlePaymentFailed(ctx, {
|
|
682
|
+
subscriptionId: params.subscriptionId,
|
|
683
|
+
error: params.error
|
|
684
|
+
});
|
|
685
|
+
},
|
|
686
|
+
/**
|
|
687
|
+
* Default onSubscriptionCancel behavior: Delegates to cancel service.
|
|
688
|
+
*
|
|
689
|
+
* The service handles all business logic:
|
|
690
|
+
* - Find customer and subscription
|
|
691
|
+
* - Cancel immediately or at period end
|
|
692
|
+
*
|
|
693
|
+
* Override this behavior if you want different logic (e.g., downgrade to free).
|
|
694
|
+
*/
|
|
695
|
+
onSubscriptionCancel: async (ctx, params) => {
|
|
696
|
+
return cancelSubscription(ctx, {
|
|
697
|
+
customerId: params.customerId,
|
|
698
|
+
cancelAt: params.cancelAt
|
|
699
|
+
});
|
|
700
|
+
},
|
|
701
|
+
/**
|
|
702
|
+
* Default onTrialEnd behavior: Delegates to trial-end service.
|
|
703
|
+
*
|
|
704
|
+
* The service handles all business logic:
|
|
705
|
+
* - If customer has payment method: activates subscription
|
|
706
|
+
* - If no payment method: cancels subscription
|
|
707
|
+
*
|
|
708
|
+
* Override this behavior if you want different logic (e.g., extend trial).
|
|
709
|
+
*/
|
|
710
|
+
onTrialEnd: async (ctx, params) => {
|
|
711
|
+
return handleTrialEnd(ctx, {
|
|
712
|
+
subscriptionId: params.subscriptionId
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
// src/logic/behaviors/runner.ts
|
|
718
|
+
async function runBehavior(ctx, behaviorName, params) {
|
|
719
|
+
const userBehavior = ctx.options.behaviors?.[behaviorName];
|
|
720
|
+
const defaultFn = defaultBehaviors[behaviorName];
|
|
721
|
+
const defaultBehavior = async () => {
|
|
722
|
+
return defaultFn(ctx, params);
|
|
723
|
+
};
|
|
724
|
+
if (userBehavior) {
|
|
725
|
+
ctx.logger.debug(`Running user-defined behavior: ${behaviorName}`);
|
|
726
|
+
return userBehavior(ctx, params, defaultBehavior);
|
|
727
|
+
}
|
|
728
|
+
ctx.logger.debug(`Running default behavior: ${behaviorName}`);
|
|
729
|
+
return defaultBehavior();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// src/api/routes/refund.ts
|
|
733
|
+
var createRefundSchema = z.object({
|
|
734
|
+
/**
|
|
735
|
+
* Payment ID to refund (BillSDK payment ID)
|
|
736
|
+
*/
|
|
737
|
+
paymentId: z.string().min(1),
|
|
738
|
+
/**
|
|
739
|
+
* Amount to refund in cents (partial refund)
|
|
740
|
+
* If omitted, full refund is issued
|
|
741
|
+
*/
|
|
742
|
+
amount: z.number().positive().optional(),
|
|
743
|
+
/**
|
|
744
|
+
* Reason for the refund
|
|
745
|
+
*/
|
|
746
|
+
reason: z.string().optional()
|
|
747
|
+
});
|
|
748
|
+
var refundEndpoints = {
|
|
749
|
+
createRefund: {
|
|
750
|
+
path: "/refund",
|
|
751
|
+
options: {
|
|
752
|
+
method: "POST",
|
|
753
|
+
body: createRefundSchema
|
|
754
|
+
},
|
|
755
|
+
handler: async (context) => {
|
|
756
|
+
const { ctx, body } = context;
|
|
757
|
+
return runBehavior(ctx, "onRefund", {
|
|
758
|
+
paymentId: body.paymentId,
|
|
759
|
+
amount: body.amount,
|
|
760
|
+
reason: body.reason
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
};
|
|
160
765
|
var getSubscriptionQuerySchema = z.object({
|
|
161
766
|
customerId: z.string().min(1)
|
|
162
767
|
});
|
|
@@ -171,6 +776,15 @@ var cancelSubscriptionSchema = z.object({
|
|
|
171
776
|
customerId: z.string().min(1),
|
|
172
777
|
cancelAt: z.enum(["period_end", "immediately"]).optional().default("period_end")
|
|
173
778
|
});
|
|
779
|
+
var changeSubscriptionSchema = z.object({
|
|
780
|
+
customerId: z.string().min(1),
|
|
781
|
+
newPlanCode: z.string().min(1),
|
|
782
|
+
/**
|
|
783
|
+
* Whether to prorate the charge/credit
|
|
784
|
+
* If false, the new plan starts at the next billing cycle
|
|
785
|
+
*/
|
|
786
|
+
prorate: z.boolean().optional().default(true)
|
|
787
|
+
});
|
|
174
788
|
var subscriptionEndpoints = {
|
|
175
789
|
getSubscription: {
|
|
176
790
|
path: "/subscription",
|
|
@@ -210,101 +824,13 @@ var subscriptionEndpoints = {
|
|
|
210
824
|
},
|
|
211
825
|
handler: async (context) => {
|
|
212
826
|
const { ctx, body } = context;
|
|
213
|
-
|
|
214
|
-
|
|
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,
|
|
827
|
+
return createSubscription(ctx, {
|
|
828
|
+
customerId: body.customerId,
|
|
237
829
|
planCode: body.planCode,
|
|
238
830
|
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
831
|
successUrl: body.successUrl,
|
|
261
|
-
cancelUrl: body.cancelUrl
|
|
262
|
-
metadata: {
|
|
263
|
-
subscriptionId: subscription.id,
|
|
264
|
-
customerId: customer.id
|
|
265
|
-
}
|
|
832
|
+
cancelUrl: body.cancelUrl
|
|
266
833
|
});
|
|
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
834
|
}
|
|
309
835
|
},
|
|
310
836
|
cancelSubscription: {
|
|
@@ -315,31 +841,22 @@ var subscriptionEndpoints = {
|
|
|
315
841
|
},
|
|
316
842
|
handler: async (context) => {
|
|
317
843
|
const { ctx, body } = context;
|
|
318
|
-
|
|
319
|
-
body.customerId
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
};
|
|
844
|
+
return runBehavior(ctx, "onSubscriptionCancel", {
|
|
845
|
+
customerId: body.customerId,
|
|
846
|
+
cancelAt: body.cancelAt
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
},
|
|
850
|
+
changeSubscription: {
|
|
851
|
+
path: "/subscription/change",
|
|
852
|
+
options: {
|
|
853
|
+
method: "POST",
|
|
854
|
+
body: changeSubscriptionSchema
|
|
855
|
+
},
|
|
856
|
+
handler: async (context) => {
|
|
857
|
+
const { ctx, body } = context;
|
|
858
|
+
const billingCtx = ctx;
|
|
859
|
+
return changeSubscription(billingCtx, body);
|
|
343
860
|
}
|
|
344
861
|
}
|
|
345
862
|
};
|
|
@@ -435,6 +952,8 @@ function getEndpoints(ctx) {
|
|
|
435
952
|
...planEndpoints,
|
|
436
953
|
...subscriptionEndpoints,
|
|
437
954
|
...featureEndpoints,
|
|
955
|
+
...paymentEndpoints,
|
|
956
|
+
...refundEndpoints,
|
|
438
957
|
...webhookEndpoints
|
|
439
958
|
};
|
|
440
959
|
let allEndpoints = { ...baseEndpoints };
|
|
@@ -612,151 +1131,6 @@ function createRouter(ctx) {
|
|
|
612
1131
|
return { handler, endpoints };
|
|
613
1132
|
}
|
|
614
1133
|
|
|
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
1134
|
// src/db/internal-adapter.ts
|
|
761
1135
|
function planConfigToPlan(config) {
|
|
762
1136
|
return {
|
|
@@ -981,6 +1355,59 @@ function createInternalAdapter(adapter, plans = [], features = []) {
|
|
|
981
1355
|
}
|
|
982
1356
|
const planFeatures = this.getPlanFeatures(subscription.planCode);
|
|
983
1357
|
return { allowed: planFeatures.includes(featureCode) };
|
|
1358
|
+
},
|
|
1359
|
+
// Payment operations (DB)
|
|
1360
|
+
async createPayment(data) {
|
|
1361
|
+
const now = /* @__PURE__ */ new Date();
|
|
1362
|
+
return adapter.create({
|
|
1363
|
+
model: TABLES.PAYMENT,
|
|
1364
|
+
data: {
|
|
1365
|
+
customerId: data.customerId,
|
|
1366
|
+
subscriptionId: data.subscriptionId,
|
|
1367
|
+
type: data.type,
|
|
1368
|
+
status: data.status ?? "pending",
|
|
1369
|
+
amount: data.amount,
|
|
1370
|
+
currency: data.currency ?? "usd",
|
|
1371
|
+
providerPaymentId: data.providerPaymentId,
|
|
1372
|
+
metadata: data.metadata,
|
|
1373
|
+
createdAt: now,
|
|
1374
|
+
updatedAt: now
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
},
|
|
1378
|
+
async findPaymentById(id) {
|
|
1379
|
+
return adapter.findOne({
|
|
1380
|
+
model: TABLES.PAYMENT,
|
|
1381
|
+
where: [{ field: "id", operator: "eq", value: id }]
|
|
1382
|
+
});
|
|
1383
|
+
},
|
|
1384
|
+
async findPaymentByProviderPaymentId(providerPaymentId) {
|
|
1385
|
+
return adapter.findOne({
|
|
1386
|
+
model: TABLES.PAYMENT,
|
|
1387
|
+
where: [
|
|
1388
|
+
{
|
|
1389
|
+
field: "providerPaymentId",
|
|
1390
|
+
operator: "eq",
|
|
1391
|
+
value: providerPaymentId
|
|
1392
|
+
}
|
|
1393
|
+
]
|
|
1394
|
+
});
|
|
1395
|
+
},
|
|
1396
|
+
async updatePayment(id, data) {
|
|
1397
|
+
return adapter.update({
|
|
1398
|
+
model: TABLES.PAYMENT,
|
|
1399
|
+
where: [{ field: "id", operator: "eq", value: id }],
|
|
1400
|
+
update: { ...data, updatedAt: /* @__PURE__ */ new Date() }
|
|
1401
|
+
});
|
|
1402
|
+
},
|
|
1403
|
+
async listPayments(customerId, options) {
|
|
1404
|
+
return adapter.findMany({
|
|
1405
|
+
model: TABLES.PAYMENT,
|
|
1406
|
+
where: [{ field: "customerId", operator: "eq", value: customerId }],
|
|
1407
|
+
limit: options?.limit,
|
|
1408
|
+
offset: options?.offset,
|
|
1409
|
+
sortBy: { field: "createdAt", direction: "desc" }
|
|
1410
|
+
});
|
|
984
1411
|
}
|
|
985
1412
|
};
|
|
986
1413
|
}
|
|
@@ -1020,6 +1447,7 @@ function resolveOptions(options, adapter) {
|
|
|
1020
1447
|
features: options.features,
|
|
1021
1448
|
plugins: options.plugins ?? [],
|
|
1022
1449
|
hooks: options.hooks ?? {},
|
|
1450
|
+
behaviors: options.behaviors,
|
|
1023
1451
|
logger: {
|
|
1024
1452
|
level: options.logger?.level ?? "info",
|
|
1025
1453
|
disabled: options.logger?.disabled ?? false
|
|
@@ -1118,112 +1546,25 @@ function createAPI(contextPromise) {
|
|
|
1118
1546
|
},
|
|
1119
1547
|
async createSubscription(params) {
|
|
1120
1548
|
const ctx = await contextPromise;
|
|
1121
|
-
|
|
1122
|
-
|
|
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,
|
|
1549
|
+
return createSubscription(ctx, {
|
|
1550
|
+
customerId: params.customerId,
|
|
1143
1551
|
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 },
|
|
1552
|
+
interval: params.interval,
|
|
1161
1553
|
successUrl: params.successUrl,
|
|
1162
|
-
cancelUrl: params.cancelUrl
|
|
1163
|
-
metadata: { subscriptionId: subscription.id }
|
|
1554
|
+
cancelUrl: params.cancelUrl
|
|
1164
1555
|
});
|
|
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
1556
|
},
|
|
1207
1557
|
async cancelSubscription(params) {
|
|
1208
1558
|
const ctx = await contextPromise;
|
|
1209
|
-
const
|
|
1210
|
-
params.customerId
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
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
|
-
);
|
|
1559
|
+
const result = await runBehavior(ctx, "onSubscriptionCancel", {
|
|
1560
|
+
customerId: params.customerId,
|
|
1561
|
+
cancelAt: params.cancelAt
|
|
1562
|
+
});
|
|
1563
|
+
return result.subscription;
|
|
1564
|
+
},
|
|
1565
|
+
async changeSubscription(params) {
|
|
1566
|
+
const ctx = await contextPromise;
|
|
1567
|
+
return changeSubscription(ctx, params);
|
|
1227
1568
|
},
|
|
1228
1569
|
async checkFeature(params) {
|
|
1229
1570
|
const ctx = await contextPromise;
|
|
@@ -1257,6 +1598,29 @@ function createAPI(contextPromise) {
|
|
|
1257
1598
|
status: "ok",
|
|
1258
1599
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1259
1600
|
};
|
|
1601
|
+
},
|
|
1602
|
+
async listPayments(params) {
|
|
1603
|
+
const ctx = await contextPromise;
|
|
1604
|
+
const customer = await ctx.internalAdapter.findCustomerByExternalId(
|
|
1605
|
+
params.customerId
|
|
1606
|
+
);
|
|
1607
|
+
if (!customer) return [];
|
|
1608
|
+
return ctx.internalAdapter.listPayments(customer.id, {
|
|
1609
|
+
limit: params.limit,
|
|
1610
|
+
offset: params.offset
|
|
1611
|
+
});
|
|
1612
|
+
},
|
|
1613
|
+
async getPayment(params) {
|
|
1614
|
+
const ctx = await contextPromise;
|
|
1615
|
+
return ctx.internalAdapter.findPaymentById(params.paymentId);
|
|
1616
|
+
},
|
|
1617
|
+
async createRefund(params) {
|
|
1618
|
+
const ctx = await contextPromise;
|
|
1619
|
+
return runBehavior(ctx, "onRefund", {
|
|
1620
|
+
paymentId: params.paymentId,
|
|
1621
|
+
amount: params.amount,
|
|
1622
|
+
reason: params.reason
|
|
1623
|
+
});
|
|
1260
1624
|
}
|
|
1261
1625
|
};
|
|
1262
1626
|
}
|