@better-auth/stripe 1.4.10-beta.1 → 1.4.11-beta.1

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