@better-auth/stripe 1.5.0-beta.1 → 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,29 +1,102 @@
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 { referenceMiddleware, stripeSessionMiddleware } from "./middleware";
20
19
  import type {
21
- InputSubscription,
20
+ CustomerType,
21
+ StripeCtxSession,
22
22
  StripeOptions,
23
23
  Subscription,
24
24
  SubscriptionOptions,
25
+ WithStripeCustomerId,
25
26
  } from "./types";
26
- import { getPlanByName, getPlanByPriceInfo, getPlans } from "./utils";
27
+ import {
28
+ escapeStripeSearchValue,
29
+ getPlanByName,
30
+ getPlanByPriceInfo,
31
+ getPlans,
32
+ isActiveOrTrialing,
33
+ isPendingCancel,
34
+ isStripePendingCancel,
35
+ } from "./utils";
36
+
37
+ /**
38
+ * Converts a relative URL to an absolute URL using baseURL.
39
+ * @internal
40
+ */
41
+ function getUrl(ctx: GenericEndpointContext, url: string) {
42
+ if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(url)) {
43
+ return url;
44
+ }
45
+ return `${ctx.context.options.baseURL}${
46
+ url.startsWith("/") ? url : `/${url}`
47
+ }`;
48
+ }
49
+
50
+ /**
51
+ * Resolves a Stripe price ID from a lookup key.
52
+ * @internal
53
+ */
54
+ async function resolvePriceIdFromLookupKey(
55
+ stripeClient: Stripe,
56
+ lookupKey: string,
57
+ ): Promise<string | undefined> {
58
+ if (!lookupKey) return undefined;
59
+ const prices = await stripeClient.prices.list({
60
+ lookup_keys: [lookupKey],
61
+ active: true,
62
+ limit: 1,
63
+ });
64
+ return prices.data[0]?.id;
65
+ }
66
+
67
+ /**
68
+ * Determines the reference ID based on customer type.
69
+ * - `user` (default): uses userId
70
+ * - `organization`: uses activeOrganizationId from session
71
+ * @internal
72
+ */
73
+ function getReferenceId(
74
+ ctxSession: StripeCtxSession,
75
+ customerType: CustomerType | undefined,
76
+ options: StripeOptions,
77
+ ): string {
78
+ const { user, session } = ctxSession;
79
+ const type = customerType || "user";
80
+
81
+ if (type === "organization") {
82
+ if (!options.organization?.enabled) {
83
+ throw APIError.from(
84
+ "BAD_REQUEST",
85
+ STRIPE_ERROR_CODES.ORGANIZATION_SUBSCRIPTION_NOT_ENABLED,
86
+ );
87
+ }
88
+
89
+ if (!session.activeOrganizationId) {
90
+ throw APIError.from(
91
+ "BAD_REQUEST",
92
+ STRIPE_ERROR_CODES.ORGANIZATION_NOT_FOUND,
93
+ );
94
+ }
95
+ return session.activeOrganizationId;
96
+ }
97
+
98
+ return user.id;
99
+ }
27
100
 
28
101
  const upgradeSubscriptionBodySchema = z.object({
29
102
  /**
@@ -42,14 +115,14 @@ const upgradeSubscriptionBodySchema = z.object({
42
115
  })
43
116
  .optional(),
44
117
  /**
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
118
+ * Reference ID for the subscription based on customerType:
119
+ * - `user`: defaults to `user.id`
120
+ * - `organization`: defaults to `session.activeOrganizationId`
48
121
  */
49
122
  referenceId: z
50
123
  .string()
51
124
  .meta({
52
- description: 'Reference id of the subscription to upgrade. Eg: "123"',
125
+ description: 'Reference ID for the subscription. Eg: "org_123"',
53
126
  })
54
127
  .optional(),
55
128
  /**
@@ -64,12 +137,23 @@ const upgradeSubscriptionBodySchema = z.object({
64
137
  })
65
138
  .optional(),
66
139
  /**
67
- * Any additional data you want to store in your database
68
- * 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.
69
153
  */
70
154
  metadata: z.record(z.string(), z.any()).optional(),
71
155
  /**
72
- * If a subscription
156
+ * Number of seats for subscriptions.
73
157
  */
74
158
  seats: z
75
159
  .number()
@@ -148,22 +232,27 @@ export const upgradeSubscription = (options: StripeOptions) => {
148
232
  },
149
233
  },
150
234
  use: [
151
- sessionMiddleware,
235
+ stripeSessionMiddleware,
236
+ referenceMiddleware(subscriptionOptions, "upgrade-subscription"),
152
237
  originCheck((c) => {
153
238
  return [c.body.successUrl as string, c.body.cancelUrl as string];
154
239
  }),
155
- referenceMiddleware(subscriptionOptions, "upgrade-subscription"),
156
240
  ],
157
241
  },
