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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/routes.ts CHANGED
@@ -1,12 +1,10 @@
1
1
  import { createAuthEndpoint } from "@better-auth/core/api";
2
2
  import { APIError } from "@better-auth/core/error";
3
- import type { GenericEndpointContext } from "better-auth";
3
+ import type { GenericEndpointContext, User } from "better-auth";
4
4
  import { HIDE_METADATA } from "better-auth";
5
- import {
6
- getSessionFromCtx,
7
- originCheck,
8
- sessionMiddleware,
9
- } from "better-auth/api";
5
+ import { getSessionFromCtx, originCheck } from "better-auth/api";
6
+ import type { Organization } from "better-auth/plugins/organization";
7
+ import { defu } from "defu";
10
8
  import type Stripe from "stripe";
11
9
  import type { Stripe as StripeType } from "stripe";
12
10
  import * as z from "zod/v4";
@@ -17,14 +15,17 @@ import {
17
15
  onSubscriptionDeleted,
18
16
  onSubscriptionUpdated,
19
17
  } from "./hooks";
20
- import { referenceMiddleware } from "./middleware";
18
+ import { referenceMiddleware, stripeSessionMiddleware } from "./middleware";
21
19
  import type {
22
- InputSubscription,
20
+ CustomerType,
21
+ StripeCtxSession,
23
22
  StripeOptions,
24
23
  Subscription,
25
24
  SubscriptionOptions,
25
+ WithStripeCustomerId,
26
26
  } from "./types";
