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/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
- 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,
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
- 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
- };
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
- 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,
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 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
- );
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
  }