158
242
  async (ctx) => {
159
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
+
160
249
  if (!user.emailVerified && subscriptionOptions.requireEmailVerification) {
161
250
  throw APIError.from(
162
251
  "BAD_REQUEST",
163
252
  STRIPE_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED,
164
253
  );
165
254
  }
166
- const referenceId = ctx.body.referenceId || user.id;
255
+
167
256
  const plan = await getPlanByName(options, ctx.body.plan);
168
257
  if (!plan) {
169
258
  throw APIError.from(
@@ -171,6 +260,8 @@ export const upgradeSubscription = (options: StripeOptions) => {
171
260
  STRIPE_ERROR_CODES.SUBSCRIPTION_PLAN_NOT_FOUND,
172
261
  );
173
262
  }
263
+
264
+ // Find existing subscription by Stripe ID or reference ID
174
265
  let subscriptionToUpdate = ctx.body.subscriptionId
175
266
  ? await ctx.context.adapter.findOne<Subscription>({
176
267
  model: "subscription",
@@ -184,7 +275,12 @@ export const upgradeSubscription = (options: StripeOptions) => {
184
275
  : referenceId
185
276
  ? await ctx.context.adapter.findOne<Subscription>({
186
277
  model: "subscription",
187
- where: [{ field: "referenceId", value: referenceId }],
278
+ where: [
279
+ {
280
+ field: "referenceId",
281
+ value: referenceId,
282
+ },
283
+ ],
188
284
  })
189
285
  : null;
190
286
 
@@ -195,7 +291,6 @@ export const upgradeSubscription = (options: StripeOptions) => {
195
291
  ) {
196
292
  subscriptionToUpdate = null;
197
293
  }
198
-
199
294
  if (ctx.body.subscriptionId && !subscriptionToUpdate) {
200
295
  throw APIError.from(
201
296
  "BAD_REQUEST",
@@ -203,51 +298,154 @@ export const upgradeSubscription = (options: StripeOptions) => {
203
298
  );
204
299
  }
205
300
 
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
- },
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",
236
311
  where: [
237
312
  {
238
313
  field: "id",
239
- value: user.id,
314
+ value: referenceId,
240
315
  },
241
316
  ],
242
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
+ });
243
334
 
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
- );
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
+ }
251
449
  }
252
450
  }
253
451
 
@@ -258,24 +456,20 @@ export const upgradeSubscription = (options: StripeOptions) => {
258
456
  where: [
259
457
  {
260
458
  field: "referenceId",
261
- value: ctx.body.referenceId || user.id,
459
+ value: referenceId,
262
460
  },
263
461
  ],
264
462
  });
265
463
 
