@alexasomba/better-auth-paystack 1.1.0 → 1.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.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { defineErrorCodes } from "@better-auth/core/utils";
1
+ import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
2
2
  import { defu } from "defu";
3
3
  import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
4
4
  import { HIDE_METADATA, logger } from "better-auth";
@@ -73,6 +73,17 @@ function getPaystackOps(paystackClient) {
73
73
  if (paystackClient?.subscription_manageEmail !== void 0) return paystackClient.subscription_manageEmail({ params: { path: { code } } });
74
74
  return paystackClient?.subscription?.manage?.email?.(code, email);
75
75
  },
76
+ subscriptionUpdate: (params) => {
77
+ if (paystackClient?.subscription_update !== void 0) return paystackClient.subscription_update({
78
+ params: { path: { code: params.code } },
79
+ body: {
80
+ plan: params.plan,
81
+ authorization: params.authorization,
82
+ amount: params.amount
83
+ }
84
+ });
85
+ return paystackClient?.subscription?.update?.(params.code, params);
86
+ },
76
87
  transactionChargeAuthorization: (body) => {
77
88
  if (paystackClient?.transaction_chargeAuthorization !== void 0) return paystackClient.transaction_chargeAuthorization({ body });
78
89
  return paystackClient?.transaction?.chargeAuthorization?.(body);
@@ -99,6 +110,10 @@ function getPaystackOps(paystackClient) {
99
110
  productDelete: (idOrCode) => {
100
111
  if (paystackClient?.product_delete !== void 0) return paystackClient.product_delete({ params: { path: { id_or_code: idOrCode } } });
101
112
  return paystackClient?.product?.delete?.(idOrCode);
113
+ },
114
+ planList: () => {
115
+ if (paystackClient?.plan_list !== void 0) return paystackClient.plan_list();
116
+ return paystackClient?.plan?.list?.();
102
117
  }
103
118
  };
104
119
  }
@@ -175,7 +190,7 @@ async function syncProductQuantityFromPaystack(ctx, productName, paystackClient)
175
190
  value: productName.toLowerCase().replace(/\s+/g, "-")
176
191
  }]
177
192
  });
