@better-auth/stripe 1.5.0-beta.1 → 1.5.0-beta.10

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