@better-auth/stripe 1.5.0-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.
- package/.turbo/turbo-build.log +7 -7
- package/dist/client.d.mts +2 -1
- package/dist/client.mjs +4 -1
- package/dist/{index-DpiQGYLJ.d.mts → index-SbT5j9k6.d.mts} +45 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +201 -59
- package/package.json +5 -5
- package/src/client.ts +1 -0
- package/src/hooks.ts +166 -17
- package/src/middleware.ts +1 -2
- package/src/routes.ts +126 -84
- package/src/schema.ts +12 -0
- package/src/stripe.test.ts +2434 -1291
- package/src/types.ts +30 -1
- package/src/utils.ts +25 -1
package/src/stripe.test.ts
CHANGED
|
@@ -769,11 +769,24 @@ describe("stripe", () => {
|
|
|
769
769
|
}
|
|
770
770
|
});
|
|
771
771
|
|
|
772
|
-
it("should
|
|
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(
|
|
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
|
-
|
|
797
|
+
// Create a user with stripeCustomerId
|
|
798
|
+
const userWithCustomerId = await testCtx.adapter.create({
|
|
785
799
|
model: "user",
|
|
786
800
|
data: {
|
|
787
|
-
email: "
|
|
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
|
|
792
|
-
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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()
|
|
871
|
+
constructEventAsync: vi.fn(),
|
|
839
872
|
},
|
|
840
873
|
};
|
|
841
874
|
|
|
842
|
-
const
|
|
843
|
-
...
|
|
844
|
-
stripeClient:
|
|
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:
|
|
885
|
+
const { auth: testAuth } = await getTestInstance(
|
|
848
886
|
{
|
|
849
887
|
database: memory,
|
|
850
|
-
plugins: [stripe(
|
|
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
|
-
|
|
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
|
-
|
|
907
|
+
// Create existing subscription
|
|
908
|
+
await testCtx.adapter.create({
|
|
860
909
|
model: "subscription",
|
|
861
910
|
data: {
|
|
862
|
-
referenceId:
|
|
863
|
-
stripeCustomerId: "
|
|
864
|
-
stripeSubscriptionId: "
|
|
865
|
-
status: "
|
|
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
|
|
871
|
-
|
|
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:
|
|
901
|
-
customer: "
|
|
923
|
+
id: "sub_already_exists",
|
|
924
|
+
customer: "cus_duplicate_test",
|
|
902
925
|
status: "active",
|
|
903
926
|
items: {
|
|
904
|
-
data: [
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
953
|
+
body: JSON.stringify(mockEvent),
|
|
920
954
|
},
|
|
921
955
|
);
|
|
922
956
|
|
|
923
|
-
|
|
924
|
-
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
|
956
|
-
|
|
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
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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
|
-
|
|
967
|
-
|
|
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:
|
|
975
|
-
customer: "
|
|
1012
|
+
id: "sub_no_user",
|
|
1013
|
+
customer: "cus_nonexistent",
|
|
976
1014
|
status: "active",
|
|
977
|
-
cancel_at_period_end: true,
|
|
978
1015
|
items: {
|
|
979
|
-
data: [
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1042
|
+
body: JSON.stringify(mockEvent),
|
|
995
1043
|
},
|
|
996
1044
|
);
|
|
997
1045
|
|
|
998
|
-
|
|
999
|
-
|
|
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
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
-
|
|
1032
|
-
deleteEvent,
|
|
1033
|
-
);
|
|
1034
|
-
await eventTestAuth.handler(deleteRequest);
|
|
1055
|
+
expect(subscription).toBeNull();
|
|
1035
1056
|
|
|
1036
|
-
|
|
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
|
|
1040
|
-
const
|
|
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()
|
|
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
|
-
|
|
1077
|
+
onSubscriptionCreated: onSubscriptionCreatedCallback,
|
|
1081
1078
|
},
|
|
1082
|
-
} as
|
|
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
|
-
|
|
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: "
|
|
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
|
|
1104
|
-
|
|
1103
|
+
const mockEvent = {
|
|
1104
|
+
type: "customer.subscription.created",
|
|
1105
1105
|
data: {
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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(
|
|
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
|
-
//
|
|
1142
|
-
const
|
|
1144
|
+
// Verify subscription was NOT created (no matching plan)
|
|
1145
|
+
const subscription = await testCtx.adapter.findOne<Subscription>({
|
|
1143
1146
|
model: "subscription",
|
|
1144
|
-
where: [{ field: "
|
|
1147
|
+
where: [{ field: "stripeSubscriptionId", value: "sub_no_plan" }],
|
|
1145
1148
|
});
|
|
1146
|
-
|
|
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
|
|
1150
|
-
const {
|
|
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
|
|
1166
|
+
const testCtx = await testAuth.$context;
|
|
1163
1167
|
|
|
1164
|
-
const
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
email: "
|
|
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
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
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
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1207
|
+
const mockSubscription = {
|
|
1208
|
+
status: "active",
|
|
1209
|
+
items: {
|
|
1210
|
+
data: [{ price: { id: process.env.STRIPE_PRICE_ID_1 } }],
|
|
1198
1211
|
},
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
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
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|
-
|
|
1216
|
-
|
|
1226
|
+
const eventTestOptions = {
|
|
1227
|
+
...testOptions,
|
|
1228
|
+
stripeClient: mockStripeForEvents as unknown as Stripe,
|
|
1229
|
+
};
|
|
1217
1230
|
|
|
1218
|
-
|
|
1219
|
-
const { client, auth, sessionSetter } = await getTestInstance(
|
|
1231
|
+
const { auth: eventTestAuth } = await getTestInstance(
|
|
1220
1232
|
{
|
|
1221
1233
|
database: memory,
|
|
1222
|
-
plugins: [stripe(
|
|
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
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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
|
|
1244
|
-
|
|
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
|
-
|
|
1251
|
-
|
|
1257
|
+
method: "POST",
|
|
1258
|
+
headers: {
|
|
1259
|
+
"stripe-signature": "test_signature",
|
|
1260
|
+
},
|
|
1261
|
+
body: JSON.stringify(completeEvent),
|
|
1252
1262
|
},
|
|
1253
1263
|
);
|
|
1254
1264
|
|
|
1255
|
-
await
|
|
1256
|
-
plan: "starter",
|
|
1257
|
-
seats: 3,
|
|
1258
|
-
fetchOptions: {
|
|
1259
|
-
headers,
|
|
1260
|
-
},
|
|
1261
|
-
});
|
|
1265
|
+
await eventTestAuth.handler(webhookRequest);
|
|
1262
1266
|
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
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
|
-
|
|
1290
|
-
|
|
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
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
+
method: "POST",
|
|
1300
|
+
headers: {
|
|
1301
|
+
"stripe-signature": "test_signature",
|
|
1299
1302
|
},
|
|
1303
|
+
body: JSON.stringify(updateEvent),
|
|
1300
1304
|
},
|
|
1301
1305
|
);
|
|
1302
1306
|
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
{ throw: true },
|
|
1307
|
+
mockStripeForEvents.webhooks.constructEventAsync.mockReturnValue(
|
|
1308
|
+
updateEvent,
|
|
1306
1309
|
);
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
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
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
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
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1342
|
+
method: "POST",
|
|
1343
|
+
headers: {
|
|
1344
|
+
"stripe-signature": "test_signature",
|
|
1335
1345
|
},
|
|
1346
|
+
body: JSON.stringify(userCancelEvent),
|
|
1336
1347
|
},
|
|
1337
1348
|
);
|
|
1338
1349
|
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
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
|
-
|
|
1374
|
+
method: "POST",
|
|
1375
|
+
headers: {
|
|
1376
|
+
"stripe-signature": "test_signature",
|
|
1377
|
+
},
|
|
1378
|
+
body: JSON.stringify(cancelEvent),
|
|
1346
1379
|
},
|
|
1347
1380
|
);
|
|
1348
1381
|
|
|
1349
|
-
|
|
1350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1375
|
-
/* cspell:disable-next-line */
|
|
1376
|
-
const orgId = "org_b67GF32Cljh7u588AuEblmLVobclDRcP";
|
|
1387
|
+
expect(onSubscriptionCancel).toHaveBeenCalled();
|
|
1377
1388
|
|
|
1378
|
-
const
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
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
|
-
}
|
|
1402
|
+
};
|
|
1386
1403
|
|
|
1387
|
-
const
|
|
1388
|
-
auth
|
|
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
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
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
|
-
|
|
1406
|
-
|
|
1407
|
-
{ ...testUser, email: "org-ref@email.com" },
|
|
1408
|
-
{ throw: true },
|
|
1415
|
+
mockStripeForEvents.webhooks.constructEventAsync.mockReturnValue(
|
|
1416
|
+
deleteEvent,
|
|
1409
1417
|
);
|
|
1410
|
-
|
|
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
|
-
|
|
1423
|
-
|
|
1424
|
-
where: [{ field: "referenceId", value: userRes.user.id }],
|
|
1425
|
-
});
|
|
1426
|
-
expect(personalSub).toBeTruthy();
|
|
1420
|
+
expect(onSubscriptionDeleted).toHaveBeenCalled();
|
|
1421
|
+
});
|
|
1427
1422
|
|
|
1428
|
-
|
|
1429
|
-
|
|
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
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
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
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
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
|
|
1487
|
+
const { id: testSubscriptionId } = await ctx.adapter.create({
|
|
1466
1488
|
model: "subscription",
|
|
1467
|
-
|
|
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
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
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
|
-
|
|
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:
|
|
1528
|
+
where: [{ field: "id", value: testSubscriptionId }],
|
|
1478
1529
|
});
|
|
1479
|
-
expect(
|
|
1530
|
+
expect(updatedSub?.seats).toBe(5);
|
|
1480
1531
|
});
|
|
1481
1532
|
|
|
1482
|
-
it("should
|
|
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
|
-
{
|
|
1500
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
1513
|
-
const firstUpgradeRes = await client.subscription.upgrade({
|
|
1570
|
+
await client.subscription.upgrade({
|
|
1514
1571
|
plan: "starter",
|
|
1515
|
-
|
|
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: "
|
|
1581
|
+
status: "active",
|
|
1541
1582
|
},
|
|
1542
1583
|
where: [
|
|
1543
1584
|
{
|
|
@@ -1547,41 +1588,18 @@ describe("stripe", () => {
|
|
|
1547
1588
|
],
|
|
1548
1589
|
});
|
|
1549
1590
|
|
|
1550
|
-
|
|
1551
|
-
const secondUpgradeRes = await client.subscription.upgrade({
|
|
1591
|
+
const upgradeRes = await client.subscription.upgrade({
|
|
1552
1592
|
plan: "starter",
|
|
1553
|
-
|
|
1593
|
+
seats: 5,
|
|
1594
|
+
fetchOptions: {
|
|
1595
|
+
headers,
|
|
1596
|
+
},
|
|
1554
1597
|
});
|
|
1555
1598
|
|
|
1556
|
-
expect(
|
|
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
|
|
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
|
-
{
|
|
1602
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
1641
|
+
seats: 3,
|
|
1642
|
+
fetchOptions: {
|
|
1643
|
+
headers,
|
|
1644
|
+
},
|
|
1623
1645
|
});
|
|
1624
1646
|
|
|
1625
|
-
|
|
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
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
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
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
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
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
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
|
-
|
|
1666
|
-
|
|
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: "
|
|
1824
|
+
id: "sub_personal_active_123",
|
|
1670
1825
|
status: "active",
|
|
1671
1826
|
items: {
|
|
1672
1827
|
data: [
|
|
1673
1828
|
{
|
|
1674
|
-
id: "
|
|
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
|
-
//
|
|
1688
|
-
|
|
1689
|
-
|
|
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
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
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
|
-
|
|
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(
|
|
1863
|
+
expect(personalAfter?.status).toBe("active");
|
|
1728
1864
|
});
|
|
1729
1865
|
|
|
1730
|
-
it("should prevent multiple free trials
|
|
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: "
|
|
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: "
|
|
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
|
|
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
|
|
1934
|
+
// Try to subscribe again - should NOT get a trial
|
|
1799
1935
|
const secondUpgradeRes = await client.subscription.upgrade({
|
|
1800
|
-
plan: "
|
|
1936
|
+
plan: "starter",
|
|
1801
1937
|
fetchOptions: { headers },
|
|
1802
1938
|
});
|
|
1803
1939
|
|
|
1804
1940
|
expect(secondUpgradeRes.data?.url).toBeDefined();
|
|
1805
1941
|
|
|
1806
|
-
// Verify that the
|
|
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
|
|
1818
|
-
expect(subscriptions
|
|
1955
|
+
// Should have 2 subscriptions (first canceled, second new)
|
|
1956
|
+
expect(subscriptions).toHaveLength(2);
|
|
1819
1957
|
|
|
1820
|
-
//
|
|
1821
|
-
const
|
|
1822
|
-
(s: Subscription) => s.
|
|
1823
|
-
)
|
|
1824
|
-
expect(
|
|
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
|
|
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: [
|
|
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
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
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
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
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
|
-
//
|
|
1864
|
-
|
|
1865
|
-
|
|
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(
|
|
2037
|
+
expect(upgradeRes.data?.url).toBeDefined();
|
|
1869
2038
|
|
|
1870
|
-
// Verify
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1905
|
-
|
|
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
|
-
|
|
1908
|
-
|
|
1909
|
-
email: "
|
|
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
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
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
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
2082
|
+
// First create a starter subscription
|
|
2083
|
+
await client.subscription.upgrade({
|
|
2084
|
+
plan: "starter",
|
|
2085
|
+
fetchOptions: { headers },
|
|
2086
|
+
});
|
|
1924
2087
|
|
|
1925
|
-
|
|
2088
|
+
// Simulate the subscription being active
|
|
2089
|
+
const starterSub = await ctx.adapter.findOne<Subscription>({
|
|
2090
|
+
model: "subscription",
|
|
2091
|
+
where: [
|
|
1926
2092
|
{
|
|
1927
|
-
|
|
1928
|
-
|
|
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
|
-
|
|
1932
|
-
|
|
1933
|
-
plugins: [stripeClient({ subscription: true })],
|
|
1934
|
-
},
|
|
2108
|
+
field: "id",
|
|
2109
|
+
value: starterSub!.id,
|
|
1935
2110
|
},
|
|
1936
|
-
|
|
2111
|
+
],
|
|
2112
|
+
});
|
|
1937
2113
|
|
|
1938
|
-
|
|
1939
|
-
|
|
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
|
-
|
|
1942
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
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
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
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
|
-
|
|
1976
|
-
|
|
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
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1995
|
-
|
|
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
|
-
|
|
1999
|
-
|
|
2000
|
-
plugins: [stripeClient({ subscription: true })],
|
|
2001
|
-
},
|
|
2241
|
+
field: "referenceId",
|
|
2242
|
+
value: userRes.user.id,
|
|
2002
2243
|
},
|
|
2003
|
-
|
|
2244
|
+
],
|
|
2245
|
+
});
|
|
2004
2246
|
|
|
2005
|
-
|
|
2006
|
-
|
|
2247
|
+
// Cancel the subscription
|
|
2248
|
+
await ctx.adapter.update({
|
|
2249
|
+
model: "subscription",
|
|
2250
|
+
update: {
|
|
2251
|
+
status: "canceled",
|
|
2252
|
+
},
|
|
2253
|
+
where: [
|
|
2007
2254
|
{
|
|
2008
|
-
|
|
2009
|
-
|
|
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
|
-
|
|
2274
|
+
field: "referenceId",
|
|
2275
|
+
value: userRes.user.id,
|
|
2014
2276
|
},
|
|
2015
|
-
|
|
2277
|
+
],
|
|
2278
|
+
})) as Subscription[];
|
|
2016
2279
|
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
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
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
2402
|
+
const userRes = await testClient.signUp.email(
|
|
2066
2403
|
{
|
|
2067
|
-
email: "
|
|
2404
|
+
email: "custom-params@email.com",
|
|
2068
2405
|
password: "password",
|
|
2069
|
-
name: "
|
|
2406
|
+
name: "Custom User",
|
|
2070
2407
|
},
|
|
2071
2408
|
{
|
|
2072
2409
|
throw: true,
|
|
2073
2410
|
},
|
|
2074
2411
|
);
|
|
2075
2412
|
|
|
2076
|
-
// Verify
|
|
2077
|
-
|
|
2078
|
-
expect(mockStripe.customers.create).toHaveBeenCalledWith(
|
|
2413
|
+
// Verify getCustomerCreateParams was called
|
|
2414
|
+
expect(getCustomerCreateParamsMock).toHaveBeenCalledWith(
|
|
2079
2415
|
expect.objectContaining({
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
3190
|
+
email: existingEmail,
|
|
2117
3191
|
password: "password",
|
|
2118
|
-
name: "
|
|
2119
|
-
},
|
|
2120
|
-
{
|
|
2121
|
-
throw: true,
|
|
3192
|
+
name: "Duplicate Email User",
|
|
2122
3193
|
},
|
|
3194
|
+
{ throw: true },
|
|
2123
3195
|
);
|
|
2124
3196
|
|
|
2125
|
-
//
|
|
2126
|
-
expect(mockStripe.customers.
|
|
2127
|
-
email:
|
|
2128
|
-
|
|
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
|
-
|
|
2163
|
-
|
|
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
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
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
|
|
2180
|
-
const
|
|
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
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
expect(data.message).toContain("Stripe webhook secret not found");
|
|
2205
|
-
});
|
|
3219
|
+
mockStripe.customers.list.mockResolvedValueOnce({
|
|
3220
|
+
data: [],
|
|
3221
|
+
});
|
|
2206
3222
|
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
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
|
|
3228
|
+
const testOptionsWithHook = {
|
|
2216
3229
|
...stripeOptions,
|
|
2217
|
-
|
|
2218
|
-
|
|
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(
|
|
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
|
-
|
|
2232
|
-
|
|
3247
|
+
vi.clearAllMocks();
|
|
3248
|
+
|
|
3249
|
+
// Sign up with brand new email
|
|
3250
|
+
const userRes = await testAuthClient.signUp.email(
|
|
2233
3251
|
{
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
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
|
-
|
|
2243
|
-
expect(
|
|
2244
|
-
|
|
2245
|
-
|
|
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
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
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
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
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
|
-
|
|
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(
|
|
2291
|
-
},
|
|
2292
|
-
{
|
|
2293
|
-
disableTestUser: true,
|
|
3291
|
+
plugins: [stripe(stripeOptions)],
|
|
2294
3292
|
},
|
|
3293
|
+
{ disableTestUser: true },
|
|
2295
3294
|
);
|
|
2296
|
-
const
|
|
3295
|
+
const ctx = await auth.$context;
|
|
2297
3296
|
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
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
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
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
|
-
|
|
2331
|
-
const
|
|
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: "
|
|
2336
|
-
customer: "
|
|
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:
|
|
3336
|
+
price: { id: "price_starter_123", lookup_key: null },
|
|
2342
3337
|
quantity: 1,
|
|
2343
|
-
current_period_start:
|
|
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
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
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
|
-
|
|
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: "
|
|
3361
|
+
stripeWebhookSecret: "test_secret",
|
|
2368
3362
|
};
|
|
2369
3363
|
|
|
2370
|
-
const { auth:
|
|
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
|
-
|
|
3400
|
+
database: memory,
|
|
3401
|
+
plugins: [stripe(stripeOptions)],
|
|
2377
3402
|
},
|
|
3403
|
+
{ disableTestUser: true },
|
|
2378
3404
|
);
|
|
2379
|
-
const
|
|
3405
|
+
const ctx = await auth.$context;
|
|
2380
3406
|
|
|
2381
|
-
const { id:
|
|
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
|
-
|
|
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:
|
|
2392
|
-
stripeCustomerId: "
|
|
2393
|
-
stripeSubscriptionId: "
|
|
2394
|
-
status: "
|
|
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
|
-
|
|
2400
|
-
|
|
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: "
|
|
2428
|
-
customer: "
|
|
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(
|
|
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: "
|
|
3466
|
+
stripeWebhookSecret: "test_secret",
|
|
2445
3467
|
};
|
|
2446
3468
|
|
|
2447
|
-
const { auth:
|
|
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
|
|
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
|
-
|
|
2463
|
-
|
|
2464
|
-
body: JSON.stringify(mockEvent),
|
|
2465
|
-
},
|
|
3481
|
+
headers: { "stripe-signature": "test_signature" },
|
|
3482
|
+
body: JSON.stringify(webhookEvent),
|
|
3483
|
+
}),
|
|
2466
3484
|
);
|
|
2467
3485
|
|
|
2468
|
-
|
|
3486
|
+
expect(response.status).toBe(200);
|
|
2469
3487
|
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
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
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
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
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
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: "
|
|
2490
|
-
customer: "
|
|
2491
|
-
status: "
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
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
|
-
|
|
2511
|
-
const stripeV18 = {
|
|
3551
|
+
const stripeForTest = {
|
|
2512
3552
|
...stripeOptions.stripeClient,
|
|
2513
3553
|
webhooks: {
|
|
2514
|
-
|
|
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:
|
|
2523
|
-
stripeWebhookSecret: "
|
|
3560
|
+
stripeClient: stripeForTest as unknown as Stripe,
|
|
3561
|
+
stripeWebhookSecret: "test_secret",
|
|
2524
3562
|
};
|
|
2525
3563
|
|
|
2526
|
-
const { auth:
|
|
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
|
-
|
|
3596
|
+
database: memory,
|
|
3597
|
+
plugins: [stripe(stripeOptions)],
|
|
2533
3598
|
},
|
|
3599
|
+
{ disableTestUser: true },
|
|
2534
3600
|
);
|
|
2535
|
-
const
|
|
3601
|
+
const ctx = await auth.$context;
|
|
2536
3602
|
|
|
2537
|
-
const { id:
|
|
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
|
-
|
|
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:
|
|
2548
|
-
stripeCustomerId: "
|
|
2549
|
-
stripeSubscriptionId: "
|
|
2550
|
-
status: "
|
|
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
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
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
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
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
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
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
|
-
|
|
3658
|
+
{ disableTestUser: true },
|
|
3659
|
+
);
|
|
3660
|
+
const webhookCtx = await webhookAuth.$context;
|
|
2631
3661
|
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
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
|
-
|
|
2641
|
-
{ email: "limits@test.com", password: "password" },
|
|
2642
|
-
{ throw: true, onSuccess: testSessionSetter(headers) },
|
|
2643
|
-
);
|
|
3670
|
+
expect(response.status).toBe(200);
|
|
2644
3671
|
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
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
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
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
|
-
|
|
2663
|
-
|
|
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
|
-
|
|
2666
|
-
|
|
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
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
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
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
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.
|
|
3733
|
+
mockStripe.subscriptions.list.mockResolvedValueOnce({
|
|
2694
3734
|
data: [
|
|
2695
3735
|
{
|
|
2696
|
-
id:
|
|
2697
|
-
|
|
2698
|
-
|
|
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
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
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
|
|
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(
|
|
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
|
|
3788
|
+
const ctx = await auth.$context;
|
|
2721
3789
|
|
|
2722
|
-
|
|
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:
|
|
3792
|
+
email: "restore-cancel-at@test.com",
|
|
2728
3793
|
password: "password",
|
|
2729
|
-
name: "
|
|
3794
|
+
name: "Test",
|
|
2730
3795
|
},
|
|
2731
3796
|
{ throw: true },
|
|
2732
3797
|
);
|
|
2733
3798
|
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
email:
|
|
2737
|
-
|
|
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
|
-
|
|
2741
|
-
expect(mockStripe.customers.create).not.toHaveBeenCalled();
|
|
3805
|
+
const cancelAt = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000);
|
|
2742
3806
|
|
|
2743
|
-
//
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
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
|
-
|
|
2754
|
-
|
|
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.
|
|
2757
|
-
|
|
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
|
-
|
|
2761
|
-
|
|
2762
|
-
email: newEmail,
|
|
3840
|
+
const restoreRes = await client.subscription.restore({
|
|
3841
|
+
fetchOptions: { headers },
|
|
2763
3842
|
});
|
|
2764
3843
|
|
|
2765
|
-
|
|
2766
|
-
...stripeOptions,
|
|
2767
|
-
createCustomerOnSignUp: true,
|
|
2768
|
-
} satisfies StripeOptions;
|
|
3844
|
+
expect(restoreRes.data).toBeDefined();
|
|
2769
3845
|
|
|
2770
|
-
|
|
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(
|
|
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
|
|
2783
|
-
|
|
2784
|
-
vi.clearAllMocks();
|
|
3879
|
+
const ctx = await auth.$context;
|
|
2785
3880
|
|
|
2786
|
-
|
|
2787
|
-
const userRes = await testAuthClient.signUp.email(
|
|
3881
|
+
const userRes = await client.signUp.email(
|
|
2788
3882
|
{
|
|
2789
|
-
email:
|
|
3883
|
+
email: "missed-webhook@test.com",
|
|
2790
3884
|
password: "password",
|
|
2791
|
-
name: "
|
|
3885
|
+
name: "Test",
|
|
2792
3886
|
},
|
|
2793
3887
|
{ throw: true },
|
|
2794
3888
|
);
|
|
2795
3889
|
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
email:
|
|
2799
|
-
|
|
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
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
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
|
-
//
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
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
|
-
|
|
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
|
});
|