@better-auth/stripe 1.4.16 → 1.4.18

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@better-auth/stripe",
3
3
  "author": "Bereket Engida",
4
- "version": "1.4.16",
4
+ "version": "1.4.18",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
7
7
  "types": "dist/index.d.mts",
@@ -46,19 +46,19 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "defu": "^6.1.4",
49
- "zod": "^4.1.12"
49
+ "zod": "^4.3.5"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "stripe": "^18 || ^19 || ^20",
53
- "@better-auth/core": "1.4.16",
54
- "better-auth": "1.4.16"
53
+ "@better-auth/core": "1.4.18",
54
+ "better-auth": "1.4.18"
55
55
  },
56
56
  "devDependencies": {
57
57
  "better-call": "1.1.8",
58
58
  "stripe": "^20.0.0",
59
59
  "tsdown": "^0.17.2",
60
- "@better-auth/core": "1.4.16",
61
- "better-auth": "1.4.16"
60
+ "@better-auth/core": "1.4.18",
61
+ "better-auth": "1.4.18"
62
62
  },
63
63
  "scripts": {
64
64
  "test": "vitest",
package/src/client.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BetterAuthClientPlugin } from "better-auth";
1
+ import type { BetterAuthClientPlugin } from "better-auth/client";
2
2
  import type { stripe } from "./index";
3
3
 
4
4
  export const stripeClient = <
package/src/hooks.ts CHANGED
@@ -2,6 +2,7 @@ import type { GenericEndpointContext } from "@better-auth/core";
2
2
  import type { User } from "@better-auth/core/db";
3
3
  import type { Organization } from "better-auth/plugins/organization";
4
4
  import type Stripe from "stripe";
5
+ import { subscriptionMetadata } from "./metadata";
5
6
  import type { CustomerType, StripeOptions, Subscription } from "./types";