266
- const activeOrTrialingSubscription = subscriptions.find(
267
- (sub) => sub.status === "active" || sub.status === "trialing",
464
+ const activeOrTrialingSubscription = subscriptions.find((sub) =>
465
+ isActiveOrTrialing(sub),
268
466
  );
269
467
 
270
468
  const activeSubscriptions = await client.subscriptions
271
469
  .list({
272
470
  customer: customerId,
273
471
  })
274
- .then((res) =>
275
- res.data.filter(
276
- (sub) => sub.status === "active" || sub.status === "trialing",
277
- ),
278
- );
472
+ .then((res) => res.data.filter((sub) => isActiveOrTrialing(sub)));
279
473
 
280
474
  const activeSubscription = activeSubscriptions.find((sub) => {
281
475
  // If we have a specific subscription to update, match by ID
@@ -326,7 +520,7 @@ export const upgradeSubscription = (options: StripeOptions) => {
326
520
 
327
521
  // If no database record exists for this Stripe subscription, update the existing one
328
522
  if (!dbSubscription && activeOrTrialingSubscription) {
329
- await ctx.context.adapter.update<InputSubscription>({
523
+ await ctx.context.adapter.update<Subscription>({
330
524
  model: "subscription",
331
525
  update: {
332
526
  stripeSubscriptionId: activeSubscription.id,
@@ -400,7 +594,7 @@ export const upgradeSubscription = (options: StripeOptions) => {
400
594
  });
401
595
  return ctx.json({
402
596
  url,
403
- redirect: true,
597
+ redirect: !ctx.body.disableRedirect,
404
598
  });
405
599
  }
406
600
 
@@ -408,7 +602,7 @@ export const upgradeSubscription = (options: StripeOptions) => {
408
602
  activeOrTrialingSubscription || incompleteSubscription;
409
603
 
410
604
  if (incompleteSubscription && !activeOrTrialingSubscription) {
411
- const updated = await ctx.context.adapter.update<InputSubscription>({
605
+ const updated = await ctx.context.adapter.update<Subscription>({
412
606
  model: "subscription",
413
607
  update: {
414
608
  plan: plan.name.toLowerCase(),
@@ -426,10 +620,7 @@ export const upgradeSubscription = (options: StripeOptions) => {
426
620
  }
427
621
 
428
622
  if (!subscription) {
429
- subscription = await ctx.context.adapter.create<
430
- InputSubscription,
431
- Subscription
432
- >({
623
+ subscription = await ctx.context.adapter.create<Subscription>({
433
624
  model: "subscription",
434
625
  data: {
435
626
  plan: plan.name.toLowerCase(),
@@ -443,7 +634,10 @@ export const upgradeSubscription = (options: StripeOptions) => {
443
634
 
444
635
  if (!subscription) {
445
636
  ctx.context.logger.error("Subscription ID not found");
446
- throw new APIError("INTERNAL_SERVER_ERROR");
637
+ throw APIError.from(
638
+ "NOT_FOUND",
639
+ STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
640
+ );
447
641
  }
448
642
 
449
643
  const params = await subscriptionOptions.getCheckoutSessionParams?.(
@@ -457,7 +651,13 @@ export const upgradeSubscription = (options: StripeOptions) => {
457
651
  ctx,
458
652
  );
459
653
 
460
- const hasEverTrialed = subscriptions.some((s) => {
654
+ const allSubscriptions = await ctx.context.adapter.findMany<Subscription>(
655
+ {
656
+ model: "subscription",
657
+ where: [{ field: "referenceId", value: referenceId }],
658
+ },
659
+ );
660
+ const hasEverTrialed = allSubscriptions.some((s) => {
461
661
  // Check if user has ever had a trial for any plan (not just the same plan)
462
662
  // This prevents users from getting multiple trials by switching plans
463
663
  const hadTrial =
@@ -494,13 +694,13 @@ export const upgradeSubscription = (options: StripeOptions) => {
494
694
  ...(customerId
495
695
  ? {
496
696
  customer: customerId,
497
- customer_update: {
498
- name: "auto",
499
- address: "auto",
500
- },
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
501
701
  }
502
702
  : {
503
- customer_email: session.user.email,
703
+ customer_email: user.email,
504
704
  }),
505
705
  success_url: getUrl(
506
706
  ctx,
@@ -519,15 +719,23 @@ export const upgradeSubscription = (options: StripeOptions) => {
519
719
  ],
520
720
  subscription_data: {
521
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
+ },
522
729
  },
523
730
  mode: "subscription",
524
731
  client_reference_id: referenceId,
525
732
  ...params?.params,
526
733
  metadata: {
734
+ ...ctx.body.metadata,
735
+ ...params?.params?.metadata,
527
736
  userId: user.id,
528
737
  subscriptionId: subscription.id,
529
738
  referenceId,
530
- ...params?.params?.metadata,
531
739
  },
532
740
  },
533
741
  params?.options,
@@ -569,9 +777,7 @@ export const cancelSubscriptionCallback = (options: StripeOptions) => {
569
777
  if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) {
570
778
  throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
571
779
  }
572
- const session = await getSessionFromCtx<{ stripeCustomerId: string }>(
573
- ctx,
574
- );
780
+ const session = await getSessionFromCtx<User & WithStripeCustomerId>(ctx);
575
781
  if (!session) {
576
782
  throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
577
783
  }
@@ -591,8 +797,8 @@ export const cancelSubscriptionCallback = (options: StripeOptions) => {
591
797
  });
592
798
  if (
593
799
  !subscription ||
594
- subscription.cancelAtPeriodEnd ||
595
- subscription.status === "canceled"
800
+ subscription.status === "canceled" ||
801
+ isPendingCancel(subscription)
596
802
  ) {
597
803
  throw ctx.redirect(getUrl(ctx, callbackURL));
598
804
  }
@@ -604,12 +810,24 @@ export const cancelSubscriptionCallback = (options: StripeOptions) => {
604
810
  const currentSubscription = stripeSubscription.data.find(
605
811
  (sub) => sub.id === subscription.stripeSubscriptionId,
606
812
  );
607
- if (currentSubscription?.cancel_at_period_end === true) {
813
+
814
+ const isNewCancellation =
815
+ currentSubscription &&
816
+ isStripePendingCancel(currentSubscription) &&
817
+ !isPendingCancel(subscription);
818
+ if (isNewCancellation) {
608
819
  await ctx.context.adapter.update({
609
820
  model: "subscription",
610
821
  update: {
611
822
  status: currentSubscription?.status,
612
- cancelAtPeriodEnd: true,
823
+ cancelAtPeriodEnd:
824
+ currentSubscription?.cancel_at_period_end || false,
825
+ cancelAt: currentSubscription?.cancel_at
826
+ ? new Date(currentSubscription.cancel_at * 1000)
827
+ : null,
828
+ canceledAt: currentSubscription?.canceled_at
829
+ ? new Date(currentSubscription.canceled_at * 1000)
830
+ : null,
613
831
  },
614
832
  where: [
615
833
  {
@@ -651,10 +869,32 @@ const cancelSubscriptionBodySchema = z.object({
651
869
  "The Stripe subscription ID to cancel. Eg: 'sub_1ABC2DEF3GHI4JKL'",
652
870
  })
653
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(),
654
884
  returnUrl: z.string().meta({
655
885
  description:
656
886
  'URL to take customers to when they click on the billing portal\'s link to return to your website. Eg: "/account"',
657
887
  }),
888
+ /**
889
+ * Disable Redirect
890
+ */
891
+ disableRedirect: z
892
+ .boolean()
893
+ .meta({
894
+ description:
895
+ "Disable redirect after successful subscription cancellation. Eg: true",
896
+ })
897
+ .default(false),
658
898
  });
659
899
 
660
900
  /**
@@ -686,13 +926,17 @@ export const cancelSubscription = (options: StripeOptions) => {
686
926
  },
687
927
  },
688
928
  use: [
689
- sessionMiddleware,
690
- originCheck((ctx) => ctx.body.returnUrl),
929
+ stripeSessionMiddleware,
691
930
  referenceMiddleware(subscriptionOptions, "cancel-subscription"),
931
+ originCheck((ctx) => ctx.body.returnUrl),
692
932
  ],
693
933
  },
694
934
  async (ctx) => {
695
- 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
+
696
940
  let subscription = ctx.body.subscriptionId
697
941
  ? await ctx.context.adapter.findOne<Subscription>({
698
942
  model: "subscription",
@@ -708,13 +952,7 @@ export const cancelSubscription = (options: StripeOptions) => {
708
952
  model: "subscription",
709
953
  where: [{ field: "referenceId", value: referenceId }],
710
954
  })
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.
955
+ .then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
718
956
  if (
719
957
  ctx.body.subscriptionId &&
720
958
  subscription &&
@@ -733,11 +971,7 @@ export const cancelSubscription = (options: StripeOptions) => {
733
971
  .list({
734
972
  customer: subscription.stripeCustomerId,
735
973
  })
736
- .then((res) =>
737
- res.data.filter(
738
- (sub) => sub.status === "active" || sub.status === "trialing",
739
- ),
740
- );
974
+ .then((res) => res.data.filter((sub) => isActiveOrTrialing(sub)));
741
975
  if (!activeSubscriptions.length) {
742
976
  /**
743
977
  * If the subscription is not found, we need to delete the subscription
@@ -785,21 +1019,30 @@ export const cancelSubscription = (options: StripeOptions) => {
785
1019
  },
786
1020
  })
787
1021
  .catch(async (e) => {
788
- if (e.message.includes("already set to be cancel")) {
1022
+ if (e.message?.includes("already set to be canceled")) {
789
1023
  /**
790
- * in-case we missed the event from stripe, we set it manually
1024
+ * in-case we missed the event from stripe, we sync the actual state
791
1025
  * this is a rare case and should not happen
792
1026
  */
793
- if (!subscription.cancelAtPeriodEnd) {
794
- await ctx.context.adapter.updateMany({
1027
+ if (!isPendingCancel(subscription)) {
1028
+ const stripeSub = await client.subscriptions.retrieve(
1029
+ activeSubscription.id,
1030
+ );
1031
+ await ctx.context.adapter.update({
795
1032
  model: "subscription",
796
1033
  update: {
797
- cancelAtPeriodEnd: true,
1034
+ cancelAtPeriodEnd: stripeSub.cancel_at_period_end,
1035
+ cancelAt: stripeSub.cancel_at
1036
+ ? new Date(stripeSub.cancel_at * 1000)
1037
+ : null,
1038
+ canceledAt: stripeSub.canceled_at
1039
+ ? new Date(stripeSub.canceled_at * 1000)
1040
+ : null,
798
1041
  },
799
1042
  where: [
800
1043
  {
801
- field: "referenceId",
802
- value: referenceId,
1044
+ field: "id",
1045
+ value: subscription.id,
803
1046
  },
804
1047
  ],
805
1048
  });
@@ -810,10 +1053,10 @@ export const cancelSubscription = (options: StripeOptions) => {
810
1053
  code: e.code,
811
1054
  });
812
1055
  });
813
- return {
1056
+ return ctx.json({
814
1057
  url,
815
- redirect: true,
816
- };
1058
+ redirect: !ctx.body.disableRedirect,
1059
+ });
817
1060
  },
818
1061
  );
819
1062
  };
@@ -832,6 +1075,18 @@ const restoreSubscriptionBodySchema = z.object({
832
1075
  "The Stripe subscription ID to restore. Eg: 'sub_1ABC2DEF3GHI4JKL'",
833
1076
  })
834
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(),
835
1090
  });
836
1091
 
837
1092
  export const restoreSubscription = (options: StripeOptions) => {
@@ -848,12 +1103,15 @@ export const restoreSubscription = (options: StripeOptions) => {
848
1103
  },
849
1104
  },
850
1105
  use: [
851
- sessionMiddleware,
1106
+ stripeSessionMiddleware,
852
1107
  referenceMiddleware(subscriptionOptions, "restore-subscription"),
853
1108
  ],
854
1109
  },
855
1110
  async (ctx) => {
856
- 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);
857
1115
 
858
1116
  let subscription = ctx.body.subscriptionId
859
1117
  ? await ctx.context.adapter.findOne<Subscription>({
@@ -875,11 +1133,7 @@ export const restoreSubscription = (options: StripeOptions) => {
875
1133
  },
876
1134
  ],
877
1135
  })
878
- .then((subs) =>
879
- subs.find(
880
- (sub) => sub.status === "active" || sub.status === "trialing",
881
- ),
882
- );
1136
+ .then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
883
1137
  if (
884
1138
  ctx.body.subscriptionId &&
885
1139
  subscription &&
@@ -893,16 +1147,13 @@ export const restoreSubscription = (options: StripeOptions) => {
893
1147
  STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
894
1148
  );
895
1149
  }
896
- if (
897
- subscription.status != "active" &&
898
- subscription.status != "trialing"
899
- ) {
1150
+ if (!isActiveOrTrialing(subscription)) {
900
1151
  throw APIError.from(
901
1152
  "BAD_REQUEST",
902
1153
  STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_ACTIVE,
903
1154
  );
904
1155
  }
905
- if (!subscription.cancelAtPeriodEnd) {
1156
+ if (!isPendingCancel(subscription)) {
906
1157
  throw APIError.from(
907
1158
  "BAD_REQUEST",
908
1159
  STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION,
@@ -913,12 +1164,7 @@ export const restoreSubscription = (options: StripeOptions) => {
913
1164
  .list({
914
1165
  customer: subscription.stripeCustomerId,
915
1166
  })
916
- .then(
917
- (res) =>
918
- res.data.filter(
919
- (sub) => sub.status === "active" || sub.status === "trialing",
920
- )[0],
921
- );
1167
+ .then((res) => res.data.filter((sub) => isActiveOrTrialing(sub))[0]);
922
1168
  if (!activeSubscription) {
923
1169
  throw APIError.from(
924
1170
  "BAD_REQUEST",
@@ -926,36 +1172,41 @@ export const restoreSubscription = (options: StripeOptions) => {
926
1172
  );
927
1173
  }
928
1174
 
929
- try {
930
- const newSub = await client.subscriptions.update(
931
- activeSubscription.id,
932
- {
933
- cancel_at_period_end: false,
934
- },
935
- );
1175
+ // Clear scheduled cancellation based on Stripe subscription state
1176
+ // Note: Stripe doesn't accept both `cancel_at` and `cancel_at_period_end` simultaneously
1177
+ const updateParams: Stripe.SubscriptionUpdateParams = {};
1178
+ if (activeSubscription.cancel_at) {
1179
+ updateParams.cancel_at = "";
1180
+ } else if (activeSubscription.cancel_at_period_end) {
1181
+ updateParams.cancel_at_period_end = false;
1182
+ }
936
1183
 
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
- ],
1184
+ const newSub = await client.subscriptions
1185
+ .update(activeSubscription.id, updateParams)
1186
+ .catch((e) => {
1187
+ throw ctx.error("BAD_REQUEST", {
1188
+ message: e.message,
1189
+ code: e.code,
1190
+ });
949
1191
  });
950
1192
 
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
- }
1193
+ await ctx.context.adapter.update({
1194
+ model: "subscription",
1195
+ update: {
1196
+ cancelAtPeriodEnd: false,
1197
+ cancelAt: null,
1198
+ canceledAt: null,
1199
+ updatedAt: new Date(),
1200
+ },
1201
+ where: [
1202
+ {
1203
+ field: "id",
1204
+ value: subscription.id,
1205
+ },
1206
+ ],
1207
+ });
1208
+
1209
+ return ctx.json(newSub);
959
1210
  },
960
1211
  );
961
1212
  };
@@ -968,6 +1219,18 @@ const listActiveSubscriptionsQuerySchema = z.optional(
968
1219
  description: "Reference id of the subscription to list. Eg: '123'",
969
1220
  })
970
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(),
971
1234
  }),
972
1235
  );
973
1236
  /**
@@ -998,17 +1261,22 @@ export const listActiveSubscriptions = (options: StripeOptions) => {
998
1261
  },
999
1262
  },
1000
1263
  use: [
1001
- sessionMiddleware,
1264
+ stripeSessionMiddleware,
1002
1265
  referenceMiddleware(subscriptionOptions, "list-subscription"),
1003
1266
  ],
1004
1267
  },
1005
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
+
1006
1274
  const subscriptions = await ctx.context.adapter.findMany<Subscription>({
1007
1275
  model: "subscription",
1008
1276
  where: [
1009
1277
  {
1010
1278
  field: "referenceId",
1011
- value: ctx.query?.referenceId || ctx.context.session.user.id,
1279
+ value: referenceId,
1012
1280
  },
1013
1281
  ],
1014
1282
  });
@@ -1030,9 +1298,7 @@ export const listActiveSubscriptions = (options: StripeOptions) => {
1030
1298
  priceId: plan?.priceId,
1031
1299
  };
1032
1300
  })
1033
- .filter((sub) => {
1034
- return sub.status === "active" || sub.status === "trialing";
1035
- });
1301
+ .filter((sub) => isActiveOrTrialing(sub));
1036
1302
  return ctx.json(subs);
1037
1303
  },
1038
1304
  );
@@ -1058,14 +1324,12 @@ export const subscriptionSuccess = (options: StripeOptions) => {
1058
1324
  if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) {
1059
1325
  throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
1060
1326
  }
1061
- const session = await getSessionFromCtx<{ stripeCustomerId: string }>(
1062
- ctx,
1063
- );
1327
+ const { callbackURL, subscriptionId } = ctx.query;
1328
+
1329
+ const session = await getSessionFromCtx<User & WithStripeCustomerId>(ctx);
1064
1330
  if (!session) {
1065
1331
  throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
1066
1332
  }
1067
- const { user } = session;
1068
- const { callbackURL, subscriptionId } = ctx.query;
1069
1333
 
1070
1334
  const subscription = await ctx.context.adapter.findOne<Subscription>({
1071
1335
  model: "subscription",
@@ -1076,74 +1340,89 @@ export const subscriptionSuccess = (options: StripeOptions) => {
1076
1340
  },
1077
1341
  ],
1078
1342
  });
1079
-
1080
- if (
1081
- subscription?.status === "active" ||
1082
- subscription?.status === "trialing"
1083
- ) {
1084
- 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));
1085
1348
  }
1086
- const customerId =
1087
- subscription?.stripeCustomerId || user.stripeCustomerId;
1088
1349
 
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]);
1350
+ // Already active or trialing, no need to update
1351
+ if (isActiveOrTrialing(subscription)) {
1352
+ throw ctx.redirect(getUrl(ctx, callbackURL));
1353
+ }
1097
1354
 
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
- );
1355
+ const customerId =
1356
+ subscription.stripeCustomerId || session.user.stripeCustomerId;
1357
+ if (!customerId) {
1358
+ throw ctx.redirect(getUrl(ctx, callbackURL));
1359
+ }
1104
1360
 
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) {
1361
+ const stripeSubscription = await client.subscriptions
1362
+ .list({ customer: customerId, status: "active" })
1363
+ .then((res) => res.data[0])
1364
+ .catch((error) => {
1141
1365
  ctx.context.logger.error(
1142
1366
  "Error fetching subscription from Stripe",
1143
1367
  error,
1144
1368
  );
1145
- }
1369
+ throw ctx.redirect(getUrl(ctx, callbackURL));
1370
+ });
1371
+ if (!stripeSubscription) {
1372
+ throw ctx.redirect(getUrl(ctx, callbackURL));
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));
1146
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
+
1147
1426
  throw ctx.redirect(getUrl(ctx, callbackURL));
1148
1427
  },
1149
1428
  );
@@ -1156,7 +1435,29 @@ const createBillingPortalBodySchema = z.object({
1156
1435
  })
1157
1436
  .optional(),
1158
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(),
1159
1450
  returnUrl: z.string().default("/"),
1451
+ /**
1452
+ * Disable Redirect
1453
+ */
1454
+ disableRedirect: z
1455
+ .boolean()
1456
+ .meta({
1457
+ description:
1458
+ "Disable redirect after creating billing portal session. Eg: true",
1459
+ })
1460
+ .default(false),
1160
1461
  });
1161
1462
 
1162
1463
  export const createBillingPortal = (options: StripeOptions) => {
@@ -1173,41 +1474,61 @@ export const createBillingPortal = (options: StripeOptions) => {
1173
1474
  },
1174
1475
  },
1175
1476
  use: [
1176
- sessionMiddleware,
1177
- originCheck((ctx) => ctx.body.returnUrl),
1477
+ stripeSessionMiddleware,
1178
1478
  referenceMiddleware(subscriptionOptions, "billing-portal"),
1479
+ originCheck((ctx) => ctx.body.returnUrl),
1179
1480
  ],
1180
1481
  },
1181
1482
  async (ctx) => {
1182
1483
  const { user } = ctx.context.session;
1183
- 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);
1184
1488
 
1185
- let customerId = user.stripeCustomerId;
1489
+ let customerId: string | undefined;
1186
1490
 
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
- );
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;
1203
1500
 
1204
- customerId = subscription?.stripeCustomerId;
1205
- }
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)));
1206
1526
 
1527
+ customerId = subscription?.stripeCustomerId;
1528
+ }
1529
+ }
1207
1530
  if (!customerId) {
1208
- throw new APIError("BAD_REQUEST", {
1209
- message: "No Stripe customer found for this user",
1210
- });
1531
+ throw APIError.from("NOT_FOUND", STRIPE_ERROR_CODES.CUSTOMER_NOT_FOUND);
1211
1532
  }
1212
1533
 
1213
1534
  try {
@@ -1219,16 +1540,17 @@ export const createBillingPortal = (options: StripeOptions) => {
1219
1540
 
1220
1541
  return ctx.json({
1221
1542
  url,
1222
- redirect: true,
1543
+ redirect: !ctx.body.disableRedirect,
1223
1544
  });
1224
1545
  } catch (error: any) {
1225
1546
  ctx.context.logger.error(
1226
1547
  "Error creating billing portal session",
1227
1548
  error,
1228
1549
  );
1229
- throw new APIError("BAD_REQUEST", {
1230
- message: error.message,
1231
- });
1550
+ throw APIError.from(
1551
+ "INTERNAL_SERVER_ERROR",
1552
+ STRIPE_ERROR_CODES.UNABLE_TO_CREATE_BILLING_PORTAL,
1553
+ );
1232
1554
  }
1233
1555
  },
1234
1556
  );
@@ -1247,45 +1569,60 @@ export const stripeWebhook = (options: StripeOptions) => {
1247
1569
  },
1248
1570
  },
1249
1571
  cloneRequest: true,
1250
- //don't parse the body
1251
- disableBody: true,
1572
+ disableBody: true, // Don't parse the body
1252
1573
  },
1253
1574
  async (ctx) => {
1254
1575
  if (!ctx.request?.body) {
1255
- throw new APIError("INTERNAL_SERVER_ERROR");
1576
+ throw APIError.from(
1577
+ "BAD_REQUEST",
1578
+ STRIPE_ERROR_CODES.INVALID_REQUEST_BODY,
1579
+ );
1256
1580
  }
1257
- const buf = await ctx.request.text();
1258
- 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
+
1259
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
+
1260
1600
  let event: Stripe.Event;
1261
1601
  try {
1262
- if (!sig || !webhookSecret) {
1263
- throw new APIError("BAD_REQUEST", {
1264
- message: "Stripe webhook secret not found",
1265
- });
1266
- }
1267
1602
  // Support both Stripe v18 (constructEvent) and v19+ (constructEventAsync)
1268
1603
  if (typeof client.webhooks.constructEventAsync === "function") {
1269
1604
  // Stripe v19+ - use async method
1270
1605
  event = await client.webhooks.constructEventAsync(
1271
- buf,
1606
+ payload,
1272
1607
  sig,
1273
1608
  webhookSecret,
1274
1609
  );
1275
1610
  } else {
1276
1611
  // Stripe v18 - use sync method
1277
- event = client.webhooks.constructEvent(buf, sig, webhookSecret);
1612
+ event = client.webhooks.constructEvent(payload, sig, webhookSecret);
1278
1613
  }
1279
1614
  } catch (err: any) {
1280
1615
  ctx.context.logger.error(`${err.message}`);
1281
- throw new APIError("BAD_REQUEST", {
1282
- message: `Webhook Error: ${err.message}`,
1283
- });
1616
+ throw APIError.from(
1617
+ "BAD_REQUEST",
1618
+ STRIPE_ERROR_CODES.FAILED_TO_CONSTRUCT_STRIPE_EVENT,
1619
+ );
1284
1620
  }
1285
1621
  if (!event) {
1286
- throw new APIError("BAD_REQUEST", {
1287
- message: "Failed to construct event",
1288
- });
1622
+ throw APIError.from(
1623
+ "BAD_REQUEST",
1624
+ STRIPE_ERROR_CODES.FAILED_TO_CONSTRUCT_STRIPE_EVENT,
1625
+ );
1289
1626
  }
1290
1627
  try {
1291
1628
  switch (event.type) {
@@ -1293,6 +1630,10 @@ export const stripeWebhook = (options: StripeOptions) => {
1293
1630
  await onCheckoutSessionCompleted(ctx, options, event);
1294
1631
  await options.onEvent?.(event);
1295
1632
  break;
1633
+ case "customer.subscription.created":
1634
+ await onSubscriptionCreated(ctx, options, event);
1635
+ await options.onEvent?.(event);
1636
+ break;
1296
1637
  case "customer.subscription.updated":
1297
1638
  await onSubscriptionUpdated(ctx, options, event);
1298
1639
  await options.onEvent?.(event);
@@ -1307,33 +1648,12 @@ export const stripeWebhook = (options: StripeOptions) => {
1307
1648
  }
1308
1649
  } catch (e: any) {
1309
1650
  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
- });
1651
+ throw APIError.from(
1652
+ "BAD_REQUEST",
1653
+ STRIPE_ERROR_CODES.STRIPE_WEBHOOK_ERROR,
1654
+ );
1313
1655
  }
1314
1656
  return ctx.json({ success: true });
1315
1657
  },
1316
1658
  );
1317
1659
  };
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
- }