billsdk 0.2.0 → 0.3.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
@@ -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 daysRemaining = daysBetween(changeDate, currentPeriodEnd);
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 = Math.round(
337
- newPlanAmount / totalDays * effectiveDaysRemaining
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
- daysRemaining: effectiveDaysRemaining,
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
- if (subscription.planCode === newPlanCode) {
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
- subscription.interval
534
+ targetInterval
523
535
  );
524
536
  if (!newPrice) {
525
537
  throw new Error(
526
- `No price found for plan ${newPlanCode} with interval ${subscription.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: /* @__PURE__ */ new Date()
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
- daysRemaining: prorationResult.daysRemaining
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
- { planCode: newPlanCode }
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
  });
@@ -954,6 +1347,7 @@ function getEndpoints(ctx) {
954
1347
  ...featureEndpoints,
955
1348
  ...paymentEndpoints,
956
1349
  ...refundEndpoints,
1350
+ ...renewalEndpoints,
957
1351
  ...webhookEndpoints
958
1352
  };
959
1353
  let allEndpoints = { ...baseEndpoints };
@@ -1154,7 +1548,7 @@ function featureConfigToFeature(config) {
1154
1548
  type: config.type ?? "boolean"
1155
1549
  };
1156
1550
  }
1157
- function createInternalAdapter(adapter, plans = [], features = []) {
1551
+ function createInternalAdapter(adapter, plans = [], features = [], getNow = async () => /* @__PURE__ */ new Date()) {
1158
1552
  const plansByCode = /* @__PURE__ */ new Map();
1159
1553
  for (const config of plans) {
1160
1554
  plansByCode.set(config.code, planConfigToPlan(config));
@@ -1166,7 +1560,7 @@ function createInternalAdapter(adapter, plans = [], features = []) {
1166
1560
  return {
1167
1561
  // Customer operations (DB)
1168
1562
  async createCustomer(data) {
1169
- const now = /* @__PURE__ */ new Date();
1563
+ const now = await getNow();
1170
1564
  return adapter.create({
1171
1565
  model: TABLES.CUSTOMER,
1172
1566
  data: {
@@ -1238,7 +1632,7 @@ function createInternalAdapter(adapter, plans = [], features = []) {
1238
1632
  },
1239
1633
  // Subscription operations (DB)
1240
1634
  async createSubscription(data) {
1241
- const now = /* @__PURE__ */ new Date();
1635
+ const now = await getNow();
1242
1636
  const interval = data.interval ?? "monthly";
1243
1637
  const currentPeriodEnd = new Date(now);
1244
1638
  if (interval === "yearly") {
@@ -1308,14 +1702,15 @@ function createInternalAdapter(adapter, plans = [], features = []) {
1308
1702
  });
1309
1703
  },
1310
1704
  async updateSubscription(id, data) {
1705
+ const now = await getNow();
1311
1706
  return adapter.update({
1312
1707
  model: TABLES.SUBSCRIPTION,
1313
1708
  where: [{ field: "id", operator: "eq", value: id }],
1314
- update: { ...data, updatedAt: /* @__PURE__ */ new Date() }
1709
+ update: { ...data, updatedAt: now }
1315
1710
  });
1316
1711
  },
1317
1712
  async cancelSubscription(id, cancelAt) {
1318
- const now = /* @__PURE__ */ new Date();
1713
+ const now = await getNow();
1319
1714
  return adapter.update({
1320
1715
  model: TABLES.SUBSCRIPTION,
1321
1716
  where: [{ field: "id", operator: "eq", value: id }],
@@ -1358,7 +1753,7 @@ function createInternalAdapter(adapter, plans = [], features = []) {
1358
1753
  },
1359
1754
  // Payment operations (DB)
1360
1755
  async createPayment(data) {
1361
- const now = /* @__PURE__ */ new Date();
1756
+ const now = await getNow();
1362
1757
  return adapter.create({
1363
1758
  model: TABLES.PAYMENT,
1364
1759
  data: {
@@ -1467,10 +1862,12 @@ async function createBillingContext(adapter, options) {
1467
1862
  schema = { ...schema, ...plugin.schema };
1468
1863
  }
1469
1864
  }
1865
+ const getNow = async () => /* @__PURE__ */ new Date();
1470
1866
  const internalAdapter = createInternalAdapter(
1471
1867
  adapter,
1472
1868
  options.plans ?? [],
1473
- options.features ?? []
1869
+ options.features ?? [],
1870
+ getNow
1474
1871
  );
1475
1872
  const context = {
1476
1873
  options: resolvedOptions,
@@ -1482,6 +1879,7 @@ async function createBillingContext(adapter, options) {
1482
1879
  plugins,
1483
1880
  logger,
1484
1881
  secret: resolvedOptions.secret,
1882
+ timeProvider: createDefaultTimeProvider(),
1485
1883
  hasPlugin(id) {
1486
1884
  return plugins.some((p) => p.id === id);
1487
1885
  },
@@ -1621,6 +2019,10 @@ function createAPI(contextPromise) {
1621
2019
  amount: params.amount,
1622
2020
  reason: params.reason
1623
2021
  });
2022
+ },
2023
+ async processRenewals(params) {
2024
+ const ctx = await contextPromise;
2025
+ return processRenewals(ctx, params);
1624
2026
  }
1625
2027
  };
1626
2028
  }