@better-auth/stripe 1.5.0-beta.8 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4807 +0,0 @@
1
- import { runWithEndpointContext } from "@better-auth/core/context";
2
- import type { Auth, User } from "better-auth";
3
- import { memoryAdapter } from "better-auth/adapters/memory";
4
- import { organization } from "better-auth/plugins/organization";
5
- import { getTestInstance } from "better-auth/test";
6
- import type Stripe from "stripe";
7
- import {
8
- assert,
9
- beforeEach,
10
- describe,
11
- expect,
12
- expectTypeOf,
13
- it,
14
- vi,
15
- } from "vitest";
16
- import type { StripePlugin } from "../src";
17
- import { stripe } from "../src";
18
- import { stripeClient } from "../src/client";
19
- import type { StripeOptions, Subscription } from "../src/types";
20
-
21
- describe("stripe type", () => {
22
- it("should api endpoint exists", () => {
23
- type Plugins = [
24
- StripePlugin<{
25
- stripeClient: Stripe;
26
- stripeWebhookSecret: string;
27
- subscription: {
28
- enabled: false;
29
- };
30
- }>,
31
- ];
32
- type MyAuth = Auth<{
33
- plugins: Plugins;
34
- }>;
35
- expectTypeOf<MyAuth["api"]["stripeWebhook"]>().toBeFunction();
36
- });
37
-
38
- it("should have subscription endpoints", () => {
39
- type Plugins = [
40
- StripePlugin<{
41
- stripeClient: Stripe;
42
- stripeWebhookSecret: string;
43
- subscription: {
44
- enabled: true;
45
- plans: [];
46
- };
47
- }>,
48
- ];
49
- type MyAuth = Auth<{
50
- plugins: Plugins;
51
- }>;
52
- expectTypeOf<MyAuth["api"]["stripeWebhook"]>().toBeFunction();
53
- expectTypeOf<MyAuth["api"]["subscriptionSuccess"]>().toBeFunction();
54
- expectTypeOf<MyAuth["api"]["listActiveSubscriptions"]>().toBeFunction();
55
- expectTypeOf<MyAuth["api"]["cancelSubscriptionCallback"]>().toBeFunction();
56
- expectTypeOf<MyAuth["api"]["cancelSubscription"]>().toBeFunction();
57
- expectTypeOf<MyAuth["api"]["restoreSubscription"]>().toBeFunction();
58
- expectTypeOf<MyAuth["api"]["upgradeSubscription"]>().toBeFunction();
59
- expectTypeOf<MyAuth["api"]["createBillingPortal"]>().toBeFunction();
60
- });
61
-
62
- it("should infer plugin schema fields on user type", async () => {
63
- const { auth } = await getTestInstance({
64
- plugins: [
65
- stripe({
66
- stripeClient: {} as Stripe,
67
- stripeWebhookSecret: "test",
68
- }),
69
- ],
70
- });
71
- expectTypeOf<
72
- (typeof auth)["$Infer"]["Session"]["user"]["stripeCustomerId"]
73
- >().toEqualTypeOf<string | null | undefined>();
74
- });
75
-
76
- it("should infer plugin schema fields alongside additional user fields", async () => {
77
- const { auth } = await getTestInstance({
78
- plugins: [
79
- stripe({
80
- stripeClient: {} as Stripe,
81
- stripeWebhookSecret: "test",
82
- }),
83
- ],
84
- user: {
85
- additionalFields: {
86
- customField: {
87
- type: "string",
88
- required: false,
89
- },
90
- },
91
- },
92
- });
93
- expectTypeOf<
94
- (typeof auth)["$Infer"]["Session"]["user"]["stripeCustomerId"]
95
- >().toEqualTypeOf<string | null | undefined>();
96
- expectTypeOf<
97
- (typeof auth)["$Infer"]["Session"]["user"]["customField"]
98
- >().toEqualTypeOf<string | null | undefined>();
99
- });
100
- });
101
-
102
- describe("stripe", () => {
103
- const mockStripe = {
104
- prices: {
105
- list: vi.fn().mockResolvedValue({ data: [{ id: "price_lookup_123" }] }),
106
- },
107
- customers: {
108
- create: vi.fn().mockResolvedValue({ id: "cus_mock123" }),
109
- list: vi.fn().mockResolvedValue({ data: [] }),
110
- search: vi.fn().mockResolvedValue({ data: [] }),
111
- retrieve: vi.fn().mockResolvedValue({
112
- id: "cus_mock123",
113
- email: "test@email.com",
114
- deleted: false,
115
- }),
116
- update: vi.fn().mockResolvedValue({
117
- id: "cus_mock123",
118
- email: "newemail@example.com",
119
- }),
120
- },
121
- checkout: {
122
- sessions: {
123
- create: vi.fn().mockResolvedValue({
124
- url: "https://checkout.stripe.com/mock",
125
- id: "",
126
- }),
127
- },
128
- },
129
- billingPortal: {
130
- sessions: {
131
- create: vi
132
- .fn()
133
- .mockResolvedValue({ url: "https://billing.stripe.com/mock" }),
134
- },
135
- },
136
- subscriptions: {
137
- retrieve: vi.fn(),
138
- list: vi.fn().mockResolvedValue({ data: [] }),
139
- update: vi.fn(),
140
- },
141
- webhooks: {
142
- constructEventAsync: vi.fn(),
143
- },
144
- };
145
- const _stripe = mockStripe as unknown as Stripe;
146
- const stripeOptions = {
147
- stripeClient: _stripe,
148
- stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
149
- createCustomerOnSignUp: true,
150
- subscription: {
151
- enabled: true,
152
- plans: [
153
- {
154
- priceId: process.env.STRIPE_PRICE_ID_1!,
155
- name: "starter",
156
- lookupKey: "lookup_key_123",
157
- },
158
- {
159
- priceId: process.env.STRIPE_PRICE_ID_2!,
160
- name: "premium",
161
- lookupKey: "lookup_key_234",
162
- },
163
- ],
164
- },
165
- } satisfies StripeOptions;
166
-
167
- const testUser = {
168
- email: "test@email.com",
169
- password: "password",
170
- name: "Test User",
171
- };
172
- const data = {
173
- user: [],
174
- session: [],
175
- verification: [],
176
- account: [],
177
- customer: [],
178
- subscription: [],
179
- };
180
-
181
- beforeEach(() => {
182
- data.user = [];
183
- data.session = [];
184
- data.verification = [];
185
- data.account = [];
186
- data.customer = [];
187
- data.subscription = [];
188
-
189
- vi.clearAllMocks();
190
- });
191
-
192
- const memory = memoryAdapter(data);
193
-
194
- it("should create a customer on sign up", async () => {
195
- const { client, auth } = await getTestInstance(
196
- {
197
- database: memory,
198
- plugins: [stripe(stripeOptions)],
199
- },
200
- {
201
- disableTestUser: true,
202
- clientOptions: {
203
- plugins: [stripeClient({ subscription: true })],
204
- },
205
- },
206
- );
207
- const ctx = await auth.$context;
208
-
209
- const userRes = await client.signUp.email(testUser, {
210
- throw: true,
211
- });
212
- const res = await ctx.adapter.findOne<User>({
213
- model: "user",
214
- where: [
215
- {
216
- field: "id",
217
- value: userRes.user.id,
218
- },
219
- ],
220
- });
221
- expect(res).toMatchObject({
222
- id: expect.any(String),
223
- stripeCustomerId: expect.any(String),
224
- });
225
- });
226
-
227
- it("should create a subscription", async () => {
228
- const { client, auth, sessionSetter } = await getTestInstance(
229
- {
230
- database: memory,
231
- plugins: [stripe(stripeOptions)],
232
- },
233
- {
234
- disableTestUser: true,
235
- clientOptions: {
236
- plugins: [stripeClient({ subscription: true })],
237
- },
238
- },
239
- );
240
- const ctx = await auth.$context;
241
-
242
- const userRes = await client.signUp.email(testUser, {
243
- throw: true,
244
- });
245
-
246
- const headers = new Headers();
247
- await client.signIn.email(testUser, {
248
- throw: true,
249
- onSuccess: sessionSetter(headers),
250
- });
251
-
252
- const res = await client.subscription.upgrade({
253
- plan: "starter",
254
- fetchOptions: {
255
- headers,
256
- },
257
- });
258
- expect(res.data?.url).toBeDefined();
259
- const subscription = await ctx.adapter.findOne<Subscription>({
260
- model: "subscription",
261
- where: [
262
- {
263
- field: "referenceId",
264
- value: userRes.user.id,
265
- },
266
- ],
267
- });
268
- expect(subscription).toMatchObject({
269
- id: expect.any(String),
270
- plan: "starter",
271
- referenceId: userRes.user.id,
272
- stripeCustomerId: expect.any(String),
273
- status: "incomplete",
274
- periodStart: undefined,
275
- cancelAtPeriodEnd: false,
276
- trialStart: undefined,
277
- trialEnd: undefined,
278
- });
279
- });
280
-
281
- it("should not allow cross-user subscriptionId operations (upgrade/cancel/restore)", async () => {
282
- const { client, auth, sessionSetter } = await getTestInstance(
283
- {
284
- database: memory,
285
- plugins: [stripe(stripeOptions)],
286
- },
287
- {
288
- disableTestUser: true,
289
- clientOptions: {
290
- plugins: [stripeClient({ subscription: true })],
291
- },
292
- },
293
- );
294
- const ctx = await auth.$context;
295
-
296
- const userA = {
297
- email: "user-a@email.com",
298
- password: "password",
299
- name: "User A",
300
- };
301
- const userARes = await client.signUp.email(userA, { throw: true });
302
-
303
- const userAHeaders = new Headers();
304
- await client.signIn.email(userA, {
305
- throw: true,
306
- onSuccess: sessionSetter(userAHeaders),
307
- });
308
- await client.subscription.upgrade({
309
- plan: "starter",
310
- fetchOptions: { headers: userAHeaders },
311
- });
312
-
313
- const userASub = await ctx.adapter.findOne<Subscription>({
314
- model: "subscription",
315
- where: [{ field: "referenceId", value: userARes.user.id }],
316
- });
317
- expect(userASub).toBeTruthy();
318
-
319
- const userB = {
320
- email: "user-b@email.com",
321
- password: "password",
322
- name: "User B",
323
- };
324
- await client.signUp.email(userB, { throw: true });
325
- const userBHeaders = new Headers();
326
- await client.signIn.email(userB, {
327
- throw: true,
328
- onSuccess: sessionSetter(userBHeaders),
329
- });
330
-
331
- mockStripe.checkout.sessions.create.mockClear();
332
- mockStripe.billingPortal.sessions.create.mockClear();
333
- mockStripe.subscriptions.list.mockClear();
334
- mockStripe.subscriptions.update.mockClear();
335
-
336
- const upgradeRes = await client.subscription.upgrade({
337
- plan: "premium",
338
- subscriptionId: userASub!.id,
339
- fetchOptions: { headers: userBHeaders },
340
- });
341
- expect(upgradeRes.error?.message).toContain("Subscription not found");
342
- expect(mockStripe.checkout.sessions.create).not.toHaveBeenCalled();
343
- expect(mockStripe.billingPortal.sessions.create).not.toHaveBeenCalled();
344
-
345
- const cancelHeaders = new Headers(userBHeaders);
346
- cancelHeaders.set("content-type", "application/json");
347
- const cancelResponse = await auth.handler(
348
- new Request("http://localhost:3000/api/auth/subscription/cancel", {
349
- method: "POST",
350
- headers: cancelHeaders,
351
- body: JSON.stringify({
352
- subscriptionId: userASub!.id,
353
- returnUrl: "/account",
354
- }),
355
- }),
356
- );
357
- expect(cancelResponse.status).toBe(400);
358
- expect((await cancelResponse.json()).message).toContain(
359
- "Subscription not found",
360
- );
361
- expect(mockStripe.billingPortal.sessions.create).not.toHaveBeenCalled();
362
-
363
- const restoreHeaders = new Headers(userBHeaders);
364
- restoreHeaders.set("content-type", "application/json");
365
- const restoreResponse = await auth.handler(
366
- new Request("http://localhost:3000/api/auth/subscription/restore", {
367
- method: "POST",
368
- headers: restoreHeaders,
369
- body: JSON.stringify({
370
- subscriptionId: userASub!.id,
371
- }),
372
- }),
373
- );
374
- expect(restoreResponse.status).toBe(400);
375
- expect((await restoreResponse.json()).message).toContain(
376
- "Subscription not found",
377
- );
378
- expect(mockStripe.subscriptions.update).not.toHaveBeenCalled();
379
- });
380
-
381
- it("should pass metadata to subscription when upgrading", async () => {
382
- const { client, sessionSetter } = await getTestInstance(
383
- {
384
- database: memory,
385
- plugins: [stripe(stripeOptions)],
386
- },
387
- {
388
- disableTestUser: true,
389
- clientOptions: {
390
- plugins: [
391
- stripeClient({
392
- subscription: true,
393
- }),
394
- ],
395
- },
396
- },
397
- );
398
-
399
- await client.signUp.email(
400
- {
401
- ...testUser,
402
- email: "metadata-test@email.com",
403
- },
404
- {
405
- throw: true,
406
- },
407
- );
408
-
409
- const headers = new Headers();
410
- await client.signIn.email(
411
- {
412
- ...testUser,
413
- email: "metadata-test@email.com",
414
- },
415
- {
416
- throw: true,
417
- onSuccess: sessionSetter(headers),
418
- },
419
- );
420
-
421
- const customMetadata = {
422
- customField: "customValue",
423
- organizationId: "org_123",
424
- projectId: "proj_456",
425
- };
426
-
427
- await client.subscription.upgrade({
428
- plan: "starter",
429
- metadata: customMetadata,
430
- fetchOptions: {
431
- headers,
432
- },
433
- });
434
-
435
- expect(mockStripe.checkout.sessions.create).toHaveBeenCalledWith(
436
- expect.objectContaining({
437
- subscription_data: expect.objectContaining({
438
- metadata: expect.objectContaining(customMetadata),
439
- }),
440
- metadata: expect.objectContaining(customMetadata),
441
- }),
442
- undefined,
443
- );
444
- });
445
-
446
- it("should list active subscriptions", async () => {
447
- const { client, auth, sessionSetter } = await getTestInstance(
448
- {
449
- database: memory,
450
- plugins: [stripe(stripeOptions)],
451
- },
452
- {
453
- disableTestUser: true,
454
- clientOptions: {
455
- plugins: [stripeClient({ subscription: true })],
456
- },
457
- },
458
- );
459
- const ctx = await auth.$context;
460
-
461
- const userRes = await client.signUp.email(
462
- {
463
- ...testUser,
464
- email: "list-test@email.com",
465
- },
466
- {
467
- throw: true,
468
- },
469
- );
470
- const userId = userRes.user.id;
471
-
472
- const headers = new Headers();
473
- await client.signIn.email(
474
- {
475
- ...testUser,
476
- email: "list-test@email.com",
477
- },
478
- {
479
- throw: true,
480
- onSuccess: sessionSetter(headers),
481
- },
482
- );
483
-
484
- const listRes = await client.subscription.list({
485
- fetchOptions: {
486
- headers,
487
- },
488
- });
489
-
490
- expect(Array.isArray(listRes.data)).toBe(true);
491
-
492
- await client.subscription.upgrade({
493
- plan: "starter",
494
- fetchOptions: {
495
- headers,
496
- },
497
- });
498
- const listBeforeActive = await client.subscription.list({
499
- fetchOptions: {
500
- headers,
501
- },
502
- });
503
- expect(listBeforeActive.data?.length).toBe(0);
504
- // Update the subscription status to active
505
- await ctx.adapter.update({
506
- model: "subscription",
507
- update: {
508
- status: "active",
509
- },
510
- where: [
511
- {
512
- field: "referenceId",
513
- value: userId,
514
- },
515
- ],
516
- });
517
- const listAfterRes = await client.subscription.list({
518
- fetchOptions: {
519
- headers,
520
- },
521
- });
522
- expect(listAfterRes.data?.length).toBeGreaterThan(0);
523
- });
524
-
525
- it("should handle subscription webhook events", async () => {
526
- const { auth: testAuth } = await getTestInstance(
527
- {
528
- database: memory,
529
- plugins: [stripe(stripeOptions)],
530
- },
531
- {
532
- disableTestUser: true,
533
- },
534
- );
535
- const testCtx = await testAuth.$context;
536
-
537
- const { id: testReferenceId } = await testCtx.adapter.create({
538
- model: "user",
539
- data: {
540
- email: "test@email.com",
541
- },
542
- });
543
- const { id: testSubscriptionId } = await testCtx.adapter.create({
544
- model: "subscription",
545
- data: {
546
- referenceId: testReferenceId,
547
- stripeCustomerId: "cus_mock123",
548
- status: "active",
549
- plan: "starter",
550
- },
551
- });
552
- const mockCheckoutSessionEvent = {
553
- type: "checkout.session.completed",
554
- data: {
555
- object: {
556
- mode: "subscription",
557
- subscription: testSubscriptionId,
558
- metadata: {
559
- referenceId: testReferenceId,
560
- subscriptionId: testSubscriptionId,
561
- },
562
- },
563
- },
564
- };
565
-
566
- const mockSubscription = {
567
- id: testSubscriptionId,
568
- status: "active",
569
- items: {
570
- data: [
571
- {
572
- price: { id: process.env.STRIPE_PRICE_ID_1 },
573
- quantity: 1,
574
- },
575
- ],
576
- },
577
- current_period_start: Math.floor(Date.now() / 1000),
578
- current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
579
- };
580
-
581
- const stripeForTest = {
582
- ...stripeOptions.stripeClient,
583
- subscriptions: {
584
- ...stripeOptions.stripeClient.subscriptions,
585
- retrieve: vi.fn().mockResolvedValue(mockSubscription),
586
- },
587
- webhooks: {
588
- constructEventAsync: vi
589
- .fn()
590
- .mockResolvedValue(mockCheckoutSessionEvent),
591
- },
592
- };
593
-
594
- const testOptions = {
595
- ...stripeOptions,
596
- stripeClient: stripeForTest as unknown as Stripe,
597
- stripeWebhookSecret: "test_secret",
598
- };
599
-
600
- const { auth: webhookTestAuth } = await getTestInstance(
601
- {
602
- database: memory,
603
- plugins: [stripe(testOptions)],
604
- },
605
- {
606
- disableTestUser: true,
607
- },
608
- );
609
-
610
- const webhookTestCtx = await webhookTestAuth.$context;
611
-
612
- const mockRequest = new Request(
613
- "http://localhost:3000/api/auth/stripe/webhook",
614
- {
615
- method: "POST",
616
- headers: {
617
- "stripe-signature": "test_signature",
618
- },
619
- body: JSON.stringify(mockCheckoutSessionEvent),
620
- },
621
- );
622
- const response = await webhookTestAuth.handler(mockRequest);
623
- expect(response.status).toBe(200);
624
-
625
- const updatedSubscription =
626
- await webhookTestCtx.adapter.findOne<Subscription>({
627
- model: "subscription",
628
- where: [
629
- {
630
- field: "id",
631
- value: testSubscriptionId,
632
- },
633
- ],
634
- });
635
-
636
- expect(updatedSubscription).toMatchObject({
637
- id: testSubscriptionId,
638
- status: "active",
639
- periodStart: expect.any(Date),
640
- periodEnd: expect.any(Date),
641
- plan: "starter",
642
- });
643
- });
644
-
645
- it("should handle subscription webhook events with trial", async () => {
646
- const { auth: testAuth } = await getTestInstance(
647
- {
648
- database: memory,
649
- plugins: [stripe(stripeOptions)],
650
- },
651
- {
652
- disableTestUser: true,
653
- },
654
- );
655
- const testCtx = await testAuth.$context;
656
-
657
- const { id: testReferenceId } = await testCtx.adapter.create({
658
- model: "user",
659
- data: {
660
- email: "test@email.com",
661
- },
662
- });
663
- const { id: testSubscriptionId } = await testCtx.adapter.create({
664
- model: "subscription",
665
- data: {
666
- referenceId: testReferenceId,
667
- stripeCustomerId: "cus_mock123",
668
- status: "incomplete",
669
- plan: "starter",
670
- },
671
- });
672
- const mockCheckoutSessionEvent = {
673
- type: "checkout.session.completed",
674
- data: {
675
- object: {
676
- mode: "subscription",
677
- subscription: testSubscriptionId,
678
- metadata: {
679
- referenceId: testReferenceId,
680
- subscriptionId: testSubscriptionId,
681
- },
682
- },
683
- },
684
- };
685
-
686
- const mockSubscription = {
687
- id: testSubscriptionId,
688
- status: "active",
689
- items: {
690
- data: [
691
- {
692
- price: { id: process.env.STRIPE_PRICE_ID_1 },
693
- quantity: 1,
694
- },
695
- ],
696
- },
697
- current_period_start: Math.floor(Date.now() / 1000),
698
- current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
699
- trial_start: Math.floor(Date.now() / 1000),
700
- trial_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
701
- };
702
-
703
- const stripeForTest = {
704
- ...stripeOptions.stripeClient,
705
- subscriptions: {
706
- ...stripeOptions.stripeClient.subscriptions,
707
- retrieve: vi.fn().mockResolvedValue(mockSubscription),
708
- },
709
- webhooks: {
710
- constructEventAsync: vi
711
- .fn()
712
- .mockResolvedValue(mockCheckoutSessionEvent),
713
- },
714
- };
715
-
716
- const testOptions = {
717
- ...stripeOptions,
718
- stripeClient: stripeForTest as unknown as Stripe,
719
- stripeWebhookSecret: "test_secret",
720
- };
721
-
722
- const { auth: webhookTestAuth } = await getTestInstance(
723
- {
724
- database: memory,
725
- plugins: [stripe(testOptions)],
726
- },
727
- {
728
- disableTestUser: true,
729
- },
730
- );
731
-
732
- const webhookTestCtx = await webhookTestAuth.$context;
733
-
734
- const mockRequest = new Request(
735
- "http://localhost:3000/api/auth/stripe/webhook",
736
- {
737
- method: "POST",
738
- headers: {
739
- "stripe-signature": "test_signature",
740
- },
741
- body: JSON.stringify(mockCheckoutSessionEvent),
742
- },
743
- );
744
- const response = await webhookTestAuth.handler(mockRequest);
745
- expect(response.status).toBe(200);
746
-
747
- const updatedSubscription =
748
- await webhookTestCtx.adapter.findOne<Subscription>({
749
- model: "subscription",
750
- where: [
751
- {
752
- field: "id",
753
- value: testSubscriptionId,
754
- },
755
- ],
756
- });
757
-
758
- expect(updatedSubscription).toMatchObject({
759
- id: testSubscriptionId,
760
- status: "active",
761
- periodStart: expect.any(Date),
762
- periodEnd: expect.any(Date),
763
- plan: "starter",
764
- trialStart: expect.any(Date),
765
- trialEnd: expect.any(Date),
766
- });
767
- });
768
-
769
- it("should handle subscription deletion webhook", async () => {
770
- const { auth: testAuth } = await getTestInstance(
771
- {
772
- database: memory,
773
- plugins: [stripe(stripeOptions)],
774
- },
775
- {
776
- disableTestUser: true,
777
- },
778
- );
779
- const testCtx = await testAuth.$context;
780
-
781
- const { id: userId } = await testCtx.adapter.create({
782
- model: "user",
783
- data: {
784
- email: "delete-test@email.com",
785
- },
786
- });
787
-
788
- const subId = "test_sub_delete";
789
-
790
- await testCtx.adapter.create({
791
- model: "subscription",
792
- data: {
793
- referenceId: userId,
794
- stripeCustomerId: "cus_delete_test",
795
- status: "active",
796
- plan: "starter",
797
- stripeSubscriptionId: "sub_delete_test",
798
- },
799
- });
800
-
801
- const subscription = await testCtx.adapter.findOne<Subscription>({
802
- model: "subscription",
803
- where: [
804
- {
805
- field: "referenceId",
806
- value: userId,
807
- },
808
- ],
809
- });
810
-
811
- const mockDeleteEvent = {
812
- type: "customer.subscription.deleted",
813
- data: {
814
- object: {
815
- id: "sub_delete_test",
816
- customer: subscription?.stripeCustomerId,
817
- status: "canceled",
818
- metadata: {
819
- referenceId: subscription?.referenceId,
820
- subscriptionId: subscription?.id,
821
- },
822
- },
823
- },
824
- };
825
-
826
- const stripeForTest = {
827
- ...stripeOptions.stripeClient,
828
- webhooks: {
829
- constructEventAsync: vi.fn().mockResolvedValue(mockDeleteEvent),
830
- },
831
- subscriptions: {
832
- retrieve: vi.fn().mockResolvedValue({
833
- status: "canceled",
834
- id: subId,
835
- }),
836
- },
837
- };
838
-
839
- const testOptions = {
840
- ...stripeOptions,
841
- stripeClient: stripeForTest as unknown as Stripe,
842
- stripeWebhookSecret: "test_secret",
843
- };
844
-
845
- const { auth: webhookTestAuth } = await getTestInstance(
846
- {
847
- database: memory,
848
- plugins: [stripe(testOptions)],
849
- },
850
- {
851
- disableTestUser: true,
852
- },
853
- );
854
-
855
- const webhookTestCtx = await webhookTestAuth.$context;
856
-
857
- const mockRequest = new Request(
858
- "http://localhost:3000/api/auth/stripe/webhook",
859
- {
860
- method: "POST",
861
- headers: {
862
- "stripe-signature": "test_signature",
863
- },
864
- body: JSON.stringify(mockDeleteEvent),
865
- },
866
- );
867
-
868
- const response = await webhookTestAuth.handler(mockRequest);
869
- expect(response.status).toBe(200);
870
-
871
- if (subscription) {
872
- const updatedSubscription =
873
- await webhookTestCtx.adapter.findOne<Subscription>({
874
- model: "subscription",
875
- where: [
876
- {
877
- field: "id",
878
- value: subscription.id,
879
- },
880
- ],
881
- });
882
- expect(updatedSubscription?.status).toBe("canceled");
883
- }
884
- });
885
-
886
- it("should handle customer.subscription.created webhook event", async () => {
887
- const stripeForTest = {
888
- ...stripeOptions.stripeClient,
889
- webhooks: {
890
- constructEventAsync: vi.fn(),
891
- },
892
- };
893
-
894
- const testOptions = {
895
- ...stripeOptions,
896
- stripeClient: stripeForTest as unknown as Stripe,
897
- stripeWebhookSecret: "test_secret",
898
- };
899
-
900
- const { auth: testAuth } = await getTestInstance(
901
- {
902
- database: memory,
903
- plugins: [stripe(testOptions)],
904
- },
905
- {
906
- disableTestUser: true,
907
- },
908
- );
909
- const testCtx = await testAuth.$context;
910
-
911
- // Create a user with stripeCustomerId
912
- const userWithCustomerId = await testCtx.adapter.create({
913
- model: "user",
914
- data: {
915
- email: "dashboard-user@test.com",
916
- name: "Dashboard User",
917
- emailVerified: true,
918
- stripeCustomerId: "cus_dashboard_test",
919
- },
920
- });
921
-
922
- const mockEvent = {
923
- type: "customer.subscription.created",
924
- data: {
925
- object: {
926
- id: "sub_dashboard_created",
927
- customer: "cus_dashboard_test",
928
- status: "active",
929
- items: {
930
- data: [
931
- {
932
- price: { id: process.env.STRIPE_PRICE_ID_1 },
933
- quantity: 1,
934
- current_period_start: Math.floor(Date.now() / 1000),
935
- current_period_end:
936
- Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
937
- },
938
- ],
939
- },
940
- cancel_at_period_end: false,
941
- },
942
- },
943
- };
944
-
945
- (stripeForTest.webhooks.constructEventAsync as any).mockResolvedValue(
946
- mockEvent,
947
- );
948
-
949
- const mockRequest = new Request(
950
- "http://localhost:3000/api/auth/stripe/webhook",
951
- {
952
- method: "POST",
953
- headers: {
954
- "stripe-signature": "test_signature",
955
- },
956
- body: JSON.stringify(mockEvent),
957
- },
958
- );
959
-
960
- const response = await testAuth.handler(mockRequest);
961
- expect(response.status).toBe(200);
962
-
963
- // Verify subscription was created in database
964
- const subscription = await testCtx.adapter.findOne<Subscription>({
965
- model: "subscription",
966
- where: [
967
- { field: "stripeSubscriptionId", value: "sub_dashboard_created" },
968
- ],
969
- });
970
-
971
- expect(subscription).toBeDefined();
972
- expect(subscription?.referenceId).toBe(userWithCustomerId.id);
973
- expect(subscription?.stripeCustomerId).toBe("cus_dashboard_test");
974
- expect(subscription?.status).toBe("active");
975
- expect(subscription?.plan).toBe("starter");
976
- expect(subscription?.seats).toBe(1);
977
- });
978
-
979
- it("should not create duplicate subscription if already exists", async () => {
980
- const onSubscriptionCreatedCallback = vi.fn();
981
-
982
- const stripeForTest = {
983
- ...stripeOptions.stripeClient,
984
- webhooks: {
985
- constructEventAsync: vi.fn(),
986
- },
987
- };
988
-
989
- const testOptions = {
990
- ...stripeOptions,
991
- stripeClient: stripeForTest as unknown as Stripe,
992
- stripeWebhookSecret: "test_secret",
993
- subscription: {
994
- ...stripeOptions.subscription,
995
- onSubscriptionCreated: onSubscriptionCreatedCallback,
996
- },
997
- } as StripeOptions;
998
-
999
- const { auth: testAuth } = await getTestInstance(
1000
- {
1001
- database: memory,
1002
- plugins: [stripe(testOptions)],
1003
- },
1004
- {
1005
- disableTestUser: true,
1006
- },
1007
- );
1008
- const testCtx = await testAuth.$context;
1009
-
1010
- // Create user
1011
- const user = await testCtx.adapter.create({
1012
- model: "user",
1013
- data: {
1014
- email: "duplicate-sub@test.com",
1015
- name: "Duplicate Test",
1016
- emailVerified: true,
1017
- stripeCustomerId: "cus_duplicate_test",
1018
- },
1019
- });
1020
-
1021
- // Create existing subscription
1022
- await testCtx.adapter.create({
1023
- model: "subscription",
1024
- data: {
1025
- referenceId: user.id,
1026
- stripeCustomerId: "cus_duplicate_test",
1027
- stripeSubscriptionId: "sub_already_exists",
1028
- status: "active",
1029
- plan: "starter",
1030
- },
1031
- });
1032
-
1033
- const mockEvent = {
1034
- type: "customer.subscription.created",
1035
- data: {
1036
- object: {
1037
- id: "sub_already_exists",
1038
- customer: "cus_duplicate_test",
1039
- status: "active",
1040
- items: {
1041
- data: [
1042
- {
1043
- price: { id: process.env.STRIPE_PRICE_ID_1 },
1044
- quantity: 1,
1045
- current_period_start: Math.floor(Date.now() / 1000),
1046
- current_period_end:
1047
- Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1048
- },
1049
- ],
1050
- },
1051
- cancel_at_period_end: false,
1052
- },
1053
- },
1054
- };
1055
-
1056
- (stripeForTest.webhooks.constructEventAsync as any).mockResolvedValue(
1057
- mockEvent,
1058
- );
1059
-
1060
- const mockRequest = new Request(
1061
- "http://localhost:3000/api/auth/stripe/webhook",
1062
- {
1063
- method: "POST",
1064
- headers: {
1065
- "stripe-signature": "test_signature",
1066
- },
1067
- body: JSON.stringify(mockEvent),
1068
- },
1069
- );
1070
-
1071
- const response = await testAuth.handler(mockRequest);
1072
- expect(response.status).toBe(200);
1073
-
1074
- // Verify only one subscription exists (no duplicate)
1075
- const subscriptions = await testCtx.adapter.findMany<Subscription>({
1076
- model: "subscription",
1077
- where: [
1078
- {
1079
- field: "stripeSubscriptionId",
1080
- value: "sub_already_exists",
1081
- },
1082
- ],
1083
- });
1084
-
1085
- expect(subscriptions.length).toBe(1);
1086
-
1087
- // Verify callback was NOT called (early return due to existing subscription)
1088
- expect(onSubscriptionCreatedCallback).not.toHaveBeenCalled();
1089
- });
1090
-
1091
- it("should skip subscription creation when user not found", async () => {
1092
- const onSubscriptionCreatedCallback = vi.fn();
1093
-
1094
- const stripeForTest = {
1095
- ...stripeOptions.stripeClient,
1096
- webhooks: {
1097
- constructEventAsync: vi.fn(),
1098
- },
1099
- };
1100
-
1101
- const testOptions = {
1102
- ...stripeOptions,
1103
- stripeClient: stripeForTest as unknown as Stripe,
1104
- stripeWebhookSecret: "test_secret",
1105
- subscription: {
1106
- ...stripeOptions.subscription,
1107
- onSubscriptionCreated: onSubscriptionCreatedCallback,
1108
- },
1109
- } as StripeOptions;
1110
-
1111
- const { auth: testAuth } = await getTestInstance(
1112
- {
1113
- database: memory,
1114
- plugins: [stripe(testOptions)],
1115
- },
1116
- {
1117
- disableTestUser: true,
1118
- },
1119
- );
1120
- const testCtx = await testAuth.$context;
1121
-
1122
- const mockEvent = {
1123
- type: "customer.subscription.created",
1124
- data: {
1125
- object: {
1126
- id: "sub_no_user",
1127
- customer: "cus_nonexistent",
1128
- status: "active",
1129
- items: {
1130
- data: [
1131
- {
1132
- price: { id: process.env.STRIPE_PRICE_ID_1 },
1133
- quantity: 1,
1134
- current_period_start: Math.floor(Date.now() / 1000),
1135
- current_period_end:
1136
- Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1137
- },
1138
- ],
1139
- },
1140
- cancel_at_period_end: false,
1141
- },
1142
- },
1143
- };
1144
-
1145
- (stripeForTest.webhooks.constructEventAsync as any).mockResolvedValue(
1146
- mockEvent,
1147
- );
1148
-
1149
- const mockRequest = new Request(
1150
- "http://localhost:3000/api/auth/stripe/webhook",
1151
- {
1152
- method: "POST",
1153
- headers: {
1154
- "stripe-signature": "test_signature",
1155
- },
1156
- body: JSON.stringify(mockEvent),
1157
- },
1158
- );
1159
-
1160
- const response = await testAuth.handler(mockRequest);
1161
- expect(response.status).toBe(200);
1162
-
1163
- // Verify subscription was NOT created
1164
- const subscription = await testCtx.adapter.findOne<Subscription>({
1165
- model: "subscription",
1166
- where: [{ field: "stripeSubscriptionId", value: "sub_no_user" }],
1167
- });
1168
-
1169
- expect(subscription).toBeNull();
1170
-
1171
- // Verify callback was NOT called (early return due to user not found)
1172
- expect(onSubscriptionCreatedCallback).not.toHaveBeenCalled();
1173
- });
1174
-
1175
- it("should skip subscription creation when plan not found", async () => {
1176
- const onSubscriptionCreatedCallback = vi.fn();
1177
-
1178
- const stripeForTest = {
1179
- ...stripeOptions.stripeClient,
1180
- webhooks: {
1181
- constructEventAsync: vi.fn(),
1182
- },
1183
- };
1184
-
1185
- const testOptions = {
1186
- ...stripeOptions,
1187
- stripeClient: stripeForTest as unknown as Stripe,
1188
- stripeWebhookSecret: "test_secret",
1189
- subscription: {
1190
- ...stripeOptions.subscription,
1191
- onSubscriptionCreated: onSubscriptionCreatedCallback,
1192
- },
1193
- } as StripeOptions;
1194
-
1195
- const { auth: testAuth } = await getTestInstance(
1196
- {
1197
- database: memory,
1198
- plugins: [stripe(testOptions)],
1199
- },
1200
- {
1201
- disableTestUser: true,
1202
- },
1203
- );
1204
- const testCtx = await testAuth.$context;
1205
-
1206
- // Create user
1207
- await testCtx.adapter.create({
1208
- model: "user",
1209
- data: {
1210
- email: "no-plan@test.com",
1211
- name: "No Plan User",
1212
- emailVerified: true,
1213
- stripeCustomerId: "cus_no_plan",
1214
- },
1215
- });
1216
-
1217
- const mockEvent = {
1218
- type: "customer.subscription.created",
1219
- data: {
1220
- object: {
1221
- id: "sub_no_plan",
1222
- customer: "cus_no_plan",
1223
- status: "active",
1224
- items: {
1225
- data: [
1226
- {
1227
- price: { id: "price_unknown" }, // Unknown price
1228
- quantity: 1,
1229
- current_period_start: Math.floor(Date.now() / 1000),
1230
- current_period_end:
1231
- Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1232
- },
1233
- ],
1234
- },
1235
- cancel_at_period_end: false,
1236
- },
1237
- },
1238
- };
1239
-
1240
- (stripeForTest.webhooks.constructEventAsync as any).mockResolvedValue(
1241
- mockEvent,
1242
- );
1243
-
1244
- const mockRequest = new Request(
1245
- "http://localhost:3000/api/auth/stripe/webhook",
1246
- {
1247
- method: "POST",
1248
- headers: {
1249
- "stripe-signature": "test_signature",
1250
- },
1251
- body: JSON.stringify(mockEvent),
1252
- },
1253
- );
1254
-
1255
- const response = await testAuth.handler(mockRequest);
1256
- expect(response.status).toBe(200);
1257
-
1258
- // Verify subscription was NOT created (no matching plan)
1259
- const subscription = await testCtx.adapter.findOne<Subscription>({
1260
- model: "subscription",
1261
- where: [{ field: "stripeSubscriptionId", value: "sub_no_plan" }],
1262
- });
1263
-
1264
- expect(subscription).toBeNull();
1265
-
1266
- // Verify callback was NOT called (early return due to plan not found)
1267
- expect(onSubscriptionCreatedCallback).not.toHaveBeenCalled();
1268
- });
1269
-
1270
- it("should skip creating subscription when metadata.subscriptionId exists", async () => {
1271
- const stripeForTest = {
1272
- ...stripeOptions.stripeClient,
1273
- webhooks: {
1274
- constructEventAsync: vi.fn(),
1275
- },
1276
- };
1277
-
1278
- const testOptions = {
1279
- ...stripeOptions,
1280
- stripeClient: stripeForTest as unknown as Stripe,
1281
- stripeWebhookSecret: "test_secret",
1282
- };
1283
-
1284
- const {
1285
- auth: testAuth,
1286
- client,
1287
- sessionSetter,
1288
- } = await getTestInstance(
1289
- {
1290
- database: memory,
1291
- plugins: [stripe(testOptions)],
1292
- },
1293
- {
1294
- disableTestUser: true,
1295
- clientOptions: {
1296
- plugins: [stripeClient({ subscription: true })],
1297
- },
1298
- },
1299
- );
1300
- const testCtx = await testAuth.$context;
1301
-
1302
- // Create user and sign in
1303
- const userRes = await client.signUp.email(
1304
- {
1305
- ...testUser,
1306
- },
1307
- { throw: true },
1308
- );
1309
- const userId = userRes.user.id;
1310
-
1311
- const headers = new Headers();
1312
- await client.signIn.email(
1313
- {
1314
- ...testUser,
1315
- },
1316
- {
1317
- throw: true,
1318
- onSuccess: sessionSetter(headers),
1319
- },
1320
- );
1321
-
1322
- // User upgrades to paid plan - this creates an "incomplete" subscription
1323
- await client.subscription.upgrade({
1324
- plan: "starter",
1325
- fetchOptions: { headers },
1326
- });
1327
-
1328
- // Verify the incomplete subscription was created
1329
- const incompleteSubscription = await testCtx.adapter.findOne<Subscription>({
1330
- model: "subscription",
1331
- where: [{ field: "referenceId", value: userId }],
1332
- });
1333
- assert(
1334
- incompleteSubscription,
1335
- "Expected incomplete subscription to be created",
1336
- );
1337
- expect(incompleteSubscription.status).toBe("incomplete");
1338
- expect(incompleteSubscription.stripeSubscriptionId).toBeUndefined();
1339
-
1340
- // Get user with stripeCustomerId
1341
- const user = await testCtx.adapter.findOne<any>({
1342
- model: "user",
1343
- where: [{ field: "id", value: userId }],
1344
- });
1345
- const stripeCustomerId = user?.stripeCustomerId;
1346
- expect(stripeCustomerId).toBeDefined();
1347
-
1348
- // Simulate `customer.subscription.created` webhook arriving
1349
- const mockEvent = {
1350
- type: "customer.subscription.created",
1351
- data: {
1352
- object: {
1353
- id: "sub_new_from_checkout",
1354
- customer: stripeCustomerId,
1355
- status: "active",
1356
- metadata: {
1357
- subscriptionId: incompleteSubscription.id,
1358
- },
1359
- items: {
1360
- data: [
1361
- {
1362
- price: { id: process.env.STRIPE_PRICE_ID_1 },
1363
- quantity: 1,
1364
- current_period_start: Math.floor(Date.now() / 1000),
1365
- current_period_end:
1366
- Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1367
- },
1368
- ],
1369
- },
1370
- cancel_at_period_end: false,
1371
- },
1372
- },
1373
- };
1374
-
1375
- (stripeForTest.webhooks.constructEventAsync as any).mockResolvedValue(
1376
- mockEvent,
1377
- );
1378
-
1379
- const mockRequest = new Request(
1380
- "http://localhost:3000/api/auth/stripe/webhook",
1381
- {
1382
- method: "POST",
1383
- headers: {
1384
- "stripe-signature": "test_signature",
1385
- },
1386
- body: JSON.stringify(mockEvent),
1387
- },
1388
- );
1389
-
1390
- const response = await testAuth.handler(mockRequest);
1391
- expect(response.status).toBe(200);
1392
-
1393
- // Verify that no duplicate subscription was created
1394
- const allSubscriptions = await testCtx.adapter.findMany<Subscription>({
1395
- model: "subscription",
1396
- where: [{ field: "referenceId", value: userId }],
1397
- });
1398
- expect(allSubscriptions.length).toBe(1);
1399
- });
1400
-
1401
- it("should execute subscription event handlers", async () => {
1402
- const { auth: testAuth } = await getTestInstance(
1403
- {
1404
- database: memory,
1405
- plugins: [stripe(stripeOptions)],
1406
- },
1407
- {
1408
- disableTestUser: true,
1409
- },
1410
- );
1411
- const testCtx = await testAuth.$context;
1412
-
1413
- const { id: userId } = await testCtx.adapter.create({
1414
- model: "user",
1415
- data: {
1416
- email: "event-handler-test@email.com",
1417
- },
1418
- });
1419
-
1420
- const onSubscriptionComplete = vi.fn();
1421
- const onSubscriptionUpdate = vi.fn();
1422
- const onSubscriptionCancel = vi.fn();
1423
- const onSubscriptionDeleted = vi.fn();
1424
-
1425
- const testOptions = {
1426
- ...stripeOptions,
1427
- subscription: {
1428
- ...stripeOptions.subscription,
1429
- onSubscriptionComplete,
1430
- onSubscriptionUpdate,
1431
- onSubscriptionCancel,
1432
- onSubscriptionDeleted,
1433
- },
1434
- stripeWebhookSecret: "test_secret",
1435
- } as unknown as StripeOptions;
1436
-
1437
- // Test subscription complete handler
1438
- const completeEvent = {
1439
- type: "checkout.session.completed",
1440
- data: {
1441
- object: {
1442
- mode: "subscription",
1443
- subscription: "sub_123",
1444
- metadata: {
1445
- referenceId: "user_123",
1446
- subscriptionId: "sub_123",
1447
- },
1448
- },
1449
- },
1450
- };
1451
-
1452
- const mockSubscription = {
1453
- status: "active",
1454
- items: {
1455
- data: [{ price: { id: process.env.STRIPE_PRICE_ID_1 } }],
1456
- },
1457
- current_period_start: Math.floor(Date.now() / 1000),
1458
- current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1459
- };
1460
-
1461
- const mockStripeForEvents = {
1462
- ...testOptions.stripeClient,
1463
- subscriptions: {
1464
- retrieve: vi.fn().mockResolvedValue(mockSubscription),
1465
- },
1466
- webhooks: {
1467
- constructEventAsync: vi.fn().mockResolvedValue(completeEvent),
1468
- },
1469
- };
1470
-
1471
- const eventTestOptions = {
1472
- ...testOptions,
1473
- stripeClient: mockStripeForEvents as unknown as Stripe,
1474
- };
1475
-
1476
- const { auth: eventTestAuth } = await getTestInstance(
1477
- {
1478
- database: memory,
1479
- plugins: [stripe(eventTestOptions)],
1480
- },
1481
- {
1482
- disableTestUser: true,
1483
- },
1484
- );
1485
-
1486
- const eventTestCtx = await eventTestAuth.$context;
1487
-
1488
- const { id: testSubscriptionId } = await eventTestCtx.adapter.create({
1489
- model: "subscription",
1490
- data: {
1491
- referenceId: userId,
1492
- stripeCustomerId: "cus_123",
1493
- stripeSubscriptionId: "sub_123",
1494
- status: "incomplete",
1495
- plan: "starter",
1496
- },
1497
- });
1498
-
1499
- const webhookRequest = 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(completeEvent),
1507
- },
1508
- );
1509
-
1510
- await eventTestAuth.handler(webhookRequest);
1511
-
1512
- expect(onSubscriptionComplete).toHaveBeenCalledWith(
1513
- expect.objectContaining({
1514
- event: expect.any(Object),
1515
- subscription: expect.any(Object),
1516
- stripeSubscription: expect.any(Object),
1517
- plan: expect.any(Object),
1518
- }),
1519
- expect.objectContaining({
1520
- context: expect.any(Object),
1521
- _flag: expect.any(String),
1522
- }),
1523
- );
1524
-
1525
- const updateEvent = {
1526
- type: "customer.subscription.updated",
1527
- data: {
1528
- object: {
1529
- id: testSubscriptionId,
1530
- customer: "cus_123",
1531
- status: "active",
1532
- items: {
1533
- data: [{ price: { id: process.env.STRIPE_PRICE_ID_1 } }],
1534
- },
1535
- current_period_start: Math.floor(Date.now() / 1000),
1536
- current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1537
- },
1538
- },
1539
- };
1540
-
1541
- const updateRequest = new Request(
1542
- "http://localhost:3000/api/auth/stripe/webhook",
1543
- {
1544
- method: "POST",
1545
- headers: {
1546
- "stripe-signature": "test_signature",
1547
- },
1548
- body: JSON.stringify(updateEvent),
1549
- },
1550
- );
1551
-
1552
- mockStripeForEvents.webhooks.constructEventAsync.mockReturnValue(
1553
- updateEvent,
1554
- );
1555
- await eventTestAuth.handler(updateRequest);
1556
- expect(onSubscriptionUpdate).toHaveBeenCalledWith(
1557
- expect.objectContaining({
1558
- event: expect.any(Object),
1559
- subscription: expect.any(Object),
1560
- }),
1561
- );
1562
-
1563
- const userCancelEvent = {
1564
- type: "customer.subscription.updated",
1565
- data: {
1566
- object: {
1567
- id: testSubscriptionId,
1568
- customer: "cus_123",
1569
- status: "active",
1570
- cancel_at_period_end: true,
1571
- cancellation_details: {
1572
- reason: "cancellation_requested",
1573
- comment: "Customer canceled subscription",
1574
- },
1575
- items: {
1576
- data: [{ price: { id: process.env.STRIPE_PRICE_ID_1 } }],
1577
- },
1578
- current_period_start: Math.floor(Date.now() / 1000),
1579
- current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1580
- },
1581
- },
1582
- };
1583
-
1584
- const userCancelRequest = new Request(
1585
- "http://localhost:3000/api/auth/stripe/webhook",
1586
- {
1587
- method: "POST",
1588
- headers: {
1589
- "stripe-signature": "test_signature",
1590
- },
1591
- body: JSON.stringify(userCancelEvent),
1592
- },
1593
- );
1594
-
1595
- mockStripeForEvents.webhooks.constructEventAsync.mockReturnValue(
1596
- userCancelEvent,
1597
- );
1598
- await eventTestAuth.handler(userCancelRequest);
1599
- const cancelEvent = {
1600
- type: "customer.subscription.updated",
1601
- data: {
1602
- object: {
1603
- id: testSubscriptionId,
1604
- customer: "cus_123",
1605
- status: "active",
1606
- cancel_at_period_end: true,
1607
- items: {
1608
- data: [{ price: { id: process.env.STRIPE_PRICE_ID_1 } }],
1609
- },
1610
- current_period_start: Math.floor(Date.now() / 1000),
1611
- current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1612
- },
1613
- },
1614
- };
1615
-
1616
- const cancelRequest = new Request(
1617
- "http://localhost:3000/api/auth/stripe/webhook",
1618
- {
1619
- method: "POST",
1620
- headers: {
1621
- "stripe-signature": "test_signature",
1622
- },
1623
- body: JSON.stringify(cancelEvent),
1624
- },
1625
- );
1626
-
1627
- mockStripeForEvents.webhooks.constructEventAsync.mockReturnValue(
1628
- cancelEvent,
1629
- );
1630
- await eventTestAuth.handler(cancelRequest);
1631
-
1632
- expect(onSubscriptionCancel).toHaveBeenCalled();
1633
-
1634
- const deleteEvent = {
1635
- type: "customer.subscription.deleted",
1636
- data: {
1637
- object: {
1638
- id: testSubscriptionId,
1639
- customer: "cus_123",
1640
- status: "canceled",
1641
- metadata: {
1642
- referenceId: userId,
1643
- subscriptionId: testSubscriptionId,
1644
- },
1645
- },
1646
- },
1647
- };
1648
-
1649
- const deleteRequest = new Request(
1650
- "http://localhost:3000/api/auth/stripe/webhook",
1651
- {
1652
- method: "POST",
1653
- headers: {
1654
- "stripe-signature": "test_signature",
1655
- },
1656
- body: JSON.stringify(deleteEvent),
1657
- },
1658
- );
1659
-
1660
- mockStripeForEvents.webhooks.constructEventAsync.mockReturnValue(
1661
- deleteEvent,
1662
- );
1663
- await eventTestAuth.handler(deleteRequest);
1664
-
1665
- expect(onSubscriptionDeleted).toHaveBeenCalled();
1666
- });
1667
-
1668
- it("should return updated subscription in onSubscriptionUpdate callback", async () => {
1669
- const onSubscriptionUpdate = vi.fn();
1670
-
1671
- // Simulate subscription update event (e.g., seat change from 1 to 5)
1672
- const updateEvent = {
1673
- type: "customer.subscription.updated",
1674
- data: {
1675
- object: {
1676
- id: "sub_update_test",
1677
- customer: "cus_update_test",
1678
- status: "active",
1679
- items: {
1680
- data: [
1681
- {
1682
- price: { id: process.env.STRIPE_PRICE_ID_1 },
1683
- quantity: 5, // Updated from 1 to 5
1684
- current_period_start: Math.floor(Date.now() / 1000),
1685
- current_period_end:
1686
- Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1687
- },
1688
- ],
1689
- },
1690
- current_period_start: Math.floor(Date.now() / 1000),
1691
- current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1692
- },
1693
- },
1694
- };
1695
-
1696
- const stripeForTest = {
1697
- ...stripeOptions.stripeClient,
1698
- webhooks: {
1699
- constructEventAsync: vi.fn().mockResolvedValue(updateEvent),
1700
- },
1701
- };
1702
-
1703
- const testOptions = {
1704
- ...stripeOptions,
1705
- stripeClient: stripeForTest as unknown as Stripe,
1706
- stripeWebhookSecret: "test_secret",
1707
- subscription: {
1708
- ...stripeOptions.subscription,
1709
- onSubscriptionUpdate,
1710
- },
1711
- } as unknown as StripeOptions;
1712
-
1713
- const { auth: testAuth } = await getTestInstance(
1714
- {
1715
- database: memory,
1716
- plugins: [stripe(testOptions)],
1717
- },
1718
- {
1719
- disableTestUser: true,
1720
- },
1721
- );
1722
-
1723
- const ctx = await testAuth.$context;
1724
-
1725
- const { id: testReferenceId } = await ctx.adapter.create({
1726
- model: "user",
1727
- data: {
1728
- email: "update-callback@email.com",
1729
- },
1730
- });
1731
-
1732
- const { id: testSubscriptionId } = await ctx.adapter.create({
1733
- model: "subscription",
1734
- data: {
1735
- referenceId: testReferenceId,
1736
- stripeCustomerId: "cus_update_test",
1737
- stripeSubscriptionId: "sub_update_test",
1738
- status: "active",
1739
- plan: "starter",
1740
- seats: 1,
1741
- },
1742
- });
1743
-
1744
- const mockRequest = new Request(
1745
- "http://localhost:3000/api/auth/stripe/webhook",
1746
- {
1747
- method: "POST",
1748
- headers: {
1749
- "stripe-signature": "test_signature",
1750
- },
1751
- body: JSON.stringify(updateEvent),
1752
- },
1753
- );
1754
-
1755
- await testAuth.handler(mockRequest);
1756
-
1757
- // Verify that onSubscriptionUpdate was called
1758
- expect(onSubscriptionUpdate).toHaveBeenCalledTimes(1);
1759
-
1760
- // Verify that the callback received the UPDATED subscription (seats: 5, not 1)
1761
- const callbackArg = onSubscriptionUpdate.mock.calls[0]?.[0];
1762
- expect(callbackArg).toBeDefined();
1763
- expect(callbackArg.subscription).toMatchObject({
1764
- id: testSubscriptionId,
1765
- seats: 5, // Should be the NEW value, not the old value (1)
1766
- status: "active",
1767
- plan: "starter",
1768
- });
1769
-
1770
- // Also verify the subscription was actually updated in the database
1771
- const updatedSub = await ctx.adapter.findOne<Subscription>({
1772
- model: "subscription",
1773
- where: [{ field: "id", value: testSubscriptionId }],
1774
- });
1775
- expect(updatedSub?.seats).toBe(5);
1776
- });
1777
-
1778
- it("should allow seat upgrades for the same plan", async () => {
1779
- const { client, auth, sessionSetter } = await getTestInstance(
1780
- {
1781
- database: memory,
1782
- plugins: [stripe(stripeOptions)],
1783
- },
1784
- {
1785
- disableTestUser: true,
1786
- clientOptions: {
1787
- plugins: [stripeClient({ subscription: true })],
1788
- },
1789
- },
1790
- );
1791
- const ctx = await auth.$context;
1792
-
1793
- const userRes = await client.signUp.email(
1794
- {
1795
- ...testUser,
1796
- email: "seat-upgrade@email.com",
1797
- },
1798
- {
1799
- throw: true,
1800
- },
1801
- );
1802
-
1803
- const headers = new Headers();
1804
- await client.signIn.email(
1805
- {
1806
- ...testUser,
1807
- email: "seat-upgrade@email.com",
1808
- },
1809
- {
1810
- throw: true,
1811
- onSuccess: sessionSetter(headers),
1812
- },
1813
- );
1814
-
1815
- await client.subscription.upgrade({
1816
- plan: "starter",
1817
- seats: 1,
1818
- fetchOptions: {
1819
- headers,
1820
- },
1821
- });
1822
-
1823
- await ctx.adapter.update({
1824
- model: "subscription",
1825
- update: {
1826
- status: "active",
1827
- },
1828
- where: [
1829
- {
1830
- field: "referenceId",
1831
- value: userRes.user.id,
1832
- },
1833
- ],
1834
- });
1835
-
1836
- const upgradeRes = await client.subscription.upgrade({
1837
- plan: "starter",
1838
- seats: 5,
1839
- fetchOptions: {
1840
- headers,
1841
- },
1842
- });
1843
-
1844
- expect(upgradeRes.data?.url).toBeDefined();
1845
- });
1846
-
1847
- it("should prevent duplicate subscriptions with same plan and same seats", async () => {
1848
- const { client, auth, sessionSetter } = await getTestInstance(
1849
- {
1850
- database: memory,
1851
- plugins: [stripe(stripeOptions)],
1852
- },
1853
- {
1854
- disableTestUser: true,
1855
- clientOptions: {
1856
- plugins: [stripeClient({ subscription: true })],
1857
- },
1858
- },
1859
- );
1860
- const ctx = await auth.$context;
1861
-
1862
- const userRes = await client.signUp.email(
1863
- {
1864
- ...testUser,
1865
- email: "duplicate-prevention@email.com",
1866
- },
1867
- {
1868
- throw: true,
1869
- },
1870
- );
1871
-
1872
- const headers = new Headers();
1873
- await client.signIn.email(
1874
- {
1875
- ...testUser,
1876
- email: "duplicate-prevention@email.com",
1877
- },
1878
- {
1879
- throw: true,
1880
- onSuccess: sessionSetter(headers),
1881
- },
1882
- );
1883
-
1884
- await client.subscription.upgrade({
1885
- plan: "starter",
1886
- seats: 3,
1887
- fetchOptions: {
1888
- headers,
1889
- },
1890
- });
1891
-
1892
- await ctx.adapter.update({
1893
- model: "subscription",
1894
- update: {
1895
- status: "active",
1896
- seats: 3,
1897
- },
1898
- where: [
1899
- {
1900
- field: "referenceId",
1901
- value: userRes.user.id,
1902
- },
1903
- ],
1904
- });
1905
-
1906
- const upgradeRes = await client.subscription.upgrade({
1907
- plan: "starter",
1908
- seats: 3,
1909
- fetchOptions: {
1910
- headers,
1911
- },
1912
- });
1913
-
1914
- expect(upgradeRes.error).toBeDefined();
1915
- expect(upgradeRes.error?.message).toContain("already subscribed");
1916
- });
1917
-
1918
- it("should only call Stripe customers.create once for signup and upgrade", async () => {
1919
- const { client, sessionSetter } = await getTestInstance(
1920
- {
1921
- database: memory,
1922
- plugins: [stripe(stripeOptions)],
1923
- },
1924
- {
1925
- disableTestUser: true,
1926
- clientOptions: {
1927
- plugins: [stripeClient({ subscription: true })],
1928
- },
1929
- },
1930
- );
1931
-
1932
- await client.signUp.email(
1933
- { ...testUser, email: "single-create@email.com" },
1934
- { throw: true },
1935
- );
1936
-
1937
- const headers = new Headers();
1938
- await client.signIn.email(
1939
- { ...testUser, email: "single-create@email.com" },
1940
- {
1941
- throw: true,
1942
- onSuccess: sessionSetter(headers),
1943
- },
1944
- );
1945
-
1946
- await client.subscription.upgrade({
1947
- plan: "starter",
1948
- fetchOptions: { headers },
1949
- });
1950
-
1951
- expect(mockStripe.customers.create).toHaveBeenCalledTimes(1);
1952
- });
1953
-
1954
- it("should create billing portal session", async () => {
1955
- const { client, sessionSetter } = await getTestInstance(
1956
- {
1957
- database: memory,
1958
- plugins: [stripe(stripeOptions)],
1959
- },
1960
- {
1961
- disableTestUser: true,
1962
- clientOptions: {
1963
- plugins: [stripeClient({ subscription: true })],
1964
- },
1965
- },
1966
- );
1967
-
1968
- await client.signUp.email(
1969
- {
1970
- ...testUser,
1971
- email: "billing-portal@email.com",
1972
- },
1973
- {
1974
- throw: true,
1975
- },
1976
- );
1977
-
1978
- const headers = new Headers();
1979
- await client.signIn.email(
1980
- {
1981
- ...testUser,
1982
- email: "billing-portal@email.com",
1983
- },
1984
- {
1985
- throw: true,
1986
- onSuccess: sessionSetter(headers),
1987
- },
1988
- );
1989
- const billingPortalRes = await client.subscription.billingPortal({
1990
- returnUrl: "/dashboard",
1991
- fetchOptions: {
1992
- headers,
1993
- },
1994
- });
1995
- expect(billingPortalRes.data?.url).toBe("https://billing.stripe.com/mock");
1996
- expect(billingPortalRes.data?.redirect).toBe(true);
1997
- expect(mockStripe.billingPortal.sessions.create).toHaveBeenCalledWith({
1998
- customer: expect.any(String),
1999
- return_url: "http://localhost:3000/dashboard",
2000
- });
2001
- });
2002
-
2003
- it("should not update personal subscription when upgrading with an org referenceId", async () => {
2004
- /* cspell:disable-next-line */
2005
- const orgId = "org_b67GF32Cljh7u588AuEblmLVobclDRcP";
2006
-
2007
- const testOptions = {
2008
- ...stripeOptions,
2009
- stripeClient: _stripe,
2010
- subscription: {
2011
- ...stripeOptions.subscription,
2012
- authorizeReference: async () => true,
2013
- },
2014
- } as unknown as StripeOptions;
2015
-
2016
- const {
2017
- auth: testAuth,
2018
- client: testClient,
2019
- sessionSetter: testSessionSetter,
2020
- } = await getTestInstance(
2021
- {
2022
- database: memory,
2023
- plugins: [stripe(testOptions)],
2024
- },
2025
- {
2026
- disableTestUser: true,
2027
- clientOptions: {
2028
- plugins: [stripeClient({ subscription: true })],
2029
- },
2030
- },
2031
- );
2032
- const testCtx = await testAuth.$context;
2033
-
2034
- // Sign up and sign in the user
2035
- const userRes = await testClient.signUp.email(
2036
- { ...testUser, email: "org-ref@email.com" },
2037
- { throw: true },
2038
- );
2039
- const headers = new Headers();
2040
- await testClient.signIn.email(
2041
- { ...testUser, email: "org-ref@email.com" },
2042
- { throw: true, onSuccess: testSessionSetter(headers) },
2043
- );
2044
-
2045
- // Create a personal subscription (referenceId = user id)
2046
- await testClient.subscription.upgrade({
2047
- plan: "starter",
2048
- fetchOptions: { headers },
2049
- });
2050
-
2051
- const personalSub = await testCtx.adapter.findOne<Subscription>({
2052
- model: "subscription",
2053
- where: [{ field: "referenceId", value: userRes.user.id }],
2054
- });
2055
- expect(personalSub).toBeTruthy();
2056
-
2057
- await testCtx.adapter.update({
2058
- model: "subscription",
2059
- update: {
2060
- status: "active",
2061
- stripeSubscriptionId: "sub_personal_active_123",
2062
- },
2063
- where: [{ field: "id", value: personalSub!.id }],
2064
- });
2065
-
2066
- mockStripe.subscriptions.list.mockResolvedValue({
2067
- data: [
2068
- {
2069
- id: "sub_personal_active_123",
2070
- status: "active",
2071
- items: {
2072
- data: [
2073
- {
2074
- id: "si_1",
2075
- price: { id: process.env.STRIPE_PRICE_ID_1 },
2076
- quantity: 1,
2077
- },
2078
- ],
2079
- },
2080
- },
2081
- ],
2082
- });
2083
-
2084
- // Attempt to upgrade using an org referenceId
2085
- const upgradeRes = await testClient.subscription.upgrade({
2086
- plan: "starter",
2087
- referenceId: orgId,
2088
- fetchOptions: { headers },
2089
- });
2090
- // It should NOT go through billing portal (which would update the personal sub)
2091
- expect(mockStripe.billingPortal.sessions.create).not.toHaveBeenCalled();
2092
- expect(upgradeRes.data?.url).toBeDefined();
2093
-
2094
- const orgSub = await testCtx.adapter.findOne<Subscription>({
2095
- model: "subscription",
2096
- where: [{ field: "referenceId", value: orgId }],
2097
- });
2098
- expect(orgSub).toMatchObject({
2099
- referenceId: orgId,
2100
- status: "incomplete",
2101
- plan: "starter",
2102
- });
2103
-
2104
- const personalAfter = await testCtx.adapter.findOne<Subscription>({
2105
- model: "subscription",
2106
- where: [{ field: "id", value: personalSub!.id }],
2107
- });
2108
- expect(personalAfter?.status).toBe("active");
2109
- });
2110
-
2111
- it("should prevent multiple free trials for the same user", async () => {
2112
- const { client, auth, sessionSetter } = await getTestInstance(
2113
- {
2114
- database: memory,
2115
- plugins: [stripe(stripeOptions)],
2116
- },
2117
- {
2118
- disableTestUser: true,
2119
- clientOptions: {
2120
- plugins: [stripeClient({ subscription: true })],
2121
- },
2122
- },
2123
- );
2124
- const ctx = await auth.$context;
2125
-
2126
- // Create a user
2127
- const userRes = await client.signUp.email(
2128
- { ...testUser, email: "trial-prevention@email.com" },
2129
- { throw: true },
2130
- );
2131
-
2132
- const headers = new Headers();
2133
- await client.signIn.email(
2134
- { ...testUser, email: "trial-prevention@email.com" },
2135
- {
2136
- throw: true,
2137
- onSuccess: sessionSetter(headers),
2138
- },
2139
- );
2140
-
2141
- // First subscription with trial
2142
- const firstUpgradeRes = await client.subscription.upgrade({
2143
- plan: "starter",
2144
- fetchOptions: { headers },
2145
- });
2146
-
2147
- expect(firstUpgradeRes.data?.url).toBeDefined();
2148
-
2149
- // Simulate the subscription being created with trial data
2150
- await ctx.adapter.update({
2151
- model: "subscription",
2152
- update: {
2153
- status: "trialing",
2154
- trialStart: new Date(),
2155
- trialEnd: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
2156
- },
2157
- where: [
2158
- {
2159
- field: "referenceId",
2160
- value: userRes.user.id,
2161
- },
2162
- ],
2163
- });
2164
-
2165
- // Cancel the subscription
2166
- await ctx.adapter.update({
2167
- model: "subscription",
2168
- update: {
2169
- status: "canceled",
2170
- },
2171
- where: [
2172
- {
2173
- field: "referenceId",
2174
- value: userRes.user.id,
2175
- },
2176
- ],
2177
- });
2178
-
2179
- // Try to subscribe again - should NOT get a trial
2180
- const secondUpgradeRes = await client.subscription.upgrade({
2181
- plan: "starter",
2182
- fetchOptions: { headers },
2183
- });
2184
-
2185
- expect(secondUpgradeRes.data?.url).toBeDefined();
2186
-
2187
- // Verify that the checkout session was created without trial_period_days
2188
- // We can't directly test the Stripe session, but we can verify the logic
2189
- // by checking that the user has trial history
2190
- const subscriptions = (await ctx.adapter.findMany({
2191
- model: "subscription",
2192
- where: [
2193
- {
2194
- field: "referenceId",
2195
- value: userRes.user.id,
2196
- },
2197
- ],
2198
- })) as Subscription[];
2199
-
2200
- // Should have 2 subscriptions (first canceled, second new)
2201
- expect(subscriptions).toHaveLength(2);
2202
-
2203
- // At least one should have trial data
2204
- const hasTrialData = subscriptions.some(
2205
- (s: Subscription) => s.trialStart || s.trialEnd,
2206
- );
2207
- expect(hasTrialData).toBe(true);
2208
- });
2209
-
2210
- it("should prevent trial abuse when processing incomplete subscription with past trial history", async () => {
2211
- const { client, auth, sessionSetter } = await getTestInstance(
2212
- {
2213
- database: memory,
2214
- plugins: [
2215
- stripe({
2216
- ...stripeOptions,
2217
- subscription: {
2218
- ...stripeOptions.subscription,
2219
- plans: stripeOptions.subscription.plans.map((plan) => ({
2220
- ...plan,
2221
- freeTrial: { days: 7 },
2222
- })),
2223
- },
2224
- }),
2225
- ],
2226
- },
2227
- {
2228
- disableTestUser: true,
2229
- clientOptions: {
2230
- plugins: [stripeClient({ subscription: true })],
2231
- },
2232
- },
2233
- );
2234
- const ctx = await auth.$context;
2235
-
2236
- const userRes = await client.signUp.email(
2237
- { ...testUser, email: "trial-findone-test@email.com" },
2238
- { throw: true },
2239
- );
2240
-
2241
- const headers = new Headers();
2242
- await client.signIn.email(
2243
- { ...testUser, email: "trial-findone-test@email.com" },
2244
- { throw: true, onSuccess: sessionSetter(headers) },
2245
- );
2246
-
2247
- // Create a canceled subscription with trial history first
2248
- await ctx.adapter.create({
2249
- model: "subscription",
2250
- data: {
2251
- referenceId: userRes.user.id,
2252
- stripeCustomerId: "cus_old_customer",
2253
- status: "canceled",
2254
- plan: "starter",
2255
- stripeSubscriptionId: "sub_canceled_with_trial",
2256
- trialStart: new Date(Date.now() - 1000000),
2257
- trialEnd: new Date(Date.now() - 500000),
2258
- },
2259
- });
2260
-
2261
- // Create an new incomplete subscription (without trial info)
2262
- const incompleteSubId = "sub_incomplete_new";
2263
- await ctx.adapter.create({
2264
- model: "subscription",
2265
- data: {
2266
- referenceId: userRes.user.id,
2267
- stripeCustomerId: "cus_old_customer",
2268
- status: "incomplete",
2269
- plan: "premium",
2270
- stripeSubscriptionId: incompleteSubId,
2271
- },
2272
- });
2273
-
2274
- // When upgrading with a specific subscriptionId pointing to the incomplete one,
2275
- // the system should still check ALL subscriptions for trial history
2276
- const upgradeRes = await client.subscription.upgrade({
2277
- plan: "premium",
2278
- subscriptionId: incompleteSubId,
2279
- fetchOptions: { headers },
2280
- });
2281
-
2282
- expect(upgradeRes.data?.url).toBeDefined();
2283
-
2284
- // Verify that NO trial was granted despite processing the incomplete subscription
2285
- const callArgs = mockStripe.checkout.sessions.create.mock.lastCall?.[0];
2286
- expect(callArgs?.subscription_data?.trial_period_days).toBeUndefined();
2287
- });
2288
-
2289
- it("should upgrade existing subscription instead of creating new one", async () => {
2290
- // Reset mocks for this test
2291
- vi.clearAllMocks();
2292
-
2293
- const { client, auth, sessionSetter } = await getTestInstance(
2294
- {
2295
- database: memory,
2296
- plugins: [stripe(stripeOptions)],
2297
- },
2298
- {
2299
- disableTestUser: true,
2300
- clientOptions: {
2301
- plugins: [stripeClient({ subscription: true })],
2302
- },
2303
- },
2304
- );
2305
- const ctx = await auth.$context;
2306
-
2307
- // Create a user
2308
- const userRes = await client.signUp.email(
2309
- { ...testUser, email: "upgrade-existing@email.com" },
2310
- { throw: true },
2311
- );
2312
-
2313
- const headers = new Headers();
2314
- await client.signIn.email(
2315
- { ...testUser, email: "upgrade-existing@email.com" },
2316
- {
2317
- throw: true,
2318
- onSuccess: sessionSetter(headers),
2319
- },
2320
- );
2321
-
2322
- // Mock customers.search to find existing user customer
2323
- mockStripe.customers.search.mockResolvedValueOnce({
2324
- data: [{ id: "cus_test_123" }],
2325
- });
2326
-
2327
- // First create a starter subscription
2328
- await client.subscription.upgrade({
2329
- plan: "starter",
2330
- fetchOptions: { headers },
2331
- });
2332
-
2333
- // Simulate the subscription being active
2334
- const starterSub = await ctx.adapter.findOne<Subscription>({
2335
- model: "subscription",
2336
- where: [
2337
- {
2338
- field: "referenceId",
2339
- value: userRes.user.id,
2340
- },
2341
- ],
2342
- });
2343
-
2344
- await ctx.adapter.update({
2345
- model: "subscription",
2346
- update: {
2347
- status: "active",
2348
- stripeSubscriptionId: "sub_active_test_123",
2349
- stripeCustomerId: "cus_mock123", // Use the same customer ID as the mock
2350
- },
2351
- where: [
2352
- {
2353
- field: "id",
2354
- value: starterSub!.id,
2355
- },
2356
- ],
2357
- });
2358
-
2359
- // Also update the user with the Stripe customer ID
2360
- await ctx.adapter.update({
2361
- model: "user",
2362
- update: {
2363
- stripeCustomerId: "cus_mock123",
2364
- },
2365
- where: [
2366
- {
2367
- field: "id",
2368
- value: userRes.user.id,
2369
- },
2370
- ],
2371
- });
2372
-
2373
- // Mock Stripe subscriptions.list to return the active subscription
2374
- mockStripe.subscriptions.list.mockResolvedValueOnce({
2375
- data: [
2376
- {
2377
- id: "sub_active_test_123",
2378
- status: "active",
2379
- items: {
2380
- data: [
2381
- {
2382
- id: "si_test_123",
2383
- price: { id: process.env.STRIPE_PRICE_ID_1 },
2384
- quantity: 1,
2385
- current_period_start: Math.floor(Date.now() / 1000),
2386
- current_period_end:
2387
- Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
2388
- },
2389
- ],
2390
- },
2391
- },
2392
- ],
2393
- });
2394
-
2395
- // Clear mock calls before the upgrade
2396
- mockStripe.checkout.sessions.create.mockClear();
2397
- mockStripe.billingPortal.sessions.create.mockClear();
2398
-
2399
- // Now upgrade to premium plan - should use billing portal to update existing subscription
2400
- const upgradeRes = await client.subscription.upgrade({
2401
- plan: "premium",
2402
- fetchOptions: { headers },
2403
- });
2404
-
2405
- // Verify that billing portal was called (indicating update, not new subscription)
2406
- expect(mockStripe.billingPortal.sessions.create).toHaveBeenCalledWith(
2407
- expect.objectContaining({
2408
- customer: "cus_mock123",
2409
- flow_data: expect.objectContaining({
2410
- type: "subscription_update_confirm",
2411
- subscription_update_confirm: expect.objectContaining({
2412
- subscription: "sub_active_test_123",
2413
- }),
2414
- }),
2415
- }),
2416
- );
2417
-
2418
- // Should not create a new checkout session
2419
- expect(mockStripe.checkout.sessions.create).not.toHaveBeenCalled();
2420
-
2421
- // Verify the response has a redirect URL
2422
- expect(upgradeRes.data?.url).toBe("https://billing.stripe.com/mock");
2423
- expect(upgradeRes.data?.redirect).toBe(true);
2424
-
2425
- // Verify no new subscription was created in the database
2426
- const allSubs = await ctx.adapter.findMany<Subscription>({
2427
- model: "subscription",
2428
- where: [
2429
- {
2430
- field: "referenceId",
2431
- value: userRes.user.id,
2432
- },
2433
- ],
2434
- });
2435
- expect(allSubs).toHaveLength(1); // Should still have only one subscription
2436
- });
2437
-
2438
- it("should prevent multiple free trials across different plans", async () => {
2439
- const { client, auth, sessionSetter } = await getTestInstance(
2440
- {
2441
- database: memory,
2442
- plugins: [stripe(stripeOptions)],
2443
- },
2444
- {
2445
- disableTestUser: true,
2446
- clientOptions: {
2447
- plugins: [stripeClient({ subscription: true })],
2448
- },
2449
- },
2450
- );
2451
- const ctx = await auth.$context;
2452
-
2453
- // Create a user
2454
- const userRes = await client.signUp.email(
2455
- { ...testUser, email: "cross-plan-trial@email.com" },
2456
- { throw: true },
2457
- );
2458
-
2459
- const headers = new Headers();
2460
- await client.signIn.email(
2461
- { ...testUser, email: "cross-plan-trial@email.com" },
2462
- {
2463
- throw: true,
2464
- onSuccess: sessionSetter(headers),
2465
- },
2466
- );
2467
-
2468
- // First subscription with trial on starter plan
2469
- const firstUpgradeRes = await client.subscription.upgrade({
2470
- plan: "starter",
2471
- fetchOptions: { headers },
2472
- });
2473
-
2474
- expect(firstUpgradeRes.data?.url).toBeDefined();
2475
-
2476
- // Simulate the subscription being created with trial data
2477
- await ctx.adapter.update({
2478
- model: "subscription",
2479
- update: {
2480
- status: "trialing",
2481
- trialStart: new Date(),
2482
- trialEnd: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
2483
- },
2484
- where: [
2485
- {
2486
- field: "referenceId",
2487
- value: userRes.user.id,
2488
- },
2489
- ],
2490
- });
2491
-
2492
- // Cancel the subscription
2493
- await ctx.adapter.update({
2494
- model: "subscription",
2495
- update: {
2496
- status: "canceled",
2497
- },
2498
- where: [
2499
- {
2500
- field: "referenceId",
2501
- value: userRes.user.id,
2502
- },
2503
- ],
2504
- });
2505
-
2506
- // Try to subscribe to a different plan - should NOT get a trial
2507
- const secondUpgradeRes = await client.subscription.upgrade({
2508
- plan: "premium",
2509
- fetchOptions: { headers },
2510
- });
2511
-
2512
- expect(secondUpgradeRes.data?.url).toBeDefined();
2513
-
2514
- // Verify that the user has trial history from the first plan
2515
- const subscriptions = (await ctx.adapter.findMany({
2516
- model: "subscription",
2517
- where: [
2518
- {
2519
- field: "referenceId",
2520
- value: userRes.user.id,
2521
- },
2522
- ],
2523
- })) as Subscription[];
2524
-
2525
- // Should have at least 1 subscription (the starter with trial data)
2526
- expect(subscriptions.length).toBeGreaterThanOrEqual(1);
2527
-
2528
- // The starter subscription should have trial data
2529
- const starterSub = subscriptions.find(
2530
- (s: Subscription) => s.plan === "starter",
2531
- ) as Subscription | undefined;
2532
- expect(starterSub?.trialStart).toBeDefined();
2533
- expect(starterSub?.trialEnd).toBeDefined();
2534
-
2535
- // Verify that the trial eligibility logic is working by checking
2536
- // that the user has ever had a trial (which should prevent future trials)
2537
- const hasEverTrialed = subscriptions.some((s: Subscription) => {
2538
- const hadTrial =
2539
- !!(s.trialStart || s.trialEnd) || s.status === "trialing";
2540
- return hadTrial;
2541
- });
2542
- expect(hasEverTrialed).toBe(true);
2543
- });
2544
-
2545
- it("should update stripe customer email when user email changes", async () => {
2546
- const { client, auth } = await getTestInstance(
2547
- {
2548
- database: memory,
2549
- plugins: [stripe(stripeOptions)],
2550
- },
2551
- {
2552
- disableTestUser: true,
2553
- clientOptions: {
2554
- plugins: [stripeClient({ subscription: true })],
2555
- },
2556
- },
2557
- );
2558
- const ctx = await auth.$context;
2559
-
2560
- // Setup mock for customer retrieve and update
2561
- mockStripe.customers.retrieve = vi.fn().mockResolvedValue({
2562
- id: "cus_mock123",
2563
- email: "test@email.com",
2564
- deleted: false,
2565
- });
2566
- mockStripe.customers.update = vi.fn().mockResolvedValue({
2567
- id: "cus_mock123",
2568
- email: "newemail@example.com",
2569
- });
2570
-
2571
- // Sign up a user
2572
- const userRes = await client.signUp.email(testUser, {
2573
- throw: true,
2574
- });
2575
-
2576
- expect(userRes.user).toBeDefined();
2577
-
2578
- // Verify customer was created during signup
2579
- expect(mockStripe.customers.create).toHaveBeenCalledWith({
2580
- email: testUser.email,
2581
- name: testUser.name,
2582
- metadata: {
2583
- customerType: "user",
2584
- userId: userRes.user.id,
2585
- },
2586
- });
2587
-
2588
- // Clear mocks to track the update
2589
- vi.clearAllMocks();
2590
-
2591
- // Re-setup the retrieve mock for the update flow
2592
- mockStripe.customers.retrieve = vi.fn().mockResolvedValue({
2593
- id: "cus_mock123",
2594
- email: "test@email.com",
2595
- deleted: false,
2596
- });
2597
- mockStripe.customers.update = vi.fn().mockResolvedValue({
2598
- id: "cus_mock123",
2599
- email: "newemail@example.com",
2600
- });
2601
-
2602
- // Update the user's email using internal adapter (which triggers hooks)
2603
- await runWithEndpointContext(
2604
- {
2605
- context: ctx,
2606
- },
2607
- () =>
2608
- ctx.internalAdapter.updateUserByEmail(testUser.email, {
2609
- email: "newemail@example.com",
2610
- }),
2611
- );
2612
-
2613
- // Verify that Stripe customer.retrieve was called
2614
- expect(mockStripe.customers.retrieve).toHaveBeenCalledWith("cus_mock123");
2615
-
2616
- // Verify that Stripe customer.update was called with the new email
2617
- expect(mockStripe.customers.update).toHaveBeenCalledWith("cus_mock123", {
2618
- email: "newemail@example.com",
2619
- });
2620
- });
2621
-
2622
- describe("getCustomerCreateParams", () => {
2623
- it("should call getCustomerCreateParams and merge with default params", async () => {
2624
- const getCustomerCreateParamsMock = vi
2625
- .fn()
2626
- .mockResolvedValue({ metadata: { customField: "customValue" } });
2627
-
2628
- const testOptions = {
2629
- ...stripeOptions,
2630
- createCustomerOnSignUp: true,
2631
- getCustomerCreateParams: getCustomerCreateParamsMock,
2632
- } satisfies StripeOptions;
2633
-
2634
- const { client: testClient } = await getTestInstance(
2635
- {
2636
- database: memory,
2637
- plugins: [stripe(testOptions)],
2638
- },
2639
- {
2640
- disableTestUser: true,
2641
- clientOptions: {
2642
- plugins: [stripeClient({ subscription: true })],
2643
- },
2644
- },
2645
- );
2646
-
2647
- // Sign up a user
2648
- const userRes = await testClient.signUp.email(
2649
- {
2650
- email: "custom-params@email.com",
2651
- password: "password",
2652
- name: "Custom User",
2653
- },
2654
- {
2655
- throw: true,
2656
- },
2657
- );
2658
-
2659
- // Verify getCustomerCreateParams was called
2660
- expect(getCustomerCreateParamsMock).toHaveBeenCalledWith(
2661
- expect.objectContaining({
2662
- id: userRes.user.id,
2663
- email: "custom-params@email.com",
2664
- name: "Custom User",
2665
- }),
2666
- expect.objectContaining({
2667
- context: expect.any(Object),
2668
- }),
2669
- );
2670
-
2671
- // Verify customer was created with merged params
2672
- expect(mockStripe.customers.create).toHaveBeenCalledWith(
2673
- expect.objectContaining({
2674
- email: "custom-params@email.com",
2675
- name: "Custom User",
2676
- metadata: expect.objectContaining({
2677
- userId: userRes.user.id,
2678
- customField: "customValue",
2679
- }),
2680
- }),
2681
- );
2682
- });
2683
-
2684
- it("should use getCustomerCreateParams to add custom address", async () => {
2685
- const getCustomerCreateParamsMock = vi.fn().mockResolvedValue({
2686
- address: {
2687
- line1: "123 Main St",
2688
- city: "San Francisco",
2689
- state: "CA",
2690
- postal_code: "94111",
2691
- country: "US",
2692
- },
2693
- });
2694
-
2695
- const testOptions = {
2696
- ...stripeOptions,
2697
- createCustomerOnSignUp: true,
2698
- getCustomerCreateParams: getCustomerCreateParamsMock,
2699
- } satisfies StripeOptions;
2700
-
2701
- const { client: testAuthClient } = await getTestInstance(
2702
- {
2703
- database: memory,
2704
- plugins: [stripe(testOptions)],
2705
- },
2706
- {
2707
- disableTestUser: true,
2708
- clientOptions: {
2709
- plugins: [stripeClient({ subscription: true })],
2710
- },
2711
- },
2712
- );
2713
-
2714
- // Sign up a user
2715
- await testAuthClient.signUp.email(
2716
- {
2717
- email: "address-user@email.com",
2718
- password: "password",
2719
- name: "Address User",
2720
- },
2721
- {
2722
- throw: true,
2723
- },
2724
- );
2725
-
2726
- // Verify customer was created with address
2727
- expect(mockStripe.customers.create).toHaveBeenCalledWith(
2728
- expect.objectContaining({
2729
- email: "address-user@email.com",
2730
- name: "Address User",
2731
- address: {
2732
- line1: "123 Main St",
2733
- city: "San Francisco",
2734
- state: "CA",
2735
- postal_code: "94111",
2736
- country: "US",
2737
- },
2738
- metadata: expect.objectContaining({
2739
- userId: expect.any(String),
2740
- }),
2741
- }),
2742
- );
2743
- });
2744
-
2745
- it("should properly merge nested objects using defu", async () => {
2746
- const getCustomerCreateParamsMock = vi.fn().mockResolvedValue({
2747
- metadata: {
2748
- customField: "customValue",
2749
- anotherField: "anotherValue",
2750
- },
2751
- phone: "+1234567890",
2752
- });
2753
-
2754
- const testOptions = {
2755
- ...stripeOptions,
2756
- createCustomerOnSignUp: true,
2757
- getCustomerCreateParams: getCustomerCreateParamsMock,
2758
- } satisfies StripeOptions;
2759
-
2760
- const { client: testAuthClient } = await getTestInstance(
2761
- {
2762
- database: memory,
2763
- plugins: [stripe(testOptions)],
2764
- },
2765
- {
2766
- disableTestUser: true,
2767
- clientOptions: {
2768
- plugins: [stripeClient({ subscription: true })],
2769
- },
2770
- },
2771
- );
2772
-
2773
- // Sign up a user
2774
- const userRes = await testAuthClient.signUp.email(
2775
- {
2776
- email: "merge-test@email.com",
2777
- password: "password",
2778
- name: "Merge User",
2779
- },
2780
- {
2781
- throw: true,
2782
- },
2783
- );
2784
-
2785
- // Verify customer was created with properly merged params
2786
- // defu merges objects and preserves all fields
2787
- expect(mockStripe.customers.create).toHaveBeenCalledWith(
2788
- expect.objectContaining({
2789
- email: "merge-test@email.com",
2790
- name: "Merge User",
2791
- phone: "+1234567890",
2792
- metadata: {
2793
- customerType: "user",
2794
- userId: userRes.user.id,
2795
- customField: "customValue",
2796
- anotherField: "anotherValue",
2797
- },
2798
- }),
2799
- );
2800
- });
2801
-
2802
- it("should work without getCustomerCreateParams", async () => {
2803
- // This test ensures backward compatibility
2804
- const testOptions = {
2805
- ...stripeOptions,
2806
- createCustomerOnSignUp: true,
2807
- // No getCustomerCreateParams provided
2808
- } satisfies StripeOptions;
2809
-
2810
- const { client: testAuthClient } = await getTestInstance(
2811
- {
2812
- database: memory,
2813
- plugins: [stripe(testOptions)],
2814
- },
2815
- {
2816
- disableTestUser: true,
2817
- clientOptions: {
2818
- plugins: [stripeClient({ subscription: true })],
2819
- },
2820
- },
2821
- );
2822
-
2823
- // Sign up a user
2824
- const userRes = await testAuthClient.signUp.email(
2825
- {
2826
- email: "no-custom-params@email.com",
2827
- password: "password",
2828
- name: "Default User",
2829
- },
2830
- {
2831
- throw: true,
2832
- },
2833
- );
2834
-
2835
- // Verify customer was created with default params only
2836
- expect(mockStripe.customers.create).toHaveBeenCalledWith({
2837
- email: "no-custom-params@email.com",
2838
- name: "Default User",
2839
- metadata: {
2840
- customerType: "user",
2841
- userId: userRes.user.id,
2842
- },
2843
- });
2844
- });
2845
- });
2846
-
2847
- describe("Webhook Error Handling (Stripe v19)", () => {
2848
- it("should handle invalid webhook signature with constructEventAsync", async () => {
2849
- const mockError = new Error("Invalid signature");
2850
- const stripeWithError = {
2851
- ...stripeOptions.stripeClient,
2852
- webhooks: {
2853
- constructEventAsync: vi.fn().mockRejectedValue(mockError),
2854
- },
2855
- };
2856
-
2857
- const testOptions = {
2858
- ...stripeOptions,
2859
- stripeClient: stripeWithError as unknown as Stripe,
2860
- stripeWebhookSecret: "test_secret",
2861
- };
2862
-
2863
- const { auth: testAuth } = await getTestInstance(
2864
- {
2865
- database: memory,
2866
- plugins: [stripe(testOptions)],
2867
- },
2868
- {
2869
- disableTestUser: true,
2870
- },
2871
- );
2872
-
2873
- const mockRequest = new Request(
2874
- "http://localhost:3000/api/auth/stripe/webhook",
2875
- {
2876
- method: "POST",
2877
- headers: {
2878
- "stripe-signature": "invalid_signature",
2879
- },
2880
- body: JSON.stringify({ type: "test.event" }),
2881
- },
2882
- );
2883
-
2884
- const response = await testAuth.handler(mockRequest);
2885
- expect(response.status).toBe(400);
2886
- const data = await response.json();
2887
- expect(data.message).toContain("Failed to construct Stripe event");
2888
- });
2889
-
2890
- it("should reject webhook request without stripe-signature header", async () => {
2891
- const { auth: testAuth } = await getTestInstance(
2892
- {
2893
- database: memory,
2894
- plugins: [stripe(stripeOptions)],
2895
- },
2896
- {
2897
- disableTestUser: true,
2898
- },
2899
- );
2900
-
2901
- const mockRequest = new Request(
2902
- "http://localhost:3000/api/auth/stripe/webhook",
2903
- {
2904
- method: "POST",
2905
- headers: {
2906
- "content-type": "application/json",
2907
- },
2908
- body: JSON.stringify({ type: "test.event" }),
2909
- },
2910
- );
2911
-
2912
- const response = await testAuth.handler(mockRequest);
2913
- expect(response.status).toBe(400);
2914
- const data = await response.json();
2915
- expect(data.message).toContain("Stripe signature not found");
2916
- });
2917
-
2918
- it("should handle constructEventAsync returning null/undefined", async () => {
2919
- const stripeWithNull = {
2920
- ...stripeOptions.stripeClient,
2921
- webhooks: {
2922
- constructEventAsync: vi.fn().mockResolvedValue(null),
2923
- },
2924
- };
2925
-
2926
- const testOptions = {
2927
- ...stripeOptions,
2928
- stripeClient: stripeWithNull as unknown as Stripe,
2929
- stripeWebhookSecret: "test_secret",
2930
- };
2931
-
2932
- const { auth: testAuth } = await getTestInstance(
2933
- {
2934
- database: memory,
2935
- plugins: [stripe(testOptions)],
2936
- },
2937
- {
2938
- disableTestUser: true,
2939
- },
2940
- );
2941
-
2942
- const mockRequest = new Request(
2943
- "http://localhost:3000/api/auth/stripe/webhook",
2944
- {
2945
- method: "POST",
2946
- headers: {
2947
- "stripe-signature": "test_signature",
2948
- },
2949
- body: JSON.stringify({ type: "test.event" }),
2950
- },
2951
- );
2952
-
2953
- const response = await testAuth.handler(mockRequest);
2954
- expect(response.status).toBe(400);
2955
- const data = await response.json();
2956
- expect(data.message).toContain("Failed to construct Stripe event");
2957
- });
2958
-
2959
- it("should handle async errors in webhook event processing", async () => {
2960
- const errorThrowingHandler = vi
2961
- .fn()
2962
- .mockRejectedValue(new Error("Event processing failed"));
2963
-
2964
- const mockEvent = {
2965
- type: "checkout.session.completed",
2966
- data: {
2967
- object: {
2968
- mode: "subscription",
2969
- subscription: "sub_123",
2970
- metadata: {
2971
- referenceId: "user_123",
2972
- subscriptionId: "sub_123",
2973
- },
2974
- },
2975
- },
2976
- };
2977
-
2978
- const stripeForTest = {
2979
- ...stripeOptions.stripeClient,
2980
- subscriptions: {
2981
- retrieve: vi.fn().mockRejectedValue(new Error("Stripe API error")),
2982
- },
2983
- webhooks: {
2984
- constructEventAsync: vi.fn().mockResolvedValue(mockEvent),
2985
- },
2986
- };
2987
-
2988
- const testOptions = {
2989
- ...stripeOptions,
2990
- stripeClient: stripeForTest as unknown as Stripe,
2991
- stripeWebhookSecret: "test_secret",
2992
- subscription: {
2993
- ...stripeOptions.subscription,
2994
- onSubscriptionComplete: errorThrowingHandler,
2995
- },
2996
- };
2997
-
2998
- const { auth: testAuth } = await getTestInstance(
2999
- {
3000
- database: memory,
3001
- plugins: [stripe(testOptions as StripeOptions)],
3002
- },
3003
- {
3004
- disableTestUser: true,
3005
- },
3006
- );
3007
- const testCtx = await testAuth.$context;
3008
-
3009
- await testCtx.adapter.create({
3010
- model: "subscription",
3011
- data: {
3012
- referenceId: "user_123",
3013
- stripeCustomerId: "cus_123",
3014
- status: "incomplete",
3015
- plan: "starter",
3016
- id: "sub_123",
3017
- },
3018
- });
3019
-
3020
- const mockRequest = new Request(
3021
- "http://localhost:3000/api/auth/stripe/webhook",
3022
- {
3023
- method: "POST",
3024
- headers: {
3025
- "stripe-signature": "test_signature",
3026
- },
3027
- body: JSON.stringify(mockEvent),
3028
- },
3029
- );
3030
-
3031
- const response = await testAuth.handler(mockRequest);
3032
- // Errors inside event handlers are caught and logged but don't fail the webhook
3033
- // This prevents Stripe from retrying and is the expected behavior
3034
- expect(response.status).toBe(200);
3035
- const data = await response.json();
3036
- expect(data).toEqual({ success: true });
3037
- // Verify the error was logged (via the stripeClient.subscriptions.retrieve rejection)
3038
- expect(stripeForTest.subscriptions.retrieve).toHaveBeenCalled();
3039
- });
3040
-
3041
- it("should successfully process webhook with valid async signature verification", async () => {
3042
- const mockEvent = {
3043
- type: "customer.subscription.updated",
3044
- data: {
3045
- object: {
3046
- id: "sub_test_async",
3047
- customer: "cus_test_async",
3048
- status: "active",
3049
- items: {
3050
- data: [
3051
- {
3052
- price: { id: process.env.STRIPE_PRICE_ID_1 },
3053
- quantity: 1,
3054
- current_period_start: Math.floor(Date.now() / 1000),
3055
- current_period_end:
3056
- Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
3057
- },
3058
- ],
3059
- },
3060
- current_period_start: Math.floor(Date.now() / 1000),
3061
- current_period_end:
3062
- Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
3063
- },
3064
- },
3065
- };
3066
-
3067
- const stripeForTest = {
3068
- ...stripeOptions.stripeClient,
3069
- webhooks: {
3070
- // Simulate async verification success
3071
- constructEventAsync: vi.fn().mockResolvedValue(mockEvent),
3072
- },
3073
- };
3074
-
3075
- const testOptions = {
3076
- ...stripeOptions,
3077
- stripeClient: stripeForTest as unknown as Stripe,
3078
- stripeWebhookSecret: "test_secret_async",
3079
- };
3080
-
3081
- const { auth: testAuth } = await getTestInstance(
3082
- {
3083
- database: memory,
3084
- plugins: [stripe(testOptions)],
3085
- },
3086
- {
3087
- disableTestUser: true,
3088
- },
3089
- );
3090
- const testCtx = await testAuth.$context;
3091
-
3092
- const { id: testUserId } = await testCtx.adapter.create({
3093
- model: "user",
3094
- data: {
3095
- email: "async-test@email.com",
3096
- },
3097
- });
3098
-
3099
- await testCtx.adapter.create({
3100
- model: "subscription",
3101
- data: {
3102
- referenceId: testUserId,
3103
- stripeCustomerId: "cus_test_async",
3104
- stripeSubscriptionId: "sub_test_async",
3105
- status: "incomplete",
3106
- plan: "starter",
3107
- },
3108
- });
3109
-
3110
- const mockRequest = new Request(
3111
- "http://localhost:3000/api/auth/stripe/webhook",
3112
- {
3113
- method: "POST",
3114
- headers: {
3115
- "stripe-signature": "valid_async_signature",
3116
- },
3117
- body: JSON.stringify(mockEvent),
3118
- },
3119
- );
3120
-
3121
- const response = await testAuth.handler(mockRequest);
3122
- expect(response.status).toBe(200);
3123
- expect(stripeForTest.webhooks.constructEventAsync).toHaveBeenCalledWith(
3124
- expect.any(String),
3125
- "valid_async_signature",
3126
- "test_secret_async",
3127
- );
3128
-
3129
- const data = await response.json();
3130
- expect(data).toEqual({ success: true });
3131
- });
3132
-
3133
- it("should call constructEventAsync with exactly 3 required parameters", async () => {
3134
- const mockEvent = {
3135
- type: "customer.subscription.created",
3136
- data: {
3137
- object: {
3138
- id: "sub_test_params",
3139
- customer: "cus_test_params",
3140
- status: "active",
3141
- },
3142
- },
3143
- };
3144
-
3145
- const stripeForTest = {
3146
- ...stripeOptions.stripeClient,
3147
- webhooks: {
3148
- constructEventAsync: vi.fn().mockResolvedValue(mockEvent),
3149
- },
3150
- };
3151
-
3152
- const testOptions = {
3153
- ...stripeOptions,
3154
- stripeClient: stripeForTest as unknown as Stripe,
3155
- stripeWebhookSecret: "test_secret_params",
3156
- };
3157
-
3158
- const { auth: testAuth } = await getTestInstance(
3159
- {
3160
- database: memory,
3161
- plugins: [stripe(testOptions)],
3162
- },
3163
- {
3164
- disableTestUser: true,
3165
- },
3166
- );
3167
-
3168
- const mockRequest = new Request(
3169
- "http://localhost:3000/api/auth/stripe/webhook",
3170
- {
3171
- method: "POST",
3172
- headers: {
3173
- "stripe-signature": "test_signature_params",
3174
- },
3175
- body: JSON.stringify(mockEvent),
3176
- },
3177
- );
3178
-
3179
- await testAuth.handler(mockRequest);
3180
-
3181
- // Verify that constructEventAsync is called with exactly 3 required parameters
3182
- // (payload, signature, secret) and no optional parameters
3183
- expect(stripeForTest.webhooks.constructEventAsync).toHaveBeenCalledWith(
3184
- expect.any(String), // payload
3185
- "test_signature_params", // signature
3186
- "test_secret_params", // secret
3187
- );
3188
-
3189
- // Verify it was called exactly once
3190
- expect(stripeForTest.webhooks.constructEventAsync).toHaveBeenCalledTimes(
3191
- 1,
3192
- );
3193
- });
3194
-
3195
- it("should support Stripe v18 with sync constructEvent method", async () => {
3196
- const mockEvent = {
3197
- type: "customer.subscription.updated",
3198
- data: {
3199
- object: {
3200
- id: "sub_test_v18",
3201
- customer: "cus_test_v18",
3202
- status: "active",
3203
- items: {
3204
- data: [
3205
- {
3206
- price: { id: process.env.STRIPE_PRICE_ID_1 },
3207
- quantity: 1,
3208
- current_period_start: Math.floor(Date.now() / 1000),
3209
- current_period_end:
3210
- Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
3211
- },
3212
- ],
3213
- },
3214
- current_period_start: Math.floor(Date.now() / 1000),
3215
- current_period_end:
3216
- Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
3217
- },
3218
- },
3219
- };
3220
-
3221
- // Simulate Stripe v18 - only has sync constructEvent, no constructEventAsync
3222
- const stripeV18 = {
3223
- ...stripeOptions.stripeClient,
3224
- webhooks: {
3225
- constructEvent: vi.fn().mockReturnValue(mockEvent),
3226
- // v18 doesn't have constructEventAsync
3227
- constructEventAsync: undefined,
3228
- },
3229
- };
3230
-
3231
- const testOptions = {
3232
- ...stripeOptions,
3233
- stripeClient: stripeV18 as unknown as Stripe,
3234
- stripeWebhookSecret: "test_secret_v18",
3235
- };
3236
-
3237
- const { auth: testAuth } = await getTestInstance(
3238
- {
3239
- database: memory,
3240
- plugins: [stripe(testOptions)],
3241
- },
3242
- {
3243
- disableTestUser: true,
3244
- },
3245
- );
3246
- const testCtx = await testAuth.$context;
3247
-
3248
- const { id: testUserId } = await testCtx.adapter.create({
3249
- model: "user",
3250
- data: {
3251
- email: "v18-test@email.com",
3252
- },
3253
- });
3254
-
3255
- await testCtx.adapter.create({
3256
- model: "subscription",
3257
- data: {
3258
- referenceId: testUserId,
3259
- stripeCustomerId: "cus_test_v18",
3260
- stripeSubscriptionId: "sub_test_v18",
3261
- status: "incomplete",
3262
- plan: "starter",
3263
- },
3264
- });
3265
-
3266
- const mockRequest = new Request(
3267
- "http://localhost:3000/api/auth/stripe/webhook",
3268
- {
3269
- method: "POST",
3270
- headers: {
3271
- "stripe-signature": "test_signature_v18",
3272
- },
3273
- body: JSON.stringify(mockEvent),
3274
- },
3275
- );
3276
-
3277
- const response = await testAuth.handler(mockRequest);
3278
- expect(response.status).toBe(200);
3279
-
3280
- // Verify that constructEvent (sync) was called instead of constructEventAsync
3281
- expect(stripeV18.webhooks.constructEvent).toHaveBeenCalledWith(
3282
- expect.any(String),
3283
- "test_signature_v18",
3284
- "test_secret_v18",
3285
- );
3286
- expect(stripeV18.webhooks.constructEvent).toHaveBeenCalledTimes(1);
3287
-
3288
- const data = await response.json();
3289
- expect(data).toEqual({ success: true });
3290
- });
3291
- });
3292
-
3293
- it("should support flexible limits types", async () => {
3294
- const flexiblePlans = [
3295
- {
3296
- name: "flexible",
3297
- priceId: "price_flexible",
3298
- limits: {
3299
- // Numbers
3300
- maxUsers: 100,
3301
- maxProjects: 10,
3302
- // Arrays
3303
- features: ["analytics", "api", "webhooks"],
3304
- supportedMethods: ["GET", "POST", "PUT", "DELETE"],
3305
- // Objects
3306
- rateLimit: { requests: 1000, window: 3600 },
3307
- permissions: { admin: true, read: true, write: false },
3308
- // Mixed
3309
- quotas: {
3310
- storage: 50,
3311
- bandwidth: [100, "GB"],
3312
- },
3313
- },
3314
- },
3315
- ];
3316
-
3317
- const {
3318
- client: testClient,
3319
- auth: testAuth,
3320
- sessionSetter: testSessionSetter,
3321
- } = await getTestInstance(
3322
- {
3323
- database: memory,
3324
- plugins: [
3325
- stripe({
3326
- ...stripeOptions,
3327
- subscription: {
3328
- enabled: true,
3329
- plans: flexiblePlans,
3330
- },
3331
- }),
3332
- ],
3333
- },
3334
- {
3335
- disableTestUser: true,
3336
- clientOptions: {
3337
- plugins: [stripeClient({ subscription: true })],
3338
- },
3339
- },
3340
- );
3341
- const testCtx = await testAuth.$context;
3342
-
3343
- // Create user and sign in
3344
- const headers = new Headers();
3345
- const userRes = await testClient.signUp.email(
3346
- { email: "limits@test.com", password: "password", name: "Test" },
3347
- { throw: true },
3348
- );
3349
- const limitUserId = userRes.user.id;
3350
-
3351
- await testClient.signIn.email(
3352
- { email: "limits@test.com", password: "password" },
3353
- { throw: true, onSuccess: testSessionSetter(headers) },
3354
- );
3355
-
3356
- // Create subscription
3357
- await testCtx.adapter.create({
3358
- model: "subscription",
3359
- data: {
3360
- referenceId: limitUserId,
3361
- stripeCustomerId: "cus_limits_test",
3362
- stripeSubscriptionId: "sub_limits_test",
3363
- status: "active",
3364
- plan: "flexible",
3365
- },
3366
- });
3367
-
3368
- // List subscriptions and verify limits structure
3369
- const result = await testClient.subscription.list({
3370
- fetchOptions: { headers, throw: true },
3371
- });
3372
-
3373
- expect(result.length).toBe(1);
3374
- const limits = result[0]?.limits;
3375
-
3376
- // Verify different types are preserved
3377
- expect(limits).toBeDefined();
3378
-
3379
- // Type-safe access with unknown (cast once for test convenience)
3380
- const typedLimits = limits as Record<string, unknown>;
3381
- expect(typedLimits.maxUsers).toBe(100);
3382
- expect(typedLimits.maxProjects).toBe(10);
3383
- expect(typeof typedLimits.rateLimit).toBe("object");
3384
- expect(typedLimits.features).toEqual(["analytics", "api", "webhooks"]);
3385
- expect(Array.isArray(typedLimits.features)).toBe(true);
3386
- expect(Array.isArray(typedLimits.supportedMethods)).toBe(true);
3387
- expect((typedLimits.quotas as Record<string, unknown>).storage).toBe(50);
3388
- expect((typedLimits.rateLimit as Record<string, unknown>).requests).toBe(
3389
- 1000,
3390
- );
3391
- expect((typedLimits.permissions as Record<string, unknown>).admin).toBe(
3392
- true,
3393
- );
3394
- expect(
3395
- Array.isArray((typedLimits.quotas as Record<string, unknown>).bandwidth),
3396
- ).toBe(true);
3397
- });
3398
-
3399
- describe("Duplicate customer prevention on signup", () => {
3400
- it("should NOT create duplicate customer when email already exists in Stripe", async () => {
3401
- const existingEmail = "duplicate-email@example.com";
3402
- const existingCustomerId = "cus_stripe_existing_456";
3403
-
3404
- mockStripe.customers.search.mockResolvedValueOnce({
3405
- data: [
3406
- {
3407
- id: existingCustomerId,
3408
- email: existingEmail,
3409
- name: "Existing Stripe Customer",
3410
- },
3411
- ],
3412
- });
3413
-
3414
- const testOptionsWithHook = {
3415
- ...stripeOptions,
3416
- createCustomerOnSignUp: true,
3417
- } satisfies StripeOptions;
3418
-
3419
- const { client: testAuthClient, auth: testAuth } = await getTestInstance(
3420
- {
3421
- database: memory,
3422
- plugins: [stripe(testOptionsWithHook)],
3423
- },
3424
- {
3425
- disableTestUser: true,
3426
- clientOptions: {
3427
- plugins: [stripeClient({ subscription: true })],
3428
- },
3429
- },
3430
- );
3431
- const testCtx = await testAuth.$context;
3432
-
3433
- vi.clearAllMocks();
3434
-
3435
- // Sign up with email that exists in Stripe
3436
- const userRes = await testAuthClient.signUp.email(
3437
- {
3438
- email: existingEmail,
3439
- password: "password",
3440
- name: "Duplicate Email User",
3441
- },
3442
- { throw: true },
3443
- );
3444
-
3445
- // Should check for existing user customer by email (excluding organization customers)
3446
- expect(mockStripe.customers.search).toHaveBeenCalledWith({
3447
- query: `email:"${existingEmail}" AND -metadata["customerType"]:"organization"`,
3448
- limit: 1,
3449
- });
3450
-
3451
- // Should NOT create duplicate customer
3452
- expect(mockStripe.customers.create).not.toHaveBeenCalled();
3453
-
3454
- // Verify user has the EXISTING Stripe customer ID (not new duplicate)
3455
- const user = await testCtx.adapter.findOne<
3456
- User & { stripeCustomerId?: string }
3457
- >({
3458
- model: "user",
3459
- where: [{ field: "id", value: userRes.user.id }],
3460
- });
3461
- expect(user?.stripeCustomerId).toBe(existingCustomerId); // Should use existing ID
3462
- });
3463
-
3464
- it("should CREATE customer only when user has no stripeCustomerId and none exists in Stripe", async () => {
3465
- const newEmail = "brand-new@example.com";
3466
-
3467
- mockStripe.customers.search.mockResolvedValueOnce({
3468
- data: [],
3469
- });
3470
-
3471
- mockStripe.customers.create.mockResolvedValueOnce({
3472
- id: "cus_new_created_789",
3473
- email: newEmail,
3474
- });
3475
-
3476
- const testOptionsWithHook = {
3477
- ...stripeOptions,
3478
- createCustomerOnSignUp: true,
3479
- } satisfies StripeOptions;
3480
-
3481
- const { client: testAuthClient, auth: testAuth } = await getTestInstance(
3482
- {
3483
- database: memory,
3484
- plugins: [stripe(testOptionsWithHook)],
3485
- },
3486
- {
3487
- disableTestUser: true,
3488
- clientOptions: {
3489
- plugins: [stripeClient({ subscription: true })],
3490
- },
3491
- },
3492
- );
3493
- const testCtx = await testAuth.$context;
3494
-
3495
- vi.clearAllMocks();
3496
-
3497
- // Sign up with brand new email
3498
- const userRes = await testAuthClient.signUp.email(
3499
- {
3500
- email: newEmail,
3501
- password: "password",
3502
- name: "Brand New User",
3503
- },
3504
- { throw: true },
3505
- );
3506
-
3507
- // Should check for existing user customer first (excluding organization customers)
3508
- expect(mockStripe.customers.search).toHaveBeenCalledWith({
3509
- query: `email:"${newEmail}" AND -metadata["customerType"]:"organization"`,
3510
- limit: 1,
3511
- });
3512
-
3513
- // Should create new customer (this is correct behavior)
3514
- expect(mockStripe.customers.create).toHaveBeenCalledTimes(1);
3515
- expect(mockStripe.customers.create).toHaveBeenCalledWith({
3516
- email: newEmail,
3517
- name: "Brand New User",
3518
- metadata: {
3519
- userId: userRes.user.id,
3520
- customerType: "user",
3521
- },
3522
- });
3523
-
3524
- // Verify user has the new Stripe customer ID
3525
- const user = await testCtx.adapter.findOne<
3526
- User & { stripeCustomerId?: string }
3527
- >({
3528
- model: "user",
3529
- where: [{ field: "id", value: userRes.user.id }],
3530
- });
3531
- expect(user?.stripeCustomerId).toBeDefined();
3532
- });
3533
- });
3534
-
3535
- describe("User/Organization customer collision prevention", () => {
3536
- it("should NOT return organization customer when searching for user customer with same email", async () => {
3537
- // Scenario: Organization has a Stripe customer with email "shared@example.com"
3538
- // When a user signs up with the same email, the search should NOT find the org customer
3539
- const sharedEmail = "shared@example.com";
3540
- const orgCustomerId = "cus_org_123";
3541
-
3542
- // Mock: Only organization customer exists with this email
3543
- // The search query includes `-metadata['customerType']:'organization'`
3544
- // so this should NOT be returned
3545
- mockStripe.customers.search.mockResolvedValueOnce({
3546
- data: [], // Organization customer is excluded by the search query
3547
- });
3548
-
3549
- mockStripe.customers.create.mockResolvedValueOnce({
3550
- id: "cus_user_new_456",
3551
- email: sharedEmail,
3552
- });
3553
-
3554
- const testOptions = {
3555
- ...stripeOptions,
3556
- createCustomerOnSignUp: true,
3557
- } satisfies StripeOptions;
3558
-
3559
- const { client: testAuthClient, auth: testAuth } = await getTestInstance(
3560
- {
3561
- database: memory,
3562
- plugins: [stripe(testOptions)],
3563
- },
3564
- {
3565
- disableTestUser: true,
3566
- clientOptions: {
3567
- plugins: [stripeClient({ subscription: true })],
3568
- },
3569
- },
3570
- );
3571
- const testCtx = await testAuth.$context;
3572
-
3573
- vi.clearAllMocks();
3574
-
3575
- // User signs up with email that organization already uses
3576
- const userRes = await testAuthClient.signUp.email(
3577
- {
3578
- email: sharedEmail,
3579
- password: "password",
3580
- name: "User With Shared Email",
3581
- },
3582
- { throw: true },
3583
- );
3584
-
3585
- // Should search with query that EXCLUDES organization customers
3586
- expect(mockStripe.customers.search).toHaveBeenCalledWith({
3587
- query: `email:"${sharedEmail}" AND -metadata["customerType"]:"organization"`,
3588
- limit: 1,
3589
- });
3590
-
3591
- // Should create NEW user customer (not use org customer)
3592
- expect(mockStripe.customers.create).toHaveBeenCalledTimes(1);
3593
- expect(mockStripe.customers.create).toHaveBeenCalledWith({
3594
- email: sharedEmail,
3595
- name: "User With Shared Email",
3596
- metadata: {
3597
- customerType: "user",
3598
- userId: userRes.user.id,
3599
- },
3600
- });
3601
-
3602
- // Verify user has their own customer ID (not the org's)
3603
- const user = await testCtx.adapter.findOne<
3604
- User & { stripeCustomerId?: string }
3605
- >({
3606
- model: "user",
3607
- where: [{ field: "id", value: userRes.user.id }],
3608
- });
3609
- expect(user?.stripeCustomerId).toBe("cus_user_new_456");
3610
- expect(user?.stripeCustomerId).not.toBe(orgCustomerId);
3611
- });
3612
-
3613
- it("should find existing user customer even when organization customer with same email exists", async () => {
3614
- // Scenario: Both user and organization customers exist with same email
3615
- // The search should only return the user customer
3616
- const sharedEmail = "both-exist@example.com";
3617
- const existingUserCustomerId = "cus_user_existing_789";
3618
-
3619
- // Mock: Search returns ONLY user customer (org customer excluded by query)
3620
- mockStripe.customers.search.mockResolvedValueOnce({
3621
- data: [
3622
- {
3623
- id: existingUserCustomerId,
3624
- email: sharedEmail,
3625
- name: "Existing User Customer",
3626
- metadata: {
3627
- customerType: "user",
3628
- userId: "some-old-user-id",
3629
- },
3630
- },
3631
- ],
3632
- });
3633
-
3634
- const testOptions = {
3635
- ...stripeOptions,
3636
- createCustomerOnSignUp: true,
3637
- } satisfies StripeOptions;
3638
-
3639
- const { client: testAuthClient, auth: testAuth } = await getTestInstance(
3640
- {
3641
- database: memory,
3642
- plugins: [stripe(testOptions)],
3643
- },
3644
- {
3645
- disableTestUser: true,
3646
- clientOptions: {
3647
- plugins: [stripeClient({ subscription: true })],
3648
- },
3649
- },
3650
- );
3651
- const testCtx = await testAuth.$context;
3652
-
3653
- vi.clearAllMocks();
3654
-
3655
- // User signs up - should find their existing customer
3656
- const userRes = await testAuthClient.signUp.email(
3657
- {
3658
- email: sharedEmail,
3659
- password: "password",
3660
- name: "User Reclaiming Account",
3661
- },
3662
- { throw: true },
3663
- );
3664
-
3665
- // Should search excluding organization customers
3666
- expect(mockStripe.customers.search).toHaveBeenCalledWith({
3667
- query: `email:"${sharedEmail}" AND -metadata["customerType"]:"organization"`,
3668
- limit: 1,
3669
- });
3670
-
3671
- // Should NOT create new customer - use existing user customer
3672
- expect(mockStripe.customers.create).not.toHaveBeenCalled();
3673
-
3674
- // Verify user has the existing user customer ID
3675
- const user = await testCtx.adapter.findOne<
3676
- User & { stripeCustomerId?: string }
3677
- >({
3678
- model: "user",
3679
- where: [{ field: "id", value: userRes.user.id }],
3680
- });
3681
- expect(user?.stripeCustomerId).toBe(existingUserCustomerId);
3682
- });
3683
-
3684
- it("should create organization customer with customerType metadata", async () => {
3685
- // Test that organization customers are properly tagged
3686
- const orgEmail = "org@example.com";
3687
- const orgId = "org_test_123";
3688
-
3689
- mockStripe.customers.search.mockResolvedValueOnce({
3690
- data: [],
3691
- });
3692
-
3693
- mockStripe.customers.create.mockResolvedValueOnce({
3694
- id: "cus_org_new_999",
3695
- email: orgEmail,
3696
- });
3697
-
3698
- const { auth: testAuth } = await getTestInstance(
3699
- {
3700
- database: memory,
3701
- plugins: [
3702
- organization(),
3703
- stripe({
3704
- ...stripeOptions,
3705
- organization: {
3706
- enabled: true,
3707
- createCustomerOnOrganizationCreate: true,
3708
- },
3709
- }),
3710
- ],
3711
- },
3712
- { disableTestUser: true },
3713
- );
3714
- const testCtx = await testAuth.$context;
3715
-
3716
- vi.clearAllMocks();
3717
-
3718
- // Create organization
3719
- await testCtx.adapter.create({
3720
- model: "organization",
3721
- data: {
3722
- id: orgId,
3723
- name: "Test Organization",
3724
- slug: "test-org-collision",
3725
- createdAt: new Date(),
3726
- },
3727
- });
3728
-
3729
- // Manually trigger the organization customer creation flow
3730
- // by calling the internal function (simulating what hooks do)
3731
- const stripeClient = stripeOptions.stripeClient;
3732
- const searchResult = await stripeClient.customers.search({
3733
- query: `email:'${orgEmail}' AND metadata['customerType']:'organization'`,
3734
- limit: 1,
3735
- });
3736
-
3737
- if (searchResult.data.length === 0) {
3738
- await stripeClient.customers.create({
3739
- email: orgEmail,
3740
- name: "Test Organization",
3741
- metadata: {
3742
- customerType: "organization",
3743
- organizationId: orgId,
3744
- },
3745
- });
3746
- }
3747
-
3748
- // Verify organization customer was created with correct metadata
3749
- expect(mockStripe.customers.create).toHaveBeenCalledWith({
3750
- email: orgEmail,
3751
- name: "Test Organization",
3752
- metadata: {
3753
- customerType: "organization",
3754
- organizationId: orgId,
3755
- },
3756
- });
3757
- });
3758
- });
3759
-
3760
- describe("webhook: cancel_at_period_end cancellation", () => {
3761
- it("should sync cancelAtPeriodEnd and canceledAt when user cancels via Billing Portal (at_period_end mode)", async () => {
3762
- const { auth } = await getTestInstance(
3763
- {
3764
- database: memory,
3765
- plugins: [stripe(stripeOptions)],
3766
- },
3767
- { disableTestUser: true },
3768
- );
3769
- const ctx = await auth.$context;
3770
-
3771
- // Setup: Create user and active subscription
3772
- const { id: userId } = await ctx.adapter.create({
3773
- model: "user",
3774
- data: { email: "cancel-period-end@test.com" },
3775
- });
3776
-
3777
- const now = Math.floor(Date.now() / 1000);
3778
- const periodEnd = now + 30 * 24 * 60 * 60;
3779
- const canceledAt = now;
3780
-
3781
- const { id: subscriptionId } = await ctx.adapter.create({
3782
- model: "subscription",
3783
- data: {
3784
- referenceId: userId,
3785
- stripeCustomerId: "cus_cancel_test",
3786
- stripeSubscriptionId: "sub_cancel_period_end",
3787
- status: "active",
3788
- plan: "starter",
3789
- cancelAtPeriodEnd: false,
3790
- cancelAt: null,
3791
- canceledAt: null,
3792
- },
3793
- });
3794
-
3795
- // Simulate: Stripe webhook for cancel_at_period_end
3796
- const webhookEvent = {
3797
- type: "customer.subscription.updated",
3798
- data: {
3799
- object: {
3800
- id: "sub_cancel_period_end",
3801
- customer: "cus_cancel_test",
3802
- status: "active",
3803
- cancel_at_period_end: true,
3804
- cancel_at: null,
3805
- canceled_at: canceledAt,
3806
- ended_at: null,
3807
- items: {
3808
- data: [
3809
- {
3810
- price: { id: "price_starter_123", lookup_key: null },
3811
- quantity: 1,
3812
- current_period_start: now,
3813
- current_period_end: periodEnd,
3814
- },
3815
- ],
3816
- },
3817
- cancellation_details: {
3818
- reason: "cancellation_requested",
3819
- comment: "User requested cancellation",
3820
- },
3821
- },
3822
- },
3823
- };
3824
-
3825
- const stripeForTest = {
3826
- ...stripeOptions.stripeClient,
3827
- webhooks: {
3828
- constructEventAsync: vi.fn().mockResolvedValue(webhookEvent),
3829
- },
3830
- };
3831
-
3832
- const testOptions = {
3833
- ...stripeOptions,
3834
- stripeClient: stripeForTest as unknown as Stripe,
3835
- stripeWebhookSecret: "test_secret",
3836
- };
3837
-
3838
- const { auth: webhookAuth } = await getTestInstance(
3839
- {
3840
- database: memory,
3841
- plugins: [stripe(testOptions)],
3842
- },
3843
- { disableTestUser: true },
3844
- );
3845
- const webhookCtx = await webhookAuth.$context;
3846
-
3847
- const response = await webhookAuth.handler(
3848
- new Request("http://localhost:3000/api/auth/stripe/webhook", {
3849
- method: "POST",
3850
- headers: { "stripe-signature": "test_signature" },
3851
- body: JSON.stringify(webhookEvent),
3852
- }),
3853
- );
3854
-
3855
- expect(response.status).toBe(200);
3856
-
3857
- const updatedSub = await webhookCtx.adapter.findOne<Subscription>({
3858
- model: "subscription",
3859
- where: [{ field: "id", value: subscriptionId }],
3860
- });
3861
-
3862
- expect(updatedSub).toMatchObject({
3863
- status: "active",
3864
- cancelAtPeriodEnd: true,
3865
- cancelAt: null,
3866
- canceledAt: expect.any(Date),
3867
- endedAt: null,
3868
- });
3869
- });
3870
-
3871
- it("should sync cancelAt when subscription is scheduled to cancel at a specific date", async () => {
3872
- const { auth } = await getTestInstance(
3873
- {
3874
- database: memory,
3875
- plugins: [stripe(stripeOptions)],
3876
- },
3877
- { disableTestUser: true },
3878
- );
3879
- const ctx = await auth.$context;
3880
-
3881
- const { id: userId } = await ctx.adapter.create({
3882
- model: "user",
3883
- data: { email: "cancel-at-date@test.com" },
3884
- });
3885
-
3886
- const now = Math.floor(Date.now() / 1000);
3887
- const cancelAt = now + 15 * 24 * 60 * 60; // Cancel in 15 days
3888
- const canceledAt = now;
3889
-
3890
- const { id: subscriptionId } = await ctx.adapter.create({
3891
- model: "subscription",
3892
- data: {
3893
- referenceId: userId,
3894
- stripeCustomerId: "cus_cancel_at_test",
3895
- stripeSubscriptionId: "sub_cancel_at_date",
3896
- status: "active",
3897
- plan: "starter",
3898
- cancelAtPeriodEnd: false,
3899
- cancelAt: null,
3900
- canceledAt: null,
3901
- },
3902
- });
3903
-
3904
- // Simulate: Dashboard/API cancel with specific date (cancel_at)
3905
- const webhookEvent = {
3906
- type: "customer.subscription.updated",
3907
- data: {
3908
- object: {
3909
- id: "sub_cancel_at_date",
3910
- customer: "cus_cancel_at_test",
3911
- status: "active",
3912
- cancel_at_period_end: false,
3913
- cancel_at: cancelAt,
3914
- canceled_at: canceledAt,
3915
- ended_at: null,
3916
- items: {
3917
- data: [
3918
- {
3919
- price: { id: "price_starter_123", lookup_key: null },
3920
- quantity: 1,
3921
- current_period_start: now,
3922
- current_period_end: now + 30 * 24 * 60 * 60,
3923
- },
3924
- ],
3925
- },
3926
- },
3927
- },
3928
- };
3929
-
3930
- const stripeForTest = {
3931
- ...stripeOptions.stripeClient,
3932
- webhooks: {
3933
- constructEventAsync: vi.fn().mockResolvedValue(webhookEvent),
3934
- },
3935
- };
3936
-
3937
- const testOptions = {
3938
- ...stripeOptions,
3939
- stripeClient: stripeForTest as unknown as Stripe,
3940
- stripeWebhookSecret: "test_secret",
3941
- };
3942
-
3943
- const { auth: webhookAuth } = await getTestInstance(
3944
- {
3945
- database: memory,
3946
- plugins: [stripe(testOptions)],
3947
- },
3948
- { disableTestUser: true },
3949
- );
3950
- const webhookCtx = await webhookAuth.$context;
3951
-
3952
- const response = await webhookAuth.handler(
3953
- new Request("http://localhost:3000/api/auth/stripe/webhook", {
3954
- method: "POST",
3955
- headers: { "stripe-signature": "test_signature" },
3956
- body: JSON.stringify(webhookEvent),
3957
- }),
3958
- );
3959
-
3960
- expect(response.status).toBe(200);
3961
-
3962
- const updatedSub = await webhookCtx.adapter.findOne<Subscription>({
3963
- model: "subscription",
3964
- where: [{ field: "id", value: subscriptionId }],
3965
- });
3966
-
3967
- expect(updatedSub).toMatchObject({
3968
- status: "active",
3969
- cancelAtPeriodEnd: false,
3970
- cancelAt: expect.any(Date),
3971
- canceledAt: expect.any(Date),
3972
- endedAt: null,
3973
- });
3974
-
3975
- // Verify the cancelAt date is correct
3976
- expect(updatedSub!.cancelAt!.getTime()).toBe(cancelAt * 1000);
3977
- });
3978
- });
3979
-
3980
- describe("webhook: immediate cancellation (subscription deleted)", () => {
3981
- it("should set status=canceled and endedAt when subscription is immediately canceled", async () => {
3982
- const { auth } = await getTestInstance(
3983
- {
3984
- database: memory,
3985
- plugins: [stripe(stripeOptions)],
3986
- },
3987
- { disableTestUser: true },
3988
- );
3989
- const ctx = await auth.$context;
3990
-
3991
- const { id: userId } = await ctx.adapter.create({
3992
- model: "user",
3993
- data: { email: "immediate-cancel@test.com" },
3994
- });
3995
-
3996
- const now = Math.floor(Date.now() / 1000);
3997
-
3998
- const { id: subscriptionId } = await ctx.adapter.create({
3999
- model: "subscription",
4000
- data: {
4001
- referenceId: userId,
4002
- stripeCustomerId: "cus_immediate_cancel",
4003
- stripeSubscriptionId: "sub_immediate_cancel",
4004
- status: "active",
4005
- plan: "starter",
4006
- },
4007
- });
4008
-
4009
- // Simulate: Immediate cancellation via Billing Portal (mode: immediately) or API
4010
- const webhookEvent = {
4011
- type: "customer.subscription.deleted",
4012
- data: {
4013
- object: {
4014
- id: "sub_immediate_cancel",
4015
- customer: "cus_immediate_cancel",
4016
- status: "canceled",
4017
- cancel_at_period_end: false,
4018
- cancel_at: null,
4019
- canceled_at: now,
4020
- ended_at: now,
4021
- },
4022
- },
4023
- };
4024
-
4025
- const stripeForTest = {
4026
- ...stripeOptions.stripeClient,
4027
- webhooks: {
4028
- constructEventAsync: vi.fn().mockResolvedValue(webhookEvent),
4029
- },
4030
- };
4031
-
4032
- const testOptions = {
4033
- ...stripeOptions,
4034
- stripeClient: stripeForTest as unknown as Stripe,
4035
- stripeWebhookSecret: "test_secret",
4036
- };
4037
-
4038
- const { auth: webhookAuth } = await getTestInstance(
4039
- {
4040
- database: memory,
4041
- plugins: [stripe(testOptions)],
4042
- },
4043
- { disableTestUser: true },
4044
- );
4045
- const webhookCtx = await webhookAuth.$context;
4046
-
4047
- const response = await webhookAuth.handler(
4048
- new Request("http://localhost:3000/api/auth/stripe/webhook", {
4049
- method: "POST",
4050
- headers: { "stripe-signature": "test_signature" },
4051
- body: JSON.stringify(webhookEvent),
4052
- }),
4053
- );
4054
-
4055
- expect(response.status).toBe(200);
4056
-
4057
- const updatedSub = await webhookCtx.adapter.findOne<Subscription>({
4058
- model: "subscription",
4059
- where: [{ field: "id", value: subscriptionId }],
4060
- });
4061
-
4062
- expect(updatedSub).not.toBeNull();
4063
- expect(updatedSub!.status).toBe("canceled");
4064
- expect(updatedSub!.endedAt).not.toBeNull();
4065
- });
4066
-
4067
- it("should set endedAt when cancel_at_period_end subscription reaches period end", async () => {
4068
- const { auth } = await getTestInstance(
4069
- {
4070
- database: memory,
4071
- plugins: [stripe(stripeOptions)],
4072
- },
4073
- { disableTestUser: true },
4074
- );
4075
- const ctx = await auth.$context;
4076
-
4077
- const { id: userId } = await ctx.adapter.create({
4078
- model: "user",
4079
- data: { email: "period-end-reached@test.com" },
4080
- });
4081
-
4082
- const now = Math.floor(Date.now() / 1000);
4083
- const canceledAt = now - 30 * 24 * 60 * 60; // Canceled 30 days ago
4084
-
4085
- const { id: subscriptionId } = await ctx.adapter.create({
4086
- model: "subscription",
4087
- data: {
4088
- referenceId: userId,
4089
- stripeCustomerId: "cus_period_end_reached",
4090
- stripeSubscriptionId: "sub_period_end_reached",
4091
- status: "active",
4092
- plan: "starter",
4093
- cancelAtPeriodEnd: true,
4094
- canceledAt: new Date(canceledAt * 1000),
4095
- },
4096
- });
4097
-
4098
- // Simulate: Period ended, subscription is now deleted
4099
- const webhookEvent = {
4100
- type: "customer.subscription.deleted",
4101
- data: {
4102
- object: {
4103
- id: "sub_period_end_reached",
4104
- customer: "cus_period_end_reached",
4105
- status: "canceled",
4106
- cancel_at_period_end: true,
4107
- cancel_at: null,
4108
- canceled_at: canceledAt,
4109
- ended_at: now,
4110
- },
4111
- },
4112
- };
4113
-
4114
- const stripeForTest = {
4115
- ...stripeOptions.stripeClient,
4116
- webhooks: {
4117
- constructEventAsync: vi.fn().mockResolvedValue(webhookEvent),
4118
- },
4119
- };
4120
-
4121
- const testOptions = {
4122
- ...stripeOptions,
4123
- stripeClient: stripeForTest as unknown as Stripe,
4124
- stripeWebhookSecret: "test_secret",
4125
- };
4126
-
4127
- const { auth: webhookAuth } = await getTestInstance(
4128
- {
4129
- database: memory,
4130
- plugins: [stripe(testOptions)],
4131
- },
4132
- { disableTestUser: true },
4133
- );
4134
- const webhookCtx = await webhookAuth.$context;
4135
-
4136
- const response = await webhookAuth.handler(
4137
- new Request("http://localhost:3000/api/auth/stripe/webhook", {
4138
- method: "POST",
4139
- headers: { "stripe-signature": "test_signature" },
4140
- body: JSON.stringify(webhookEvent),
4141
- }),
4142
- );
4143
-
4144
- expect(response.status).toBe(200);
4145
-
4146
- const updatedSub = await webhookCtx.adapter.findOne<Subscription>({
4147
- model: "subscription",
4148
- where: [{ field: "id", value: subscriptionId }],
4149
- });
4150
-
4151
- expect(updatedSub).not.toBeNull();
4152
- expect(updatedSub!.status).toBe("canceled");
4153
- expect(updatedSub!.cancelAtPeriodEnd).toBe(true);
4154
- expect(updatedSub!.endedAt).not.toBeNull();
4155
-
4156
- // endedAt should be the actual termination time (now), not the cancellation request time
4157
- expect(updatedSub!.endedAt!.getTime()).toBe(now * 1000);
4158
- });
4159
- });
4160
-
4161
- describe("restore subscription", () => {
4162
- it("should clear cancelAtPeriodEnd when restoring a cancel_at_period_end subscription", async () => {
4163
- const { client, auth, sessionSetter } = await getTestInstance(
4164
- {
4165
- database: memory,
4166
- plugins: [stripe(stripeOptions)],
4167
- },
4168
- {
4169
- disableTestUser: true,
4170
- clientOptions: {
4171
- plugins: [stripeClient({ subscription: true })],
4172
- },
4173
- },
4174
- );
4175
- const ctx = await auth.$context;
4176
-
4177
- const userRes = await client.signUp.email(
4178
- {
4179
- email: "restore-period-end@test.com",
4180
- password: "password",
4181
- name: "Test",
4182
- },
4183
- { throw: true },
4184
- );
4185
-
4186
- const headers = new Headers();
4187
- await client.signIn.email(
4188
- { email: "restore-period-end@test.com", password: "password" },
4189
- { throw: true, onSuccess: sessionSetter(headers) },
4190
- );
4191
-
4192
- // Create subscription scheduled to cancel at period end
4193
- await ctx.adapter.create({
4194
- model: "subscription",
4195
- data: {
4196
- referenceId: userRes.user.id,
4197
- stripeCustomerId: "cus_restore_test",
4198
- stripeSubscriptionId: "sub_restore_period_end",
4199
- status: "active",
4200
- plan: "starter",
4201
- cancelAtPeriodEnd: true,
4202
- cancelAt: null,
4203
- canceledAt: new Date(),
4204
- },
4205
- });
4206
-
4207
- mockStripe.subscriptions.list.mockResolvedValueOnce({
4208
- data: [
4209
- {
4210
- id: "sub_restore_period_end",
4211
- status: "active",
4212
- cancel_at_period_end: true,
4213
- cancel_at: null,
4214
- },
4215
- ],
4216
- });
4217
-
4218
- mockStripe.subscriptions.update.mockResolvedValueOnce({
4219
- id: "sub_restore_period_end",
4220
- status: "active",
4221
- cancel_at_period_end: false,
4222
- cancel_at: null,
4223
- });
4224
-
4225
- const restoreRes = await client.subscription.restore({
4226
- fetchOptions: { headers },
4227
- });
4228
-
4229
- expect(restoreRes.data).toBeDefined();
4230
-
4231
- // Verify Stripe was called with correct params (cancel_at_period_end: false)
4232
- expect(mockStripe.subscriptions.update).toHaveBeenCalledWith(
4233
- "sub_restore_period_end",
4234
- { cancel_at_period_end: false },
4235
- );
4236
-
4237
- const updatedSub = await ctx.adapter.findOne<Subscription>({
4238
- model: "subscription",
4239
- where: [{ field: "referenceId", value: userRes.user.id }],
4240
- });
4241
-
4242
- expect(updatedSub).toMatchObject({
4243
- cancelAtPeriodEnd: false,
4244
- cancelAt: null,
4245
- canceledAt: null,
4246
- });
4247
- });
4248
-
4249
- it("should clear cancelAt when restoring a cancel_at (specific date) subscription", async () => {
4250
- const { client, auth, sessionSetter } = await getTestInstance(
4251
- {
4252
- database: memory,
4253
- plugins: [stripe(stripeOptions)],
4254
- },
4255
- {
4256
- disableTestUser: true,
4257
- clientOptions: {
4258
- plugins: [stripeClient({ subscription: true })],
4259
- },
4260
- },
4261
- );
4262
- const ctx = await auth.$context;
4263
-
4264
- const userRes = await client.signUp.email(
4265
- {
4266
- email: "restore-cancel-at@test.com",
4267
- password: "password",
4268
- name: "Test",
4269
- },
4270
- { throw: true },
4271
- );
4272
-
4273
- const headers = new Headers();
4274
- await client.signIn.email(
4275
- { email: "restore-cancel-at@test.com", password: "password" },
4276
- { throw: true, onSuccess: sessionSetter(headers) },
4277
- );
4278
-
4279
- const cancelAt = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000);
4280
-
4281
- // Create subscription scheduled to cancel at specific date
4282
- await ctx.adapter.create({
4283
- model: "subscription",
4284
- data: {
4285
- referenceId: userRes.user.id,
4286
- stripeCustomerId: "cus_restore_cancel_at",
4287
- stripeSubscriptionId: "sub_restore_cancel_at",
4288
- status: "active",
4289
- plan: "starter",
4290
- cancelAtPeriodEnd: false,
4291
- cancelAt: cancelAt,
4292
- canceledAt: new Date(),
4293
- },
4294
- });
4295
-
4296
- mockStripe.subscriptions.list.mockResolvedValueOnce({
4297
- data: [
4298
- {
4299
- id: "sub_restore_cancel_at",
4300
- status: "active",
4301
- cancel_at_period_end: false,
4302
- cancel_at: Math.floor(cancelAt.getTime() / 1000),
4303
- },
4304
- ],
4305
- });
4306
-
4307
- mockStripe.subscriptions.update.mockResolvedValueOnce({
4308
- id: "sub_restore_cancel_at",
4309
- status: "active",
4310
- cancel_at_period_end: false,
4311
- cancel_at: null,
4312
- });
4313
-
4314
- const restoreRes = await client.subscription.restore({
4315
- fetchOptions: { headers },
4316
- });
4317
-
4318
- expect(restoreRes.data).toBeDefined();
4319
-
4320
- // Verify Stripe was called with correct params (cancel_at: "" to clear)
4321
- expect(mockStripe.subscriptions.update).toHaveBeenCalledWith(
4322
- "sub_restore_cancel_at",
4323
- { cancel_at: "" },
4324
- );
4325
-
4326
- const updatedSub = await ctx.adapter.findOne<Subscription>({
4327
- model: "subscription",
4328
- where: [{ field: "referenceId", value: userRes.user.id }],
4329
- });
4330
-
4331
- expect(updatedSub).toMatchObject({
4332
- cancelAtPeriodEnd: false,
4333
- cancelAt: null,
4334
- canceledAt: null,
4335
- });
4336
- });
4337
- });
4338
-
4339
- describe("cancel subscription fallback (missed webhook)", () => {
4340
- it("should sync from Stripe when cancel request fails because subscription is already canceled", async () => {
4341
- const { client, auth, sessionSetter } = await getTestInstance(
4342
- {
4343
- database: memory,
4344
- plugins: [stripe(stripeOptions)],
4345
- },
4346
- {
4347
- disableTestUser: true,
4348
- clientOptions: {
4349
- plugins: [stripeClient({ subscription: true })],
4350
- },
4351
- },
4352
- );
4353
- const ctx = await auth.$context;
4354
-
4355
- const userRes = await client.signUp.email(
4356
- {
4357
- email: "missed-webhook@test.com",
4358
- password: "password",
4359
- name: "Test",
4360
- },
4361
- { throw: true },
4362
- );
4363
-
4364
- const headers = new Headers();
4365
- await client.signIn.email(
4366
- { email: "missed-webhook@test.com", password: "password" },
4367
- { throw: true, onSuccess: sessionSetter(headers) },
4368
- );
4369
-
4370
- const now = Math.floor(Date.now() / 1000);
4371
- const cancelAt = now + 15 * 24 * 60 * 60;
4372
-
4373
- // Create subscription in DB (not synced - missed webhook)
4374
- const { id: subscriptionId } = await ctx.adapter.create({
4375
- model: "subscription",
4376
- data: {
4377
- referenceId: userRes.user.id,
4378
- stripeCustomerId: "cus_missed_webhook",
4379
- stripeSubscriptionId: "sub_missed_webhook",
4380
- status: "active",
4381
- plan: "starter",
4382
- cancelAtPeriodEnd: false, // DB thinks it's not canceling
4383
- cancelAt: null,
4384
- canceledAt: null,
4385
- },
4386
- });
4387
-
4388
- // Stripe has the subscription already scheduled to cancel with cancel_at
4389
- mockStripe.subscriptions.list.mockResolvedValueOnce({
4390
- data: [
4391
- {
4392
- id: "sub_missed_webhook",
4393
- status: "active",
4394
- cancel_at_period_end: false,
4395
- cancel_at: cancelAt,
4396
- },
4397
- ],
4398
- });
4399
-
4400
- // Billing portal returns error because subscription is already set to cancel
4401
- mockStripe.billingPortal.sessions.create.mockRejectedValueOnce(
4402
- new Error("This subscription is already set to be canceled"),
4403
- );
4404
-
4405
- // When fallback kicks in, it retrieves from Stripe
4406
- mockStripe.subscriptions.retrieve.mockResolvedValueOnce({
4407
- id: "sub_missed_webhook",
4408
- status: "active",
4409
- cancel_at_period_end: false,
4410
- cancel_at: cancelAt,
4411
- canceled_at: now,
4412
- });
4413
-
4414
- // Try to cancel - should fail but trigger sync
4415
- const cancelRes = await client.subscription.cancel({
4416
- returnUrl: "/account",
4417
- fetchOptions: { headers },
4418
- });
4419
-
4420
- // Should have error because portal creation failed
4421
- expect(cancelRes.error).toBeDefined();
4422
-
4423
- // But DB should now be synced with Stripe's actual state
4424
- const updatedSub = await ctx.adapter.findOne<Subscription>({
4425
- model: "subscription",
4426
- where: [{ field: "id", value: subscriptionId }],
4427
- });
4428
-
4429
- expect(updatedSub).toMatchObject({
4430
- cancelAtPeriodEnd: false,
4431
- cancelAt: expect.any(Date),
4432
- canceledAt: expect.any(Date),
4433
- });
4434
-
4435
- // Verify it's the correct cancel_at date from Stripe
4436
- expect(updatedSub!.cancelAt!.getTime()).toBe(cancelAt * 1000);
4437
- });
4438
- });
4439
-
4440
- describe("referenceMiddleware", () => {
4441
- describe("referenceMiddleware - user subscription", () => {
4442
- it("should pass when no explicit referenceId is provided", async () => {
4443
- const { client, sessionSetter } = await getTestInstance(
4444
- {
4445
- plugins: [stripe(stripeOptions)],
4446
- },
4447
- {
4448
- disableTestUser: true,
4449
- clientOptions: {
4450
- plugins: [stripeClient({ subscription: true })],
4451
- },
4452
- },
4453
- );
4454
-
4455
- await client.signUp.email(testUser, { throw: true });
4456
- const headers = new Headers();
4457
- await client.signIn.email(testUser, {
4458
- throw: true,
4459
- onSuccess: sessionSetter(headers),
4460
- });
4461
-
4462
- const res = await client.subscription.upgrade({
4463
- plan: "starter",
4464
- fetchOptions: { headers },
4465
- });
4466
-
4467
- expect(res.error).toBeNull();
4468
- expect(res.data?.url).toBeDefined();
4469
- });
4470
-
4471
- it("should pass when referenceId equals user id", async () => {
4472
- const { client, sessionSetter } = await getTestInstance(
4473
- {
4474
- plugins: [stripe(stripeOptions)],
4475
- },
4476
- {
4477
- disableTestUser: true,
4478
- clientOptions: {
4479
- plugins: [stripeClient({ subscription: true })],
4480
- },
4481
- },
4482
- );
4483
-
4484
- const signUpRes = await client.signUp.email(
4485
- { ...testUser, email: "ref-test-2@example.com" },
4486
- { throw: true },
4487
- );
4488
- const headers = new Headers();
4489
- await client.signIn.email(
4490
- { ...testUser, email: "ref-test-2@example.com" },
4491
- {
4492
- throw: true,
4493
- onSuccess: sessionSetter(headers),
4494
- },
4495
- );
4496
-
4497
- const res = await client.subscription.upgrade({
4498
- plan: "starter",
4499
- referenceId: signUpRes.user.id,
4500
- fetchOptions: { headers },
4501
- });
4502
-
4503
- expect(res.error).toBeNull();
4504
- expect(res.data?.url).toBeDefined();
4505
- });
4506
-
4507
- it("should reject when authorizeReference is not defined but other referenceId is provided", async () => {
4508
- const { client, sessionSetter } = await getTestInstance(
4509
- {
4510
- plugins: [stripe(stripeOptions)],
4511
- },
4512
- {
4513
- disableTestUser: true,
4514
- clientOptions: {
4515
- plugins: [stripeClient({ subscription: true })],
4516
- },
4517
- },
4518
- );
4519
-
4520
- await client.signUp.email(
4521
- { ...testUser, email: "ref-test-3@example.com" },
4522
- { throw: true },
4523
- );
4524
- const headers = new Headers();
4525
- await client.signIn.email(
4526
- { ...testUser, email: "ref-test-3@example.com" },
4527
- {
4528
- throw: true,
4529
- onSuccess: sessionSetter(headers),
4530
- },
4531
- );
4532
-
4533
- const res = await client.subscription.upgrade({
4534
- plan: "starter",
4535
- referenceId: "some-other-id",
4536
- fetchOptions: { headers },
4537
- });
4538
-
4539
- expect(res.error?.code).toBe("REFERENCE_ID_NOT_ALLOWED");
4540
- });
4541
-
4542
- it("should reject when authorizeReference returns false", async () => {
4543
- const stripeOptionsWithAuth: StripeOptions = {
4544
- ...stripeOptions,
4545
- subscription: {
4546
- ...stripeOptions.subscription,
4547
- authorizeReference: async () => false,
4548
- },
4549
- };
4550
-
4551
- const { client, sessionSetter } = await getTestInstance(
4552
- {
4553
- plugins: [stripe(stripeOptionsWithAuth)],
4554
- },
4555
- {
4556
- disableTestUser: true,
4557
- clientOptions: {
4558
- plugins: [stripeClient({ subscription: true })],
4559
- },
4560
- },
4561
- );
4562
-
4563
- await client.signUp.email(
4564
- { ...testUser, email: "ref-test-4@example.com" },
4565
- { throw: true },
4566
- );
4567
- const headers = new Headers();
4568
- await client.signIn.email(
4569
- { ...testUser, email: "ref-test-4@example.com" },
4570
- {
4571
- throw: true,
4572
- onSuccess: sessionSetter(headers),
4573
- },
4574
- );
4575
-
4576
- const res = await client.subscription.upgrade({
4577
- plan: "starter",
4578
- referenceId: "some-other-id",
4579
- fetchOptions: { headers },
4580
- });
4581
-
4582
- expect(res.error?.code).toBe("UNAUTHORIZED");
4583
- });
4584
-
4585
- it("should pass when authorizeReference returns true", async () => {
4586
- const stripeOptionsWithAuth: StripeOptions = {
4587
- ...stripeOptions,
4588
- subscription: {
4589
- ...stripeOptions.subscription,
4590
- authorizeReference: async () => true,
4591
- },
4592
- };
4593
-
4594
- const { client, sessionSetter } = await getTestInstance(
4595
- {
4596
- plugins: [stripe(stripeOptionsWithAuth)],
4597
- },
4598
- {
4599
- disableTestUser: true,
4600
- clientOptions: {
4601
- plugins: [stripeClient({ subscription: true })],
4602
- },
4603
- },
4604
- );
4605
-
4606
- await client.signUp.email(
4607
- { ...testUser, email: "ref-test-5@example.com" },
4608
- { throw: true },
4609
- );
4610
- const headers = new Headers();
4611
- await client.signIn.email(
4612
- { ...testUser, email: "ref-test-5@example.com" },
4613
- {
4614
- throw: true,
4615
- onSuccess: sessionSetter(headers),
4616
- },
4617
- );
4618
-
4619
- const res = await client.subscription.upgrade({
4620
- plan: "starter",
4621
- referenceId: "some-other-id",
4622
- fetchOptions: { headers },
4623
- });
4624
-
4625
- expect(res.error).toBeNull();
4626
- expect(res.data?.url).toBeDefined();
4627
- });
4628
- });
4629
-
4630
- describe("referenceMiddleware - organization subscription", () => {
4631
- it("should reject when authorizeReference is not defined", async () => {
4632
- const { client, sessionSetter } = await getTestInstance(
4633
- {
4634
- plugins: [stripe(stripeOptions)],
4635
- },
4636
- {
4637
- disableTestUser: true,
4638
- clientOptions: {
4639
- plugins: [stripeClient({ subscription: true })],
4640
- },
4641
- },
4642
- );
4643
-
4644
- await client.signUp.email(
4645
- { ...testUser, email: "org-test-1@example.com" },
4646
- { throw: true },
4647
- );
4648
- const headers = new Headers();
4649
- await client.signIn.email(
4650
- { ...testUser, email: "org-test-1@example.com" },
4651
- {
4652
- throw: true,
4653
- onSuccess: sessionSetter(headers),
4654
- },
4655
- );
4656
-
4657
- const res = await client.subscription.upgrade({
4658
- plan: "starter",
4659
- customerType: "organization",
4660
- referenceId: "org_123",
4661
- fetchOptions: { headers },
4662
- });
4663
-
4664
- expect(res.error?.code).toBe("ORGANIZATION_SUBSCRIPTION_NOT_ENABLED");
4665
- });
4666
-
4667
- it("should reject when no referenceId or activeOrganizationId", async () => {
4668
- const stripeOptionsWithAuth: StripeOptions = {
4669
- ...stripeOptions,
4670
- subscription: {
4671
- ...stripeOptions.subscription,
4672
- authorizeReference: async () => true,
4673
- },
4674
- };
4675
-
4676
- const { client, sessionSetter } = await getTestInstance(
4677
- {
4678
- plugins: [stripe(stripeOptionsWithAuth)],
4679
- },
4680
- {
4681
- disableTestUser: true,
4682
- clientOptions: {
4683
- plugins: [stripeClient({ subscription: true })],
4684
- },
4685
- },
4686
- );
4687
-
4688
- await client.signUp.email(
4689
- { ...testUser, email: "org-test-2@example.com" },
4690
- { throw: true },
4691
- );
4692
- const headers = new Headers();
4693
- await client.signIn.email(
4694
- { ...testUser, email: "org-test-2@example.com" },
4695
- {
4696
- throw: true,
4697
- onSuccess: sessionSetter(headers),
4698
- },
4699
- );
4700
-
4701
- const res = await client.subscription.upgrade({
4702
- plan: "starter",
4703
- customerType: "organization",
4704
- fetchOptions: { headers },
4705
- });
4706
-
4707
- expect(res.error?.code).toBe("ORGANIZATION_REFERENCE_ID_REQUIRED");
4708
- });
4709
-
4710
- it("should reject when authorizeReference returns false", async () => {
4711
- const stripeOptionsWithAuth: StripeOptions = {
4712
- ...stripeOptions,
4713
- subscription: {
4714
- ...stripeOptions.subscription,
4715
- authorizeReference: async () => false,
4716
- },
4717
- };
4718
-
4719
- const { client, sessionSetter } = await getTestInstance(
4720
- {
4721
- plugins: [stripe(stripeOptionsWithAuth)],
4722
- },
4723
- {
4724
- disableTestUser: true,
4725
- clientOptions: {
4726
- plugins: [stripeClient({ subscription: true })],
4727
- },
4728
- },
4729
- );
4730
-
4731
- await client.signUp.email(
4732
- { ...testUser, email: "org-test-3@example.com" },
4733
- { throw: true },
4734
- );
4735
- const headers = new Headers();
4736
- await client.signIn.email(
4737
- { ...testUser, email: "org-test-3@example.com" },
4738
- {
4739
- throw: true,
4740
- onSuccess: sessionSetter(headers),
4741
- },
4742
- );
4743
-
4744
- const res = await client.subscription.upgrade({
4745
- plan: "starter",
4746
- customerType: "organization",
4747
- referenceId: "org_123",
4748
- fetchOptions: { headers },
4749
- });
4750
-
4751
- expect(res.error?.code).toBe("UNAUTHORIZED");
4752
- });
4753
-
4754
- it("should pass when authorizeReference returns true", async () => {
4755
- const stripeOptionsWithAuth: StripeOptions = {
4756
- ...stripeOptions,
4757
- organization: {
4758
- enabled: true,
4759
- },
4760
- subscription: {
4761
- ...stripeOptions.subscription,
4762
- authorizeReference: async () => true,
4763
- },
4764
- };
4765
-
4766
- const { client, sessionSetter } = await getTestInstance(
4767
- {
4768
- plugins: [stripe(stripeOptionsWithAuth)],
4769
- },
4770
- {
4771
- disableTestUser: true,
4772
- clientOptions: {
4773
- plugins: [stripeClient({ subscription: true })],
4774
- },
4775
- },
4776
- );
4777
-
4778
- await client.signUp.email(
4779
- { ...testUser, email: "org-test-4@example.com" },
4780
- { throw: true },
4781
- );
4782
- const headers = new Headers();
4783
- await client.signIn.email(
4784
- { ...testUser, email: "org-test-4@example.com" },
4785
- {
4786
- throw: true,
4787
- onSuccess: sessionSetter(headers),
4788
- },
4789
- );
4790
-
4791
- const res = await client.subscription.upgrade({
4792
- plan: "starter",
4793
- customerType: "organization",
4794
- referenceId: "org_123",
4795
- fetchOptions: { headers },
4796
- });
4797
-
4798
- // Should pass middleware but may fail later due to org not existing
4799
- // We're testing middleware authorization, not the full flow
4800
- expect(res.error?.code).not.toBe(
4801
- "ORGANIZATION_SUBSCRIPTION_NOT_ENABLED",
4802
- );
4803
- expect(res.error?.code).not.toBe("UNAUTHORIZED");
4804
- });
4805
- });
4806
- });
4807
- });