@better-auth/stripe 1.3.24 → 1.3.26

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.
@@ -1,17 +1,17 @@
1
1
 
2
- > @better-auth/stripe@1.3.24 build /home/runner/work/better-auth/better-auth/packages/stripe
2
+ > @better-auth/stripe@1.3.26 build /home/runner/work/better-auth/better-auth/packages/stripe
3
3
  > unbuild
4
4
 
5
5
  [info] Automatically detected entries: src/index, src/client [esm] [cjs] [dts]
6
6
  [info] Building stripe
7
7
  [success] Build succeeded for stripe
8
- [log] dist/index.cjs (total size: 45.3 kB, chunk size: 45.3 kB, exports: stripe)
8
+ [log] dist/index.cjs (total size: 49 kB, chunk size: 49 kB, exports: stripe)
9
9
 
10
10
  [log] dist/client.cjs (total size: 270 B, chunk size: 270 B, exports: stripeClient)
11
11
 
12
- [log] dist/index.mjs (total size: 44.5 kB, chunk size: 44.5 kB, exports: stripe)
12
+ [log] dist/index.mjs (total size: 48.2 kB, chunk size: 48.2 kB, exports: stripe)
13
13
 
14
14
  [log] dist/client.mjs (total size: 243 B, chunk size: 243 B, exports: stripeClient)
15
15
 
16
- Σ Total dist size (byte size): 227 kB
16
+ Σ Total dist size (byte size): 236 kB
17
17
  [log]
package/dist/index.cjs CHANGED
@@ -5,6 +5,7 @@ const plugins = require('better-auth/plugins');
5
5
  const z = require('zod/v4');
6
6
  const api = require('better-auth/api');
7
7
  const db = require('better-auth/db');
8
+ const defu = require('defu');
8
9
 
9
10
  function _interopNamespaceCompat(e) {
10
11
  if (e && typeof e === 'object' && 'default' in e) return e;
@@ -550,16 +551,6 @@ const stripe = (options) => {
550
551
  });
551
552
  }
552
553
  }
