@better-auth/stripe 1.4.10-beta.1 → 1.5.0-beta.2

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.
@@ -769,11 +769,24 @@ describe("stripe", () => {
769
769
  }
770
770
  });
771
771
 
772
- it("should execute subscription event handlers", async () => {
772
+ it("should handle customer.subscription.created webhook event", async () => {
773
+ const stripeForTest = {
774
+ ...stripeOptions.stripeClient,
775
+ webhooks: {
776
+ constructEventAsync: vi.fn(),
777
+ },
778
+ };
779
+
780
+ const testOptions = {
781
+ ...stripeOptions,
782
+ stripeClient: stripeForTest as unknown as Stripe,
783
+ stripeWebhookSecret: "test_secret",
784
+ };
785
+
773
786
  const { auth: testAuth } = await getTestInstance(
774
787
  {
775
788
  database: memory,
776
- plugins: [stripe(stripeOptions)],
789
+ plugins: [stripe(testOptions)],
777
790
  },
778
791
  {
779
792
  disableTestUser: true,
@@ -781,293 +794,277 @@ describe("stripe", () => {
781
794
  );
782
795
  const testCtx = await testAuth.$context;
783
796
 
784
- const { id: userId } = await testCtx.adapter.create({
797
+ // Create a user with stripeCustomerId
798
+ const userWithCustomerId = await testCtx.adapter.create({
785
799
  model: "user",
786
800
  data: {
787
- email: "event-handler-test@email.com",
801
+ email: "dashboard-user@test.com",
802
+ name: "Dashboard User",
803
+ emailVerified: true,
804
+ stripeCustomerId: "cus_dashboard_test",
788
805
  },
789
806
  });
790
807
 
791
- const onSubscriptionComplete = vi.fn();
792
- const onSubscriptionUpdate = vi.fn();
793
- const onSubscriptionCancel = vi.fn();
794
- const onSubscriptionDeleted = vi.fn();
795
-
796
- const testOptions = {
797
- ...stripeOptions,
798
- subscription: {
799
- ...stripeOptions.subscription,
800
- onSubscriptionComplete,
801
- onSubscriptionUpdate,
802
- onSubscriptionCancel,
803
- onSubscriptionDeleted,
804
- },
805
- stripeWebhookSecret: "test_secret",
806
- } as unknown as StripeOptions;
807
-
808
- // Test subscription complete handler
809
- const completeEvent = {
810
- type: "checkout.session.completed",
808
+ const mockEvent = {
809
+ type: "customer.subscription.created",
811
810
  data: {
812
811
  object: {
813
- mode: "subscription",
814
- subscription: "sub_123",
815
- metadata: {
816
- referenceId: "user_123",
817
- subscriptionId: "sub_123",
812
+ id: "sub_dashboard_created",
813
+ customer: "cus_dashboard_test",
814
+ status: "active",
815
+ items: {
816
+ data: [
817
+ {
818
+ price: { id: process.env.STRIPE_PRICE_ID_1 },
819
+ quantity: 1,
820
+ current_period_start: Math.floor(Date.now() / 1000),
821
+ current_period_end:
822
+ Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
823
+ },
824
+ ],
818
825
  },
826
+ cancel_at_period_end: false,
819
827
  },
820
828
  },
821
829
  };
822
830
 
823
- const mockSubscription = {
824
- status: "active",
825
- items: {
826
- data: [{ price: { id: process.env.STRIPE_PRICE_ID_1 } }],
827
- },
828
- current_period_start: Math.floor(Date.now() / 1000),
829
- current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
830
- };
831
+ (stripeForTest.webhooks.constructEventAsync as any).mockResolvedValue(
832
+ mockEvent,
833
+ );
831
834
 
832
- const mockStripeForEvents = {
833
- ...testOptions.stripeClient,
834
- subscriptions: {
835
- retrieve: vi.fn().mockResolvedValue(mockSubscription),
835
+ const mockRequest = new Request(
836
+ "http://localhost:3000/api/auth/stripe/webhook",
837
+ {
838
+ method: "POST",
839
+ headers: {
840
+ "stripe-signature": "test_signature",
841
+ },
842
+ body: JSON.stringify(mockEvent),
836
843
  },
844
+ );
845
+
846
+ const response = await testAuth.handler(mockRequest);
847
+ expect(response.status).toBe(200);
848
+
849
+ // Verify subscription was created in database
850
+ const subscription = await testCtx.adapter.findOne<Subscription>({
851
+ model: "subscription",
852
+ where: [
853
+ { field: "stripeSubscriptionId", value: "sub_dashboard_created" },
854
+ ],
855
+ });
856
+
857
+ expect(subscription).toBeDefined();
858
+ expect(subscription?.referenceId).toBe(userWithCustomerId.id);
859
+ expect(subscription?.stripeCustomerId).toBe("cus_dashboard_test");
860
+ expect(subscription?.status).toBe("active");
861
+ expect(subscription?.plan).toBe("starter");
862
+ expect(subscription?.seats).toBe(1);
863
+ });
864
+
865
+ it("should not create duplicate subscription if already exists", async () => {
866
+ const onSubscriptionCreatedCallback = vi.fn();
867
+
868
+ const stripeForTest = {
869
+ ...stripeOptions.stripeClient,
837
870
  webhooks: {
838
- constructEventAsync: vi.fn().mockResolvedValue(completeEvent),
871
+ constructEventAsync: vi.fn(),
839
872
  },
840
873
  };
841
874
 
842
- const eventTestOptions = {
843
- ...testOptions,
844
- stripeClient: mockStripeForEvents as unknown as Stripe,
845
- };
875
+ const testOptions = {
876
+ ...stripeOptions,
877
+ stripeClient: stripeForTest as unknown as Stripe,
878
+ stripeWebhookSecret: "test_secret",
879
+ subscription: {
880
+ ...stripeOptions.subscription,
881
+ onSubscriptionCreated: onSubscriptionCreatedCallback,
882
+ },
883
+ } as StripeOptions;
846
884
 
847
- const { auth: eventTestAuth } = await getTestInstance(
885
+ const { auth: testAuth } = await getTestInstance(
848
886
  {
849
887
  database: memory,
850
- plugins: [stripe(eventTestOptions)],
888
+ plugins: [stripe(testOptions)],
851
889
  },
852
890
  {
853
891
  disableTestUser: true,
854
892
  },
855
893
  );
894
+ const testCtx = await testAuth.$context;
856
895
 
857
- const eventTestCtx = await eventTestAuth.$context;
896
+ // Create user
897
+ const user = await testCtx.adapter.create({
898
+ model: "user",
899
+ data: {
900
+ email: "duplicate-sub@test.com",
901
+ name: "Duplicate Test",
902
+ emailVerified: true,
903
+ stripeCustomerId: "cus_duplicate_test",
904
+ },
905
+ });
858
906
 
859
- const { id: testSubscriptionId } = await eventTestCtx.adapter.create({
907
+ // Create existing subscription
908
+ await testCtx.adapter.create({
860
909
  model: "subscription",
861
910
  data: {
862
- referenceId: userId,
863
- stripeCustomerId: "cus_123",
864
- stripeSubscriptionId: "sub_123",
865
- status: "incomplete",
911
+ referenceId: user.id,
912
+ stripeCustomerId: "cus_duplicate_test",
913
+ stripeSubscriptionId: "sub_already_exists",
914
+ status: "active",
866
915
  plan: "starter",
867
916
  },
868
917
  });
869
918
 
870
- const webhookRequest = new Request(
871
- "http://localhost:3000/api/auth/stripe/webhook",
872
- {
873
- method: "POST",
874
- headers: {
875
- "stripe-signature": "test_signature",
876
- },
877
- body: JSON.stringify(completeEvent),
878
- },
879
- );
880
-
881
- await eventTestAuth.handler(webhookRequest);
882
-
883
- expect(onSubscriptionComplete).toHaveBeenCalledWith(
884
- expect.objectContaining({
885
- event: expect.any(Object),
886
- subscription: expect.any(Object),
887
- stripeSubscription: expect.any(Object),
888
- plan: expect.any(Object),
889
- }),
890
- expect.objectContaining({
891
- context: expect.any(Object),
892
- _flag: expect.any(String),
893
- }),
894
- );
895
-
896
- const updateEvent = {
897
- type: "customer.subscription.updated",
919
+ const mockEvent = {
920
+ type: "customer.subscription.created",
898
921
  data: {
899
922
  object: {
900
- id: testSubscriptionId,
901
- customer: "cus_123",
923
+ id: "sub_already_exists",
924
+ customer: "cus_duplicate_test",
902
925
  status: "active",
903
926
  items: {
904
- data: [{ price: { id: process.env.STRIPE_PRICE_ID_1 } }],
927
+ data: [
928
+ {
929
+ price: { id: process.env.STRIPE_PRICE_ID_1 },
930
+ quantity: 1,
931
+ current_period_start: Math.floor(Date.now() / 1000),
932
+ current_period_end:
933
+ Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
934
+ },
935
+ ],
905
936
  },
906
- current_period_start: Math.floor(Date.now() / 1000),
907
- current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
937
+ cancel_at_period_end: false,
908
938
  },
909
939
  },
910
940
  };
911
941
 
912
- const updateRequest = new Request(
942
+ (stripeForTest.webhooks.constructEventAsync as any).mockResolvedValue(
943
+ mockEvent,
944
+ );
945
+
946
+ const mockRequest = new Request(
913
947
  "http://localhost:3000/api/auth/stripe/webhook",
914
948
  {
915
949
  method: "POST",
916
950
  headers: {
917
951
  "stripe-signature": "test_signature",
918
952
  },
919
- body: JSON.stringify(updateEvent),
953
+ body: JSON.stringify(mockEvent),
920
954
  },
921
955
  );
922
956
 
923
- mockStripeForEvents.webhooks.constructEventAsync.mockReturnValue(
924
- updateEvent,
925
- );
926
- await eventTestAuth.handler(updateRequest);
927
- expect(onSubscriptionUpdate).toHaveBeenCalledWith(
928
- expect.objectContaining({
929
- event: expect.any(Object),
930
- subscription: expect.any(Object),
931
- }),
932
- );
957
+ const response = await testAuth.handler(mockRequest);
958
+ expect(response.status).toBe(200);
933
959
 
934
- const userCancelEvent = {
935
- type: "customer.subscription.updated",
936
- data: {
937
- object: {
938
- id: testSubscriptionId,
939
- customer: "cus_123",
940
- status: "active",
941
- cancel_at_period_end: true,
942
- cancellation_details: {
943
- reason: "cancellation_requested",
944
- comment: "Customer canceled subscription",
945
- },
946
- items: {
947
- data: [{ price: { id: process.env.STRIPE_PRICE_ID_1 } }],
948
- },
949
- current_period_start: Math.floor(Date.now() / 1000),
950
- current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
960
+ // Verify only one subscription exists (no duplicate)
961
+ const subscriptions = await testCtx.adapter.findMany<Subscription>({
962
+ model: "subscription",
963
+ where: [
964
+ {
965
+ field: "stripeSubscriptionId",
966
+ value: "sub_already_exists",
951
967
  },
968
+ ],
969
+ });
970
+
971
+ expect(subscriptions.length).toBe(1);
972
+
973
+ // Verify callback was NOT called (early return due to existing subscription)
974
+ expect(onSubscriptionCreatedCallback).not.toHaveBeenCalled();
975
+ });
976
+
977
+ it("should skip subscription creation when user not found", async () => {
978
+ const onSubscriptionCreatedCallback = vi.fn();
979
+
980
+ const stripeForTest = {
981
+ ...stripeOptions.stripeClient,
982
+ webhooks: {
983
+ constructEventAsync: vi.fn(),
952
984
  },
953
985
  };
954
986
 
955
- const userCancelRequest = new Request(
956
- "http://localhost:3000/api/auth/stripe/webhook",
987
+ const testOptions = {
988
+ ...stripeOptions,
989
+ stripeClient: stripeForTest as unknown as Stripe,
990
+ stripeWebhookSecret: "test_secret",
991
+ subscription: {
992
+ ...stripeOptions.subscription,
993
+ onSubscriptionCreated: onSubscriptionCreatedCallback,
994
+ },
995
+ } as StripeOptions;
996
+
997
+ const { auth: testAuth } = await getTestInstance(
957
998
  {
958
- method: "POST",
959
- headers: {
960
- "stripe-signature": "test_signature",
961
- },
962
- body: JSON.stringify(userCancelEvent),
999
+ database: memory,
1000
+ plugins: [stripe(testOptions)],
1001
+ },
1002
+ {
1003
+ disableTestUser: true,
963
1004
  },
964
1005
  );
1006
+ const testCtx = await testAuth.$context;
965
1007
 
966
- mockStripeForEvents.webhooks.constructEventAsync.mockReturnValue(
967
- userCancelEvent,
968
- );
969
- await eventTestAuth.handler(userCancelRequest);
970
- const cancelEvent = {
971
- type: "customer.subscription.updated",
1008
+ const mockEvent = {
1009
+ type: "customer.subscription.created",
972
1010
  data: {
973
1011
  object: {
974
- id: testSubscriptionId,
975
- customer: "cus_123",
1012
+ id: "sub_no_user",
1013
+ customer: "cus_nonexistent",
976
1014
  status: "active",
977
- cancel_at_period_end: true,
978
1015
  items: {
979
- data: [{ price: { id: process.env.STRIPE_PRICE_ID_1 } }],
1016
+ data: [
1017
+ {
1018
+ price: { id: process.env.STRIPE_PRICE_ID_1 },
1019
+ quantity: 1,
1020
+ current_period_start: Math.floor(Date.now() / 1000),
1021
+ current_period_end:
1022
+ Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1023
+ },
1024
+ ],
980
1025
  },
981
- current_period_start: Math.floor(Date.now() / 1000),
982
- current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1026
+ cancel_at_period_end: false,
983
1027
  },
984
1028
  },
985
1029
  };
986
1030
 
987
- const cancelRequest = new Request(
1031
+ (stripeForTest.webhooks.constructEventAsync as any).mockResolvedValue(
1032
+ mockEvent,
1033
+ );
1034
+
1035
+ const mockRequest = new Request(
988
1036
  "http://localhost:3000/api/auth/stripe/webhook",
989
1037
  {
990
1038
  method: "POST",
991
1039
  headers: {
992
1040
  "stripe-signature": "test_signature",
993
1041
  },
994
- body: JSON.stringify(cancelEvent),
1042
+ body: JSON.stringify(mockEvent),
995
1043
  },
996
1044
  );
997
1045
 
998
- mockStripeForEvents.webhooks.constructEventAsync.mockReturnValue(
999
- cancelEvent,
1000
- );
1001
- await eventTestAuth.handler(cancelRequest);
1002
-
1003
- expect(onSubscriptionCancel).toHaveBeenCalled();
1004
-
1005
- const deleteEvent = {
1006
- type: "customer.subscription.deleted",
1007
- data: {
1008
- object: {
1009
- id: testSubscriptionId,
1010
- customer: "cus_123",
1011
- status: "canceled",
1012
- metadata: {
1013
- referenceId: userId,
1014
- subscriptionId: testSubscriptionId,
1015
- },
1016
- },
1017
- },
1018
- };
1046
+ const response = await testAuth.handler(mockRequest);
1047
+ expect(response.status).toBe(200);
1019
1048
 
1020
- const deleteRequest = new Request(
1021
- "http://localhost:3000/api/auth/stripe/webhook",
1022
- {
1023
- method: "POST",
1024
- headers: {
1025
- "stripe-signature": "test_signature",
1026
- },
1027
- body: JSON.stringify(deleteEvent),
1028
- },
1029
- );
1049
+ // Verify subscription was NOT created
1050
+ const subscription = await testCtx.adapter.findOne<Subscription>({
1051
+ model: "subscription",
1052
+ where: [{ field: "stripeSubscriptionId", value: "sub_no_user" }],
1053
+ });
1030
1054
 
1031
- mockStripeForEvents.webhooks.constructEventAsync.mockReturnValue(
1032
- deleteEvent,
1033
- );
1034
- await eventTestAuth.handler(deleteRequest);
1055
+ expect(subscription).toBeNull();
1035
1056
 
1036
- expect(onSubscriptionDeleted).toHaveBeenCalled();
1057
+ // Verify callback was NOT called (early return due to user not found)
1058
+ expect(onSubscriptionCreatedCallback).not.toHaveBeenCalled();
1037
1059
  });
1038
1060
 
1039
- it("should return updated subscription in onSubscriptionUpdate callback", async () => {
1040
- const onSubscriptionUpdate = vi.fn();
1041
-
1042
- // Simulate subscription update event (e.g., seat change from 1 to 5)
1043
- const updateEvent = {
1044
- type: "customer.subscription.updated",
1045
- data: {
1046
- object: {
1047
- id: "sub_update_test",
1048
- customer: "cus_update_test",
1049
- status: "active",
1050
- items: {
1051
- data: [
1052
- {
1053
- price: { id: process.env.STRIPE_PRICE_ID_1 },
1054
- quantity: 5, // Updated from 1 to 5
1055
- current_period_start: Math.floor(Date.now() / 1000),
1056
- current_period_end:
1057
- Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1058
- },
1059
- ],
1060
- },
1061
- current_period_start: Math.floor(Date.now() / 1000),
1062
- current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1063
- },
1064
- },
1065
- };
1061
+ it("should skip subscription creation when plan not found", async () => {
1062
+ const onSubscriptionCreatedCallback = vi.fn();
1066
1063
 
1067
1064
  const stripeForTest = {
1068
1065
  ...stripeOptions.stripeClient,
1069
1066
  webhooks: {
1070
- constructEventAsync: vi.fn().mockResolvedValue(updateEvent),
1067
+ constructEventAsync: vi.fn(),
1071
1068
  },
1072
1069
  };
1073
1070
 
@@ -1077,9 +1074,9 @@ describe("stripe", () => {
1077
1074
  stripeWebhookSecret: "test_secret",
1078
1075
  subscription: {
1079
1076
  ...stripeOptions.subscription,
1080
- onSubscriptionUpdate,
1077
+ onSubscriptionCreated: onSubscriptionCreatedCallback,
1081
1078
  },
1082
- } as unknown as StripeOptions;
1079
+ } as StripeOptions;
1083
1080
 
1084
1081
  const { auth: testAuth } = await getTestInstance(
1085
1082
  {
@@ -1090,27 +1087,45 @@ describe("stripe", () => {
1090
1087
  disableTestUser: true,
1091
1088
  },
1092
1089
  );
1090
+ const testCtx = await testAuth.$context;
1093
1091
 
1094
- const ctx = await testAuth.$context;
1095
-
1096
- const { id: testReferenceId } = await ctx.adapter.create({
1092
+ // Create user
1093
+ await testCtx.adapter.create({
1097
1094
  model: "user",
1098
1095
  data: {
1099
- email: "update-callback@email.com",
1096
+ email: "no-plan@test.com",
1097
+ name: "No Plan User",
1098
+ emailVerified: true,
1099
+ stripeCustomerId: "cus_no_plan",
1100
1100
  },
1101
1101
  });
1102
1102
 
1103
- const { id: testSubscriptionId } = await ctx.adapter.create({
1104
- model: "subscription",
1103
+ const mockEvent = {
1104
+ type: "customer.subscription.created",
1105
1105
  data: {
1106
- referenceId: testReferenceId,
1107
- stripeCustomerId: "cus_update_test",
1108
- stripeSubscriptionId: "sub_update_test",
1109
- status: "active",
1110
- plan: "starter",
1111
- seats: 1,
1106
+ object: {
1107
+ id: "sub_no_plan",
1108
+ customer: "cus_no_plan",
1109
+ status: "active",
1110
+ items: {
1111
+ data: [
1112
+ {
1113
+ price: { id: "price_unknown" }, // Unknown price
1114
+ quantity: 1,
1115
+ current_period_start: Math.floor(Date.now() / 1000),
1116
+ current_period_end:
1117
+ Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1118
+ },
1119
+ ],
1120
+ },
1121
+ cancel_at_period_end: false,
1122
+ },
1112
1123
  },
1113
- });
1124
+ };
1125
+
1126
+ (stripeForTest.webhooks.constructEventAsync as any).mockResolvedValue(
1127
+ mockEvent,
1128
+ );
1114
1129
 
1115
1130
  const mockRequest = new Request(
1116
1131
  "http://localhost:3000/api/auth/stripe/webhook",
@@ -1119,367 +1134,403 @@ describe("stripe", () => {
1119
1134
  headers: {
1120
1135
  "stripe-signature": "test_signature",
1121
1136
  },
1122
- body: JSON.stringify(updateEvent),
1137
+ body: JSON.stringify(mockEvent),
1123
1138
  },
1124
1139
  );
1125
1140
 
1126
- await testAuth.handler(mockRequest);
1127
-
1128
- // Verify that onSubscriptionUpdate was called
1129
- expect(onSubscriptionUpdate).toHaveBeenCalledTimes(1);
1130
-
1131
- // Verify that the callback received the UPDATED subscription (seats: 5, not 1)
1132
- const callbackArg = onSubscriptionUpdate.mock.calls[0]?.[0];
1133
- expect(callbackArg).toBeDefined();
1134
- expect(callbackArg.subscription).toMatchObject({
1135
- id: testSubscriptionId,
1136
- seats: 5, // Should be the NEW value, not the old value (1)
1137
- status: "active",
1138
- plan: "starter",
1139
- });
1141
+ const response = await testAuth.handler(mockRequest);
1142
+ expect(response.status).toBe(200);
1140
1143
 
1141
- // Also verify the subscription was actually updated in the database
1142
- const updatedSub = await ctx.adapter.findOne<Subscription>({
1144
+ // Verify subscription was NOT created (no matching plan)
1145
+ const subscription = await testCtx.adapter.findOne<Subscription>({
1143
1146
  model: "subscription",
1144
- where: [{ field: "id", value: testSubscriptionId }],
1147
+ where: [{ field: "stripeSubscriptionId", value: "sub_no_plan" }],
1145
1148
  });
1146
- expect(updatedSub?.seats).toBe(5);
1149
+
1150
+ expect(subscription).toBeNull();
1151
+
1152
+ // Verify callback was NOT called (early return due to plan not found)
1153
+ expect(onSubscriptionCreatedCallback).not.toHaveBeenCalled();
1147
1154
  });
1148
1155
 
1149
- it("should allow seat upgrades for the same plan", async () => {
1150
- const { client, auth, sessionSetter } = await getTestInstance(
1156
+ it("should execute subscription event handlers", async () => {
1157
+ const { auth: testAuth } = await getTestInstance(
1151
1158
  {
1152
1159
  database: memory,
1153
1160
  plugins: [stripe(stripeOptions)],
1154
1161
  },
1155
1162
  {
1156
1163
  disableTestUser: true,
1157
- clientOptions: {
1158
- plugins: [stripeClient({ subscription: true })],
1159
- },
1160
1164
  },
1161
1165
  );
1162
- const ctx = await auth.$context;
1166
+ const testCtx = await testAuth.$context;
1163
1167
 
1164
- const userRes = await client.signUp.email(
1165
- {
1166
- ...testUser,
1167
- email: "seat-upgrade@email.com",
1168
- },
1169
- {
1170
- throw: true,
1168
+ const { id: userId } = await testCtx.adapter.create({
1169
+ model: "user",
1170
+ data: {
1171
+ email: "event-handler-test@email.com",
1171
1172
  },
1172
- );
1173
+ });
1173
1174
 
1174
- const headers = new Headers();
1175
- await client.signIn.email(
1176
- {
1177
- ...testUser,
1178
- email: "seat-upgrade@email.com",
1179
- },
1180
- {
1181
- throw: true,
1182
- onSuccess: sessionSetter(headers),
1175
+ const onSubscriptionComplete = vi.fn();
1176
+ const onSubscriptionUpdate = vi.fn();
1177
+ const onSubscriptionCancel = vi.fn();
1178
+ const onSubscriptionDeleted = vi.fn();
1179
+
1180
+ const testOptions = {
1181
+ ...stripeOptions,
1182
+ subscription: {
1183
+ ...stripeOptions.subscription,
1184
+ onSubscriptionComplete,
1185
+ onSubscriptionUpdate,
1186
+ onSubscriptionCancel,
1187
+ onSubscriptionDeleted,
1183
1188
  },
1184
- );
1189
+ stripeWebhookSecret: "test_secret",
1190
+ } as unknown as StripeOptions;
1185
1191
 
1186
- await client.subscription.upgrade({
1187
- plan: "starter",
1188
- seats: 1,
1189
- fetchOptions: {
1190
- headers,
1192
+ // Test subscription complete handler
1193
+ const completeEvent = {
1194
+ type: "checkout.session.completed",
1195
+ data: {
1196
+ object: {
1197
+ mode: "subscription",
1198
+ subscription: "sub_123",
1199
+ metadata: {
1200
+ referenceId: "user_123",
1201
+ subscriptionId: "sub_123",
1202
+ },
1203
+ },
1191
1204
  },
1192
- });
1205
+ };
1193
1206
 
1194
- await ctx.adapter.update({
1195
- model: "subscription",
1196
- update: {
1197
- status: "active",
1207
+ const mockSubscription = {
1208
+ status: "active",
1209
+ items: {
1210
+ data: [{ price: { id: process.env.STRIPE_PRICE_ID_1 } }],
1198
1211
  },
1199
- where: [
1200
- {
1201
- field: "referenceId",
1202
- value: userRes.user.id,
1203
- },
1204
- ],
1205
- });
1212
+ current_period_start: Math.floor(Date.now() / 1000),
1213
+ current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1214
+ };
1206
1215
 
1207
- const upgradeRes = await client.subscription.upgrade({
1208
- plan: "starter",
1209
- seats: 5,
1210
- fetchOptions: {
1211
- headers,
1216
+ const mockStripeForEvents = {
1217
+ ...testOptions.stripeClient,
1218
+ subscriptions: {
1219
+ retrieve: vi.fn().mockResolvedValue(mockSubscription),
1212
1220
  },
1213
- });
1221
+ webhooks: {
1222
+ constructEventAsync: vi.fn().mockResolvedValue(completeEvent),
1223
+ },
1224
+ };
1214
1225
 
1215
- expect(upgradeRes.data?.url).toBeDefined();
1216
- });
1226
+ const eventTestOptions = {
1227
+ ...testOptions,
1228
+ stripeClient: mockStripeForEvents as unknown as Stripe,
1229
+ };
1217
1230
 
1218
- it("should prevent duplicate subscriptions with same plan and same seats", async () => {
1219
- const { client, auth, sessionSetter } = await getTestInstance(
1231
+ const { auth: eventTestAuth } = await getTestInstance(
1220
1232
  {
1221
1233
  database: memory,
1222
- plugins: [stripe(stripeOptions)],
1234
+ plugins: [stripe(eventTestOptions)],
1223
1235
  },
1224
1236
  {
1225
1237
  disableTestUser: true,
1226
- clientOptions: {
1227
- plugins: [stripeClient({ subscription: true })],
1228
- },
1229
1238
  },
1230
1239
  );
1231
- const ctx = await auth.$context;
1232
1240
 
1233
- const userRes = await client.signUp.email(
1234
- {
1235
- ...testUser,
1236
- email: "duplicate-prevention@email.com",
1237
- },
1238
- {
1239
- throw: true,
1241
+ const eventTestCtx = await eventTestAuth.$context;
1242
+
1243
+ const { id: testSubscriptionId } = await eventTestCtx.adapter.create({
1244
+ model: "subscription",
1245
+ data: {
1246
+ referenceId: userId,
1247
+ stripeCustomerId: "cus_123",
1248
+ stripeSubscriptionId: "sub_123",
1249
+ status: "incomplete",
1250
+ plan: "starter",
1240
1251
  },
1241
- );
1252
+ });
1242
1253
 
1243
- const headers = new Headers();
1244
- await client.signIn.email(
1245
- {
1246
- ...testUser,
1247
- email: "duplicate-prevention@email.com",
1248
- },
1254
+ const webhookRequest = new Request(
1255
+ "http://localhost:3000/api/auth/stripe/webhook",
1249
1256
  {
1250
- throw: true,
1251
- onSuccess: sessionSetter(headers),
1257
+ method: "POST",
1258
+ headers: {
1259
+ "stripe-signature": "test_signature",
1260
+ },
1261
+ body: JSON.stringify(completeEvent),
1252
1262
  },
1253
1263
  );
1254
1264
 
1255
- await client.subscription.upgrade({
1256
- plan: "starter",
1257
- seats: 3,
1258
- fetchOptions: {
1259
- headers,
1260
- },
1261
- });
1265
+ await eventTestAuth.handler(webhookRequest);
1262
1266
 
1263
- await ctx.adapter.update({
1264
- model: "subscription",
1265
- update: {
1266
- status: "active",
1267
- seats: 3,
1268
- },
1269
- where: [
1270
- {
1271
- field: "referenceId",
1272
- value: userRes.user.id,
1273
- },
1274
- ],
1275
- });
1267
+ expect(onSubscriptionComplete).toHaveBeenCalledWith(
1268
+ expect.objectContaining({
1269
+ event: expect.any(Object),
1270
+ subscription: expect.any(Object),
1271
+ stripeSubscription: expect.any(Object),
1272
+ plan: expect.any(Object),
1273
+ }),
1274
+ expect.objectContaining({
1275
+ context: expect.any(Object),
1276
+ _flag: expect.any(String),
1277
+ }),
1278
+ );
1276
1279
 
1277
- const upgradeRes = await client.subscription.upgrade({
1278
- plan: "starter",
1279
- seats: 3,
1280
- fetchOptions: {
1281
- headers,
1280
+ const updateEvent = {
1281
+ type: "customer.subscription.updated",
1282
+ data: {
1283
+ object: {
1284
+ id: testSubscriptionId,
1285
+ customer: "cus_123",
1286
+ status: "active",
1287
+ items: {
1288
+ data: [{ price: { id: process.env.STRIPE_PRICE_ID_1 } }],
1289
+ },
1290
+ current_period_start: Math.floor(Date.now() / 1000),
1291
+ current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1292
+ },
1282
1293
  },
1283
- });
1284
-
1285
- expect(upgradeRes.error).toBeDefined();
1286
- expect(upgradeRes.error?.message).toContain("already subscribed");
1287
- });
1294
+ };
1288
1295
 
1289
- it("should only call Stripe customers.create once for signup and upgrade", async () => {
1290
- const { client, sessionSetter } = await getTestInstance(
1291
- {
1292
- database: memory,
1293
- plugins: [stripe(stripeOptions)],
1294
- },
1296
+ const updateRequest = new Request(
1297
+ "http://localhost:3000/api/auth/stripe/webhook",
1295
1298
  {
1296
- disableTestUser: true,
1297
- clientOptions: {
1298
- plugins: [stripeClient({ subscription: true })],
1299
+ method: "POST",
1300
+ headers: {
1301
+ "stripe-signature": "test_signature",
1299
1302
  },
1303
+ body: JSON.stringify(updateEvent),
1300
1304
  },
1301
1305
  );
1302
1306
 
1303
- await client.signUp.email(
1304
- { ...testUser, email: "single-create@email.com" },
1305
- { throw: true },
1307
+ mockStripeForEvents.webhooks.constructEventAsync.mockReturnValue(
1308
+ updateEvent,
1306
1309
  );
1307
-
1308
- const headers = new Headers();
1309
- await client.signIn.email(
1310
- { ...testUser, email: "single-create@email.com" },
1311
- {
1312
- throw: true,
1313
- onSuccess: sessionSetter(headers),
1314
- },
1310
+ await eventTestAuth.handler(updateRequest);
1311
+ expect(onSubscriptionUpdate).toHaveBeenCalledWith(
1312
+ expect.objectContaining({
1313
+ event: expect.any(Object),
1314
+ subscription: expect.any(Object),
1315
+ }),
1315
1316
  );
1316
1317
 
1317
- await client.subscription.upgrade({
1318
- plan: "starter",
1319
- fetchOptions: { headers },
1320
- });
1321
-
1322
- expect(mockStripe.customers.create).toHaveBeenCalledTimes(1);
1323
- });
1324
-
1325
- it("should create billing portal session", async () => {
1326
- const { client, sessionSetter } = await getTestInstance(
1327
- {
1328
- database: memory,
1329
- plugins: [stripe(stripeOptions)],
1318
+ const userCancelEvent = {
1319
+ type: "customer.subscription.updated",
1320
+ data: {
1321
+ object: {
1322
+ id: testSubscriptionId,
1323
+ customer: "cus_123",
1324
+ status: "active",
1325
+ cancel_at_period_end: true,
1326
+ cancellation_details: {
1327
+ reason: "cancellation_requested",
1328
+ comment: "Customer canceled subscription",
1329
+ },
1330
+ items: {
1331
+ data: [{ price: { id: process.env.STRIPE_PRICE_ID_1 } }],
1332
+ },
1333
+ current_period_start: Math.floor(Date.now() / 1000),
1334
+ current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1335
+ },
1330
1336
  },
1337
+ };
1338
+
1339
+ const userCancelRequest = new Request(
1340
+ "http://localhost:3000/api/auth/stripe/webhook",
1331
1341
  {
1332
- disableTestUser: true,
1333
- clientOptions: {
1334
- plugins: [stripeClient({ subscription: true })],
1342
+ method: "POST",
1343
+ headers: {
1344
+ "stripe-signature": "test_signature",
1335
1345
  },
1346
+ body: JSON.stringify(userCancelEvent),
1336
1347
  },
1337
1348
  );
1338
1349
 
1339
- await client.signUp.email(
1340
- {
1341
- ...testUser,
1342
- email: "billing-portal@email.com",
1350
+ mockStripeForEvents.webhooks.constructEventAsync.mockReturnValue(
1351
+ userCancelEvent,
1352
+ );
1353
+ await eventTestAuth.handler(userCancelRequest);
1354
+ const cancelEvent = {
1355
+ type: "customer.subscription.updated",
1356
+ data: {
1357
+ object: {
1358
+ id: testSubscriptionId,
1359
+ customer: "cus_123",
1360
+ status: "active",
1361
+ cancel_at_period_end: true,
1362
+ items: {
1363
+ data: [{ price: { id: process.env.STRIPE_PRICE_ID_1 } }],
1364
+ },
1365
+ current_period_start: Math.floor(Date.now() / 1000),
1366
+ current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1367
+ },
1343
1368
  },
1369
+ };
1370
+
1371
+ const cancelRequest = new Request(
1372
+ "http://localhost:3000/api/auth/stripe/webhook",
1344
1373
  {
1345
- throw: true,
1374
+ method: "POST",
1375
+ headers: {
1376
+ "stripe-signature": "test_signature",
1377
+ },
1378
+ body: JSON.stringify(cancelEvent),
1346
1379
  },
1347
1380
  );
1348
1381
 
1349
- const headers = new Headers();
1350
- await client.signIn.email(
1351
- {
1352
- ...testUser,
1353
- email: "billing-portal@email.com",
1354
- },
1355
- {
1356
- throw: true,
1357
- onSuccess: sessionSetter(headers),
1358
- },
1382
+ mockStripeForEvents.webhooks.constructEventAsync.mockReturnValue(
1383
+ cancelEvent,
1359
1384
  );
1360
- const billingPortalRes = await client.subscription.billingPortal({
1361
- returnUrl: "/dashboard",
1362
- fetchOptions: {
1363
- headers,
1364
- },
1365
- });
1366
- expect(billingPortalRes.data?.url).toBe("https://billing.stripe.com/mock");
1367
- expect(billingPortalRes.data?.redirect).toBe(true);
1368
- expect(mockStripe.billingPortal.sessions.create).toHaveBeenCalledWith({
1369
- customer: expect.any(String),
1370
- return_url: "http://localhost:3000/dashboard",
1371
- });
1372
- });
1385
+ await eventTestAuth.handler(cancelRequest);
1373
1386
 
1374
- it("should not update personal subscription when upgrading with an org referenceId", async () => {
1375
- /* cspell:disable-next-line */
1376
- const orgId = "org_b67GF32Cljh7u588AuEblmLVobclDRcP";
1387
+ expect(onSubscriptionCancel).toHaveBeenCalled();
1377
1388
 
1378
- const testOptions = {
1379
- ...stripeOptions,
1380
- stripeClient: _stripe,
1381
- subscription: {
1382
- ...stripeOptions.subscription,
1383
- authorizeReference: async () => true,
1389
+ const deleteEvent = {
1390
+ type: "customer.subscription.deleted",
1391
+ data: {
1392
+ object: {
1393
+ id: testSubscriptionId,
1394
+ customer: "cus_123",
1395
+ status: "canceled",
1396
+ metadata: {
1397
+ referenceId: userId,
1398
+ subscriptionId: testSubscriptionId,
1399
+ },
1400
+ },
1384
1401
  },
1385
- } as unknown as StripeOptions;
1402
+ };
1386
1403
 
1387
- const {
1388
- auth: testAuth,
1389
- client: testClient,
1390
- sessionSetter: testSessionSetter,
1391
- } = await getTestInstance(
1392
- {
1393
- database: memory,
1394
- plugins: [stripe(testOptions)],
1395
- },
1404
+ const deleteRequest = new Request(
1405
+ "http://localhost:3000/api/auth/stripe/webhook",
1396
1406
  {
1397
- disableTestUser: true,
1398
- clientOptions: {
1399
- plugins: [stripeClient({ subscription: true })],
1407
+ method: "POST",
1408
+ headers: {
1409
+ "stripe-signature": "test_signature",
1400
1410
  },
1411
+ body: JSON.stringify(deleteEvent),
1401
1412
  },
1402
1413
  );
1403
- const testCtx = await testAuth.$context;
1404
1414
 
1405
- // Sign up and sign in the user
1406
- const userRes = await testClient.signUp.email(
1407
- { ...testUser, email: "org-ref@email.com" },
1408
- { throw: true },
1415
+ mockStripeForEvents.webhooks.constructEventAsync.mockReturnValue(
1416
+ deleteEvent,
1409
1417
  );
1410
- const headers = new Headers();
1411
- await testClient.signIn.email(
1412
- { ...testUser, email: "org-ref@email.com" },
1413
- { throw: true, onSuccess: testSessionSetter(headers) },
1414
- );
1415
-
1416
- // Create a personal subscription (referenceId = user id)
1417
- await testClient.subscription.upgrade({
1418
- plan: "starter",
1419
- fetchOptions: { headers },
1420
- });
1418
+ await eventTestAuth.handler(deleteRequest);
1421
1419
 
1422
- const personalSub = await testCtx.adapter.findOne<Subscription>({
1423
- model: "subscription",
1424
- where: [{ field: "referenceId", value: userRes.user.id }],
1425
- });
1426
- expect(personalSub).toBeTruthy();
1420
+ expect(onSubscriptionDeleted).toHaveBeenCalled();
1421
+ });
1427
1422
 
1428
- await testCtx.adapter.update({
1429
- model: "subscription",
1430
- update: {
1431
- status: "active",
1432
- stripeSubscriptionId: "sub_personal_active_123",
1433
- },
1434
- where: [{ field: "id", value: personalSub!.id }],
1435
- });
1423
+ it("should return updated subscription in onSubscriptionUpdate callback", async () => {
1424
+ const onSubscriptionUpdate = vi.fn();
1436
1425
 
1437
- mockStripe.subscriptions.list.mockResolvedValue({
1438
- data: [
1439
- {
1440
- id: "sub_personal_active_123",
1426
+ // Simulate subscription update event (e.g., seat change from 1 to 5)
1427
+ const updateEvent = {
1428
+ type: "customer.subscription.updated",
1429
+ data: {
1430
+ object: {
1431
+ id: "sub_update_test",
1432
+ customer: "cus_update_test",
1441
1433
  status: "active",
1442
1434
  items: {
1443
1435
  data: [
1444
1436
  {
1445
- id: "si_1",
1446
1437
  price: { id: process.env.STRIPE_PRICE_ID_1 },
1447
- quantity: 1,
1438
+ quantity: 5, // Updated from 1 to 5
1439
+ current_period_start: Math.floor(Date.now() / 1000),
1440
+ current_period_end:
1441
+ Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1448
1442
  },
1449
1443
  ],
1450
1444
  },
1445
+ current_period_start: Math.floor(Date.now() / 1000),
1446
+ current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1451
1447
  },
1452
- ],
1453
- });
1448
+ },
1449
+ };
1454
1450
 
1455
- // Attempt to upgrade using an org referenceId
1456
- const upgradeRes = await testClient.subscription.upgrade({
1457
- plan: "starter",
1458
- referenceId: orgId,
1459
- fetchOptions: { headers },
1451
+ const stripeForTest = {
1452
+ ...stripeOptions.stripeClient,
1453
+ webhooks: {
1454
+ constructEventAsync: vi.fn().mockResolvedValue(updateEvent),
1455
+ },
1456
+ };
1457
+
1458
+ const testOptions = {
1459
+ ...stripeOptions,
1460
+ stripeClient: stripeForTest as unknown as Stripe,
1461
+ stripeWebhookSecret: "test_secret",
1462
+ subscription: {
1463
+ ...stripeOptions.subscription,
1464
+ onSubscriptionUpdate,
1465
+ },
1466
+ } as unknown as StripeOptions;
1467
+
1468
+ const { auth: testAuth } = await getTestInstance(
1469
+ {
1470
+ database: memory,
1471
+ plugins: [stripe(testOptions)],
1472
+ },
1473
+ {
1474
+ disableTestUser: true,
1475
+ },
1476
+ );
1477
+
1478
+ const ctx = await testAuth.$context;
1479
+
1480
+ const { id: testReferenceId } = await ctx.adapter.create({
1481
+ model: "user",
1482
+ data: {
1483
+ email: "update-callback@email.com",
1484
+ },
1460
1485
  });
1461
- // It should NOT go through billing portal (which would update the personal sub)
1462
- expect(mockStripe.billingPortal.sessions.create).not.toHaveBeenCalled();
1463
- expect(upgradeRes.data?.url).toBeDefined();
1464
1486
 
1465
- const orgSub = await testCtx.adapter.findOne<Subscription>({
1487
+ const { id: testSubscriptionId } = await ctx.adapter.create({
1466
1488
  model: "subscription",
1467
- where: [{ field: "referenceId", value: orgId }],
1489
+ data: {
1490
+ referenceId: testReferenceId,
1491
+ stripeCustomerId: "cus_update_test",
1492
+ stripeSubscriptionId: "sub_update_test",
1493
+ status: "active",
1494
+ plan: "starter",
1495
+ seats: 1,
1496
+ },
1468
1497
  });
1469
- expect(orgSub).toMatchObject({
1470
- referenceId: orgId,
1471
- status: "incomplete",
1498
+
1499
+ const mockRequest = new Request(
1500
+ "http://localhost:3000/api/auth/stripe/webhook",
1501
+ {
1502
+ method: "POST",
1503
+ headers: {
1504
+ "stripe-signature": "test_signature",
1505
+ },
1506
+ body: JSON.stringify(updateEvent),
1507
+ },
1508
+ );
1509
+
1510
+ await testAuth.handler(mockRequest);
1511
+
1512
+ // Verify that onSubscriptionUpdate was called
1513
+ expect(onSubscriptionUpdate).toHaveBeenCalledTimes(1);
1514
+
1515
+ // Verify that the callback received the UPDATED subscription (seats: 5, not 1)
1516
+ const callbackArg = onSubscriptionUpdate.mock.calls[0]?.[0];
1517
+ expect(callbackArg).toBeDefined();
1518
+ expect(callbackArg.subscription).toMatchObject({
1519
+ id: testSubscriptionId,
1520
+ seats: 5, // Should be the NEW value, not the old value (1)
1521
+ status: "active",
1472
1522
  plan: "starter",
1473
1523
  });
1474
1524
 
1475
- const personalAfter = await testCtx.adapter.findOne<Subscription>({
1525
+ // Also verify the subscription was actually updated in the database
1526
+ const updatedSub = await ctx.adapter.findOne<Subscription>({
1476
1527
  model: "subscription",
1477
- where: [{ field: "id", value: personalSub!.id }],
1528
+ where: [{ field: "id", value: testSubscriptionId }],
1478
1529
  });
1479
- expect(personalAfter?.status).toBe("active");
1530
+ expect(updatedSub?.seats).toBe(5);
1480
1531
  });
1481
1532
 
1482
- it("should prevent multiple free trials for the same user", async () => {
1533
+ it("should allow seat upgrades for the same plan", async () => {
1483
1534
  const { client, auth, sessionSetter } = await getTestInstance(
1484
1535
  {
1485
1536
  database: memory,
@@ -1494,50 +1545,40 @@ describe("stripe", () => {
1494
1545
  );
1495
1546
  const ctx = await auth.$context;
1496
1547
 
1497
- // Create a user
1498
1548
  const userRes = await client.signUp.email(
1499
- { ...testUser, email: "trial-prevention@email.com" },
1500
- { throw: true },
1549
+ {
1550
+ ...testUser,
1551
+ email: "seat-upgrade@email.com",
1552
+ },
1553
+ {
1554
+ throw: true,
1555
+ },
1501
1556
  );
1502
1557
 
1503
1558
  const headers = new Headers();
1504
1559
  await client.signIn.email(
1505
- { ...testUser, email: "trial-prevention@email.com" },
1560
+ {
1561
+ ...testUser,
1562
+ email: "seat-upgrade@email.com",
1563
+ },
1506
1564
  {
1507
1565
  throw: true,
1508
1566
  onSuccess: sessionSetter(headers),
1509
1567
  },
1510
1568
  );
1511
1569
 
1512
- // First subscription with trial
1513
- const firstUpgradeRes = await client.subscription.upgrade({
1570
+ await client.subscription.upgrade({
1514
1571
  plan: "starter",
1515
- fetchOptions: { headers },
1516
- });
1517
-
1518
- expect(firstUpgradeRes.data?.url).toBeDefined();
1519
-
1520
- // Simulate the subscription being created with trial data
1521
- await ctx.adapter.update({
1522
- model: "subscription",
1523
- update: {
1524
- status: "trialing",
1525
- trialStart: new Date(),
1526
- trialEnd: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
1572
+ seats: 1,
1573
+ fetchOptions: {
1574
+ headers,
1527
1575
  },
1528
- where: [
1529
- {
1530
- field: "referenceId",
1531
- value: userRes.user.id,
1532
- },
1533
- ],
1534
1576
  });
1535
1577
 
1536
- // Cancel the subscription
1537
1578
  await ctx.adapter.update({
1538
1579
  model: "subscription",
1539
1580
  update: {
1540
- status: "canceled",
1581
+ status: "active",
1541
1582
  },
1542
1583
  where: [
1543
1584
  {
@@ -1547,41 +1588,18 @@ describe("stripe", () => {
1547
1588
  ],
1548
1589
  });
1549
1590
 
1550
- // Try to subscribe again - should NOT get a trial
1551
- const secondUpgradeRes = await client.subscription.upgrade({
1591
+ const upgradeRes = await client.subscription.upgrade({
1552
1592
  plan: "starter",
1553
- fetchOptions: { headers },
1593
+ seats: 5,
1594
+ fetchOptions: {
1595
+ headers,
1596
+ },
1554
1597
  });
1555
1598
 
1556
- expect(secondUpgradeRes.data?.url).toBeDefined();
1557
-
1558
- // Verify that the checkout session was created without trial_period_days
1559
- // We can't directly test the Stripe session, but we can verify the logic
1560
- // by checking that the user has trial history
1561
- const subscriptions = (await ctx.adapter.findMany({
1562
- model: "subscription",
1563
- where: [
1564
- {
1565
- field: "referenceId",
1566
- value: userRes.user.id,
1567
- },
1568
- ],
1569
- })) as Subscription[];
1570
-
1571
- // Should have 2 subscriptions (first canceled, second new)
1572
- expect(subscriptions).toHaveLength(2);
1573
-
1574
- // At least one should have trial data
1575
- const hasTrialData = subscriptions.some(
1576
- (s: Subscription) => s.trialStart || s.trialEnd,
1577
- );
1578
- expect(hasTrialData).toBe(true);
1599
+ expect(upgradeRes.data?.url).toBeDefined();
1579
1600
  });
1580
1601
 
1581
- it("should upgrade existing subscription instead of creating new one", async () => {
1582
- // Reset mocks for this test
1583
- vi.clearAllMocks();
1584
-
1602
+ it("should prevent duplicate subscriptions with same plan and same seats", async () => {
1585
1603
  const { client, auth, sessionSetter } = await getTestInstance(
1586
1604
  {
1587
1605
  database: memory,
@@ -1596,35 +1614,42 @@ describe("stripe", () => {
1596
1614
  );
1597
1615
  const ctx = await auth.$context;
1598
1616
 
1599
- // Create a user
1600
1617
  const userRes = await client.signUp.email(
1601
- { ...testUser, email: "upgrade-existing@email.com" },
1602
- { throw: true },
1618
+ {
1619
+ ...testUser,
1620
+ email: "duplicate-prevention@email.com",
1621
+ },
1622
+ {
1623
+ throw: true,
1624
+ },
1603
1625
  );
1604
1626
 
1605
1627
  const headers = new Headers();
1606
1628
  await client.signIn.email(
1607
- { ...testUser, email: "upgrade-existing@email.com" },
1629
+ {
1630
+ ...testUser,
1631
+ email: "duplicate-prevention@email.com",
1632
+ },
1608
1633
  {
1609
1634
  throw: true,
1610
1635
  onSuccess: sessionSetter(headers),
1611
1636
  },
1612
1637
  );
1613
1638
 
1614
- // Mock customers.list to find existing customer
1615
- mockStripe.customers.list.mockResolvedValueOnce({
1616
- data: [{ id: "cus_test_123" }],
1617
- });
1618
-
1619
- // First create a starter subscription
1620
1639
  await client.subscription.upgrade({
1621
1640
  plan: "starter",
1622
- fetchOptions: { headers },
1641
+ seats: 3,
1642
+ fetchOptions: {
1643
+ headers,
1644
+ },
1623
1645
  });
1624
1646
 
1625
- // Simulate the subscription being active
1626
- const starterSub = await ctx.adapter.findOne<Subscription>({
1647
+ await ctx.adapter.update({
1627
1648
  model: "subscription",
1649
+ update: {
1650
+ status: "active",
1651
+ seats: 3,
1652
+ },
1628
1653
  where: [
1629
1654
  {
1630
1655
  field: "referenceId",
@@ -1633,50 +1658,177 @@ describe("stripe", () => {
1633
1658
  ],
1634
1659
  });
1635
1660
 
1636
- await ctx.adapter.update({
1637
- model: "subscription",
1638
- update: {
1639
- status: "active",
1640
- stripeSubscriptionId: "sub_active_test_123",
1641
- stripeCustomerId: "cus_mock123", // Use the same customer ID as the mock
1642
- },
1643
- where: [
1644
- {
1645
- field: "id",
1646
- value: starterSub!.id,
1661
+ const upgradeRes = await client.subscription.upgrade({
1662
+ plan: "starter",
1663
+ seats: 3,
1664
+ fetchOptions: {
1665
+ headers,
1666
+ },
1667
+ });
1668
+
1669
+ expect(upgradeRes.error).toBeDefined();
1670
+ expect(upgradeRes.error?.message).toContain("already subscribed");
1671
+ });
1672
+
1673
+ it("should only call Stripe customers.create once for signup and upgrade", async () => {
1674
+ const { client, sessionSetter } = await getTestInstance(
1675
+ {
1676
+ database: memory,
1677
+ plugins: [stripe(stripeOptions)],
1678
+ },
1679
+ {
1680
+ disableTestUser: true,
1681
+ clientOptions: {
1682
+ plugins: [stripeClient({ subscription: true })],
1647
1683
  },
1648
- ],
1684
+ },
1685
+ );
1686
+
1687
+ await client.signUp.email(
1688
+ { ...testUser, email: "single-create@email.com" },
1689
+ { throw: true },
1690
+ );
1691
+
1692
+ const headers = new Headers();
1693
+ await client.signIn.email(
1694
+ { ...testUser, email: "single-create@email.com" },
1695
+ {
1696
+ throw: true,
1697
+ onSuccess: sessionSetter(headers),
1698
+ },
1699
+ );
1700
+
1701
+ await client.subscription.upgrade({
1702
+ plan: "starter",
1703
+ fetchOptions: { headers },
1649
1704
  });
1650
1705
 
1651
- // Also update the user with the Stripe customer ID
1652
- await ctx.adapter.update({
1653
- model: "user",
1654
- update: {
1655
- stripeCustomerId: "cus_mock123",
1706
+ expect(mockStripe.customers.create).toHaveBeenCalledTimes(1);
1707
+ });
1708
+
1709
+ it("should create billing portal session", async () => {
1710
+ const { client, sessionSetter } = await getTestInstance(
1711
+ {
1712
+ database: memory,
1713
+ plugins: [stripe(stripeOptions)],
1656
1714
  },
1657
- where: [
1658
- {
1659
- field: "id",
1660
- value: userRes.user.id,
1715
+ {
1716
+ disableTestUser: true,
1717
+ clientOptions: {
1718
+ plugins: [stripeClient({ subscription: true })],
1661
1719
  },
1662
- ],
1720
+ },
1721
+ );
1722
+
1723
+ await client.signUp.email(
1724
+ {
1725
+ ...testUser,
1726
+ email: "billing-portal@email.com",
1727
+ },
1728
+ {
1729
+ throw: true,
1730
+ },
1731
+ );
1732
+
1733
+ const headers = new Headers();
1734
+ await client.signIn.email(
1735
+ {
1736
+ ...testUser,
1737
+ email: "billing-portal@email.com",
1738
+ },
1739
+ {
1740
+ throw: true,
1741
+ onSuccess: sessionSetter(headers),
1742
+ },
1743
+ );
1744
+ const billingPortalRes = await client.subscription.billingPortal({
1745
+ returnUrl: "/dashboard",
1746
+ fetchOptions: {
1747
+ headers,
1748
+ },
1749
+ });
1750
+ expect(billingPortalRes.data?.url).toBe("https://billing.stripe.com/mock");
1751
+ expect(billingPortalRes.data?.redirect).toBe(true);
1752
+ expect(mockStripe.billingPortal.sessions.create).toHaveBeenCalledWith({
1753
+ customer: expect.any(String),
1754
+ return_url: "http://localhost:3000/dashboard",
1663
1755
  });
1756
+ });
1664
1757
 
1665
- // Mock Stripe subscriptions.list to return the active subscription
1666
- mockStripe.subscriptions.list.mockResolvedValueOnce({
1758
+ it("should not update personal subscription when upgrading with an org referenceId", async () => {
1759
+ /* cspell:disable-next-line */
1760
+ const orgId = "org_b67GF32Cljh7u588AuEblmLVobclDRcP";
1761
+
1762
+ const testOptions = {
1763
+ ...stripeOptions,
1764
+ stripeClient: _stripe,
1765
+ subscription: {
1766
+ ...stripeOptions.subscription,
1767
+ authorizeReference: async () => true,
1768
+ },
1769
+ } as unknown as StripeOptions;
1770
+
1771
+ const {
1772
+ auth: testAuth,
1773
+ client: testClient,
1774
+ sessionSetter: testSessionSetter,
1775
+ } = await getTestInstance(
1776
+ {
1777
+ database: memory,
1778
+ plugins: [stripe(testOptions)],
1779
+ },
1780
+ {
1781
+ disableTestUser: true,
1782
+ clientOptions: {
1783
+ plugins: [stripeClient({ subscription: true })],
1784
+ },
1785
+ },
1786
+ );
1787
+ const testCtx = await testAuth.$context;
1788
+
1789
+ // Sign up and sign in the user
1790
+ const userRes = await testClient.signUp.email(
1791
+ { ...testUser, email: "org-ref@email.com" },
1792
+ { throw: true },
1793
+ );
1794
+ const headers = new Headers();
1795
+ await testClient.signIn.email(
1796
+ { ...testUser, email: "org-ref@email.com" },
1797
+ { throw: true, onSuccess: testSessionSetter(headers) },
1798
+ );
1799
+
1800
+ // Create a personal subscription (referenceId = user id)
1801
+ await testClient.subscription.upgrade({
1802
+ plan: "starter",
1803
+ fetchOptions: { headers },
1804
+ });
1805
+
1806
+ const personalSub = await testCtx.adapter.findOne<Subscription>({
1807
+ model: "subscription",
1808
+ where: [{ field: "referenceId", value: userRes.user.id }],
1809
+ });
1810
+ expect(personalSub).toBeTruthy();
1811
+
1812
+ await testCtx.adapter.update({
1813
+ model: "subscription",
1814
+ update: {
1815
+ status: "active",
1816
+ stripeSubscriptionId: "sub_personal_active_123",
1817
+ },
1818
+ where: [{ field: "id", value: personalSub!.id }],
1819
+ });
1820
+
1821
+ mockStripe.subscriptions.list.mockResolvedValue({
1667
1822
  data: [
1668
1823
  {
1669
- id: "sub_active_test_123",
1824
+ id: "sub_personal_active_123",
1670
1825
  status: "active",
1671
1826
  items: {
1672
1827
  data: [
1673
1828
  {
1674
- id: "si_test_123",
1829
+ id: "si_1",
1675
1830
  price: { id: process.env.STRIPE_PRICE_ID_1 },
1676
1831
  quantity: 1,
1677
- current_period_start: Math.floor(Date.now() / 1000),
1678
- current_period_end:
1679
- Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1680
1832
  },
1681
1833
  ],
1682
1834
  },
@@ -1684,50 +1836,34 @@ describe("stripe", () => {
1684
1836
  ],
1685
1837
  });
1686
1838
 
1687
- // Clear mock calls before the upgrade
1688
- mockStripe.checkout.sessions.create.mockClear();
1689
- mockStripe.billingPortal.sessions.create.mockClear();
1690
-
1691
- // Now upgrade to premium plan - should use billing portal to update existing subscription
1692
- const upgradeRes = await client.subscription.upgrade({
1693
- plan: "premium",
1839
+ // Attempt to upgrade using an org referenceId
1840
+ const upgradeRes = await testClient.subscription.upgrade({
1841
+ plan: "starter",
1842
+ referenceId: orgId,
1694
1843
  fetchOptions: { headers },
1695
1844
  });
1845
+ // It should NOT go through billing portal (which would update the personal sub)
1846
+ expect(mockStripe.billingPortal.sessions.create).not.toHaveBeenCalled();
1847
+ expect(upgradeRes.data?.url).toBeDefined();
1696
1848
 
1697
- // Verify that billing portal was called (indicating update, not new subscription)
1698
- expect(mockStripe.billingPortal.sessions.create).toHaveBeenCalledWith(
1699
- expect.objectContaining({
1700
- customer: "cus_mock123",
1701
- flow_data: expect.objectContaining({
1702
- type: "subscription_update_confirm",
1703
- subscription_update_confirm: expect.objectContaining({
1704
- subscription: "sub_active_test_123",
1705
- }),
1706
- }),
1707
- }),
1708
- );
1709
-
1710
- // Should not create a new checkout session
1711
- expect(mockStripe.checkout.sessions.create).not.toHaveBeenCalled();
1712
-
1713
- // Verify the response has a redirect URL
1714
- expect(upgradeRes.data?.url).toBe("https://billing.stripe.com/mock");
1715
- expect(upgradeRes.data?.redirect).toBe(true);
1849
+ const orgSub = await testCtx.adapter.findOne<Subscription>({
1850
+ model: "subscription",
1851
+ where: [{ field: "referenceId", value: orgId }],
1852
+ });
1853
+ expect(orgSub).toMatchObject({
1854
+ referenceId: orgId,
1855
+ status: "incomplete",
1856
+ plan: "starter",
1857
+ });
1716
1858
 
1717
- // Verify no new subscription was created in the database
1718
- const allSubs = await ctx.adapter.findMany<Subscription>({
1859
+ const personalAfter = await testCtx.adapter.findOne<Subscription>({
1719
1860
  model: "subscription",
1720
- where: [
1721
- {
1722
- field: "referenceId",
1723
- value: userRes.user.id,
1724
- },
1725
- ],
1861
+ where: [{ field: "id", value: personalSub!.id }],
1726
1862
  });
1727
- expect(allSubs).toHaveLength(1); // Should still have only one subscription
1863
+ expect(personalAfter?.status).toBe("active");
1728
1864
  });
1729
1865
 
1730
- it("should prevent multiple free trials across different plans", async () => {
1866
+ it("should prevent multiple free trials for the same user", async () => {
1731
1867
  const { client, auth, sessionSetter } = await getTestInstance(
1732
1868
  {
1733
1869
  database: memory,
@@ -1744,20 +1880,20 @@ describe("stripe", () => {
1744
1880
 
1745
1881
  // Create a user
1746
1882
  const userRes = await client.signUp.email(
1747
- { ...testUser, email: "cross-plan-trial@email.com" },
1883
+ { ...testUser, email: "trial-prevention@email.com" },
1748
1884
  { throw: true },
1749
1885
  );
1750
1886
 
1751
1887
  const headers = new Headers();
1752
1888
  await client.signIn.email(
1753
- { ...testUser, email: "cross-plan-trial@email.com" },
1889
+ { ...testUser, email: "trial-prevention@email.com" },
1754
1890
  {
1755
1891
  throw: true,
1756
1892
  onSuccess: sessionSetter(headers),
1757
1893
  },
1758
1894
  );
1759
1895
 
1760
- // First subscription with trial on starter plan
1896
+ // First subscription with trial
1761
1897
  const firstUpgradeRes = await client.subscription.upgrade({
1762
1898
  plan: "starter",
1763
1899
  fetchOptions: { headers },
@@ -1771,7 +1907,7 @@ describe("stripe", () => {
1771
1907
  update: {
1772
1908
  status: "trialing",
1773
1909
  trialStart: new Date(),
1774
- trialEnd: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
1910
+ trialEnd: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
1775
1911
  },
1776
1912
  where: [
1777
1913
  {
@@ -1795,15 +1931,17 @@ describe("stripe", () => {
1795
1931
  ],
1796
1932
  });
1797
1933
 
1798
- // Try to subscribe to a different plan - should NOT get a trial
1934
+ // Try to subscribe again - should NOT get a trial
1799
1935
  const secondUpgradeRes = await client.subscription.upgrade({
1800
- plan: "premium",
1936
+ plan: "starter",
1801
1937
  fetchOptions: { headers },
1802
1938
  });
1803
1939
 
1804
1940
  expect(secondUpgradeRes.data?.url).toBeDefined();
1805
1941
 
1806
- // Verify that the user has trial history from the first plan
1942
+ // Verify that the checkout session was created without trial_period_days
1943
+ // We can't directly test the Stripe session, but we can verify the logic
1944
+ // by checking that the user has trial history
1807
1945
  const subscriptions = (await ctx.adapter.findMany({
1808
1946
  model: "subscription",
1809
1947
  where: [
@@ -1814,31 +1952,32 @@ describe("stripe", () => {
1814
1952
  ],
1815
1953
  })) as Subscription[];
1816
1954
 
1817
- // Should have at least 1 subscription (the starter with trial data)
1818
- expect(subscriptions.length).toBeGreaterThanOrEqual(1);
1955
+ // Should have 2 subscriptions (first canceled, second new)
1956
+ expect(subscriptions).toHaveLength(2);
1819
1957
 
1820
- // The starter subscription should have trial data
1821
- const starterSub = subscriptions.find(
1822
- (s: Subscription) => s.plan === "starter",
1823
- ) as Subscription | undefined;
1824
- expect(starterSub?.trialStart).toBeDefined();
1825
- expect(starterSub?.trialEnd).toBeDefined();
1826
-
1827
- // Verify that the trial eligibility logic is working by checking
1828
- // that the user has ever had a trial (which should prevent future trials)
1829
- const hasEverTrialed = subscriptions.some((s: Subscription) => {
1830
- const hadTrial =
1831
- !!(s.trialStart || s.trialEnd) || s.status === "trialing";
1832
- return hadTrial;
1833
- });
1834
- expect(hasEverTrialed).toBe(true);
1958
+ // At least one should have trial data
1959
+ const hasTrialData = subscriptions.some(
1960
+ (s: Subscription) => s.trialStart || s.trialEnd,
1961
+ );
1962
+ expect(hasTrialData).toBe(true);
1835
1963
  });
1836
1964
 
1837
- it("should update stripe customer email when user email changes", async () => {
1838
- const { client, auth } = await getTestInstance(
1965
+ it("should prevent trial abuse when processing incomplete subscription with past trial history", async () => {
1966
+ const { client, auth, sessionSetter } = await getTestInstance(
1839
1967
  {
1840
1968
  database: memory,
1841
- plugins: [stripe(stripeOptions)],
1969
+ plugins: [
1970
+ stripe({
1971
+ ...stripeOptions,
1972
+ subscription: {
1973
+ ...stripeOptions.subscription,
1974
+ plans: stripeOptions.subscription.plans.map((plan) => ({
1975
+ ...plan,
1976
+ freeTrial: { days: 7 },
1977
+ })),
1978
+ },
1979
+ }),
1980
+ ],
1842
1981
  },
1843
1982
  {
1844
1983
  disableTestUser: true,
@@ -1849,198 +1988,396 @@ describe("stripe", () => {
1849
1988
  );
1850
1989
  const ctx = await auth.$context;
1851
1990
 
1852
- // Setup mock for customer retrieve and update
1853
- mockStripe.customers.retrieve = vi.fn().mockResolvedValue({
1854
- id: "cus_mock123",
1855
- email: "test@email.com",
1856
- deleted: false,
1991
+ const userRes = await client.signUp.email(
1992
+ { ...testUser, email: "trial-findone-test@email.com" },
1993
+ { throw: true },
1994
+ );
1995
+
1996
+ const headers = new Headers();
1997
+ await client.signIn.email(
1998
+ { ...testUser, email: "trial-findone-test@email.com" },
1999
+ { throw: true, onSuccess: sessionSetter(headers) },
2000
+ );
2001
+
2002
+ // Create a canceled subscription with trial history first
2003
+ await ctx.adapter.create({
2004
+ model: "subscription",
2005
+ data: {
2006
+ referenceId: userRes.user.id,
2007
+ stripeCustomerId: "cus_old_customer",
2008
+ status: "canceled",
2009
+ plan: "starter",
2010
+ stripeSubscriptionId: "sub_canceled_with_trial",
2011
+ trialStart: new Date(Date.now() - 1000000),
2012
+ trialEnd: new Date(Date.now() - 500000),
2013
+ },
1857
2014
  });
1858
- mockStripe.customers.update = vi.fn().mockResolvedValue({
1859
- id: "cus_mock123",
1860
- email: "newemail@example.com",
2015
+
2016
+ // Create an new incomplete subscription (without trial info)
2017
+ const incompleteSubId = "sub_incomplete_new";
2018
+ await ctx.adapter.create({
2019
+ model: "subscription",
2020
+ data: {
2021
+ referenceId: userRes.user.id,
2022
+ stripeCustomerId: "cus_old_customer",
2023
+ status: "incomplete",
2024
+ plan: "premium",
2025
+ stripeSubscriptionId: incompleteSubId,
2026
+ },
1861
2027
  });
1862
2028
 
1863
- // Sign up a user
1864
- const userRes = await client.signUp.email(testUser, {
1865
- throw: true,
2029
+ // When upgrading with a specific subscriptionId pointing to the incomplete one,
2030
+ // the system should still check ALL subscriptions for trial history
2031
+ const upgradeRes = await client.subscription.upgrade({
2032
+ plan: "premium",
2033
+ subscriptionId: incompleteSubId,
2034
+ fetchOptions: { headers },
1866
2035
  });
1867
2036
 
1868
- expect(userRes.user).toBeDefined();
2037
+ expect(upgradeRes.data?.url).toBeDefined();
1869
2038
 
1870
- // Verify customer was created during signup
1871
- expect(mockStripe.customers.create).toHaveBeenCalledWith({
1872
- email: testUser.email,
1873
- name: testUser.name,
1874
- metadata: {
1875
- userId: userRes.user.id,
1876
- },
1877
- });
2039
+ // Verify that NO trial was granted despite processing the incomplete subscription
2040
+ const callArgs = mockStripe.checkout.sessions.create.mock.lastCall?.[0];
2041
+ expect(callArgs?.subscription_data?.trial_period_days).toBeUndefined();
2042
+ });
1878
2043
 
1879
- // Clear mocks to track the update
2044
+ it("should upgrade existing subscription instead of creating new one", async () => {
2045
+ // Reset mocks for this test
1880
2046
  vi.clearAllMocks();
1881
2047
 
1882
- // Re-setup the retrieve mock for the update flow
1883
- mockStripe.customers.retrieve = vi.fn().mockResolvedValue({
1884
- id: "cus_mock123",
1885
- email: "test@email.com",
1886
- deleted: false,
1887
- });
1888
- mockStripe.customers.update = vi.fn().mockResolvedValue({
1889
- id: "cus_mock123",
1890
- email: "newemail@example.com",
1891
- });
1892
-
1893
- // Update the user's email using internal adapter (which triggers hooks)
1894
- await runWithEndpointContext(
2048
+ const { client, auth, sessionSetter } = await getTestInstance(
1895
2049
  {
1896
- context: ctx,
2050
+ database: memory,
2051
+ plugins: [stripe(stripeOptions)],
2052
+ },
2053
+ {
2054
+ disableTestUser: true,
2055
+ clientOptions: {
2056
+ plugins: [stripeClient({ subscription: true })],
2057
+ },
1897
2058
  },
1898
- () =>
1899
- ctx.internalAdapter.updateUserByEmail(testUser.email, {
1900
- email: "newemail@example.com",
1901
- }),
1902
2059
  );
2060
+ const ctx = await auth.$context;
1903
2061
 
1904
- // Verify that Stripe customer.retrieve was called
1905
- expect(mockStripe.customers.retrieve).toHaveBeenCalledWith("cus_mock123");
2062
+ // Create a user
2063
+ const userRes = await client.signUp.email(
2064
+ { ...testUser, email: "upgrade-existing@email.com" },
2065
+ { throw: true },
2066
+ );
1906
2067
 
1907
- // Verify that Stripe customer.update was called with the new email
1908
- expect(mockStripe.customers.update).toHaveBeenCalledWith("cus_mock123", {
1909
- email: "newemail@example.com",
1910
- });
1911
- });
2068
+ const headers = new Headers();
2069
+ await client.signIn.email(
2070
+ { ...testUser, email: "upgrade-existing@email.com" },
2071
+ {
2072
+ throw: true,
2073
+ onSuccess: sessionSetter(headers),
2074
+ },
2075
+ );
1912
2076
 
1913
- describe("getCustomerCreateParams", () => {
1914
- it("should call getCustomerCreateParams and merge with default params", async () => {
1915
- const getCustomerCreateParamsMock = vi
1916
- .fn()
1917
- .mockResolvedValue({ metadata: { customField: "customValue" } });
2077
+ // Mock customers.list to find existing customer
2078
+ mockStripe.customers.list.mockResolvedValueOnce({
2079
+ data: [{ id: "cus_test_123" }],
2080
+ });
1918
2081
 
1919
- const testOptions = {
1920
- ...stripeOptions,
1921
- createCustomerOnSignUp: true,
1922
- getCustomerCreateParams: getCustomerCreateParamsMock,
1923
- } satisfies StripeOptions;
2082
+ // First create a starter subscription
2083
+ await client.subscription.upgrade({
2084
+ plan: "starter",
2085
+ fetchOptions: { headers },
2086
+ });
1924
2087
 
1925
- const { client: testClient } = await getTestInstance(
2088
+ // Simulate the subscription being active
2089
+ const starterSub = await ctx.adapter.findOne<Subscription>({
2090
+ model: "subscription",
2091
+ where: [
1926
2092
  {
1927
- database: memory,
1928
- plugins: [stripe(testOptions)],
2093
+ field: "referenceId",
2094
+ value: userRes.user.id,
1929
2095
  },
2096
+ ],
2097
+ });
2098
+
2099
+ await ctx.adapter.update({
2100
+ model: "subscription",
2101
+ update: {
2102
+ status: "active",
2103
+ stripeSubscriptionId: "sub_active_test_123",
2104
+ stripeCustomerId: "cus_mock123", // Use the same customer ID as the mock
2105
+ },
2106
+ where: [
1930
2107
  {
1931
- disableTestUser: true,
1932
- clientOptions: {
1933
- plugins: [stripeClient({ subscription: true })],
1934
- },
2108
+ field: "id",
2109
+ value: starterSub!.id,
1935
2110
  },
1936
- );
2111
+ ],
2112
+ });
1937
2113
 
1938
- // Sign up a user
1939
- const userRes = await testClient.signUp.email(
2114
+ // Also update the user with the Stripe customer ID
2115
+ await ctx.adapter.update({
2116
+ model: "user",
2117
+ update: {
2118
+ stripeCustomerId: "cus_mock123",
2119
+ },
2120
+ where: [
1940
2121
  {
1941
- email: "custom-params@email.com",
1942
- password: "password",
1943
- name: "Custom User",
2122
+ field: "id",
2123
+ value: userRes.user.id,
1944
2124
  },
2125
+ ],
2126
+ });
2127
+
2128
+ // Mock Stripe subscriptions.list to return the active subscription
2129
+ mockStripe.subscriptions.list.mockResolvedValueOnce({
2130
+ data: [
1945
2131
  {
1946
- throw: true,
2132
+ id: "sub_active_test_123",
2133
+ status: "active",
2134
+ items: {
2135
+ data: [
2136
+ {
2137
+ id: "si_test_123",
2138
+ price: { id: process.env.STRIPE_PRICE_ID_1 },
2139
+ quantity: 1,
2140
+ current_period_start: Math.floor(Date.now() / 1000),
2141
+ current_period_end:
2142
+ Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
2143
+ },
2144
+ ],
2145
+ },
1947
2146
  },
1948
- );
2147
+ ],
2148
+ });
1949
2149
 
1950
- // Verify getCustomerCreateParams was called
1951
- expect(getCustomerCreateParamsMock).toHaveBeenCalledWith(
1952
- expect.objectContaining({
1953
- id: userRes.user.id,
1954
- email: "custom-params@email.com",
1955
- name: "Custom User",
1956
- }),
1957
- expect.objectContaining({
1958
- context: expect.any(Object),
1959
- }),
1960
- );
2150
+ // Clear mock calls before the upgrade
2151
+ mockStripe.checkout.sessions.create.mockClear();
2152
+ mockStripe.billingPortal.sessions.create.mockClear();
1961
2153
 
1962
- // Verify customer was created with merged params
1963
- expect(mockStripe.customers.create).toHaveBeenCalledWith(
1964
- expect.objectContaining({
1965
- email: "custom-params@email.com",
1966
- name: "Custom User",
1967
- metadata: expect.objectContaining({
1968
- userId: userRes.user.id,
1969
- customField: "customValue",
2154
+ // Now upgrade to premium plan - should use billing portal to update existing subscription
2155
+ const upgradeRes = await client.subscription.upgrade({
2156
+ plan: "premium",
2157
+ fetchOptions: { headers },
2158
+ });
2159
+
2160
+ // Verify that billing portal was called (indicating update, not new subscription)
2161
+ expect(mockStripe.billingPortal.sessions.create).toHaveBeenCalledWith(
2162
+ expect.objectContaining({
2163
+ customer: "cus_mock123",
2164
+ flow_data: expect.objectContaining({
2165
+ type: "subscription_update_confirm",
2166
+ subscription_update_confirm: expect.objectContaining({
2167
+ subscription: "sub_active_test_123",
1970
2168
  }),
1971
2169
  }),
1972
- );
1973
- });
2170
+ }),
2171
+ );
1974
2172
 
1975
- it("should use getCustomerCreateParams to add custom address", async () => {
1976
- const getCustomerCreateParamsMock = vi.fn().mockResolvedValue({
1977
- address: {
1978
- line1: "123 Main St",
1979
- city: "San Francisco",
1980
- state: "CA",
1981
- postal_code: "94111",
1982
- country: "US",
1983
- },
1984
- });
2173
+ // Should not create a new checkout session
2174
+ expect(mockStripe.checkout.sessions.create).not.toHaveBeenCalled();
1985
2175
 
1986
- const testOptions = {
1987
- ...stripeOptions,
1988
- createCustomerOnSignUp: true,
1989
- getCustomerCreateParams: getCustomerCreateParamsMock,
1990
- } satisfies StripeOptions;
2176
+ // Verify the response has a redirect URL
2177
+ expect(upgradeRes.data?.url).toBe("https://billing.stripe.com/mock");
2178
+ expect(upgradeRes.data?.redirect).toBe(true);
1991
2179
 
1992
- const { client: testAuthClient } = await getTestInstance(
2180
+ // Verify no new subscription was created in the database
2181
+ const allSubs = await ctx.adapter.findMany<Subscription>({
2182
+ model: "subscription",
2183
+ where: [
1993
2184
  {
1994
- database: memory,
1995
- plugins: [stripe(testOptions)],
2185
+ field: "referenceId",
2186
+ value: userRes.user.id,
2187
+ },
2188
+ ],
2189
+ });
2190
+ expect(allSubs).toHaveLength(1); // Should still have only one subscription
2191
+ });
2192
+
2193
+ it("should prevent multiple free trials across different plans", async () => {
2194
+ const { client, auth, sessionSetter } = await getTestInstance(
2195
+ {
2196
+ database: memory,
2197
+ plugins: [stripe(stripeOptions)],
2198
+ },
2199
+ {
2200
+ disableTestUser: true,
2201
+ clientOptions: {
2202
+ plugins: [stripeClient({ subscription: true })],
1996
2203
  },
2204
+ },
2205
+ );
2206
+ const ctx = await auth.$context;
2207
+
2208
+ // Create a user
2209
+ const userRes = await client.signUp.email(
2210
+ { ...testUser, email: "cross-plan-trial@email.com" },
2211
+ { throw: true },
2212
+ );
2213
+
2214
+ const headers = new Headers();
2215
+ await client.signIn.email(
2216
+ { ...testUser, email: "cross-plan-trial@email.com" },
2217
+ {
2218
+ throw: true,
2219
+ onSuccess: sessionSetter(headers),
2220
+ },
2221
+ );
2222
+
2223
+ // First subscription with trial on starter plan
2224
+ const firstUpgradeRes = await client.subscription.upgrade({
2225
+ plan: "starter",
2226
+ fetchOptions: { headers },
2227
+ });
2228
+
2229
+ expect(firstUpgradeRes.data?.url).toBeDefined();
2230
+
2231
+ // Simulate the subscription being created with trial data
2232
+ await ctx.adapter.update({
2233
+ model: "subscription",
2234
+ update: {
2235
+ status: "trialing",
2236
+ trialStart: new Date(),
2237
+ trialEnd: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
2238
+ },
2239
+ where: [
1997
2240
  {
1998
- disableTestUser: true,
1999
- clientOptions: {
2000
- plugins: [stripeClient({ subscription: true })],
2001
- },
2241
+ field: "referenceId",
2242
+ value: userRes.user.id,
2002
2243
  },
2003
- );
2244
+ ],
2245
+ });
2004
2246
 
2005
- // Sign up a user
2006
- await testAuthClient.signUp.email(
2247
+ // Cancel the subscription
2248
+ await ctx.adapter.update({
2249
+ model: "subscription",
2250
+ update: {
2251
+ status: "canceled",
2252
+ },
2253
+ where: [
2007
2254
  {
2008
- email: "address-user@email.com",
2009
- password: "password",
2010
- name: "Address User",
2255
+ field: "referenceId",
2256
+ value: userRes.user.id,
2011
2257
  },
2258
+ ],
2259
+ });
2260
+
2261
+ // Try to subscribe to a different plan - should NOT get a trial
2262
+ const secondUpgradeRes = await client.subscription.upgrade({
2263
+ plan: "premium",
2264
+ fetchOptions: { headers },
2265
+ });
2266
+
2267
+ expect(secondUpgradeRes.data?.url).toBeDefined();
2268
+
2269
+ // Verify that the user has trial history from the first plan
2270
+ const subscriptions = (await ctx.adapter.findMany({
2271
+ model: "subscription",
2272
+ where: [
2012
2273
  {
2013
- throw: true,
2274
+ field: "referenceId",
2275
+ value: userRes.user.id,
2014
2276
  },
2015
- );
2277
+ ],
2278
+ })) as Subscription[];
2016
2279
 
2017
- // Verify customer was created with address
2018
- expect(mockStripe.customers.create).toHaveBeenCalledWith(
2019
- expect.objectContaining({
2020
- email: "address-user@email.com",
2021
- name: "Address User",
2022
- address: {
2023
- line1: "123 Main St",
2024
- city: "San Francisco",
2025
- state: "CA",
2026
- postal_code: "94111",
2027
- country: "US",
2028
- },
2029
- metadata: expect.objectContaining({
2030
- userId: expect.any(String),
2031
- }),
2032
- }),
2033
- );
2280
+ // Should have at least 1 subscription (the starter with trial data)
2281
+ expect(subscriptions.length).toBeGreaterThanOrEqual(1);
2282
+
2283
+ // The starter subscription should have trial data
2284
+ const starterSub = subscriptions.find(
2285
+ (s: Subscription) => s.plan === "starter",
2286
+ ) as Subscription | undefined;
2287
+ expect(starterSub?.trialStart).toBeDefined();
2288
+ expect(starterSub?.trialEnd).toBeDefined();
2289
+
2290
+ // Verify that the trial eligibility logic is working by checking
2291
+ // that the user has ever had a trial (which should prevent future trials)
2292
+ const hasEverTrialed = subscriptions.some((s: Subscription) => {
2293
+ const hadTrial =
2294
+ !!(s.trialStart || s.trialEnd) || s.status === "trialing";
2295
+ return hadTrial;
2034
2296
  });
2297
+ expect(hasEverTrialed).toBe(true);
2298
+ });
2035
2299
 
2036
- it("should properly merge nested objects using defu", async () => {
2037
- const getCustomerCreateParamsMock = vi.fn().mockResolvedValue({
2038
- metadata: {
2039
- customField: "customValue",
2040
- anotherField: "anotherValue",
2300
+ it("should update stripe customer email when user email changes", async () => {
2301
+ const { client, auth } = await getTestInstance(
2302
+ {
2303
+ database: memory,
2304
+ plugins: [stripe(stripeOptions)],
2305
+ },
2306
+ {
2307
+ disableTestUser: true,
2308
+ clientOptions: {
2309
+ plugins: [stripeClient({ subscription: true })],
2041
2310
  },
2042
- phone: "+1234567890",
2043
- });
2311
+ },
2312
+ );
2313
+ const ctx = await auth.$context;
2314
+
2315
+ // Setup mock for customer retrieve and update
2316
+ mockStripe.customers.retrieve = vi.fn().mockResolvedValue({
2317
+ id: "cus_mock123",
2318
+ email: "test@email.com",
2319
+ deleted: false,
2320
+ });
2321
+ mockStripe.customers.update = vi.fn().mockResolvedValue({
2322
+ id: "cus_mock123",
2323
+ email: "newemail@example.com",
2324
+ });
2325
+
2326
+ // Sign up a user
2327
+ const userRes = await client.signUp.email(testUser, {
2328
+ throw: true,
2329
+ });
2330
+
2331
+ expect(userRes.user).toBeDefined();
2332
+
2333
+ // Verify customer was created during signup
2334
+ expect(mockStripe.customers.create).toHaveBeenCalledWith({
2335
+ email: testUser.email,
2336
+ name: testUser.name,
2337
+ metadata: {
2338
+ userId: userRes.user.id,
2339
+ },
2340
+ });
2341
+
2342
+ // Clear mocks to track the update
2343
+ vi.clearAllMocks();
2344
+
2345
+ // Re-setup the retrieve mock for the update flow
2346
+ mockStripe.customers.retrieve = vi.fn().mockResolvedValue({
2347
+ id: "cus_mock123",
2348
+ email: "test@email.com",
2349
+ deleted: false,
2350
+ });
2351
+ mockStripe.customers.update = vi.fn().mockResolvedValue({
2352
+ id: "cus_mock123",
2353
+ email: "newemail@example.com",
2354
+ });
2355
+
2356
+ // Update the user's email using internal adapter (which triggers hooks)
2357
+ await runWithEndpointContext(
2358
+ {
2359
+ context: ctx,
2360
+ },
2361
+ () =>
2362
+ ctx.internalAdapter.updateUserByEmail(testUser.email, {
2363
+ email: "newemail@example.com",
2364
+ }),
2365
+ );
2366
+
2367
+ // Verify that Stripe customer.retrieve was called
2368
+ expect(mockStripe.customers.retrieve).toHaveBeenCalledWith("cus_mock123");
2369
+
2370
+ // Verify that Stripe customer.update was called with the new email
2371
+ expect(mockStripe.customers.update).toHaveBeenCalledWith("cus_mock123", {
2372
+ email: "newemail@example.com",
2373
+ });
2374
+ });
2375
+
2376
+ describe("getCustomerCreateParams", () => {
2377
+ it("should call getCustomerCreateParams and merge with default params", async () => {
2378
+ const getCustomerCreateParamsMock = vi
2379
+ .fn()
2380
+ .mockResolvedValue({ metadata: { customField: "customValue" } });
2044
2381
 
2045
2382
  const testOptions = {
2046
2383
  ...stripeOptions,
@@ -2048,7 +2385,7 @@ describe("stripe", () => {
2048
2385
  getCustomerCreateParams: getCustomerCreateParamsMock,
2049
2386
  } satisfies StripeOptions;
2050
2387
 
2051
- const { client: testAuthClient } = await getTestInstance(
2388
+ const { client: testClient } = await getTestInstance(
2052
2389
  {
2053
2390
  database: memory,
2054
2391
  plugins: [stripe(testOptions)],
@@ -2062,45 +2399,779 @@ describe("stripe", () => {
2062
2399
  );
2063
2400
 
2064
2401
  // Sign up a user
2065
- const userRes = await testAuthClient.signUp.email(
2402
+ const userRes = await testClient.signUp.email(
2066
2403
  {
2067
- email: "merge-test@email.com",
2404
+ email: "custom-params@email.com",
2068
2405
  password: "password",
2069
- name: "Merge User",
2406
+ name: "Custom User",
2070
2407
  },
2071
2408
  {
2072
2409
  throw: true,
2073
2410
  },
2074
2411
  );
2075
2412
 
2076
- // Verify customer was created with properly merged params
2077
- // defu merges objects and preserves all fields
2078
- expect(mockStripe.customers.create).toHaveBeenCalledWith(
2413
+ // Verify getCustomerCreateParams was called
2414
+ expect(getCustomerCreateParamsMock).toHaveBeenCalledWith(
2079
2415
  expect.objectContaining({
2080
- email: "merge-test@email.com",
2081
- name: "Merge User",
2082
- phone: "+1234567890",
2416
+ id: userRes.user.id,
2417
+ email: "custom-params@email.com",
2418
+ name: "Custom User",
2419
+ }),
2420
+ expect.objectContaining({
2421
+ context: expect.any(Object),
2422
+ }),
2423
+ );
2424
+
2425
+ // Verify customer was created with merged params
2426
+ expect(mockStripe.customers.create).toHaveBeenCalledWith(
2427
+ expect.objectContaining({
2428
+ email: "custom-params@email.com",
2429
+ name: "Custom User",
2430
+ metadata: expect.objectContaining({
2431
+ userId: userRes.user.id,
2432
+ customField: "customValue",
2433
+ }),
2434
+ }),
2435
+ );
2436
+ });
2437
+
2438
+ it("should use getCustomerCreateParams to add custom address", async () => {
2439
+ const getCustomerCreateParamsMock = vi.fn().mockResolvedValue({
2440
+ address: {
2441
+ line1: "123 Main St",
2442
+ city: "San Francisco",
2443
+ state: "CA",
2444
+ postal_code: "94111",
2445
+ country: "US",
2446
+ },
2447
+ });
2448
+
2449
+ const testOptions = {
2450
+ ...stripeOptions,
2451
+ createCustomerOnSignUp: true,
2452
+ getCustomerCreateParams: getCustomerCreateParamsMock,
2453
+ } satisfies StripeOptions;
2454
+
2455
+ const { client: testAuthClient } = await getTestInstance(
2456
+ {
2457
+ database: memory,
2458
+ plugins: [stripe(testOptions)],
2459
+ },
2460
+ {
2461
+ disableTestUser: true,
2462
+ clientOptions: {
2463
+ plugins: [stripeClient({ subscription: true })],
2464
+ },
2465
+ },
2466
+ );
2467
+
2468
+ // Sign up a user
2469
+ await testAuthClient.signUp.email(
2470
+ {
2471
+ email: "address-user@email.com",
2472
+ password: "password",
2473
+ name: "Address User",
2474
+ },
2475
+ {
2476
+ throw: true,
2477
+ },
2478
+ );
2479
+
2480
+ // Verify customer was created with address
2481
+ expect(mockStripe.customers.create).toHaveBeenCalledWith(
2482
+ expect.objectContaining({
2483
+ email: "address-user@email.com",
2484
+ name: "Address User",
2485
+ address: {
2486
+ line1: "123 Main St",
2487
+ city: "San Francisco",
2488
+ state: "CA",
2489
+ postal_code: "94111",
2490
+ country: "US",
2491
+ },
2492
+ metadata: expect.objectContaining({
2493
+ userId: expect.any(String),
2494
+ }),
2495
+ }),
2496
+ );
2497
+ });
2498
+
2499
+ it("should properly merge nested objects using defu", async () => {
2500
+ const getCustomerCreateParamsMock = vi.fn().mockResolvedValue({
2501
+ metadata: {
2502
+ customField: "customValue",
2503
+ anotherField: "anotherValue",
2504
+ },
2505
+ phone: "+1234567890",
2506
+ });
2507
+
2508
+ const testOptions = {
2509
+ ...stripeOptions,
2510
+ createCustomerOnSignUp: true,
2511
+ getCustomerCreateParams: getCustomerCreateParamsMock,
2512
+ } satisfies StripeOptions;
2513
+
2514
+ const { client: testAuthClient } = await getTestInstance(
2515
+ {
2516
+ database: memory,
2517
+ plugins: [stripe(testOptions)],
2518
+ },
2519
+ {
2520
+ disableTestUser: true,
2521
+ clientOptions: {
2522
+ plugins: [stripeClient({ subscription: true })],
2523
+ },
2524
+ },
2525
+ );
2526
+
2527
+ // Sign up a user
2528
+ const userRes = await testAuthClient.signUp.email(
2529
+ {
2530
+ email: "merge-test@email.com",
2531
+ password: "password",
2532
+ name: "Merge User",
2533
+ },
2534
+ {
2535
+ throw: true,
2536
+ },
2537
+ );
2538
+
2539
+ // Verify customer was created with properly merged params
2540
+ // defu merges objects and preserves all fields
2541
+ expect(mockStripe.customers.create).toHaveBeenCalledWith(
2542
+ expect.objectContaining({
2543
+ email: "merge-test@email.com",
2544
+ name: "Merge User",
2545
+ phone: "+1234567890",
2083
2546
  metadata: {
2084
2547
  userId: userRes.user.id,
2085
2548
  customField: "customValue",
2086
2549
  anotherField: "anotherValue",
2087
2550
  },
2088
- }),
2089
- );
2090
- });
2551
+ }),
2552
+ );
2553
+ });
2554
+
2555
+ it("should work without getCustomerCreateParams", async () => {
2556
+ // This test ensures backward compatibility
2557
+ const testOptions = {
2558
+ ...stripeOptions,
2559
+ createCustomerOnSignUp: true,
2560
+ // No getCustomerCreateParams provided
2561
+ } satisfies StripeOptions;
2562
+
2563
+ const { client: testAuthClient } = await getTestInstance(
2564
+ {
2565
+ database: memory,
2566
+ plugins: [stripe(testOptions)],
2567
+ },
2568
+ {
2569
+ disableTestUser: true,
2570
+ clientOptions: {
2571
+ plugins: [stripeClient({ subscription: true })],
2572
+ },
2573
+ },
2574
+ );
2575
+
2576
+ // Sign up a user
2577
+ const userRes = await testAuthClient.signUp.email(
2578
+ {
2579
+ email: "no-custom-params@email.com",
2580
+ password: "password",
2581
+ name: "Default User",
2582
+ },
2583
+ {
2584
+ throw: true,
2585
+ },
2586
+ );
2587
+
2588
+ // Verify customer was created with default params only
2589
+ expect(mockStripe.customers.create).toHaveBeenCalledWith({
2590
+ email: "no-custom-params@email.com",
2591
+ name: "Default User",
2592
+ metadata: {
2593
+ userId: userRes.user.id,
2594
+ },
2595
+ });
2596
+ });
2597
+ });
2598
+
2599
+ describe("Webhook Error Handling (Stripe v19)", () => {
2600
+ it("should handle invalid webhook signature with constructEventAsync", async () => {
2601
+ const mockError = new Error("Invalid signature");
2602
+ const stripeWithError = {
2603
+ ...stripeOptions.stripeClient,
2604
+ webhooks: {
2605
+ constructEventAsync: vi.fn().mockRejectedValue(mockError),
2606
+ },
2607
+ };
2608
+
2609
+ const testOptions = {
2610
+ ...stripeOptions,
2611
+ stripeClient: stripeWithError as unknown as Stripe,
2612
+ stripeWebhookSecret: "test_secret",
2613
+ };
2614
+
2615
+ const { auth: testAuth } = await getTestInstance(
2616
+ {
2617
+ database: memory,
2618
+ plugins: [stripe(testOptions)],
2619
+ },
2620
+ {
2621
+ disableTestUser: true,
2622
+ },
2623
+ );
2624
+
2625
+ const mockRequest = new Request(
2626
+ "http://localhost:3000/api/auth/stripe/webhook",
2627
+ {
2628
+ method: "POST",
2629
+ headers: {
2630
+ "stripe-signature": "invalid_signature",
2631
+ },
2632
+ body: JSON.stringify({ type: "test.event" }),
2633
+ },
2634
+ );
2635
+
2636
+ const response = await testAuth.handler(mockRequest);
2637
+ expect(response.status).toBe(400);
2638
+ const data = await response.json();
2639
+ expect(data.message).toContain("Webhook Error");
2640
+ });
2641
+
2642
+ it("should reject webhook request without stripe-signature header", async () => {
2643
+ const { auth: testAuth } = await getTestInstance(
2644
+ {
2645
+ database: memory,
2646
+ plugins: [stripe(stripeOptions)],
2647
+ },
2648
+ {
2649
+ disableTestUser: true,
2650
+ },
2651
+ );
2652
+
2653
+ const mockRequest = new Request(
2654
+ "http://localhost:3000/api/auth/stripe/webhook",
2655
+ {
2656
+ method: "POST",
2657
+ headers: {
2658
+ "content-type": "application/json",
2659
+ },
2660
+ body: JSON.stringify({ type: "test.event" }),
2661
+ },
2662
+ );
2663
+
2664
+ const response = await testAuth.handler(mockRequest);
2665
+ expect(response.status).toBe(400);
2666
+ const data = await response.json();
2667
+ expect(data.message).toContain("Stripe webhook secret not found");
2668
+ });
2669
+
2670
+ it("should handle constructEventAsync returning null/undefined", async () => {
2671
+ const stripeWithNull = {
2672
+ ...stripeOptions.stripeClient,
2673
+ webhooks: {
2674
+ constructEventAsync: vi.fn().mockResolvedValue(null),
2675
+ },
2676
+ };
2677
+
2678
+ const testOptions = {
2679
+ ...stripeOptions,
2680
+ stripeClient: stripeWithNull as unknown as Stripe,
2681
+ stripeWebhookSecret: "test_secret",
2682
+ };
2683
+
2684
+ const { auth: testAuth } = await getTestInstance(
2685
+ {
2686
+ database: memory,
2687
+ plugins: [stripe(testOptions)],
2688
+ },
2689
+ {
2690
+ disableTestUser: true,
2691
+ },
2692
+ );
2693
+
2694
+ const mockRequest = new Request(
2695
+ "http://localhost:3000/api/auth/stripe/webhook",
2696
+ {
2697
+ method: "POST",
2698
+ headers: {
2699
+ "stripe-signature": "test_signature",
2700
+ },
2701
+ body: JSON.stringify({ type: "test.event" }),
2702
+ },
2703
+ );
2704
+
2705
+ const response = await testAuth.handler(mockRequest);
2706
+ expect(response.status).toBe(400);
2707
+ const data = await response.json();
2708
+ expect(data.message).toContain("Failed to construct event");
2709
+ });
2710
+
2711
+ it("should handle async errors in webhook event processing", async () => {
2712
+ const errorThrowingHandler = vi
2713
+ .fn()
2714
+ .mockRejectedValue(new Error("Event processing failed"));
2715
+
2716
+ const mockEvent = {
2717
+ type: "checkout.session.completed",
2718
+ data: {
2719
+ object: {
2720
+ mode: "subscription",
2721
+ subscription: "sub_123",
2722
+ metadata: {
2723
+ referenceId: "user_123",
2724
+ subscriptionId: "sub_123",
2725
+ },
2726
+ },
2727
+ },
2728
+ };
2729
+
2730
+ const stripeForTest = {
2731
+ ...stripeOptions.stripeClient,
2732
+ subscriptions: {
2733
+ retrieve: vi.fn().mockRejectedValue(new Error("Stripe API error")),
2734
+ },
2735
+ webhooks: {
2736
+ constructEventAsync: vi.fn().mockResolvedValue(mockEvent),
2737
+ },
2738
+ };
2739
+
2740
+ const testOptions = {
2741
+ ...stripeOptions,
2742
+ stripeClient: stripeForTest as unknown as Stripe,
2743
+ stripeWebhookSecret: "test_secret",
2744
+ subscription: {
2745
+ ...stripeOptions.subscription,
2746
+ onSubscriptionComplete: errorThrowingHandler,
2747
+ },
2748
+ };
2749
+
2750
+ const { auth: testAuth } = await getTestInstance(
2751
+ {
2752
+ database: memory,
2753
+ plugins: [stripe(testOptions as StripeOptions)],
2754
+ },
2755
+ {
2756
+ disableTestUser: true,
2757
+ },
2758
+ );
2759
+ const testCtx = await testAuth.$context;
2760
+
2761
+ await testCtx.adapter.create({
2762
+ model: "subscription",
2763
+ data: {
2764
+ referenceId: "user_123",
2765
+ stripeCustomerId: "cus_123",
2766
+ status: "incomplete",
2767
+ plan: "starter",
2768
+ id: "sub_123",
2769
+ },
2770
+ });
2771
+
2772
+ const mockRequest = new Request(
2773
+ "http://localhost:3000/api/auth/stripe/webhook",
2774
+ {
2775
+ method: "POST",
2776
+ headers: {
2777
+ "stripe-signature": "test_signature",
2778
+ },
2779
+ body: JSON.stringify(mockEvent),
2780
+ },
2781
+ );
2782
+
2783
+ const response = await testAuth.handler(mockRequest);
2784
+ // Errors inside event handlers are caught and logged but don't fail the webhook
2785
+ // This prevents Stripe from retrying and is the expected behavior
2786
+ expect(response.status).toBe(200);
2787
+ const data = await response.json();
2788
+ expect(data).toEqual({ success: true });
2789
+ // Verify the error was logged (via the stripeClient.subscriptions.retrieve rejection)
2790
+ expect(stripeForTest.subscriptions.retrieve).toHaveBeenCalled();
2791
+ });
2792
+
2793
+ it("should successfully process webhook with valid async signature verification", async () => {
2794
+ const mockEvent = {
2795
+ type: "customer.subscription.updated",
2796
+ data: {
2797
+ object: {
2798
+ id: "sub_test_async",
2799
+ customer: "cus_test_async",
2800
+ status: "active",
2801
+ items: {
2802
+ data: [
2803
+ {
2804
+ price: { id: process.env.STRIPE_PRICE_ID_1 },
2805
+ quantity: 1,
2806
+ current_period_start: Math.floor(Date.now() / 1000),
2807
+ current_period_end:
2808
+ Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
2809
+ },
2810
+ ],
2811
+ },
2812
+ current_period_start: Math.floor(Date.now() / 1000),
2813
+ current_period_end:
2814
+ Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
2815
+ },
2816
+ },
2817
+ };
2818
+
2819
+ const stripeForTest = {
2820
+ ...stripeOptions.stripeClient,
2821
+ webhooks: {
2822
+ // Simulate async verification success
2823
+ constructEventAsync: vi.fn().mockResolvedValue(mockEvent),
2824
+ },
2825
+ };
2826
+
2827
+ const testOptions = {
2828
+ ...stripeOptions,
2829
+ stripeClient: stripeForTest as unknown as Stripe,
2830
+ stripeWebhookSecret: "test_secret_async",
2831
+ };
2832
+
2833
+ const { auth: testAuth } = await getTestInstance(
2834
+ {
2835
+ database: memory,
2836
+ plugins: [stripe(testOptions)],
2837
+ },
2838
+ {
2839
+ disableTestUser: true,
2840
+ },
2841
+ );
2842
+ const testCtx = await testAuth.$context;
2843
+
2844
+ const { id: testUserId } = await testCtx.adapter.create({
2845
+ model: "user",
2846
+ data: {
2847
+ email: "async-test@email.com",
2848
+ },
2849
+ });
2850
+
2851
+ await testCtx.adapter.create({
2852
+ model: "subscription",
2853
+ data: {
2854
+ referenceId: testUserId,
2855
+ stripeCustomerId: "cus_test_async",
2856
+ stripeSubscriptionId: "sub_test_async",
2857
+ status: "incomplete",
2858
+ plan: "starter",
2859
+ },
2860
+ });
2861
+
2862
+ const mockRequest = new Request(
2863
+ "http://localhost:3000/api/auth/stripe/webhook",
2864
+ {
2865
+ method: "POST",
2866
+ headers: {
2867
+ "stripe-signature": "valid_async_signature",
2868
+ },
2869
+ body: JSON.stringify(mockEvent),
2870
+ },
2871
+ );
2872
+
2873
+ const response = await testAuth.handler(mockRequest);
2874
+ expect(response.status).toBe(200);
2875
+ expect(stripeForTest.webhooks.constructEventAsync).toHaveBeenCalledWith(
2876
+ expect.any(String),
2877
+ "valid_async_signature",
2878
+ "test_secret_async",
2879
+ );
2880
+
2881
+ const data = await response.json();
2882
+ expect(data).toEqual({ success: true });
2883
+ });
2884
+
2885
+ it("should call constructEventAsync with exactly 3 required parameters", async () => {
2886
+ const mockEvent = {
2887
+ type: "customer.subscription.created",
2888
+ data: {
2889
+ object: {
2890
+ id: "sub_test_params",
2891
+ customer: "cus_test_params",
2892
+ status: "active",
2893
+ },
2894
+ },
2895
+ };
2896
+
2897
+ const stripeForTest = {
2898
+ ...stripeOptions.stripeClient,
2899
+ webhooks: {
2900
+ constructEventAsync: vi.fn().mockResolvedValue(mockEvent),
2901
+ },
2902
+ };
2903
+
2904
+ const testOptions = {
2905
+ ...stripeOptions,
2906
+ stripeClient: stripeForTest as unknown as Stripe,
2907
+ stripeWebhookSecret: "test_secret_params",
2908
+ };
2909
+
2910
+ const { auth: testAuth } = await getTestInstance(
2911
+ {
2912
+ database: memory,
2913
+ plugins: [stripe(testOptions)],
2914
+ },
2915
+ {
2916
+ disableTestUser: true,
2917
+ },
2918
+ );
2919
+
2920
+ const mockRequest = new Request(
2921
+ "http://localhost:3000/api/auth/stripe/webhook",
2922
+ {
2923
+ method: "POST",
2924
+ headers: {
2925
+ "stripe-signature": "test_signature_params",
2926
+ },
2927
+ body: JSON.stringify(mockEvent),
2928
+ },
2929
+ );
2930
+
2931
+ await testAuth.handler(mockRequest);
2932
+
2933
+ // Verify that constructEventAsync is called with exactly 3 required parameters
2934
+ // (payload, signature, secret) and no optional parameters
2935
+ expect(stripeForTest.webhooks.constructEventAsync).toHaveBeenCalledWith(
2936
+ expect.any(String), // payload
2937
+ "test_signature_params", // signature
2938
+ "test_secret_params", // secret
2939
+ );
2940
+
2941
+ // Verify it was called exactly once
2942
+ expect(stripeForTest.webhooks.constructEventAsync).toHaveBeenCalledTimes(
2943
+ 1,
2944
+ );
2945
+ });
2946
+
2947
+ it("should support Stripe v18 with sync constructEvent method", async () => {
2948
+ const mockEvent = {
2949
+ type: "customer.subscription.updated",
2950
+ data: {
2951
+ object: {
2952
+ id: "sub_test_v18",
2953
+ customer: "cus_test_v18",
2954
+ status: "active",
2955
+ items: {
2956
+ data: [
2957
+ {
2958
+ price: { id: process.env.STRIPE_PRICE_ID_1 },
2959
+ quantity: 1,
2960
+ current_period_start: Math.floor(Date.now() / 1000),
2961
+ current_period_end:
2962
+ Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
2963
+ },
2964
+ ],
2965
+ },
2966
+ current_period_start: Math.floor(Date.now() / 1000),
2967
+ current_period_end:
2968
+ Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
2969
+ },
2970
+ },
2971
+ };
2972
+
2973
+ // Simulate Stripe v18 - only has sync constructEvent, no constructEventAsync
2974
+ const stripeV18 = {
2975
+ ...stripeOptions.stripeClient,
2976
+ webhooks: {
2977
+ constructEvent: vi.fn().mockReturnValue(mockEvent),
2978
+ // v18 doesn't have constructEventAsync
2979
+ constructEventAsync: undefined,
2980
+ },
2981
+ };
2982
+
2983
+ const testOptions = {
2984
+ ...stripeOptions,
2985
+ stripeClient: stripeV18 as unknown as Stripe,
2986
+ stripeWebhookSecret: "test_secret_v18",
2987
+ };
2988
+
2989
+ const { auth: testAuth } = await getTestInstance(
2990
+ {
2991
+ database: memory,
2992
+ plugins: [stripe(testOptions)],
2993
+ },
2994
+ {
2995
+ disableTestUser: true,
2996
+ },
2997
+ );
2998
+ const testCtx = await testAuth.$context;
2999
+
3000
+ const { id: testUserId } = await testCtx.adapter.create({
3001
+ model: "user",
3002
+ data: {
3003
+ email: "v18-test@email.com",
3004
+ },
3005
+ });
3006
+
3007
+ await testCtx.adapter.create({
3008
+ model: "subscription",
3009
+ data: {
3010
+ referenceId: testUserId,
3011
+ stripeCustomerId: "cus_test_v18",
3012
+ stripeSubscriptionId: "sub_test_v18",
3013
+ status: "incomplete",
3014
+ plan: "starter",
3015
+ },
3016
+ });
3017
+
3018
+ const mockRequest = new Request(
3019
+ "http://localhost:3000/api/auth/stripe/webhook",
3020
+ {
3021
+ method: "POST",
3022
+ headers: {
3023
+ "stripe-signature": "test_signature_v18",
3024
+ },
3025
+ body: JSON.stringify(mockEvent),
3026
+ },
3027
+ );
3028
+
3029
+ const response = await testAuth.handler(mockRequest);
3030
+ expect(response.status).toBe(200);
3031
+
3032
+ // Verify that constructEvent (sync) was called instead of constructEventAsync
3033
+ expect(stripeV18.webhooks.constructEvent).toHaveBeenCalledWith(
3034
+ expect.any(String),
3035
+ "test_signature_v18",
3036
+ "test_secret_v18",
3037
+ );
3038
+ expect(stripeV18.webhooks.constructEvent).toHaveBeenCalledTimes(1);
3039
+
3040
+ const data = await response.json();
3041
+ expect(data).toEqual({ success: true });
3042
+ });
3043
+ });
3044
+
3045
+ it("should support flexible limits types", async () => {
3046
+ const flexiblePlans = [
3047
+ {
3048
+ name: "flexible",
3049
+ priceId: "price_flexible",
3050
+ limits: {
3051
+ // Numbers
3052
+ maxUsers: 100,
3053
+ maxProjects: 10,
3054
+ // Arrays
3055
+ features: ["analytics", "api", "webhooks"],
3056
+ supportedMethods: ["GET", "POST", "PUT", "DELETE"],
3057
+ // Objects
3058
+ rateLimit: { requests: 1000, window: 3600 },
3059
+ permissions: { admin: true, read: true, write: false },
3060
+ // Mixed
3061
+ quotas: {
3062
+ storage: 50,
3063
+ bandwidth: [100, "GB"],
3064
+ },
3065
+ },
3066
+ },
3067
+ ];
3068
+
3069
+ const {
3070
+ client: testClient,
3071
+ auth: testAuth,
3072
+ sessionSetter: testSessionSetter,
3073
+ } = await getTestInstance(
3074
+ {
3075
+ database: memory,
3076
+ plugins: [
3077
+ stripe({
3078
+ ...stripeOptions,
3079
+ subscription: {
3080
+ enabled: true,
3081
+ plans: flexiblePlans,
3082
+ },
3083
+ }),
3084
+ ],
3085
+ },
3086
+ {
3087
+ disableTestUser: true,
3088
+ clientOptions: {
3089
+ plugins: [stripeClient({ subscription: true })],
3090
+ },
3091
+ },
3092
+ );
3093
+ const testCtx = await testAuth.$context;
3094
+
3095
+ // Create user and sign in
3096
+ const headers = new Headers();
3097
+ const userRes = await testClient.signUp.email(
3098
+ { email: "limits@test.com", password: "password", name: "Test" },
3099
+ { throw: true },
3100
+ );
3101
+ const limitUserId = userRes.user.id;
3102
+
3103
+ await testClient.signIn.email(
3104
+ { email: "limits@test.com", password: "password" },
3105
+ { throw: true, onSuccess: testSessionSetter(headers) },
3106
+ );
3107
+
3108
+ // Create subscription
3109
+ await testCtx.adapter.create({
3110
+ model: "subscription",
3111
+ data: {
3112
+ referenceId: limitUserId,
3113
+ stripeCustomerId: "cus_limits_test",
3114
+ stripeSubscriptionId: "sub_limits_test",
3115
+ status: "active",
3116
+ plan: "flexible",
3117
+ },
3118
+ });
3119
+
3120
+ // List subscriptions and verify limits structure
3121
+ const result = await testClient.subscription.list({
3122
+ fetchOptions: { headers, throw: true },
3123
+ });
3124
+
3125
+ expect(result.length).toBe(1);
3126
+ const limits = result[0]?.limits;
3127
+
3128
+ // Verify different types are preserved
3129
+ expect(limits).toBeDefined();
3130
+
3131
+ // Type-safe access with unknown (cast once for test convenience)
3132
+ const typedLimits = limits as Record<string, unknown>;
3133
+ expect(typedLimits.maxUsers).toBe(100);
3134
+ expect(typedLimits.maxProjects).toBe(10);
3135
+ expect(typeof typedLimits.rateLimit).toBe("object");
3136
+ expect(typedLimits.features).toEqual(["analytics", "api", "webhooks"]);
3137
+ expect(Array.isArray(typedLimits.features)).toBe(true);
3138
+ expect(Array.isArray(typedLimits.supportedMethods)).toBe(true);
3139
+ expect((typedLimits.quotas as Record<string, unknown>).storage).toBe(50);
3140
+ expect((typedLimits.rateLimit as Record<string, unknown>).requests).toBe(
3141
+ 1000,
3142
+ );
3143
+ expect((typedLimits.permissions as Record<string, unknown>).admin).toBe(
3144
+ true,
3145
+ );
3146
+ expect(
3147
+ Array.isArray((typedLimits.quotas as Record<string, unknown>).bandwidth),
3148
+ ).toBe(true);
3149
+ });
3150
+
3151
+ describe("Duplicate customer prevention on signup", () => {
3152
+ it("should NOT create duplicate customer when email already exists in Stripe", async () => {
3153
+ const existingEmail = "duplicate-email@example.com";
3154
+ const existingCustomerId = "cus_stripe_existing_456";
3155
+
3156
+ mockStripe.customers.list.mockResolvedValueOnce({
3157
+ data: [
3158
+ {
3159
+ id: existingCustomerId,
3160
+ email: existingEmail,
3161
+ name: "Existing Stripe Customer",
3162
+ },
3163
+ ],
3164
+ });
2091
3165
 
2092
- it("should work without getCustomerCreateParams", async () => {
2093
- // This test ensures backward compatibility
2094
- const testOptions = {
3166
+ const testOptionsWithHook = {
2095
3167
  ...stripeOptions,
2096
3168
  createCustomerOnSignUp: true,
2097
- // No getCustomerCreateParams provided
2098
3169
  } satisfies StripeOptions;
2099
3170
 
2100
- const { client: testAuthClient } = await getTestInstance(
3171
+ const { client: testAuthClient, auth: testAuth } = await getTestInstance(
2101
3172
  {
2102
3173
  database: memory,
2103
- plugins: [stripe(testOptions)],
3174
+ plugins: [stripe(testOptionsWithHook)],
2104
3175
  },
2105
3176
  {
2106
3177
  disableTestUser: true,
@@ -2109,246 +3180,170 @@ describe("stripe", () => {
2109
3180
  },
2110
3181
  },
2111
3182
  );
3183
+ const testCtx = await testAuth.$context;
2112
3184
 
2113
- // Sign up a user
3185
+ vi.clearAllMocks();
3186
+
3187
+ // Sign up with email that exists in Stripe
2114
3188
  const userRes = await testAuthClient.signUp.email(
2115
3189
  {
2116
- email: "no-custom-params@email.com",
3190
+ email: existingEmail,
2117
3191
  password: "password",
2118
- name: "Default User",
2119
- },
2120
- {
2121
- throw: true,
3192
+ name: "Duplicate Email User",
2122
3193
  },
3194
+ { throw: true },
2123
3195
  );
2124
3196
 
2125
- // Verify customer was created with default params only
2126
- expect(mockStripe.customers.create).toHaveBeenCalledWith({
2127
- email: "no-custom-params@email.com",
2128
- name: "Default User",
2129
- metadata: {
2130
- userId: userRes.user.id,
2131
- },
3197
+ // Should check for existing customer by email
3198
+ expect(mockStripe.customers.list).toHaveBeenCalledWith({
3199
+ email: existingEmail,
3200
+ limit: 1,
2132
3201
  });
2133
- });
2134
- });
2135
-
2136
- describe("Webhook Error Handling (Stripe v19)", () => {
2137
- it("should handle invalid webhook signature with constructEventAsync", async () => {
2138
- const mockError = new Error("Invalid signature");
2139
- const stripeWithError = {
2140
- ...stripeOptions.stripeClient,
2141
- webhooks: {
2142
- constructEventAsync: vi.fn().mockRejectedValue(mockError),
2143
- },
2144
- };
2145
-
2146
- const testOptions = {
2147
- ...stripeOptions,
2148
- stripeClient: stripeWithError as unknown as Stripe,
2149
- stripeWebhookSecret: "test_secret",
2150
- };
2151
-
2152
- const { auth: testAuth } = await getTestInstance(
2153
- {
2154
- database: memory,
2155
- plugins: [stripe(testOptions)],
2156
- },
2157
- {
2158
- disableTestUser: true,
2159
- },
2160
- );
2161
3202
 
2162
- const mockRequest = new Request(
2163
- "http://localhost:3000/api/auth/stripe/webhook",
2164
- {
2165
- method: "POST",
2166
- headers: {
2167
- "stripe-signature": "invalid_signature",
2168
- },
2169
- body: JSON.stringify({ type: "test.event" }),
2170
- },
2171
- );
3203
+ // Should NOT create duplicate customer
3204
+ expect(mockStripe.customers.create).not.toHaveBeenCalled();
2172
3205
 
2173
- const response = await testAuth.handler(mockRequest);
2174
- expect(response.status).toBe(400);
2175
- const data = await response.json();
2176
- expect(data.message).toContain("Webhook Error");
3206
+ // Verify user has the EXISTING Stripe customer ID (not new duplicate)
3207
+ const user = await testCtx.adapter.findOne<
3208
+ User & { stripeCustomerId?: string }
3209
+ >({
3210
+ model: "user",
3211
+ where: [{ field: "id", value: userRes.user.id }],
3212
+ });
3213
+ expect(user?.stripeCustomerId).toBe(existingCustomerId); // Should use existing ID
2177
3214
  });
2178
3215
 
2179
- it("should reject webhook request without stripe-signature header", async () => {
2180
- const { auth: testAuth } = await getTestInstance(
2181
- {
2182
- database: memory,
2183
- plugins: [stripe(stripeOptions)],
2184
- },
2185
- {
2186
- disableTestUser: true,
2187
- },
2188
- );
2189
-
2190
- const mockRequest = new Request(
2191
- "http://localhost:3000/api/auth/stripe/webhook",
2192
- {
2193
- method: "POST",
2194
- headers: {
2195
- "content-type": "application/json",
2196
- },
2197
- body: JSON.stringify({ type: "test.event" }),
2198
- },
2199
- );
3216
+ it("should CREATE customer only when user has no stripeCustomerId and none exists in Stripe", async () => {
3217
+ const newEmail = "brand-new@example.com";
2200
3218
 
2201
- const response = await testAuth.handler(mockRequest);
2202
- expect(response.status).toBe(400);
2203
- const data = await response.json();
2204
- expect(data.message).toContain("Stripe webhook secret not found");
2205
- });
3219
+ mockStripe.customers.list.mockResolvedValueOnce({
3220
+ data: [],
3221
+ });
2206
3222
 
2207
- it("should handle constructEventAsync returning null/undefined", async () => {
2208
- const stripeWithNull = {
2209
- ...stripeOptions.stripeClient,
2210
- webhooks: {
2211
- constructEventAsync: vi.fn().mockResolvedValue(null),
2212
- },
2213
- };
3223
+ mockStripe.customers.create.mockResolvedValueOnce({
3224
+ id: "cus_new_created_789",
3225
+ email: newEmail,
3226
+ });
2214
3227
 
2215
- const testOptions = {
3228
+ const testOptionsWithHook = {
2216
3229
  ...stripeOptions,
2217
- stripeClient: stripeWithNull as unknown as Stripe,
2218
- stripeWebhookSecret: "test_secret",
2219
- };
3230
+ createCustomerOnSignUp: true,
3231
+ } satisfies StripeOptions;
2220
3232
 
2221
- const { auth: testAuth } = await getTestInstance(
3233
+ const { client: testAuthClient, auth: testAuth } = await getTestInstance(
2222
3234
  {
2223
3235
  database: memory,
2224
- plugins: [stripe(testOptions)],
3236
+ plugins: [stripe(testOptionsWithHook)],
2225
3237
  },
2226
3238
  {
2227
3239
  disableTestUser: true,
3240
+ clientOptions: {
3241
+ plugins: [stripeClient({ subscription: true })],
3242
+ },
2228
3243
  },
2229
3244
  );
3245
+ const testCtx = await testAuth.$context;
2230
3246
 
2231
- const mockRequest = new Request(
2232
- "http://localhost:3000/api/auth/stripe/webhook",
3247
+ vi.clearAllMocks();
3248
+
3249
+ // Sign up with brand new email
3250
+ const userRes = await testAuthClient.signUp.email(
2233
3251
  {
2234
- method: "POST",
2235
- headers: {
2236
- "stripe-signature": "test_signature",
2237
- },
2238
- body: JSON.stringify({ type: "test.event" }),
3252
+ email: newEmail,
3253
+ password: "password",
3254
+ name: "Brand New User",
2239
3255
  },
3256
+ { throw: true },
2240
3257
  );
2241
3258
 
2242
- const response = await testAuth.handler(mockRequest);
2243
- expect(response.status).toBe(400);
2244
- const data = await response.json();
2245
- expect(data.message).toContain("Failed to construct event");
2246
- });
2247
-
2248
- it("should handle async errors in webhook event processing", async () => {
2249
- const errorThrowingHandler = vi
2250
- .fn()
2251
- .mockRejectedValue(new Error("Event processing failed"));
2252
-
2253
- const mockEvent = {
2254
- type: "checkout.session.completed",
2255
- data: {
2256
- object: {
2257
- mode: "subscription",
2258
- subscription: "sub_123",
2259
- metadata: {
2260
- referenceId: "user_123",
2261
- subscriptionId: "sub_123",
2262
- },
2263
- },
2264
- },
2265
- };
3259
+ // Should check for existing customer first
3260
+ expect(mockStripe.customers.list).toHaveBeenCalledWith({
3261
+ email: newEmail,
3262
+ limit: 1,
3263
+ });
2266
3264
 
2267
- const stripeForTest = {
2268
- ...stripeOptions.stripeClient,
2269
- subscriptions: {
2270
- retrieve: vi.fn().mockRejectedValue(new Error("Stripe API error")),
2271
- },
2272
- webhooks: {
2273
- constructEventAsync: vi.fn().mockResolvedValue(mockEvent),
3265
+ // Should create new customer (this is correct behavior)
3266
+ expect(mockStripe.customers.create).toHaveBeenCalledTimes(1);
3267
+ expect(mockStripe.customers.create).toHaveBeenCalledWith({
3268
+ email: newEmail,
3269
+ name: "Brand New User",
3270
+ metadata: {
3271
+ userId: userRes.user.id,
2274
3272
  },
2275
- };
3273
+ });
2276
3274
 
2277
- const testOptions = {
2278
- ...stripeOptions,
2279
- stripeClient: stripeForTest as unknown as Stripe,
2280
- stripeWebhookSecret: "test_secret",
2281
- subscription: {
2282
- ...stripeOptions.subscription,
2283
- onSubscriptionComplete: errorThrowingHandler,
2284
- },
2285
- };
3275
+ // Verify user has the new Stripe customer ID
3276
+ const user = await testCtx.adapter.findOne<
3277
+ User & { stripeCustomerId?: string }
3278
+ >({
3279
+ model: "user",
3280
+ where: [{ field: "id", value: userRes.user.id }],
3281
+ });
3282
+ expect(user?.stripeCustomerId).toBeDefined();
3283
+ });
3284
+ });
2286
3285
 
2287
- const { auth: testAuth } = await getTestInstance(
3286
+ describe("webhook: cancel_at_period_end cancellation", () => {
3287
+ it("should sync cancelAtPeriodEnd and canceledAt when user cancels via Billing Portal (at_period_end mode)", async () => {
3288
+ const { auth } = await getTestInstance(
2288
3289
  {
2289
3290
  database: memory,
2290
- plugins: [stripe(testOptions as StripeOptions)],
2291
- },
2292
- {
2293
- disableTestUser: true,
3291
+ plugins: [stripe(stripeOptions)],
2294
3292
  },
3293
+ { disableTestUser: true },
2295
3294
  );
2296
- const testCtx = await testAuth.$context;
3295
+ const ctx = await auth.$context;
2297
3296
 
2298
- await testCtx.adapter.create({
2299
- model: "subscription",
2300
- data: {
2301
- referenceId: "user_123",
2302
- stripeCustomerId: "cus_123",
2303
- status: "incomplete",
2304
- plan: "starter",
2305
- id: "sub_123",
2306
- },
3297
+ // Setup: Create user and active subscription
3298
+ const { id: userId } = await ctx.adapter.create({
3299
+ model: "user",
3300
+ data: { email: "cancel-period-end@test.com" },
2307
3301
  });
2308
3302
 
2309
- const mockRequest = new Request(
2310
- "http://localhost:3000/api/auth/stripe/webhook",
2311
- {
2312
- method: "POST",
2313
- headers: {
2314
- "stripe-signature": "test_signature",
2315
- },
2316
- body: JSON.stringify(mockEvent),
3303
+ const now = Math.floor(Date.now() / 1000);
3304
+ const periodEnd = now + 30 * 24 * 60 * 60;
3305
+ const canceledAt = now;
3306
+
3307
+ const { id: subscriptionId } = await ctx.adapter.create({
3308
+ model: "subscription",
3309
+ data: {
3310
+ referenceId: userId,
3311
+ stripeCustomerId: "cus_cancel_test",
3312
+ stripeSubscriptionId: "sub_cancel_period_end",
3313
+ status: "active",
3314
+ plan: "starter",
3315
+ cancelAtPeriodEnd: false,
3316
+ cancelAt: null,
3317
+ canceledAt: null,
2317
3318
  },
2318
- );
2319
-
2320
- const response = await testAuth.handler(mockRequest);
2321
- // Errors inside event handlers are caught and logged but don't fail the webhook
2322
- // This prevents Stripe from retrying and is the expected behavior
2323
- expect(response.status).toBe(200);
2324
- const data = await response.json();
2325
- expect(data).toEqual({ success: true });
2326
- // Verify the error was logged (via the stripeClient.subscriptions.retrieve rejection)
2327
- expect(stripeForTest.subscriptions.retrieve).toHaveBeenCalled();
2328
- });
3319
+ });
2329
3320
 
2330
- it("should successfully process webhook with valid async signature verification", async () => {
2331
- const mockEvent = {
3321
+ // Simulate: Stripe webhook for cancel_at_period_end
3322
+ const webhookEvent = {
2332
3323
  type: "customer.subscription.updated",
2333
3324
  data: {
2334
3325
  object: {
2335
- id: "sub_test_async",
2336
- customer: "cus_test_async",
3326
+ id: "sub_cancel_period_end",
3327
+ customer: "cus_cancel_test",
2337
3328
  status: "active",
3329
+ cancel_at_period_end: true,
3330
+ cancel_at: null,
3331
+ canceled_at: canceledAt,
3332
+ ended_at: null,
2338
3333
  items: {
2339
3334
  data: [
2340
3335
  {
2341
- price: { id: process.env.STRIPE_PRICE_ID_1 },
3336
+ price: { id: "price_starter_123", lookup_key: null },
2342
3337
  quantity: 1,
2343
- current_period_start: Math.floor(Date.now() / 1000),
2344
- current_period_end:
2345
- Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
3338
+ current_period_start: now,
3339
+ current_period_end: periodEnd,
2346
3340
  },
2347
3341
  ],
2348
3342
  },
2349
- current_period_start: Math.floor(Date.now() / 1000),
2350
- current_period_end:
2351
- Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
3343
+ cancellation_details: {
3344
+ reason: "cancellation_requested",
3345
+ comment: "User requested cancellation",
3346
+ },
2352
3347
  },
2353
3348
  },
2354
3349
  };
@@ -2356,77 +3351,104 @@ describe("stripe", () => {
2356
3351
  const stripeForTest = {
2357
3352
  ...stripeOptions.stripeClient,
2358
3353
  webhooks: {
2359
- // Simulate async verification success
2360
- constructEventAsync: vi.fn().mockResolvedValue(mockEvent),
3354
+ constructEventAsync: vi.fn().mockResolvedValue(webhookEvent),
2361
3355
  },
2362
3356
  };
2363
3357
 
2364
3358
  const testOptions = {
2365
3359
  ...stripeOptions,
2366
3360
  stripeClient: stripeForTest as unknown as Stripe,
2367
- stripeWebhookSecret: "test_secret_async",
3361
+ stripeWebhookSecret: "test_secret",
2368
3362
  };
2369
3363
 
2370
- const { auth: testAuth } = await getTestInstance(
3364
+ const { auth: webhookAuth } = await getTestInstance(
2371
3365
  {
2372
3366
  database: memory,
2373
3367
  plugins: [stripe(testOptions)],
2374
3368
  },
3369
+ { disableTestUser: true },
3370
+ );
3371
+ const webhookCtx = await webhookAuth.$context;
3372
+
3373
+ const response = await webhookAuth.handler(
3374
+ new Request("http://localhost:3000/api/auth/stripe/webhook", {
3375
+ method: "POST",
3376
+ headers: { "stripe-signature": "test_signature" },
3377
+ body: JSON.stringify(webhookEvent),
3378
+ }),
3379
+ );
3380
+
3381
+ expect(response.status).toBe(200);
3382
+
3383
+ const updatedSub = await webhookCtx.adapter.findOne<Subscription>({
3384
+ model: "subscription",
3385
+ where: [{ field: "id", value: subscriptionId }],
3386
+ });
3387
+
3388
+ expect(updatedSub).toMatchObject({
3389
+ status: "active",
3390
+ cancelAtPeriodEnd: true,
3391
+ cancelAt: null,
3392
+ canceledAt: expect.any(Date),
3393
+ endedAt: null,
3394
+ });
3395
+ });
3396
+
3397
+ it("should sync cancelAt when subscription is scheduled to cancel at a specific date", async () => {
3398
+ const { auth } = await getTestInstance(
2375
3399
  {
2376
- disableTestUser: true,
3400
+ database: memory,
3401
+ plugins: [stripe(stripeOptions)],
2377
3402
  },
3403
+ { disableTestUser: true },
2378
3404
  );
2379
- const testCtx = await testAuth.$context;
3405
+ const ctx = await auth.$context;
2380
3406
 
2381
- const { id: testUserId } = await testCtx.adapter.create({
3407
+ const { id: userId } = await ctx.adapter.create({
2382
3408
  model: "user",
2383
- data: {
2384
- email: "async-test@email.com",
2385
- },
3409
+ data: { email: "cancel-at-date@test.com" },
2386
3410
  });
2387
3411
 
2388
- await testCtx.adapter.create({
3412
+ const now = Math.floor(Date.now() / 1000);
3413
+ const cancelAt = now + 15 * 24 * 60 * 60; // Cancel in 15 days
3414
+ const canceledAt = now;
3415
+
3416
+ const { id: subscriptionId } = await ctx.adapter.create({
2389
3417
  model: "subscription",
2390
3418
  data: {
2391
- referenceId: testUserId,
2392
- stripeCustomerId: "cus_test_async",
2393
- stripeSubscriptionId: "sub_test_async",
2394
- status: "incomplete",
3419
+ referenceId: userId,
3420
+ stripeCustomerId: "cus_cancel_at_test",
3421
+ stripeSubscriptionId: "sub_cancel_at_date",
3422
+ status: "active",
2395
3423
  plan: "starter",
3424
+ cancelAtPeriodEnd: false,
3425
+ cancelAt: null,
3426
+ canceledAt: null,
2396
3427
  },
2397
3428
  });
2398
3429
 
2399
- const mockRequest = new Request(
2400
- "http://localhost:3000/api/auth/stripe/webhook",
2401
- {
2402
- method: "POST",
2403
- headers: {
2404
- "stripe-signature": "valid_async_signature",
2405
- },
2406
- body: JSON.stringify(mockEvent),
2407
- },
2408
- );
2409
-
2410
- const response = await testAuth.handler(mockRequest);
2411
- expect(response.status).toBe(200);
2412
- expect(stripeForTest.webhooks.constructEventAsync).toHaveBeenCalledWith(
2413
- expect.any(String),
2414
- "valid_async_signature",
2415
- "test_secret_async",
2416
- );
2417
-
2418
- const data = await response.json();
2419
- expect(data).toEqual({ success: true });
2420
- });
2421
-
2422
- it("should call constructEventAsync with exactly 3 required parameters", async () => {
2423
- const mockEvent = {
2424
- type: "customer.subscription.created",
3430
+ // Simulate: Dashboard/API cancel with specific date (cancel_at)
3431
+ const webhookEvent = {
3432
+ type: "customer.subscription.updated",
2425
3433
  data: {
2426
3434
  object: {
2427
- id: "sub_test_params",
2428
- customer: "cus_test_params",
3435
+ id: "sub_cancel_at_date",
3436
+ customer: "cus_cancel_at_test",
2429
3437
  status: "active",
3438
+ cancel_at_period_end: false,
3439
+ cancel_at: cancelAt,
3440
+ canceled_at: canceledAt,
3441
+ ended_at: null,
3442
+ items: {
3443
+ data: [
3444
+ {
3445
+ price: { id: "price_starter_123", lookup_key: null },
3446
+ quantity: 1,
3447
+ current_period_start: now,
3448
+ current_period_end: now + 30 * 24 * 60 * 60,
3449
+ },
3450
+ ],
3451
+ },
2430
3452
  },
2431
3453
  },
2432
3454
  };
@@ -2434,281 +3456,327 @@ describe("stripe", () => {
2434
3456
  const stripeForTest = {
2435
3457
  ...stripeOptions.stripeClient,
2436
3458
  webhooks: {
2437
- constructEventAsync: vi.fn().mockResolvedValue(mockEvent),
3459
+ constructEventAsync: vi.fn().mockResolvedValue(webhookEvent),
2438
3460
  },
2439
3461
  };
2440
3462
 
2441
3463
  const testOptions = {
2442
3464
  ...stripeOptions,
2443
3465
  stripeClient: stripeForTest as unknown as Stripe,
2444
- stripeWebhookSecret: "test_secret_params",
3466
+ stripeWebhookSecret: "test_secret",
2445
3467
  };
2446
3468
 
2447
- const { auth: testAuth } = await getTestInstance(
3469
+ const { auth: webhookAuth } = await getTestInstance(
2448
3470
  {
2449
3471
  database: memory,
2450
3472
  plugins: [stripe(testOptions)],
2451
3473
  },
2452
- {
2453
- disableTestUser: true,
2454
- },
3474
+ { disableTestUser: true },
2455
3475
  );
3476
+ const webhookCtx = await webhookAuth.$context;
2456
3477
 
2457
- const mockRequest = new Request(
2458
- "http://localhost:3000/api/auth/stripe/webhook",
2459
- {
3478
+ const response = await webhookAuth.handler(
3479
+ new Request("http://localhost:3000/api/auth/stripe/webhook", {
2460
3480
  method: "POST",
2461
- headers: {
2462
- "stripe-signature": "test_signature_params",
2463
- },
2464
- body: JSON.stringify(mockEvent),
2465
- },
3481
+ headers: { "stripe-signature": "test_signature" },
3482
+ body: JSON.stringify(webhookEvent),
3483
+ }),
2466
3484
  );
2467
3485
 
2468
- await testAuth.handler(mockRequest);
3486
+ expect(response.status).toBe(200);
2469
3487
 
2470
- // Verify that constructEventAsync is called with exactly 3 required parameters
2471
- // (payload, signature, secret) and no optional parameters
2472
- expect(stripeForTest.webhooks.constructEventAsync).toHaveBeenCalledWith(
2473
- expect.any(String), // payload
2474
- "test_signature_params", // signature
2475
- "test_secret_params", // secret
2476
- );
3488
+ const updatedSub = await webhookCtx.adapter.findOne<Subscription>({
3489
+ model: "subscription",
3490
+ where: [{ field: "id", value: subscriptionId }],
3491
+ });
2477
3492
 
2478
- // Verify it was called exactly once
2479
- expect(stripeForTest.webhooks.constructEventAsync).toHaveBeenCalledTimes(
2480
- 1,
2481
- );
3493
+ expect(updatedSub).toMatchObject({
3494
+ status: "active",
3495
+ cancelAtPeriodEnd: false,
3496
+ cancelAt: expect.any(Date),
3497
+ canceledAt: expect.any(Date),
3498
+ endedAt: null,
3499
+ });
3500
+
3501
+ // Verify the cancelAt date is correct
3502
+ expect(updatedSub!.cancelAt!.getTime()).toBe(cancelAt * 1000);
2482
3503
  });
3504
+ });
2483
3505
 
2484
- it("should support Stripe v18 with sync constructEvent method", async () => {
2485
- const mockEvent = {
2486
- type: "customer.subscription.updated",
3506
+ describe("webhook: immediate cancellation (subscription deleted)", () => {
3507
+ it("should set status=canceled and endedAt when subscription is immediately canceled", async () => {
3508
+ const { auth } = await getTestInstance(
3509
+ {
3510
+ database: memory,
3511
+ plugins: [stripe(stripeOptions)],
3512
+ },
3513
+ { disableTestUser: true },
3514
+ );
3515
+ const ctx = await auth.$context;
3516
+
3517
+ const { id: userId } = await ctx.adapter.create({
3518
+ model: "user",
3519
+ data: { email: "immediate-cancel@test.com" },
3520
+ });
3521
+
3522
+ const now = Math.floor(Date.now() / 1000);
3523
+
3524
+ const { id: subscriptionId } = await ctx.adapter.create({
3525
+ model: "subscription",
3526
+ data: {
3527
+ referenceId: userId,
3528
+ stripeCustomerId: "cus_immediate_cancel",
3529
+ stripeSubscriptionId: "sub_immediate_cancel",
3530
+ status: "active",
3531
+ plan: "starter",
3532
+ },
3533
+ });
3534
+
3535
+ // Simulate: Immediate cancellation via Billing Portal (mode: immediately) or API
3536
+ const webhookEvent = {
3537
+ type: "customer.subscription.deleted",
2487
3538
  data: {
2488
3539
  object: {
2489
- id: "sub_test_v18",
2490
- customer: "cus_test_v18",
2491
- status: "active",
2492
- items: {
2493
- data: [
2494
- {
2495
- price: { id: process.env.STRIPE_PRICE_ID_1 },
2496
- quantity: 1,
2497
- current_period_start: Math.floor(Date.now() / 1000),
2498
- current_period_end:
2499
- Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
2500
- },
2501
- ],
2502
- },
2503
- current_period_start: Math.floor(Date.now() / 1000),
2504
- current_period_end:
2505
- Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
3540
+ id: "sub_immediate_cancel",
3541
+ customer: "cus_immediate_cancel",
3542
+ status: "canceled",
3543
+ cancel_at_period_end: false,
3544
+ cancel_at: null,
3545
+ canceled_at: now,
3546
+ ended_at: now,
2506
3547
  },
2507
3548
  },
2508
3549
  };
2509
3550
 
2510
- // Simulate Stripe v18 - only has sync constructEvent, no constructEventAsync
2511
- const stripeV18 = {
3551
+ const stripeForTest = {
2512
3552
  ...stripeOptions.stripeClient,
2513
3553
  webhooks: {
2514
- constructEvent: vi.fn().mockReturnValue(mockEvent),
2515
- // v18 doesn't have constructEventAsync
2516
- constructEventAsync: undefined,
3554
+ constructEventAsync: vi.fn().mockResolvedValue(webhookEvent),
2517
3555
  },
2518
3556
  };
2519
3557
 
2520
3558
  const testOptions = {
2521
3559
  ...stripeOptions,
2522
- stripeClient: stripeV18 as unknown as Stripe,
2523
- stripeWebhookSecret: "test_secret_v18",
3560
+ stripeClient: stripeForTest as unknown as Stripe,
3561
+ stripeWebhookSecret: "test_secret",
2524
3562
  };
2525
3563
 
2526
- const { auth: testAuth } = await getTestInstance(
3564
+ const { auth: webhookAuth } = await getTestInstance(
2527
3565
  {
2528
3566
  database: memory,
2529
3567
  plugins: [stripe(testOptions)],
2530
3568
  },
3569
+ { disableTestUser: true },
3570
+ );
3571
+ const webhookCtx = await webhookAuth.$context;
3572
+
3573
+ const response = await webhookAuth.handler(
3574
+ new Request("http://localhost:3000/api/auth/stripe/webhook", {
3575
+ method: "POST",
3576
+ headers: { "stripe-signature": "test_signature" },
3577
+ body: JSON.stringify(webhookEvent),
3578
+ }),
3579
+ );
3580
+
3581
+ expect(response.status).toBe(200);
3582
+
3583
+ const updatedSub = await webhookCtx.adapter.findOne<Subscription>({
3584
+ model: "subscription",
3585
+ where: [{ field: "id", value: subscriptionId }],
3586
+ });
3587
+
3588
+ expect(updatedSub).not.toBeNull();
3589
+ expect(updatedSub!.status).toBe("canceled");
3590
+ expect(updatedSub!.endedAt).not.toBeNull();
3591
+ });
3592
+
3593
+ it("should set endedAt when cancel_at_period_end subscription reaches period end", async () => {
3594
+ const { auth } = await getTestInstance(
2531
3595
  {
2532
- disableTestUser: true,
3596
+ database: memory,
3597
+ plugins: [stripe(stripeOptions)],
2533
3598
  },
3599
+ { disableTestUser: true },
2534
3600
  );
2535
- const testCtx = await testAuth.$context;
3601
+ const ctx = await auth.$context;
2536
3602
 
2537
- const { id: testUserId } = await testCtx.adapter.create({
3603
+ const { id: userId } = await ctx.adapter.create({
2538
3604
  model: "user",
2539
- data: {
2540
- email: "v18-test@email.com",
2541
- },
3605
+ data: { email: "period-end-reached@test.com" },
2542
3606
  });
2543
3607
 
2544
- await testCtx.adapter.create({
3608
+ const now = Math.floor(Date.now() / 1000);
3609
+ const canceledAt = now - 30 * 24 * 60 * 60; // Canceled 30 days ago
3610
+
3611
+ const { id: subscriptionId } = await ctx.adapter.create({
2545
3612
  model: "subscription",
2546
3613
  data: {
2547
- referenceId: testUserId,
2548
- stripeCustomerId: "cus_test_v18",
2549
- stripeSubscriptionId: "sub_test_v18",
2550
- status: "incomplete",
3614
+ referenceId: userId,
3615
+ stripeCustomerId: "cus_period_end_reached",
3616
+ stripeSubscriptionId: "sub_period_end_reached",
3617
+ status: "active",
2551
3618
  plan: "starter",
3619
+ cancelAtPeriodEnd: true,
3620
+ canceledAt: new Date(canceledAt * 1000),
2552
3621
  },
2553
3622
  });
2554
3623
 
2555
- const mockRequest = new Request(
2556
- "http://localhost:3000/api/auth/stripe/webhook",
2557
- {
2558
- method: "POST",
2559
- headers: {
2560
- "stripe-signature": "test_signature_v18",
3624
+ // Simulate: Period ended, subscription is now deleted
3625
+ const webhookEvent = {
3626
+ type: "customer.subscription.deleted",
3627
+ data: {
3628
+ object: {
3629
+ id: "sub_period_end_reached",
3630
+ customer: "cus_period_end_reached",
3631
+ status: "canceled",
3632
+ cancel_at_period_end: true,
3633
+ cancel_at: null,
3634
+ canceled_at: canceledAt,
3635
+ ended_at: now,
2561
3636
  },
2562
- body: JSON.stringify(mockEvent),
2563
3637
  },
2564
- );
2565
-
2566
- const response = await testAuth.handler(mockRequest);
2567
- expect(response.status).toBe(200);
2568
-
2569
- // Verify that constructEvent (sync) was called instead of constructEventAsync
2570
- expect(stripeV18.webhooks.constructEvent).toHaveBeenCalledWith(
2571
- expect.any(String),
2572
- "test_signature_v18",
2573
- "test_secret_v18",
2574
- );
2575
- expect(stripeV18.webhooks.constructEvent).toHaveBeenCalledTimes(1);
2576
-
2577
- const data = await response.json();
2578
- expect(data).toEqual({ success: true });
2579
- });
2580
- });
3638
+ };
2581
3639
 
2582
- it("should support flexible limits types", async () => {
2583
- const flexiblePlans = [
2584
- {
2585
- name: "flexible",
2586
- priceId: "price_flexible",
2587
- limits: {
2588
- // Numbers
2589
- maxUsers: 100,
2590
- maxProjects: 10,
2591
- // Arrays
2592
- features: ["analytics", "api", "webhooks"],
2593
- supportedMethods: ["GET", "POST", "PUT", "DELETE"],
2594
- // Objects
2595
- rateLimit: { requests: 1000, window: 3600 },
2596
- permissions: { admin: true, read: true, write: false },
2597
- // Mixed
2598
- quotas: {
2599
- storage: 50,
2600
- bandwidth: [100, "GB"],
2601
- },
3640
+ const stripeForTest = {
3641
+ ...stripeOptions.stripeClient,
3642
+ webhooks: {
3643
+ constructEventAsync: vi.fn().mockResolvedValue(webhookEvent),
2602
3644
  },
2603
- },
2604
- ];
3645
+ };
2605
3646
 
2606
- const {
2607
- client: testClient,
2608
- auth: testAuth,
2609
- sessionSetter: testSessionSetter,
2610
- } = await getTestInstance(
2611
- {
2612
- database: memory,
2613
- plugins: [
2614
- stripe({
2615
- ...stripeOptions,
2616
- subscription: {
2617
- enabled: true,
2618
- plans: flexiblePlans,
2619
- },
2620
- }),
2621
- ],
2622
- },
2623
- {
2624
- disableTestUser: true,
2625
- clientOptions: {
2626
- plugins: [stripeClient({ subscription: true })],
3647
+ const testOptions = {
3648
+ ...stripeOptions,
3649
+ stripeClient: stripeForTest as unknown as Stripe,
3650
+ stripeWebhookSecret: "test_secret",
3651
+ };
3652
+
3653
+ const { auth: webhookAuth } = await getTestInstance(
3654
+ {
3655
+ database: memory,
3656
+ plugins: [stripe(testOptions)],
2627
3657
  },
2628
- },
2629
- );
2630
- const testCtx = await testAuth.$context;
3658
+ { disableTestUser: true },
3659
+ );
3660
+ const webhookCtx = await webhookAuth.$context;
2631
3661
 
2632
- // Create user and sign in
2633
- const headers = new Headers();
2634
- const userRes = await testClient.signUp.email(
2635
- { email: "limits@test.com", password: "password", name: "Test" },
2636
- { throw: true },
2637
- );
2638
- const limitUserId = userRes.user.id;
3662
+ const response = await webhookAuth.handler(
3663
+ new Request("http://localhost:3000/api/auth/stripe/webhook", {
3664
+ method: "POST",
3665
+ headers: { "stripe-signature": "test_signature" },
3666
+ body: JSON.stringify(webhookEvent),
3667
+ }),
3668
+ );
2639
3669
 
2640
- await testClient.signIn.email(
2641
- { email: "limits@test.com", password: "password" },
2642
- { throw: true, onSuccess: testSessionSetter(headers) },
2643
- );
3670
+ expect(response.status).toBe(200);
2644
3671
 
2645
- // Create subscription
2646
- await testCtx.adapter.create({
2647
- model: "subscription",
2648
- data: {
2649
- referenceId: limitUserId,
2650
- stripeCustomerId: "cus_limits_test",
2651
- stripeSubscriptionId: "sub_limits_test",
2652
- status: "active",
2653
- plan: "flexible",
2654
- },
2655
- });
3672
+ const updatedSub = await webhookCtx.adapter.findOne<Subscription>({
3673
+ model: "subscription",
3674
+ where: [{ field: "id", value: subscriptionId }],
3675
+ });
2656
3676
 
2657
- // List subscriptions and verify limits structure
2658
- const result = await testClient.subscription.list({
2659
- fetchOptions: { headers, throw: true },
3677
+ expect(updatedSub).not.toBeNull();
3678
+ expect(updatedSub!.status).toBe("canceled");
3679
+ expect(updatedSub!.cancelAtPeriodEnd).toBe(true);
3680
+ expect(updatedSub!.endedAt).not.toBeNull();
3681
+
3682
+ // endedAt should be the actual termination time (now), not the cancellation request time
3683
+ expect(updatedSub!.endedAt!.getTime()).toBe(now * 1000);
2660
3684
  });
3685
+ });
2661
3686
 
2662
- expect(result.length).toBe(1);
2663
- const limits = result[0]?.limits;
3687
+ describe("restore subscription", () => {
3688
+ it("should clear cancelAtPeriodEnd when restoring a cancel_at_period_end subscription", async () => {
3689
+ const { client, auth, sessionSetter } = await getTestInstance(
3690
+ {
3691
+ database: memory,
3692
+ plugins: [stripe(stripeOptions)],
3693
+ },
3694
+ {
3695
+ disableTestUser: true,
3696
+ clientOptions: {
3697
+ plugins: [stripeClient({ subscription: true })],
3698
+ },
3699
+ },
3700
+ );
3701
+ const ctx = await auth.$context;
2664
3702
 
2665
- // Verify different types are preserved
2666
- expect(limits).toBeDefined();
3703
+ const userRes = await client.signUp.email(
3704
+ {
3705
+ email: "restore-period-end@test.com",
3706
+ password: "password",
3707
+ name: "Test",
3708
+ },
3709
+ { throw: true },
3710
+ );
2667
3711
 
2668
- // Type-safe access with unknown (cast once for test convenience)
2669
- const typedLimits = limits as Record<string, unknown>;
2670
- expect(typedLimits.maxUsers).toBe(100);
2671
- expect(typedLimits.maxProjects).toBe(10);
2672
- expect(typeof typedLimits.rateLimit).toBe("object");
2673
- expect(typedLimits.features).toEqual(["analytics", "api", "webhooks"]);
2674
- expect(Array.isArray(typedLimits.features)).toBe(true);
2675
- expect(Array.isArray(typedLimits.supportedMethods)).toBe(true);
2676
- expect((typedLimits.quotas as Record<string, unknown>).storage).toBe(50);
2677
- expect((typedLimits.rateLimit as Record<string, unknown>).requests).toBe(
2678
- 1000,
2679
- );
2680
- expect((typedLimits.permissions as Record<string, unknown>).admin).toBe(
2681
- true,
2682
- );
2683
- expect(
2684
- Array.isArray((typedLimits.quotas as Record<string, unknown>).bandwidth),
2685
- ).toBe(true);
2686
- });
3712
+ const headers = new Headers();
3713
+ await client.signIn.email(
3714
+ { email: "restore-period-end@test.com", password: "password" },
3715
+ { throw: true, onSuccess: sessionSetter(headers) },
3716
+ );
2687
3717
 
2688
- describe("Duplicate customer prevention on signup", () => {
2689
- it("should NOT create duplicate customer when email already exists in Stripe", async () => {
2690
- const existingEmail = "duplicate-email@example.com";
2691
- const existingCustomerId = "cus_stripe_existing_456";
3718
+ // Create subscription scheduled to cancel at period end
3719
+ await ctx.adapter.create({
3720
+ model: "subscription",
3721
+ data: {
3722
+ referenceId: userRes.user.id,
3723
+ stripeCustomerId: "cus_restore_test",
3724
+ stripeSubscriptionId: "sub_restore_period_end",
3725
+ status: "active",
3726
+ plan: "starter",
3727
+ cancelAtPeriodEnd: true,
3728
+ cancelAt: null,
3729
+ canceledAt: new Date(),
3730
+ },
3731
+ });
2692
3732
 
2693
- mockStripe.customers.list.mockResolvedValueOnce({
3733
+ mockStripe.subscriptions.list.mockResolvedValueOnce({
2694
3734
  data: [
2695
3735
  {
2696
- id: existingCustomerId,
2697
- email: existingEmail,
2698
- name: "Existing Stripe Customer",
3736
+ id: "sub_restore_period_end",
3737
+ status: "active",
3738
+ cancel_at_period_end: true,
3739
+ cancel_at: null,
2699
3740
  },
2700
3741
  ],
2701
3742
  });
2702
3743
 
2703
- const testOptionsWithHook = {
2704
- ...stripeOptions,
2705
- createCustomerOnSignUp: true,
2706
- } satisfies StripeOptions;
3744
+ mockStripe.subscriptions.update.mockResolvedValueOnce({
3745
+ id: "sub_restore_period_end",
3746
+ status: "active",
3747
+ cancel_at_period_end: false,
3748
+ cancel_at: null,
3749
+ });
2707
3750
 
2708
- const { client: testAuthClient, auth: testAuth } = await getTestInstance(
3751
+ const restoreRes = await client.subscription.restore({
3752
+ fetchOptions: { headers },
3753
+ });
3754
+
3755
+ expect(restoreRes.data).toBeDefined();
3756
+
3757
+ // Verify Stripe was called with correct params (cancel_at_period_end: false)
3758
+ expect(mockStripe.subscriptions.update).toHaveBeenCalledWith(
3759
+ "sub_restore_period_end",
3760
+ { cancel_at_period_end: false },
3761
+ );
3762
+
3763
+ const updatedSub = await ctx.adapter.findOne<Subscription>({
3764
+ model: "subscription",
3765
+ where: [{ field: "referenceId", value: userRes.user.id }],
3766
+ });
3767
+
3768
+ expect(updatedSub).toMatchObject({
3769
+ cancelAtPeriodEnd: false,
3770
+ cancelAt: null,
3771
+ canceledAt: null,
3772
+ });
3773
+ });
3774
+
3775
+ it("should clear cancelAt when restoring a cancel_at (specific date) subscription", async () => {
3776
+ const { client, auth, sessionSetter } = await getTestInstance(
2709
3777
  {
2710
3778
  database: memory,
2711
- plugins: [stripe(testOptionsWithHook)],
3779
+ plugins: [stripe(stripeOptions)],
2712
3780
  },
2713
3781
  {
2714
3782
  disableTestUser: true,
@@ -2717,60 +3785,89 @@ describe("stripe", () => {
2717
3785
  },
2718
3786
  },
2719
3787
  );
2720
- const testCtx = await testAuth.$context;
3788
+ const ctx = await auth.$context;
2721
3789
 
2722
- vi.clearAllMocks();
2723
-
2724
- // Sign up with email that exists in Stripe
2725
- const userRes = await testAuthClient.signUp.email(
3790
+ const userRes = await client.signUp.email(
2726
3791
  {
2727
- email: existingEmail,
3792
+ email: "restore-cancel-at@test.com",
2728
3793
  password: "password",
2729
- name: "Duplicate Email User",
3794
+ name: "Test",
2730
3795
  },
2731
3796
  { throw: true },
2732
3797
  );
2733
3798
 
2734
- // Should check for existing customer by email
2735
- expect(mockStripe.customers.list).toHaveBeenCalledWith({
2736
- email: existingEmail,
2737
- limit: 1,
2738
- });
3799
+ const headers = new Headers();
3800
+ await client.signIn.email(
3801
+ { email: "restore-cancel-at@test.com", password: "password" },
3802
+ { throw: true, onSuccess: sessionSetter(headers) },
3803
+ );
2739
3804
 
2740
- // Should NOT create duplicate customer
2741
- expect(mockStripe.customers.create).not.toHaveBeenCalled();
3805
+ const cancelAt = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000);
2742
3806
 
2743
- // Verify user has the EXISTING Stripe customer ID (not new duplicate)
2744
- const user = await testCtx.adapter.findOne<
2745
- User & { stripeCustomerId?: string }
2746
- >({
2747
- model: "user",
2748
- where: [{ field: "id", value: userRes.user.id }],
3807
+ // Create subscription scheduled to cancel at specific date
3808
+ await ctx.adapter.create({
3809
+ model: "subscription",
3810
+ data: {
3811
+ referenceId: userRes.user.id,
3812
+ stripeCustomerId: "cus_restore_cancel_at",
3813
+ stripeSubscriptionId: "sub_restore_cancel_at",
3814
+ status: "active",
3815
+ plan: "starter",
3816
+ cancelAtPeriodEnd: false,
3817
+ cancelAt: cancelAt,
3818
+ canceledAt: new Date(),
3819
+ },
2749
3820
  });
2750
- expect(user?.stripeCustomerId).toBe(existingCustomerId); // Should use existing ID
2751
- });
2752
3821
 
2753
- it("should CREATE customer only when user has no stripeCustomerId and none exists in Stripe", async () => {
2754
- const newEmail = "brand-new@example.com";
3822
+ mockStripe.subscriptions.list.mockResolvedValueOnce({
3823
+ data: [
3824
+ {
3825
+ id: "sub_restore_cancel_at",
3826
+ status: "active",
3827
+ cancel_at_period_end: false,
3828
+ cancel_at: Math.floor(cancelAt.getTime() / 1000),
3829
+ },
3830
+ ],
3831
+ });
2755
3832
 
2756
- mockStripe.customers.list.mockResolvedValueOnce({
2757
- data: [],
3833
+ mockStripe.subscriptions.update.mockResolvedValueOnce({
3834
+ id: "sub_restore_cancel_at",
3835
+ status: "active",
3836
+ cancel_at_period_end: false,
3837
+ cancel_at: null,
2758
3838
  });
2759
3839
 
2760
- mockStripe.customers.create.mockResolvedValueOnce({
2761
- id: "cus_new_created_789",
2762
- email: newEmail,
3840
+ const restoreRes = await client.subscription.restore({
3841
+ fetchOptions: { headers },
2763
3842
  });
2764
3843
 
2765
- const testOptionsWithHook = {
2766
- ...stripeOptions,
2767
- createCustomerOnSignUp: true,
2768
- } satisfies StripeOptions;
3844
+ expect(restoreRes.data).toBeDefined();
2769
3845
 
2770
- const { client: testAuthClient, auth: testAuth } = await getTestInstance(
3846
+ // Verify Stripe was called with correct params (cancel_at: "" to clear)
3847
+ expect(mockStripe.subscriptions.update).toHaveBeenCalledWith(
3848
+ "sub_restore_cancel_at",
3849
+ { cancel_at: "" },
3850
+ );
3851
+
3852
+ const updatedSub = await ctx.adapter.findOne<Subscription>({
3853
+ model: "subscription",
3854
+ where: [{ field: "referenceId", value: userRes.user.id }],
3855
+ });
3856
+
3857
+ expect(updatedSub).toMatchObject({
3858
+ cancelAtPeriodEnd: false,
3859
+ cancelAt: null,
3860
+ canceledAt: null,
3861
+ });
3862
+ });
3863
+ });
3864
+
3865
+ describe("cancel subscription fallback (missed webhook)", () => {
3866
+ it("should sync from Stripe when cancel request fails because subscription is already canceled", async () => {
3867
+ const { client, auth, sessionSetter } = await getTestInstance(
2771
3868
  {
2772
3869
  database: memory,
2773
- plugins: [stripe(testOptionsWithHook)],
3870
+ plugins: [stripe(stripeOptions)],
2774
3871
  },
2775
3872
  {
2776
3873
  disableTestUser: true,
@@ -2779,44 +3876,90 @@ describe("stripe", () => {
2779
3876
  },
2780
3877
  },
2781
3878
  );
2782
- const testCtx = await testAuth.$context;
2783
-
2784
- vi.clearAllMocks();
3879
+ const ctx = await auth.$context;
2785
3880
 
2786
- // Sign up with brand new email
2787
- const userRes = await testAuthClient.signUp.email(
3881
+ const userRes = await client.signUp.email(
2788
3882
  {
2789
- email: newEmail,
3883
+ email: "missed-webhook@test.com",
2790
3884
  password: "password",
2791
- name: "Brand New User",
3885
+ name: "Test",
2792
3886
  },
2793
3887
  { throw: true },
2794
3888
  );
2795
3889
 
2796
- // Should check for existing customer first
2797
- expect(mockStripe.customers.list).toHaveBeenCalledWith({
2798
- email: newEmail,
2799
- limit: 1,
2800
- });
3890
+ const headers = new Headers();
3891
+ await client.signIn.email(
3892
+ { email: "missed-webhook@test.com", password: "password" },
3893
+ { throw: true, onSuccess: sessionSetter(headers) },
3894
+ );
2801
3895
 
2802
- // Should create new customer (this is correct behavior)
2803
- expect(mockStripe.customers.create).toHaveBeenCalledTimes(1);
2804
- expect(mockStripe.customers.create).toHaveBeenCalledWith({
2805
- email: newEmail,
2806
- name: "Brand New User",
2807
- metadata: {
2808
- userId: userRes.user.id,
3896
+ const now = Math.floor(Date.now() / 1000);
3897
+ const cancelAt = now + 15 * 24 * 60 * 60;
3898
+
3899
+ // Create subscription in DB (not synced - missed webhook)
3900
+ const { id: subscriptionId } = await ctx.adapter.create({
3901
+ model: "subscription",
3902
+ data: {
3903
+ referenceId: userRes.user.id,
3904
+ stripeCustomerId: "cus_missed_webhook",
3905
+ stripeSubscriptionId: "sub_missed_webhook",
3906
+ status: "active",
3907
+ plan: "starter",
3908
+ cancelAtPeriodEnd: false, // DB thinks it's not canceling
3909
+ cancelAt: null,
3910
+ canceledAt: null,
2809
3911
  },
2810
3912
  });
2811
3913
 
2812
- // Verify user has the new Stripe customer ID
2813
- const user = await testCtx.adapter.findOne<
2814
- User & { stripeCustomerId?: string }
2815
- >({
2816
- model: "user",
2817
- where: [{ field: "id", value: userRes.user.id }],
3914
+ // Stripe has the subscription already scheduled to cancel with cancel_at
3915
+ mockStripe.subscriptions.list.mockResolvedValueOnce({
3916
+ data: [
3917
+ {
3918
+ id: "sub_missed_webhook",
3919
+ status: "active",
3920
+ cancel_at_period_end: false,
3921
+ cancel_at: cancelAt,
3922
+ },
3923
+ ],
2818
3924
  });
2819
- expect(user?.stripeCustomerId).toBeDefined();
3925
+
3926
+ // Billing portal returns error because subscription is already set to cancel
3927
+ mockStripe.billingPortal.sessions.create.mockRejectedValueOnce(
3928
+ new Error("This subscription is already set to be canceled"),
3929
+ );
3930
+
3931
+ // When fallback kicks in, it retrieves from Stripe
3932
+ mockStripe.subscriptions.retrieve.mockResolvedValueOnce({
3933
+ id: "sub_missed_webhook",
3934
+ status: "active",
3935
+ cancel_at_period_end: false,
3936
+ cancel_at: cancelAt,
3937
+ canceled_at: now,
3938
+ });
3939
+
3940
+ // Try to cancel - should fail but trigger sync
3941
+ const cancelRes = await client.subscription.cancel({
3942
+ returnUrl: "/account",
3943
+ fetchOptions: { headers },
3944
+ });
3945
+
3946
+ // Should have error because portal creation failed
3947
+ expect(cancelRes.error).toBeDefined();
3948
+
3949
+ // But DB should now be synced with Stripe's actual state
3950
+ const updatedSub = await ctx.adapter.findOne<Subscription>({
3951
+ model: "subscription",
3952
+ where: [{ field: "id", value: subscriptionId }],
3953
+ });
3954
+
3955
+ expect(updatedSub).toMatchObject({
3956
+ cancelAtPeriodEnd: false,
3957
+ cancelAt: expect.any(Date),
3958
+ canceledAt: expect.any(Date),
3959
+ });
3960
+
3961
+ // Verify it's the correct cancel_at date from Stripe
3962
+ expect(updatedSub!.cancelAt!.getTime()).toBe(cancelAt * 1000);
2820
3963
  });
2821
3964
  });
2822
3965
  });