@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.
package/src/routes.ts DELETED
@@ -1,1681 +0,0 @@
1
- import { createAuthEndpoint } from "@better-auth/core/api";
2
- import { APIError } from "@better-auth/core/error";
3
- import type { GenericEndpointContext, User } from "better-auth";
4
- import { HIDE_METADATA } from "better-auth";
5
- import { getSessionFromCtx, originCheck } from "better-auth/api";
6
- import type { Organization } from "better-auth/plugins/organization";
7
- import { defu } from "defu";
8
- import type Stripe from "stripe";
9
- import type { Stripe as StripeType } from "stripe";
10
- import * as z from "zod/v4";
11
- import { STRIPE_ERROR_CODES } from "./error-codes";
12
- import {
13
- onCheckoutSessionCompleted,
14
- onSubscriptionCreated,
15
- onSubscriptionDeleted,
16
- onSubscriptionUpdated,
17
- } from "./hooks";
18
- import { referenceMiddleware, stripeSessionMiddleware } from "./middleware";
19
- import type {
20
- CustomerType,
21
- StripeCtxSession,
22
- StripeOptions,
23
- Subscription,
24
- SubscriptionOptions,
25
- WithStripeCustomerId,
26
- } from "./types";
27
- import {
28
- escapeStripeSearchValue,
29
- getPlanByName,
30
- getPlanByPriceInfo,
31
- getPlans,
32
- isActiveOrTrialing,
33
- isPendingCancel,
34
- isStripePendingCancel,
35
- } from "./utils";
36
-
37
- /**
38
- * Converts a relative URL to an absolute URL using baseURL.
39
- * @internal
40
- */
41
- function getUrl(ctx: GenericEndpointContext, url: string) {
42
- if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(url)) {
43
- return url;
44
- }
45
- return `${ctx.context.options.baseURL}${
46
- url.startsWith("/") ? url : `/${url}`
47
- }`;
48
- }
49
-
50
- /**
51
- * Resolves a Stripe price ID from a lookup key.
52
- * @internal
53
- */
54
- async function resolvePriceIdFromLookupKey(
55
- stripeClient: Stripe,
56
- lookupKey: string,
57
- ): Promise<string | undefined> {
58
- if (!lookupKey) return undefined;
59
- const prices = await stripeClient.prices.list({
60
- lookup_keys: [lookupKey],
61
- active: true,
62
- limit: 1,
63
- });
64
- return prices.data[0]?.id;
65
- }
66
-
67
- /**
68
- * Determines the reference ID based on customer type.
69
- * - `user` (default): uses userId
70
- * - `organization`: uses activeOrganizationId from session
71
- * @internal
72
- */
73
- function getReferenceId(
74
- ctxSession: StripeCtxSession,
75
- customerType: CustomerType | undefined,
76
- options: StripeOptions,
77
- ): string {
78
- const { user, session } = ctxSession;
79
- const type = customerType || "user";
80
-
81
- if (type === "organization") {
82
- if (!options.organization?.enabled) {
83
- throw APIError.from(
84
- "BAD_REQUEST",
85
- STRIPE_ERROR_CODES.ORGANIZATION_SUBSCRIPTION_NOT_ENABLED,
86
- );
87
- }
88
-
89
- if (!session.activeOrganizationId) {
90
- throw APIError.from(
91
- "BAD_REQUEST",
92
- STRIPE_ERROR_CODES.ORGANIZATION_NOT_FOUND,
93
- );
94
- }
95
- return session.activeOrganizationId;
96
- }
97
-
98
- return user.id;
99
- }
100
-
101
- const upgradeSubscriptionBodySchema = z.object({
102
- /**
103
- * The name of the plan to subscribe
104
- */
105
- plan: z.string().meta({
106
- description: 'The name of the plan to upgrade to. Eg: "pro"',
107
- }),
108
- /**
109
- * If annual plan should be applied.
110
- */
111
- annual: z
112
- .boolean()
113
- .meta({
114
- description: "Whether to upgrade to an annual plan. Eg: true",
115
- })
116
- .optional(),
117
- /**
118
- * Reference ID for the subscription based on customerType:
119
- * - `user`: defaults to `user.id`
120
- * - `organization`: defaults to `session.activeOrganizationId`
121
- */
122
- referenceId: z
123
- .string()
124
- .meta({
125
- description: 'Reference ID for the subscription. Eg: "org_123"',
126
- })
127
- .optional(),
128
- /**
129
- * The Stripe subscription ID to upgrade.
130
- * If provided and not found, it'll throw an error.
131
- */
132
- subscriptionId: z
133
- .string()
134
- .meta({
135
- description:
136
- 'The Stripe subscription ID to upgrade. Eg: "sub_1ABC2DEF3GHI4JKL"',
137
- })
138
- .optional(),
139
- /**
140
- * Customer type for the subscription.
141
- * - `user`: User owns the subscription (default)
142
- * - `organization`: Organization owns the subscription (requires referenceId)
143
- */
144
- customerType: z
145
- .enum(["user", "organization"])
146
- .meta({
147
- description:
148
- 'Customer type for the subscription. Eg: "user" or "organization"',
149
- })
150
- .optional(),
151
- /**
152
- * Additional metadata to store with the subscription.
153
- */
154
- metadata: z.record(z.string(), z.any()).optional(),
155
- /**
156
- * Number of seats for subscriptions.
157
- */
158
- seats: z
159
- .number()
160
- .meta({
161
- description: "Number of seats to upgrade to (if applicable). Eg: 1",
162
- })
163
- .optional(),
164
- /**
165
- * The IETF language tag of the locale Checkout is displayed in.
166
- * If not provided or set to `auto`, the browser's locale is used.
167
- */
168
- locale: z
169
- .custom<StripeType.Checkout.Session.Locale>((localization) => {
170
- return typeof localization === "string";
171
- })
172
- .meta({
173
- description:
174
- "The locale to display Checkout in. Eg: 'en', 'ko'. If not provided or set to `auto`, the browser's locale is used.",
175
- })
176
- .optional(),
177
- /**
178
- * The URL to which Stripe should send customers when payment or setup is complete.
179
- */
180
- successUrl: z
181
- .string()
182
- .meta({
183
- description:
184
- 'Callback URL to redirect back after successful subscription. Eg: "https://example.com/success"',
185
- })
186
- .default("/"),
187
- /**
188
- * If set, checkout shows a back button and customers will be directed here if they cancel payment.
189
- */
190
- cancelUrl: z
191
- .string()
192
- .meta({
193
- description:
194
- 'If set, checkout shows a back button and customers will be directed here if they cancel payment. Eg: "https://example.com/pricing"',
195
- })
196
- .default("/"),
197
- /**
198
- * The URL to return to from the Billing Portal (used when upgrading existing subscriptions)
199
- */
200
- returnUrl: z
201
- .string()
202
- .meta({
203
- description:
204
- 'URL to take customers to when they click on the billing portal’s link to return to your website. Eg: "https://example.com/dashboard"',
205
- })
206
- .optional(),
207
- /**
208
- * Disable Redirect
209
- */
210
- disableRedirect: z
211
- .boolean()
212
- .meta({
213
- description: "Disable redirect after successful subscription. Eg: true",
214
- })
215
- .default(false),
216
- });
217
-
218
- /**
219
- * ### Endpoint
220
- *
221
- * POST `/subscription/upgrade`
222
- *
223
- * ### API Methods
224
- *
225
- * **server:**
226
- * `auth.api.upgradeSubscription`
227
- *
228
- * **client:**
229
- * `authClient.subscription.upgrade`
230
- *
231
- * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/stripe#api-method-subscription-upgrade)
232
- */
233
- export const upgradeSubscription = (options: StripeOptions) => {
234
- const client = options.stripeClient;
235
- const subscriptionOptions = options.subscription as SubscriptionOptions;
236
-
237
- return createAuthEndpoint(
238
- "/subscription/upgrade",
239
- {
240
- method: "POST",
241
- body: upgradeSubscriptionBodySchema,
242
- metadata: {
243
- openapi: {
244
- operationId: "upgradeSubscription",
245
- },
246
- },
247
- use: [
248
- stripeSessionMiddleware,
249
- referenceMiddleware(subscriptionOptions, "upgrade-subscription"),
250
- originCheck((c) => {
251
- return [c.body.successUrl as string, c.body.cancelUrl as string];
252
- }),
253
- ],
254
- },
255
- async (ctx) => {
256
- const { user, session } = ctx.context.session;
257
- const customerType = ctx.body.customerType || "user";
258
- const referenceId =
259
- ctx.body.referenceId ||
260
- getReferenceId(ctx.context.session, customerType, options);
261
-
262
- if (!user.emailVerified && subscriptionOptions.requireEmailVerification) {
263
- throw APIError.from(
264
- "BAD_REQUEST",
265
- STRIPE_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED,
266
- );
267
- }
268
-
269
- const plan = await getPlanByName(options, ctx.body.plan);
270
- if (!plan) {
271
- throw APIError.from(
272
- "BAD_REQUEST",
273
- STRIPE_ERROR_CODES.SUBSCRIPTION_PLAN_NOT_FOUND,
274
- );
275
- }
276
-
277
- // Find existing subscription by Stripe ID or reference ID
278
- let subscriptionToUpdate = ctx.body.subscriptionId
279
- ? await ctx.context.adapter.findOne<Subscription>({
280
- model: "subscription",
281
- where: [
282
- {
283
- field: "stripeSubscriptionId",
284
- value: ctx.body.subscriptionId,
285
- },
286
- ],
287
- })
288
- : referenceId
289
- ? await ctx.context.adapter.findOne<Subscription>({
290
- model: "subscription",
291
- where: [
292
- {
293
- field: "referenceId",
294
- value: referenceId,
295
- },
296
- ],
297
- })
298
- : null;
299
-
300
- if (
301
- ctx.body.subscriptionId &&
302
- subscriptionToUpdate &&
303
- subscriptionToUpdate.referenceId !== referenceId
304
- ) {
305
- subscriptionToUpdate = null;
306
- }
307
- if (ctx.body.subscriptionId && !subscriptionToUpdate) {
308
- throw APIError.from(
309
- "BAD_REQUEST",
310
- STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
311
- );
312
- }
313
-
314
- // Determine customer id
315
- let customerId: string | undefined;
316
- if (customerType === "organization") {
317
- // Organization subscription - get customer ID from organization
318
- customerId = subscriptionToUpdate?.stripeCustomerId;
319
- if (!customerId) {
320
- const org = await ctx.context.adapter.findOne<
321
- Organization & WithStripeCustomerId
322
- >({
323
- model: "organization",
324
- where: [
325
- {
326
- field: "id",
327
- value: referenceId,
328
- },
329
- ],
330
- });
331
- if (!org) {
332
- throw APIError.from(
333
- "BAD_REQUEST",
334
- STRIPE_ERROR_CODES.ORGANIZATION_NOT_FOUND,
335
- );
336
- }
337
- customerId = org.stripeCustomerId;
338
-
339
- // If org doesn't have a customer ID, create one
340
- if (!customerId) {
341
- try {
342
- // First, search for existing organization customer by organizationId
343
- const existingOrgCustomers = await client.customers.search({
344
- query: `metadata["organizationId"]:"${org.id}"`,
345
- limit: 1,
346
- });
347
-
348
- let stripeCustomer = existingOrgCustomers.data[0];
349
-
350
- if (!stripeCustomer) {
351
- // Get custom params if provided
352
- let extraCreateParams: Partial<StripeType.CustomerCreateParams> =
353
- {};
354
- if (options.organization?.getCustomerCreateParams) {
355
- extraCreateParams =
356
- await options.organization.getCustomerCreateParams(
357
- org,
358
- ctx,
359
- );
360
- }
361
-
362
- // Create Stripe customer for organization
363
- // Email can be set via getCustomerCreateParams or updated in billing portal
364
- // Use defu to ensure internal metadata fields are preserved
365
- const customerParams: StripeType.CustomerCreateParams = defu(
366
- {
367
- name: org.name,
368
- metadata: {
369
- ...ctx.body.metadata,
370
- organizationId: org.id,
371
- customerType: "organization",
372
- },
373
- },
374
- extraCreateParams,
375
- );
376
- stripeCustomer = await client.customers.create(customerParams);
377
-
378
- // Call onCustomerCreate callback only for newly created customers
379
- await options.organization?.onCustomerCreate?.(
380
- {
381
- stripeCustomer,
382
- organization: {
383
- ...org,
384
- stripeCustomerId: stripeCustomer.id,
385
- },
386
- },
387
- ctx,
388
- );
389
- }
390
-
391
- await ctx.context.adapter.update({
392
- model: "organization",
393
- update: {
394
- stripeCustomerId: stripeCustomer.id,
395
- },
396
- where: [
397
- {
398
- field: "id",
399
- value: org.id,
400
- },
401
- ],
402
- });
403
-
404
- customerId = stripeCustomer.id;
405
- } catch (e: any) {
406
- ctx.context.logger.error(e);
407
- throw APIError.from(
408
- "BAD_REQUEST",
409
- STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER,
410
- );
411
- }
412
- }
413
- }
414
- } else {
415
- // User subscription - get customer ID from user
416
- customerId =
417
- subscriptionToUpdate?.stripeCustomerId || user.stripeCustomerId;
418
- if (!customerId) {
419
- try {
420
- // Try to find existing user Stripe customer by email
421
- const existingCustomers = await client.customers.search({
422
- query: `email:"${escapeStripeSearchValue(user.email)}" AND -metadata["customerType"]:"organization"`,
423
- limit: 1,
424
- });
425
-
426
- let stripeCustomer = existingCustomers.data[0];
427
-
428
- if (!stripeCustomer) {
429
- stripeCustomer = await client.customers.create({
430
- email: user.email,
431
- name: user.name,
432
- metadata: {
433
- ...ctx.body.metadata,
434
- userId: user.id,
435
- customerType: "user",
436
- },
437
- });
438
- }
439
-
440
- // Update local DB with Stripe customer ID
441
- await ctx.context.adapter.update({
442
- model: "user",
443
- update: {
444
- stripeCustomerId: stripeCustomer.id,
445
- },
446
- where: [
447
- {
448
- field: "id",
449
- value: user.id,
450
- },
451
- ],
452
- });
453
-
454
- customerId = stripeCustomer.id;
455
- } catch (e: any) {
456
- ctx.context.logger.error(e);
457
- throw APIError.from(
458
- "BAD_REQUEST",
459
- STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER,
460
- );
461
- }
462
- }
463
- }
464
-
465
- const subscriptions = subscriptionToUpdate
466
- ? [subscriptionToUpdate]
467
- : await ctx.context.adapter.findMany<Subscription>({
468
- model: "subscription",
469
- where: [
470
- {
471
- field: "referenceId",
472
- value: referenceId,
473
- },
474
- ],
475
- });
476
-
477
- const activeOrTrialingSubscription = subscriptions.find((sub) =>
478
- isActiveOrTrialing(sub),
479
- );
480
-
481
- const activeSubscriptions = await client.subscriptions
482
- .list({
483
- customer: customerId,
484
- })
485
- .then((res) => res.data.filter((sub) => isActiveOrTrialing(sub)));
486
-
487
- const activeSubscription = activeSubscriptions.find((sub) => {
488
- // If we have a specific subscription to update, match by ID
489
- if (
490
- subscriptionToUpdate?.stripeSubscriptionId ||
491
- ctx.body.subscriptionId
492
- ) {
493
- return (
494
- sub.id === subscriptionToUpdate?.stripeSubscriptionId ||
495
- sub.id === ctx.body.subscriptionId
496
- );
497
- }
498
- // Only find subscription for the same referenceId to avoid mixing personal and org subscriptions
499
- if (activeOrTrialingSubscription?.stripeSubscriptionId) {
500
- return sub.id === activeOrTrialingSubscription.stripeSubscriptionId;
501
- }
502
- return false;
503
- });
504
-
505
- // Also find any incomplete subscription that we can reuse
506
- const incompleteSubscription = subscriptions.find(
507
- (sub) => sub.status === "incomplete",
508
- );
509
-
510
- if (
511
- activeOrTrialingSubscription &&
512
- activeOrTrialingSubscription.status === "active" &&
513
- activeOrTrialingSubscription.plan === ctx.body.plan &&
514
- activeOrTrialingSubscription.seats === (ctx.body.seats || 1)
515
- ) {
516
- throw APIError.from(
517
- "BAD_REQUEST",
518
- STRIPE_ERROR_CODES.ALREADY_SUBSCRIBED_PLAN,
519
- );
520
- }
521
-
522
- if (activeSubscription && customerId) {
523
- // Find the corresponding database subscription for this Stripe subscription
524
- let dbSubscription = await ctx.context.adapter.findOne<Subscription>({
525
- model: "subscription",
526
- where: [
527
- {
528
- field: "stripeSubscriptionId",
529
- value: activeSubscription.id,
530
- },
531
- ],
532
- });
533
-
534
- // If no database record exists for this Stripe subscription, update the existing one
535
- if (!dbSubscription && activeOrTrialingSubscription) {
536
- await ctx.context.adapter.update<Subscription>({
537
- model: "subscription",
538
- update: {
539
- stripeSubscriptionId: activeSubscription.id,
540
- updatedAt: new Date(),
541
- },
542
- where: [
543
- {
544
- field: "id",
545
- value: activeOrTrialingSubscription.id,
546
- },
547
- ],
548
- });
549
- dbSubscription = activeOrTrialingSubscription;
550
- }
551
-
552
- // Resolve price ID if using lookup keys
553
- let priceIdToUse: string | undefined = undefined;
554
- if (ctx.body.annual) {
555
- priceIdToUse = plan.annualDiscountPriceId;
556
- if (!priceIdToUse && plan.annualDiscountLookupKey) {
557
- priceIdToUse = await resolvePriceIdFromLookupKey(
558
- client,
559
- plan.annualDiscountLookupKey,
560
- );
561
- }
562
- } else {
563
- priceIdToUse = plan.priceId;
564
- if (!priceIdToUse && plan.lookupKey) {
565
- priceIdToUse = await resolvePriceIdFromLookupKey(
566
- client,
567
- plan.lookupKey,
568
- );
569
- }
570
- }
571
-
572
- if (!priceIdToUse) {
573
- throw ctx.error("BAD_REQUEST", {
574
- message: "Price ID not found for the selected plan",
575
- });
576
- }
577
-
578
- const { url } = await client.billingPortal.sessions
579
- .create({
580
- customer: customerId,
581
- return_url: getUrl(ctx, ctx.body.returnUrl || "/"),
582
- flow_data: {
583
- type: "subscription_update_confirm",
584
- after_completion: {
585
- type: "redirect",
586
- redirect: {
587
- return_url: getUrl(ctx, ctx.body.returnUrl || "/"),
588
- },
589
- },
590
- subscription_update_confirm: {
591
- subscription: activeSubscription.id,
592
- items: [
593
- {
594
- id: activeSubscription.items.data[0]?.id as string,
595
- quantity: ctx.body.seats || 1,
596
- price: priceIdToUse,
597
- },
598
- ],
599
- },
600
- },
601
- })
602
- .catch(async (e) => {
603
- throw ctx.error("BAD_REQUEST", {
604
- message: e.message,
605
- code: e.code,
606
- });
607
- });
608
- return ctx.json({
609
- url,
610
- redirect: !ctx.body.disableRedirect,
611
- });
612
- }
613
-
614
- let subscription: Subscription | undefined =
615
- activeOrTrialingSubscription || incompleteSubscription;
616
-
617
- if (incompleteSubscription && !activeOrTrialingSubscription) {
618
- const updated = await ctx.context.adapter.update<Subscription>({
619
- model: "subscription",
620
- update: {
621
- plan: plan.name.toLowerCase(),
622
- seats: ctx.body.seats || 1,
623
- updatedAt: new Date(),
624
- },
625
- where: [
626
- {
627
- field: "id",
628
- value: incompleteSubscription.id,
629
- },
630
- ],
631
- });
632
- subscription = (updated as Subscription) || incompleteSubscription;
633
- }
634
-
635
- if (!subscription) {
636
- subscription = await ctx.context.adapter.create<Subscription>({
637
- model: "subscription",
638
- data: {
639
- plan: plan.name.toLowerCase(),
640
- stripeCustomerId: customerId,
641
- status: "incomplete",
642
- referenceId,
643
- seats: ctx.body.seats || 1,
644
- },
645
- });
646
- }
647
-
648
- if (!subscription) {
649
- ctx.context.logger.error("Subscription ID not found");
650
- throw APIError.from(
651
- "NOT_FOUND",
652
- STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
653
- );
654
- }
655
-
656
- const params = await subscriptionOptions.getCheckoutSessionParams?.(
657
- {
658
- user,
659
- session,
660
- plan,
661
- subscription,
662
- },
663
- ctx.request,
664
- ctx,
665
- );
666
-
667
- const allSubscriptions = await ctx.context.adapter.findMany<Subscription>(
668
- {
669
- model: "subscription",
670
- where: [{ field: "referenceId", value: referenceId }],
671
- },
672
- );
673
- const hasEverTrialed = allSubscriptions.some((s) => {
674
- // Check if user has ever had a trial for any plan (not just the same plan)
675
- // This prevents users from getting multiple trials by switching plans
676
- const hadTrial =
677
- !!(s.trialStart || s.trialEnd) || s.status === "trialing";
678
- return hadTrial;
679
- });
680
-
681
- const freeTrial =
682
- !hasEverTrialed && plan.freeTrial
683
- ? { trial_period_days: plan.freeTrial.days }
684
- : undefined;
685
-
686
- let priceIdToUse: string | undefined = undefined;
687
- if (ctx.body.annual) {
688
- priceIdToUse = plan.annualDiscountPriceId;
689
- if (!priceIdToUse && plan.annualDiscountLookupKey) {
690
- priceIdToUse = await resolvePriceIdFromLookupKey(
691
- client,
692
- plan.annualDiscountLookupKey,
693
- );
694
- }
695
- } else {
696
- priceIdToUse = plan.priceId;
697
- if (!priceIdToUse && plan.lookupKey) {
698
- priceIdToUse = await resolvePriceIdFromLookupKey(
699
- client,
700
- plan.lookupKey,
701
- );
702
- }
703
- }
704
- const checkoutSession = await client.checkout.sessions
705
- .create(
706
- {
707
- ...(customerId
708
- ? {
709
- customer: customerId,
710
- customer_update:
711
- customerType !== "user"
712
- ? ({ address: "auto" } as const)
713
- : ({ name: "auto", address: "auto" } as const), // The customer name is automatically set only for users
714
- }
715
- : {
716
- customer_email: user.email,
717
- }),
718
- locale: ctx.body.locale,
719
- success_url: getUrl(
720
- ctx,
721
- `${
722
- ctx.context.baseURL
723
- }/subscription/success?callbackURL=${encodeURIComponent(
724
- ctx.body.successUrl,
725
- )}&subscriptionId=${encodeURIComponent(subscription.id)}`,
726
- ),
727
- cancel_url: getUrl(ctx, ctx.body.cancelUrl),
728
- line_items: [
729
- {
730
- price: priceIdToUse,
731
- quantity: ctx.body.seats || 1,
732
- },
733
- ],
734
- subscription_data: {
735
- ...freeTrial,
736
- metadata: {
737
- ...ctx.body.metadata,
738
- ...params?.params?.subscription_data?.metadata,
739
- userId: user.id,
740
- subscriptionId: subscription.id,
741
- referenceId,
742
- },
743
- },
744
- mode: "subscription",
745
- client_reference_id: referenceId,
746
- ...params?.params,
747
- metadata: {
748
- ...ctx.body.metadata,
749
- ...params?.params?.metadata,
750
- userId: user.id,
751
- subscriptionId: subscription.id,
752
- referenceId,
753
- },
754
- },
755
- params?.options,
756
- )
757
- .catch(async (e) => {
758
- throw ctx.error("BAD_REQUEST", {
759
- message: e.message,
760
- code: e.code,
761
- });
762
- });
763
- return ctx.json({
764
- ...checkoutSession,
765
- redirect: !ctx.body.disableRedirect,
766
- });
767
- },
768
- );
769
- };
770
-
771
- const cancelSubscriptionCallbackQuerySchema = z
772
- .record(z.string(), z.any())
773
- .optional();
774
-
775
- export const cancelSubscriptionCallback = (options: StripeOptions) => {
776
- const client = options.stripeClient;
777
- const subscriptionOptions = options.subscription as SubscriptionOptions;
778
- return createAuthEndpoint(
779
- "/subscription/cancel/callback",
780
- {
781
- method: "GET",
782
- query: cancelSubscriptionCallbackQuerySchema,
783
- metadata: {
784
- openapi: {
785
- operationId: "cancelSubscriptionCallback",
786
- },
787
- },
788
- use: [originCheck((ctx) => ctx.query.callbackURL)],
789
- },
790
- async (ctx) => {
791
- if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) {
792
- throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
793
- }
794
- const session = await getSessionFromCtx<User & WithStripeCustomerId>(ctx);
795
- if (!session) {
796
- throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
797
- }
798
- const { user } = session;
799
- const { callbackURL, subscriptionId } = ctx.query;
800
-
801
- if (user?.stripeCustomerId) {
802
- try {
803
- const subscription = await ctx.context.adapter.findOne<Subscription>({
804
- model: "subscription",
805
- where: [
806
- {
807
- field: "id",
808
- value: subscriptionId,
809
- },
810
- ],
811
- });
812
- if (
813
- !subscription ||
814
- subscription.status === "canceled" ||
815
- isPendingCancel(subscription)
816
- ) {
817
- throw ctx.redirect(getUrl(ctx, callbackURL));
818
- }
819
-
820
- const stripeSubscription = await client.subscriptions.list({
821
- customer: user.stripeCustomerId,
822
- status: "active",
823
- });
824
- const currentSubscription = stripeSubscription.data.find(
825
- (sub) => sub.id === subscription.stripeSubscriptionId,
826
- );
827
-
828
- const isNewCancellation =
829
- currentSubscription &&
830
- isStripePendingCancel(currentSubscription) &&
831
- !isPendingCancel(subscription);
832
- if (isNewCancellation) {
833
- await ctx.context.adapter.update({
834
- model: "subscription",
835
- update: {
836
- status: currentSubscription?.status,
837
- cancelAtPeriodEnd:
838
- currentSubscription?.cancel_at_period_end || false,
839
- cancelAt: currentSubscription?.cancel_at
840
- ? new Date(currentSubscription.cancel_at * 1000)
841
- : null,
842
- canceledAt: currentSubscription?.canceled_at
843
- ? new Date(currentSubscription.canceled_at * 1000)
844
- : null,
845
- },
846
- where: [
847
- {
848
- field: "id",
849
- value: subscription.id,
850
- },
851
- ],
852
- });
853
- await subscriptionOptions.onSubscriptionCancel?.({
854
- subscription,
855
- cancellationDetails: currentSubscription.cancellation_details,
856
- stripeSubscription: currentSubscription,
857
- event: undefined,
858
- });
859
- }
860
- } catch (error) {
861
- ctx.context.logger.error(
862
- "Error checking subscription status from Stripe",
863
- error,
864
- );
865
- }
866
- }
867
- throw ctx.redirect(getUrl(ctx, callbackURL));
868
- },
869
- );
870
- };
871
-
872
- const cancelSubscriptionBodySchema = z.object({
873
- referenceId: z
874
- .string()
875
- .meta({
876
- description: "Reference id of the subscription to cancel. Eg: '123'",
877
- })
878
- .optional(),
879
- subscriptionId: z
880
- .string()
881
- .meta({
882
- description:
883
- "The Stripe subscription ID to cancel. Eg: 'sub_1ABC2DEF3GHI4JKL'",
884
- })
885
- .optional(),
886
- /**
887
- * Customer type for the subscription.
888
- * - `user`: User owns the subscription (default)
889
- * - `organization`: Organization owns the subscription
890
- */
891
- customerType: z
892
- .enum(["user", "organization"])
893
- .meta({
894
- description:
895
- 'Customer type for the subscription. Eg: "user" or "organization"',
896
- })
897
- .optional(),
898
- returnUrl: z.string().meta({
899
- description:
900
- 'URL to take customers to when they click on the billing portal\'s link to return to your website. Eg: "/account"',
901
- }),
902
- /**
903
- * Disable Redirect
904
- */
905
- disableRedirect: z
906
- .boolean()
907
- .meta({
908
- description:
909
- "Disable redirect after successful subscription cancellation. Eg: true",
910
- })
911
- .default(false),
912
- });
913
-
914
- /**
915
- * ### Endpoint
916
- *
917
- * POST `/subscription/cancel`
918
- *
919
- * ### API Methods
920
- *
921
- * **server:**
922
- * `auth.api.cancelSubscription`
923
- *
924
- * **client:**
925
- * `authClient.subscription.cancel`
926
- *
927
- * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/stripe#api-method-subscription-cancel)
928
- */
929
- export const cancelSubscription = (options: StripeOptions) => {
930
- const client = options.stripeClient;
931
- const subscriptionOptions = options.subscription as SubscriptionOptions;
932
- return createAuthEndpoint(
933
- "/subscription/cancel",
934
- {
935
- method: "POST",
936
- body: cancelSubscriptionBodySchema,
937
- metadata: {
938
- openapi: {
939
- operationId: "cancelSubscription",
940
- },
941
- },
942
- use: [
943
- stripeSessionMiddleware,
944
- referenceMiddleware(subscriptionOptions, "cancel-subscription"),
945
- originCheck((ctx) => ctx.body.returnUrl),
946
- ],
947
- },
948
- async (ctx) => {
949
- const customerType = ctx.body.customerType || "user";
950
- const referenceId =
951
- ctx.body.referenceId ||
952
- getReferenceId(ctx.context.session, customerType, options);
953
-
954
- let subscription = ctx.body.subscriptionId
955
- ? await ctx.context.adapter.findOne<Subscription>({
956
- model: "subscription",
957
- where: [
958
- {
959
- field: "stripeSubscriptionId",
960
- value: ctx.body.subscriptionId,
961
- },
962
- ],
963
- })
964
- : await ctx.context.adapter
965
- .findMany<Subscription>({
966
- model: "subscription",
967
- where: [{ field: "referenceId", value: referenceId }],
968
- })
969
- .then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
970
- if (
971
- ctx.body.subscriptionId &&
972
- subscription &&
973
- subscription.referenceId !== referenceId
974
- ) {
975
- subscription = undefined;
976
- }
977
-
978
- if (!subscription || !subscription.stripeCustomerId) {
979
- throw APIError.from(
980
- "BAD_REQUEST",
981
- STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
982
- );
983
- }
984
- const activeSubscriptions = await client.subscriptions
985
- .list({
986
- customer: subscription.stripeCustomerId,
987
- })
988
- .then((res) => res.data.filter((sub) => isActiveOrTrialing(sub)));
989
- if (!activeSubscriptions.length) {
990
- /**
991
- * If the subscription is not found, we need to delete the subscription
992
- * from the database. This is a rare case and should not happen.
993
- */
994
- await ctx.context.adapter.deleteMany({
995
- model: "subscription",
996
- where: [
997
- {
998
- field: "referenceId",
999
- value: referenceId,
1000
- },
1001
- ],
1002
- });
1003
- throw APIError.from(
1004
- "BAD_REQUEST",
1005
- STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
1006
- );
1007
- }
1008
- const activeSubscription = activeSubscriptions.find(
1009
- (sub) => sub.id === subscription.stripeSubscriptionId,
1010
- );
1011
- if (!activeSubscription) {
1012
- throw APIError.from(
1013
- "BAD_REQUEST",
1014
- STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
1015
- );
1016
- }
1017
- const { url } = await client.billingPortal.sessions
1018
- .create({
1019
- customer: subscription.stripeCustomerId,
1020
- return_url: getUrl(
1021
- ctx,
1022
- `${
1023
- ctx.context.baseURL
1024
- }/subscription/cancel/callback?callbackURL=${encodeURIComponent(
1025
- ctx.body?.returnUrl || "/",
1026
- )}&subscriptionId=${encodeURIComponent(subscription.id)}`,
1027
- ),
1028
- flow_data: {
1029
- type: "subscription_cancel",
1030
- subscription_cancel: {
1031
- subscription: activeSubscription.id,
1032
- },
1033
- },
1034
- })
1035
- .catch(async (e) => {
1036
- if (e.message?.includes("already set to be canceled")) {
1037
- /**
1038
- * in-case we missed the event from stripe, we sync the actual state
1039
- * this is a rare case and should not happen
1040
- */
1041
- if (!isPendingCancel(subscription)) {
1042
- const stripeSub = await client.subscriptions.retrieve(
1043
- activeSubscription.id,
1044
- );
1045
- await ctx.context.adapter.update({
1046
- model: "subscription",
1047
- update: {
1048
- cancelAtPeriodEnd: stripeSub.cancel_at_period_end,
1049
- cancelAt: stripeSub.cancel_at
1050
- ? new Date(stripeSub.cancel_at * 1000)
1051
- : null,
1052
- canceledAt: stripeSub.canceled_at
1053
- ? new Date(stripeSub.canceled_at * 1000)
1054
- : null,
1055
- },
1056
- where: [
1057
- {
1058
- field: "id",
1059
- value: subscription.id,
1060
- },
1061
- ],
1062
- });
1063
- }
1064
- }
1065
- throw ctx.error("BAD_REQUEST", {
1066
- message: e.message,
1067
- code: e.code,
1068
- });
1069
- });
1070
- return ctx.json({
1071
- url,
1072
- redirect: !ctx.body.disableRedirect,
1073
- });
1074
- },
1075
- );
1076
- };
1077
-
1078
- const restoreSubscriptionBodySchema = z.object({
1079
- referenceId: z
1080
- .string()
1081
- .meta({
1082
- description: "Reference id of the subscription to restore. Eg: '123'",
1083
- })
1084
- .optional(),
1085
- subscriptionId: z
1086
- .string()
1087
- .meta({
1088
- description:
1089
- "The Stripe subscription ID to restore. Eg: 'sub_1ABC2DEF3GHI4JKL'",
1090
- })
1091
- .optional(),
1092
- /**
1093
- * Customer type for the subscription.
1094
- * - `user`: User owns the subscription (default)
1095
- * - `organization`: Organization owns the subscription
1096
- */
1097
- customerType: z
1098
- .enum(["user", "organization"])
1099
- .meta({
1100
- description:
1101
- 'Customer type for the subscription. Eg: "user" or "organization"',
1102
- })
1103
- .optional(),
1104
- });
1105
-
1106
- export const restoreSubscription = (options: StripeOptions) => {
1107
- const client = options.stripeClient;
1108
- const subscriptionOptions = options.subscription as SubscriptionOptions;
1109
- return createAuthEndpoint(
1110
- "/subscription/restore",
1111
- {
1112
- method: "POST",
1113
- body: restoreSubscriptionBodySchema,
1114
- metadata: {
1115
- openapi: {
1116
- operationId: "restoreSubscription",
1117
- },
1118
- },
1119
- use: [
1120
- stripeSessionMiddleware,
1121
- referenceMiddleware(subscriptionOptions, "restore-subscription"),
1122
- ],
1123
- },
1124
- async (ctx) => {
1125
- const customerType = ctx.body.customerType || "user";
1126
- const referenceId =
1127
- ctx.body.referenceId ||
1128
- getReferenceId(ctx.context.session, customerType, options);
1129
-
1130
- let subscription = ctx.body.subscriptionId
1131
- ? await ctx.context.adapter.findOne<Subscription>({
1132
- model: "subscription",
1133
- where: [
1134
- {
1135
- field: "stripeSubscriptionId",
1136
- value: ctx.body.subscriptionId,
1137
- },
1138
- ],
1139
- })
1140
- : await ctx.context.adapter
1141
- .findMany<Subscription>({
1142
- model: "subscription",
1143
- where: [
1144
- {
1145
- field: "referenceId",
1146
- value: referenceId,
1147
- },
1148
- ],
1149
- })
1150
- .then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
1151
- if (
1152
- ctx.body.subscriptionId &&
1153
- subscription &&
1154
- subscription.referenceId !== referenceId
1155
- ) {
1156
- subscription = undefined;
1157
- }
1158
- if (!subscription || !subscription.stripeCustomerId) {
1159
- throw APIError.from(
1160
- "BAD_REQUEST",
1161
- STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
1162
- );
1163
- }
1164
- if (!isActiveOrTrialing(subscription)) {
1165
- throw APIError.from(
1166
- "BAD_REQUEST",
1167
- STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_ACTIVE,
1168
- );
1169
- }
1170
- if (!isPendingCancel(subscription)) {
1171
- throw APIError.from(
1172
- "BAD_REQUEST",
1173
- STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION,
1174
- );
1175
- }
1176
-
1177
- const activeSubscription = await client.subscriptions
1178
- .list({
1179
- customer: subscription.stripeCustomerId,
1180
- })
1181
- .then((res) => res.data.filter((sub) => isActiveOrTrialing(sub))[0]);
1182
- if (!activeSubscription) {
1183
- throw APIError.from(
1184
- "BAD_REQUEST",
1185
- STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
1186
- );
1187
- }
1188
-
1189
- // Clear scheduled cancellation based on Stripe subscription state
1190
- // Note: Stripe doesn't accept both `cancel_at` and `cancel_at_period_end` simultaneously
1191
- const updateParams: Stripe.SubscriptionUpdateParams = {};
1192
- if (activeSubscription.cancel_at) {
1193
- updateParams.cancel_at = "";
1194
- } else if (activeSubscription.cancel_at_period_end) {
1195
- updateParams.cancel_at_period_end = false;
1196
- }
1197
-
1198
- const newSub = await client.subscriptions
1199
- .update(activeSubscription.id, updateParams)
1200
- .catch((e) => {
1201
- throw ctx.error("BAD_REQUEST", {
1202
- message: e.message,
1203
- code: e.code,
1204
- });
1205
- });
1206
-
1207
- await ctx.context.adapter.update({
1208
- model: "subscription",
1209
- update: {
1210
- cancelAtPeriodEnd: false,
1211
- cancelAt: null,
1212
- canceledAt: null,
1213
- updatedAt: new Date(),
1214
- },
1215
- where: [
1216
- {
1217
- field: "id",
1218
- value: subscription.id,
1219
- },
1220
- ],
1221
- });
1222
-
1223
- return ctx.json(newSub);
1224
- },
1225
- );
1226
- };
1227
-
1228
- const listActiveSubscriptionsQuerySchema = z.optional(
1229
- z.object({
1230
- referenceId: z
1231
- .string()
1232
- .meta({
1233
- description: "Reference id of the subscription to list. Eg: '123'",
1234
- })
1235
- .optional(),
1236
- /**
1237
- * Customer type for the subscription.
1238
- * - `user`: User owns the subscription (default)
1239
- * - `organization`: Organization owns the subscription
1240
- */
1241
- customerType: z
1242
- .enum(["user", "organization"])
1243
- .meta({
1244
- description:
1245
- 'Customer type for the subscription. Eg: "user" or "organization"',
1246
- })
1247
- .optional(),
1248
- }),
1249
- );
1250
- /**
1251
- * ### Endpoint
1252
- *
1253
- * GET `/subscription/list`
1254
- *
1255
- * ### API Methods
1256
- *
1257
- * **server:**
1258
- * `auth.api.listActiveSubscriptions`
1259
- *
1260
- * **client:**
1261
- * `authClient.subscription.list`
1262
- *
1263
- * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/stripe#api-method-subscription-list)
1264
- */
1265
- export const listActiveSubscriptions = (options: StripeOptions) => {
1266
- const subscriptionOptions = options.subscription as SubscriptionOptions;
1267
- return createAuthEndpoint(
1268
- "/subscription/list",
1269
- {
1270
- method: "GET",
1271
- query: listActiveSubscriptionsQuerySchema,
1272
- metadata: {
1273
- openapi: {
1274
- operationId: "listActiveSubscriptions",
1275
- },
1276
- },
1277
- use: [
1278
- stripeSessionMiddleware,
1279
- referenceMiddleware(subscriptionOptions, "list-subscription"),
1280
- ],
1281
- },
1282
- async (ctx) => {
1283
- const customerType = ctx.query?.customerType || "user";
1284
- const referenceId =
1285
- ctx.query?.referenceId ||
1286
- getReferenceId(ctx.context.session, customerType, options);
1287
-
1288
- const subscriptions = await ctx.context.adapter.findMany<Subscription>({
1289
- model: "subscription",
1290
- where: [
1291
- {
1292
- field: "referenceId",
1293
- value: referenceId,
1294
- },
1295
- ],
1296
- });
1297
- if (!subscriptions.length) {
1298
- return [];
1299
- }
1300
- const plans = await getPlans(options.subscription);
1301
- if (!plans) {
1302
- return [];
1303
- }
1304
- const subs = subscriptions
1305
- .map((sub) => {
1306
- const plan = plans.find(
1307
- (p) => p.name.toLowerCase() === sub.plan.toLowerCase(),
1308
- );
1309
- return {
1310
- ...sub,
1311
- limits: plan?.limits,
1312
- priceId: plan?.priceId,
1313
- };
1314
- })
1315
- .filter((sub) => isActiveOrTrialing(sub));
1316
- return ctx.json(subs);
1317
- },
1318
- );
1319
- };
1320
-
1321
- const subscriptionSuccessQuerySchema = z.record(z.string(), z.any()).optional();
1322
-
1323
- export const subscriptionSuccess = (options: StripeOptions) => {
1324
- const client = options.stripeClient;
1325
- return createAuthEndpoint(
1326
- "/subscription/success",
1327
- {
1328
- method: "GET",
1329
- query: subscriptionSuccessQuerySchema,
1330
- metadata: {
1331
- openapi: {
1332
- operationId: "handleSubscriptionSuccess",
1333
- },
1334
- },
1335
- use: [originCheck((ctx) => ctx.query.callbackURL)],
1336
- },
1337
- async (ctx) => {
1338
- if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) {
1339
- throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
1340
- }
1341
- const { callbackURL, subscriptionId } = ctx.query;
1342
-
1343
- const session = await getSessionFromCtx<User & WithStripeCustomerId>(ctx);
1344
- if (!session) {
1345
- throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
1346
- }
1347
-
1348
- const subscription = await ctx.context.adapter.findOne<Subscription>({
1349
- model: "subscription",
1350
- where: [
1351
- {
1352
- field: "id",
1353
- value: subscriptionId,
1354
- },
1355
- ],
1356
- });
1357
- if (!subscription) {
1358
- ctx.context.logger.warn(
1359
- `Subscription record not found for subscriptionId: ${subscriptionId}`,
1360
- );
1361
- throw ctx.redirect(getUrl(ctx, callbackURL));
1362
- }
1363
-
1364
- // Already active or trialing, no need to update
1365
- if (isActiveOrTrialing(subscription)) {
1366
- throw ctx.redirect(getUrl(ctx, callbackURL));
1367
- }
1368
-
1369
- const customerId =
1370
- subscription.stripeCustomerId || session.user.stripeCustomerId;
1371
- if (!customerId) {
1372
- throw ctx.redirect(getUrl(ctx, callbackURL));
1373
- }
1374
-
1375
- const stripeSubscription = await client.subscriptions
1376
- .list({ customer: customerId, status: "active" })
1377
- .then((res) => res.data[0])
1378
- .catch((error) => {
1379
- ctx.context.logger.error(
1380
- "Error fetching subscription from Stripe",
1381
- error,
1382
- );
1383
- throw ctx.redirect(getUrl(ctx, callbackURL));
1384
- });
1385
- if (!stripeSubscription) {
1386
- throw ctx.redirect(getUrl(ctx, callbackURL));
1387
- }
1388
-
1389
- const subscriptionItem = stripeSubscription.items.data[0];
1390
- if (!subscriptionItem) {
1391
- ctx.context.logger.warn(
1392
- `No subscription items found for Stripe subscription ${stripeSubscription.id}`,
1393
- );
1394
- throw ctx.redirect(getUrl(ctx, callbackURL));
1395
- }
1396
-
1397
- const plan = await getPlanByPriceInfo(
1398
- options,
1399
- subscriptionItem.price.id,
1400
- subscriptionItem.price.lookup_key,
1401
- );
1402
- if (!plan) {
1403
- ctx.context.logger.warn(
1404
- `Plan not found for price ${subscriptionItem.price.id}`,
1405
- );
1406
- throw ctx.redirect(getUrl(ctx, callbackURL));
1407
- }
1408
-
1409
- await ctx.context.adapter.update({
1410
- model: "subscription",
1411
- update: {
1412
- status: stripeSubscription.status,
1413
- seats: subscriptionItem.quantity || 1,
1414
- plan: plan.name.toLowerCase(),
1415
- periodEnd: new Date(subscriptionItem.current_period_end * 1000),
1416
- periodStart: new Date(subscriptionItem.current_period_start * 1000),
1417
- stripeSubscriptionId: stripeSubscription.id,
1418
- cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
1419
- cancelAt: stripeSubscription.cancel_at
1420
- ? new Date(stripeSubscription.cancel_at * 1000)
1421
- : null,
1422
- canceledAt: stripeSubscription.canceled_at
1423
- ? new Date(stripeSubscription.canceled_at * 1000)
1424
- : null,
1425
- ...(stripeSubscription.trial_start && stripeSubscription.trial_end
1426
- ? {
1427
- trialStart: new Date(stripeSubscription.trial_start * 1000),
1428
- trialEnd: new Date(stripeSubscription.trial_end * 1000),
1429
- }
1430
- : {}),
1431
- },
1432
- where: [
1433
- {
1434
- field: "id",
1435
- value: subscription.id,
1436
- },
1437
- ],
1438
- });
1439
-
1440
- throw ctx.redirect(getUrl(ctx, callbackURL));
1441
- },
1442
- );
1443
- };
1444
-
1445
- const createBillingPortalBodySchema = z.object({
1446
- /**
1447
- * The IETF language tag of the locale Customer Portal is displayed in.
1448
- * If not provided or set to `auto`, the browser's locale is used.
1449
- */
1450
- locale: z
1451
- .custom<StripeType.Checkout.Session.Locale>((localization) => {
1452
- return typeof localization === "string";
1453
- })
1454
- .meta({
1455
- description:
1456
- "The IETF language tag of the locale Customer Portal is displayed in. Eg: 'en', 'ko'. If not provided or set to `auto`, the browser's locale is used.",
1457
- })
1458
- .optional(),
1459
- referenceId: z.string().optional(),
1460
- /**
1461
- * Customer type for the subscription.
1462
- * - `user`: User owns the subscription (default)
1463
- * - `organization`: Organization owns the subscription
1464
- */
1465
- customerType: z
1466
- .enum(["user", "organization"])
1467
- .meta({
1468
- description:
1469
- 'Customer type for the subscription. Eg: "user" or "organization"',
1470
- })
1471
- .optional(),
1472
- returnUrl: z.string().default("/"),
1473
- /**
1474
- * Disable Redirect
1475
- */
1476
- disableRedirect: z
1477
- .boolean()
1478
- .meta({
1479
- description:
1480
- "Disable redirect after creating billing portal session. Eg: true",
1481
- })
1482
- .default(false),
1483
- });
1484
-
1485
- export const createBillingPortal = (options: StripeOptions) => {
1486
- const client = options.stripeClient;
1487
- const subscriptionOptions = options.subscription as SubscriptionOptions;
1488
- return createAuthEndpoint(
1489
- "/subscription/billing-portal",
1490
- {
1491
- method: "POST",
1492
- body: createBillingPortalBodySchema,
1493
- metadata: {
1494
- openapi: {
1495
- operationId: "createBillingPortal",
1496
- },
1497
- },
1498
- use: [
1499
- stripeSessionMiddleware,
1500
- referenceMiddleware(subscriptionOptions, "billing-portal"),
1501
- originCheck((ctx) => ctx.body.returnUrl),
1502
- ],
1503
- },
1504
- async (ctx) => {
1505
- const { user } = ctx.context.session;
1506
- const customerType = ctx.body.customerType || "user";
1507
- const referenceId =
1508
- ctx.body.referenceId ||
1509
- getReferenceId(ctx.context.session, customerType, options);
1510
-
1511
- let customerId: string | undefined;
1512
-
1513
- if (customerType === "organization") {
1514
- // Organization billing portal - get customer ID from organization
1515
- const org = await ctx.context.adapter.findOne<
1516
- Organization & WithStripeCustomerId
1517
- >({
1518
- model: "organization",
1519
- where: [{ field: "id", value: referenceId }],
1520
- });
1521
- customerId = org?.stripeCustomerId;
1522
-
1523
- if (!customerId) {
1524
- // Fallback to subscription's stripeCustomerId
1525
- const subscription = await ctx.context.adapter
1526
- .findMany<Subscription>({
1527
- model: "subscription",
1528
- where: [{ field: "referenceId", value: referenceId }],
1529
- })
1530
- .then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
1531
- customerId = subscription?.stripeCustomerId;
1532
- }
1533
- } else {
1534
- // User billing portal
1535
- customerId = user.stripeCustomerId;
1536
- if (!customerId) {
1537
- const subscription = await ctx.context.adapter
1538
- .findMany<Subscription>({
1539
- model: "subscription",
1540
- where: [
1541
- {
1542
- field: "referenceId",
1543
- value: referenceId,
1544
- },
1545
- ],
1546
- })
1547
- .then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
1548
-
1549
- customerId = subscription?.stripeCustomerId;
1550
- }
1551
- }
1552
- if (!customerId) {
1553
- throw APIError.from("NOT_FOUND", STRIPE_ERROR_CODES.CUSTOMER_NOT_FOUND);
1554
- }
1555
-
1556
- try {
1557
- const { url } = await client.billingPortal.sessions.create({
1558
- locale: ctx.body.locale,
1559
- customer: customerId,
1560
- return_url: getUrl(ctx, ctx.body.returnUrl),
1561
- });
1562
-
1563
- return ctx.json({
1564
- url,
1565
- redirect: !ctx.body.disableRedirect,
1566
- });
1567
- } catch (error: any) {
1568
- ctx.context.logger.error(
1569
- "Error creating billing portal session",
1570
- error,
1571
- );
1572
- throw APIError.from(
1573
- "INTERNAL_SERVER_ERROR",
1574
- STRIPE_ERROR_CODES.UNABLE_TO_CREATE_BILLING_PORTAL,
1575
- );
1576
- }
1577
- },
1578
- );
1579
- };
1580
-
1581
- export const stripeWebhook = (options: StripeOptions) => {
1582
- const client = options.stripeClient;
1583
- return createAuthEndpoint(
1584
- "/stripe/webhook",
1585
- {
1586
- method: "POST",
1587
- metadata: {
1588
- ...HIDE_METADATA,
1589
- openapi: {
1590
- operationId: "handleStripeWebhook",
1591
- },
1592
- },
1593
- cloneRequest: true,
1594
- disableBody: true, // Don't parse the body
1595
- },
1596
- async (ctx) => {
1597
- if (!ctx.request?.body) {
1598
- throw APIError.from(
1599
- "BAD_REQUEST",
1600
- STRIPE_ERROR_CODES.INVALID_REQUEST_BODY,
1601
- );
1602
- }
1603
-
1604
- const sig = ctx.request.headers.get("stripe-signature");
1605
- if (!sig) {
1606
- throw APIError.from(
1607
- "BAD_REQUEST",
1608
- STRIPE_ERROR_CODES.STRIPE_SIGNATURE_NOT_FOUND,
1609
- );
1610
- }
1611
-
1612
- const webhookSecret = options.stripeWebhookSecret;
1613
- if (!webhookSecret) {
1614
- throw APIError.from(
1615
- "INTERNAL_SERVER_ERROR",
1616
- STRIPE_ERROR_CODES.STRIPE_WEBHOOK_SECRET_NOT_FOUND,
1617
- );
1618
- }
1619
-
1620
- const payload = await ctx.request.text();
1621
-
1622
- let event: Stripe.Event;
1623
- try {
1624
- // Support both Stripe v18 (constructEvent) and v19+ (constructEventAsync)
1625
- if (typeof client.webhooks.constructEventAsync === "function") {
1626
- // Stripe v19+ - use async method
1627
- event = await client.webhooks.constructEventAsync(
1628
- payload,
1629
- sig,
1630
- webhookSecret,
1631
- );
1632
- } else {
1633
- // Stripe v18 - use sync method
1634
- event = client.webhooks.constructEvent(payload, sig, webhookSecret);
1635
- }
1636
- } catch (err: any) {
1637
- ctx.context.logger.error(`${err.message}`);
1638
- throw APIError.from(
1639
- "BAD_REQUEST",
1640
- STRIPE_ERROR_CODES.FAILED_TO_CONSTRUCT_STRIPE_EVENT,
1641
- );
1642
- }
1643
- if (!event) {
1644
- throw APIError.from(
1645
- "BAD_REQUEST",
1646
- STRIPE_ERROR_CODES.FAILED_TO_CONSTRUCT_STRIPE_EVENT,
1647
- );
1648
- }
1649
- try {
1650
- switch (event.type) {
1651
- case "checkout.session.completed":
1652
- await onCheckoutSessionCompleted(ctx, options, event);
1653
- await options.onEvent?.(event);
1654
- break;
1655
- case "customer.subscription.created":
1656
- await onSubscriptionCreated(ctx, options, event);
1657
- await options.onEvent?.(event);
1658
- break;
1659
- case "customer.subscription.updated":
1660
- await onSubscriptionUpdated(ctx, options, event);
1661
- await options.onEvent?.(event);
1662
- break;
1663
- case "customer.subscription.deleted":
1664
- await onSubscriptionDeleted(ctx, options, event);
1665
- await options.onEvent?.(event);
1666
- break;
1667
- default:
1668
- await options.onEvent?.(event);
1669
- break;
1670
- }
1671
- } catch (e: any) {
1672
- ctx.context.logger.error(`Stripe webhook failed. Error: ${e.message}`);
1673
- throw APIError.from(
1674
- "BAD_REQUEST",
1675
- STRIPE_ERROR_CODES.STRIPE_WEBHOOK_ERROR,
1676
- );
1677
- }
1678
- return ctx.json({ success: true });
1679
- },
1680
- );
1681
- };