@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/README.md +17 -0
- package/dist/client.d.mts +52 -171
- package/dist/client.mjs +3 -2
- package/dist/client.mjs.map +1 -0
- package/dist/{error-codes-Clj-xYDP.mjs → error-codes-CCosYkXx.mjs} +4 -1
- package/dist/error-codes-CCosYkXx.mjs.map +1 -0
- package/dist/index.d.mts +519 -2
- package/dist/index.mjs +586 -232
- package/dist/index.mjs.map +1 -0
- package/dist/types-OT6L84x4.d.mts +480 -0
- package/package.json +28 -22
- package/.turbo/turbo-build.log +0 -17
- package/CHANGELOG.md +0 -24
- package/dist/index-BqGWQFAv.d.mts +0 -1015
- package/src/client.ts +0 -38
- package/src/error-codes.ts +0 -30
- package/src/hooks.ts +0 -435
- package/src/index.ts +0 -314
- package/src/middleware.ts +0 -104
- package/src/routes.ts +0 -1681
- package/src/schema.ts +0 -128
- package/src/types.ts +0 -449
- package/src/utils.ts +0 -70
- package/test/stripe-organization.test.ts +0 -1993
- package/test/stripe.test.ts +0 -4807
- package/tsconfig.json +0 -14
- package/tsdown.config.ts +0 -8
- package/vitest.config.ts +0 -8
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as STRIPE_ERROR_CODES } from "./error-codes-
|
|
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
|
|
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
|
|
151
|
+
if (user) return {
|
|
78
152
|
customerType: "user",
|
|
79
|
-
referenceId: user
|
|
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
|
|
90
|
-
if (!
|
|
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
|
|
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
|
|
99
|
-
const
|
|
100
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
179
|
-
if (!
|
|
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
|
|
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: ${
|
|
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.
|
|
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
|
-
|
|
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
|
|
227
|
-
if (!
|
|
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
|
|
232
|
-
const
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
628
|
+
customerId = subscriptionToUpdate?.stripeCustomerId || user.stripeCustomerId;
|
|
540
629
|
if (!customerId) try {
|
|
541
|
-
let stripeCustomer
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
|
547
|
-
name: user
|
|
548
|
-
metadata: {
|
|
549
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
582
|
-
|
|
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
|
-
|
|
606
|
-
if (
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
614
|
-
const
|
|
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:
|
|
627
|
-
|
|
628
|
-
|
|
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
|
|
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
|
|
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)}&
|
|
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
|
-
|
|
706
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
1247
|
+
if (!subscriptions.length) return [];
|
|
1001
1248
|
const plans = await getPlans(options.subscription);
|
|
1002
1249
|
if (!plans) return [];
|
|
1003
|
-
const subs = subscriptions
|
|
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
|
-
|
|
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,
|
|
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
|
|
1050
|
-
if (!
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1327
|
-
if (!organization
|
|
1602
|
+
const { organization } = data;
|
|
1603
|
+
if (!organization?.stripeCustomerId) return;
|
|
1328
1604
|
try {
|
|
1329
|
-
const stripeCustomer = await client.customers.retrieve(organization
|
|
1605
|
+
const stripeCustomer = await client.customers.retrieve(organization.stripeCustomerId);
|
|
1330
1606
|
if (stripeCustomer.deleted) {
|
|
1331
|
-
ctx.logger.warn(`Stripe customer ${organization
|
|
1607
|
+
ctx.logger.warn(`Stripe customer ${organization.stripeCustomerId} was deleted`);
|
|
1332
1608
|
return;
|
|
1333
1609
|
}
|
|
1334
|
-
if (organization
|
|
1335
|
-
await client.customers.update(organization
|
|
1336
|
-
ctx.logger.info(`Synced organization name to Stripe: "${stripeCustomer.name}" → "${organization
|
|
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
|
|
1347
|
-
if (!organization
|
|
1622
|
+
const { organization } = data;
|
|
1623
|
+
if (!organization.stripeCustomerId) return;
|
|
1348
1624
|
try {
|
|
1349
|
-
const subscriptions
|
|
1350
|
-
customer: organization
|
|
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
|
|
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
|
|
1375
|
-
if (!ctx
|
|
1715
|
+
create: { async after(user, ctx) {
|
|
1716
|
+
if (!ctx || !options.createCustomerOnSignUp || user.stripeCustomerId) return;
|
|
1376
1717
|
try {
|
|
1377
|
-
let stripeCustomer
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
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
|
|
1735
|
+
await ctx.context.internalAdapter.updateUser(user.id, { stripeCustomerId: stripeCustomer.id });
|
|
1383
1736
|
await options.onCustomerCreate?.({
|
|
1384
1737
|
stripeCustomer,
|
|
1385
1738
|
user: {
|
|
1386
|
-
...user
|
|
1739
|
+
...user,
|
|
1387
1740
|
stripeCustomerId: stripeCustomer.id
|
|
1388
1741
|
}
|
|
1389
|
-
}, ctx
|
|
1390
|
-
ctx
|
|
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
|
|
1747
|
+
if (options.getCustomerCreateParams) extraCreateParams = await options.getCustomerCreateParams(user, ctx);
|
|
1395
1748
|
const params = defu({
|
|
1396
|
-
email: user
|
|
1397
|
-
name: user
|
|
1398
|
-
metadata: {
|
|
1399
|
-
userId: user
|
|
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
|
|
1757
|
+
await ctx.context.internalAdapter.updateUser(user.id, { stripeCustomerId: stripeCustomer.id });
|
|
1405
1758
|
await options.onCustomerCreate?.({
|
|
1406
1759
|
stripeCustomer,
|
|
1407
1760
|
user: {
|
|
1408
|
-
...user
|
|
1761
|
+
...user,
|
|
1409
1762
|
stripeCustomerId: stripeCustomer.id
|
|
1410
1763
|
}
|
|
1411
|
-
}, ctx
|
|
1412
|
-
ctx
|
|
1764
|
+
}, ctx);
|
|
1765
|
+
ctx.context.logger.info(`Created new Stripe customer ${stripeCustomer.id} for user ${user.id}`);
|
|
1413
1766
|
} catch (e) {
|
|
1414
|
-
ctx
|
|
1767
|
+
ctx.context.logger.error(`Failed to create or link Stripe customer: ${e.message}`, e);
|
|
1415
1768
|
}
|
|
1416
1769
|
} },
|
|
1417
|
-
update: { async after(user
|
|
1418
|
-
if (!ctx
|
|
1770
|
+
update: { async after(user, ctx) {
|
|
1771
|
+
if (!ctx || !user.stripeCustomerId) return;
|
|
1419
1772
|
try {
|
|
1420
|
-
const stripeCustomer = await client.customers.retrieve(user
|
|
1773
|
+
const stripeCustomer = await client.customers.retrieve(user.stripeCustomerId);
|
|
1421
1774
|
if (stripeCustomer.deleted) {
|
|
1422
|
-
ctx
|
|
1775
|
+
ctx.context.logger.warn(`Stripe customer ${user.stripeCustomerId} was deleted, cannot update email`);
|
|
1423
1776
|
return;
|
|
1424
1777
|
}
|
|
1425
|
-
if (stripeCustomer.email !== user
|
|
1426
|
-
await client.customers.update(user
|
|
1427
|
-
ctx
|
|
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
|
|
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
|