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