billsdk 0.2.0 → 0.3.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/index.js +447 -22
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createDefaultTimeProvider } from '@billsdk/core';
|
|
1
2
|
export * from '@billsdk/core';
|
|
2
3
|
export { drizzleAdapter } from '@billsdk/drizzle-adapter';
|
|
3
4
|
import { memoryAdapter } from '@billsdk/memory-adapter';
|
|
@@ -325,7 +326,8 @@ function calculateProration(params) {
|
|
|
325
326
|
changeDate = /* @__PURE__ */ new Date()
|
|
326
327
|
} = params;
|
|
327
328
|
const totalDays = daysBetween(currentPeriodStart, currentPeriodEnd);
|
|
328
|
-
const
|
|
329
|
+
const daysUsed = daysBetween(currentPeriodStart, changeDate);
|
|
330
|
+
const daysRemaining = totalDays - daysUsed;
|
|
329
331
|
const effectiveDaysRemaining = Math.max(
|
|
330
332
|
0,
|
|
331
333
|
Math.min(daysRemaining, totalDays)
|
|
@@ -333,15 +335,13 @@ function calculateProration(params) {
|
|
|
333
335
|
const credit = Math.round(
|
|
334
336
|
oldPlanAmount / totalDays * effectiveDaysRemaining
|
|
335
337
|
);
|
|
336
|
-
const charge =
|
|
337
|
-
|
|
338
|
-
);
|
|
339
|
-
const netAmount = charge - credit;
|
|
338
|
+
const charge = newPlanAmount;
|
|
339
|
+
const netAmount = Math.max(0, charge - credit);
|
|
340
340
|
return {
|
|
341
341
|
credit,
|
|
342
342
|
charge,
|
|
343
343
|
netAmount,
|
|
344
|
-
|
|
344
|
+
daysUsed: Math.max(0, Math.min(daysUsed, totalDays)),
|
|
345
345
|
totalDays
|
|
346
346
|
};
|
|
347
347
|
}
|
|
@@ -490,8 +490,19 @@ async function cancelSubscription(ctx, params) {
|
|
|
490
490
|
accessUntil: subscription.currentPeriodEnd
|
|
491
491
|
};
|
|
492
492
|
}
|
|
493
|
+
var intervalRank = {
|
|
494
|
+
monthly: 1,
|
|
495
|
+
quarterly: 2,
|
|
496
|
+
yearly: 3
|
|
497
|
+
};
|
|
498
|
+
function isUpgrade(oldPrice, newPrice, oldInterval, newInterval) {
|
|
499
|
+
if (oldInterval !== newInterval) {
|
|
500
|
+
return intervalRank[newInterval] > intervalRank[oldInterval];
|
|
501
|
+
}
|
|
502
|
+
return newPrice > oldPrice;
|
|
503
|
+
}
|
|
493
504
|
async function changeSubscription(ctx, params) {
|
|
494
|
-
const { customerId, newPlanCode, prorate = true } = params;
|
|
505
|
+
const { customerId, newPlanCode, newInterval, prorate = true } = params;
|
|
495
506
|
const customer = await ctx.internalAdapter.findCustomerByExternalId(customerId);
|
|
496
507
|
if (!customer) {
|
|
497
508
|
throw new Error("Customer not found");
|
|
@@ -502,7 +513,8 @@ async function changeSubscription(ctx, params) {
|
|
|
502
513
|
if (!subscription) {
|
|
503
514
|
throw new Error("No active subscription found");
|
|
504
515
|
}
|
|
505
|
-
|
|
516
|
+
const targetInterval = newInterval ?? subscription.interval;
|
|
517
|
+
if (subscription.planCode === newPlanCode && subscription.interval === targetInterval) {
|
|
506
518
|
throw new Error("Already on this plan");
|
|
507
519
|
}
|
|
508
520
|
const oldPlan = ctx.internalAdapter.findPlanByCode(subscription.planCode);
|
|
@@ -519,27 +531,67 @@ async function changeSubscription(ctx, params) {
|
|
|
519
531
|
}
|
|
520
532
|
const newPrice = ctx.internalAdapter.getPlanPrice(
|
|
521
533
|
newPlanCode,
|
|
522
|
-
|
|
534
|
+
targetInterval
|
|
523
535
|
);
|
|
524
536
|
if (!newPrice) {
|
|
525
537
|
throw new Error(
|
|
526
|
-
`No price found for plan ${newPlanCode} with interval ${
|
|
538
|
+
`No price found for plan ${newPlanCode} with interval ${targetInterval}`
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
const upgrade = isUpgrade(
|
|
542
|
+
oldPrice.amount,
|
|
543
|
+
newPrice.amount,
|
|
544
|
+
subscription.interval,
|
|
545
|
+
targetInterval
|
|
546
|
+
);
|
|
547
|
+
ctx.logger.info("Plan change detected", {
|
|
548
|
+
from: `${subscription.planCode} (${subscription.interval})`,
|
|
549
|
+
to: `${newPlanCode} (${targetInterval})`,
|
|
550
|
+
oldAmount: oldPrice.amount,
|
|
551
|
+
newAmount: newPrice.amount,
|
|
552
|
+
isUpgrade: upgrade
|
|
553
|
+
});
|
|
554
|
+
if (!upgrade) {
|
|
555
|
+
ctx.logger.info("Downgrade scheduled for period end", {
|
|
556
|
+
scheduledPlanCode: newPlanCode,
|
|
557
|
+
scheduledInterval: targetInterval,
|
|
558
|
+
effectiveAt: subscription.currentPeriodEnd
|
|
559
|
+
});
|
|
560
|
+
const updatedSubscription2 = await ctx.internalAdapter.updateSubscription(
|
|
561
|
+
subscription.id,
|
|
562
|
+
{
|
|
563
|
+
scheduledPlanCode: newPlanCode,
|
|
564
|
+
scheduledInterval: targetInterval
|
|
565
|
+
}
|
|
527
566
|
);
|
|
567
|
+
return {
|
|
568
|
+
subscription: updatedSubscription2,
|
|
569
|
+
previousPlan: oldPlan,
|
|
570
|
+
newPlan,
|
|
571
|
+
payment: null,
|
|
572
|
+
scheduled: true,
|
|
573
|
+
effectiveAt: subscription.currentPeriodEnd
|
|
574
|
+
};
|
|
528
575
|
}
|
|
529
576
|
let payment = null;
|
|
577
|
+
const changeDate = await ctx.timeProvider.now(params.customerId);
|
|
578
|
+
ctx.logger.info("changeDate from timeProvider", {
|
|
579
|
+
changeDate: changeDate.toISOString(),
|
|
580
|
+
realTime: (/* @__PURE__ */ new Date()).toISOString()
|
|
581
|
+
});
|
|
530
582
|
if (prorate) {
|
|
531
583
|
const prorationResult = calculateProration({
|
|
532
584
|
oldPlanAmount: oldPrice.amount,
|
|
533
585
|
newPlanAmount: newPrice.amount,
|
|
534
586
|
currentPeriodStart: subscription.currentPeriodStart,
|
|
535
587
|
currentPeriodEnd: subscription.currentPeriodEnd,
|
|
536
|
-
changeDate
|
|
588
|
+
changeDate
|
|
537
589
|
});
|
|
538
590
|
ctx.logger.info("Proration calculated", {
|
|
539
591
|
credit: prorationResult.credit,
|
|
540
592
|
charge: prorationResult.charge,
|
|
541
593
|
netAmount: prorationResult.netAmount,
|
|
542
|
-
|
|
594
|
+
daysUsed: prorationResult.daysUsed
|
|
543
595
|
});
|
|
544
596
|
if (prorationResult.netAmount > 0) {
|
|
545
597
|
if (!ctx.paymentAdapter?.charge) {
|
|
@@ -591,15 +643,36 @@ async function changeSubscription(ctx, params) {
|
|
|
591
643
|
});
|
|
592
644
|
}
|
|
593
645
|
}
|
|
646
|
+
const newPeriodStart = changeDate;
|
|
647
|
+
const newPeriodEnd = new Date(changeDate);
|
|
648
|
+
if (targetInterval === "yearly") {
|
|
649
|
+
newPeriodEnd.setFullYear(newPeriodEnd.getFullYear() + 1);
|
|
650
|
+
} else if (targetInterval === "quarterly") {
|
|
651
|
+
newPeriodEnd.setMonth(newPeriodEnd.getMonth() + 3);
|
|
652
|
+
} else {
|
|
653
|
+
newPeriodEnd.setMonth(newPeriodEnd.getMonth() + 1);
|
|
654
|
+
}
|
|
594
655
|
const updatedSubscription = await ctx.internalAdapter.updateSubscription(
|
|
595
656
|
subscription.id,
|
|
596
|
-
{
|
|
657
|
+
{
|
|
658
|
+
planCode: newPlanCode,
|
|
659
|
+
interval: targetInterval,
|
|
660
|
+
currentPeriodStart: newPeriodStart,
|
|
661
|
+
currentPeriodEnd: newPeriodEnd,
|
|
662
|
+
scheduledPlanCode: void 0,
|
|
663
|
+
scheduledInterval: void 0
|
|
664
|
+
}
|
|
597
665
|
);
|
|
666
|
+
ctx.logger.info("Period reset", {
|
|
667
|
+
from: `${subscription.currentPeriodStart.toISOString()} - ${subscription.currentPeriodEnd.toISOString()}`,
|
|
668
|
+
to: `${newPeriodStart.toISOString()} - ${newPeriodEnd.toISOString()}`
|
|
669
|
+
});
|
|
598
670
|
return {
|
|
599
671
|
subscription: updatedSubscription,
|
|
600
672
|
previousPlan: oldPlan,
|
|
601
673
|
newPlan,
|
|
602
|
-
payment
|
|
674
|
+
payment,
|
|
675
|
+
scheduled: false
|
|
603
676
|
};
|
|
604
677
|
}
|
|
605
678
|
|
|
@@ -762,6 +835,326 @@ var refundEndpoints = {
|
|
|
762
835
|
}
|
|
763
836
|
}
|
|
764
837
|
};
|
|
838
|
+
|
|
839
|
+
// src/logic/renewal-service.ts
|
|
840
|
+
function calculateNewPeriodEnd(from, interval) {
|
|
841
|
+
const newEnd = new Date(from);
|
|
842
|
+
if (interval === "yearly") {
|
|
843
|
+
newEnd.setFullYear(newEnd.getFullYear() + 1);
|
|
844
|
+
} else if (interval === "quarterly") {
|
|
845
|
+
newEnd.setMonth(newEnd.getMonth() + 3);
|
|
846
|
+
} else {
|
|
847
|
+
newEnd.setMonth(newEnd.getMonth() + 1);
|
|
848
|
+
}
|
|
849
|
+
return newEnd;
|
|
850
|
+
}
|
|
851
|
+
async function findDueSubscriptions(ctx, params) {
|
|
852
|
+
const now = params.customerId ? await ctx.timeProvider.now(params.customerId) : /* @__PURE__ */ new Date();
|
|
853
|
+
const where = [
|
|
854
|
+
// Only active or past_due subscriptions
|
|
855
|
+
{
|
|
856
|
+
field: "status",
|
|
857
|
+
operator: "in",
|
|
858
|
+
value: ["active", "past_due"]
|
|
859
|
+
},
|
|
860
|
+
// Period has ended
|
|
861
|
+
{
|
|
862
|
+
field: "currentPeriodEnd",
|
|
863
|
+
operator: "lte",
|
|
864
|
+
value: now
|
|
865
|
+
}
|
|
866
|
+
];
|
|
867
|
+
if (params.customerId) {
|
|
868
|
+
const customer = await ctx.internalAdapter.findCustomerByExternalId(
|
|
869
|
+
params.customerId
|
|
870
|
+
);
|
|
871
|
+
if (!customer) {
|
|
872
|
+
return [];
|
|
873
|
+
}
|
|
874
|
+
where.push({
|
|
875
|
+
field: "customerId",
|
|
876
|
+
operator: "eq",
|
|
877
|
+
value: customer.id
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
return ctx.adapter.findMany({
|
|
881
|
+
model: TABLES.SUBSCRIPTION,
|
|
882
|
+
where,
|
|
883
|
+
limit: params.limit,
|
|
884
|
+
sortBy: { field: "currentPeriodEnd", direction: "asc" }
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
async function applyScheduledChanges(ctx, subscription) {
|
|
888
|
+
if (!subscription.scheduledPlanCode) {
|
|
889
|
+
return {
|
|
890
|
+
planChanged: false,
|
|
891
|
+
newPlanCode: subscription.planCode,
|
|
892
|
+
newInterval: subscription.interval
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
const newPlanCode = subscription.scheduledPlanCode;
|
|
896
|
+
const newInterval = subscription.scheduledInterval ?? subscription.interval;
|
|
897
|
+
ctx.logger.info("Applying scheduled plan change", {
|
|
898
|
+
subscriptionId: subscription.id,
|
|
899
|
+
from: subscription.planCode,
|
|
900
|
+
to: newPlanCode,
|
|
901
|
+
newInterval
|
|
902
|
+
});
|
|
903
|
+
await ctx.internalAdapter.updateSubscription(subscription.id, {
|
|
904
|
+
planCode: newPlanCode,
|
|
905
|
+
interval: newInterval,
|
|
906
|
+
scheduledPlanCode: void 0,
|
|
907
|
+
scheduledInterval: void 0
|
|
908
|
+
});
|
|
909
|
+
return {
|
|
910
|
+
planChanged: true,
|
|
911
|
+
newPlanCode,
|
|
912
|
+
newInterval
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
async function processSubscriptionRenewal(ctx, subscription, dryRun) {
|
|
916
|
+
const customer = await ctx.internalAdapter.findCustomerById(
|
|
917
|
+
subscription.customerId
|
|
918
|
+
);
|
|
919
|
+
if (!customer) {
|
|
920
|
+
return {
|
|
921
|
+
subscriptionId: subscription.id,
|
|
922
|
+
customerId: subscription.customerId,
|
|
923
|
+
status: "failed",
|
|
924
|
+
error: "Customer not found"
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
const {
|
|
928
|
+
planChanged,
|
|
929
|
+
newPlanCode,
|
|
930
|
+
newInterval: newIntervalStr
|
|
931
|
+
} = await applyScheduledChanges(ctx, subscription);
|
|
932
|
+
const newInterval = newIntervalStr;
|
|
933
|
+
const price = ctx.internalAdapter.getPlanPrice(newPlanCode, newInterval);
|
|
934
|
+
if (!price) {
|
|
935
|
+
return {
|
|
936
|
+
subscriptionId: subscription.id,
|
|
937
|
+
customerId: customer.externalId,
|
|
938
|
+
status: "failed",
|
|
939
|
+
error: `No price found for plan ${newPlanCode} with interval ${newInterval}`,
|
|
940
|
+
planChanged: planChanged ? { from: subscription.planCode, to: newPlanCode } : void 0
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
const amount = price.amount;
|
|
944
|
+
const now = await ctx.timeProvider.now(customer.externalId);
|
|
945
|
+
if (dryRun) {
|
|
946
|
+
return {
|
|
947
|
+
subscriptionId: subscription.id,
|
|
948
|
+
customerId: customer.externalId,
|
|
949
|
+
status: "succeeded",
|
|
950
|
+
amount,
|
|
951
|
+
planChanged: planChanged ? { from: subscription.planCode, to: newPlanCode } : void 0
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
if (amount === 0) {
|
|
955
|
+
const newPeriodEnd2 = calculateNewPeriodEnd(now, newInterval);
|
|
956
|
+
await ctx.internalAdapter.updateSubscription(subscription.id, {
|
|
957
|
+
currentPeriodStart: now,
|
|
958
|
+
currentPeriodEnd: newPeriodEnd2,
|
|
959
|
+
status: "active"
|
|
960
|
+
});
|
|
961
|
+
ctx.logger.info("Free renewal processed", {
|
|
962
|
+
subscriptionId: subscription.id,
|
|
963
|
+
newPeriodEnd: newPeriodEnd2.toISOString()
|
|
964
|
+
});
|
|
965
|
+
return {
|
|
966
|
+
subscriptionId: subscription.id,
|
|
967
|
+
customerId: customer.externalId,
|
|
968
|
+
status: "succeeded",
|
|
969
|
+
amount: 0,
|
|
970
|
+
planChanged: planChanged ? { from: subscription.planCode, to: newPlanCode } : void 0
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
if (!customer.providerCustomerId) {
|
|
974
|
+
ctx.logger.warn("Customer has no payment method, marking as past_due", {
|
|
975
|
+
subscriptionId: subscription.id,
|
|
976
|
+
customerId: customer.externalId
|
|
977
|
+
});
|
|
978
|
+
await ctx.internalAdapter.updateSubscription(subscription.id, {
|
|
979
|
+
status: "past_due"
|
|
980
|
+
});
|
|
981
|
+
return {
|
|
982
|
+
subscriptionId: subscription.id,
|
|
983
|
+
customerId: customer.externalId,
|
|
984
|
+
status: "failed",
|
|
985
|
+
error: "No payment method on file",
|
|
986
|
+
planChanged: planChanged ? { from: subscription.planCode, to: newPlanCode } : void 0
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
if (!ctx.paymentAdapter?.charge) {
|
|
990
|
+
ctx.logger.error("Payment adapter does not support direct charging", {
|
|
991
|
+
subscriptionId: subscription.id
|
|
992
|
+
});
|
|
993
|
+
return {
|
|
994
|
+
subscriptionId: subscription.id,
|
|
995
|
+
customerId: customer.externalId,
|
|
996
|
+
status: "failed",
|
|
997
|
+
error: "Payment adapter does not support direct charging",
|
|
998
|
+
planChanged: planChanged ? { from: subscription.planCode, to: newPlanCode } : void 0
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
const plan = ctx.internalAdapter.findPlanByCode(newPlanCode);
|
|
1002
|
+
const chargeResult = await ctx.paymentAdapter.charge({
|
|
1003
|
+
customer: {
|
|
1004
|
+
id: customer.id,
|
|
1005
|
+
email: customer.email,
|
|
1006
|
+
providerCustomerId: customer.providerCustomerId
|
|
1007
|
+
},
|
|
1008
|
+
amount,
|
|
1009
|
+
currency: price.currency,
|
|
1010
|
+
description: `Renewal: ${plan?.name ?? newPlanCode} (${newInterval})`,
|
|
1011
|
+
metadata: {
|
|
1012
|
+
subscriptionId: subscription.id,
|
|
1013
|
+
customerId: customer.id,
|
|
1014
|
+
type: "renewal",
|
|
1015
|
+
planCode: newPlanCode
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
if (chargeResult.status === "failed") {
|
|
1019
|
+
ctx.logger.warn("Renewal charge failed", {
|
|
1020
|
+
subscriptionId: subscription.id,
|
|
1021
|
+
error: chargeResult.error
|
|
1022
|
+
});
|
|
1023
|
+
await ctx.internalAdapter.updateSubscription(subscription.id, {
|
|
1024
|
+
status: "past_due"
|
|
1025
|
+
});
|
|
1026
|
+
return {
|
|
1027
|
+
subscriptionId: subscription.id,
|
|
1028
|
+
customerId: customer.externalId,
|
|
1029
|
+
status: "failed",
|
|
1030
|
+
error: chargeResult.error ?? "Charge failed",
|
|
1031
|
+
planChanged: planChanged ? { from: subscription.planCode, to: newPlanCode } : void 0
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
const newPeriodEnd = calculateNewPeriodEnd(now, newInterval);
|
|
1035
|
+
await ctx.internalAdapter.updateSubscription(subscription.id, {
|
|
1036
|
+
currentPeriodStart: now,
|
|
1037
|
+
currentPeriodEnd: newPeriodEnd,
|
|
1038
|
+
status: "active"
|
|
1039
|
+
});
|
|
1040
|
+
await ctx.internalAdapter.createPayment({
|
|
1041
|
+
customerId: customer.id,
|
|
1042
|
+
subscriptionId: subscription.id,
|
|
1043
|
+
type: "renewal",
|
|
1044
|
+
status: "succeeded",
|
|
1045
|
+
amount,
|
|
1046
|
+
currency: price.currency,
|
|
1047
|
+
providerPaymentId: chargeResult.providerPaymentId,
|
|
1048
|
+
metadata: {
|
|
1049
|
+
planCode: newPlanCode,
|
|
1050
|
+
interval: newInterval
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
ctx.logger.info("Renewal succeeded", {
|
|
1054
|
+
subscriptionId: subscription.id,
|
|
1055
|
+
amount,
|
|
1056
|
+
newPeriodEnd: newPeriodEnd.toISOString()
|
|
1057
|
+
});
|
|
1058
|
+
return {
|
|
1059
|
+
subscriptionId: subscription.id,
|
|
1060
|
+
customerId: customer.externalId,
|
|
1061
|
+
status: "succeeded",
|
|
1062
|
+
amount,
|
|
1063
|
+
planChanged: planChanged ? { from: subscription.planCode, to: newPlanCode } : void 0
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
async function processRenewals(ctx, params = {}) {
|
|
1067
|
+
const { dryRun = false } = params;
|
|
1068
|
+
ctx.logger.info("Starting renewal processing", {
|
|
1069
|
+
dryRun,
|
|
1070
|
+
customerId: params.customerId,
|
|
1071
|
+
limit: params.limit
|
|
1072
|
+
});
|
|
1073
|
+
const dueSubscriptions = await findDueSubscriptions(ctx, params);
|
|
1074
|
+
ctx.logger.info(
|
|
1075
|
+
`Found ${dueSubscriptions.length} subscriptions due for renewal`
|
|
1076
|
+
);
|
|
1077
|
+
const result = {
|
|
1078
|
+
processed: 0,
|
|
1079
|
+
succeeded: 0,
|
|
1080
|
+
failed: 0,
|
|
1081
|
+
skipped: 0,
|
|
1082
|
+
renewals: []
|
|
1083
|
+
};
|
|
1084
|
+
for (const subscription of dueSubscriptions) {
|
|
1085
|
+
result.processed++;
|
|
1086
|
+
try {
|
|
1087
|
+
const renewalResult = await processSubscriptionRenewal(
|
|
1088
|
+
ctx,
|
|
1089
|
+
subscription,
|
|
1090
|
+
dryRun
|
|
1091
|
+
);
|
|
1092
|
+
result.renewals.push(renewalResult);
|
|
1093
|
+
if (renewalResult.status === "succeeded") {
|
|
1094
|
+
result.succeeded++;
|
|
1095
|
+
} else if (renewalResult.status === "failed") {
|
|
1096
|
+
result.failed++;
|
|
1097
|
+
} else {
|
|
1098
|
+
result.skipped++;
|
|
1099
|
+
}
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
ctx.logger.error("Error processing renewal", {
|
|
1102
|
+
subscriptionId: subscription.id,
|
|
1103
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1104
|
+
});
|
|
1105
|
+
result.failed++;
|
|
1106
|
+
result.renewals.push({
|
|
1107
|
+
subscriptionId: subscription.id,
|
|
1108
|
+
customerId: subscription.customerId,
|
|
1109
|
+
status: "failed",
|
|
1110
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
ctx.logger.info("Renewal processing complete", {
|
|
1115
|
+
processed: result.processed,
|
|
1116
|
+
succeeded: result.succeeded,
|
|
1117
|
+
failed: result.failed,
|
|
1118
|
+
skipped: result.skipped
|
|
1119
|
+
});
|
|
1120
|
+
return result;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// src/api/routes/renewals.ts
|
|
1124
|
+
var processRenewalsQuerySchema = z.object({
|
|
1125
|
+
/**
|
|
1126
|
+
* Process only a specific customer (useful for testing)
|
|
1127
|
+
*/
|
|
1128
|
+
customerId: z.string().optional(),
|
|
1129
|
+
/**
|
|
1130
|
+
* Dry run - don't actually charge, just report what would happen
|
|
1131
|
+
*/
|
|
1132
|
+
dryRun: z.string().transform((val) => val === "true").optional(),
|
|
1133
|
+
/**
|
|
1134
|
+
* Maximum number of subscriptions to process (for batching)
|
|
1135
|
+
*/
|
|
1136
|
+
limit: z.string().transform((val) => Number.parseInt(val, 10)).refine((val) => !Number.isNaN(val) && val > 0, {
|
|
1137
|
+
message: "limit must be a positive number"
|
|
1138
|
+
}).optional()
|
|
1139
|
+
});
|
|
1140
|
+
var renewalEndpoints = {
|
|
1141
|
+
processRenewals: {
|
|
1142
|
+
path: "/renewals",
|
|
1143
|
+
options: {
|
|
1144
|
+
method: "GET",
|
|
1145
|
+
query: processRenewalsQuerySchema
|
|
1146
|
+
},
|
|
1147
|
+
handler: async (context) => {
|
|
1148
|
+
const { ctx, query } = context;
|
|
1149
|
+
const result = await processRenewals(ctx, {
|
|
1150
|
+
customerId: query.customerId,
|
|
1151
|
+
dryRun: query.dryRun ?? false,
|
|
1152
|
+
limit: query.limit
|
|
1153
|
+
});
|
|
1154
|
+
return result;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
};
|
|
765
1158
|
var getSubscriptionQuerySchema = z.object({
|
|
766
1159
|
customerId: z.string().min(1)
|
|
767
1160
|
});
|
|
@@ -884,7 +1277,8 @@ var webhookEndpoints = {
|
|
|
884
1277
|
}
|
|
885
1278
|
ctx.logger.debug("Payment confirmation received", {
|
|
886
1279
|
subscriptionId: result.subscriptionId,
|
|
887
|
-
status: result.status
|
|
1280
|
+
status: result.status,
|
|
1281
|
+
amount: result.amount
|
|
888
1282
|
});
|
|
889
1283
|
if (result.status === "active") {
|
|
890
1284
|
const subscription = await ctx.internalAdapter.findSubscriptionById(
|
|
@@ -917,6 +1311,28 @@ var webhookEndpoints = {
|
|
|
917
1311
|
});
|
|
918
1312
|
}
|
|
919
1313
|
}
|
|
1314
|
+
if (result.amount && result.amount > 0) {
|
|
1315
|
+
await ctx.internalAdapter.createPayment({
|
|
1316
|
+
customerId: subscription.customerId,
|
|
1317
|
+
subscriptionId: subscription.id,
|
|
1318
|
+
type: "subscription",
|
|
1319
|
+
status: "succeeded",
|
|
1320
|
+
amount: result.amount,
|
|
1321
|
+
currency: result.currency ?? "usd",
|
|
1322
|
+
providerPaymentId: result.providerPaymentId,
|
|
1323
|
+
metadata: {
|
|
1324
|
+
planCode: subscription.planCode,
|
|
1325
|
+
interval: subscription.interval,
|
|
1326
|
+
confirmedVia: "webhook"
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
ctx.logger.info("Payment record created", {
|
|
1330
|
+
subscriptionId: subscription.id,
|
|
1331
|
+
amount: result.amount,
|
|
1332
|
+
currency: result.currency,
|
|
1333
|
+
providerPaymentId: result.providerPaymentId
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
920
1336
|
ctx.logger.info("Subscription activated via webhook", {
|
|
921
1337
|
subscriptionId: subscription.id,
|
|
922
1338
|
providerSubscriptionId: result.providerSubscriptionId
|
|
@@ -954,6 +1370,7 @@ function getEndpoints(ctx) {
|
|
|
954
1370
|
...featureEndpoints,
|
|
955
1371
|
...paymentEndpoints,
|
|
956
1372
|
...refundEndpoints,
|
|
1373
|
+
...renewalEndpoints,
|
|
957
1374
|
...webhookEndpoints
|
|
958
1375
|
};
|
|
959
1376
|
let allEndpoints = { ...baseEndpoints };
|
|
@@ -1154,7 +1571,7 @@ function featureConfigToFeature(config) {
|
|
|
1154
1571
|
type: config.type ?? "boolean"
|
|
1155
1572
|
};
|
|
1156
1573
|
}
|
|
1157
|
-
function createInternalAdapter(adapter, plans = [], features = []) {
|
|
1574
|
+
function createInternalAdapter(adapter, plans = [], features = [], getNow = async () => /* @__PURE__ */ new Date()) {
|
|
1158
1575
|
const plansByCode = /* @__PURE__ */ new Map();
|
|
1159
1576
|
for (const config of plans) {
|
|
1160
1577
|
plansByCode.set(config.code, planConfigToPlan(config));
|
|
@@ -1166,7 +1583,7 @@ function createInternalAdapter(adapter, plans = [], features = []) {
|
|
|
1166
1583
|
return {
|
|
1167
1584
|
// Customer operations (DB)
|
|
1168
1585
|
async createCustomer(data) {
|
|
1169
|
-
const now =
|
|
1586
|
+
const now = await getNow();
|
|
1170
1587
|
return adapter.create({
|
|
1171
1588
|
model: TABLES.CUSTOMER,
|
|
1172
1589
|
data: {
|
|
@@ -1238,7 +1655,7 @@ function createInternalAdapter(adapter, plans = [], features = []) {
|
|
|
1238
1655
|
},
|
|
1239
1656
|
// Subscription operations (DB)
|
|
1240
1657
|
async createSubscription(data) {
|
|
1241
|
-
const now =
|
|
1658
|
+
const now = await getNow();
|
|
1242
1659
|
const interval = data.interval ?? "monthly";
|
|
1243
1660
|
const currentPeriodEnd = new Date(now);
|
|
1244
1661
|
if (interval === "yearly") {
|
|
@@ -1308,14 +1725,15 @@ function createInternalAdapter(adapter, plans = [], features = []) {
|
|
|
1308
1725
|
});
|
|
1309
1726
|
},
|
|
1310
1727
|
async updateSubscription(id, data) {
|
|
1728
|
+
const now = await getNow();
|
|
1311
1729
|
return adapter.update({
|
|
1312
1730
|
model: TABLES.SUBSCRIPTION,
|
|
1313
1731
|
where: [{ field: "id", operator: "eq", value: id }],
|
|
1314
|
-
update: { ...data, updatedAt:
|
|
1732
|
+
update: { ...data, updatedAt: now }
|
|
1315
1733
|
});
|
|
1316
1734
|
},
|
|
1317
1735
|
async cancelSubscription(id, cancelAt) {
|
|
1318
|
-
const now =
|
|
1736
|
+
const now = await getNow();
|
|
1319
1737
|
return adapter.update({
|
|
1320
1738
|
model: TABLES.SUBSCRIPTION,
|
|
1321
1739
|
where: [{ field: "id", operator: "eq", value: id }],
|
|
@@ -1358,7 +1776,7 @@ function createInternalAdapter(adapter, plans = [], features = []) {
|
|
|
1358
1776
|
},
|
|
1359
1777
|
// Payment operations (DB)
|
|
1360
1778
|
async createPayment(data) {
|
|
1361
|
-
const now =
|
|
1779
|
+
const now = await getNow();
|
|
1362
1780
|
return adapter.create({
|
|
1363
1781
|
model: TABLES.PAYMENT,
|
|
1364
1782
|
data: {
|
|
@@ -1467,10 +1885,12 @@ async function createBillingContext(adapter, options) {
|
|
|
1467
1885
|
schema = { ...schema, ...plugin.schema };
|
|
1468
1886
|
}
|
|
1469
1887
|
}
|
|
1888
|
+
const getNow = async () => /* @__PURE__ */ new Date();
|
|
1470
1889
|
const internalAdapter = createInternalAdapter(
|
|
1471
1890
|
adapter,
|
|
1472
1891
|
options.plans ?? [],
|
|
1473
|
-
options.features ?? []
|
|
1892
|
+
options.features ?? [],
|
|
1893
|
+
getNow
|
|
1474
1894
|
);
|
|
1475
1895
|
const context = {
|
|
1476
1896
|
options: resolvedOptions,
|
|
@@ -1482,6 +1902,7 @@ async function createBillingContext(adapter, options) {
|
|
|
1482
1902
|
plugins,
|
|
1483
1903
|
logger,
|
|
1484
1904
|
secret: resolvedOptions.secret,
|
|
1905
|
+
timeProvider: createDefaultTimeProvider(),
|
|
1485
1906
|
hasPlugin(id) {
|
|
1486
1907
|
return plugins.some((p) => p.id === id);
|
|
1487
1908
|
},
|
|
@@ -1621,6 +2042,10 @@ function createAPI(contextPromise) {
|
|
|
1621
2042
|
amount: params.amount,
|
|
1622
2043
|
reason: params.reason
|
|
1623
2044
|
});
|
|
2045
|
+
},
|
|
2046
|
+
async processRenewals(params) {
|
|
2047
|
+
const ctx = await contextPromise;
|
|
2048
|
+
return processRenewals(ctx, params);
|
|
1624
2049
|
}
|
|
1625
2050
|
};
|
|
1626
2051
|
}
|