@better-auth/stripe 1.5.0-beta.8 → 1.5.0

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/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as STRIPE_ERROR_CODES } from "./error-codes-Clj-xYDP.mjs";
1
+ import { t as STRIPE_ERROR_CODES } from "./error-codes-CCosYkXx.mjs";
2
2
  import { APIError, HIDE_METADATA } from "better-auth";
3
3
  import { defu } from "defu";
4
4
  import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
@@ -7,14 +7,54 @@ import { getSessionFromCtx, originCheck, sessionMiddleware } from "better-auth/a
7
7
  import * as z from "zod/v4";
8
8
  import { mergeSchema } from "better-auth/db";
9
9
 
10
+ //#region src/metadata.ts
11
+ /**
12
+ * Customer metadata - set internal fields and extract typed fields.
13
+ */
14
+ const customerMetadata = {
15
+ keys: {
16
+ userId: "userId",
17
+ organizationId: "organizationId",
18
+ customerType: "customerType"
19
+ },
20
+ set(internalFields, ...userMetadata) {
21
+ return defu(internalFields, ...userMetadata.filter(Boolean));
22
+ },
23
+ get(metadata) {
24
+ return {
25
+ userId: metadata?.userId,
26
+ organizationId: metadata?.organizationId,
27
+ customerType: metadata?.customerType
28
+ };
29
+ }
30
+ };
31
+ /**
32
+ * Subscription/Checkout metadata - set internal fields and extract typed fields.
33
+ */
34
+ const subscriptionMetadata = {
35
+ keys: {
36
+ userId: "userId",
37
+ subscriptionId: "subscriptionId",
38
+ referenceId: "referenceId"
39
+ },
40
+ set(internalFields, ...userMetadata) {
41
+ return defu(internalFields, ...userMetadata.filter(Boolean));
42
+ },
43
+ get(metadata) {
44
+ return {
45
+ userId: metadata?.userId,
46
+ subscriptionId: metadata?.subscriptionId,
47
+ referenceId: metadata?.referenceId
48
+ };
49
+ }
50
+ };
51
+
52
+ //#endregion
10
53
  //#region src/utils.ts
11
54
  async function getPlans(subscriptionOptions) {
12
55
  if (subscriptionOptions?.enabled) return typeof subscriptionOptions.plans === "function" ? await subscriptionOptions.plans() : subscriptionOptions.plans;
13
56
  throw new Error("Subscriptions are not enabled in the Stripe options.");
14
57
  }
15
- async function getPlanByPriceInfo(options, priceId, priceLookupKey) {
16
- return await getPlans(options.subscription).then((res) => res?.find((plan) => plan.priceId === priceId || plan.annualDiscountPriceId === priceId || priceLookupKey && (plan.lookupKey === priceLookupKey || plan.annualDiscountLookupKey === priceLookupKey)));
17
- }
18
58
  async function getPlanByName(options, name) {
19
59
  return await getPlans(options.subscription).then((res) => res?.find((plan) => plan.name.toLowerCase() === name.toLowerCase()));
20
60
  }
@@ -46,6 +86,40 @@ function isStripePendingCancel(stripeSub) {
46
86
  function escapeStripeSearchValue(value) {
47
87
  return value.replace(/"/g, "\\\"");
48
88
  }
89
+ /**
90
+ * Resolve the quantity for a subscription by checking the seat item first,
91
+ * then falling back to the plan item's quantity.
92
+ */
93
+ function resolveQuantity(items, planItem, seatPriceId) {
94
+ if (seatPriceId) {
95
+ const seatItem = items.find((item) => item.price.id === seatPriceId);
96
+ if (seatItem) return seatItem.quantity ?? 1;
97
+ }
98
+ return planItem.quantity ?? 1;
99
+ }
100
+ /**
101
+ * Resolve the plan-matching subscription item and its plan config
102
+ * from a (possibly multi-item) Stripe subscription.
103
+ *
104
+ * - Iterates items to find one whose price matches a configured plan.
105
+ * - For single-item subscriptions, returns the item even without a plan match.
106
+ */
107
+ async function resolvePlanItem(options, items) {
108
+ const first = items[0];
109
+ if (!first) return void 0;
110
+ const plans = await getPlans(options.subscription);
111
+ for (const item of items) {
112
+ const plan = plans?.find((p) => p.priceId === item.price.id || p.annualDiscountPriceId === item.price.id || item.price.lookup_key && (p.lookupKey === item.price.lookup_key || p.annualDiscountLookupKey === item.price.lookup_key));
113
+ if (plan) return {
114
+ item,
115
+ plan
116
+ };
117
+ }
118
+ return items.length === 1 ? {
119
+ item: first,
120
+ plan: void 0
121
+ } : void 0;
122
+ }
49
123
 
50
124
  //#endregion
51
125
  //#region src/hooks.ts
@@ -67,16 +141,16 @@ async function findReferenceByStripeCustomerId(ctx, options, stripeCustomerId) {
67
141
  referenceId: org.id
68
142
  };
69
143
  }
