@better-auth/stripe 1.5.0-beta.2 → 1.5.0-beta.3

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.
@@ -0,0 +1,1993 @@
1
+ import { organizationClient } from "better-auth/client/plugins";
2
+ import { organization } from "better-auth/plugins/organization";
3
+ import { getTestInstance } from "better-auth/test";
4
+ import type Stripe from "stripe";
5
+ import { beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { stripe } from "../src";
7
+ import { stripeClient } from "../src/client";
8
+ import type { StripeOptions, Subscription } from "../src/types";
9
+
10
+ describe("stripe - organization customer", () => {
11
+ const mockStripeOrg = {
12
+ prices: {
13
+ list: vi.fn().mockResolvedValue({ data: [{ id: "price_lookup_123" }] }),
14
+ },
15
+ customers: {
16
+ create: vi.fn().mockResolvedValue({ id: "cus_org_mock123" }),
17
+ list: vi.fn().mockResolvedValue({ data: [] }),
18
+ search: vi.fn().mockResolvedValue({ data: [] }),
19
+ retrieve: vi.fn().mockResolvedValue({
20
+ id: "cus_org_mock123",
21
+ email: "org@email.com",
22
+ deleted: false,
23
+ }),
24
+ update: vi.fn().mockResolvedValue({
25
+ id: "cus_org_mock123",
26
+ email: "org@email.com",
27
+ }),
28
+ },
29
+ checkout: {
30
+ sessions: {
31
+ create: vi.fn().mockResolvedValue({
32
+ url: "https://checkout.stripe.com/mock",
33
+ id: "cs_mock123",
34
+ }),
35
+ },
36
+ },
37
+ billingPortal: {
38
+ sessions: {
39
+ create: vi
40
+ .fn()
41
+ .mockResolvedValue({ url: "https://billing.stripe.com/mock" }),
42
+ },
43
+ },
44
+ subscriptions: {
45
+ retrieve: vi.fn(),
46
+ list: vi.fn().mockResolvedValue({ data: [] }),
47
+ update: vi.fn(),
48
+ },
49
+ webhooks: {
50
+ constructEventAsync: vi.fn(),
51
+ },
52
+ };
53
+ const testUser = {
54
+ email: "test@email.com",
55
+ password: "password",
56
+ name: "Test User",
57
+ };
58
+ const _stripeOrg = mockStripeOrg as unknown as Stripe;
59
+ const baseOrgStripeOptions = {
60
+ stripeClient: _stripeOrg,
61
+ stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
62
+ createCustomerOnSignUp: false, // Disable for org tests
63
+ organization: {
64
+ enabled: true,
65
+ },
66
+ subscription: {
67
+ enabled: true,
68
+ plans: [
69
+ {
70
+ priceId: process.env.STRIPE_PRICE_ID_1!,
71
+ name: "starter",
72
+ lookupKey: "lookup_key_123",
73
+ },
74
+ {
75
+ priceId: process.env.STRIPE_PRICE_ID_2!,
76
+ name: "premium",
77
+ lookupKey: "lookup_key_234",
78
+ },
79
+ ],
80
+ authorizeReference: async () => true,
81
+ },
82
+ } satisfies StripeOptions;
83
+
84
+ beforeEach(() => {
85
+ vi.clearAllMocks();
86
+ });
87
+
88
+ it("should create a Stripe customer for organization when upgrading subscription", async () => {
89
+ const onCustomerCreate = vi.fn();
90
+ const stripeOptionsWithOrgCallback: StripeOptions = {
91
+ ...baseOrgStripeOptions,
92
+ organization: {
93
+ ...baseOrgStripeOptions.organization,
94
+ onCustomerCreate,
95
+ },
96
+ };
97
+
98
+ const { client, auth, sessionSetter } = await getTestInstance(
99
+ {
100
+ plugins: [organization(), stripe(stripeOptionsWithOrgCallback)],
101
+ },
102
+ {
103
+ disableTestUser: true,
104
+ clientOptions: {
105
+ plugins: [organizationClient(), stripeClient({ subscription: true })],
106
+ },
107
+ },
108
+ );
109
+ const ctx = await auth.$context;
110
+
111
+ // Create user and organization
112
+ await client.signUp.email(
113
+ { ...testUser, email: "org-customer-test@email.com" },
114
+ { throw: true },
115
+ );
116
+
117
+ const headers = new Headers();
118
+ await client.signIn.email(
119
+ { ...testUser, email: "org-customer-test@email.com" },
120
+ {
121
+ throw: true,
122
+ onSuccess: sessionSetter(headers),
123
+ },
124
+ );
125
+
126
+ // Create organization via client
127
+ const org = await client.organization.create({
128
+ name: "Test Organization",
129
+ slug: "test-org",
130
+ fetchOptions: { headers },
131
+ });
132
+ const orgId = org.data?.id as string;
133
+
134
+ // Upgrade subscription for organization
135
+ const res = await client.subscription.upgrade({
136
+ plan: "starter",
137
+ customerType: "organization",
138
+ referenceId: orgId,
139
+ fetchOptions: { headers },
140
+ });
141
+
142
+ expect(res.data?.url).toBeDefined();
143
+
144
+ // Verify Stripe customer was created for org
145
+ expect(mockStripeOrg.customers.create).toHaveBeenCalledWith(
146
+ expect.objectContaining({
147
+ name: "Test Organization",
148
+ metadata: expect.objectContaining({
149
+ organizationId: orgId,
150
+ customerType: "organization",
151
+ }),
152
+ }),
153
+ );
154
+
155
+ // Verify org was updated with stripeCustomerId
156
+ const updatedOrg = await ctx.adapter.findOne<{
157
+ id: string;
158
+ stripeCustomerId?: string;
159
+ }>({
160
+ model: "organization",
161
+ where: [{ field: "id", value: orgId }],
162
+ });
163
+ expect(updatedOrg?.stripeCustomerId).toBe("cus_org_mock123");
164
+
165
+ // Verify callback was called
166
+ expect(onCustomerCreate).toHaveBeenCalledWith(
167
+ expect.objectContaining({
168
+ stripeCustomer: expect.objectContaining({ id: "cus_org_mock123" }),
169
+ organization: expect.objectContaining({
170
+ id: orgId,
171
+ stripeCustomerId: "cus_org_mock123",
172
+ }),
173
+ }),
174
+ expect.anything(),
175
+ );
176
+ });
177
+
178
+ it("should use existing Stripe customer ID from organization", async () => {
179
+ const { client, auth, sessionSetter } = await getTestInstance(
180
+ {
181
+ plugins: [organization(), stripe(baseOrgStripeOptions)],
182
+ },
183
+ {
184
+ disableTestUser: true,
185
+ clientOptions: {
186
+ plugins: [organizationClient(), stripeClient({ subscription: true })],
187
+ },
188
+ },
189
+ );
190
+ const ctx = await auth.$context;
191
+
192
+ // Create user and organization with existing stripeCustomerId
193
+ await client.signUp.email(
194
+ { ...testUser, email: "org-existing-customer@email.com" },
195
+ { throw: true },
196
+ );
197
+
198
+ const headers = new Headers();
199
+ await client.signIn.email(
200
+ { ...testUser, email: "org-existing-customer@email.com" },
201
+ {
202
+ throw: true,
203
+ onSuccess: sessionSetter(headers),
204
+ },
205
+ );
206
+
207
+ // Create organization via client
208
+ const org = await client.organization.create({
209
+ name: "Existing Stripe Org",
210
+ slug: "existing-stripe-org",
211
+ fetchOptions: { headers },
212
+ });
213
+ const orgId = org.data?.id as string;
214
+
215
+ // Update org with existing stripeCustomerId
216
+ await ctx.adapter.update({
217
+ model: "organization",
218
+ update: { stripeCustomerId: "cus_existing_org_123" },
219
+ where: [{ field: "id", value: orgId }],
220
+ });
221
+
222
+ await client.subscription.upgrade({
223
+ plan: "starter",
224
+ customerType: "organization",
225
+ referenceId: orgId,
226
+ fetchOptions: { headers },
227
+ });
228
+
229
+ // Should NOT create a new Stripe customer
230
+ expect(mockStripeOrg.customers.create).not.toHaveBeenCalled();
231
+
232
+ // Should use existing customer ID in checkout
233
+ expect(mockStripeOrg.checkout.sessions.create).toHaveBeenCalledWith(
234
+ expect.objectContaining({
235
+ customer: "cus_existing_org_123",
236
+ }),
237
+ undefined,
238
+ );
239
+ });
240
+
241
+ it("should call getCustomerCreateParams when creating org customer", async () => {
242
+ const getCustomerCreateParams = vi.fn().mockResolvedValue({
243
+ email: "billing@org.com",
244
+ description: "Custom org description",
245
+ });
246
+
247
+ const stripeOptionsWithParams: StripeOptions = {
248
+ ...baseOrgStripeOptions,
249
+ organization: {
250
+ ...baseOrgStripeOptions.organization,
251
+ getCustomerCreateParams,
252
+ },
253
+ };
254
+
255
+ const { client, sessionSetter } = await getTestInstance(
256
+ {
257
+ plugins: [organization(), stripe(stripeOptionsWithParams)],
258
+ },
259
+ {
260
+ disableTestUser: true,
261
+ clientOptions: {
262
+ plugins: [organizationClient(), stripeClient({ subscription: true })],
263
+ },
264
+ },
265
+ );
266
+
267
+ await client.signUp.email(
268
+ { ...testUser, email: "org-params-test@email.com" },
269
+ { throw: true },
270
+ );
271
+
272
+ const headers = new Headers();
273
+ await client.signIn.email(
274
+ { ...testUser, email: "org-params-test@email.com" },
275
+ {
276
+ throw: true,
277
+ onSuccess: sessionSetter(headers),
278
+ },
279
+ );
280
+
281
+ // Create organization via client
282
+ const org = await client.organization.create({
283
+ name: "Params Test Org",
284
+ slug: "params-test-org",
285
+ fetchOptions: { headers },
286
+ });
287
+ const orgId = org.data?.id as string;
288
+
289
+ await client.subscription.upgrade({
290
+ plan: "starter",
291
+ customerType: "organization",
292
+ referenceId: orgId,
293
+ fetchOptions: { headers },
294
+ });
295
+
296
+ // Verify getCustomerCreateParams was called with org
297
+ expect(getCustomerCreateParams).toHaveBeenCalled();
298
+ const callArgs = getCustomerCreateParams.mock.calls[0]!;
299
+ expect(callArgs[0]).toMatchObject({ id: orgId });
300
+
301
+ // Verify custom params were passed to Stripe
302
+ expect(mockStripeOrg.customers.create).toHaveBeenCalledWith(
303
+ expect.objectContaining({
304
+ email: "billing@org.com",
305
+ description: "Custom org description",
306
+ }),
307
+ );
308
+ });
309
+
310
+ it("should create billing portal for organization", async () => {
311
+ const { client, auth, sessionSetter } = await getTestInstance(
312
+ {
313
+ plugins: [organization(), stripe(baseOrgStripeOptions)],
314
+ },
315
+ {
316
+ disableTestUser: true,
317
+ clientOptions: {
318
+ plugins: [organizationClient(), stripeClient({ subscription: true })],
319
+ },
320
+ },
321
+ );
322
+ const ctx = await auth.$context;
323
+
324
+ await client.signUp.email(
325
+ { ...testUser, email: "org-portal-test@email.com" },
326
+ { throw: true },
327
+ );
328
+
329
+ const headers = new Headers();
330
+ await client.signIn.email(
331
+ { ...testUser, email: "org-portal-test@email.com" },
332
+ {
333
+ throw: true,
334
+ onSuccess: sessionSetter(headers),
335
+ },
336
+ );
337
+
338
+ // Create organization via client
339
+ const org = await client.organization.create({
340
+ name: "Portal Test Org",
341
+ slug: "portal-test-org",
342
+ fetchOptions: { headers },
343
+ });
344
+ const orgId = org.data?.id as string;
345
+
346
+ // Update org with stripeCustomerId
347
+ await ctx.adapter.update({
348
+ model: "organization",
349
+ update: { stripeCustomerId: "cus_portal_org_123" },
350
+ where: [{ field: "id", value: orgId }],
351
+ });
352
+
353
+ // Create a subscription for the org
354
+ await ctx.adapter.create({
355
+ model: "subscription",
356
+ data: {
357
+ referenceId: orgId,
358
+ stripeCustomerId: "cus_portal_org_123",
359
+ status: "active",
360
+ plan: "starter",
361
+ },
362
+ });
363
+
364
+ const res = await client.subscription.billingPortal({
365
+ customerType: "organization",
366
+ referenceId: orgId,
367
+ returnUrl: "/dashboard",
368
+ fetchOptions: { headers },
369
+ });
370
+
371
+ expect(res.data?.url).toBe("https://billing.stripe.com/mock");
372
+ expect(mockStripeOrg.billingPortal.sessions.create).toHaveBeenCalledWith(
373
+ expect.objectContaining({
374
+ customer: "cus_portal_org_123",
375
+ }),
376
+ );
377
+ });
378
+
379
+ it("should cancel subscription for organization", async () => {
380
+ mockStripeOrg.subscriptions.list.mockResolvedValueOnce({
381
+ data: [
382
+ {
383
+ id: "sub_org_cancel_123",
384
+ status: "active",
385
+ cancel_at_period_end: false,
386
+ },
387
+ ],
388
+ });
389
+
390
+ const { client, auth, sessionSetter } = await getTestInstance(
391
+ {
392
+ plugins: [organization(), stripe(baseOrgStripeOptions)],
393
+ },
394
+ {
395
+ disableTestUser: true,
396
+ clientOptions: {
397
+ plugins: [organizationClient(), stripeClient({ subscription: true })],
398
+ },
399
+ },
400
+ );
401
+ const ctx = await auth.$context;
402
+
403
+ await client.signUp.email(
404
+ { ...testUser, email: "org-cancel-test@email.com" },
405
+ { throw: true },
406
+ );
407
+
408
+ const headers = new Headers();
409
+ await client.signIn.email(
410
+ { ...testUser, email: "org-cancel-test@email.com" },
411
+ {
412
+ throw: true,
413
+ onSuccess: sessionSetter(headers),
414
+ },
415
+ );
416
+
417
+ // Create organization via client
418
+ const org = await client.organization.create({
419
+ name: "Cancel Test Org",
420
+ slug: "cancel-test-org",
421
+ fetchOptions: { headers },
422
+ });
423
+ const orgId = org.data?.id as string;
424
+
425
+ // Update org with stripeCustomerId
426
+ await ctx.adapter.update({
427
+ model: "organization",
428
+ update: { stripeCustomerId: "cus_cancel_org_123" },
429
+ where: [{ field: "id", value: orgId }],
430
+ });
431
+
432
+ await ctx.adapter.create({
433
+ model: "subscription",
434
+ data: {
435
+ referenceId: orgId,
436
+ stripeCustomerId: "cus_cancel_org_123",
437
+ stripeSubscriptionId: "sub_org_cancel_123",
438
+ status: "active",
439
+ plan: "starter",
440
+ },
441
+ });
442
+
443
+ const res = await client.subscription.cancel({
444
+ customerType: "organization",
445
+ referenceId: orgId,
446
+ returnUrl: "/dashboard",
447
+ fetchOptions: { headers },
448
+ });
449
+
450
+ expect(res.data?.url).toBeDefined();
451
+ expect(mockStripeOrg.billingPortal.sessions.create).toHaveBeenCalledWith(
452
+ expect.objectContaining({
453
+ customer: "cus_cancel_org_123",
454
+ flow_data: expect.objectContaining({
455
+ type: "subscription_cancel",
456
+ subscription_cancel: {
457
+ subscription: "sub_org_cancel_123",
458
+ },
459
+ }),
460
+ }),
461
+ );
462
+ });
463
+
464
+ it("should restore subscription for organization", async () => {
465
+ mockStripeOrg.subscriptions.list.mockResolvedValueOnce({
466
+ data: [
467
+ {
468
+ id: "sub_org_restore_123",
469
+ status: "active",
470
+ cancel_at_period_end: true,
471
+ cancel_at: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
472
+ },
473
+ ],
474
+ });
475
+ mockStripeOrg.subscriptions.update.mockResolvedValueOnce({
476
+ id: "sub_org_restore_123",
477
+ status: "active",
478
+ cancel_at_period_end: false,
479
+ });
480
+
481
+ const { client, auth, sessionSetter } = await getTestInstance(
482
+ {
483
+ plugins: [organization(), stripe(baseOrgStripeOptions)],
484
+ },
485
+ {
486
+ disableTestUser: true,
487
+ clientOptions: {
488
+ plugins: [organizationClient(), stripeClient({ subscription: true })],
489
+ },
490
+ },
491
+ );
492
+ const ctx = await auth.$context;
493
+
494
+ await client.signUp.email(
495
+ { ...testUser, email: "org-restore-test@email.com" },
496
+ { throw: true },
497
+ );
498
+
499
+ const headers = new Headers();
500
+ await client.signIn.email(
501
+ { ...testUser, email: "org-restore-test@email.com" },
502
+ {
503
+ throw: true,
504
+ onSuccess: sessionSetter(headers),
505
+ },
506
+ );
507
+
508
+ // Create organization via client
509
+ const org = await client.organization.create({
510
+ name: "Restore Test Org",
511
+ slug: "restore-test-org",
512
+ fetchOptions: { headers },
513
+ });
514
+ const orgId = org.data?.id as string;
515
+
516
+ // Update org with stripeCustomerId
517
+ await ctx.adapter.update({
518
+ model: "organization",
519
+ update: { stripeCustomerId: "cus_restore_org_123" },
520
+ where: [{ field: "id", value: orgId }],
521
+ });
522
+
523
+ await ctx.adapter.create({
524
+ model: "subscription",
525
+ data: {
526
+ referenceId: orgId,
527
+ stripeCustomerId: "cus_restore_org_123",
528
+ stripeSubscriptionId: "sub_org_restore_123",
529
+ status: "active",
530
+ plan: "starter",
531
+ cancelAtPeriodEnd: true,
532
+ canceledAt: new Date(),
533
+ },
534
+ });
535
+
536
+ const res = await client.subscription.restore({
537
+ customerType: "organization",
538
+ referenceId: orgId,
539
+ fetchOptions: { headers },
540
+ });
541
+
542
+ expect(res.data).toBeDefined();
543
+ // Note: Stripe API doesn't accept both cancel_at and cancel_at_period_end simultaneously
544
+ expect(mockStripeOrg.subscriptions.update).toHaveBeenCalledWith(
545
+ "sub_org_restore_123",
546
+ expect.objectContaining({
547
+ cancel_at: "",
548
+ }),
549
+ );
550
+
551
+ // Verify subscription was updated in DB
552
+ const updatedSub = await ctx.adapter.findOne<Subscription>({
553
+ model: "subscription",
554
+ where: [{ field: "referenceId", value: orgId }],
555
+ });
556
+ expect(updatedSub?.cancelAtPeriodEnd).toBe(false);
557
+ expect(updatedSub?.canceledAt).toBeNull();
558
+ });
559
+
560
+ it("should list subscriptions for organization", async () => {
561
+ const { client, auth, sessionSetter } = await getTestInstance(
562
+ {
563
+ plugins: [organization(), stripe(baseOrgStripeOptions)],
564
+ },
565
+ {
566
+ disableTestUser: true,
567
+ clientOptions: {
568
+ plugins: [organizationClient(), stripeClient({ subscription: true })],
569
+ },
570
+ },
571
+ );
572
+ const ctx = await auth.$context;
573
+
574
+ await client.signUp.email(
575
+ { ...testUser, email: "org-list-test@email.com" },
576
+ { throw: true },
577
+ );
578
+
579
+ const headers = new Headers();
580
+ await client.signIn.email(
581
+ { ...testUser, email: "org-list-test@email.com" },
582
+ {
583
+ throw: true,
584
+ onSuccess: sessionSetter(headers),
585
+ },
586
+ );
587
+
588
+ // Create organization via client
589
+ const org = await client.organization.create({
590
+ name: "List Test Org",
591
+ slug: "list-test-org",
592
+ fetchOptions: { headers },
593
+ });
594
+ const orgId = org.data?.id as string;
595
+
596
+ // Update org with stripeCustomerId
597
+ await ctx.adapter.update({
598
+ model: "organization",
599
+ update: { stripeCustomerId: "cus_list_org_123" },
600
+ where: [{ field: "id", value: orgId }],
601
+ });
602
+
603
+ // Create subscription for org
604
+ await ctx.adapter.create({
605
+ model: "subscription",
606
+ data: {
607
+ referenceId: orgId,
608
+ stripeCustomerId: "cus_list_org_123",
609
+ stripeSubscriptionId: "sub_org_list_123",
610
+ status: "active",
611
+ plan: "starter",
612
+ },
613
+ });
614
+
615
+ const res = await client.subscription.list({
616
+ query: {
617
+ customerType: "organization",
618
+ referenceId: orgId,
619
+ },
620
+ fetchOptions: { headers },
621
+ });
622
+
623
+ expect(res.data?.length).toBe(1);
624
+ expect(res.data?.[0]).toMatchObject({
625
+ referenceId: orgId,
626
+ plan: "starter",
627
+ status: "active",
628
+ });
629
+ });
630
+
631
+ it("should handle webhook for organization subscription created from dashboard", async () => {
632
+ const mockEvent = {
633
+ type: "customer.subscription.created",
634
+ data: {
635
+ object: {
636
+ id: "sub_org_webhook_123",
637
+ customer: "cus_org_webhook_123",
638
+ status: "active",
639
+ items: {
640
+ data: [
641
+ {
642
+ price: {
643
+ id: process.env.STRIPE_PRICE_ID_1,
644
+ lookup_key: null,
645
+ },
646
+ quantity: 5,
647
+ current_period_start: Math.floor(Date.now() / 1000),
648
+ current_period_end:
649
+ Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
650
+ },
651
+ ],
652
+ },
653
+ cancel_at_period_end: false,
654
+ },
655
+ },
656
+ };
657
+
658
+ const stripeForOrgWebhook = {
659
+ ...mockStripeOrg,
660
+ webhooks: {
661
+ constructEventAsync: vi.fn().mockResolvedValue(mockEvent),
662
+ },
663
+ };
664
+
665
+ const testOrgWebhookOptions: StripeOptions = {
666
+ ...baseOrgStripeOptions,
667
+ stripeClient: stripeForOrgWebhook as unknown as Stripe,
668
+ stripeWebhookSecret: "test_secret",
669
+ };
670
+
671
+ const { auth, client, sessionSetter } = await getTestInstance(
672
+ {
673
+ plugins: [organization(), stripe(testOrgWebhookOptions)],
674
+ },
675
+ {
676
+ disableTestUser: true,
677
+ clientOptions: {
678
+ plugins: [organizationClient(), stripeClient({ subscription: true })],
679
+ },
680
+ },
681
+ );
682
+ const ctx = await auth.$context;
683
+
684
+ // Sign up and sign in
685
+ await client.signUp.email(
686
+ { ...testUser, email: "org-webhook-test@email.com" },
687
+ { throw: true },
688
+ );
689
+ const headers = new Headers();
690
+ await client.signIn.email(
691
+ { ...testUser, email: "org-webhook-test@email.com" },
692
+ {
693
+ throw: true,
694
+ onSuccess: sessionSetter(headers),
695
+ },
696
+ );
697
+
698
+ // Create organization via client
699
+ const org = await client.organization.create({
700
+ name: "Webhook Test Org",
701
+ slug: "webhook-test-org",
702
+ fetchOptions: { headers },
703
+ });
704
+ const orgId = org.data?.id as string;
705
+
706
+ // Update org with stripeCustomerId
707
+ await ctx.adapter.update({
708
+ model: "organization",
709
+ update: { stripeCustomerId: "cus_org_webhook_123" },
710
+ where: [{ field: "id", value: orgId }],
711
+ });
712
+
713
+ const mockRequest = new Request(
714
+ "http://localhost:3000/api/auth/stripe/webhook",
715
+ {
716
+ method: "POST",
717
+ headers: {
718
+ "stripe-signature": "test_signature",
719
+ },
720
+ body: JSON.stringify(mockEvent),
721
+ },
722
+ );
723
+
724
+ const response = await auth.handler(mockRequest);
725
+ expect(response.status).toBe(200);
726
+
727
+ // Verify subscription was created for organization
728
+ const subscription = await ctx.adapter.findOne<Subscription>({
729
+ model: "subscription",
730
+ where: [{ field: "stripeSubscriptionId", value: "sub_org_webhook_123" }],
731
+ });
732
+
733
+ expect(subscription).toBeDefined();
734
+ expect(subscription?.referenceId).toBe(orgId);
735
+ expect(subscription?.stripeCustomerId).toBe("cus_org_webhook_123");
736
+ expect(subscription?.status).toBe("active");
737
+ expect(subscription?.plan).toBe("starter");
738
+ expect(subscription?.seats).toBe(5);
739
+ });
740
+
741
+ it("should not allow cross-organization subscription operations", async () => {
742
+ // Track which org is being accessed
743
+ let otherOrgIdForCheck: string = "";
744
+
745
+ const { client, auth, sessionSetter } = await getTestInstance(
746
+ {
747
+ plugins: [
748
+ organization(),
749
+ stripe({
750
+ ...baseOrgStripeOptions,
751
+ subscription: {
752
+ ...baseOrgStripeOptions.subscription,
753
+ authorizeReference: async ({ referenceId }) => {
754
+ // Simulate member check: only allow if user is member of org
755
+ // For test, we'll return false for the "other" org
756
+ return referenceId !== otherOrgIdForCheck;
757
+ },
758
+ },
759
+ }),
760
+ ],
761
+ },
762
+ {
763
+ disableTestUser: true,
764
+ clientOptions: {
765
+ plugins: [organizationClient(), stripeClient({ subscription: true })],
766
+ },
767
+ },
768
+ );
769
+ const ctx = await auth.$context;
770
+
771
+ await client.signUp.email(
772
+ { ...testUser, email: "cross-org-test@email.com" },
773
+ { throw: true },
774
+ );
775
+
776
+ const headers = new Headers();
777
+ await client.signIn.email(
778
+ { ...testUser, email: "cross-org-test@email.com" },
779
+ {
780
+ throw: true,
781
+ onSuccess: sessionSetter(headers),
782
+ },
783
+ );
784
+
785
+ // Create user's organization via client
786
+ const userOrg = await client.organization.create({
787
+ name: "User Org",
788
+ slug: "user-org",
789
+ fetchOptions: { headers },
790
+ });
791
+
792
+ // Update org with stripeCustomerId
793
+ await ctx.adapter.update({
794
+ model: "organization",
795
+ update: { stripeCustomerId: "cus_user_org" },
796
+ where: [{ field: "id", value: userOrg.data?.id as string }],
797
+ });
798
+
799
+ // Create other organization via client
800
+ const { id: otherOrgId } = await ctx.adapter.create({
801
+ model: "organization",
802
+ data: {
803
+ name: "Other Org",
804
+ slug: "other-org",
805
+ stripeCustomerId: "cus_other_org",
806
+ createdAt: new Date(),
807
+ },
808
+ });
809
+ otherOrgIdForCheck = otherOrgId;
810
+
811
+ // Create subscription for other org
812
+ await ctx.adapter.create({
813
+ model: "subscription",
814
+ data: {
815
+ referenceId: otherOrgId,
816
+ stripeCustomerId: "cus_other_org",
817
+ stripeSubscriptionId: "sub_other_org",
818
+ status: "active",
819
+ plan: "starter",
820
+ },
821
+ });
822
+
823
+ // Try to cancel other org's subscription - authorizeReference returns false for org_other
824
+ const cancelRes = await client.subscription.cancel({
825
+ customerType: "organization",
826
+ referenceId: otherOrgId,
827
+ returnUrl: "/dashboard",
828
+ fetchOptions: { headers },
829
+ });
830
+
831
+ // authorizeReference returns false -> UNAUTHORIZED from middleware
832
+ expect(cancelRes.error?.code).toBe("UNAUTHORIZED");
833
+ });
834
+
835
+ it("should reject organization subscription when organization.enabled is false", async () => {
836
+ const stripeOptionsWithoutOrg: StripeOptions = {
837
+ ...baseOrgStripeOptions,
838
+ organization: undefined, // Disable organization support
839
+ subscription: {
840
+ ...baseOrgStripeOptions.subscription,
841
+ // Remove authorizeReference so middleware can check organization.enabled
842
+ authorizeReference: undefined,
843
+ },
844
+ };
845
+
846
+ const { client, sessionSetter } = await getTestInstance(
847
+ {
848
+ plugins: [stripe(stripeOptionsWithoutOrg)],
849
+ },
850
+ {
851
+ disableTestUser: true,
852
+ clientOptions: {
853
+ plugins: [stripeClient({ subscription: true })],
854
+ },
855
+ },
856
+ );
857
+
858
+ await client.signUp.email(
859
+ { ...testUser, email: "org-disabled-test@email.com" },
860
+ { throw: true },
861
+ );
862
+
863
+ const headers = new Headers();
864
+ await client.signIn.email(
865
+ { ...testUser, email: "org-disabled-test@email.com" },
866
+ {
867
+ throw: true,
868
+ onSuccess: sessionSetter(headers),
869
+ },
870
+ );
871
+
872
+ // Try to upgrade with organization customerType when organization is not enabled
873
+ // Without authorizeReference, middleware rejects organization subscriptions
874
+ const res = await client.subscription.upgrade({
875
+ plan: "starter",
876
+ customerType: "organization",
877
+ referenceId: "fake-org-id",
878
+ fetchOptions: { headers },
879
+ });
880
+
881
+ expect(res.error?.code).toBe("ORGANIZATION_SUBSCRIPTION_NOT_ENABLED");
882
+ });
883
+
884
+ it("should keep user and organization subscriptions separate", async () => {
885
+ const { client, auth, sessionSetter } = await getTestInstance(
886
+ {
887
+ plugins: [organization(), stripe(baseOrgStripeOptions)],
888
+ },
889
+ {
890
+ disableTestUser: true,
891
+ clientOptions: {
892
+ plugins: [organizationClient(), stripeClient({ subscription: true })],
893
+ },
894
+ },
895
+ );
896
+ const ctx = await auth.$context;
897
+
898
+ const userRes = await client.signUp.email(
899
+ { ...testUser, email: "separate-sub-test@email.com" },
900
+ { throw: true },
901
+ );
902
+
903
+ const headers = new Headers();
904
+ await client.signIn.email(
905
+ { ...testUser, email: "separate-sub-test@email.com" },
906
+ {
907
+ throw: true,
908
+ onSuccess: sessionSetter(headers),
909
+ },
910
+ );
911
+
912
+ // Create organization via client
913
+ const org = await client.organization.create({
914
+ name: "Separate Sub Org",
915
+ slug: "separate-sub-org",
916
+ fetchOptions: { headers },
917
+ });
918
+ const orgId = org.data?.id as string;
919
+
920
+ // Update org with stripeCustomerId
921
+ await ctx.adapter.update({
922
+ model: "organization",
923
+ update: { stripeCustomerId: "cus_separate_org" },
924
+ where: [{ field: "id", value: orgId }],
925
+ });
926
+
927
+ // Create user subscription
928
+ await ctx.adapter.create({
929
+ model: "subscription",
930
+ data: {
931
+ referenceId: userRes.user.id,
932
+ stripeCustomerId: "cus_user_123",
933
+ stripeSubscriptionId: "sub_user_123",
934
+ status: "active",
935
+ plan: "starter",
936
+ },
937
+ });
938
+
939
+ // Create org subscription
940
+ await ctx.adapter.create({
941
+ model: "subscription",
942
+ data: {
943
+ referenceId: orgId,
944
+ stripeCustomerId: "cus_separate_org",
945
+ stripeSubscriptionId: "sub_org_123",
946
+ status: "active",
947
+ plan: "premium",
948
+ },
949
+ });
950
+
951
+ // List user subscriptions
952
+ const userSubs = await client.subscription.list({
953
+ fetchOptions: { headers },
954
+ });
955
+
956
+ // List org subscriptions
957
+ const orgSubs = await client.subscription.list({
958
+ query: {
959
+ customerType: "organization",
960
+ referenceId: orgId,
961
+ },
962
+ fetchOptions: { headers },
963
+ });
964
+
965
+ expect(userSubs.data?.length).toBe(1);
966
+ expect(userSubs.data?.[0]?.plan).toBe("starter");
967
+ expect(userSubs.data?.[0]?.referenceId).toBe(userRes.user.id);
968
+
969
+ expect(orgSubs.data?.length).toBe(1);
970
+ expect(orgSubs.data?.[0]?.plan).toBe("premium");
971
+ expect(orgSubs.data?.[0]?.referenceId).toBe(orgId);
972
+ });
973
+
974
+ it("should handle customer.subscription.updated webhook for organization", async () => {
975
+ const onSubscriptionUpdate = vi.fn();
976
+ const onSubscriptionCancel = vi.fn();
977
+
978
+ const mockUpdateEvent = {
979
+ type: "customer.subscription.updated",
980
+ data: {
981
+ object: {
982
+ id: "sub_org_update_123",
983
+ customer: "cus_org_update_123",
984
+ status: "active",
985
+ items: {
986
+ data: [
987
+ {
988
+ price: {
989
+ id: "price_premium_123", // Different price ID
990
+ lookup_key: "lookup_key_234", // Matches premium plan
991
+ },
992
+ quantity: 10,
993
+ current_period_start: Math.floor(Date.now() / 1000),
994
+ current_period_end:
995
+ Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
996
+ },
997
+ ],
998
+ },
999
+ cancel_at_period_end: false,
1000
+ cancel_at: null,
1001
+ canceled_at: null,
1002
+ ended_at: null,
1003
+ },
1004
+ },
1005
+ };
1006
+
1007
+ const stripeForOrgUpdateWebhook = {
1008
+ ...mockStripeOrg,
1009
+ webhooks: {
1010
+ constructEventAsync: vi.fn().mockResolvedValue(mockUpdateEvent),
1011
+ },
1012
+ };
1013
+
1014
+ const testOrgUpdateWebhookOptions: StripeOptions = {
1015
+ ...baseOrgStripeOptions,
1016
+ stripeClient: stripeForOrgUpdateWebhook as unknown as Stripe,
1017
+ stripeWebhookSecret: "test_secret",
1018
+ subscription: {
1019
+ ...baseOrgStripeOptions.subscription,
1020
+ onSubscriptionUpdate,
1021
+ onSubscriptionCancel,
1022
+ },
1023
+ };
1024
+
1025
+ const { auth, client, sessionSetter } = await getTestInstance(
1026
+ {
1027
+ plugins: [organization(), stripe(testOrgUpdateWebhookOptions)],
1028
+ },
1029
+ {
1030
+ disableTestUser: true,
1031
+ clientOptions: {
1032
+ plugins: [organizationClient(), stripeClient({ subscription: true })],
1033
+ },
1034
+ },
1035
+ );
1036
+ const ctx = await auth.$context;
1037
+
1038
+ // Sign up and sign in
1039
+ await client.signUp.email(
1040
+ { ...testUser, email: "org-update-webhook-test@email.com" },
1041
+ { throw: true },
1042
+ );
1043
+ const headers = new Headers();
1044
+ await client.signIn.email(
1045
+ { ...testUser, email: "org-update-webhook-test@email.com" },
1046
+ {
1047
+ throw: true,
1048
+ onSuccess: sessionSetter(headers),
1049
+ },
1050
+ );
1051
+
1052
+ // Create organization via client
1053
+ const org = await client.organization.create({
1054
+ name: "Update Webhook Test Org",
1055
+ slug: "update-webhook-test-org",
1056
+ fetchOptions: { headers },
1057
+ });
1058
+ const orgId = org.data?.id as string;
1059
+
1060
+ // Update org with stripeCustomerId
1061
+ await ctx.adapter.update({
1062
+ model: "organization",
1063
+ update: { stripeCustomerId: "cus_org_update_123" },
1064
+ where: [{ field: "id", value: orgId }],
1065
+ });
1066
+
1067
+ // Create existing subscription for org
1068
+ const { id: subId } = await ctx.adapter.create({
1069
+ model: "subscription",
1070
+ data: {
1071
+ referenceId: orgId,
1072
+ stripeCustomerId: "cus_org_update_123",
1073
+ stripeSubscriptionId: "sub_org_update_123",
1074
+ status: "active",
1075
+ plan: "starter",
1076
+ seats: 5,
1077
+ },
1078
+ });
1079
+
1080
+ const mockRequest = new Request(
1081
+ "http://localhost:3000/api/auth/stripe/webhook",
1082
+ {
1083
+ method: "POST",
1084
+ headers: {
1085
+ "stripe-signature": "test_signature",
1086
+ },
1087
+ body: JSON.stringify(mockUpdateEvent),
1088
+ },
1089
+ );
1090
+
1091
+ const response = await auth.handler(mockRequest);
1092
+ expect(response.status).toBe(200);
1093
+
1094
+ // Verify subscription was updated
1095
+ const updatedSubscription = await ctx.adapter.findOne<Subscription>({
1096
+ model: "subscription",
1097
+ where: [{ field: "id", value: subId }],
1098
+ });
1099
+
1100
+ expect(updatedSubscription).toBeDefined();
1101
+ expect(updatedSubscription?.plan).toBe("premium");
1102
+ expect(updatedSubscription?.seats).toBe(10);
1103
+ expect(updatedSubscription?.status).toBe("active");
1104
+
1105
+ // Verify callback was called
1106
+ expect(onSubscriptionUpdate).toHaveBeenCalledWith(
1107
+ expect.objectContaining({
1108
+ subscription: expect.objectContaining({
1109
+ referenceId: orgId,
1110
+ plan: "premium",
1111
+ }),
1112
+ }),
1113
+ );
1114
+ });
1115
+
1116
+ it("should handle customer.subscription.updated webhook with cancellation for organization", async () => {
1117
+ const onSubscriptionCancel = vi.fn();
1118
+
1119
+ const cancelAt = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60;
1120
+ const mockCancelEvent = {
1121
+ type: "customer.subscription.updated",
1122
+ data: {
1123
+ object: {
1124
+ id: "sub_org_cancel_webhook_123",
1125
+ customer: "cus_org_cancel_webhook_123",
1126
+ status: "active",
1127
+ items: {
1128
+ data: [
1129
+ {
1130
+ price: {
1131
+ id: process.env.STRIPE_PRICE_ID_1,
1132
+ lookup_key: null,
1133
+ },
1134
+ quantity: 5,
1135
+ current_period_start: Math.floor(Date.now() / 1000),
1136
+ current_period_end: cancelAt,
1137
+ },
1138
+ ],
1139
+ },
1140
+ cancel_at_period_end: true,
1141
+ cancel_at: cancelAt,
1142
+ canceled_at: Math.floor(Date.now() / 1000),
1143
+ ended_at: null,
1144
+ cancellation_details: {
1145
+ reason: "cancellation_requested",
1146
+ comment: "User requested cancellation",
1147
+ },
1148
+ },
1149
+ },
1150
+ };
1151
+
1152
+ const stripeForOrgCancelWebhook = {
1153
+ ...mockStripeOrg,
1154
+ webhooks: {
1155
+ constructEventAsync: vi.fn().mockResolvedValue(mockCancelEvent),
1156
+ },
1157
+ };
1158
+
1159
+ const testOrgCancelWebhookOptions: StripeOptions = {
1160
+ ...baseOrgStripeOptions,
1161
+ stripeClient: stripeForOrgCancelWebhook as unknown as Stripe,
1162
+ stripeWebhookSecret: "test_secret",
1163
+ subscription: {
1164
+ ...baseOrgStripeOptions.subscription,
1165
+ onSubscriptionCancel,
1166
+ },
1167
+ };
1168
+
1169
+ const { auth, client, sessionSetter } = await getTestInstance(
1170
+ {
1171
+ plugins: [organization(), stripe(testOrgCancelWebhookOptions)],
1172
+ },
1173
+ {
1174
+ disableTestUser: true,
1175
+ clientOptions: {
1176
+ plugins: [organizationClient(), stripeClient({ subscription: true })],
1177
+ },
1178
+ },
1179
+ );
1180
+ const ctx = await auth.$context;
1181
+
1182
+ // Sign up and sign in
1183
+ await client.signUp.email(
1184
+ { ...testUser, email: "org-cancel-webhook-test@email.com" },
1185
+ { throw: true },
1186
+ );
1187
+ const headers = new Headers();
1188
+ await client.signIn.email(
1189
+ { ...testUser, email: "org-cancel-webhook-test@email.com" },
1190
+ {
1191
+ throw: true,
1192
+ onSuccess: sessionSetter(headers),
1193
+ },
1194
+ );
1195
+
1196
+ // Create organization via client
1197
+ const org = await client.organization.create({
1198
+ name: "Cancel Webhook Test Org",
1199
+ slug: "cancel-webhook-test-org",
1200
+ fetchOptions: { headers },
1201
+ });
1202
+ const orgId = org.data?.id as string;
1203
+
1204
+ // Update org with stripeCustomerId
1205
+ await ctx.adapter.update({
1206
+ model: "organization",
1207
+ update: { stripeCustomerId: "cus_org_cancel_webhook_123" },
1208
+ where: [{ field: "id", value: orgId }],
1209
+ });
1210
+
1211
+ // Create subscription (not yet marked as pending cancel)
1212
+ const { id: subId } = await ctx.adapter.create({
1213
+ model: "subscription",
1214
+ data: {
1215
+ referenceId: orgId,
1216
+ stripeCustomerId: "cus_org_cancel_webhook_123",
1217
+ stripeSubscriptionId: "sub_org_cancel_webhook_123",
1218
+ status: "active",
1219
+ plan: "starter",
1220
+ cancelAtPeriodEnd: false,
1221
+ },
1222
+ });
1223
+
1224
+ const mockRequest = new Request(
1225
+ "http://localhost:3000/api/auth/stripe/webhook",
1226
+ {
1227
+ method: "POST",
1228
+ headers: {
1229
+ "stripe-signature": "test_signature",
1230
+ },
1231
+ body: JSON.stringify(mockCancelEvent),
1232
+ },
1233
+ );
1234
+
1235
+ const response = await auth.handler(mockRequest);
1236
+ expect(response.status).toBe(200);
1237
+
1238
+ // Verify subscription was updated with cancellation info
1239
+ const updatedSubscription = await ctx.adapter.findOne<Subscription>({
1240
+ model: "subscription",
1241
+ where: [{ field: "id", value: subId }],
1242
+ });
1243
+
1244
+ expect(updatedSubscription?.cancelAtPeriodEnd).toBe(true);
1245
+ expect(updatedSubscription?.cancelAt).toBeDefined();
1246
+ expect(updatedSubscription?.canceledAt).toBeDefined();
1247
+
1248
+ // Verify onSubscriptionCancel callback was called
1249
+ expect(onSubscriptionCancel).toHaveBeenCalledWith(
1250
+ expect.objectContaining({
1251
+ subscription: expect.objectContaining({
1252
+ referenceId: orgId,
1253
+ }),
1254
+ cancellationDetails: expect.objectContaining({
1255
+ reason: "cancellation_requested",
1256
+ }),
1257
+ }),
1258
+ );
1259
+ });
1260
+
1261
+ it("should handle customer.subscription.deleted webhook for organization", async () => {
1262
+ const onSubscriptionDeleted = vi.fn();
1263
+
1264
+ const mockDeleteEvent = {
1265
+ type: "customer.subscription.deleted",
1266
+ data: {
1267
+ object: {
1268
+ id: "sub_org_delete_123",
1269
+ customer: "cus_org_delete_123",
1270
+ status: "canceled",
1271
+ cancel_at_period_end: false,
1272
+ cancel_at: null,
1273
+ canceled_at: Math.floor(Date.now() / 1000),
1274
+ ended_at: Math.floor(Date.now() / 1000),
1275
+ },
1276
+ },
1277
+ };
1278
+
1279
+ const stripeForOrgDeleteWebhook = {
1280
+ ...mockStripeOrg,
1281
+ webhooks: {
1282
+ constructEventAsync: vi.fn().mockResolvedValue(mockDeleteEvent),
1283
+ },
1284
+ };
1285
+
1286
+ const testOrgDeleteWebhookOptions: StripeOptions = {
1287
+ ...baseOrgStripeOptions,
1288
+ stripeClient: stripeForOrgDeleteWebhook as unknown as Stripe,
1289
+ stripeWebhookSecret: "test_secret",
1290
+ subscription: {
1291
+ ...baseOrgStripeOptions.subscription,
1292
+ onSubscriptionDeleted,
1293
+ },
1294
+ };
1295
+
1296
+ const { auth, client, sessionSetter } = await getTestInstance(
1297
+ {
1298
+ plugins: [organization(), stripe(testOrgDeleteWebhookOptions)],
1299
+ },
1300
+ {
1301
+ disableTestUser: true,
1302
+ clientOptions: {
1303
+ plugins: [organizationClient(), stripeClient({ subscription: true })],
1304
+ },
1305
+ },
1306
+ );
1307
+ const ctx = await auth.$context;
1308
+
1309
+ // Sign up and sign in
1310
+ await client.signUp.email(
1311
+ { ...testUser, email: "org-delete-webhook-test@email.com" },
1312
+ { throw: true },
1313
+ );
1314
+ const headers = new Headers();
1315
+ await client.signIn.email(
1316
+ { ...testUser, email: "org-delete-webhook-test@email.com" },
1317
+ {
1318
+ throw: true,
1319
+ onSuccess: sessionSetter(headers),
1320
+ },
1321
+ );
1322
+
1323
+ // Create organization via client
1324
+ const org = await client.organization.create({
1325
+ name: "Delete Webhook Test Org",
1326
+ slug: "delete-webhook-test-org",
1327
+ fetchOptions: { headers },
1328
+ });
1329
+ const orgId = org.data?.id as string;
1330
+
1331
+ // Update org with stripeCustomerId
1332
+ await ctx.adapter.update({
1333
+ model: "organization",
1334
+ update: { stripeCustomerId: "cus_org_delete_123" },
1335
+ where: [{ field: "id", value: orgId }],
1336
+ });
1337
+
1338
+ // Create subscription
1339
+ const { id: subId } = await ctx.adapter.create({
1340
+ model: "subscription",
1341
+ data: {
1342
+ referenceId: orgId,
1343
+ stripeCustomerId: "cus_org_delete_123",
1344
+ stripeSubscriptionId: "sub_org_delete_123",
1345
+ status: "active",
1346
+ plan: "starter",
1347
+ },
1348
+ });
1349
+
1350
+ const mockRequest = new Request(
1351
+ "http://localhost:3000/api/auth/stripe/webhook",
1352
+ {
1353
+ method: "POST",
1354
+ headers: {
1355
+ "stripe-signature": "test_signature",
1356
+ },
1357
+ body: JSON.stringify(mockDeleteEvent),
1358
+ },
1359
+ );
1360
+
1361
+ const response = await auth.handler(mockRequest);
1362
+ expect(response.status).toBe(200);
1363
+
1364
+ // Verify subscription was marked as canceled
1365
+ const deletedSubscription = await ctx.adapter.findOne<Subscription>({
1366
+ model: "subscription",
1367
+ where: [{ field: "id", value: subId }],
1368
+ });
1369
+
1370
+ expect(deletedSubscription?.status).toBe("canceled");
1371
+ expect(deletedSubscription?.canceledAt).toBeDefined();
1372
+ expect(deletedSubscription?.endedAt).toBeDefined();
1373
+
1374
+ // Verify callback was called
1375
+ expect(onSubscriptionDeleted).toHaveBeenCalledWith(
1376
+ expect.objectContaining({
1377
+ subscription: expect.objectContaining({
1378
+ referenceId: orgId,
1379
+ }),
1380
+ stripeSubscription: expect.objectContaining({
1381
+ id: "sub_org_delete_123",
1382
+ }),
1383
+ }),
1384
+ );
1385
+ });
1386
+
1387
+ it("should return ORGANIZATION_NOT_FOUND when upgrading for non-existent organization", async () => {
1388
+ const { client, sessionSetter } = await getTestInstance(
1389
+ {
1390
+ plugins: [organization(), stripe(baseOrgStripeOptions)],
1391
+ },
1392
+ {
1393
+ disableTestUser: true,
1394
+ clientOptions: {
1395
+ plugins: [organizationClient(), stripeClient({ subscription: true })],
1396
+ },
1397
+ },
1398
+ );
1399
+
1400
+ await client.signUp.email(
1401
+ { ...testUser, email: "org-not-found-test@email.com" },
1402
+ { throw: true },
1403
+ );
1404
+
1405
+ const headers = new Headers();
1406
+ await client.signIn.email(
1407
+ { ...testUser, email: "org-not-found-test@email.com" },
1408
+ {
1409
+ throw: true,
1410
+ onSuccess: sessionSetter(headers),
1411
+ },
1412
+ );
1413
+
1414
+ // Try to upgrade subscription for non-existent organization
1415
+ const res = await client.subscription.upgrade({
1416
+ plan: "starter",
1417
+ customerType: "organization",
1418
+ referenceId: "non_existent_org_id",
1419
+ fetchOptions: { headers },
1420
+ });
1421
+
1422
+ expect(res.error?.code).toBe("ORGANIZATION_NOT_FOUND");
1423
+ });
1424
+
1425
+ it("should return error when Stripe customer creation fails for organization", async () => {
1426
+ const mockStripeOrgFail = {
1427
+ ...mockStripeOrg,
1428
+ customers: {
1429
+ ...mockStripeOrg.customers,
1430
+ create: vi.fn().mockRejectedValue(new Error("Stripe API error")),
1431
+ },
1432
+ };
1433
+
1434
+ const stripeOptionsWithFailingStripe: StripeOptions = {
1435
+ ...baseOrgStripeOptions,
1436
+ stripeClient: mockStripeOrgFail as unknown as Stripe,
1437
+ };
1438
+
1439
+ const { client, sessionSetter } = await getTestInstance(
1440
+ {
1441
+ plugins: [organization(), stripe(stripeOptionsWithFailingStripe)],
1442
+ },
1443
+ {
1444
+ disableTestUser: true,
1445
+ clientOptions: {
1446
+ plugins: [organizationClient(), stripeClient({ subscription: true })],
1447
+ },
1448
+ },
1449
+ );
1450
+
1451
+ await client.signUp.email(
1452
+ { ...testUser, email: "stripe-fail-test@email.com" },
1453
+ { throw: true },
1454
+ );
1455
+
1456
+ const headers = new Headers();
1457
+ await client.signIn.email(
1458
+ { ...testUser, email: "stripe-fail-test@email.com" },
1459
+ {
1460
+ throw: true,
1461
+ onSuccess: sessionSetter(headers),
1462
+ },
1463
+ );
1464
+
1465
+ // Create organization without stripeCustomerId
1466
+ const org = await client.organization.create({
1467
+ name: "Stripe Fail Test Org",
1468
+ slug: "stripe-fail-test-org",
1469
+ fetchOptions: { headers },
1470
+ });
1471
+ const orgId = org.data?.id as string;
1472
+
1473
+ const res = await client.subscription.upgrade({
1474
+ plan: "starter",
1475
+ customerType: "organization",
1476
+ referenceId: orgId,
1477
+ fetchOptions: { headers },
1478
+ });
1479
+
1480
+ expect(res.error?.code).toBe("UNABLE_TO_CREATE_CUSTOMER");
1481
+ });
1482
+
1483
+ it("should return error when getCustomerCreateParams callback throws", async () => {
1484
+ const stripeOptionsWithThrowingCallback: StripeOptions = {
1485
+ ...baseOrgStripeOptions,
1486
+ organization: {
1487
+ ...baseOrgStripeOptions.organization,
1488
+ getCustomerCreateParams: async () => {
1489
+ throw new Error("Callback error");
1490
+ },
1491
+ },
1492
+ };
1493
+
1494
+ const { client, sessionSetter } = await getTestInstance(
1495
+ {
1496
+ plugins: [organization(), stripe(stripeOptionsWithThrowingCallback)],
1497
+ },
1498
+ {
1499
+ disableTestUser: true,
1500
+ clientOptions: {
1501
+ plugins: [organizationClient(), stripeClient({ subscription: true })],
1502
+ },
1503
+ },
1504
+ );
1505
+ await client.signUp.email(
1506
+ { ...testUser, email: "callback-throw-test@email.com" },
1507
+ { throw: true },
1508
+ );
1509
+
1510
+ const headers = new Headers();
1511
+ await client.signIn.email(
1512
+ { ...testUser, email: "callback-throw-test@email.com" },
1513
+ {
1514
+ throw: true,
1515
+ onSuccess: sessionSetter(headers),
1516
+ },
1517
+ );
1518
+
1519
+ // Create organization without stripeCustomerId
1520
+ const org = await client.organization.create({
1521
+ name: "Callback Throw Test Org",
1522
+ slug: "callback-throw-test-org",
1523
+ fetchOptions: { headers },
1524
+ });
1525
+ const orgId = org.data?.id as string;
1526
+
1527
+ const res = await client.subscription.upgrade({
1528
+ plan: "starter",
1529
+ customerType: "organization",
1530
+ referenceId: orgId,
1531
+ fetchOptions: { headers },
1532
+ });
1533
+
1534
+ expect(res.error?.code).toBe("UNABLE_TO_CREATE_CUSTOMER");
1535
+ });
1536
+
1537
+ it("should call onSubscriptionCreated callback for organization subscription from dashboard", async () => {
1538
+ const onSubscriptionCreated = vi.fn();
1539
+
1540
+ const mockCreateEvent = {
1541
+ type: "customer.subscription.created",
1542
+ data: {
1543
+ object: {
1544
+ id: "sub_org_created_callback_123",
1545
+ customer: "cus_org_created_callback_123",
1546
+ status: "active",
1547
+ items: {
1548
+ data: [
1549
+ {
1550
+ price: {
1551
+ id: process.env.STRIPE_PRICE_ID_1,
1552
+ lookup_key: null,
1553
+ },
1554
+ quantity: 5,
1555
+ current_period_start: Math.floor(Date.now() / 1000),
1556
+ current_period_end:
1557
+ Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1558
+ },
1559
+ ],
1560
+ },
1561
+ cancel_at_period_end: false,
1562
+ trial_start: null,
1563
+ trial_end: null,
1564
+ },
1565
+ },
1566
+ };
1567
+
1568
+ const stripeForCreatedCallback = {
1569
+ ...mockStripeOrg,
1570
+ webhooks: {
1571
+ constructEventAsync: vi.fn().mockResolvedValue(mockCreateEvent),
1572
+ },
1573
+ };
1574
+
1575
+ const testCreatedCallbackOptions: StripeOptions = {
1576
+ ...baseOrgStripeOptions,
1577
+ stripeClient: stripeForCreatedCallback as unknown as Stripe,
1578
+ stripeWebhookSecret: "test_secret",
1579
+ subscription: {
1580
+ ...baseOrgStripeOptions.subscription,
1581
+ onSubscriptionCreated,
1582
+ },
1583
+ };
1584
+
1585
+ const { auth, client, sessionSetter } = await getTestInstance(
1586
+ {
1587
+ plugins: [organization(), stripe(testCreatedCallbackOptions)],
1588
+ },
1589
+ {
1590
+ disableTestUser: true,
1591
+ clientOptions: {
1592
+ plugins: [organizationClient(), stripeClient({ subscription: true })],
1593
+ },
1594
+ },
1595
+ );
1596
+ const ctx = await auth.$context;
1597
+
1598
+ // Sign up and sign in
1599
+ await client.signUp.email(
1600
+ { ...testUser, email: "org-created-callback-test@email.com" },
1601
+ { throw: true },
1602
+ );
1603
+ const headers = new Headers();
1604
+ await client.signIn.email(
1605
+ { ...testUser, email: "org-created-callback-test@email.com" },
1606
+ {
1607
+ throw: true,
1608
+ onSuccess: sessionSetter(headers),
1609
+ },
1610
+ );
1611
+
1612
+ // Create organization with stripeCustomerId
1613
+ const org = await client.organization.create({
1614
+ name: "Created Callback Test Org",
1615
+ slug: "created-callback-test-org",
1616
+ fetchOptions: { headers },
1617
+ });
1618
+ const orgId = org.data?.id as string;
1619
+
1620
+ // Update org with stripeCustomerId
1621
+ await ctx.adapter.update({
1622
+ model: "organization",
1623
+ update: { stripeCustomerId: "cus_org_created_callback_123" },
1624
+ where: [{ field: "id", value: orgId }],
1625
+ });
1626
+
1627
+ const mockRequest = new Request(
1628
+ "http://localhost:3000/api/auth/stripe/webhook",
1629
+ {
1630
+ method: "POST",
1631
+ headers: {
1632
+ "stripe-signature": "test_signature",
1633
+ },
1634
+ body: JSON.stringify(mockCreateEvent),
1635
+ },
1636
+ );
1637
+
1638
+ const response = await auth.handler(mockRequest);
1639
+ expect(response.status).toBe(200);
1640
+
1641
+ // Verify subscription was created
1642
+ const subscription = await ctx.adapter.findOne<Subscription>({
1643
+ model: "subscription",
1644
+ where: [
1645
+ {
1646
+ field: "stripeSubscriptionId",
1647
+ value: "sub_org_created_callback_123",
1648
+ },
1649
+ ],
1650
+ });
1651
+
1652
+ expect(subscription).toBeDefined();
1653
+ expect(subscription?.referenceId).toBe(orgId);
1654
+
1655
+ // Verify callback was called
1656
+ expect(onSubscriptionCreated).toHaveBeenCalledWith(
1657
+ expect.objectContaining({
1658
+ subscription: expect.objectContaining({
1659
+ referenceId: orgId,
1660
+ plan: "starter",
1661
+ }),
1662
+ stripeSubscription: expect.objectContaining({
1663
+ id: "sub_org_created_callback_123",
1664
+ }),
1665
+ plan: expect.objectContaining({
1666
+ name: "starter",
1667
+ }),
1668
+ }),
1669
+ );
1670
+ });
1671
+ });
1672
+
1673
+ describe("stripe - organizationHooks integration", () => {
1674
+ const mockStripeHooks = {
1675
+ prices: {
1676
+ list: vi.fn().mockResolvedValue({ data: [{ id: "price_lookup_123" }] }),
1677
+ },
1678
+ customers: {
1679
+ create: vi.fn().mockResolvedValue({ id: "cus_org_hooks_123" }),
1680
+ list: vi.fn().mockResolvedValue({ data: [] }),
1681
+ retrieve: vi.fn().mockResolvedValue({
1682
+ id: "cus_org_hooks_123",
1683
+ name: "Old Org Name",
1684
+ deleted: false,
1685
+ }),
1686
+ update: vi.fn().mockResolvedValue({
1687
+ id: "cus_org_hooks_123",
1688
+ name: "Updated Org Name",
1689
+ }),
1690
+ del: vi
1691
+ .fn()
1692
+ .mockResolvedValue({ id: "cus_org_hooks_123", deleted: true }),
1693
+ },
1694
+ checkout: {
1695
+ sessions: {
1696
+ create: vi.fn().mockResolvedValue({
1697
+ url: "https://checkout.stripe.com/mock",
1698
+ id: "cs_mock123",
1699
+ }),
1700
+ },
1701
+ },
1702
+ billingPortal: {
1703
+ sessions: {
1704
+ create: vi
1705
+ .fn()
1706
+ .mockResolvedValue({ url: "https://billing.stripe.com/mock" }),
1707
+ },
1708
+ },
1709
+ subscriptions: {
1710
+ retrieve: vi.fn(),
1711
+ list: vi.fn().mockResolvedValue({ data: [] }),
1712
+ update: vi.fn(),
1713
+ },
1714
+ webhooks: {
1715
+ constructEventAsync: vi.fn(),
1716
+ },
1717
+ };
1718
+
1719
+ const baseHooksStripeOptions: StripeOptions = {
1720
+ stripeClient: mockStripeHooks as unknown as Stripe,
1721
+ stripeWebhookSecret: "test_secret",
1722
+ createCustomerOnSignUp: false,
1723
+ organization: {
1724
+ enabled: true,
1725
+ },
1726
+ subscription: {
1727
+ enabled: true,
1728
+ plans: [
1729
+ {
1730
+ priceId: "price_starter_123",
1731
+ name: "starter",
1732
+ },
1733
+ ],
1734
+ authorizeReference: async () => true,
1735
+ },
1736
+ };
1737
+
1738
+ beforeEach(() => {
1739
+ vi.resetAllMocks();
1740
+ mockStripeHooks.subscriptions.list.mockResolvedValue({ data: [] });
1741
+ mockStripeHooks.customers.list.mockResolvedValue({ data: [] });
1742
+ });
1743
+
1744
+ it("should sync organization name to Stripe customer on update", async () => {
1745
+ const { client, sessionSetter, auth } = await getTestInstance(
1746
+ {
1747
+ plugins: [organization(), stripe(baseHooksStripeOptions)],
1748
+ },
1749
+ {
1750
+ disableTestUser: true,
1751
+ clientOptions: {
1752
+ plugins: [organizationClient(), stripeClient({ subscription: true })],
1753
+ },
1754
+ },
1755
+ );
1756
+
1757
+ // Sign up and sign in
1758
+ await client.signUp.email({
1759
+ email: "org-hook-test@example.com",
1760
+ password: "password123",
1761
+ name: "Org Hook Test User",
1762
+ });
1763
+ const headers = new Headers();
1764
+ await client.signIn.email(
1765
+ {
1766
+ email: "org-hook-test@example.com",
1767
+ password: "password123",
1768
+ },
1769
+ {
1770
+ onSuccess: sessionSetter(headers),
1771
+ },
1772
+ );
1773
+
1774
+ // Create organization via client
1775
+ const org = await client.organization.create({
1776
+ name: "Old Org Name",
1777
+ slug: "sync-test-org",
1778
+ fetchOptions: { headers },
1779
+ });
1780
+ const orgId = org.data?.id as string;
1781
+ expect(orgId).toBeDefined();
1782
+
1783
+ // Set stripeCustomerId on organization
1784
+ const ctx = await auth.$context;
1785
+ await ctx.adapter.update({
1786
+ model: "organization",
1787
+ update: { stripeCustomerId: "cus_sync_test_123" },
1788
+ where: [{ field: "id", value: orgId }],
1789
+ });
1790
+
1791
+ // Mock Stripe customer retrieve to return old name
1792
+ mockStripeHooks.customers.retrieve.mockResolvedValueOnce({
1793
+ id: "cus_sync_test_123",
1794
+ name: "Old Org Name",
1795
+ deleted: false,
1796
+ });
1797
+
1798
+ // Update organization name
1799
+ const updateResult = await client.organization.update({
1800
+ organizationId: orgId,
1801
+ data: { name: "New Org Name" },
1802
+ fetchOptions: { headers },
1803
+ });
1804
+ expect(updateResult.error).toBeNull();
1805
+
1806
+ // Verify Stripe customer was updated with new name
1807
+ expect(mockStripeHooks.customers.update).toHaveBeenCalledWith(
1808
+ "cus_sync_test_123",
1809
+ expect.objectContaining({ name: "New Org Name" }),
1810
+ );
1811
+ });
1812
+
1813
+ it("should block organization deletion when active subscription exists", async () => {
1814
+ const { client, sessionSetter, auth } = await getTestInstance(
1815
+ {
1816
+ plugins: [organization(), stripe(baseHooksStripeOptions)],
1817
+ },
1818
+ {
1819
+ disableTestUser: true,
1820
+ clientOptions: {
1821
+ plugins: [organizationClient(), stripeClient({ subscription: true })],
1822
+ },
1823
+ },
1824
+ );
1825
+
1826
+ // Sign up and sign in
1827
+ await client.signUp.email({
1828
+ email: "org-block-test@example.com",
1829
+ password: "password123",
1830
+ name: "Block Test User",
1831
+ });
1832
+ const headers = new Headers();
1833
+ await client.signIn.email(
1834
+ {
1835
+ email: "org-block-test@example.com",
1836
+ password: "password123",
1837
+ },
1838
+ {
1839
+ onSuccess: sessionSetter(headers),
1840
+ },
1841
+ );
1842
+
1843
+ // Create organization via client
1844
+ const org = await client.organization.create({
1845
+ name: "Delete Block Test Org",
1846
+ slug: "delete-block-test-org",
1847
+ fetchOptions: { headers },
1848
+ });
1849
+ const orgId = org.data?.id as string;
1850
+
1851
+ // Update org with stripeCustomerId via adapter
1852
+ const ctx = await auth.$context;
1853
+ await ctx.adapter.update({
1854
+ model: "organization",
1855
+ update: { stripeCustomerId: "cus_delete_block_123" },
1856
+ where: [{ field: "id", value: orgId }],
1857
+ });
1858
+
1859
+ // Create active subscription for the org in DB
1860
+ await ctx.adapter.create({
1861
+ model: "subscription",
1862
+ data: {
1863
+ referenceId: orgId,
1864
+ stripeCustomerId: "cus_delete_block_123",
1865
+ stripeSubscriptionId: "sub_active_123",
1866
+ status: "active",
1867
+ plan: "starter",
1868
+ },
1869
+ });
1870
+
1871
+ // Mock Stripe API to return an active subscription
1872
+ mockStripeHooks.subscriptions.list.mockResolvedValueOnce({
1873
+ data: [
1874
+ {
1875
+ id: "sub_active_123",
1876
+ status: "active",
1877
+ customer: "cus_delete_block_123",
1878
+ },
1879
+ ],
1880
+ });
1881
+
1882
+ // Attempt to delete the organization
1883
+ const deleteResult = await client.organization.delete({
1884
+ organizationId: orgId,
1885
+ fetchOptions: { headers },
1886
+ });
1887
+
1888
+ // Verify deletion was blocked with expected error
1889
+ expect(deleteResult.error).toBeDefined();
1890
+ expect(deleteResult.error?.code).toBe(
1891
+ "ORGANIZATION_HAS_ACTIVE_SUBSCRIPTION",
1892
+ );
1893
+
1894
+ // Verify organization still exists
1895
+ const orgAfterDelete = await ctx.adapter.findOne({
1896
+ model: "organization",
1897
+ where: [{ field: "id", value: orgId }],
1898
+ });
1899
+ expect(orgAfterDelete).not.toBeNull();
1900
+ });
1901
+
1902
+ it("should allow organization deletion when no active subscription", async () => {
1903
+ const { client, sessionSetter, auth } = await getTestInstance(
1904
+ {
1905
+ plugins: [organization(), stripe(baseHooksStripeOptions)],
1906
+ },
1907
+ {
1908
+ disableTestUser: true,
1909
+ clientOptions: {
1910
+ plugins: [organizationClient(), stripeClient({ subscription: true })],
1911
+ },
1912
+ },
1913
+ );
1914
+
1915
+ // Sign up and sign in
1916
+ await client.signUp.email({
1917
+ email: "org-allow-test@example.com",
1918
+ password: "password123",
1919
+ name: "Allow Test User",
1920
+ });
1921
+ const headers = new Headers();
1922
+ await client.signIn.email(
1923
+ {
1924
+ email: "org-allow-test@example.com",
1925
+ password: "password123",
1926
+ },
1927
+ {
1928
+ onSuccess: sessionSetter(headers),
1929
+ },
1930
+ );
1931
+
1932
+ // Create organization via client
1933
+ const org = await client.organization.create({
1934
+ name: "Delete Allow Test Org",
1935
+ slug: "delete-allow-test-org",
1936
+ fetchOptions: { headers },
1937
+ });
1938
+ const orgId = org.data?.id as string;
1939
+
1940
+ // Update org with stripeCustomerId via adapter
1941
+ const ctx = await auth.$context;
1942
+ await ctx.adapter.update({
1943
+ model: "organization",
1944
+ update: { stripeCustomerId: "cus_delete_allow_123" },
1945
+ where: [{ field: "id", value: orgId }],
1946
+ });
1947
+
1948
+ // Create canceled subscription for the org
1949
+ await ctx.adapter.create({
1950
+ model: "subscription",
1951
+ data: {
1952
+ referenceId: orgId,
1953
+ stripeCustomerId: "cus_delete_allow_123",
1954
+ stripeSubscriptionId: "sub_canceled_123",
1955
+ status: "canceled",
1956
+ plan: "starter",
1957
+ },
1958
+ });
1959
+
1960
+ // Verify the subscription is canceled in DB
1961
+ const subscription = await ctx.adapter.findOne<Subscription>({
1962
+ model: "subscription",
1963
+ where: [{ field: "referenceId", value: orgId }],
1964
+ });
1965
+ expect(subscription).toBeDefined();
1966
+ expect(subscription?.status).toBe("canceled");
1967
+
1968
+ // Mock Stripe API to return only canceled subscriptions
1969
+ mockStripeHooks.subscriptions.list.mockResolvedValueOnce({
1970
+ data: [
1971
+ {
1972
+ id: "sub_canceled_123",
1973
+ status: "canceled",
1974
+ customer: "cus_delete_allow_123",
1975
+ },
1976
+ ],
1977
+ });
1978
+
1979
+ // Actually delete the organization and verify it succeeds
1980
+ const deleteResult = await client.organization.delete({
1981
+ organizationId: orgId,
1982
+ fetchOptions: { headers },
1983
+ });
1984
+ expect(deleteResult.error).toBeNull();
1985
+
1986
+ // Verify organization is deleted
1987
+ const deletedOrg = await ctx.adapter.findOne({
1988
+ model: "organization",
1989
+ where: [{ field: "id", value: orgId }],
1990
+ });
1991
+ expect(deletedOrg).toBeNull();
1992
+ });
1993
+ });