178
- if (!localProduct?.paystackId) {
193
+ if (localProduct?.paystackId === void 0 || localProduct?.paystackId === null || localProduct?.paystackId === "") {
179
194
  if (localProduct && localProduct.unlimited !== true && localProduct.quantity !== void 0 && localProduct.quantity > 0) await ctx.context.adapter.update({
180
195
  model: "paystackProduct",
181
196
  update: {
@@ -216,6 +231,51 @@ async function syncProductQuantityFromPaystack(ctx, productName, paystackClient)
216
231
  });
217
232
  }
218
233
  }
234
+ async function syncSubscriptionSeats(ctx, organizationId, options) {
235
+ if (options.subscription?.enabled !== true) return;
236
+ const adapter = ctx.context?.adapter ?? ctx.adapter;
237
+ const subscription = await adapter.findOne({
238
+ model: "subscription",
239
+ where: [{
240
+ field: "referenceId",
241
+ value: organizationId
242
+ }]
243
+ });
244
+ if (subscription === null || subscription.paystackSubscriptionCode === void 0 || subscription.paystackSubscriptionCode === null) return;
245
+ const plan = await getPlanByName(options, subscription.plan);
246
+ if (plan === null) return;
247
+ if (plan.seatAmount === void 0 && plan.seatPlanCode === void 0) return;
248
+ const quantity = (await adapter.findMany({
249
+ model: "member",
250
+ where: [{
251
+ field: "organizationId",
252
+ value: organizationId
253
+ }]
254
+ })).length;
255
+ let totalAmount = plan.amount ?? 0;
256
+ if (plan.seatAmount !== void 0 && plan.seatAmount !== null) totalAmount += quantity * plan.seatAmount;
257
+ const ops = getPaystackOps(options.paystackClient);
258
+ try {
259
+ await ops.subscriptionUpdate({
260
+ code: subscription.paystackSubscriptionCode,
261
+ amount: totalAmount
262
+ });
263
+ await adapter.update({
264
+ model: "subscription",
265
+ where: [{
266
+ field: "id",
267
+ value: subscription.id
268
+ }],
269
+ update: {
270
+ seats: quantity,
271
+ updatedAt: /* @__PURE__ */ new Date()
272
+ }
273
+ });
274
+ } catch (e) {
275
+ const log = ctx.context?.logger ?? ctx.logger;
276
+ if (log !== void 0 && log !== null) log.error("Failed to sync subscription seats with Paystack", e);
277
+ }
278
+ }
219
279
 
220
280
  //#endregion
221
281
  //#region src/middleware.ts
@@ -256,6 +316,41 @@ const referenceMiddleware = (options, action) => createAuthMiddleware(async (ctx
256
316
  throw new APIError("BAD_REQUEST", { message: "Passing referenceId isn't allowed without subscription.authorizeReference or valid organization membership." });
257
317
  });
258
318
 
319
+ //#endregion
320
+ //#region src/limits.ts
321
+ const getOrganizationSubscription = async (ctx, organizationId) => {
322
+ return await ctx.context.adapter.findOne({
323
+ model: "subscription",
324
+ where: [{
325
+ field: "referenceId",
326
+ value: organizationId
327
+ }]
328
+ });
329
+ };
330
+ const checkSeatLimit = async (ctx, organizationId, seatsToAdd = 1) => {
331
+ const subscription = await getOrganizationSubscription(ctx, organizationId);
332
+ if (subscription?.seats === void 0 || subscription.seats === null) return true;
333
+ const members = await ctx.context.adapter.findMany({
334
+ model: "member",
335
+ where: [{
336
+ field: "organizationId",
337
+ value: organizationId
338
+ }]
339
+ });
340
+ if (members.length + seatsToAdd > subscription.seats) throw new APIError("FORBIDDEN", { message: `Organization member limit reached. Used: ${members.length}, Max: ${subscription.seats}` });
341
+ return true;
342
+ };
343
+ const checkTeamLimit = async (ctx, organizationId, maxTeams) => {
344
+ if ((await ctx.context.adapter.findMany({
345
+ model: "team",
346
+ where: [{
347
+ field: "organizationId",
348
+ value: organizationId
349
+ }]
350
+ })).length >= maxTeams) throw new APIError("FORBIDDEN", { message: `Organization team limit reached. Max teams: ${maxTeams}` });
351
+ return true;
352
+ };
353
+
259
354
  //#endregion
260
355
  //#region src/routes.ts
261
356
  const PAYSTACK_ERROR_CODES = defineErrorCodes({
@@ -364,10 +459,11 @@ const paystackWebhook = (options) => {
364
459
  }
365
460
  if (options.subscription?.enabled === true) try {
366
461
  if (eventName === "subscription.create") {
367
- const subscriptionCode = data?.subscription_code ?? data?.subscription?.subscription_code ?? data?.code;
368
- const customerCode = data?.customer?.customer_code ?? data?.customer_code ?? data?.customer?.code;
369
- const planCode = data?.plan?.plan_code ?? data?.plan_code ?? data?.plan;
370
- let metadata = data?.metadata;
462
+ const payloadData = data;
463
+ const subscriptionCode = payloadData?.subscription_code ?? payloadData?.subscription?.subscription_code ?? payloadData?.code;
464
+ const customerCode = payloadData?.customer?.customer_code ?? payloadData?.customer_code ?? payloadData?.customer?.code;
465
+ const planCode = payloadData?.plan?.plan_code ?? payloadData?.plan_code ?? payloadData?.plan;
466
+ let metadata = payloadData?.metadata;
371
467
  if (typeof metadata === "string") try {
372
468
  metadata = JSON.parse(metadata);
373
469
  } catch {}
@@ -405,7 +501,7 @@ const paystackWebhook = (options) => {
405
501
  paystackSubscriptionCode: subscriptionCode,
406
502
  status: "active",
407
503
  updatedAt: /* @__PURE__ */ new Date(),
408
- periodEnd: data?.next_payment_date !== void 0 && data?.next_payment_date !== null && data?.next_payment_date !== "" ? new Date(data.next_payment_date) : void 0
504
+ periodEnd: payloadData?.next_payment_date !== void 0 && payloadData?.next_payment_date !== null && payloadData?.next_payment_date !== "" ? new Date(payloadData.next_payment_date) : void 0
409
505
  },
410
506
  where: [{
411
507
  field: "id",
@@ -438,7 +534,8 @@ const paystackWebhook = (options) => {
438
534
  }
439
535
  }
440
536
  if (eventName === "subscription.disable" || eventName === "subscription.not_renew") {
441
- const subscriptionCode = data?.subscription_code ?? data?.subscription?.subscription_code ?? data?.code;
537
+ const payloadData = data;
538
+ const subscriptionCode = payloadData?.subscription_code ?? payloadData?.subscription?.subscription_code ?? payloadData?.code;
442
539
  if (subscriptionCode !== void 0 && subscriptionCode !== null && subscriptionCode !== "") {
443
540
  const existing = await ctx.context.adapter.findOne({
444
541
  model: "subscription",
@@ -448,11 +545,15 @@ const paystackWebhook = (options) => {
448
545
  }]
449
546
  });
450
547
  let newStatus = "canceled";
451
- if (existing?.cancelAtPeriodEnd === true && existing.periodEnd !== void 0 && existing.periodEnd !== null && new Date(existing.periodEnd) > /* @__PURE__ */ new Date()) newStatus = "active";
548
+ const nextPaymentDate = data?.next_payment_date;
549
+ const periodEnd = nextPaymentDate ? new Date(nextPaymentDate) : existing?.periodEnd ? new Date(existing.periodEnd) : void 0;
550
+ if (periodEnd && periodEnd > /* @__PURE__ */ new Date()) newStatus = "active";
452
551
  await ctx.context.adapter.update({
453
552
  model: "subscription",
454
553
  update: {
455
554
  status: newStatus,
555
+ cancelAtPeriodEnd: true,
556
+ ...periodEnd ? { periodEnd } : {},
456
557
  updatedAt: /* @__PURE__ */ new Date()
457
558
  },
458
559
  where: [{
@@ -469,6 +570,31 @@ const paystackWebhook = (options) => {
469
570
  }, ctx);
470
571
  }
471
572
  }
573
+ if (eventName === "charge.success" || eventName === "invoice.update") {
574
+ const payloadData = data;
575
+ const subscriptionCode = payloadData?.subscription?.subscription_code ?? payloadData?.subscription_code;
576
+ if (subscriptionCode) {
577
+ const existingSub = await ctx.context.adapter.findOne({
578
+ model: "subscription",
579
+ where: [{
580
+ field: "paystackSubscriptionCode",
581
+ value: subscriptionCode
582
+ }]
583
+ });
584
+ if (existingSub?.pendingPlan) await ctx.context.adapter.update({
585
+ model: "subscription",
586
+ update: {
587
+ plan: existingSub.pendingPlan,
588
+ pendingPlan: null,
589
+ updatedAt: /* @__PURE__ */ new Date()
590
+ },
591
+ where: [{
592
+ field: "id",
593
+ value: existingSub.id
594
+ }]
595
+ });
596
+ }
597
+ }
472
598
  } catch (_e) {
473
599
  ctx.context.logger.error("Failed to sync Paystack webhook event", _e);
474
600
  }
@@ -485,7 +611,10 @@ const initializeTransactionBodySchema = z.object({
485
611
  metadata: z.record(z.string(), z.unknown()).optional(),
486
612
  referenceId: z.string().optional(),
487
613
  callbackURL: z.string().optional(),
488
- quantity: z.number().int().positive().optional()
614
+ quantity: z.number().int().positive().optional(),
615
+ scheduleAtPeriodEnd: z.boolean().optional(),
616
+ cancelAtPeriodEnd: z.boolean().optional(),
617
+ prorateAndCharge: z.boolean().optional()
489
618
  });
490
619
  const initializeTransaction = (options, path = "/paystack/initialize-transaction") => {
491
620
  const subscriptionOptions = options.subscription;
@@ -499,7 +628,7 @@ const initializeTransaction = (options, path = "/paystack/initialize-transaction
499
628
  ] : [sessionMiddleware, originCheck]
500
629
  }, async (ctx) => {
501
630
  const paystack = getPaystackOps(options.paystackClient);
502
- const { plan: planName, product: productName, amount: bodyAmount, currency, email, metadata: extraMetadata, callbackURL, quantity } = ctx.body;
631
+ const { plan: planName, product: productName, amount: bodyAmount, currency, email, metadata: extraMetadata, callbackURL, quantity, scheduleAtPeriodEnd, cancelAtPeriodEnd, prorateAndCharge } = ctx.body;
503
632
  if (callbackURL !== void 0 && callbackURL !== null && callbackURL !== "") {
504
633
  const checkTrusted = () => {
505
634
  try {
@@ -523,20 +652,46 @@ const initializeTransaction = (options, path = "/paystack/initialize-transaction
523
652
  const user = session.user;
524
653
  if (subscriptionOptions?.enabled === true && subscriptionOptions.requireEmailVerification === true && !user.emailVerified) throw new APIError("BAD_REQUEST", {
525
654
  code: "EMAIL_VERIFICATION_REQUIRED",
526
- message: PAYSTACK_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED
655
+ message: PAYSTACK_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED.message
527
656
  });
528
657
  let plan;
529
658
  let product;
530
659
  if (planName !== void 0 && planName !== null && planName !== "") {
531
660
  if (subscriptionOptions?.enabled !== true) throw new APIError("BAD_REQUEST", { message: "Subscriptions are not enabled." });
532
661
  plan = await getPlanByName(options, planName) ?? void 0;
662
+ if (!plan) {
663
+ const nativePlan = await ctx.context.adapter.findOne({
664
+ model: "paystackPlan",
665
+ where: [{
666
+ field: "name",
667
+ value: planName
668
+ }]
669
+ });
670
+ if (nativePlan) plan = nativePlan;
671
+ else plan = await ctx.context.adapter.findOne({
672
+ model: "paystackPlan",
673
+ where: [{
674
+ field: "planCode",
675
+ value: planName
676
+ }]
677
+ }) ?? void 0;
678
+ }
533
679
  if (!plan) throw new APIError("BAD_REQUEST", {
534
680
  code: "SUBSCRIPTION_PLAN_NOT_FOUND",
535
- message: PAYSTACK_ERROR_CODES.SUBSCRIPTION_PLAN_NOT_FOUND,
681
+ message: PAYSTACK_ERROR_CODES.SUBSCRIPTION_PLAN_NOT_FOUND.message,
536
682
  status: 400
537
683
  });
538
684
  } else if (productName !== void 0 && productName !== null && productName !== "") {
539
- if (typeof productName === "string") product = await getProductByName(options, productName) ?? void 0;
685
+ if (typeof productName === "string") {
686
+ product ??= await getProductByName(options, productName) ?? void 0;
687
+ product ??= await ctx.context.adapter.findOne({
688
+ model: "paystackProduct",
689
+ where: [{
690
+ field: "name",
691
+ value: productName
692
+ }]
693
+ }) ?? void 0;
694
+ }
540
695
  if (!product) throw new APIError("BAD_REQUEST", {
541
696
  message: `Product '${productName}' not found.`,
542
697
  status: 400
@@ -545,13 +700,67 @@ const initializeTransaction = (options, path = "/paystack/initialize-transaction
545
700
  message: "Either 'plan', 'product', or 'amount' is required to initialize a transaction.",
546
701
  status: 400
547
702
  });
548
- const amount = bodyAmount ?? product?.price;
703
+ let amount = bodyAmount ?? product?.price;
549
704
  const finalCurrency = currency ?? product?.currency ?? plan?.currency ?? "NGN";
705
+ const referenceIdFromCtx = ctx.context.referenceId;
706
+ const referenceId = ctx.body.referenceId !== void 0 && ctx.body.referenceId !== null && ctx.body.referenceId !== "" ? ctx.body.referenceId : referenceIdFromCtx !== void 0 && referenceIdFromCtx !== null && referenceIdFromCtx !== "" ? referenceIdFromCtx : session.user.id;
707
+ if (plan && scheduleAtPeriodEnd === true) {
708
+ const existingSub = await getOrganizationSubscription(ctx, referenceId);
709
+ if (existingSub?.status === "active") {
710
+ await ctx.context.adapter.update({
711
+ model: "subscription",
712
+ where: [{
713
+ field: "id",
714
+ value: existingSub.id
715
+ }],
716
+ update: {
717
+ pendingPlan: plan.name,
718
+ updatedAt: /* @__PURE__ */ new Date()
719
+ }
720
+ });
721
+ return ctx.json({
722
+ status: "success",
723
+ message: "Plan change scheduled at period end.",
724
+ scheduled: true
725
+ });
726
+ }
727
+ }
728
+ if (cancelAtPeriodEnd === true) {
729
+ const existingSub = await getOrganizationSubscription(ctx, referenceId);
730
+ if (existingSub?.status === "active") {
731
+ await ctx.context.adapter.update({
732
+ model: "subscription",
733
+ where: [{
734
+ field: "id",
735
+ value: existingSub.id
736
+ }],
737
+ update: {
738
+ cancelAtPeriodEnd: true,
739
+ updatedAt: /* @__PURE__ */ new Date()
740
+ }
741
+ });
742
+ return ctx.json({
743
+ status: "success",
744
+ message: "Subscription cancellation scheduled at period end.",
745
+ scheduled: true
746
+ });
747
+ }
748
+ }
749
+ if (plan && (plan.seatAmount !== void 0 || plan.seatPriceId !== void 0)) {
750
+ const members = await ctx.context.adapter.findMany({
751
+ model: "member",
752
+ where: [{
753
+ field: "organizationId",
754
+ value: referenceId
755
+ }]
756
+ });
757
+ const seatCount = members.length > 0 ? members.length : 1;
758
+ const quantityToUse = quantity ?? seatCount;
759
+ amount = (plan.amount ?? 0) + quantityToUse * (plan.seatAmount ?? plan.seatPriceId ?? 0);
760
+ }
550
761
  let url;
551
762
  let reference;
552
763
  let accessCode;
553
- const referenceIdFromCtx = ctx.context.referenceId;
554
- const referenceId = ctx.body.referenceId !== void 0 && ctx.body.referenceId !== null && ctx.body.referenceId !== "" ? ctx.body.referenceId : referenceIdFromCtx !== void 0 && referenceIdFromCtx !== null && referenceIdFromCtx !== "" ? referenceIdFromCtx : session.user.id;
555
764
  let trialStart;
556
765
  let trialEnd;
557
766
  if (plan?.freeTrial?.days !== void 0 && plan.freeTrial.days !== null && plan.freeTrial.days > 0) {
@@ -625,13 +834,95 @@ const initializeTransaction = (options, path = "/paystack/initialize-transaction
625
834
  const ops = getPaystackOps(options.paystackClient);
626
835
  if (initBody.email !== void 0 && initBody.email !== null && initBody.email !== "") await ops.customerUpdate(paystackCustomerCode, { email: initBody.email });
627
836
  } catch (_e) {}
837
+ if (plan && prorateAndCharge === true) {
838
+ const existingSub = await getOrganizationSubscription(ctx, referenceId);
839
+ if (existingSub?.status === "active" && existingSub.paystackAuthorizationCode !== null && existingSub.paystackAuthorizationCode !== void 0 && existingSub.paystackSubscriptionCode !== null && existingSub.paystackSubscriptionCode !== void 0) {
840
+ const now = /* @__PURE__ */ new Date();
841
+ const periodEndLocal = existingSub.periodEnd ? new Date(existingSub.periodEnd) : new Date(now.getTime() + 720 * 60 * 60 * 1e3);
842
+ const periodStartLocal = existingSub.periodStart ? new Date(existingSub.periodStart) : now;
843
+ const totalDays = Math.max(1, Math.ceil((periodEndLocal.getTime() - periodStartLocal.getTime()) / (1e3 * 60 * 60 * 24)));
844
+ const remainingDays = Math.max(0, Math.ceil((periodEndLocal.getTime() - now.getTime()) / (1e3 * 60 * 60 * 24)));
845
+ let oldAmount = 0;
846
+ if (existingSub.plan) {
847
+ const oldPlan = await getPlanByName(options, existingSub.plan) ?? await ctx.context.adapter.findOne({
848
+ model: "paystackPlan",
849
+ where: [{
850
+ field: "name",
851
+ value: existingSub.plan
852
+ }]
853
+ });
854
+ if (oldPlan) {
855
+ const oldSeatCount = existingSub.seats ?? 1;
856
+ oldAmount = (oldPlan.amount ?? 0) + oldSeatCount * (oldPlan.seatAmount ?? oldPlan.seatPriceId ?? 0);
857
+ }
858
+ }
859
+ let membersCount = 1;
860
+ if (plan.seatAmount !== void 0 || plan.seatPriceId !== void 0) {
861
+ const members = await ctx.context.adapter.findMany({
862
+ model: "member",
863
+ where: [{
864
+ field: "organizationId",
865
+ value: referenceId
866
+ }]
867
+ });
868
+ membersCount = members.length > 0 ? members.length : 1;
869
+ }
870
+ const newSeatCount = quantity ?? existingSub.seats ?? membersCount;
871
+ const newAmount = (plan.amount ?? 0) + newSeatCount * (plan.seatAmount ?? plan.seatPriceId ?? 0);
872
+ const costDifference = newAmount - oldAmount;
873
+ if (costDifference > 0 && remainingDays > 0) {
874
+ const proratedAmount = Math.round(costDifference / totalDays * remainingDays);
875
+ if (proratedAmount >= 5e3) {
876
+ const chargeData = unwrapSdkResult(await getPaystackOps(options.paystackClient).transactionChargeAuthorization({
877
+ email: targetEmail,
878
+ amount: proratedAmount,
879
+ authorization_code: existingSub.paystackAuthorizationCode,
880
+ reference: `prorate_${Date.now()}_${Math.random().toString(36).substring(7)}`,
881
+ metadata: {
882
+ type: "proration",
883
+ referenceId,
884
+ newPlan: plan.name,
885
+ oldPlan: existingSub.plan,
886
+ remainingDays
887
+ }
888
+ }));
889
+ if ((chargeData?.data?.status ?? chargeData?.status) !== "success") throw new APIError("BAD_REQUEST", { message: "Failed to process prorated charge via saved authorization." });
890
+ }
891
+ }
892
+ await getPaystackOps(options.paystackClient).subscriptionUpdate({
893
+ code: existingSub.paystackSubscriptionCode,
894
+ amount: newAmount,
895
+ plan: plan.planCode
896
+ });
897
+ await ctx.context.adapter.update({
898
+ model: "subscription",
899
+ where: [{
900
+ field: "id",
901
+ value: existingSub.id
902
+ }],
903
+ update: {
904
+ plan: plan.name,
905
+ seats: newSeatCount,
906
+ updatedAt: /* @__PURE__ */ new Date()
907
+ }
908
+ });
909
+ return ctx.json({
910
+ status: "success",
911
+ message: "Subscription successfully upgraded with prorated charge.",
912
+ prorated: true
913
+ });
914
+ }
915
+ }
628
916
  if (plan) if (trialStart) initBody.amount = 5e3;
629
917
  else {
630
918
  initBody.plan = plan.planCode;
631
919
  initBody.invoice_limit = plan.invoiceLimit;
632
- const planAmount = amount ?? plan.amount ?? 5e4;
633
- initBody.amount = Math.max(Math.round(planAmount), 5e4);
634
- if (quantity !== void 0 && quantity !== null && quantity > 0) initBody.amount = initBody.amount * quantity;
920
+ let finalAmount;
921
+ if (amount !== void 0 && amount !== null) {
922
+ finalAmount = amount;
923
+ initBody.quantity = 1;
924
+ } else finalAmount = (plan.amount ?? 5e4) * (quantity ?? 1);
925
+ initBody.amount = Math.max(Math.round(finalAmount), 5e4);
635
926
  }
636
927
  else {
637
928
  if (amount === void 0 || amount === null || amount === 0) throw new APIError("BAD_REQUEST", { message: "Amount is required for one-time payments" });
@@ -647,7 +938,7 @@ const initializeTransaction = (options, path = "/paystack/initialize-transaction
647
938
  ctx.context.logger.error("Failed to initialize Paystack transaction", error);
648
939
  throw new APIError("BAD_REQUEST", {
649
940
  code: "FAILED_TO_INITIALIZE_TRANSACTION",
650
- message: error?.message ?? PAYSTACK_ERROR_CODES.FAILED_TO_INITIALIZE_TRANSACTION
941
+ message: error?.message ?? PAYSTACK_ERROR_CODES.FAILED_TO_INITIALIZE_TRANSACTION.message
651
942
  });
652
943
  }
653
944
  await ctx.context.adapter.create({
@@ -729,7 +1020,7 @@ const verifyTransaction = (options, path = "/paystack/verify-transaction") => {
729
1020
  ctx.context.logger.error("Failed to verify Paystack transaction", error);
730
1021
  throw new APIError("BAD_REQUEST", {
731
1022
  code: "FAILED_TO_VERIFY_TRANSACTION",
732
- message: error?.message ?? PAYSTACK_ERROR_CODES.FAILED_TO_VERIFY_TRANSACTION
1023
+ message: error?.message ?? PAYSTACK_ERROR_CODES.FAILED_TO_VERIFY_TRANSACTION.message
733
1024
  });
734
1025
  }
735
1026
  const data = unwrapSdkResult(verifyRes);
@@ -846,7 +1137,17 @@ const verifyTransaction = (options, path = "/paystack/verify-transaction") => {
846
1137
  if (planCodeFromPaystack === void 0 || planCodeFromPaystack === null || planCodeFromPaystack === "") paystackSubscriptionCode = `LOC_${reference}`;
847
1138
  else paystackSubscriptionCode = (data?.subscription)?.subscription_code;
848
1139
  }
849
- const updatedSubscription = await ctx.context.adapter.update({
1140
+ const existingSubs = await ctx.context.adapter.findMany({
1141
+ model: "subscription",
1142
+ where: [{
1143
+ field: "paystackTransactionReference",
1144
+ value: reference
1145
+ }]
1146
+ });
1147
+ let targetSub;
1148
+ if (existingSubs && existingSubs.length > 0) targetSub = existingSubs.find((s) => !(referenceId !== void 0 && referenceId !== null && referenceId !== "") || s.referenceId === referenceId);
1149
+ let updatedSubscription = null;
1150
+ if (targetSub !== void 0 && targetSub !== null) updatedSubscription = await ctx.context.adapter.update({
850
1151
  model: "subscription",
851
1152
  update: {
852
1153
  status: isTrial === true ? "trialing" : "active",
@@ -861,12 +1162,9 @@ const verifyTransaction = (options, path = "/paystack/verify-transaction") => {
861
1162
  ...authorizationCode !== void 0 && authorizationCode !== null && authorizationCode !== "" ? { paystackAuthorizationCode: authorizationCode } : {}
862
1163
  },
863
1164
  where: [{
864
- field: "paystackTransactionReference",
865
- value: reference
866
- }, ...referenceId !== void 0 && referenceId !== null && referenceId !== "" ? [{
867
- field: "referenceId",
868
- value: referenceId
869
- }] : []]
1165
+ field: "id",
1166
+ value: targetSub.id
1167
+ }]
870
1168
  });
871
1169
  if (updatedSubscription && subscriptionOptions?.enabled === true && "onSubscriptionComplete" in subscriptionOptions && typeof subscriptionOptions.onSubscriptionComplete === "function") {
872
1170
  const plan = (await getPlans(subscriptionOptions)).find((p) => p.name.toLowerCase() === updatedSubscription.plan.toLowerCase());
@@ -955,7 +1253,8 @@ const listTransactions = (options, path = "/paystack/list-transactions") => {
955
1253
  const enableDisableBodySchema = z.object({
956
1254
  referenceId: z.string().optional(),
957
1255
  subscriptionCode: z.string(),
958
- emailToken: z.string().optional()
1256
+ emailToken: z.string().optional(),
1257
+ atPeriodEnd: z.boolean().optional()
959
1258
  });
960
1259
  function decodeBase64UrlToString(value) {
961
1260
  const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
@@ -986,7 +1285,7 @@ const disablePaystackSubscription = (options, path = "/paystack/disable-subscrip
986
1285
  referenceMiddleware(options, "disable-subscription")
987
1286
  ] : [sessionMiddleware, originCheck]
988
1287
  }, async (ctx) => {
989
- const { subscriptionCode } = ctx.body;
1288
+ const { subscriptionCode, atPeriodEnd } = ctx.body;
990
1289
  const paystack = getPaystackOps(options.paystackClient);
991
1290
  try {
992
1291
  if (subscriptionCode.startsWith("LOC_")) {
@@ -1001,8 +1300,8 @@ const disablePaystackSubscription = (options, path = "/paystack/disable-subscrip
1001
1300
  await ctx.context.adapter.update({
1002
1301
  model: "subscription",
1003
1302
  update: {
1004
- status: "active",
1005
- cancelAtPeriodEnd: true,
1303
+ status: atPeriodEnd === false ? "canceled" : "active",
1304
+ cancelAtPeriodEnd: atPeriodEnd !== false,
1006
1305
  updatedAt: /* @__PURE__ */ new Date()
1007
1306
  },
1008
1307
  where: [{
@@ -1044,8 +1343,8 @@ const disablePaystackSubscription = (options, path = "/paystack/disable-subscrip
1044
1343
  if (sub) await ctx.context.adapter.update({
1045
1344
  model: "subscription",
1046
1345
  update: {
1047
- status: "active",
1048
- cancelAtPeriodEnd: true,
1346
+ status: atPeriodEnd === false ? "canceled" : "active",
1347
+ cancelAtPeriodEnd: atPeriodEnd !== false,
1049
1348
  periodEnd,
1050
1349
  updatedAt: /* @__PURE__ */ new Date()
1051
1350
  },
@@ -1060,7 +1359,7 @@ const disablePaystackSubscription = (options, path = "/paystack/disable-subscrip
1060
1359
  ctx.context.logger.error("Failed to disable subscription", error);
1061
1360
  throw new APIError("BAD_REQUEST", {
1062
1361
  code: "FAILED_TO_DISABLE_SUBSCRIPTION",
1063
- message: error?.message ?? PAYSTACK_ERROR_CODES.FAILED_TO_DISABLE_SUBSCRIPTION
1362
+ message: error?.message ?? PAYSTACK_ERROR_CODES.FAILED_TO_DISABLE_SUBSCRIPTION.message
1064
1363
  });
1065
1364
  }
1066
1365
  });
@@ -1110,33 +1409,40 @@ const enablePaystackSubscription = (options, path = "/paystack/enable-subscripti
1110
1409
  ctx.context.logger.error("Failed to enable subscription", error);
1111
1410
  throw new APIError("BAD_REQUEST", {
1112
1411
  code: "FAILED_TO_ENABLE_SUBSCRIPTION",
1113
- message: error?.message ?? PAYSTACK_ERROR_CODES.FAILED_TO_ENABLE_SUBSCRIPTION
1412
+ message: error?.message ?? PAYSTACK_ERROR_CODES.FAILED_TO_ENABLE_SUBSCRIPTION.message
1114
1413
  });
1115
1414
  }
1116
1415
  });
1117
1416
  };
1118
- const getSubscriptionManageLink = (options) => {
1119
- return createAuthEndpoint("/paystack/get-subscription-manage-link", {
1120
- method: "GET",
1121
- query: z.object({ subscriptionCode: z.string() }),
1122
- use: options.subscription?.enabled === true ? [
1123
- sessionMiddleware,
1124
- originCheck,
1125
- referenceMiddleware(options, "get-subscription-manage-link")
1126
- ] : [sessionMiddleware, originCheck]
1127
- }, async (ctx) => {
1417
+ const getSubscriptionManageLink = (options, path = "/paystack/get-subscription-manage-link") => {
1418
+ const manageLinkQuerySchema = z.object({ subscriptionCode: z.string() });
1419
+ const useMiddlewares = options.subscription?.enabled === true ? [
1420
+ sessionMiddleware,
1421
+ originCheck,
1422
+ referenceMiddleware(options, "get-subscription-manage-link")
1423
+ ] : [sessionMiddleware, originCheck];
1424
+ const handler = async (ctx) => {
1128
1425
  const { subscriptionCode } = ctx.query;
1426
+ if (subscriptionCode.startsWith("LOC_") || subscriptionCode.startsWith("sub_local_")) return ctx.json({
1427
+ link: null,
1428
+ message: "Local subscriptions cannot be managed on Paystack"
1429
+ });
1129
1430
  const paystack = getPaystackOps(options.paystackClient);
1130
1431
  try {
1131
1432
  const res = unwrapSdkResult(await paystack.subscriptionManageLink(subscriptionCode));
1132
- const data = res !== null && res !== void 0 && "status" in res && "data" in res ? res.data : res?.data !== void 0 ? res.data : res;
1433
+ const data = res !== null && res !== void 0 && typeof res === "object" && "status" in res && "data" in res ? res.data : res?.data !== void 0 ? res.data : res;
1133
1434
  const link = typeof data === "string" ? data : data?.link;
1134
1435
  return ctx.json({ link });
1135
1436
  } catch (error) {
1136
1437
  ctx.context.logger.error("Failed to get subscription manage link", error);
1137
1438
  throw new APIError("BAD_REQUEST", { message: error?.message ?? "Failed to get subscription manage link" });
1138
1439
  }
1139
- });
1440
+ };
1441
+ return createAuthEndpoint(path, {
1442
+ method: "GET",
1443
+ query: manageLinkQuerySchema,
1444
+ use: useMiddlewares
1445
+ }, handler);
1140
1446
  };
1141
1447
  const syncProducts = (options) => {
1142
1448
  return createAuthEndpoint("/paystack/sync-products", {
@@ -1201,6 +1507,91 @@ const syncProducts = (options) => {
1201
1507
  }
1202
1508
  });
1203
1509
  };
1510
+ const listProducts = (_options) => {
1511
+ return createAuthEndpoint("/paystack/list-products", {
1512
+ method: "GET",
1513
+ metadata: { openapi: { operationId: "listPaystackProducts" } }
1514
+ }, async (ctx) => {
1515
+ const sorted = (await ctx.context.adapter.findMany({ model: "paystackProduct" })).sort((a, b) => a.name.localeCompare(b.name));
1516
+ return ctx.json({ products: sorted });
1517
+ });
1518
+ };
1519
+ const syncPlans = (options) => {
1520
+ return createAuthEndpoint("/paystack/sync-plans", {
1521
+ method: "POST",
1522
+ metadata: { ...HIDE_METADATA },
1523
+ disableBody: true,
1524
+ use: [sessionMiddleware]
1525
+ }, async (ctx) => {
1526
+ const paystack = getPaystackOps(options.paystackClient);
1527
+ try {
1528
+ const res = unwrapSdkResult(await paystack.planList());
1529
+ const plansData = res !== null && typeof res === "object" && "status" in res && "data" in res ? res.data : res?.data ?? res;
1530
+ if (!Array.isArray(plansData)) return ctx.json({
1531
+ status: "success",
1532
+ count: 0
1533
+ });
1534
+ for (const plan of plansData) {
1535
+ const paystackId = String(plan.id);
1536
+ const existing = await ctx.context.adapter.findOne({
1537
+ model: "paystackPlan",
1538
+ where: [{
1539
+ field: "paystackId",
1540
+ value: paystackId
1541
+ }]
1542
+ });
1543
+ const planData = {
1544
+ name: plan.name,
1545
+ description: plan.description,
1546
+ amount: plan.amount,
1547
+ currency: plan.currency,
1548
+ interval: plan.interval,
1549
+ planCode: plan.plan_code,
1550
+ paystackId,
1551
+ metadata: plan.metadata ? JSON.stringify(plan.metadata) : void 0,
1552
+ updatedAt: /* @__PURE__ */ new Date()
1553
+ };
1554
+ if (existing) await ctx.context.adapter.update({
1555
+ model: "paystackPlan",
1556
+ update: planData,
1557
+ where: [{
1558
+ field: "id",
1559
+ value: existing.id
1560
+ }]
1561
+ });
1562
+ else await ctx.context.adapter.create({
1563
+ model: "paystackPlan",
1564
+ data: {
1565
+ ...planData,
1566
+ createdAt: /* @__PURE__ */ new Date()
1567
+ }
1568
+ });
1569
+ }
1570
+ return ctx.json({
1571
+ status: "success",
1572
+ count: plansData.length
1573
+ });
1574
+ } catch (error) {
1575
+ ctx.context.logger.error("Failed to sync plans", error);
1576
+ throw new APIError("BAD_REQUEST", { message: error?.message ?? "Failed to sync plans" });
1577
+ }
1578
+ });
1579
+ };
1580
+ const listPlans = (_options) => {
1581
+ return createAuthEndpoint("/paystack/list-plans", {
1582
+ method: "GET",
1583
+ metadata: { ...HIDE_METADATA },
1584
+ use: [sessionMiddleware]
1585
+ }, async (ctx) => {
1586
+ try {
1587
+ const plans = await ctx.context.adapter.findMany({ model: "paystackPlan" });
1588
+ return ctx.json({ plans });
1589
+ } catch (error) {
1590
+ ctx.context.logger.error("Failed to list plans", error);
1591
+ throw new APIError("BAD_REQUEST", { message: error?.message ?? "Failed to list plans" });
1592
+ }
1593
+ });
1594
+ };
1204
1595
  const getConfig = (options) => {
1205
1596
  return createAuthEndpoint("/paystack/get-config", {
1206
1597
  method: "GET",
@@ -1433,6 +1824,10 @@ const subscriptions = { subscription: { fields: {
1433
1824
  seats: {
1434
1825
  type: "number",
1435
1826
  required: false
1827
+ },
1828
+ pendingPlan: {
1829
+ type: "string",
1830
+ required: false
1436
1831
  }
1437
1832
  } } };
1438
1833
  const user = { user: { fields: { paystackCustomerCode: {
@@ -1501,18 +1896,64 @@ const products = { paystackProduct: { fields: {
1501
1896
  required: true
1502
1897
  }
1503
1898
  } } };
1899
+ const plans = { paystackPlan: { fields: {
1900
+ name: {
1901
+ type: "string",
1902
+ required: true
1903
+ },
1904
+ description: {
1905
+ type: "string",
1906
+ required: false
1907
+ },
1908
+ amount: {
1909
+ type: "number",
1910
+ required: true
1911
+ },
1912
+ currency: {
1913
+ type: "string",
1914
+ required: true
1915
+ },
1916
+ interval: {
1917
+ type: "string",
1918
+ required: true
1919
+ },
1920
+ planCode: {
1921
+ type: "string",
1922
+ required: true,
1923
+ unique: true
1924
+ },
1925
+ paystackId: {
1926
+ type: "string",
1927
+ required: true,
1928
+ unique: true
1929
+ },
1930
+ metadata: {
1931
+ type: "string",
1932
+ required: false
1933
+ },
1934
+ createdAt: {
1935
+ type: "date",
1936
+ required: true
1937
+ },
1938
+ updatedAt: {
1939
+ type: "date",
1940
+ required: true
1941
+ }
1942
+ } } };
1504
1943
  const getSchema = (options) => {
1505
1944
  let baseSchema;
1506
1945
  if (options.subscription?.enabled === true) baseSchema = {
1507
1946
  ...subscriptions,
1508
1947
  ...transactions,
1509
1948
  ...user,
1510
- ...products
1949
+ ...products,
1950
+ ...plans
1511
1951
  };
1512
1952
  else baseSchema = {
1513
1953
  ...user,
1514
1954
  ...transactions,
1515
- ...products
1955
+ ...products,
1956
+ ...plans
1516
1957
  };
1517
1958
  if (options.organization?.enabled === true) baseSchema = {
1518
1959
  ...baseSchema,
@@ -1525,63 +1966,33 @@ const getSchema = (options) => {
1525
1966
  return mergeSchema(baseSchema, options.schema);
1526
1967
  };
1527
1968
 
1528
- //#endregion
1529
- //#region src/limits.ts
1530
- const getOrganizationSubscription = async (ctx, organizationId) => {
1531
- return await ctx.context.adapter.findOne({
1532
- model: "subscription",
1533
- where: [{
1534
- field: "referenceId",
1535
- value: organizationId
1536
- }]
1537
- });
1538
- };
1539
- const checkSeatLimit = async (ctx, organizationId, seatsToAdd = 1) => {
1540
- const subscription = await getOrganizationSubscription(ctx, organizationId);
1541
- if (subscription?.seats === void 0 || subscription.seats === null) return true;
1542
- const members = await ctx.context.adapter.findMany({
1543
- model: "member",
1544
- where: [{
1545
- field: "organizationId",
1546
- value: organizationId
1547
- }]
1548
- });
1549
- if (members.length + seatsToAdd > subscription.seats) throw new APIError("FORBIDDEN", { message: `Organization member limit reached. Used: ${members.length}, Max: ${subscription.seats}` });
1550
- return true;
1551
- };
1552
- const checkTeamLimit = async (ctx, organizationId, maxTeams) => {
1553
- if ((await ctx.context.adapter.findMany({
1554
- model: "team",
1555
- where: [{
1556
- field: "organizationId",
1557
- value: organizationId
1558
- }]
1559
- })).length >= maxTeams) throw new APIError("FORBIDDEN", { message: `Organization team limit reached. Max teams: ${maxTeams}` });
1560
- return true;
1561
- };
1562
-
1563
1969
  //#endregion
1564
1970
  //#region src/index.ts
1565
- const INTERNAL_ERROR_CODES = defineErrorCodes({ ...PAYSTACK_ERROR_CODES });
1971
+ const INTERNAL_ERROR_CODES = defineErrorCodes({ ...Object.fromEntries(Object.entries(PAYSTACK_ERROR_CODES).map(([key, value]) => [key, typeof value === "string" ? value : value.message])) });
1566
1972
  const paystack = (options) => {
1973
+ const routeOptions = options;
1567
1974
  return {
1568
1975
  id: "paystack",
1569
1976
  endpoints: {
1570
- initializeTransaction: initializeTransaction(options),
1571
- verifyTransaction: verifyTransaction(options),
1572
- listSubscriptions: listSubscriptions(options),
1573
- paystackWebhook: paystackWebhook(options),
1574
- listTransactions: listTransactions(options),
1575
- getConfig: getConfig(options),
1576
- disableSubscription: disablePaystackSubscription(options),
1577
- enableSubscription: enablePaystackSubscription(options),
1578
- getSubscriptionManageLink: getSubscriptionManageLink(options),
1579
- createSubscription: createSubscription(options),
1580
- upgradeSubscription: upgradeSubscription(options),
1581
- cancelSubscription: cancelSubscription(options),
1582
- restoreSubscription: restoreSubscription(options),
1583
- chargeRecurringSubscription: chargeRecurringSubscription(options),
1584
- syncProducts: syncProducts(options)
1977
+ initializeTransaction: initializeTransaction(routeOptions),
1978
+ verifyTransaction: verifyTransaction(routeOptions),
1979
+ listSubscriptions: listSubscriptions(routeOptions),
1980
+ paystackWebhook: paystackWebhook(routeOptions),
1981
+ listTransactions: listTransactions(routeOptions),
1982
+ getConfig: getConfig(routeOptions),
1983
+ disableSubscription: disablePaystackSubscription(routeOptions),
1984
+ enableSubscription: enablePaystackSubscription(routeOptions),
1985
+ getSubscriptionManageLink: getSubscriptionManageLink(routeOptions),
1986
+ subscriptionManageLink: getSubscriptionManageLink(routeOptions, "/paystack/subscription/manage-link"),
1987
+ createSubscription: createSubscription(routeOptions),
1988
+ upgradeSubscription: upgradeSubscription(routeOptions),
1989
+ cancelSubscription: cancelSubscription(routeOptions),
1990
+ restoreSubscription: restoreSubscription(routeOptions),
1991
+ chargeRecurringSubscription: chargeRecurringSubscription(routeOptions),
1992
+ syncProducts: syncProducts(routeOptions),
1993
+ listProducts: listProducts(routeOptions),
1994
+ syncPlans: syncPlans(routeOptions),
1995
+ listPlans: listPlans(routeOptions)
1585
1996
  },
1586
1997
  schema: getSchema(options),
1587
1998
  init: (ctx) => {
@@ -1650,12 +2061,32 @@ const paystack = (options) => {
1650
2061
  }
1651
2062
  } } } : void 0
1652
2063
  },
1653
- member: { create: { before: async (member, ctx) => {
1654
- if (options.subscription?.enabled === true && member.organizationId && ctx !== null && ctx !== void 0) await checkSeatLimit(ctx, member.organizationId);
1655
- } } },
1656
- invitation: { create: { before: async (invitation, ctx) => {
1657
- if (options.subscription?.enabled === true && invitation.organizationId && ctx !== null && ctx !== void 0) await checkSeatLimit(ctx, invitation.organizationId);
1658
- } } },
2064
+ member: {
2065
+ create: {
2066
+ before: async (member, ctx) => {
2067
+ if (options.subscription?.enabled === true && member.organizationId && ctx !== null && ctx !== void 0) await checkSeatLimit(ctx, member.organizationId);
2068
+ },
2069
+ after: async (member, ctx) => {
2070
+ if (options.subscription?.enabled === true && member?.organizationId !== void 0 && member?.organizationId !== null && ctx !== void 0 && ctx !== null) await syncSubscriptionSeats(ctx, member.organizationId, options);
2071
+ }
2072
+ },
2073
+ delete: { after: async (member, ctx) => {
2074
+ if (options.subscription?.enabled === true && member?.organizationId !== void 0 && member?.organizationId !== null && ctx !== void 0 && ctx !== null) await syncSubscriptionSeats(ctx, member.organizationId, options);
2075
+ } }
2076
+ },
2077
+ invitation: {
2078
+ create: {
2079
+ before: async (invitation, ctx) => {
2080
+ if (options.subscription?.enabled === true && invitation.organizationId && ctx !== null && ctx !== void 0) await checkSeatLimit(ctx, invitation.organizationId);
2081
+ },
2082
+ after: async (invitation, ctx) => {
2083
+ if (options.subscription?.enabled === true && invitation?.organizationId !== void 0 && invitation?.organizationId !== null && ctx !== void 0 && ctx !== null) await syncSubscriptionSeats(ctx, invitation.organizationId, options);
2084
+ }
2085
+ },
2086
+ delete: { after: async (invitation, ctx) => {
2087
+ if (options.subscription?.enabled === true && invitation?.organizationId !== void 0 && invitation?.organizationId !== null && ctx !== void 0 && ctx !== null) await syncSubscriptionSeats(ctx, invitation.organizationId, options);
2088
+ } }
2089
+ },
1659
2090
  team: { create: { before: async (team, ctx) => {
1660
2091
  if (options.subscription?.enabled === true && team.organizationId && ctx !== null && ctx !== void 0) {
1661
2092
  const subscription = await getOrganizationSubscription(ctx, team.organizationId);