70
- const user$1 = await ctx.context.adapter.findOne({
144
+ const user = await ctx.context.adapter.findOne({
71
145
  model: "user",
72
146
  where: [{
73
147
  field: "stripeCustomerId",
74
148
  value: stripeCustomerId
75
149
  }]
76
150
  });
77
- if (user$1) return {
151
+ if (user) return {
78
152
  customerType: "user",
79
- referenceId: user$1.id
153
+ referenceId: user.id
80
154
  };
81
155
  return null;
82
156
  }
@@ -86,18 +160,17 @@ async function onCheckoutSessionCompleted(ctx, options, event) {
86
160
  const checkoutSession = event.data.object;
87
161
  if (checkoutSession.mode === "setup" || !options.subscription?.enabled) return;
88
162
  const subscription = await client.subscriptions.retrieve(checkoutSession.subscription);
89
- const subscriptionItem = subscription.items.data[0];
90
- if (!subscriptionItem) {
91
- ctx.context.logger.warn(`Stripe webhook warning: Subscription ${subscription.id} has no items`);
163
+ const resolved = await resolvePlanItem(options, subscription.items.data);
164
+ if (!resolved) {
165
+ ctx.context.logger.warn(`Stripe webhook warning: Subscription ${subscription.id} has no items matching a configured plan`);
92
166
  return;
93
167
  }
94
- const priceId = subscriptionItem.price.id;
95
- const priceLookupKey = subscriptionItem.price.lookup_key;
96
- const plan = await getPlanByPriceInfo(options, priceId, priceLookupKey);
168
+ const { item: subscriptionItem, plan } = resolved;
97
169
  if (plan) {
98
- const referenceId = checkoutSession?.client_reference_id || checkoutSession?.metadata?.referenceId;
99
- const subscriptionId = checkoutSession?.metadata?.subscriptionId;
100
- const seats = subscriptionItem.quantity;
170
+ const checkoutMeta = subscriptionMetadata.get(checkoutSession?.metadata);
171
+ const referenceId = checkoutSession?.client_reference_id || checkoutMeta.referenceId;
172
+ const { subscriptionId } = checkoutMeta;
173
+ const seats = resolveQuantity(subscription.items.data, subscriptionItem, plan.seatPriceId);
101
174
  if (referenceId && subscriptionId) {
102
175
  const trial = subscription.trial_start && subscription.trial_end ? {
103
176
  trialStart: /* @__PURE__ */ new Date(subscription.trial_start * 1e3),
@@ -106,6 +179,7 @@ async function onCheckoutSessionCompleted(ctx, options, event) {
106
179
  let dbSubscription = await ctx.context.adapter.update({
107
180
  model: "subscription",
108
181
  update: {
182
+ ...trial,
109
183
  plan: plan.name.toLowerCase(),
110
184
  status: subscription.status,
111
185
  updatedAt: /* @__PURE__ */ new Date(),
@@ -117,7 +191,7 @@ async function onCheckoutSessionCompleted(ctx, options, event) {
117
191
  canceledAt: subscription.canceled_at ? /* @__PURE__ */ new Date(subscription.canceled_at * 1e3) : null,
118
192
  endedAt: subscription.ended_at ? /* @__PURE__ */ new Date(subscription.ended_at * 1e3) : null,
119
193
  seats,
120
- ...trial
194
+ billingInterval: subscriptionItem.price.recurring?.interval
121
195
  },
122
196
  where: [{
123
197
  field: "id",
@@ -154,7 +228,7 @@ async function onSubscriptionCreated(ctx, options, event) {
154
228
  ctx.context.logger.warn(`Stripe webhook warning: customer.subscription.created event received without customer ID`);
155
229
  return;
156
230
  }
157
- const subscriptionId = subscriptionCreated.metadata?.subscriptionId;
231
+ const { subscriptionId } = subscriptionMetadata.get(subscriptionCreated.metadata);
158
232
  const existingSubscription = await ctx.context.adapter.findOne({
159
233
  model: "subscription",
160
234
  where: subscriptionId ? [{
@@ -175,18 +249,17 @@ async function onSubscriptionCreated(ctx, options, event) {
175
249
  return;
176
250
  }
177
251
  const { referenceId, customerType } = reference;
178
- const subscriptionItem = subscriptionCreated.items.data[0];
179
- if (!subscriptionItem) {
180
- ctx.context.logger.warn(`Stripe webhook warning: Subscription ${subscriptionCreated.id} has no items`);
252
+ const resolved = await resolvePlanItem(options, subscriptionCreated.items.data);
253
+ if (!resolved) {
254
+ ctx.context.logger.warn(`Stripe webhook warning: Subscription ${subscriptionCreated.id} has no items matching a configured plan`);
181
255
  return;
182
256
  }
183
- const priceId = subscriptionItem.price.id;
184
- const plan = await getPlanByPriceInfo(options, priceId, subscriptionItem.price.lookup_key || null);
257
+ const { item: subscriptionItem, plan } = resolved;
185
258
  if (!plan) {
186
- ctx.context.logger.warn(`Stripe webhook warning: No matching plan found for priceId: ${priceId}`);
259
+ ctx.context.logger.warn(`Stripe webhook warning: No matching plan found for priceId: ${subscriptionItem.price.id}`);
187
260
  return;
188
261
  }
189
- const seats = subscriptionItem.quantity;
262
+ const seats = resolveQuantity(subscriptionCreated.items.data, subscriptionItem, plan.seatPriceId);
190
263
  const periodStart = /* @__PURE__ */ new Date(subscriptionItem.current_period_start * 1e3);
191
264
  const periodEnd = /* @__PURE__ */ new Date(subscriptionItem.current_period_end * 1e3);
192
265
  const trial = subscriptionCreated.trial_start && subscriptionCreated.trial_end ? {
@@ -196,6 +269,8 @@ async function onSubscriptionCreated(ctx, options, event) {
196
269
  const newSubscription = await ctx.context.adapter.create({
197
270
  model: "subscription",
198
271
  data: {
272
+ ...trial,
273
+ ...plan.limits ? { limits: plan.limits } : {},
199
274
  referenceId,
200
275
  stripeCustomerId,
201
276
  stripeSubscriptionId: subscriptionCreated.id,
@@ -204,8 +279,7 @@ async function onSubscriptionCreated(ctx, options, event) {
204
279
  periodStart,
205
280
  periodEnd,
206
281
  seats,
207
- ...plan.limits ? { limits: plan.limits } : {},
208
- ...trial
282
+ billingInterval: subscriptionItem.price.recurring?.interval
209
283
  }
210
284
  });
211
285
  ctx.context.logger.info(`Stripe webhook: Created subscription ${subscriptionCreated.id} for ${customerType} ${referenceId} from dashboard`);
@@ -223,15 +297,13 @@ async function onSubscriptionUpdated(ctx, options, event) {
223
297
  try {
224
298
  if (!options.subscription?.enabled) return;
225
299
  const subscriptionUpdated = event.data.object;
226
- const subscriptionItem = subscriptionUpdated.items.data[0];
227
- if (!subscriptionItem) {
228
- ctx.context.logger.warn(`Stripe webhook warning: Subscription ${subscriptionUpdated.id} has no items`);
300
+ const resolved = await resolvePlanItem(options, subscriptionUpdated.items.data);
301
+ if (!resolved) {
302
+ ctx.context.logger.warn(`Stripe webhook warning: Subscription ${subscriptionUpdated.id} has no items matching a configured plan`);
229
303
  return;
230
304
  }
231
- const priceId = subscriptionItem.price.id;
232
- const priceLookupKey = subscriptionItem.price.lookup_key;
233
- const plan = await getPlanByPriceInfo(options, priceId, priceLookupKey);
234
- const subscriptionId = subscriptionUpdated.metadata?.subscriptionId;
305
+ const { item: subscriptionItem, plan } = resolved;
306
+ const { subscriptionId } = subscriptionMetadata.get(subscriptionUpdated.metadata);
235
307
  const customerId = subscriptionUpdated.customer?.toString();
236
308
  let subscription = await ctx.context.adapter.findOne({
237
309
  model: "subscription",
@@ -260,9 +332,15 @@ async function onSubscriptionUpdated(ctx, options, event) {
260
332
  subscription = activeSub;
261
333
  } else subscription = subs[0];
262
334
  }
335
+ const seats = plan ? resolveQuantity(subscriptionUpdated.items.data, subscriptionItem, plan.seatPriceId) : subscriptionItem.quantity;
336
+ const trial = subscriptionUpdated.trial_start && subscriptionUpdated.trial_end ? {
337
+ trialStart: /* @__PURE__ */ new Date(subscriptionUpdated.trial_start * 1e3),
338
+ trialEnd: /* @__PURE__ */ new Date(subscriptionUpdated.trial_end * 1e3)
339
+ } : {};
263
340
  const updatedSubscription = await ctx.context.adapter.update({
264
341
  model: "subscription",
265
342
  update: {
343
+ ...trial,
266
344
  ...plan ? {
267
345
  plan: plan.name.toLowerCase(),
268
346
  limits: plan.limits
@@ -275,8 +353,10 @@ async function onSubscriptionUpdated(ctx, options, event) {
275
353
  cancelAt: subscriptionUpdated.cancel_at ? /* @__PURE__ */ new Date(subscriptionUpdated.cancel_at * 1e3) : null,
276
354
  canceledAt: subscriptionUpdated.canceled_at ? /* @__PURE__ */ new Date(subscriptionUpdated.canceled_at * 1e3) : null,
277
355
  endedAt: subscriptionUpdated.ended_at ? /* @__PURE__ */ new Date(subscriptionUpdated.ended_at * 1e3) : null,
278
- seats: subscriptionItem.quantity,
279
- stripeSubscriptionId: subscriptionUpdated.id
356
+ seats,
357
+ stripeSubscriptionId: subscriptionUpdated.id,
358
+ billingInterval: subscriptionItem.price.recurring?.interval,
359
+ stripeScheduleId: subscriptionUpdated.schedule ? typeof subscriptionUpdated.schedule === "string" ? subscriptionUpdated.schedule : subscriptionUpdated.schedule.id : null
280
360
  },
281
361
  where: [{
282
362
  field: "id",
@@ -314,6 +394,10 @@ async function onSubscriptionDeleted(ctx, options, event) {
314
394
  }]
315
395
  });
316
396
  if (subscription) {
397
+ const trial = subscriptionDeleted.trial_start && subscriptionDeleted.trial_end ? {
398
+ trialStart: /* @__PURE__ */ new Date(subscriptionDeleted.trial_start * 1e3),
399
+ trialEnd: /* @__PURE__ */ new Date(subscriptionDeleted.trial_end * 1e3)
400
+ } : {};
317
401
  await ctx.context.adapter.update({
318
402
  model: "subscription",
319
403
  where: [{
@@ -321,12 +405,14 @@ async function onSubscriptionDeleted(ctx, options, event) {
321
405
  value: subscription.id
322
406
  }],
323
407
  update: {
408
+ ...trial,
324
409
  status: "canceled",
325
410
  updatedAt: /* @__PURE__ */ new Date(),
326
411
  cancelAtPeriodEnd: subscriptionDeleted.cancel_at_period_end,
327
412
  cancelAt: subscriptionDeleted.cancel_at ? /* @__PURE__ */ new Date(subscriptionDeleted.cancel_at * 1e3) : null,
328
413
  canceledAt: subscriptionDeleted.canceled_at ? /* @__PURE__ */ new Date(subscriptionDeleted.canceled_at * 1e3) : null,
329
- endedAt: subscriptionDeleted.ended_at ? /* @__PURE__ */ new Date(subscriptionDeleted.ended_at * 1e3) : null
414
+ endedAt: subscriptionDeleted.ended_at ? /* @__PURE__ */ new Date(subscriptionDeleted.ended_at * 1e3) : null,
415
+ stripeScheduleId: null
330
416
  }
331
417
  });
332
418
  await options.subscription.onSubscriptionDeleted?.({
@@ -353,7 +439,7 @@ const referenceMiddleware = (subscriptionOptions, action) => createAuthMiddlewar
353
439
  if (customerType === "organization") {
354
440
  if (!subscriptionOptions.authorizeReference) {
355
441
  ctx.context.logger.error(`Organization subscriptions require authorizeReference to be defined in your stripe plugin config.`);
356
- throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.ORGANIZATION_SUBSCRIPTION_NOT_ENABLED);
442
+ throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.AUTHORIZE_REFERENCE_REQUIRED);
357
443
  }
358
444
  const referenceId = explicitReferenceId || ctxSession.session.activeOrganizationId;
359
445
  if (!referenceId) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.ORGANIZATION_REFERENCE_ID_REQUIRED);
@@ -408,13 +494,13 @@ async function resolvePriceIdFromLookupKey(stripeClient, lookupKey) {
408
494
  * @internal
409
495
  */
410
496
  function getReferenceId(ctxSession, customerType, options) {
411
- const { user: user$1, session } = ctxSession;
497
+ const { user, session } = ctxSession;
412
498
  if ((customerType || "user") === "organization") {
413
499
  if (!options.organization?.enabled) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.ORGANIZATION_SUBSCRIPTION_NOT_ENABLED);
414
500
  if (!session.activeOrganizationId) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.ORGANIZATION_NOT_FOUND);
415
501
  return session.activeOrganizationId;
416
502
  }
417
- return user$1.id;
503
+ return user.id;
418
504
  }
419
505
  const upgradeSubscriptionBodySchema = z.object({
420
506
  plan: z.string().meta({ description: "The name of the plan to upgrade to. Eg: \"pro\"" }),
@@ -430,6 +516,7 @@ const upgradeSubscriptionBodySchema = z.object({
430
516
  successUrl: z.string().meta({ description: "Callback URL to redirect back after successful subscription. Eg: \"https://example.com/success\"" }).default("/"),
431
517
  cancelUrl: z.string().meta({ description: "If set, checkout shows a back button and customers will be directed here if they cancel payment. Eg: \"https://example.com/pricing\"" }).default("/"),
432
518
  returnUrl: z.string().meta({ description: "URL to take customers to when they click on the billing portal’s link to return to your website. Eg: \"https://example.com/dashboard\"" }).optional(),
519
+ scheduleAtPeriodEnd: z.boolean().meta({ description: "Schedule the plan change at the end of the current billing period instead of applying immediately." }).default(false),
433
520
  disableRedirect: z.boolean().meta({ description: "Disable redirect after successful subscription. Eg: true" }).default(false)
434
521
  });
435
522
  /**
@@ -462,27 +549,21 @@ const upgradeSubscription = (options) => {
462
549
  })
463
550
  ]
464
551
  }, async (ctx) => {
465
- const { user: user$1, session } = ctx.context.session;
552
+ const { user, session } = ctx.context.session;
466
553
  const customerType = ctx.body.customerType || "user";
467
554
  const referenceId = ctx.body.referenceId || getReferenceId(ctx.context.session, customerType, options);
468
- if (!user$1.emailVerified && subscriptionOptions.requireEmailVerification) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED);
555
+ if (!user.emailVerified && subscriptionOptions.requireEmailVerification) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED);
469
556
  const plan = await getPlanByName(options, ctx.body.plan);
470
557
  if (!plan) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_PLAN_NOT_FOUND);
471
- let subscriptionToUpdate = ctx.body.subscriptionId ? await ctx.context.adapter.findOne({
558
+ const subscriptionToUpdate = ctx.body.subscriptionId ? await ctx.context.adapter.findOne({
472
559
  model: "subscription",
473
560
  where: [{
474
561
  field: "stripeSubscriptionId",
475
562
  value: ctx.body.subscriptionId
476
563
  }]
477
- }) : referenceId ? await ctx.context.adapter.findOne({
478
- model: "subscription",
479
- where: [{
480
- field: "referenceId",
481
- value: referenceId
482
- }]
483
564
  }) : null;
484
- if (ctx.body.subscriptionId && subscriptionToUpdate && subscriptionToUpdate.referenceId !== referenceId) subscriptionToUpdate = null;
485
565
  if (ctx.body.subscriptionId && !subscriptionToUpdate) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
566
+ if (ctx.body.subscriptionId && subscriptionToUpdate && subscriptionToUpdate.referenceId !== referenceId) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
486
567
  let customerId;
487
568
  if (customerType === "organization") {
488
569
  customerId = subscriptionToUpdate?.stripeCustomerId;
@@ -497,20 +578,28 @@ const upgradeSubscription = (options) => {
497
578
  if (!org) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.ORGANIZATION_NOT_FOUND);
498
579
  customerId = org.stripeCustomerId;
499
580
  if (!customerId) try {
500
- let stripeCustomer = (await client.customers.search({
501
- query: `metadata["organizationId"]:"${org.id}"`,
502
- limit: 1
503
- })).data[0];
581
+ let stripeCustomer;
582
+ try {
583
+ stripeCustomer = (await client.customers.search({
584
+ query: `metadata["${customerMetadata.keys.organizationId}"]:"${org.id}"`,
585
+ limit: 1
586
+ })).data[0];
587
+ } catch {
588
+ ctx.context.logger.warn("Stripe customers.search failed, falling back to customers.list");
589
+ for await (const customer of client.customers.list({ limit: 100 })) if (customer.metadata?.[customerMetadata.keys.organizationId] === org.id) {
590
+ stripeCustomer = customer;
591
+ break;
592
+ }
593
+ }
504
594
  if (!stripeCustomer) {
505
595
  let extraCreateParams = {};
506
596
  if (options.organization?.getCustomerCreateParams) extraCreateParams = await options.organization.getCustomerCreateParams(org, ctx);
507
597
  const customerParams = defu({
508
598
  name: org.name,
509
- metadata: {
510
- ...ctx.body.metadata,
599
+ metadata: customerMetadata.set({
511
600
  organizationId: org.id,
512
601
  customerType: "organization"
513
- }
602
+ }, ctx.body.metadata)
514
603
  }, extraCreateParams);
515
604
  stripeCustomer = await client.customers.create(customerParams);
516
605
  await options.organization?.onCustomerCreate?.({
@@ -536,27 +625,38 @@ const upgradeSubscription = (options) => {
536
625
  }
537
626
  }
538
627
  } else {
539
- customerId = subscriptionToUpdate?.stripeCustomerId || user$1.stripeCustomerId;
628
+ customerId = subscriptionToUpdate?.stripeCustomerId || user.stripeCustomerId;
540
629
  if (!customerId) try {
541
- let stripeCustomer = (await client.customers.search({
542
- query: `email:"${escapeStripeSearchValue(user$1.email)}" AND -metadata["customerType"]:"organization"`,
543
- limit: 1
544
- })).data[0];
630
+ let stripeCustomer;
631
+ try {
632
+ stripeCustomer = (await client.customers.search({
633
+ query: `email:"${escapeStripeSearchValue(user.email)}" AND -metadata["${customerMetadata.keys.customerType}"]:"organization"`,
634
+ limit: 1
635
+ })).data[0];
636
+ } catch {
637
+ ctx.context.logger.warn("Stripe customers.search failed, falling back to customers.list");
638
+ for await (const customer of client.customers.list({
639
+ email: user.email,
640
+ limit: 100
641
+ })) if (customer.metadata?.[customerMetadata.keys.customerType] !== "organization") {
642
+ stripeCustomer = customer;
643
+ break;
644
+ }
645
+ }
545
646
  if (!stripeCustomer) stripeCustomer = await client.customers.create({
546
- email: user$1.email,
547
- name: user$1.name,
548
- metadata: {
549
- ...ctx.body.metadata,
550
- userId: user$1.id,
647
+ email: user.email,
648
+ name: user.name,
649
+ metadata: customerMetadata.set({
650
+ userId: user.id,
551
651
  customerType: "user"
552
- }
652
+ }, ctx.body.metadata)
553
653
  });
554
654
  await ctx.context.adapter.update({
555
655
  model: "user",
556
656
  update: { stripeCustomerId: stripeCustomer.id },
557
657
  where: [{
558
658
  field: "id",
559
- value: user$1.id
659
+ value: user.id
560
660
  }]
561
661
  });
562
662
  customerId = stripeCustomer.id;
@@ -565,21 +665,42 @@ const upgradeSubscription = (options) => {
565
665
  throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER);
566
666
  }
567
667
  }
568
- const subscriptions$1 = subscriptionToUpdate ? [subscriptionToUpdate] : await ctx.context.adapter.findMany({
668
+ const subscriptions = subscriptionToUpdate ? [subscriptionToUpdate] : await ctx.context.adapter.findMany({
569
669
  model: "subscription",
570
670
  where: [{
571
671
  field: "referenceId",
572
672
  value: referenceId
573
673
  }]
574
674
  });
575
- const activeOrTrialingSubscription = subscriptions$1.find((sub) => isActiveOrTrialing(sub));
675
+ const activeOrTrialingSubscription = subscriptions.find((sub) => isActiveOrTrialing(sub));
576
676
  const activeSubscription = (await client.subscriptions.list({ customer: customerId }).then((res) => res.data.filter((sub) => isActiveOrTrialing(sub)))).find((sub) => {
577
677
  if (subscriptionToUpdate?.stripeSubscriptionId || ctx.body.subscriptionId) return sub.id === subscriptionToUpdate?.stripeSubscriptionId || sub.id === ctx.body.subscriptionId;
578
678
  if (activeOrTrialingSubscription?.stripeSubscriptionId) return sub.id === activeOrTrialingSubscription.stripeSubscriptionId;
579
679
  return false;
580
680
  });
581
- const incompleteSubscription = subscriptions$1.find((sub) => sub.status === "incomplete");
582
- if (activeOrTrialingSubscription && activeOrTrialingSubscription.status === "active" && activeOrTrialingSubscription.plan === ctx.body.plan && activeOrTrialingSubscription.seats === (ctx.body.seats || 1)) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.ALREADY_SUBSCRIBED_PLAN);
681
+ const planItem = (activeSubscription ? await resolvePlanItem(options, activeSubscription.items.data) : void 0)?.item;
682
+ const stripeSubscriptionPriceId = planItem?.price.id;
683
+ const incompleteSubscription = subscriptions.find((sub) => sub.status === "incomplete");
684
+ const priceId = ctx.body.annual ? plan.annualDiscountPriceId : plan.priceId;
685
+ const lookupKey = ctx.body.annual ? plan.annualDiscountLookupKey : plan.lookupKey;
686
+ const resolvedPriceId = lookupKey ? await resolvePriceIdFromLookupKey(client, lookupKey) : void 0;
687
+ const priceIdToUse = priceId || resolvedPriceId;
688
+ if (!priceIdToUse) throw ctx.error("BAD_REQUEST", { message: "Price ID not found for the selected plan" });
689
+ const isAutoManagedSeats = !!(plan.seatPriceId && customerType === "organization");
690
+ let memberCount = 0;
691
+ if (isAutoManagedSeats) memberCount = await ctx.context.adapter.count({
692
+ model: "member",
693
+ where: [{
694
+ field: "organizationId",
695
+ value: referenceId
696
+ }]
697
+ });
698
+ const isSamePlan = activeOrTrialingSubscription?.plan === ctx.body.plan;
699
+ const isSameSeats = isAutoManagedSeats ? true : activeOrTrialingSubscription?.seats === (ctx.body.seats || 1);
700
+ const isSamePriceId = stripeSubscriptionPriceId === priceIdToUse;
701
+ const isSubscriptionStillValid = !activeOrTrialingSubscription?.periodEnd || activeOrTrialingSubscription.periodEnd > /* @__PURE__ */ new Date();
702
+ const isSeatOnlyPlan = isAutoManagedSeats && plan.seatPriceId === plan.priceId;
703
+ if (activeOrTrialingSubscription?.status === "active" && isSamePlan && isSameSeats && isSamePriceId && isSubscriptionStillValid) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.ALREADY_SUBSCRIBED_PLAN);
583
704
  if (activeSubscription && customerId) {
584
705
  let dbSubscription = await ctx.context.adapter.findOne({
585
706
  model: "subscription",
@@ -602,16 +723,173 @@ const upgradeSubscription = (options) => {
602
723
  });
603
724
  dbSubscription = activeOrTrialingSubscription;
604
725
  }
605
- let priceIdToUse$1 = void 0;
606
- if (ctx.body.annual) {
607
- priceIdToUse$1 = plan.annualDiscountPriceId;
608
- if (!priceIdToUse$1 && plan.annualDiscountLookupKey) priceIdToUse$1 = await resolvePriceIdFromLookupKey(client, plan.annualDiscountLookupKey);
609
- } else {
610
- priceIdToUse$1 = plan.priceId;
611
- if (!priceIdToUse$1 && plan.lookupKey) priceIdToUse$1 = await resolvePriceIdFromLookupKey(client, plan.lookupKey);
726
+ if (!planItem) throw APIError$1.from("NOT_FOUND", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
727
+ if (activeSubscription.schedule) {
728
+ const { data: existingSchedules } = await client.subscriptionSchedules.list({ customer: customerId });
729
+ const existingSchedule = existingSchedules.find((s) => (typeof s.subscription === "string" ? s.subscription : s.subscription?.id) === activeSubscription.id && s.status === "active");
730
+ if (existingSchedule && existingSchedule.metadata?.source === "@better-auth/stripe") {
731
+ await client.subscriptionSchedules.release(existingSchedule.id);
732
+ if (dbSubscription) await ctx.context.adapter.update({
733
+ model: "subscription",
734
+ update: {
735
+ stripeScheduleId: null,
736
+ updatedAt: /* @__PURE__ */ new Date()
737
+ },
738
+ where: [{
739
+ field: "id",
740
+ value: dbSubscription.id
741
+ }]
742
+ });
743
+ }
744
+ }
745
+ const oldPlan = activeOrTrialingSubscription ? await getPlanByName(options, activeOrTrialingSubscription.plan) : void 0;
746
+ const priceMap = /* @__PURE__ */ new Map();
747
+ if (isAutoManagedSeats && plan.seatPriceId) {
748
+ if (oldPlan?.seatPriceId && oldPlan.seatPriceId !== plan.seatPriceId) priceMap.set(oldPlan.seatPriceId, {
749
+ newPrice: plan.seatPriceId,
750
+ quantity: memberCount
751
+ });
612
752
  }
613
- if (!priceIdToUse$1) throw ctx.error("BAD_REQUEST", { message: "Price ID not found for the selected plan" });
614
- const { url } = await client.billingPortal.sessions.create({
753
+ const lineItemDelta = /* @__PURE__ */ new Map();
754
+ for (const li of oldPlan?.lineItems ?? []) if (typeof li.price === "string") lineItemDelta.set(li.price, (lineItemDelta.get(li.price) ?? 0) - 1);
755
+ for (const li of plan.lineItems ?? []) if (typeof li.price === "string") lineItemDelta.set(li.price, (lineItemDelta.get(li.price) ?? 0) + 1);
756
+ for (const [price, delta] of lineItemDelta) if (delta === 0) lineItemDelta.delete(price);
757
+ let upgradeUrl;
758
+ if (ctx.body.scheduleAtPeriodEnd) {
759
+ const schedule = await client.subscriptionSchedules.create({ from_subscription: activeSubscription.id }).catch(async (e) => {
760
+ throw ctx.error("BAD_REQUEST", {
761
+ message: e.message,
762
+ code: e.code
763
+ });
764
+ });
765
+ const currentPhase = schedule.phases[0];
766
+ if (!currentPhase) throw ctx.error("BAD_REQUEST", { message: "Subscription schedule has no phases" });
767
+ const removeQuota = /* @__PURE__ */ new Map();
768
+ for (const [p, d] of lineItemDelta) if (d < 0) removeQuota.set(p, -d);
769
+ const newPhaseItems = [];
770
+ for (const item of currentPhase.items) {
771
+ const itemPriceId = typeof item.price === "string" ? item.price : item.price.id;
772
+ const quota = removeQuota.get(itemPriceId) ?? 0;
773
+ if (quota > 0) {
774
+ removeQuota.set(itemPriceId, quota - 1);
775
+ continue;
776
+ }
777
+ const replacement = priceMap.get(itemPriceId);
778
+ if (replacement) {
779
+ newPhaseItems.push({
780
+ price: replacement.newPrice,
781
+ quantity: replacement.quantity ?? item.quantity
782
+ });
783
+ continue;
784
+ }
785
+ if (itemPriceId === stripeSubscriptionPriceId) {
786
+ newPhaseItems.push({
787
+ price: priceIdToUse,
788
+ quantity: isAutoManagedSeats ? 1 : ctx.body.seats || 1
789
+ });
790
+ continue;
791
+ }
792
+ newPhaseItems.push({
793
+ price: itemPriceId,
794
+ quantity: item.quantity
795
+ });
796
+ const d = lineItemDelta.get(itemPriceId);
797
+ if (d !== void 0 && d > 0) if (d === 1) lineItemDelta.delete(itemPriceId);
798
+ else lineItemDelta.set(itemPriceId, d - 1);
799
+ }
800
+ for (const [price, delta] of lineItemDelta) for (let i = 0; i < delta; i++) newPhaseItems.push({ price });
801
+ await client.subscriptionSchedules.update(schedule.id, {
802
+ metadata: { source: "@better-auth/stripe" },
803
+ end_behavior: "release",
804
+ phases: [{
805
+ items: currentPhase.items.map((item) => ({
806
+ price: typeof item.price === "string" ? item.price : item.price.id,
807
+ quantity: item.quantity
808
+ })),
809
+ start_date: currentPhase.start_date,
810
+ end_date: currentPhase.end_date
811
+ }, {
812
+ items: newPhaseItems,
813
+ start_date: currentPhase.end_date,
814
+ proration_behavior: "none"
815
+ }]
816
+ }).catch(async (e) => {
817
+ throw ctx.error("BAD_REQUEST", {
818
+ message: e.message,
819
+ code: e.code
820
+ });
821
+ });
822
+ if (dbSubscription) await ctx.context.adapter.update({
823
+ model: "subscription",
824
+ update: {
825
+ stripeScheduleId: schedule.id,
826
+ updatedAt: /* @__PURE__ */ new Date()
827
+ },
828
+ where: [{
829
+ field: "id",
830
+ value: dbSubscription.id
831
+ }]
832
+ });
833
+ upgradeUrl = getUrl(ctx, ctx.body.returnUrl || "/");
834
+ } else if (priceMap.size > 0 || lineItemDelta.size > 0) {
835
+ const removeQuota = /* @__PURE__ */ new Map();
836
+ for (const [p, d] of lineItemDelta) if (d < 0) removeQuota.set(p, -d);
837
+ const itemUpdates = [];
838
+ for (const si of activeSubscription.items.data) {
839
+ const quota = removeQuota.get(si.price.id) ?? 0;
840
+ if (quota > 0) {
841
+ removeQuota.set(si.price.id, quota - 1);
842
+ itemUpdates.push({
843
+ id: si.id,
844
+ deleted: true
845
+ });
846
+ continue;
847
+ }
848
+ const replacement = priceMap.get(si.price.id);
849
+ if (replacement) {
850
+ itemUpdates.push({
851
+ id: si.id,
852
+ price: replacement.newPrice,
853
+ quantity: replacement.quantity
854
+ });
855
+ continue;
856
+ }
857
+ if (si.price.id === stripeSubscriptionPriceId) {
858
+ itemUpdates.push({
859
+ id: si.id,
860
+ price: priceIdToUse,
861
+ quantity: isAutoManagedSeats ? 1 : ctx.body.seats || 1
862
+ });
863
+ continue;
864
+ }
865
+ const d = lineItemDelta.get(si.price.id);
866
+ if (d !== void 0 && d > 0) if (d === 1) lineItemDelta.delete(si.price.id);
867
+ else lineItemDelta.set(si.price.id, d - 1);
868
+ }
869
+ for (const [price, delta] of lineItemDelta) for (let i = 0; i < delta; i++) itemUpdates.push({ price });
870
+ await client.subscriptions.update(activeSubscription.id, {
871
+ items: itemUpdates,
872
+ proration_behavior: "create_prorations"
873
+ }).catch(async (e) => {
874
+ throw ctx.error("BAD_REQUEST", {
875
+ message: e.message,
876
+ code: e.code
877
+ });
878
+ });
879
+ if (dbSubscription) await ctx.context.adapter.update({
880
+ model: "subscription",
881
+ update: {
882
+ plan: plan.name.toLowerCase(),
883
+ seats: memberCount,
884
+ updatedAt: /* @__PURE__ */ new Date()
885
+ },
886
+ where: [{
887
+ field: "id",
888
+ value: dbSubscription.id
889
+ }]
890
+ });
891
+ upgradeUrl = getUrl(ctx, ctx.body.returnUrl || "/");
892
+ } else ({url: upgradeUrl} = await client.billingPortal.sessions.create({
615
893
  customer: customerId,
616
894
  return_url: getUrl(ctx, ctx.body.returnUrl || "/"),
617
895
  flow_data: {
@@ -623,9 +901,9 @@ const upgradeSubscription = (options) => {
623
901
  subscription_update_confirm: {
624
902
  subscription: activeSubscription.id,
625
903
  items: [{
626
- id: activeSubscription.items.data[0]?.id,
627
- quantity: ctx.body.seats || 1,
628
- price: priceIdToUse$1
904
+ id: planItem.id,
905
+ price: priceIdToUse,
906
+ ...isAutoManagedSeats ? {} : { quantity: ctx.body.seats || 1 }
629
907
  }]
630
908
  }
631
909
  }
@@ -634,9 +912,9 @@ const upgradeSubscription = (options) => {
634
912
  message: e.message,
635
913
  code: e.code
636
914
  });
637
- });
915
+ }));
638
916
  return ctx.json({
639
- url,
917
+ url: upgradeUrl,
640
918
  redirect: !ctx.body.disableRedirect
641
919
  });
642
920
  }
@@ -645,7 +923,7 @@ const upgradeSubscription = (options) => {
645
923
  model: "subscription",
646
924
  update: {
647
925
  plan: plan.name.toLowerCase(),
648
- seats: ctx.body.seats || 1,
926
+ seats: isAutoManagedSeats ? memberCount : ctx.body.seats || 1,
649
927
  updatedAt: /* @__PURE__ */ new Date()
650
928
  },
651
929
  where: [{
@@ -660,7 +938,7 @@ const upgradeSubscription = (options) => {
660
938
  stripeCustomerId: customerId,
661
939
  status: "incomplete",
662
940
  referenceId,
663
- seats: ctx.body.seats || 1
941
+ seats: isAutoManagedSeats ? memberCount : ctx.body.seats || 1
664
942
  }
665
943
  });
666
944
  if (!subscription) {
@@ -668,7 +946,7 @@ const upgradeSubscription = (options) => {
668
946
  throw APIError$1.from("NOT_FOUND", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
669
947
  }
670
948
  const params = await subscriptionOptions.getCheckoutSessionParams?.({
671
- user: user$1,
949
+ user,
672
950
  session,
673
951
  plan,
674
952
  subscription
@@ -682,14 +960,6 @@ const upgradeSubscription = (options) => {
682
960
  })).some((s) => {
683
961
  return !!(s.trialStart || s.trialEnd) || s.status === "trialing";
684
962
  }) && plan.freeTrial ? { trial_period_days: plan.freeTrial.days } : void 0;
685
- let priceIdToUse = void 0;
686
- if (ctx.body.annual) {
687
- priceIdToUse = plan.annualDiscountPriceId;
688
- if (!priceIdToUse && plan.annualDiscountLookupKey) priceIdToUse = await resolvePriceIdFromLookupKey(client, plan.annualDiscountLookupKey);
689
- } else {
690
- priceIdToUse = plan.priceId;
691
- if (!priceIdToUse && plan.lookupKey) priceIdToUse = await resolvePriceIdFromLookupKey(client, plan.lookupKey);
692
- }
693
963
  const checkoutSession = await client.checkout.sessions.create({
694
964
  ...customerId ? {
695
965
  customer: customerId,
@@ -697,34 +967,37 @@ const upgradeSubscription = (options) => {
697
967
  name: "auto",
698
968
  address: "auto"
699
969
  }
700
- } : { customer_email: user$1.email },
970
+ } : { customer_email: user.email },
701
971
  locale: ctx.body.locale,
702
- success_url: getUrl(ctx, `${ctx.context.baseURL}/subscription/success?callbackURL=${encodeURIComponent(ctx.body.successUrl)}&subscriptionId=${encodeURIComponent(subscription.id)}`),
972
+ success_url: getUrl(ctx, `${ctx.context.baseURL}/subscription/success?callbackURL=${encodeURIComponent(ctx.body.successUrl)}&checkoutSessionId={CHECKOUT_SESSION_ID}`),
703
973
  cancel_url: getUrl(ctx, ctx.body.cancelUrl),
704
- line_items: [{
705
- price: priceIdToUse,
706
- quantity: ctx.body.seats || 1
707
- }],
974
+ line_items: [
975
+ ...!isSeatOnlyPlan ? [{
976
+ price: priceIdToUse,
977
+ quantity: isAutoManagedSeats ? 1 : ctx.body.seats || 1
978
+ }] : [],
979
+ ...isAutoManagedSeats ? [{
980
+ price: plan.seatPriceId,
981
+ quantity: memberCount
982
+ }] : [],
983
+ ...plan.lineItems ?? []
984
+ ],
708
985
  subscription_data: {
709
986
  ...freeTrial,
710
- metadata: {
711
- ...ctx.body.metadata,
712
- ...params?.params?.subscription_data?.metadata,
713
- userId: user$1.id,
987
+ metadata: subscriptionMetadata.set({
988
+ userId: user.id,
714
989
  subscriptionId: subscription.id,
715
990
  referenceId
716
- }
991
+ }, ctx.body.metadata, params?.params?.subscription_data?.metadata)
717
992
  },
718
993
  mode: "subscription",
719
994
  client_reference_id: referenceId,
720
995
  ...params?.params,
721
- metadata: {
722
- ...ctx.body.metadata,
723
- ...params?.params?.metadata,
724
- userId: user$1.id,
996
+ metadata: subscriptionMetadata.set({
997
+ userId: user.id,
725
998
  subscriptionId: subscription.id,
726
999
  referenceId
727
- }
1000
+ }, ctx.body.metadata, params?.params?.metadata)
728
1001
  }, params?.options).catch(async (e) => {
729
1002
  throw ctx.error("BAD_REQUEST", {
730
1003
  message: e.message,
@@ -737,61 +1010,6 @@ const upgradeSubscription = (options) => {
737
1010
  });
738
1011
  });
739
1012
  };
740
- const cancelSubscriptionCallbackQuerySchema = z.record(z.string(), z.any()).optional();
741
- const cancelSubscriptionCallback = (options) => {
742
- const client = options.stripeClient;
743
- const subscriptionOptions = options.subscription;
744
- return createAuthEndpoint("/subscription/cancel/callback", {
745
- method: "GET",
746
- query: cancelSubscriptionCallbackQuerySchema,
747
- metadata: { openapi: { operationId: "cancelSubscriptionCallback" } },
748
- use: [originCheck((ctx) => ctx.query.callbackURL)]
749
- }, async (ctx) => {
750
- if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
751
- const session = await getSessionFromCtx(ctx);
752
- if (!session) throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
753
- const { user: user$1 } = session;
754
- const { callbackURL, subscriptionId } = ctx.query;
755
- if (user$1?.stripeCustomerId) try {
756
- const subscription = await ctx.context.adapter.findOne({
757
- model: "subscription",
758
- where: [{
759
- field: "id",
760
- value: subscriptionId
761
- }]
762
- });
763
- if (!subscription || subscription.status === "canceled" || isPendingCancel(subscription)) throw ctx.redirect(getUrl(ctx, callbackURL));
764
- const currentSubscription = (await client.subscriptions.list({
765
- customer: user$1.stripeCustomerId,
766
- status: "active"
767
- })).data.find((sub) => sub.id === subscription.stripeSubscriptionId);
768
- if (currentSubscription && isStripePendingCancel(currentSubscription) && !isPendingCancel(subscription)) {
769
- await ctx.context.adapter.update({
770
- model: "subscription",
771
- update: {
772
- status: currentSubscription?.status,
773
- cancelAtPeriodEnd: currentSubscription?.cancel_at_period_end || false,
774
- cancelAt: currentSubscription?.cancel_at ? /* @__PURE__ */ new Date(currentSubscription.cancel_at * 1e3) : null,
775
- canceledAt: currentSubscription?.canceled_at ? /* @__PURE__ */ new Date(currentSubscription.canceled_at * 1e3) : null
776
- },
777
- where: [{
778
- field: "id",
779
- value: subscription.id
780
- }]
781
- });
782
- await subscriptionOptions.onSubscriptionCancel?.({
783
- subscription,
784
- cancellationDetails: currentSubscription.cancellation_details,
785
- stripeSubscription: currentSubscription,
786
- event: void 0
787
- });
788
- }
789
- } catch (error) {
790
- ctx.context.logger.error("Error checking subscription status from Stripe", error);
791
- }
792
- throw ctx.redirect(getUrl(ctx, callbackURL));
793
- });
794
- };
795
1013
  const cancelSubscriptionBodySchema = z.object({
796
1014
  referenceId: z.string().meta({ description: "Reference id of the subscription to cancel. Eg: '123'" }).optional(),
797
1015
  subscriptionId: z.string().meta({ description: "The Stripe subscription ID to cancel. Eg: 'sub_1ABC2DEF3GHI4JKL'" }).optional(),
@@ -863,7 +1081,7 @@ const cancelSubscription = (options) => {
863
1081
  if (!activeSubscription) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
864
1082
  const { url } = await client.billingPortal.sessions.create({
865
1083
  customer: subscription.stripeCustomerId,
866
- return_url: getUrl(ctx, `${ctx.context.baseURL}/subscription/cancel/callback?callbackURL=${encodeURIComponent(ctx.body?.returnUrl || "/")}&subscriptionId=${encodeURIComponent(subscription.id)}`),
1084
+ return_url: getUrl(ctx, ctx.body?.returnUrl || "/"),
867
1085
  flow_data: {
868
1086
  type: "subscription_cancel",
869
1087
  subscription_cancel: { subscription: activeSubscription.id }
@@ -933,7 +1151,36 @@ const restoreSubscription = (options) => {
933
1151
  if (ctx.body.subscriptionId && subscription && subscription.referenceId !== referenceId) subscription = void 0;
934
1152
  if (!subscription || !subscription.stripeCustomerId) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
935
1153
  if (!isActiveOrTrialing(subscription)) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_ACTIVE);
936
- if (!isPendingCancel(subscription)) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION);
1154
+ const hasPendingCancel = isPendingCancel(subscription);
1155
+ const { stripeScheduleId } = subscription;
1156
+ if (!hasPendingCancel && !stripeScheduleId) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_PENDING_CHANGE);
1157
+ if (stripeScheduleId) {
1158
+ if (!subscription.stripeSubscriptionId) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
1159
+ if ((await client.subscriptionSchedules.retrieve(stripeScheduleId).catch((e) => {
1160
+ throw ctx.error("BAD_REQUEST", {
1161
+ message: e.message,
1162
+ code: e.code
1163
+ });
1164
+ })).status === "active") await client.subscriptionSchedules.release(stripeScheduleId).catch((e) => {
1165
+ throw ctx.error("BAD_REQUEST", {
1166
+ message: e.message,
1167
+ code: e.code
1168
+ });
1169
+ });
1170
+ await ctx.context.adapter.update({
1171
+ model: "subscription",
1172
+ update: {
1173
+ stripeScheduleId: null,
1174
+ updatedAt: /* @__PURE__ */ new Date()
1175
+ },
1176
+ where: [{
1177
+ field: "id",
1178
+ value: subscription.id
1179
+ }]
1180
+ });
1181
+ const releasedSub = await client.subscriptions.retrieve(subscription.stripeSubscriptionId);
1182
+ return ctx.json(releasedSub);
1183
+ }
937
1184
  const activeSubscription = await client.subscriptions.list({ customer: subscription.stripeCustomerId }).then((res) => res.data.filter((sub) => isActiveOrTrialing(sub))[0]);
938
1185
  if (!activeSubscription) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
939
1186
  const updateParams = {};
@@ -990,17 +1237,17 @@ const listActiveSubscriptions = (options) => {
990
1237
  }, async (ctx) => {
991
1238
  const customerType = ctx.query?.customerType || "user";
992
1239
  const referenceId = ctx.query?.referenceId || getReferenceId(ctx.context.session, customerType, options);
993
- const subscriptions$1 = await ctx.context.adapter.findMany({
1240
+ const subscriptions = await ctx.context.adapter.findMany({
994
1241
  model: "subscription",
995
1242
  where: [{
996
1243
  field: "referenceId",
997
1244
  value: referenceId
998
1245
  }]
999
1246
  });
1000
- if (!subscriptions$1.length) return [];
1247
+ if (!subscriptions.length) return [];
1001
1248
  const plans = await getPlans(options.subscription);
1002
1249
  if (!plans) return [];
1003
- const subs = subscriptions$1.map((sub) => {
1250
+ const subs = subscriptions.map((sub) => {
1004
1251
  const plan = plans.find((p) => p.name.toLowerCase() === sub.plan.toLowerCase());
1005
1252
  return {
1006
1253
  ...sub,
@@ -1020,10 +1267,20 @@ const subscriptionSuccess = (options) => {
1020
1267
  metadata: { openapi: { operationId: "handleSubscriptionSuccess" } },
1021
1268
  use: [originCheck((ctx) => ctx.query.callbackURL)]
1022
1269
  }, async (ctx) => {
1023
- if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
1024
- const { callbackURL, subscriptionId } = ctx.query;
1270
+ const callbackURL = ctx.query?.callbackURL || "/";
1025
1271
  const session = await getSessionFromCtx(ctx);
1026
- if (!session) throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
1272
+ if (!session) throw ctx.redirect(getUrl(ctx, callbackURL));
1273
+ if (!ctx.query?.checkoutSessionId) throw ctx.redirect(getUrl(ctx, callbackURL));
1274
+ const checkoutSession = await client.checkout.sessions.retrieve(ctx.query.checkoutSessionId).catch((error) => {
1275
+ ctx.context.logger.error("Error retrieving checkout session from Stripe", error);
1276
+ return null;
1277
+ });
1278
+ if (!checkoutSession) throw ctx.redirect(getUrl(ctx, callbackURL));
1279
+ const { subscriptionId } = subscriptionMetadata.get(checkoutSession.metadata);
1280
+ if (!subscriptionId) {
1281
+ ctx.context.logger.warn(`No subscriptionId in checkout session metadata: ${checkoutSession.id}`);
1282
+ throw ctx.redirect(getUrl(ctx, callbackURL));
1283
+ }
1027
1284
  const subscription = await ctx.context.adapter.findOne({
1028
1285
  model: "subscription",
1029
1286
  where: [{
@@ -1046,32 +1303,34 @@ const subscriptionSuccess = (options) => {
1046
1303
  throw ctx.redirect(getUrl(ctx, callbackURL));
1047
1304
  });
1048
1305
  if (!stripeSubscription) throw ctx.redirect(getUrl(ctx, callbackURL));
1049
- const subscriptionItem = stripeSubscription.items.data[0];
1050
- if (!subscriptionItem) {
1306
+ const resolved = await resolvePlanItem(options, stripeSubscription.items.data);
1307
+ if (!resolved) {
1051
1308
  ctx.context.logger.warn(`No subscription items found for Stripe subscription ${stripeSubscription.id}`);
1052
1309
  throw ctx.redirect(getUrl(ctx, callbackURL));
1053
1310
  }
1054
- const plan = await getPlanByPriceInfo(options, subscriptionItem.price.id, subscriptionItem.price.lookup_key);
1311
+ const { item: subscriptionItem, plan } = resolved;
1055
1312
  if (!plan) {
1056
1313
  ctx.context.logger.warn(`Plan not found for price ${subscriptionItem.price.id}`);
1057
1314
  throw ctx.redirect(getUrl(ctx, callbackURL));
1058
1315
  }
1316
+ const seats = resolveQuantity(stripeSubscription.items.data, subscriptionItem, plan.seatPriceId) || 1;
1059
1317
  await ctx.context.adapter.update({
1060
1318
  model: "subscription",
1061
1319
  update: {
1320
+ ...stripeSubscription.trial_start && stripeSubscription.trial_end ? {
1321
+ trialStart: /* @__PURE__ */ new Date(stripeSubscription.trial_start * 1e3),
1322
+ trialEnd: /* @__PURE__ */ new Date(stripeSubscription.trial_end * 1e3)
1323
+ } : {},
1062
1324
  status: stripeSubscription.status,
1063
- seats: subscriptionItem.quantity || 1,
1325
+ seats,
1064
1326
  plan: plan.name.toLowerCase(),
1327
+ billingInterval: subscriptionItem.price.recurring?.interval,
1065
1328
  periodEnd: /* @__PURE__ */ new Date(subscriptionItem.current_period_end * 1e3),
1066
1329
  periodStart: /* @__PURE__ */ new Date(subscriptionItem.current_period_start * 1e3),
1067
1330
  stripeSubscriptionId: stripeSubscription.id,
1068
1331
  cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
1069
1332
  cancelAt: stripeSubscription.cancel_at ? /* @__PURE__ */ new Date(stripeSubscription.cancel_at * 1e3) : null,
1070
- canceledAt: stripeSubscription.canceled_at ? /* @__PURE__ */ new Date(stripeSubscription.canceled_at * 1e3) : null,
1071
- ...stripeSubscription.trial_start && stripeSubscription.trial_end ? {
1072
- trialStart: /* @__PURE__ */ new Date(stripeSubscription.trial_start * 1e3),
1073
- trialEnd: /* @__PURE__ */ new Date(stripeSubscription.trial_end * 1e3)
1074
- } : {}
1333
+ canceledAt: stripeSubscription.canceled_at ? /* @__PURE__ */ new Date(stripeSubscription.canceled_at * 1e3) : null
1075
1334
  },
1076
1335
  where: [{
1077
1336
  field: "id",
@@ -1103,7 +1362,7 @@ const createBillingPortal = (options) => {
1103
1362
  originCheck((ctx) => ctx.body.returnUrl)
1104
1363
  ]
1105
1364
  }, async (ctx) => {
1106
- const { user: user$1 } = ctx.context.session;
1365
+ const { user } = ctx.context.session;
1107
1366
  const customerType = ctx.body.customerType || "user";
1108
1367
  const referenceId = ctx.body.referenceId || getReferenceId(ctx.context.session, customerType, options);
1109
1368
  let customerId;
@@ -1123,7 +1382,7 @@ const createBillingPortal = (options) => {
1123
1382
  }]
1124
1383
  }).then((subs) => subs.find((sub) => isActiveOrTrialing(sub))))?.stripeCustomerId;
1125
1384
  } else {
1126
- customerId = user$1.stripeCustomerId;
1385
+ customerId = user.stripeCustomerId;
1127
1386
  if (!customerId) customerId = (await ctx.context.adapter.findMany({
1128
1387
  model: "subscription",
1129
1388
  where: [{
@@ -1264,6 +1523,14 @@ const subscriptions = { subscription: { fields: {
1264
1523
  seats: {
1265
1524
  type: "number",
1266
1525
  required: false
1526
+ },
1527
+ billingInterval: {
1528
+ type: "string",
1529
+ required: false
1530
+ },
1531
+ stripeScheduleId: {
1532
+ type: "string",
1533
+ required: false
1267
1534
  }
1268
1535
  } } };
1269
1536
  const user = { user: { fields: { stripeCustomerId: {
@@ -1298,7 +1565,6 @@ const stripe = (options) => {
1298
1565
  const client = options.stripeClient;
1299
1566
  const subscriptionEndpoints = {
1300
1567
  upgradeSubscription: upgradeSubscription(options),
1301
- cancelSubscriptionCallback: cancelSubscriptionCallback(options),
1302
1568
  cancelSubscription: cancelSubscription(options),
1303
1569
  restoreSubscription: restoreSubscription(options),
1304
1570
  listActiveSubscriptions: listActiveSubscriptions(options),
@@ -1312,6 +1578,16 @@ const stripe = (options) => {
1312
1578
  ...options.subscription?.enabled ? subscriptionEndpoints : {}
1313
1579
  },
1314
1580
  init(ctx) {
1581
+ if (options.subscription?.enabled && !options.organization?.enabled) {
1582
+ const warnIfSeatPricing = (plans) => {
1583
+ if (plans.some((p) => p.seatPriceId)) ctx.logger.error("seatPriceId is configured on a plan but stripe organization option is not enabled. Seat-based billing requires `organization: { enabled: true }` in stripe plugin options.");
1584
+ };
1585
+ const { plans } = options.subscription;
1586
+ if (typeof plans === "function") Promise.resolve(plans()).then(warnIfSeatPricing).catch((e) => {
1587
+ ctx.logger.error(`Failed to resolve plans for seat pricing validation: ${e.message}`);
1588
+ });
1589
+ else warnIfSeatPricing(plans);
1590
+ }
1315
1591
  if (options.organization?.enabled) {
1316
1592
  const orgPlugin = ctx.getPlugin("organization");
1317
1593
  if (!orgPlugin) {
@@ -1323,17 +1599,17 @@ const stripe = (options) => {
1323
1599
  * Sync organization name to Stripe customer
1324
1600
  */
1325
1601
  const afterUpdateStripeOrg = async (data) => {
1326
- const { organization: organization$1 } = data;
1327
- if (!organization$1?.stripeCustomerId) return;
1602
+ const { organization } = data;
1603
+ if (!organization?.stripeCustomerId) return;
1328
1604
  try {
1329
- const stripeCustomer = await client.customers.retrieve(organization$1.stripeCustomerId);
1605
+ const stripeCustomer = await client.customers.retrieve(organization.stripeCustomerId);
1330
1606
  if (stripeCustomer.deleted) {
1331
- ctx.logger.warn(`Stripe customer ${organization$1.stripeCustomerId} was deleted`);
1607
+ ctx.logger.warn(`Stripe customer ${organization.stripeCustomerId} was deleted`);
1332
1608
  return;
1333
1609
  }
1334
- if (organization$1.name !== stripeCustomer.name) {
1335
- await client.customers.update(organization$1.stripeCustomerId, { name: organization$1.name });
1336
- ctx.logger.info(`Synced organization name to Stripe: "${stripeCustomer.name}" → "${organization$1.name}"`);
1610
+ if (organization.name !== stripeCustomer.name) {
1611
+ await client.customers.update(organization.stripeCustomerId, { name: organization.name });
1612
+ ctx.logger.info(`Synced organization name to Stripe: "${stripeCustomer.name}" → "${organization.name}"`);
1337
1613
  }
1338
1614
  } catch (e) {
1339
1615
  ctx.logger.error(`Failed to sync organization to Stripe: ${e.message}`);
@@ -1343,21 +1619,74 @@ const stripe = (options) => {
1343
1619
  * Block deletion if organization has active subscriptions
1344
1620
  */
1345
1621
  const beforeDeleteStripeOrg = async (data) => {
1346
- const { organization: organization$1 } = data;
1347
- if (!organization$1.stripeCustomerId) return;
1622
+ const { organization } = data;
1623
+ if (!organization.stripeCustomerId) return;
1348
1624
  try {
1349
- const subscriptions$1 = await client.subscriptions.list({
1350
- customer: organization$1.stripeCustomerId,
1625
+ const subscriptions = await client.subscriptions.list({
1626
+ customer: organization.stripeCustomerId,
1351
1627
  status: "all",
1352
1628
  limit: 100
1353
1629
  });
1354
- for (const sub of subscriptions$1.data) if (sub.status !== "canceled" && sub.status !== "incomplete" && sub.status !== "incomplete_expired") throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES.ORGANIZATION_HAS_ACTIVE_SUBSCRIPTION);
1630
+ for (const sub of subscriptions.data) if (sub.status !== "canceled" && sub.status !== "incomplete" && sub.status !== "incomplete_expired") throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES.ORGANIZATION_HAS_ACTIVE_SUBSCRIPTION);
1355
1631
  } catch (error) {
1356
1632
  if (error instanceof APIError) throw error;
1357
1633
  ctx.logger.error(`Failed to check organization subscriptions: ${error.message}`);
1358
1634
  throw error;
1359
1635
  }
1360
1636
  };
1637
+ /**
1638
+ * Sync seat quantity to Stripe when organization members change.
1639
+ * quantity = memberCount; Stripe graduated pricing handles free tiers.
1640
+ */
1641
+ const syncSeatsAfterMemberChange = async (data) => {
1642
+ if (!options.subscription?.enabled || !data.organization?.stripeCustomerId) return;
1643
+ try {
1644
+ const memberCount = await ctx.adapter.count({
1645
+ model: "member",
1646
+ where: [{
1647
+ field: "organizationId",
1648
+ value: data.organization.id
1649
+ }]
1650
+ });
1651
+ const seatPlans = (await getPlans(options.subscription)).filter((p) => p.seatPriceId);
1652
+ if (seatPlans.length === 0) return;
1653
+ const seatPlanNames = new Set(seatPlans.map((p) => p.name.toLowerCase()));
1654
+ const dbSub = await ctx.adapter.findOne({
1655
+ model: "subscription",
1656
+ where: [{
1657
+ field: "referenceId",
1658
+ value: data.organization.id
1659
+ }]
1660
+ });
1661
+ if (!dbSub?.stripeSubscriptionId || !isActiveOrTrialing(dbSub) || !seatPlanNames.has(dbSub.plan)) return;
1662
+ const { seatPriceId } = seatPlans.find((p) => p.name.toLowerCase() === dbSub.plan);
1663
+ const stripeSub = await client.subscriptions.retrieve(dbSub.stripeSubscriptionId);
1664
+ if (!isActiveOrTrialing(stripeSub)) return;
1665
+ const seatItem = stripeSub.items.data.find((item) => item.price.id === seatPriceId);
1666
+ if (seatItem?.quantity === memberCount) return;
1667
+ const items = seatItem ? [{
1668
+ id: seatItem.id,
1669
+ quantity: memberCount
1670
+ }] : [{
1671
+ price: seatPriceId,
1672
+ quantity: memberCount
1673
+ }];
1674
+ await client.subscriptions.update(stripeSub.id, {
1675
+ items,
1676
+ proration_behavior: "create_prorations"
1677
+ });
1678
+ await ctx.adapter.update({
1679
+ model: "subscription",
1680
+ update: { seats: memberCount },
1681
+ where: [{
1682
+ field: "id",
1683
+ value: dbSub.id
1684
+ }]
1685
+ });
1686
+ } catch (e) {
1687
+ ctx.logger.error(`Failed to sync seats to Stripe: ${e.message}`);
1688
+ }
1689
+ };
1361
1690
  orgPlugin.options.organizationHooks = {
1362
1691
  ...existingHooks,
1363
1692
  afterUpdateOrganization: existingHooks.afterUpdateOrganization ? async (data) => {
@@ -1367,67 +1696,91 @@ const stripe = (options) => {
1367
1696
  beforeDeleteOrganization: existingHooks.beforeDeleteOrganization ? async (data) => {
1368
1697
  await existingHooks.beforeDeleteOrganization(data);
1369
1698
  await beforeDeleteStripeOrg(data);
1370
- } : beforeDeleteStripeOrg
1699
+ } : beforeDeleteStripeOrg,
1700
+ afterAddMember: existingHooks.afterAddMember ? async (data) => {
1701
+ await existingHooks.afterAddMember(data);
1702
+ await syncSeatsAfterMemberChange(data);
1703
+ } : syncSeatsAfterMemberChange,
1704
+ afterRemoveMember: existingHooks.afterRemoveMember ? async (data) => {
1705
+ await existingHooks.afterRemoveMember(data);
1706
+ await syncSeatsAfterMemberChange(data);
1707
+ } : syncSeatsAfterMemberChange,
1708
+ afterAcceptInvitation: existingHooks.afterAcceptInvitation ? async (data) => {
1709
+ await existingHooks.afterAcceptInvitation(data);
1710
+ await syncSeatsAfterMemberChange(data);
1711
+ } : syncSeatsAfterMemberChange
1371
1712
  };
1372
1713
  }
1373
1714
  return { options: { databaseHooks: { user: {
1374
- create: { async after(user$1, ctx$1) {
1375
- if (!ctx$1 || !options.createCustomerOnSignUp || user$1.stripeCustomerId) return;
1715
+ create: { async after(user, ctx) {
1716
+ if (!ctx || !options.createCustomerOnSignUp || user.stripeCustomerId) return;
1376
1717
  try {
1377
- let stripeCustomer = (await client.customers.search({
1378
- query: `email:"${escapeStripeSearchValue(user$1.email)}" AND -metadata["customerType"]:"organization"`,
1379
- limit: 1
1380
- })).data[0];
1718
+ let stripeCustomer;
1719
+ try {
1720
+ stripeCustomer = (await client.customers.search({
1721
+ query: `email:"${escapeStripeSearchValue(user.email)}" AND -metadata["${customerMetadata.keys.customerType}"]:"organization"`,
1722
+ limit: 1
1723
+ })).data[0];
1724
+ } catch {
1725
+ ctx.context.logger.warn("Stripe customers.search failed, falling back to customers.list");
1726
+ for await (const customer of client.customers.list({
1727
+ email: user.email,
1728
+ limit: 100
1729
+ })) if (customer.metadata?.[customerMetadata.keys.customerType] !== "organization") {
1730
+ stripeCustomer = customer;
1731
+ break;
1732
+ }
1733
+ }
1381
1734
  if (stripeCustomer) {
1382
- await ctx$1.context.internalAdapter.updateUser(user$1.id, { stripeCustomerId: stripeCustomer.id });
1735
+ await ctx.context.internalAdapter.updateUser(user.id, { stripeCustomerId: stripeCustomer.id });
1383
1736
  await options.onCustomerCreate?.({
1384
1737
  stripeCustomer,
1385
1738
  user: {
1386
- ...user$1,
1739
+ ...user,
1387
1740
  stripeCustomerId: stripeCustomer.id
1388
1741
  }
1389
- }, ctx$1);
1390
- ctx$1.context.logger.info(`Linked existing Stripe customer ${stripeCustomer.id} to user ${user$1.id}`);
1742
+ }, ctx);
1743
+ ctx.context.logger.info(`Linked existing Stripe customer ${stripeCustomer.id} to user ${user.id}`);
1391
1744
  return;
1392
1745
  }
1393
1746
  let extraCreateParams = {};
1394
- if (options.getCustomerCreateParams) extraCreateParams = await options.getCustomerCreateParams(user$1, ctx$1);
1747
+ if (options.getCustomerCreateParams) extraCreateParams = await options.getCustomerCreateParams(user, ctx);
1395
1748
  const params = defu({
1396
- email: user$1.email,
1397
- name: user$1.name,
1398
- metadata: {
1399
- userId: user$1.id,
1749
+ email: user.email,
1750
+ name: user.name,
1751
+ metadata: customerMetadata.set({
1752
+ userId: user.id,
1400
1753
  customerType: "user"
1401
- }
1754
+ }, extraCreateParams?.metadata)
1402
1755
  }, extraCreateParams);
1403
1756
  stripeCustomer = await client.customers.create(params);
1404
- await ctx$1.context.internalAdapter.updateUser(user$1.id, { stripeCustomerId: stripeCustomer.id });
1757
+ await ctx.context.internalAdapter.updateUser(user.id, { stripeCustomerId: stripeCustomer.id });
1405
1758
  await options.onCustomerCreate?.({
1406
1759
  stripeCustomer,
1407
1760
  user: {
1408
- ...user$1,
1761
+ ...user,
1409
1762
  stripeCustomerId: stripeCustomer.id
1410
1763
  }
1411
- }, ctx$1);
1412
- ctx$1.context.logger.info(`Created new Stripe customer ${stripeCustomer.id} for user ${user$1.id}`);
1764
+ }, ctx);
1765
+ ctx.context.logger.info(`Created new Stripe customer ${stripeCustomer.id} for user ${user.id}`);
1413
1766
  } catch (e) {
1414
- ctx$1.context.logger.error(`Failed to create or link Stripe customer: ${e.message}`, e);
1767
+ ctx.context.logger.error(`Failed to create or link Stripe customer: ${e.message}`, e);
1415
1768
  }
1416
1769
  } },
1417
- update: { async after(user$1, ctx$1) {
1418
- if (!ctx$1 || !user$1.stripeCustomerId) return;
1770
+ update: { async after(user, ctx) {
1771
+ if (!ctx || !user.stripeCustomerId) return;
1419
1772
  try {
1420
- const stripeCustomer = await client.customers.retrieve(user$1.stripeCustomerId);
1773
+ const stripeCustomer = await client.customers.retrieve(user.stripeCustomerId);
1421
1774
  if (stripeCustomer.deleted) {
1422
- ctx$1.context.logger.warn(`Stripe customer ${user$1.stripeCustomerId} was deleted, cannot update email`);
1775
+ ctx.context.logger.warn(`Stripe customer ${user.stripeCustomerId} was deleted, cannot update email`);
1423
1776
  return;
1424
1777
  }
1425
- if (stripeCustomer.email !== user$1.email) {
1426
- await client.customers.update(user$1.stripeCustomerId, { email: user$1.email });
1427
- ctx$1.context.logger.info(`Updated Stripe customer email from ${stripeCustomer.email} to ${user$1.email}`);
1778
+ if (stripeCustomer.email !== user.email) {
1779
+ await client.customers.update(user.stripeCustomerId, { email: user.email });
1780
+ ctx.context.logger.info(`Updated Stripe customer email from ${stripeCustomer.email} to ${user.email}`);
1428
1781
  }
1429
1782
  } catch (e) {
1430
- ctx$1.context.logger.error(`Failed to sync email to Stripe customer: ${e.message}`, e);
1783
+ ctx.context.logger.error(`Failed to sync email to Stripe customer: ${e.message}`, e);
1431
1784
  }
1432
1785
  } }
1433
1786
  } } } };
@@ -1439,4 +1792,5 @@ const stripe = (options) => {
1439
1792
  };
1440
1793
 
1441
1794
  //#endregion
1442
- export { stripe };
1795
+ export { stripe };
1796
+ //# sourceMappingURL=index.mjs.map