553
- const activeSubscriptions = await client.subscriptions.list({
554
- customer: customerId
555
- }).then(
556
- (res) => res.data.filter(
557
- (sub) => sub.status === "active" || sub.status === "trialing"
558
- )
559
- );
560
- const activeSubscription = activeSubscriptions.find(
561
- (sub) => subscriptionToUpdate?.stripeSubscriptionId || ctx.body.subscriptionId ? sub.id === subscriptionToUpdate?.stripeSubscriptionId || sub.id === ctx.body.subscriptionId : false
562
- );
563
554
  const subscriptions = subscriptionToUpdate ? [subscriptionToUpdate] : await ctx.context.adapter.findMany({
564
555
  model: "subscription",
565
556
  where: [
@@ -572,6 +563,22 @@ const stripe = (options) => {
572
563
  const activeOrTrialingSubscription = subscriptions.find(
573
564
  (sub) => sub.status === "active" || sub.status === "trialing"
574
565
  );
566
+ const activeSubscriptions = await client.subscriptions.list({
567
+ customer: customerId
568
+ }).then(
569
+ (res) => res.data.filter(
570
+ (sub) => sub.status === "active" || sub.status === "trialing"
571
+ )
572
+ );
573
+ const activeSubscription = activeSubscriptions.find((sub) => {
574
+ if (subscriptionToUpdate?.stripeSubscriptionId || ctx.body.subscriptionId) {
575
+ return sub.id === subscriptionToUpdate?.stripeSubscriptionId || sub.id === ctx.body.subscriptionId;
576
+ }
577
+ if (activeOrTrialingSubscription?.stripeSubscriptionId) {
578
+ return sub.id === activeOrTrialingSubscription.stripeSubscriptionId;
579
+ }
580
+ return false;
581
+ });
575
582
  const incompleteSubscription = subscriptions.find(
576
583
  (sub) => sub.status === "incomplete"
577
584
  );
@@ -581,6 +588,54 @@ const stripe = (options) => {
581
588
  });
582
589
  }
583
590
  if (activeSubscription && customerId) {
591
+ let dbSubscription = await ctx.context.adapter.findOne({
592
+ model: "subscription",
593
+ where: [
594
+ {
595
+ field: "stripeSubscriptionId",
596
+ value: activeSubscription.id
597
+ }
598
+ ]
599
+ });
600
+ if (!dbSubscription && activeOrTrialingSubscription) {
601
+ await ctx.context.adapter.update({
602
+ model: "subscription",
603
+ update: {
604
+ stripeSubscriptionId: activeSubscription.id,
605
+ updatedAt: /* @__PURE__ */ new Date()
606
+ },
607
+ where: [
608
+ {
609
+ field: "id",
610
+ value: activeOrTrialingSubscription.id
611
+ }
612
+ ]
613
+ });
614
+ dbSubscription = activeOrTrialingSubscription;
615
+ }
616
+ let priceIdToUse2 = void 0;
617
+ if (ctx.body.annual) {
618
+ priceIdToUse2 = plan.annualDiscountPriceId;
619
+ if (!priceIdToUse2 && plan.annualDiscountLookupKey) {
620
+ priceIdToUse2 = await resolvePriceIdFromLookupKey(
621
+ client,
622
+ plan.annualDiscountLookupKey
623
+ );
624
+ }
625
+ } else {
626
+ priceIdToUse2 = plan.priceId;
627
+ if (!priceIdToUse2 && plan.lookupKey) {
628
+ priceIdToUse2 = await resolvePriceIdFromLookupKey(
629
+ client,
630
+ plan.lookupKey
631
+ );
632
+ }
633
+ }
634
+ if (!priceIdToUse2) {
635
+ throw ctx.error("BAD_REQUEST", {
636
+ message: "Price ID not found for the selected plan"
637
+ });
638
+ }
584
639
  const { url } = await client.billingPortal.sessions.create({
585
640
  customer: customerId,
586
641
  return_url: getUrl(ctx, ctx.body.returnUrl || "/"),
@@ -598,7 +653,7 @@ const stripe = (options) => {
598
653
  {
599
654
  id: activeSubscription.items.data[0]?.id,
600
655
  quantity: ctx.body.seats || 1,
601
- price: ctx.body.annual ? plan.annualDiscountPriceId : plan.priceId
656
+ price: priceIdToUse2
602
657
  }
603
658
  ]
604
659
  }
@@ -1314,13 +1369,24 @@ const stripe = (options) => {
1314
1369
  create: {
1315
1370
  async after(user, ctx2) {
1316
1371
  if (ctx2 && options.createCustomerOnSignUp) {
1317
- const stripeCustomer = await client.customers.create({
1318
- email: user.email,
1319
- name: user.name,
1320
- metadata: {
1321
- userId: user.id
1322
- }
1323
- });
1372
+ let extraCreateParams = {};
1373
+ if (options.getCustomerCreateParams) {
1374
+ extraCreateParams = await options.getCustomerCreateParams(
1375
+ user,
1376
+ ctx2
1377
+ );
1378
+ }
1379
+ const params = defu.defu(
1380
+ {
1381
+ email: user.email,
1382
+ name: user.name,
1383
+ metadata: {
1384
+ userId: user.id
1385
+ }
1386
+ },
1387
+ extraCreateParams
1388
+ );
1389
+ const stripeCustomer = await client.customers.create(params);
1324
1390
  await ctx2.context.internalAdapter.updateUser(user.id, {
1325
1391
  stripeCustomerId: stripeCustomer.id
1326
1392
  });
@@ -1336,6 +1402,40 @@ const stripe = (options) => {
1336
1402
  );
1337
1403
  }
1338
1404
  }
1405
+ },
1406
+ update: {
1407
+ async after(user, ctx2) {
1408
+ if (!ctx2) return;
1409
+ try {
1410
+ const userWithStripe = user;
1411
+ if (!userWithStripe.stripeCustomerId) return;
1412
+ const stripeCustomer = await client.customers.retrieve(
1413
+ userWithStripe.stripeCustomerId
1414
+ );
1415
+ if (stripeCustomer.deleted) {
1416
+ ctx2.context.logger.warn(
1417
+ `Stripe customer ${userWithStripe.stripeCustomerId} was deleted, cannot update email`
1418
+ );
1419
+ return;
1420
+ }
1421
+ if (stripeCustomer.email !== user.email) {
1422
+ await client.customers.update(
1423
+ userWithStripe.stripeCustomerId,
1424
+ {
1425
+ email: user.email
1426
+ }
1427
+ );
1428
+ ctx2.context.logger.info(
1429
+ `Updated Stripe customer email from ${stripeCustomer.email} to ${user.email}`
1430
+ );
1431
+ }
1432
+ } catch (e) {
1433
+ ctx2.context.logger.error(
1434
+ `Failed to sync email to Stripe customer: ${e.message}`,
1435
+ e
1436
+ );
1437
+ }
1438
+ }
1339
1439
  }
1340
1440
  }
1341
1441
  }
package/dist/index.d.cts CHANGED
@@ -240,10 +240,7 @@ interface StripeOptions {
240
240
  * @param data - data containing user and session
241
241
  * @returns
242
242
  */
243
- getCustomerCreateParams?: (data: {
244
- user: User;
245
- session: Session;
246
- }, ctx: GenericEndpointContext) => Promise<{}>;
243
+ getCustomerCreateParams?: (user: User, ctx: GenericEndpointContext) => Promise<Partial<Stripe.CustomerCreateParams>>;
247
244
  /**
248
245
  * Subscriptions
249
246
  */
@@ -1081,6 +1078,17 @@ declare const stripe: <O extends StripeOptions>(options: O) => {
1081
1078
  image?: string | null | undefined;
1082
1079
  } & Record<string, unknown>, ctx: GenericEndpointContext | undefined): Promise<void>;
1083
1080
  };
1081
+ update: {
1082
+ after(user: {
1083
+ id: string;
1084
+ createdAt: Date;
1085
+ updatedAt: Date;
1086
+ email: string;
1087
+ emailVerified: boolean;
1088
+ name: string;
1089
+ image?: string | null | undefined;
1090
+ } & Record<string, unknown>, ctx: GenericEndpointContext | undefined): Promise<void>;
1091
+ };
1084
1092
  };
1085
1093
  };
1086
1094
  };
package/dist/index.d.mts CHANGED
@@ -240,10 +240,7 @@ interface StripeOptions {
240
240
  * @param data - data containing user and session
241
241
  * @returns
242
242
  */
243
- getCustomerCreateParams?: (data: {
244
- user: User;
245
- session: Session;
246
- }, ctx: GenericEndpointContext) => Promise<{}>;
243
+ getCustomerCreateParams?: (user: User, ctx: GenericEndpointContext) => Promise<Partial<Stripe.CustomerCreateParams>>;
247
244
  /**
248
245
  * Subscriptions
249
246
  */
@@ -1081,6 +1078,17 @@ declare const stripe: <O extends StripeOptions>(options: O) => {
1081
1078
  image?: string | null | undefined;
1082
1079
  } & Record<string, unknown>, ctx: GenericEndpointContext | undefined): Promise<void>;
1083
1080
  };
1081
+ update: {
1082
+ after(user: {
1083
+ id: string;
1084
+ createdAt: Date;
1085
+ updatedAt: Date;
1086
+ email: string;
1087
+ emailVerified: boolean;
1088
+ name: string;
1089
+ image?: string | null | undefined;
1090
+ } & Record<string, unknown>, ctx: GenericEndpointContext | undefined): Promise<void>;
1091
+ };
1084
1092
  };
1085
1093
  };
1086
1094
  };
package/dist/index.d.ts CHANGED
@@ -240,10 +240,7 @@ interface StripeOptions {
240
240
  * @param data - data containing user and session
241
241
  * @returns
242
242
  */
243
- getCustomerCreateParams?: (data: {
244
- user: User;
245
- session: Session;
246
- }, ctx: GenericEndpointContext) => Promise<{}>;
243
+ getCustomerCreateParams?: (user: User, ctx: GenericEndpointContext) => Promise<Partial<Stripe.CustomerCreateParams>>;
247
244
  /**
248
245
  * Subscriptions
249
246
  */
@@ -1081,6 +1078,17 @@ declare const stripe: <O extends StripeOptions>(options: O) => {
1081
1078
  image?: string | null | undefined;
1082
1079
  } & Record<string, unknown>, ctx: GenericEndpointContext | undefined): Promise<void>;