6
7
  import {
7
8
  getPlanByPriceInfo,
@@ -66,10 +67,10 @@ export async function onCheckoutSessionCompleted(
66
67
  priceLookupKey,
67
68
  );
68
69
  if (plan) {
70
+ const checkoutMeta = subscriptionMetadata.get(checkoutSession?.metadata);
69
71
  const referenceId =
70
- checkoutSession?.client_reference_id ||
71
- checkoutSession?.metadata?.referenceId;
72
- const subscriptionId = checkoutSession?.metadata?.subscriptionId;
72
+ checkoutSession?.client_reference_id || checkoutMeta.referenceId;
73
+ const { subscriptionId } = checkoutMeta;
73
74
  const seats = subscriptionItem.quantity;
74
75
  if (referenceId && subscriptionId) {
75
76
  const trial =
@@ -162,7 +163,9 @@ export async function onSubscriptionCreated(
162
163
  }
163
164
 
164
165
  // Check if subscription already exists in database
165
- const subscriptionId = subscriptionCreated.metadata?.subscriptionId;
166
+ const { subscriptionId } = subscriptionMetadata.get(
167
+ subscriptionCreated.metadata,
168
+ );
166
169
  const existingSubscription =
167
170
  await ctx.context.adapter.findOne<Subscription>({
168
171
  model: "subscription",
@@ -275,7 +278,9 @@ export async function onSubscriptionUpdated(
275
278
  const priceLookupKey = subscriptionItem.price.lookup_key;
276
279
  const plan = await getPlanByPriceInfo(options, priceId, priceLookupKey);
277
280
 
278
- const subscriptionId = subscriptionUpdated.metadata?.subscriptionId;
281
+ const { subscriptionId } = subscriptionMetadata.get(
282
+ subscriptionUpdated.metadata,
283
+ );
279
284
  const customerId = subscriptionUpdated.customer?.toString();
280
285
  let subscription = await ctx.context.adapter.findOne<Subscription>({
281
286
  model: "subscription",
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  import { defu } from "defu";
9
9
  import type Stripe from "stripe";
10
10
  import { STRIPE_ERROR_CODES } from "./error-codes";
11
+ import { customerMetadata } from "./metadata";
11
12
  import {
12
13
  cancelSubscription,
13
14
  cancelSubscriptionCallback,
@@ -178,7 +179,7 @@ export const stripe = <O extends StripeOptions>(options: O) => {
178
179
  try {
179
180
  // Check if user customer already exists in Stripe by email
180
181
  const existingCustomers = await client.customers.search({
181
- query: `email:"${escapeStripeSearchValue(user.email)}" AND -metadata["customerType"]:"organization"`,
182
+ query: `email:"${escapeStripeSearchValue(user.email)}" AND -metadata["${customerMetadata.keys.customerType}"]:"organization"`,
182
183
  limit: 1,
183
184
  });
184
185
 
@@ -215,14 +216,17 @@ export const stripe = <O extends StripeOptions>(options: O) => {
215
216
  );
216
217
  }
217
218
 
218
- const params: Stripe.CustomerCreateParams = defu(
219
+ const params = defu(
219
220
  {
220
221
  email: user.email,
221
222
  name: user.name,
222
- metadata: {
223
- userId: user.id,
224
- customerType: "user",
225
- },
223
+ metadata: customerMetadata.set(
224
+ {
225
+ userId: user.id,
226
+ customerType: "user",
227
+ },
228
+ extraCreateParams?.metadata,
229
+ ),
226
230
  },
227
231
  extraCreateParams,
228
232
  );
@@ -0,0 +1,94 @@
1
+ import { defu } from "defu";
2
+ import type Stripe from "stripe";
3
+
4
+ /**
5
+ * Internal metadata fields for Stripe Customer.
6
+ */
7
+ type CustomerInternalMetadata =
8
+ | { customerType: "user"; userId: string }
9
+ | { customerType: "organization"; organizationId: string };
10
+
11
+ /**
12
+ * Internal metadata fields for Stripe Subscription/Checkout.
13
+ */
14
+ type SubscriptionInternalMetadata = {
15
+ userId: string;
16
+ subscriptionId: string;
17
+ referenceId: string;
18
+ };
19
+
20
+ /**
21
+ * Customer metadata - set internal fields and extract typed fields.
22
+ */
23
+ export const customerMetadata = {
24
+ /**
25
+ * Internal metadata keys for type-safe access.
26
+ */
27
+ keys: {
28
+ userId: "userId",
29
+ organizationId: "organizationId",
30
+ customerType: "customerType",
31
+ } as const,
32
+
33
+ /**
34
+ * Create metadata with internal fields that cannot be overridden by user metadata.
35
+ * Uses `defu` which prioritizes the first argument.
36
+ */
37
+ set(
38
+ internalFields: CustomerInternalMetadata,
39
+ ...userMetadata: (Stripe.Emptyable<Stripe.MetadataParam> | undefined)[]
40
+ ): Stripe.MetadataParam {
41
+ return defu(internalFields, ...userMetadata.filter(Boolean));
42
+ },
43
+
44
+ /**
45
+ * Extract internal fields from Stripe metadata.
46
+ * Provides type-safe access to internal metadata keys.
47
+ */
48
+ get(metadata: Stripe.Metadata | null | undefined) {
49
+ return {
50
+ userId: metadata?.userId,
51
+ organizationId: metadata?.organizationId,
52
+ customerType: metadata?.customerType as
53
+ | CustomerInternalMetadata["customerType"]
54
+ | undefined,
55
+ };
56
+ },
57
+ };
58
+
59
+ /**
60
+ * Subscription/Checkout metadata - set internal fields and extract typed fields.
61
+ */
62
+ export const subscriptionMetadata = {
63
+ /**
64
+ * Internal metadata keys for type-safe access.
65
+ */
66
+ keys: {
67
+ userId: "userId",
68
+ subscriptionId: "subscriptionId",
69
+ referenceId: "referenceId",
70
+ } as const,
71
+
72
+ /**
73
+ * Create metadata with internal fields that cannot be overridden by user metadata.
74
+ * Uses `defu` which prioritizes the first argument.
75
+ */
76
+ set(
77
+ internalFields: SubscriptionInternalMetadata,
78
+ ...userMetadata: (Stripe.Emptyable<Stripe.MetadataParam> | undefined)[]
79
+ ): Stripe.MetadataParam {
80
+ return defu(internalFields, ...userMetadata.filter(Boolean));
81
+ },
82
+
83
+ /**
84
+ * Extract internal fields from Stripe metadata.
85
+ * Provides type-safe access to internal metadata keys.
86
+ */
87
+ get(metadata: Stripe.Metadata | null | undefined) {
88
+ return {
89
+ userId: metadata?.userId,
90
+ subscriptionId: metadata?.subscriptionId,
91
+ referenceId: metadata?.referenceId,
92
+ };
93
+ },
94
+ };
package/src/routes.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  onSubscriptionDeleted,
15
15
  onSubscriptionUpdated,
16
16
  } from "./hooks";
17
+ import { customerMetadata, subscriptionMetadata } from "./metadata";
17
18
  import { referenceMiddleware, stripeSessionMiddleware } from "./middleware";
18
19
  import type {
19
20
  CustomerType,
@@ -269,8 +270,9 @@ export const upgradeSubscription = (options: StripeOptions) => {
269
270
  });
270
271
  }
271
272
 
272
- // Find existing subscription by Stripe ID or reference ID
273
- let subscriptionToUpdate = ctx.body.subscriptionId
273
+ // If subscriptionId is provided, find that specific subscription.
274
+ // Otherwise, active subscription will be resolved by referenceId later.
275
+ const subscriptionToUpdate = ctx.body.subscriptionId
274
276
  ? await ctx.context.adapter.findOne<Subscription>({
275
277
  model: "subscription",
276
278
  where: [
@@ -280,26 +282,17 @@ export const upgradeSubscription = (options: StripeOptions) => {
280
282
  },
281
283
  ],
282
284
  })
283
- : referenceId
284
- ? await ctx.context.adapter.findOne<Subscription>({
285
- model: "subscription",
286
- where: [
287
- {
288
- field: "referenceId",
289
- value: referenceId,
290
- },
291
- ],
292
- })
293
- : null;
294
-
285
+ : null;
286
+ if (ctx.body.subscriptionId && !subscriptionToUpdate) {
287
+ throw new APIError("BAD_REQUEST", {
288
+ message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
289
+ });
290
+ }
295
291
  if (
296
292
  ctx.body.subscriptionId &&
297
293
  subscriptionToUpdate &&
298
294
  subscriptionToUpdate.referenceId !== referenceId
299
295
  ) {
300
- subscriptionToUpdate = null;
301
- }
302
- if (ctx.body.subscriptionId && !subscriptionToUpdate) {
303
296
  throw new APIError("BAD_REQUEST", {
304
297
  message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
305
298
  });
@@ -334,7 +327,7 @@ export const upgradeSubscription = (options: StripeOptions) => {
334
327
  try {
335
328
  // First, search for existing organization customer by organizationId
336
329
  const existingOrgCustomers = await client.customers.search({
337
- query: `metadata["organizationId"]:"${org.id}"`,
330
+ query: `metadata["${customerMetadata.keys.organizationId}"]:"${org.id}"`,
338
331
  limit: 1,
339
332
  });
340
333
 
@@ -354,15 +347,17 @@ export const upgradeSubscription = (options: StripeOptions) => {
354
347
 
355
348
  // Create Stripe customer for organization
356
349
  // Email can be set via getCustomerCreateParams or updated in billing portal
357
- // Use defu to ensure internal metadata fields are preserved
358
- const customerParams: StripeType.CustomerCreateParams = defu(
350
+ // Use defu to merge params (first argument takes priority)
351
+ const customerParams = defu(
359
352
  {
360
353
  name: org.name,
361
- metadata: {
362
- ...ctx.body.metadata,
363
- organizationId: org.id,
364
- customerType: "organization",
365
- },
354
+ metadata: customerMetadata.set(
355
+ {
356
+ organizationId: org.id,
357
+ customerType: "organization",
358
+ },
359
+ ctx.body.metadata,
360
+ ),
366
361
  },
367
362
  extraCreateParams,
368
363
  );
@@ -411,7 +406,7 @@ export const upgradeSubscription = (options: StripeOptions) => {
411
406
  try {
412
407
  // Try to find existing user Stripe customer by email
413
408
  const existingCustomers = await client.customers.search({
414
- query: `email:"${escapeStripeSearchValue(user.email)}" AND -metadata["customerType"]:"organization"`,
409
+ query: `email:"${escapeStripeSearchValue(user.email)}" AND -metadata["${customerMetadata.keys.customerType}"]:"organization"`,
415
410
  limit: 1,
416
411
  });
417
412
 
@@ -421,11 +416,13 @@ export const upgradeSubscription = (options: StripeOptions) => {
421
416
  stripeCustomer = await client.customers.create({
422
417
  email: user.email,
423
418
  name: user.name,
424
- metadata: {
425
- ...ctx.body.metadata,
426
- userId: user.id,
427
- customerType: "user",
428
- },
419
+ metadata: customerMetadata.set(
420
+ {
421
+ userId: user.id,
422
+ customerType: "user",
423
+ },
424
+ ctx.body.metadata,
425
+ ),
429
426
  });
430
427
  }
431
428
 
@@ -493,17 +490,47 @@ export const upgradeSubscription = (options: StripeOptions) => {
493
490
  return false;
494
491
  });
495
492
 
493
+ // Get the current price ID from the active Stripe subscription
494
+ const stripeSubscriptionPriceId =
495
+ activeSubscription?.items.data[0]?.price.id;
496
+
496
497
  // Also find any incomplete subscription that we can reuse
497
498
  const incompleteSubscription = subscriptions.find(
498
499
  (sub) => sub.status === "incomplete",
499
500
  );
500
501
 
501
- if (
502
- activeOrTrialingSubscription &&
503
- activeOrTrialingSubscription.status === "active" &&
504
- activeOrTrialingSubscription.plan === ctx.body.plan &&
505
- activeOrTrialingSubscription.seats === (ctx.body.seats || 1)
506
- ) {
502
+ const priceId = ctx.body.annual
503
+ ? plan.annualDiscountPriceId
504
+ : plan.priceId;
505
+ const lookupKey = ctx.body.annual
506
+ ? plan.annualDiscountLookupKey
507
+ : plan.lookupKey;
508
+ const resolvedPriceId = lookupKey
509
+ ? await resolvePriceIdFromLookupKey(client, lookupKey)
510
+ : undefined;
511
+
512
+ const priceIdToUse = priceId || resolvedPriceId;
513
+ if (!priceIdToUse) {
514
+ throw ctx.error("BAD_REQUEST", {
515
+ message: "Price ID not found for the selected plan",
516
+ });
517
+ }
518
+
519
+ const isSamePlan = activeOrTrialingSubscription?.plan === ctx.body.plan;
520
+ const isSameSeats =
521
+ activeOrTrialingSubscription?.seats === (ctx.body.seats || 1);
522
+ const isSamePriceId = stripeSubscriptionPriceId === priceIdToUse;
523
+ const isSubscriptionStillValid =
524
+ !activeOrTrialingSubscription?.periodEnd ||
525
+ activeOrTrialingSubscription.periodEnd > new Date();
526
+
527
+ const isAlreadySubscribed =
528
+ activeOrTrialingSubscription?.status === "active" &&
529
+ isSamePlan &&
530
+ isSameSeats &&
531
+ isSamePriceId &&
532
+ isSubscriptionStillValid;
533
+ if (isAlreadySubscribed) {
507
534
  throw new APIError("BAD_REQUEST", {
508
535
  message: STRIPE_ERROR_CODES.ALREADY_SUBSCRIBED_PLAN,
509
536
  });
@@ -539,32 +566,6 @@ export const upgradeSubscription = (options: StripeOptions) => {
539
566
  dbSubscription = activeOrTrialingSubscription;
540
567
  }
541
568
 
542
- // Resolve price ID if using lookup keys
543
- let priceIdToUse: string | undefined = undefined;
544
- if (ctx.body.annual) {
545
- priceIdToUse = plan.annualDiscountPriceId;
546
- if (!priceIdToUse && plan.annualDiscountLookupKey) {
547
- priceIdToUse = await resolvePriceIdFromLookupKey(
548
- client,
549
- plan.annualDiscountLookupKey,
550
- );
551
- }
552
- } else {
553
- priceIdToUse = plan.priceId;
554
- if (!priceIdToUse && plan.lookupKey) {
555
- priceIdToUse = await resolvePriceIdFromLookupKey(
556
- client,
557
- plan.lookupKey,
558
- );
559
- }
560
- }
561
-
562
- if (!priceIdToUse) {
563
- throw ctx.error("BAD_REQUEST", {
564
- message: "Price ID not found for the selected plan",
565
- });
566
- }
567
-
568
569
  const { url } = await client.billingPortal.sessions
569
570
  .create({
570
571
  customer: customerId,
@@ -672,24 +673,6 @@ export const upgradeSubscription = (options: StripeOptions) => {
672
673
  ? { trial_period_days: plan.freeTrial.days }
673
674
  : undefined;
674
675
 
675
- let priceIdToUse: string | undefined = undefined;
676
- if (ctx.body.annual) {
677
- priceIdToUse = plan.annualDiscountPriceId;
678
- if (!priceIdToUse && plan.annualDiscountLookupKey) {
679
- priceIdToUse = await resolvePriceIdFromLookupKey(
680
- client,
681
- plan.annualDiscountLookupKey,
682
- );
683
- }
684
- } else {
685
- priceIdToUse = plan.priceId;
686
- if (!priceIdToUse && plan.lookupKey) {
687
- priceIdToUse = await resolvePriceIdFromLookupKey(
688
- client,
689
- plan.lookupKey,
690
- );
691
- }
692
- }
693
676
  const checkoutSession = await client.checkout.sessions
694
677
  .create(
695
678
  {
@@ -722,24 +705,29 @@ export const upgradeSubscription = (options: StripeOptions) => {
722
705
  ],
723
706
  subscription_data: {
724
707
  ...freeTrial,
725
- metadata: {
726
- ...ctx.body.metadata,
727
- ...params?.params?.subscription_data?.metadata,
728
- userId: user.id,
729
- subscriptionId: subscription.id,
730
- referenceId,
731
- },
708
+ metadata: subscriptionMetadata.set(
709
+ {
710
+ userId: user.id,
711
+ subscriptionId: subscription.id,
712
+ referenceId,
713
+ },
714
+ ctx.body.metadata,
715
+ params?.params?.subscription_data?.metadata,
716
+ ),
732
717
  },
733
718
  mode: "subscription",
734
719
  client_reference_id: referenceId,
735
720
  ...params?.params,
736
- metadata: {
737
- ...ctx.body.metadata,
738
- ...params?.params?.metadata,
739
- userId: user.id,
740
- subscriptionId: subscription.id,
741
- referenceId,
742
- },
721
+ // metadata should come after spread to protect internal fields
722
+ metadata: subscriptionMetadata.set(
723
+ {
724
+ userId: user.id,
725
+ subscriptionId: subscription.id,
726
+ referenceId,
727
+ },
728
+ ctx.body.metadata,
729
+ params?.params?.metadata,
730
+ ),
743
731
  },
744
732
  params?.options,
745
733
  )