27
27
  import {
28
+ escapeStripeSearchValue,
28
29
  getPlanByName,
29
30
  getPlanByPriceInfo,
30
31
  getPlans,
@@ -33,6 +34,70 @@ import {
33
34
  isStripePendingCancel,
34
35
  } from "./utils";
35
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
+
36
101
  const upgradeSubscriptionBodySchema = z.object({
37
102
  /**
38
103
  * The name of the plan to subscribe
@@ -50,14 +115,14 @@ const upgradeSubscriptionBodySchema = z.object({
50
115
  })
51
116
  .optional(),
52
117
  /**
53
- * Reference id of the subscription to upgrade
54
- * This is used to identify the subscription to upgrade
55
- * If not provided, the user's id will be used
118
+ * Reference ID for the subscription based on customerType:
119
+ * - `user`: defaults to `user.id`
120
+ * - `organization`: defaults to `session.activeOrganizationId`
56
121
  */
57
122
  referenceId: z
58
123
  .string()
59
124
  .meta({
60
- description: 'Reference id of the subscription to upgrade. Eg: "123"',
125
+ description: 'Reference ID for the subscription. Eg: "org_123"',
61
126
  })
62
127
  .optional(),
63
128
  /**
@@ -72,12 +137,23 @@ const upgradeSubscriptionBodySchema = z.object({
72
137
  })
73
138
  .optional(),
74
139
  /**
75
- * Any additional data you want to store in your database
76
- * subscriptions
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.
77
153
  */
78
154
  metadata: z.record(z.string(), z.any()).optional(),
79
155
  /**
80
- * If a subscription
156
+ * Number of seats for subscriptions.
81
157
  */
82
158
  seats: z
83
159
  .number()
@@ -156,22 +232,27 @@ export const upgradeSubscription = (options: StripeOptions) => {
156
232
  },
157
233
  },
158
234
  use: [
159
- sessionMiddleware,
235
+ stripeSessionMiddleware,
236
+ referenceMiddleware(subscriptionOptions, "upgrade-subscription"),
160
237
  originCheck((c) => {
161
238
  return [c.body.successUrl as string, c.body.cancelUrl as string];
162
239
  }),
163
- referenceMiddleware(subscriptionOptions, "upgrade-subscription"),
164
240
  ],
165
241
  },
166
242
  async (ctx) => {
167
243
  const { user, session } = ctx.context.session;
244
+ const customerType = ctx.body.customerType || "user";
245
+ const referenceId =
246
+ ctx.body.referenceId ||
247
+ getReferenceId(ctx.context.session, customerType, options);
248
+
168
249
  if (!user.emailVerified && subscriptionOptions.requireEmailVerification) {
169
250
  throw APIError.from(
170
251
  "BAD_REQUEST",
171
252
  STRIPE_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED,
172
253
  );
173
254
  }
174
- const referenceId = ctx.body.referenceId || user.id;
255
+
175
256
  const plan = await getPlanByName(options, ctx.body.plan);
176
257
  if (!plan) {
177
258
  throw APIError.from(
@@ -179,6 +260,8 @@ export const upgradeSubscription = (options: StripeOptions) => {
179
260
  STRIPE_ERROR_CODES.SUBSCRIPTION_PLAN_NOT_FOUND,
180
261
  );
181
262
  }
263
+
264
+ // Find existing subscription by Stripe ID or reference ID
182
265
  let subscriptionToUpdate = ctx.body.subscriptionId
183
266
  ? await ctx.context.adapter.findOne<Subscription>({
184
267
  model: "subscription",
@@ -192,7 +275,12 @@ export const upgradeSubscription = (options: StripeOptions) => {
192
275
  : referenceId
193
276
  ? await ctx.context.adapter.findOne<Subscription>({
194
277
  model: "subscription",
195
- where: [{ field: "referenceId", value: referenceId }],
278
+ where: [
279
+ {
280
+ field: "referenceId",
281
+ value: referenceId,
282
+ },
283
+ ],
196
284
  })
197
285
  : null;
198
286
 
@@ -203,7 +291,6 @@ export const upgradeSubscription = (options: StripeOptions) => {
203
291
  ) {
204
292
  subscriptionToUpdate = null;
205
293
  }
206
-
207
294
  if (ctx.body.subscriptionId && !subscriptionToUpdate) {
208
295
  throw APIError.from(
209
296
  "BAD_REQUEST",
@@ -211,51 +298,154 @@ export const upgradeSubscription = (options: StripeOptions) => {
211
298
  );
212
299
  }
213
300
 
214
- let customerId =
215
- subscriptionToUpdate?.stripeCustomerId || user.stripeCustomerId;
216
-
217
- if (!customerId) {
218
- try {
219
- // Try to find existing Stripe customer by email
220
- const existingCustomers = await client.customers.list({
221
- email: user.email,
222
- limit: 1,
223
- });
224
-
225
- let stripeCustomer = existingCustomers.data[0];
226
-
227
- if (!stripeCustomer) {
228
- stripeCustomer = await client.customers.create({
229
- email: user.email,
230
- name: user.name,
231
- metadata: {
232
- ...ctx.body.metadata,
233
- userId: user.id,
234
- },
235
- });
236
- }
237
-
238
- // Update local DB with Stripe customer ID
239
- await ctx.context.adapter.update({
240
- model: "user",
241
- update: {
242
- stripeCustomerId: stripeCustomer.id,
243
- },
301
+ // Determine customer id
302
+ let customerId: string | undefined;
303
+ if (customerType === "organization") {
304
+ // Organization subscription - get customer ID from organization
305
+ customerId = subscriptionToUpdate?.stripeCustomerId;
306
+ if (!customerId) {
307
+ const org = await ctx.context.adapter.findOne<
308
+ Organization & WithStripeCustomerId
309
+ >({
310
+ model: "organization",
244
311
  where: [
245
312
  {
246
313
  field: "id",
247
- value: user.id,
314
+ value: referenceId,
248
315
  },
249
316
  ],
250
317
  });
318
+ if (!org) {
319
+ throw APIError.from(
320
+ "BAD_REQUEST",
321
+ STRIPE_ERROR_CODES.ORGANIZATION_NOT_FOUND,
322
+ );
323
+ }
324
+ customerId = org.stripeCustomerId;
325
+
326
+ // If org doesn't have a customer ID, create one
327
+ if (!customerId) {
328
+ try {
329
+ // First, search for existing organization customer by organizationId
330
+ const existingOrgCustomers = await client.customers.search({
331
+ query: `metadata["organizationId"]:"${org.id}"`,
332
+ limit: 1,
333
+ });
251
334
 
252
- customerId = stripeCustomer.id;
253
- } catch (e: any) {
254
- ctx.context.logger.error(e);
255
- throw APIError.from(
256
- "BAD_REQUEST",
257
- STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER,
258
- );
335
+ let stripeCustomer = existingOrgCustomers.data[0];
336
+
337
+ if (!stripeCustomer) {
338
+ // Get custom params if provided
339
+ let extraCreateParams: Partial<StripeType.CustomerCreateParams> =
340
+ {};
341
+ if (options.organization?.getCustomerCreateParams) {
342
+ extraCreateParams =
343
+ await options.organization.getCustomerCreateParams(
344
+ org,
345
+ ctx,
346
+ );
347
+ }
348
+
349
+ // Create Stripe customer for organization
350
+ // Email can be set via getCustomerCreateParams or updated in billing portal
351
+ // Use defu to ensure internal metadata fields are preserved
352
+ const customerParams: StripeType.CustomerCreateParams = defu(
353
+ {
354
+ name: org.name,
355
+ metadata: {
356
+ ...ctx.body.metadata,
357
+ organizationId: org.id,
358
+ customerType: "organization",
359
+ },
360
+ },
361
+ extraCreateParams,
362
+ );
363
+ stripeCustomer = await client.customers.create(customerParams);
364
+
365
+ // Call onCustomerCreate callback only for newly created customers
366
+ await options.organization?.onCustomerCreate?.(
367
+ {
368
+ stripeCustomer,
369
+ organization: {
370
+ ...org,
371
+ stripeCustomerId: stripeCustomer.id,
372
+ },
373
+ },
374
+ ctx,
375
+ );
376
+ }
377
+
378
+ await ctx.context.adapter.update({
379
+ model: "organization",
380
+ update: {
381
+ stripeCustomerId: stripeCustomer.id,
382
+ },
383
+ where: [
384
+ {
385
+ field: "id",
386
+ value: org.id,
387
+ },
388
+ ],
389
+ });
390
+
391
+ customerId = stripeCustomer.id;
392
+ } catch (e: any) {
393
+ ctx.context.logger.error(e);
394
+ throw APIError.from(
395
+ "BAD_REQUEST",
396
+ STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER,
397
+ );
398
+ }
399
+ }
400
+ }
401
+ } else {
402
+ // User subscription - get customer ID from user
403
+ customerId =
404
+ subscriptionToUpdate?.stripeCustomerId || user.stripeCustomerId;
405
+ if (!customerId) {
406
+ try {
407
+ // Try to find existing user Stripe customer by email
408
+ const existingCustomers = await client.customers.search({
409
+ query: `email:"${escapeStripeSearchValue(user.email)}" AND -metadata["customerType"]:"organization"`,
410
+ limit: 1,
411
+ });
412
+
413
+ let stripeCustomer = existingCustomers.data[0];
414
+
415
+ if (!stripeCustomer) {
416
+ stripeCustomer = await client.customers.create({
417
+ email: user.email,
418
+ name: user.name,
419
+ metadata: {
420
+ ...ctx.body.metadata,
421
+ userId: user.id,
422
+ customerType: "user",
423
+ },
424
+ });
425
+ }
426
+
427
+ // Update local DB with Stripe customer ID
428
+ await ctx.context.adapter.update({
429
+ model: "user",
430
+ update: {
431
+ stripeCustomerId: stripeCustomer.id,
432
+ },
433
+ where: [
434
+ {
435
+ field: "id",
436
+ value: user.id,
437
+ },
438
+ ],
439
+ });
440
+
441
+ customerId = stripeCustomer.id;
442
+ } catch (e: any) {
443
+ ctx.context.logger.error(e);
444
+ throw APIError.from(
445
+ "BAD_REQUEST",
446
+ STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER,
447
+ );
448
+ }
259
449
  }
260
450
  }
261
451
 
@@ -266,7 +456,7 @@ export const upgradeSubscription = (options: StripeOptions) => {
266
456
  where: [
267
457
  {
268
458
  field: "referenceId",
269
- value: ctx.body.referenceId || user.id,
459
+ value: referenceId,
270
460
  },
271
461
  ],
272
462
  });
@@ -330,7 +520,7 @@ export const upgradeSubscription = (options: StripeOptions) => {
330
520
 
331
521
  // If no database record exists for this Stripe subscription, update the existing one
332
522
  if (!dbSubscription && activeOrTrialingSubscription) {
333
- await ctx.context.adapter.update<InputSubscription>({
523
+ await ctx.context.adapter.update<Subscription>({
334
524
  model: "subscription",
335
525
  update: {
336
526
  stripeSubscriptionId: activeSubscription.id,
@@ -412,7 +602,7 @@ export const upgradeSubscription = (options: StripeOptions) => {
412
602
  activeOrTrialingSubscription || incompleteSubscription;
413
603
 
414
604
  if (incompleteSubscription && !activeOrTrialingSubscription) {
415
- const updated = await ctx.context.adapter.update<InputSubscription>({
605
+ const updated = await ctx.context.adapter.update<Subscription>({
416
606
  model: "subscription",
417
607
  update: {
418
608
  plan: plan.name.toLowerCase(),
@@ -430,10 +620,7 @@ export const upgradeSubscription = (options: StripeOptions) => {
430
620
  }
431
621
 
432
622
  if (!subscription) {
433
- subscription = await ctx.context.adapter.create<
434
- InputSubscription,
435
- Subscription
436
- >({
623
+ subscription = await ctx.context.adapter.create<Subscription>({
437
624
  model: "subscription",
438
625
  data: {
439
626
  plan: plan.name.toLowerCase(),
@@ -447,7 +634,10 @@ export const upgradeSubscription = (options: StripeOptions) => {
447
634
 
448
635
  if (!subscription) {
449
636
  ctx.context.logger.error("Subscription ID not found");
450
- throw new APIError("INTERNAL_SERVER_ERROR");
637
+ throw APIError.from(
638
+ "NOT_FOUND",
639
+ STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
640
+ );
451
641
  }
452
642
 
453
643
  const params = await subscriptionOptions.getCheckoutSessionParams?.(
@@ -504,13 +694,13 @@ export const upgradeSubscription = (options: StripeOptions) => {
504
694
  ...(customerId
505
695
  ? {
506
696
  customer: customerId,
507
- customer_update: {
508
- name: "auto",
509
- address: "auto",
510
- },
697
+ customer_update:
698
+ customerType !== "user"
699
+ ? ({ address: "auto" } as const)
700
+ : ({ name: "auto", address: "auto" } as const), // The customer name is automatically set only for users
511
701
  }
512
702
  : {
513
- customer_email: session.user.email,
703
+ customer_email: user.email,
514
704
  }),
515
705
  success_url: getUrl(
516
706
  ctx,
@@ -529,15 +719,23 @@ export const upgradeSubscription = (options: StripeOptions) => {
529
719
  ],
530
720
  subscription_data: {
531
721
  ...freeTrial,
722
+ metadata: {
723
+ ...ctx.body.metadata,
724
+ ...params?.params?.subscription_data?.metadata,
725
+ userId: user.id,
726
+ subscriptionId: subscription.id,
727
+ referenceId,
728
+ },
532
729
  },
533
730
  mode: "subscription",
534
731
  client_reference_id: referenceId,
535
732
  ...params?.params,
536
733
  metadata: {
734
+ ...ctx.body.metadata,
735
+ ...params?.params?.metadata,
537
736
  userId: user.id,
538
737
  subscriptionId: subscription.id,
539
738
  referenceId,
540
- ...params?.params?.metadata,
541
739
  },
542
740
  },
543
741
  params?.options,
@@ -579,9 +777,7 @@ export const cancelSubscriptionCallback = (options: StripeOptions) => {
579
777
  if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) {
580
778
  throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
581
779
  }
582
- const session = await getSessionFromCtx<{ stripeCustomerId: string }>(
583
- ctx,
584
- );
780
+ const session = await getSessionFromCtx<User & WithStripeCustomerId>(ctx);
585
781
  if (!session) {
586
782
  throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
587
783
  }
@@ -673,6 +869,18 @@ const cancelSubscriptionBodySchema = z.object({
673
869
  "The Stripe subscription ID to cancel. Eg: 'sub_1ABC2DEF3GHI4JKL'",
674
870
  })
675
871
  .optional(),
872
+ /**
873
+ * Customer type for the subscription.
874
+ * - `user`: User owns the subscription (default)
875
+ * - `organization`: Organization owns the subscription
876
+ */
877
+ customerType: z
878
+ .enum(["user", "organization"])
879
+ .meta({
880
+ description:
881
+ 'Customer type for the subscription. Eg: "user" or "organization"',
882
+ })
883
+ .optional(),
676
884
  returnUrl: z.string().meta({
677
885
  description:
678
886
  'URL to take customers to when they click on the billing portal\'s link to return to your website. Eg: "/account"',
@@ -718,13 +926,17 @@ export const cancelSubscription = (options: StripeOptions) => {
718
926
  },
719
927
  },
720
928
  use: [
721
- sessionMiddleware,
722
- originCheck((ctx) => ctx.body.returnUrl),
929
+ stripeSessionMiddleware,
723
930
  referenceMiddleware(subscriptionOptions, "cancel-subscription"),
931
+ originCheck((ctx) => ctx.body.returnUrl),
724
932
  ],
725
933
  },
726
934
  async (ctx) => {
727
- const referenceId = ctx.body?.referenceId || ctx.context.session.user.id;
935
+ const customerType = ctx.body.customerType || "user";
936
+ const referenceId =
937
+ ctx.body.referenceId ||
938
+ getReferenceId(ctx.context.session, customerType, options);
939
+
728
940
  let subscription = ctx.body.subscriptionId
729
941
  ? await ctx.context.adapter.findOne<Subscription>({
730
942
  model: "subscription",
@@ -863,6 +1075,18 @@ const restoreSubscriptionBodySchema = z.object({
863
1075
  "The Stripe subscription ID to restore. Eg: 'sub_1ABC2DEF3GHI4JKL'",
864
1076
  })
865
1077
  .optional(),
1078
+ /**
1079
+ * Customer type for the subscription.
1080
+ * - `user`: User owns the subscription (default)
1081
+ * - `organization`: Organization owns the subscription
1082
+ */
1083
+ customerType: z
1084
+ .enum(["user", "organization"])
1085
+ .meta({
1086
+ description:
1087
+ 'Customer type for the subscription. Eg: "user" or "organization"',
1088
+ })
1089
+ .optional(),
866
1090
  });
867
1091
 
868
1092
  export const restoreSubscription = (options: StripeOptions) => {
@@ -879,12 +1103,15 @@ export const restoreSubscription = (options: StripeOptions) => {
879
1103
  },
880
1104
  },
881
1105
  use: [
882
- sessionMiddleware,
1106
+ stripeSessionMiddleware,
883
1107
  referenceMiddleware(subscriptionOptions, "restore-subscription"),
884
1108
  ],
885
1109
  },
886
1110
  async (ctx) => {
887
- const referenceId = ctx.body?.referenceId || ctx.context.session.user.id;
1111
+ const customerType = ctx.body.customerType || "user";
1112
+ const referenceId =
1113
+ ctx.body.referenceId ||
1114
+ getReferenceId(ctx.context.session, customerType, options);
888
1115
 
889
1116
  let subscription = ctx.body.subscriptionId
890
1117
  ? await ctx.context.adapter.findOne<Subscription>({
@@ -920,10 +1147,7 @@ export const restoreSubscription = (options: StripeOptions) => {
920
1147
  STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
921
1148
  );
922
1149
  }
923
- if (
924
- subscription.status != "active" &&
925
- subscription.status != "trialing"
926
- ) {
1150
+ if (!isActiveOrTrialing(subscription)) {
927
1151
  throw APIError.from(
928
1152
  "BAD_REQUEST",
929
1153
  STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_ACTIVE,
@@ -995,6 +1219,18 @@ const listActiveSubscriptionsQuerySchema = z.optional(
995
1219
  description: "Reference id of the subscription to list. Eg: '123'",
996
1220
  })
997
1221
  .optional(),
1222
+ /**
1223
+ * Customer type for the subscription.
1224
+ * - `user`: User owns the subscription (default)
1225
+ * - `organization`: Organization owns the subscription
1226
+ */
1227
+ customerType: z
1228
+ .enum(["user", "organization"])
1229
+ .meta({
1230
+ description:
1231
+ 'Customer type for the subscription. Eg: "user" or "organization"',
1232
+ })
1233
+ .optional(),
998
1234
  }),
999
1235
  );
1000
1236
  /**
@@ -1025,17 +1261,22 @@ export const listActiveSubscriptions = (options: StripeOptions) => {
1025
1261
  },
1026
1262
  },
1027
1263
  use: [
1028
- sessionMiddleware,
1264
+ stripeSessionMiddleware,
1029
1265
  referenceMiddleware(subscriptionOptions, "list-subscription"),
1030
1266
  ],
1031
1267
  },
1032
1268
  async (ctx) => {
1269
+ const customerType = ctx.query?.customerType || "user";
1270
+ const referenceId =
1271
+ ctx.query?.referenceId ||
1272
+ getReferenceId(ctx.context.session, customerType, options);
1273
+
1033
1274
  const subscriptions = await ctx.context.adapter.findMany<Subscription>({
1034
1275
  model: "subscription",
1035
1276
  where: [
1036
1277
  {
1037
1278
  field: "referenceId",
1038
- value: ctx.query?.referenceId || ctx.context.session.user.id,
1279
+ value: referenceId,
1039
1280
  },
1040
1281
  ],
1041
1282
  });
@@ -1083,14 +1324,12 @@ export const subscriptionSuccess = (options: StripeOptions) => {
1083
1324
  if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) {
1084
1325
  throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
1085
1326
  }
1086
- const session = await getSessionFromCtx<{ stripeCustomerId: string }>(
1087
- ctx,
1088
- );
1327
+ const { callbackURL, subscriptionId } = ctx.query;
1328
+
1329
+ const session = await getSessionFromCtx<User & WithStripeCustomerId>(ctx);
1089
1330
  if (!session) {
1090
1331
  throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
1091
1332
  }
1092
- const { user } = session;
1093
- const { callbackURL, subscriptionId } = ctx.query;
1094
1333
 
1095
1334
  const subscription = await ctx.context.adapter.findOne<Subscription>({
1096
1335
  model: "subscription",
@@ -1101,81 +1340,89 @@ export const subscriptionSuccess = (options: StripeOptions) => {
1101
1340
  },
1102
1341
  ],
1103
1342
  });
1104
-
1105
- if (
1106
- subscription?.status === "active" ||
1107
- subscription?.status === "trialing"
1108
- ) {
1109
- return ctx.redirect(getUrl(ctx, callbackURL));
1343
+ if (!subscription) {
1344
+ ctx.context.logger.warn(
1345
+ `Subscription record not found for subscriptionId: ${subscriptionId}`,
1346
+ );
1347
+ throw ctx.redirect(getUrl(ctx, callbackURL));
1110
1348
  }
1111
- const customerId =
1112
- subscription?.stripeCustomerId || user.stripeCustomerId;
1113
1349
 
1114
- if (customerId) {
1115
- try {
1116
- const stripeSubscription = await client.subscriptions
1117
- .list({
1118
- customer: customerId,
1119
- status: "active",
1120
- })
1121
- .then((res) => res.data[0]);
1350
+ // Already active or trialing, no need to update
1351
+ if (isActiveOrTrialing(subscription)) {
1352
+ throw ctx.redirect(getUrl(ctx, callbackURL));
1353
+ }
1122
1354
 
1123
- if (stripeSubscription) {
1124
- const plan = await getPlanByPriceInfo(
1125
- options,
1126
- stripeSubscription.items.data[0]?.price.id!,
1127
- stripeSubscription.items.data[0]?.price.lookup_key!,
1128
- );
1355
+ const customerId =
1356
+ subscription.stripeCustomerId || session.user.stripeCustomerId;
1357
+ if (!customerId) {
1358
+ throw ctx.redirect(getUrl(ctx, callbackURL));
1359
+ }
1129
1360
 
1130
- if (plan && subscription) {
1131
- await ctx.context.adapter.update({
1132
- model: "subscription",
1133
- update: {
1134
- status: stripeSubscription.status,
1135
- seats: stripeSubscription.items.data[0]?.quantity || 1,
1136
- plan: plan.name.toLowerCase(),
1137
- periodEnd: new Date(
1138
- stripeSubscription.items.data[0]?.current_period_end! *
1139
- 1000,
1140
- ),
1141
- periodStart: new Date(
1142
- stripeSubscription.items.data[0]?.current_period_start! *
1143
- 1000,
1144
- ),
1145
- stripeSubscriptionId: stripeSubscription.id,
1146
- cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
1147
- cancelAt: stripeSubscription.cancel_at
1148
- ? new Date(stripeSubscription.cancel_at * 1000)
1149
- : null,
1150
- canceledAt: stripeSubscription.canceled_at
1151
- ? new Date(stripeSubscription.canceled_at * 1000)
1152
- : null,
1153
- ...(stripeSubscription.trial_start &&
1154
- stripeSubscription.trial_end
1155
- ? {
1156
- trialStart: new Date(
1157
- stripeSubscription.trial_start * 1000,
1158
- ),
1159
- trialEnd: new Date(stripeSubscription.trial_end * 1000),
1160
- }
1161
- : {}),
1162
- },
1163
- where: [
1164
- {
1165
- field: "id",
1166
- value: subscription.id,
1167
- },
1168
- ],
1169
- });
1170
- }
1171
- }
1172
- } catch (error) {
1361
+ const stripeSubscription = await client.subscriptions
1362
+ .list({ customer: customerId, status: "active" })
1363
+ .then((res) => res.data[0])
1364
+ .catch((error) => {
1173
1365
  ctx.context.logger.error(
1174
1366
  "Error fetching subscription from Stripe",
1175
1367
  error,
1176
1368
  );
1177
- }
1369
+ throw ctx.redirect(getUrl(ctx, callbackURL));
1370
+ });
1371
+ if (!stripeSubscription) {
1372
+ throw ctx.redirect(getUrl(ctx, callbackURL));
1178
1373
  }
1374
+
1375
+ const subscriptionItem = stripeSubscription.items.data[0];
1376
+ if (!subscriptionItem) {
1377
+ ctx.context.logger.warn(
1378
+ `No subscription items found for Stripe subscription ${stripeSubscription.id}`,
1379
+ );
1380
+ throw ctx.redirect(getUrl(ctx, callbackURL));
1381
+ }
1382
+
1383
+ const plan = await getPlanByPriceInfo(
1384
+ options,
1385
+ subscriptionItem.price.id,
1386
+ subscriptionItem.price.lookup_key,
1387
+ );
1388
+ if (!plan) {
1389
+ ctx.context.logger.warn(
1390
+ `Plan not found for price ${subscriptionItem.price.id}`,
1391
+ );
1392
+ throw ctx.redirect(getUrl(ctx, callbackURL));
1393
+ }
1394
+
1395
+ await ctx.context.adapter.update({
1396
+ model: "subscription",
1397
+ update: {
1398
+ status: stripeSubscription.status,
1399
+ seats: subscriptionItem.quantity || 1,
1400
+ plan: plan.name.toLowerCase(),
1401
+ periodEnd: new Date(subscriptionItem.current_period_end * 1000),
1402
+ periodStart: new Date(subscriptionItem.current_period_start * 1000),
1403
+ stripeSubscriptionId: stripeSubscription.id,
1404
+ cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
1405
+ cancelAt: stripeSubscription.cancel_at
1406
+ ? new Date(stripeSubscription.cancel_at * 1000)
1407
+ : null,
1408
+ canceledAt: stripeSubscription.canceled_at
1409
+ ? new Date(stripeSubscription.canceled_at * 1000)
1410
+ : null,
1411
+ ...(stripeSubscription.trial_start && stripeSubscription.trial_end
1412
+ ? {
1413
+ trialStart: new Date(stripeSubscription.trial_start * 1000),
1414
+ trialEnd: new Date(stripeSubscription.trial_end * 1000),
1415
+ }
1416
+ : {}),
1417
+ },
1418
+ where: [
1419
+ {
1420
+ field: "id",
1421
+ value: subscription.id,
1422
+ },
1423
+ ],
1424
+ });
1425
+
1179
1426
  throw ctx.redirect(getUrl(ctx, callbackURL));
1180
1427
  },
1181
1428
  );
@@ -1188,6 +1435,18 @@ const createBillingPortalBodySchema = z.object({
1188
1435
  })
1189
1436
  .optional(),
1190
1437
  referenceId: z.string().optional(),
1438
+ /**
1439
+ * Customer type for the subscription.
1440
+ * - `user`: User owns the subscription (default)
1441
+ * - `organization`: Organization owns the subscription
1442
+ */
1443
+ customerType: z
1444
+ .enum(["user", "organization"])
1445
+ .meta({
1446
+ description:
1447
+ 'Customer type for the subscription. Eg: "user" or "organization"',
1448
+ })
1449
+ .optional(),
1191
1450
  returnUrl: z.string().default("/"),
1192
1451
  /**
1193
1452
  * Disable Redirect
@@ -1215,37 +1474,61 @@ export const createBillingPortal = (options: StripeOptions) => {
1215
1474
  },
1216
1475
  },
1217
1476
  use: [
1218
- sessionMiddleware,
1219
- originCheck((ctx) => ctx.body.returnUrl),
1477
+ stripeSessionMiddleware,
1220
1478
  referenceMiddleware(subscriptionOptions, "billing-portal"),
1479
+ originCheck((ctx) => ctx.body.returnUrl),
1221
1480
  ],
1222
1481
  },
1223
1482
  async (ctx) => {
1224
1483
  const { user } = ctx.context.session;
1225
- const referenceId = ctx.body.referenceId || user.id;
1484
+ const customerType = ctx.body.customerType || "user";
1485
+ const referenceId =
1486
+ ctx.body.referenceId ||
1487
+ getReferenceId(ctx.context.session, customerType, options);
1226
1488
 
1227
- let customerId = user.stripeCustomerId;
1489
+ let customerId: string | undefined;
1228
1490
 
1229
- if (!customerId) {
1230
- const subscription = await ctx.context.adapter
1231
- .findMany<Subscription>({
1232
- model: "subscription",
1233
- where: [
1234
- {
1235
- field: "referenceId",
1236
- value: referenceId,
1237
- },
1238
- ],
1239
- })
1240
- .then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
1491
+ if (customerType === "organization") {
1492
+ // Organization billing portal - get customer ID from organization
1493
+ const org = await ctx.context.adapter.findOne<
1494
+ Organization & WithStripeCustomerId
1495
+ >({
1496
+ model: "organization",
1497
+ where: [{ field: "id", value: referenceId }],
1498
+ });
1499
+ customerId = org?.stripeCustomerId;
1241
1500
 
1242
- customerId = subscription?.stripeCustomerId;
1243
- }
1501
+ if (!customerId) {
1502
+ // Fallback to subscription's stripeCustomerId
1503
+ const subscription = await ctx.context.adapter
1504
+ .findMany<Subscription>({
1505
+ model: "subscription",
1506
+ where: [{ field: "referenceId", value: referenceId }],
1507
+ })
1508
+ .then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
1509
+ customerId = subscription?.stripeCustomerId;
1510
+ }
1511
+ } else {
1512
+ // User billing portal
1513
+ customerId = user.stripeCustomerId;
1514
+ if (!customerId) {
1515
+ const subscription = await ctx.context.adapter
1516
+ .findMany<Subscription>({
1517
+ model: "subscription",
1518
+ where: [
1519
+ {
1520
+ field: "referenceId",
1521
+ value: referenceId,
1522
+ },
1523
+ ],
1524
+ })
1525
+ .then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
1244
1526
 
1527
+ customerId = subscription?.stripeCustomerId;
1528
+ }
1529
+ }
1245
1530
  if (!customerId) {
1246
- throw new APIError("BAD_REQUEST", {
1247
- message: "No Stripe customer found for this user",
1248
- });
1531
+ throw APIError.from("NOT_FOUND", STRIPE_ERROR_CODES.CUSTOMER_NOT_FOUND);
1249
1532
  }
1250
1533
 
1251
1534
  try {
@@ -1264,9 +1547,10 @@ export const createBillingPortal = (options: StripeOptions) => {
1264
1547
  "Error creating billing portal session",
1265
1548
  error,
1266
1549
  );
1267
- throw new APIError("BAD_REQUEST", {
1268
- message: error.message,
1269
- });
1550
+ throw APIError.from(
1551
+ "INTERNAL_SERVER_ERROR",
1552
+ STRIPE_ERROR_CODES.UNABLE_TO_CREATE_BILLING_PORTAL,
1553
+ );
1270
1554
  }
1271
1555
  },
1272
1556
  );
@@ -1285,45 +1569,60 @@ export const stripeWebhook = (options: StripeOptions) => {
1285
1569
  },
1286
1570
  },
1287
1571
  cloneRequest: true,
1288
- //don't parse the body
1289
- disableBody: true,
1572
+ disableBody: true, // Don't parse the body
1290
1573
  },
1291
1574
  async (ctx) => {
1292
1575
  if (!ctx.request?.body) {
1293
- throw new APIError("INTERNAL_SERVER_ERROR");
1576
+ throw APIError.from(
1577
+ "BAD_REQUEST",
1578
+ STRIPE_ERROR_CODES.INVALID_REQUEST_BODY,
1579
+ );
1294
1580
  }
1295
- const buf = await ctx.request.text();
1296
- const sig = ctx.request.headers.get("stripe-signature") as string;
1581
+
1582
+ const sig = ctx.request.headers.get("stripe-signature");
1583
+ if (!sig) {
1584
+ throw APIError.from(
1585
+ "BAD_REQUEST",
1586
+ STRIPE_ERROR_CODES.STRIPE_SIGNATURE_NOT_FOUND,
1587
+ );
1588
+ }
1589
+
1297
1590
  const webhookSecret = options.stripeWebhookSecret;
1591
+ if (!webhookSecret) {
1592
+ throw APIError.from(
1593
+ "INTERNAL_SERVER_ERROR",
1594
+ STRIPE_ERROR_CODES.STRIPE_WEBHOOK_SECRET_NOT_FOUND,
1595
+ );
1596
+ }
1597
+
1598
+ const payload = await ctx.request.text();
1599
+
1298
1600
  let event: Stripe.Event;
1299
1601
  try {
1300
- if (!sig || !webhookSecret) {
1301
- throw new APIError("BAD_REQUEST", {
1302
- message: "Stripe webhook secret not found",
1303
- });
1304
- }
1305
1602
  // Support both Stripe v18 (constructEvent) and v19+ (constructEventAsync)
1306
1603
  if (typeof client.webhooks.constructEventAsync === "function") {
1307
1604
  // Stripe v19+ - use async method
1308
1605
  event = await client.webhooks.constructEventAsync(
1309
- buf,
1606
+ payload,
1310
1607
  sig,
1311
1608
  webhookSecret,
1312
1609
  );
1313
1610
  } else {
1314
1611
  // Stripe v18 - use sync method
1315
- event = client.webhooks.constructEvent(buf, sig, webhookSecret);
1612
+ event = client.webhooks.constructEvent(payload, sig, webhookSecret);
1316
1613
  }
1317
1614
  } catch (err: any) {
1318
1615
  ctx.context.logger.error(`${err.message}`);
1319
- throw new APIError("BAD_REQUEST", {
1320
- message: `Webhook Error: ${err.message}`,
1321
- });
1616
+ throw APIError.from(
1617
+ "BAD_REQUEST",
1618
+ STRIPE_ERROR_CODES.FAILED_TO_CONSTRUCT_STRIPE_EVENT,
1619
+ );
1322
1620
  }
1323
1621
  if (!event) {
1324
- throw new APIError("BAD_REQUEST", {
1325
- message: "Failed to construct event",
1326
- });
1622
+ throw APIError.from(
1623
+ "BAD_REQUEST",
1624
+ STRIPE_ERROR_CODES.FAILED_TO_CONSTRUCT_STRIPE_EVENT,
1625
+ );
1327
1626
  }
1328
1627
  try {
1329
1628
  switch (event.type) {
@@ -1349,33 +1648,12 @@ export const stripeWebhook = (options: StripeOptions) => {
1349
1648
  }
1350
1649
  } catch (e: any) {
1351
1650
  ctx.context.logger.error(`Stripe webhook failed. Error: ${e.message}`);
1352
- throw new APIError("BAD_REQUEST", {
1353
- message: "Webhook error: See server logs for more information.",
1354
- });
1651
+ throw APIError.from(
1652
+ "BAD_REQUEST",
1653
+ STRIPE_ERROR_CODES.STRIPE_WEBHOOK_ERROR,
1654
+ );
1355
1655
  }
1356
1656
  return ctx.json({ success: true });
1357
1657
  },
1358
1658
  );
1359
1659
  };
1360
-
1361
- const getUrl = (ctx: GenericEndpointContext, url: string) => {
1362
- if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(url)) {
1363
- return url;
1364
- }
1365
- return `${ctx.context.options.baseURL}${
1366
- url.startsWith("/") ? url : `/${url}`
1367
- }`;
1368
- };
1369
-
1370
- async function resolvePriceIdFromLookupKey(
1371
- stripeClient: Stripe,
1372
- lookupKey: string,
1373
- ): Promise<string | undefined> {
1374
- if (!lookupKey) return undefined;
1375
- const prices = await stripeClient.prices.list({
1376
- lookup_keys: [lookupKey],
1377
- active: true,
1378
- limit: 1,
1379
- });
1380
- return prices.data[0]?.id;
1381
- }