1083
1080
  };
1081
+ update: {
1082
+ after(user: {
1083
+ id: string;
1084
+ createdAt: Date;
1085
+ updatedAt: Date;
1086
+ email: string;
1087
+ emailVerified: boolean;
1088
+ name: string;
1089
+ image?: string | null | undefined;
1090
+ } & Record<string, unknown>, ctx: GenericEndpointContext | undefined): Promise<void>;
1091
+ };
1084
1092
  };
1085
1093
  };
1086
1094
  };
package/dist/index.mjs CHANGED
@@ -3,6 +3,7 @@ import { createAuthEndpoint, createAuthMiddleware } from 'better-auth/plugins';
3
3
  import * as z from 'zod/v4';
4
4
  import { sessionMiddleware, originCheck, APIError, getSessionFromCtx } from 'better-auth/api';
5
5
  import { mergeSchema } from 'better-auth/db';
6
+ import { defu } from 'defu';
6
7
 
7
8
  async function getPlans(options) {
8
9
  return typeof options?.subscription?.plans === "function" ? await options.subscription?.plans() : options.subscription?.plans;
@@ -534,16 +535,6 @@ const stripe = (options) => {
534
535
  });
535
536
  }
536
537
  }
537
- const activeSubscriptions = await client.subscriptions.list({
538
- customer: customerId
539
- }).then(
540
- (res) => res.data.filter(
541
- (sub) => sub.status === "active" || sub.status === "trialing"
542
- )
543
- );
544
- const activeSubscription = activeSubscriptions.find(
545
- (sub) => subscriptionToUpdate?.stripeSubscriptionId || ctx.body.subscriptionId ? sub.id === subscriptionToUpdate?.stripeSubscriptionId || sub.id === ctx.body.subscriptionId : false
546
- );
547
538
  const subscriptions = subscriptionToUpdate ? [subscriptionToUpdate] : await ctx.context.adapter.findMany({
548
539
  model: "subscription",
549
540
  where: [
@@ -556,6 +547,22 @@ const stripe = (options) => {
556
547
  const activeOrTrialingSubscription = subscriptions.find(
557
548
  (sub) => sub.status === "active" || sub.status === "trialing"
558
549
  );
550
+ const activeSubscriptions = await client.subscriptions.list({
551
+ customer: customerId
552
+ }).then(
553
+ (res) => res.data.filter(
554
+ (sub) => sub.status === "active" || sub.status === "trialing"
555
+ )
556
+ );
557
+ const activeSubscription = activeSubscriptions.find((sub) => {
558
+ if (subscriptionToUpdate?.stripeSubscriptionId || ctx.body.subscriptionId) {
559
+ return sub.id === subscriptionToUpdate?.stripeSubscriptionId || sub.id === ctx.body.subscriptionId;
560
+ }
561
+ if (activeOrTrialingSubscription?.stripeSubscriptionId) {
562
+ return sub.id === activeOrTrialingSubscription.stripeSubscriptionId;
563
+ }
564
+ return false;
565
+ });
559
566
  const incompleteSubscription = subscriptions.find(
560
567
  (sub) => sub.status === "incomplete"
561
568
  );
@@ -565,6 +572,54 @@ const stripe = (options) => {
565
572
  });
566
573
  }
567
574
  if (activeSubscription && customerId) {
575
+ let dbSubscription = await ctx.context.adapter.findOne({
576
+ model: "subscription",
577
+ where: [
578
+ {
579
+ field: "stripeSubscriptionId",
580
+ value: activeSubscription.id
581
+ }
582
+ ]
583
+ });
584
+ if (!dbSubscription && activeOrTrialingSubscription) {
585
+ await ctx.context.adapter.update({
586
+ model: "subscription",
587
+ update: {
588
+ stripeSubscriptionId: activeSubscription.id,
589
+ updatedAt: /* @__PURE__ */ new Date()
590
+ },
591
+ where: [
592
+ {
593
+ field: "id",
594
+ value: activeOrTrialingSubscription.id
595
+ }
596
+ ]
597
+ });
598
+ dbSubscription = activeOrTrialingSubscription;
599
+ }
600
+ let priceIdToUse2 = void 0;
601
+ if (ctx.body.annual) {
602
+ priceIdToUse2 = plan.annualDiscountPriceId;
603
+ if (!priceIdToUse2 && plan.annualDiscountLookupKey) {
604
+ priceIdToUse2 = await resolvePriceIdFromLookupKey(
605
+ client,
606
+ plan.annualDiscountLookupKey
607
+ );
608
+ }
609
+ } else {
610
+ priceIdToUse2 = plan.priceId;
611
+ if (!priceIdToUse2 && plan.lookupKey) {
612
+ priceIdToUse2 = await resolvePriceIdFromLookupKey(
613
+ client,
614
+ plan.lookupKey
615
+ );
616
+ }
617
+ }
618
+ if (!priceIdToUse2) {
619
+ throw ctx.error("BAD_REQUEST", {
620
+ message: "Price ID not found for the selected plan"
621
+ });
622
+ }
568
623
  const { url } = await client.billingPortal.sessions.create({
569
624
  customer: customerId,
570
625
  return_url: getUrl(ctx, ctx.body.returnUrl || "/"),
@@ -582,7 +637,7 @@ const stripe = (options) => {
582
637
  {
583
638
  id: activeSubscription.items.data[0]?.id,
584
639
  quantity: ctx.body.seats || 1,
585
- price: ctx.body.annual ? plan.annualDiscountPriceId : plan.priceId
640
+ price: priceIdToUse2
586
641
  }
587
642
  ]
588
643
  }
@@ -1298,13 +1353,24 @@ const stripe = (options) => {
1298
1353
  create: {
1299
1354
  async after(user, ctx2) {
1300
1355
  if (ctx2 && options.createCustomerOnSignUp) {
1301
- const stripeCustomer = await client.customers.create({
1302
- email: user.email,
1303
- name: user.name,
1304
- metadata: {
1305
- userId: user.id
1306
- }
1307
- });
1356
+ let extraCreateParams = {};
1357
+ if (options.getCustomerCreateParams) {
1358
+ extraCreateParams = await options.getCustomerCreateParams(
1359
+ user,
1360
+ ctx2
1361
+ );
1362
+ }
1363
+ const params = defu(
1364
+ {
1365
+ email: user.email,
1366
+ name: user.name,
1367
+ metadata: {
1368
+ userId: user.id
1369
+ }
1370
+ },
1371
+ extraCreateParams
1372
+ );
1373
+ const stripeCustomer = await client.customers.create(params);
1308
1374
  await ctx2.context.internalAdapter.updateUser(user.id, {
1309
1375
  stripeCustomerId: stripeCustomer.id
1310
1376
  });
@@ -1320,6 +1386,40 @@ const stripe = (options) => {
1320
1386
  );
1321
1387
  }
1322
1388
  }
1389
+ },
1390
+ update: {
1391
+ async after(user, ctx2) {
1392
+ if (!ctx2) return;
1393
+ try {
1394
+ const userWithStripe = user;
1395
+ if (!userWithStripe.stripeCustomerId) return;
1396
+ const stripeCustomer = await client.customers.retrieve(
1397
+ userWithStripe.stripeCustomerId
1398
+ );
1399
+ if (stripeCustomer.deleted) {
1400
+ ctx2.context.logger.warn(
1401
+ `Stripe customer ${userWithStripe.stripeCustomerId} was deleted, cannot update email`
1402
+ );
1403
+ return;
1404
+ }
1405
+ if (stripeCustomer.email !== user.email) {
1406
+ await client.customers.update(
1407
+ userWithStripe.stripeCustomerId,
1408
+ {
1409
+ email: user.email
1410
+ }
1411
+ );
1412
+ ctx2.context.logger.info(
1413
+ `Updated Stripe customer email from ${stripeCustomer.email} to ${user.email}`
1414
+ );
1415
+ }
1416
+ } catch (e) {
1417
+ ctx2.context.logger.error(
1418
+ `Failed to sync email to Stripe customer: ${e.message}`,
1419
+ e
1420
+ );
1421
+ }
1422
+ }
1323
1423
  }
1324
1424
  }
1325
1425
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@better-auth/stripe",
3
3
  "author": "Bereket Engida",
4
- "version": "1.3.24",
4
+ "version": "1.3.26",
5
5
  "main": "dist/index.cjs",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -37,17 +37,18 @@
37
37
  }
38
38
  },
39
39
  "dependencies": {
40
+ "defu": "^6.1.4",
40
41
  "zod": "^4.1.5"
41
42
  },
42
43
  "peerDependencies": {
43
44
  "stripe": "^18",
44
- "better-auth": "1.3.24"
45
+ "better-auth": "1.3.26"
45
46
  },
46
47
  "devDependencies": {
47
48
  "better-call": "1.0.19",
48
49
  "stripe": "^18.5.0",
49
50
  "unbuild": "3.6.1",
50
- "better-auth": "1.3.24"
51
+ "better-auth": "1.3.26"
51
52
  },
52
53
  "scripts": {
53
54
  "test": "vitest",
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ import type {
26
26
  } from "./types";
27
27
  import { getPlanByName, getPlanByPriceInfo, getPlans } from "./utils";
28
28
  import { getSchema } from "./schema";
29
+ import { defu } from "defu";
29
30
 
30
31
  const STRIPE_ERROR_CODES = {
31
32
  SUBSCRIPTION_NOT_FOUND: "Subscription not found",
@@ -325,23 +326,6 @@ export const stripe = <O extends StripeOptions>(options: O) => {
325
326
  }
326
327
  }
327
328
 
328
- const activeSubscriptions = await client.subscriptions
329
- .list({
330
- customer: customerId,
331
- })
332
- .then((res) =>
333
- res.data.filter(
334
- (sub) => sub.status === "active" || sub.status === "trialing",
335
- ),
336
- );
337
-
338
- const activeSubscription = activeSubscriptions.find((sub) =>
339
- subscriptionToUpdate?.stripeSubscriptionId || ctx.body.subscriptionId
340
- ? sub.id === subscriptionToUpdate?.stripeSubscriptionId ||
341
- sub.id === ctx.body.subscriptionId
342
- : false,
343
- );
344
-
345
329
  const subscriptions = subscriptionToUpdate
346
330
  ? [subscriptionToUpdate]
347
331
  : await ctx.context.adapter.findMany<Subscription>({
@@ -358,6 +342,34 @@ export const stripe = <O extends StripeOptions>(options: O) => {
358
342
  (sub) => sub.status === "active" || sub.status === "trialing",
359
343
  );
360
344
 
345
+ const activeSubscriptions = await client.subscriptions
346
+ .list({
347
+ customer: customerId,
348
+ })
349
+ .then((res) =>
350
+ res.data.filter(
351
+ (sub) => sub.status === "active" || sub.status === "trialing",
352
+ ),
353
+ );
354
+
355
+ const activeSubscription = activeSubscriptions.find((sub) => {
356
+ // If we have a specific subscription to update, match by ID
357
+ if (
358
+ subscriptionToUpdate?.stripeSubscriptionId ||
359
+ ctx.body.subscriptionId
360
+ ) {
361
+ return (
362
+ sub.id === subscriptionToUpdate?.stripeSubscriptionId ||
363
+ sub.id === ctx.body.subscriptionId
364
+ );
365
+ }
366
+ // Only find subscription for the same referenceId to avoid mixing personal and org subscriptions
367
+ if (activeOrTrialingSubscription?.stripeSubscriptionId) {
368
+ return sub.id === activeOrTrialingSubscription.stripeSubscriptionId;
369
+ }
370
+ return false;
371
+ });
372
+
361
373
  // Also find any incomplete subscription that we can reuse
362
374
  const incompleteSubscription = subscriptions.find(
363
375
  (sub) => sub.status === "incomplete",
@@ -375,6 +387,61 @@ export const stripe = <O extends StripeOptions>(options: O) => {
375
387
  }
376
388
 
377
389
  if (activeSubscription && customerId) {
390
+ // Find the corresponding database subscription for this Stripe subscription
391
+ let dbSubscription = await ctx.context.adapter.findOne<Subscription>({
392
+ model: "subscription",
393
+ where: [
394
+ {
395
+ field: "stripeSubscriptionId",
396
+ value: activeSubscription.id,
397
+ },
398
+ ],
399
+ });
400
+
401
+ // If no database record exists for this Stripe subscription, update the existing one
402
+ if (!dbSubscription && activeOrTrialingSubscription) {
403
+ await ctx.context.adapter.update<InputSubscription>({
404
+ model: "subscription",
405
+ update: {
406
+ stripeSubscriptionId: activeSubscription.id,
407
+ updatedAt: new Date(),
408
+ },
409
+ where: [
410
+ {
411
+ field: "id",
412
+ value: activeOrTrialingSubscription.id,
413
+ },
414
+ ],
415
+ });
416
+ dbSubscription = activeOrTrialingSubscription;
417
+ }
418
+
419
+ // Resolve price ID if using lookup keys
420
+ let priceIdToUse: string | undefined = undefined;
421
+ if (ctx.body.annual) {
422
+ priceIdToUse = plan.annualDiscountPriceId;
423
+ if (!priceIdToUse && plan.annualDiscountLookupKey) {
424
+ priceIdToUse = await resolvePriceIdFromLookupKey(
425
+ client,
426
+ plan.annualDiscountLookupKey,
427
+ );
428
+ }
429
+ } else {
430
+ priceIdToUse = plan.priceId;
431
+ if (!priceIdToUse && plan.lookupKey) {
432
+ priceIdToUse = await resolvePriceIdFromLookupKey(
433
+ client,
434
+ plan.lookupKey,
435
+ );
436
+ }
437
+ }
438
+
439
+ if (!priceIdToUse) {
440
+ throw ctx.error("BAD_REQUEST", {
441
+ message: "Price ID not found for the selected plan",
442
+ });
443
+ }
444
+
378
445
  const { url } = await client.billingPortal.sessions
379
446
  .create({
380
447
  customer: customerId,
@@ -393,9 +460,7 @@ export const stripe = <O extends StripeOptions>(options: O) => {
393
460
  {
394
461
  id: activeSubscription.items.data[0]?.id as string,
395
462
  quantity: ctx.body.seats || 1,
396
- price: ctx.body.annual
397
- ? plan.annualDiscountPriceId
398
- : plan.priceId,
463
+ price: priceIdToUse,
399
464
  },
400
465
  ],
401
466
  },
@@ -1235,13 +1300,27 @@ export const stripe = <O extends StripeOptions>(options: O) => {
1235
1300
  create: {
1236
1301
  async after(user, ctx) {
1237
1302
  if (ctx && options.createCustomerOnSignUp) {
1238
- const stripeCustomer = await client.customers.create({
1239
- email: user.email,
1240
- name: user.name,
1241
- metadata: {
1242
- userId: user.id,
1303
+ let extraCreateParams: Partial<Stripe.CustomerCreateParams> =
1304
+ {};
1305
+ if (options.getCustomerCreateParams) {
1306
+ extraCreateParams = await options.getCustomerCreateParams(
1307
+ user,
1308
+ ctx,
1309
+ );
1310
+ }
1311
+
1312
+ const params: Stripe.CustomerCreateParams = defu(
1313
+ {
1314
+ email: user.email,
1315
+ name: user.name,
1316
+ metadata: {
1317
+ userId: user.id,
1318
+ },
1243
1319
  },
1244
- });
1320
+ extraCreateParams,
1321
+ );
1322
+ const stripeCustomer =
1323
+ await client.customers.create(params);
1245
1324
  await ctx.context.internalAdapter.updateUser(user.id, {
1246
1325
  stripeCustomerId: stripeCustomer.id,
1247
1326
  });
@@ -1258,6 +1337,56 @@ export const stripe = <O extends StripeOptions>(options: O) => {
1258
1337
  }
1259
1338
  },
1260
1339
  },
1340
+ update: {
1341
+ async after(user, ctx) {
1342
+ if (!ctx) return;
1343
+
1344
+ try {
1345
+ // Cast user to include stripeCustomerId (added by the stripe plugin schema)
1346
+ const userWithStripe = user as typeof user & {
1347
+ stripeCustomerId?: string;
1348
+ };
1349
+
1350
+ // Only proceed if user has a Stripe customer ID
1351
+ if (!userWithStripe.stripeCustomerId) return;
1352
+
1353
+ // Get the user from the database to check if email actually changed
1354
+ // The 'user' parameter here is the freshly updated user
1355
+ // We need to check if the Stripe customer's email matches
1356
+ const stripeCustomer = await client.customers.retrieve(
1357
+ userWithStripe.stripeCustomerId,
1358
+ );
1359
+
1360
+ // Check if customer was deleted
1361
+ if (stripeCustomer.deleted) {
1362
+ ctx.context.logger.warn(
1363
+ `Stripe customer ${userWithStripe.stripeCustomerId} was deleted, cannot update email`,
1364
+ );
1365
+ return;
1366
+ }
1367
+
1368
+ // If Stripe customer email doesn't match the user's current email, update it
1369
+ if (stripeCustomer.email !== user.email) {
1370
+ await client.customers.update(
1371
+ userWithStripe.stripeCustomerId,
1372
+ {
1373
+ email: user.email,
1374
+ },
1375
+ );
1376
+ ctx.context.logger.info(
1377
+ `Updated Stripe customer email from ${stripeCustomer.email} to ${user.email}`,
1378
+ );
1379
+ }
1380
+ } catch (e: any) {
1381
+ // Ignore errors - this is a best-effort sync
1382
+ // Email might have been deleted or Stripe customer might not exist
1383
+ ctx.context.logger.error(
1384
+ `Failed to sync email to Stripe customer: ${e.message}`,
1385
+ e,
1386
+ );
1387
+ }
1388
+ },
1389
+ },
1261
1390
  },
1262
1391
  },
1263
1392
  },
@@ -17,6 +17,16 @@ describe("stripe", async () => {
17
17
  },
18
18
  customers: {
19
19
  create: vi.fn().mockResolvedValue({ id: "cus_mock123" }),
20
+ list: vi.fn().mockResolvedValue({ data: [] }),
21
+ retrieve: vi.fn().mockResolvedValue({
22
+ id: "cus_mock123",
23
+ email: "test@email.com",
24
+ deleted: false,
25
+ }),
26
+ update: vi.fn().mockResolvedValue({
27
+ id: "cus_mock123",
28
+ email: "newemail@example.com",
29
+ }),
20
30
  },
21
31
  checkout: {
22
32
  sessions: {
@@ -1188,6 +1198,141 @@ describe("stripe", async () => {
1188
1198
  expect(hasTrialData).toBe(true);
1189
1199
  });
1190
1200
 
1201
+ it("should upgrade existing subscription instead of creating new one", async () => {
1202
+ // Reset mocks for this test
1203
+ vi.clearAllMocks();
1204
+
1205
+ // Create a user
1206
+ const userRes = await authClient.signUp.email(
1207
+ { ...testUser, email: "upgrade-existing@email.com" },
1208
+ { throw: true },
1209
+ );
1210
+
1211
+ const headers = new Headers();
1212
+ await authClient.signIn.email(
1213
+ { ...testUser, email: "upgrade-existing@email.com" },
1214
+ {
1215
+ throw: true,
1216
+ onSuccess: setCookieToHeader(headers),
1217
+ },
1218
+ );
1219
+
1220
+ // Mock customers.list to find existing customer
1221
+ mockStripe.customers.list.mockResolvedValueOnce({
1222
+ data: [{ id: "cus_test_123" }],
1223
+ });
1224
+
1225
+ // First create a starter subscription
1226
+ await authClient.subscription.upgrade({
1227
+ plan: "starter",
1228
+ fetchOptions: { headers },
1229
+ });
1230
+
1231
+ // Simulate the subscription being active
1232
+ const starterSub = await ctx.adapter.findOne<Subscription>({
1233
+ model: "subscription",
1234
+ where: [
1235
+ {
1236
+ field: "referenceId",
1237
+ value: userRes.user.id,
1238
+ },
1239
+ ],
1240
+ });
1241
+
1242
+ await ctx.adapter.update({
1243
+ model: "subscription",
1244
+ update: {
1245
+ status: "active",
1246
+ stripeSubscriptionId: "sub_active_test_123",
1247
+ stripeCustomerId: "cus_mock123", // Use the same customer ID as the mock
1248
+ },
1249
+ where: [
1250
+ {
1251
+ field: "id",
1252
+ value: starterSub!.id,
1253
+ },
1254
+ ],
1255
+ });
1256
+
1257
+ // Also update the user with the Stripe customer ID
1258
+ await ctx.adapter.update({
1259
+ model: "user",
1260
+ update: {
1261
+ stripeCustomerId: "cus_mock123",
1262
+ },
1263
+ where: [
1264
+ {
1265
+ field: "id",
1266
+ value: userRes.user.id,
1267
+ },
1268
+ ],
1269
+ });
1270
+
1271
+ // Mock Stripe subscriptions.list to return the active subscription
1272
+ mockStripe.subscriptions.list.mockResolvedValueOnce({
1273
+ data: [
1274
+ {
1275
+ id: "sub_active_test_123",
1276
+ status: "active",
1277
+ items: {
1278
+ data: [
1279
+ {
1280
+ id: "si_test_123",
1281
+ price: { id: process.env.STRIPE_PRICE_ID_1 },
1282
+ quantity: 1,
1283
+ current_period_start: Math.floor(Date.now() / 1000),
1284
+ current_period_end:
1285
+ Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
1286
+ },
1287
+ ],
1288
+ },
1289
+ },
1290
+ ],
1291
+ });
1292
+
1293
+ // Clear mock calls before the upgrade
1294
+ mockStripe.checkout.sessions.create.mockClear();
1295
+ mockStripe.billingPortal.sessions.create.mockClear();
1296
+
1297
+ // Now upgrade to premium plan - should use billing portal to update existing subscription
1298
+ const upgradeRes = await authClient.subscription.upgrade({
1299
+ plan: "premium",
1300
+ fetchOptions: { headers },
1301
+ });
1302
+
1303
+ // Verify that billing portal was called (indicating update, not new subscription)
1304
+ expect(mockStripe.billingPortal.sessions.create).toHaveBeenCalledWith(
1305
+ expect.objectContaining({
1306
+ customer: "cus_mock123",
1307
+ flow_data: expect.objectContaining({
1308
+ type: "subscription_update_confirm",
1309
+ subscription_update_confirm: expect.objectContaining({
1310
+ subscription: "sub_active_test_123",
1311
+ }),
1312
+ }),
1313
+ }),
1314
+ );
1315
+
1316
+ // Should not create a new checkout session
1317
+ expect(mockStripe.checkout.sessions.create).not.toHaveBeenCalled();
1318
+
1319
+ // Verify the response has a redirect URL
1320
+ expect(upgradeRes.data?.url).toBe("https://billing.stripe.com/mock");
1321
+ expect(upgradeRes.data?.redirect).toBe(true);
1322
+
1323
+ // Verify no new subscription was created in the database
1324
+ const allSubs = await ctx.adapter.findMany<Subscription>({
1325
+ model: "subscription",
1326
+ where: [
1327
+ {
1328
+ field: "referenceId",
1329
+ value: userRes.user.id,
1330
+ },
1331
+ ],
1332
+ });
1333
+ expect(allSubs).toHaveLength(1); // Should still have only one subscription
1334
+ });
1335
+
1191
1336
  it("should prevent multiple free trials across different plans", async () => {
1192
1337
  // Create a user
1193
1338
  const userRes = await authClient.signUp.email(
@@ -1280,4 +1425,318 @@ describe("stripe", async () => {
1280
1425
  });
1281
1426
  expect(hasEverTrialed).toBe(true);
1282
1427
  });
1428
+
1429
+ it("should update stripe customer email when user email changes", async () => {
1430
+ // Setup mock for customer retrieve and update
1431
+ mockStripe.customers.retrieve = vi.fn().mockResolvedValue({
1432
+ id: "cus_mock123",
1433
+ email: "test@email.com",
1434
+ deleted: false,
1435
+ });
1436
+ mockStripe.customers.update = vi.fn().mockResolvedValue({
1437
+ id: "cus_mock123",
1438
+ email: "newemail@example.com",
1439
+ });
1440
+
1441
+ // Sign up a user
1442
+ const userRes = await authClient.signUp.email(testUser, {
1443
+ throw: true,
1444
+ });
1445
+
1446
+ expect(userRes.user).toBeDefined();
1447
+
1448
+ // Verify customer was created during signup
1449
+ expect(mockStripe.customers.create).toHaveBeenCalledWith({
1450
+ email: testUser.email,
1451
+ name: testUser.name,
1452
+ metadata: {
1453
+ userId: userRes.user.id,
1454
+ },
1455
+ });
1456
+
1457
+ // Clear mocks to track the update
1458
+ vi.clearAllMocks();
1459
+
1460
+ // Re-setup the retrieve mock for the update flow
1461
+ mockStripe.customers.retrieve = vi.fn().mockResolvedValue({
1462
+ id: "cus_mock123",
1463
+ email: "test@email.com",
1464
+ deleted: false,
1465
+ });
1466
+ mockStripe.customers.update = vi.fn().mockResolvedValue({
1467
+ id: "cus_mock123",
1468
+ email: "newemail@example.com",
1469
+ });
1470
+
1471
+ // Create a mock request context
1472
+ const mockRequest = new Request("http://localhost:3000/api/test", {
1473
+ method: "POST",
1474
+ headers: {
1475
+ "content-type": "application/json",
1476
+ },
1477
+ });
1478
+
1479
+ // Update the user's email using internal adapter (which triggers hooks)
1480
+ await ctx.internalAdapter.updateUserByEmail(
1481
+ testUser.email,
1482
+ {
1483
+ email: "newemail@example.com",
1484
+ },
1485
+ {
1486
+ request: mockRequest,
1487
+ context: ctx,
1488
+ } as any,
1489
+ );
1490
+
1491
+ // Verify that Stripe customer.retrieve was called
1492
+ expect(mockStripe.customers.retrieve).toHaveBeenCalledWith("cus_mock123");
1493
+
1494
+ // Verify that Stripe customer.update was called with the new email
1495
+ expect(mockStripe.customers.update).toHaveBeenCalledWith("cus_mock123", {
1496
+ email: "newemail@example.com",
1497
+ });
1498
+ });
1499
+
1500
+ describe("getCustomerCreateParams", () => {
1501
+ it("should call getCustomerCreateParams and merge with default params", async () => {
1502
+ const getCustomerCreateParamsMock = vi
1503
+ .fn()
1504
+ .mockResolvedValue({ metadata: { customField: "customValue" } });
1505
+
1506
+ const testOptions = {
1507
+ ...stripeOptions,
1508
+ createCustomerOnSignUp: true,
1509
+ getCustomerCreateParams: getCustomerCreateParamsMock,
1510
+ } satisfies StripeOptions;
1511
+
1512
+ const testAuth = betterAuth({
1513
+ database: memory,
1514
+ baseURL: "http://localhost:3000",
1515
+ emailAndPassword: {
1516
+ enabled: true,
1517
+ },
1518
+ plugins: [stripe(testOptions)],
1519
+ });
1520
+
1521
+ const testAuthClient = createAuthClient({
1522
+ baseURL: "http://localhost:3000",
1523
+ plugins: [bearer(), stripeClient({ subscription: true })],
1524
+ fetchOptions: {
1525
+ customFetchImpl: async (url, init) =>
1526
+ testAuth.handler(new Request(url, init)),
1527
+ },
1528
+ });
1529
+
1530
+ // Sign up a user
1531
+ const userRes = await testAuthClient.signUp.email(
1532
+ {
1533
+ email: "custom-params@email.com",
1534
+ password: "password",
1535
+ name: "Custom User",
1536
+ },
1537
+ {
1538
+ throw: true,
1539
+ },
1540
+ );
1541
+
1542
+ // Verify getCustomerCreateParams was called
1543
+ expect(getCustomerCreateParamsMock).toHaveBeenCalledWith(
1544
+ expect.objectContaining({
1545
+ id: userRes.user.id,
1546
+ email: "custom-params@email.com",
1547
+ name: "Custom User",
1548
+ }),
1549
+ expect.objectContaining({
1550
+ context: expect.any(Object),
1551
+ }),
1552
+ );
1553
+
1554
+ // Verify customer was created with merged params
1555
+ expect(mockStripe.customers.create).toHaveBeenCalledWith(
1556
+ expect.objectContaining({
1557
+ email: "custom-params@email.com",
1558
+ name: "Custom User",
1559
+ metadata: expect.objectContaining({
1560
+ userId: userRes.user.id,
1561
+ customField: "customValue",
1562
+ }),
1563
+ }),
1564
+ );
1565
+ });
1566
+
1567
+ it("should use getCustomerCreateParams to add custom address", async () => {
1568
+ const getCustomerCreateParamsMock = vi.fn().mockResolvedValue({
1569
+ address: {
1570
+ line1: "123 Main St",
1571
+ city: "San Francisco",
1572
+ state: "CA",
1573
+ postal_code: "94111",
1574
+ country: "US",
1575
+ },
1576
+ });
1577
+
1578
+ const testOptions = {
1579
+ ...stripeOptions,
1580
+ createCustomerOnSignUp: true,
1581
+ getCustomerCreateParams: getCustomerCreateParamsMock,
1582
+ } satisfies StripeOptions;
1583
+
1584
+ const testAuth = betterAuth({
1585
+ database: memory,
1586
+ baseURL: "http://localhost:3000",
1587
+ emailAndPassword: {
1588
+ enabled: true,
1589
+ },
1590
+ plugins: [stripe(testOptions)],
1591
+ });
1592
+
1593
+ const testAuthClient = createAuthClient({
1594
+ baseURL: "http://localhost:3000",
1595
+ plugins: [bearer(), stripeClient({ subscription: true })],
1596
+ fetchOptions: {
1597
+ customFetchImpl: async (url, init) =>
1598
+ testAuth.handler(new Request(url, init)),
1599
+ },
1600
+ });
1601
+
1602
+ // Sign up a user
1603
+ await testAuthClient.signUp.email(
1604
+ {
1605
+ email: "address-user@email.com",
1606
+ password: "password",
1607
+ name: "Address User",
1608
+ },
1609
+ {
1610
+ throw: true,
1611
+ },
1612
+ );
1613
+
1614
+ // Verify customer was created with address
1615
+ expect(mockStripe.customers.create).toHaveBeenCalledWith(
1616
+ expect.objectContaining({
1617
+ email: "address-user@email.com",
1618
+ name: "Address User",
1619
+ address: {
1620
+ line1: "123 Main St",
1621
+ city: "San Francisco",
1622
+ state: "CA",
1623
+ postal_code: "94111",
1624
+ country: "US",
1625
+ },
1626
+ metadata: expect.objectContaining({
1627
+ userId: expect.any(String),
1628
+ }),
1629
+ }),
1630
+ );
1631
+ });
1632
+
1633
+ it("should properly merge nested objects using defu", async () => {
1634
+ const getCustomerCreateParamsMock = vi.fn().mockResolvedValue({
1635
+ metadata: {
1636
+ customField: "customValue",
1637
+ anotherField: "anotherValue",
1638
+ },
1639
+ phone: "+1234567890",
1640
+ });
1641
+
1642
+ const testOptions = {
1643
+ ...stripeOptions,
1644
+ createCustomerOnSignUp: true,
1645
+ getCustomerCreateParams: getCustomerCreateParamsMock,
1646
+ } satisfies StripeOptions;
1647
+
1648
+ const testAuth = betterAuth({
1649
+ database: memory,
1650
+ baseURL: "http://localhost:3000",
1651
+ emailAndPassword: {
1652
+ enabled: true,
1653
+ },
1654
+ plugins: [stripe(testOptions)],
1655
+ });
1656
+
1657
+ const testAuthClient = createAuthClient({
1658
+ baseURL: "http://localhost:3000",
1659
+ plugins: [bearer(), stripeClient({ subscription: true })],
1660
+ fetchOptions: {
1661
+ customFetchImpl: async (url, init) =>
1662
+ testAuth.handler(new Request(url, init)),
1663
+ },
1664
+ });
1665
+
1666
+ // Sign up a user
1667
+ const userRes = await testAuthClient.signUp.email(
1668
+ {
1669
+ email: "merge-test@email.com",
1670
+ password: "password",
1671
+ name: "Merge User",
1672
+ },
1673
+ {
1674
+ throw: true,
1675
+ },
1676
+ );
1677
+
1678
+ // Verify customer was created with properly merged params
1679
+ // defu merges objects and preserves all fields
1680
+ expect(mockStripe.customers.create).toHaveBeenCalledWith(
1681
+ expect.objectContaining({
1682
+ email: "merge-test@email.com",
1683
+ name: "Merge User",
1684
+ phone: "+1234567890",
1685
+ metadata: {
1686
+ userId: userRes.user.id,
1687
+ customField: "customValue",
1688
+ anotherField: "anotherValue",
1689
+ },
1690
+ }),
1691
+ );
1692
+ });
1693
+
1694
+ it("should work without getCustomerCreateParams", async () => {
1695
+ // This test ensures backward compatibility
1696
+ const testOptions = {
1697
+ ...stripeOptions,
1698
+ createCustomerOnSignUp: true,
1699
+ // No getCustomerCreateParams provided
1700
+ } satisfies StripeOptions;
1701
+
1702
+ const testAuth = betterAuth({
1703
+ database: memory,
1704
+ baseURL: "http://localhost:3000",
1705
+ emailAndPassword: {
1706
+ enabled: true,
1707
+ },
1708
+ plugins: [stripe(testOptions)],
1709
+ });
1710
+
1711
+ const testAuthClient = createAuthClient({
1712
+ baseURL: "http://localhost:3000",
1713
+ plugins: [bearer(), stripeClient({ subscription: true })],
1714
+ fetchOptions: {
1715
+ customFetchImpl: async (url, init) =>
1716
+ testAuth.handler(new Request(url, init)),
1717
+ },
1718
+ });
1719
+
1720
+ // Sign up a user
1721
+ const userRes = await testAuthClient.signUp.email(
1722
+ {
1723
+ email: "no-custom-params@email.com",
1724
+ password: "password",
1725
+ name: "Default User",
1726
+ },
1727
+ {
1728
+ throw: true,
1729
+ },
1730
+ );
1731
+
1732
+ // Verify customer was created with default params only
1733
+ expect(mockStripe.customers.create).toHaveBeenCalledWith({
1734
+ email: "no-custom-params@email.com",
1735
+ name: "Default User",
1736
+ metadata: {
1737
+ userId: userRes.user.id,
1738
+ },
1739
+ });
1740
+ });
1741
+ });
1283
1742
  });
package/src/types.ts CHANGED
@@ -199,12 +199,9 @@ export interface StripeOptions {
199
199
  * @returns
200
200
  */
201
201
  getCustomerCreateParams?: (
202
- data: {
203
- user: User;
204
- session: Session;
205
- },
202
+ user: User,
206
203
  ctx: GenericEndpointContext,
207
- ) => Promise<{}>;
204
+ ) => Promise<Partial<Stripe.CustomerCreateParams>>;
208
205
  /**
209
206
  * Subscriptions
210
207
  */