@better-auth/stripe 1.5.0-beta.1 → 1.5.0-beta.10
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/.turbo/turbo-build.log +15 -10
- package/LICENSE.md +15 -12
- package/dist/client.d.mts +47 -66
- package/dist/client.mjs +7 -3
- package/dist/client.mjs.map +1 -0
- package/dist/error-codes-CMowBCzF.mjs +30 -0
- package/dist/error-codes-CMowBCzF.mjs.map +1 -0
- package/dist/{index-DpiQGYLJ.d.mts → index-D9Pr9jIc.d.mts} +371 -188
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +720 -293
- package/dist/index.mjs.map +1 -0
- package/package.json +10 -10
- package/src/client.ts +2 -1
- package/src/error-codes.ts +17 -1
- package/src/hooks.ts +238 -57
- package/src/index.ts +149 -49
- package/src/metadata.ts +94 -0
- package/src/middleware.ts +91 -45
- package/src/routes.ts +699 -368
- package/src/schema.ts +40 -4
- package/src/types.ts +105 -20
- package/src/utils.ts +36 -1
- package/test/stripe-organization.test.ts +1993 -0
- package/{src → test}/stripe.test.ts +3775 -1393
- package/tsdown.config.ts +1 -0
- package/CHANGELOG.md +0 -22
- package/dist/error-codes-qqooUh6R.mjs +0 -16
package/dist/index.mjs
CHANGED
|
@@ -1,13 +1,55 @@
|
|
|
1
|
-
import { t as STRIPE_ERROR_CODES
|
|
2
|
-
import {
|
|
1
|
+
import { t as STRIPE_ERROR_CODES } from "./error-codes-CMowBCzF.mjs";
|
|
2
|
+
import { APIError, HIDE_METADATA } from "better-auth";
|
|
3
3
|
import { defu } from "defu";
|
|
4
4
|
import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
|
|
5
|
-
import { APIError } from "@better-auth/core/error";
|
|
6
|
-
import {
|
|
7
|
-
import { APIError as APIError$1, getSessionFromCtx, originCheck, sessionMiddleware } from "better-auth/api";
|
|
5
|
+
import { APIError as APIError$1 } from "@better-auth/core/error";
|
|
6
|
+
import { getSessionFromCtx, originCheck, sessionMiddleware } from "better-auth/api";
|
|
8
7
|
import * as z from "zod/v4";
|
|
9
8
|
import { mergeSchema } from "better-auth/db";
|
|
10
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
|
|
11
53
|
//#region src/utils.ts
|
|
12
54
|
async function getPlans(subscriptionOptions) {
|
|
13
55
|
if (subscriptionOptions?.enabled) return typeof subscriptionOptions.plans === "function" ? await subscriptionOptions.plans() : subscriptionOptions.plans;
|
|
@@ -19,21 +61,87 @@ async function getPlanByPriceInfo(options, priceId, priceLookupKey) {
|
|
|
19
61
|
async function getPlanByName(options, name) {
|
|
20
62
|
return await getPlans(options.subscription).then((res) => res?.find((plan) => plan.name.toLowerCase() === name.toLowerCase()));
|
|
21
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Checks if a subscription is in an available state (active or trialing)
|
|
66
|
+
*/
|
|
67
|
+
function isActiveOrTrialing(sub) {
|
|
68
|
+
return sub.status === "active" || sub.status === "trialing";
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Check if a subscription is scheduled to be canceled (DB subscription object)
|
|
72
|
+
*/
|
|
73
|
+
function isPendingCancel(sub) {
|
|
74
|
+
return !!(sub.cancelAtPeriodEnd || sub.cancelAt);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Check if a Stripe subscription is scheduled to be canceled (Stripe API response)
|
|
78
|
+
*/
|
|
79
|
+
function isStripePendingCancel(stripeSub) {
|
|
80
|
+
return !!(stripeSub.cancel_at_period_end || stripeSub.cancel_at);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Escapes a value for use in Stripe search queries.
|
|
84
|
+
* Stripe search query uses double quotes for string values,
|
|
85
|
+
* and double quotes within the value need to be escaped with backslash.
|
|
86
|
+
*
|
|
87
|
+
* @see https://docs.stripe.com/search#search-query-language
|
|
88
|
+
*/
|
|
89
|
+
function escapeStripeSearchValue(value) {
|
|
90
|
+
return value.replace(/"/g, "\\\"");
|
|
91
|
+
}
|
|
22
92
|
|
|
23
93
|
//#endregion
|
|
24
94
|
//#region src/hooks.ts
|
|
95
|
+
/**
|
|
96
|
+
* Find organization or user by stripeCustomerId.
|
|
97
|
+
* @internal
|
|
98
|
+
*/
|
|
99
|
+
async function findReferenceByStripeCustomerId(ctx, options, stripeCustomerId) {
|
|
100
|
+
if (options.organization?.enabled) {
|
|
101
|
+
const org = await ctx.context.adapter.findOne({
|
|
102
|
+
model: "organization",
|
|
103
|
+
where: [{
|
|
104
|
+
field: "stripeCustomerId",
|
|
105
|
+
value: stripeCustomerId
|
|
106
|
+
}]
|
|
107
|
+
});
|
|
108
|
+
if (org) return {
|
|
109
|
+
customerType: "organization",
|
|
110
|
+
referenceId: org.id
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const user = await ctx.context.adapter.findOne({
|
|
114
|
+
model: "user",
|
|
115
|
+
where: [{
|
|
116
|
+
field: "stripeCustomerId",
|
|
117
|
+
value: stripeCustomerId
|
|
118
|
+
}]
|
|
119
|
+
});
|
|
120
|
+
if (user) return {
|
|
121
|
+
customerType: "user",
|
|
122
|
+
referenceId: user.id
|
|
123
|
+
};
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
25
126
|
async function onCheckoutSessionCompleted(ctx, options, event) {
|
|
26
127
|
try {
|
|
27
128
|
const client = options.stripeClient;
|
|
28
129
|
const checkoutSession = event.data.object;
|
|
29
130
|
if (checkoutSession.mode === "setup" || !options.subscription?.enabled) return;
|
|
30
131
|
const subscription = await client.subscriptions.retrieve(checkoutSession.subscription);
|
|
31
|
-
const
|
|
32
|
-
|
|
132
|
+
const subscriptionItem = subscription.items.data[0];
|
|
133
|
+
if (!subscriptionItem) {
|
|
134
|
+
ctx.context.logger.warn(`Stripe webhook warning: Subscription ${subscription.id} has no items`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const priceId = subscriptionItem.price.id;
|
|
138
|
+
const priceLookupKey = subscriptionItem.price.lookup_key;
|
|
139
|
+
const plan = await getPlanByPriceInfo(options, priceId, priceLookupKey);
|
|
33
140
|
if (plan) {
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const
|
|
141
|
+
const checkoutMeta = subscriptionMetadata.get(checkoutSession?.metadata);
|
|
142
|
+
const referenceId = checkoutSession?.client_reference_id || checkoutMeta.referenceId;
|
|
143
|
+
const { subscriptionId } = checkoutMeta;
|
|
144
|
+
const seats = subscriptionItem.quantity;
|
|
37
145
|
if (referenceId && subscriptionId) {
|
|
38
146
|
const trial = subscription.trial_start && subscription.trial_end ? {
|
|
39
147
|
trialStart: /* @__PURE__ */ new Date(subscription.trial_start * 1e3),
|
|
@@ -45,9 +153,13 @@ async function onCheckoutSessionCompleted(ctx, options, event) {
|
|
|
45
153
|
plan: plan.name.toLowerCase(),
|
|
46
154
|
status: subscription.status,
|
|
47
155
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
48
|
-
periodStart: /* @__PURE__ */ new Date(
|
|
49
|
-
periodEnd: /* @__PURE__ */ new Date(
|
|
156
|
+
periodStart: /* @__PURE__ */ new Date(subscriptionItem.current_period_start * 1e3),
|
|
157
|
+
periodEnd: /* @__PURE__ */ new Date(subscriptionItem.current_period_end * 1e3),
|
|
50
158
|
stripeSubscriptionId: checkoutSession.subscription,
|
|
159
|
+
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
160
|
+
cancelAt: subscription.cancel_at ? /* @__PURE__ */ new Date(subscription.cancel_at * 1e3) : null,
|
|
161
|
+
canceledAt: subscription.canceled_at ? /* @__PURE__ */ new Date(subscription.canceled_at * 1e3) : null,
|
|
162
|
+
endedAt: subscription.ended_at ? /* @__PURE__ */ new Date(subscription.ended_at * 1e3) : null,
|
|
51
163
|
seats,
|
|
52
164
|
...trial
|
|
53
165
|
},
|
|
@@ -74,16 +186,96 @@ async function onCheckoutSessionCompleted(ctx, options, event) {
|
|
|
74
186
|
}
|
|
75
187
|
}
|
|
76
188
|
} catch (e) {
|
|
77
|
-
logger.error(`Stripe webhook failed. Error: ${e.message}`);
|
|
189
|
+
ctx.context.logger.error(`Stripe webhook failed. Error: ${e.message}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async function onSubscriptionCreated(ctx, options, event) {
|
|
193
|
+
try {
|
|
194
|
+
if (!options.subscription?.enabled) return;
|
|
195
|
+
const subscriptionCreated = event.data.object;
|
|
196
|
+
const stripeCustomerId = subscriptionCreated.customer?.toString();
|
|
197
|
+
if (!stripeCustomerId) {
|
|
198
|
+
ctx.context.logger.warn(`Stripe webhook warning: customer.subscription.created event received without customer ID`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const { subscriptionId } = subscriptionMetadata.get(subscriptionCreated.metadata);
|
|
202
|
+
const existingSubscription = await ctx.context.adapter.findOne({
|
|
203
|
+
model: "subscription",
|
|
204
|
+
where: subscriptionId ? [{
|
|
205
|
+
field: "id",
|
|
206
|
+
value: subscriptionId
|
|
207
|
+
}] : [{
|
|
208
|
+
field: "stripeSubscriptionId",
|
|
209
|
+
value: subscriptionCreated.id
|
|
210
|
+
}]
|
|
211
|
+
});
|
|
212
|
+
if (existingSubscription) {
|
|
213
|
+
ctx.context.logger.info(`Stripe webhook: Subscription already exists in database (id: ${existingSubscription.id}), skipping creation`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const reference = await findReferenceByStripeCustomerId(ctx, options, stripeCustomerId);
|
|
217
|
+
if (!reference) {
|
|
218
|
+
ctx.context.logger.warn(`Stripe webhook warning: No user or organization found with stripeCustomerId: ${stripeCustomerId}`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const { referenceId, customerType } = reference;
|
|
222
|
+
const subscriptionItem = subscriptionCreated.items.data[0];
|
|
223
|
+
if (!subscriptionItem) {
|
|
224
|
+
ctx.context.logger.warn(`Stripe webhook warning: Subscription ${subscriptionCreated.id} has no items`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const priceId = subscriptionItem.price.id;
|
|
228
|
+
const plan = await getPlanByPriceInfo(options, priceId, subscriptionItem.price.lookup_key || null);
|
|
229
|
+
if (!plan) {
|
|
230
|
+
ctx.context.logger.warn(`Stripe webhook warning: No matching plan found for priceId: ${priceId}`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const seats = subscriptionItem.quantity;
|
|
234
|
+
const periodStart = /* @__PURE__ */ new Date(subscriptionItem.current_period_start * 1e3);
|
|
235
|
+
const periodEnd = /* @__PURE__ */ new Date(subscriptionItem.current_period_end * 1e3);
|
|
236
|
+
const trial = subscriptionCreated.trial_start && subscriptionCreated.trial_end ? {
|
|
237
|
+
trialStart: /* @__PURE__ */ new Date(subscriptionCreated.trial_start * 1e3),
|
|
238
|
+
trialEnd: /* @__PURE__ */ new Date(subscriptionCreated.trial_end * 1e3)
|
|
239
|
+
} : {};
|
|
240
|
+
const newSubscription = await ctx.context.adapter.create({
|
|
241
|
+
model: "subscription",
|
|
242
|
+
data: {
|
|
243
|
+
referenceId,
|
|
244
|
+
stripeCustomerId,
|
|
245
|
+
stripeSubscriptionId: subscriptionCreated.id,
|
|
246
|
+
status: subscriptionCreated.status,
|
|
247
|
+
plan: plan.name.toLowerCase(),
|
|
248
|
+
periodStart,
|
|
249
|
+
periodEnd,
|
|
250
|
+
seats,
|
|
251
|
+
...plan.limits ? { limits: plan.limits } : {},
|
|
252
|
+
...trial
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
ctx.context.logger.info(`Stripe webhook: Created subscription ${subscriptionCreated.id} for ${customerType} ${referenceId} from dashboard`);
|
|
256
|
+
await options.subscription.onSubscriptionCreated?.({
|
|
257
|
+
event,
|
|
258
|
+
subscription: newSubscription,
|
|
259
|
+
stripeSubscription: subscriptionCreated,
|
|
260
|
+
plan
|
|
261
|
+
});
|
|
262
|
+
} catch (error) {
|
|
263
|
+
ctx.context.logger.error(`Stripe webhook failed. Error: ${error}`);
|
|
78
264
|
}
|
|
79
265
|
}
|
|
80
266
|
async function onSubscriptionUpdated(ctx, options, event) {
|
|
81
267
|
try {
|
|
82
268
|
if (!options.subscription?.enabled) return;
|
|
83
269
|
const subscriptionUpdated = event.data.object;
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
270
|
+
const subscriptionItem = subscriptionUpdated.items.data[0];
|
|
271
|
+
if (!subscriptionItem) {
|
|
272
|
+
ctx.context.logger.warn(`Stripe webhook warning: Subscription ${subscriptionUpdated.id} has no items`);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const priceId = subscriptionItem.price.id;
|
|
276
|
+
const priceLookupKey = subscriptionItem.price.lookup_key;
|
|
277
|
+
const plan = await getPlanByPriceInfo(options, priceId, priceLookupKey);
|
|
278
|
+
const { subscriptionId } = subscriptionMetadata.get(subscriptionUpdated.metadata);
|
|
87
279
|
const customerId = subscriptionUpdated.customer?.toString();
|
|
88
280
|
let subscription = await ctx.context.adapter.findOne({
|
|
89
281
|
model: "subscription",
|
|
@@ -104,15 +296,14 @@ async function onSubscriptionUpdated(ctx, options, event) {
|
|
|
104
296
|
}]
|
|
105
297
|
});
|
|
106
298
|
if (subs.length > 1) {
|
|
107
|
-
const activeSub = subs.find((sub) => sub
|
|
299
|
+
const activeSub = subs.find((sub) => isActiveOrTrialing(sub));
|
|
108
300
|
if (!activeSub) {
|
|
109
|
-
logger.warn(`Stripe webhook error: Multiple subscriptions found for customerId: ${customerId} and no active subscription is found`);
|
|
301
|
+
ctx.context.logger.warn(`Stripe webhook error: Multiple subscriptions found for customerId: ${customerId} and no active subscription is found`);
|
|
110
302
|
return;
|
|
111
303
|
}
|
|
112
304
|
subscription = activeSub;
|
|
113
305
|
} else subscription = subs[0];
|
|
114
306
|
}
|
|
115
|
-
const seats = subscriptionUpdated.items.data[0].quantity;
|
|
116
307
|
const updatedSubscription = await ctx.context.adapter.update({
|
|
117
308
|
model: "subscription",
|
|
118
309
|
update: {
|
|
@@ -122,10 +313,13 @@ async function onSubscriptionUpdated(ctx, options, event) {
|
|
|
122
313
|
} : {},
|
|
123
314
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
124
315
|
status: subscriptionUpdated.status,
|
|
125
|
-
periodStart: /* @__PURE__ */ new Date(
|
|
126
|
-
periodEnd: /* @__PURE__ */ new Date(
|
|
316
|
+
periodStart: /* @__PURE__ */ new Date(subscriptionItem.current_period_start * 1e3),
|
|
317
|
+
periodEnd: /* @__PURE__ */ new Date(subscriptionItem.current_period_end * 1e3),
|
|
127
318
|
cancelAtPeriodEnd: subscriptionUpdated.cancel_at_period_end,
|
|
128
|
-
|
|
319
|
+
cancelAt: subscriptionUpdated.cancel_at ? /* @__PURE__ */ new Date(subscriptionUpdated.cancel_at * 1e3) : null,
|
|
320
|
+
canceledAt: subscriptionUpdated.canceled_at ? /* @__PURE__ */ new Date(subscriptionUpdated.canceled_at * 1e3) : null,
|
|
321
|
+
endedAt: subscriptionUpdated.ended_at ? /* @__PURE__ */ new Date(subscriptionUpdated.ended_at * 1e3) : null,
|
|
322
|
+
seats: subscriptionItem.quantity,
|
|
129
323
|
stripeSubscriptionId: subscriptionUpdated.id
|
|
130
324
|
},
|
|
131
325
|
where: [{
|
|
@@ -133,7 +327,7 @@ async function onSubscriptionUpdated(ctx, options, event) {
|
|
|
133
327
|
value: subscription.id
|
|
134
328
|
}]
|
|
135
329
|
});
|
|
136
|
-
if (subscriptionUpdated.status === "active" && subscriptionUpdated
|
|
330
|
+
if (subscriptionUpdated.status === "active" && isStripePendingCancel(subscriptionUpdated) && !isPendingCancel(subscription)) await options.subscription.onSubscriptionCancel?.({
|
|
137
331
|
subscription,
|
|
138
332
|
cancellationDetails: subscriptionUpdated.cancellation_details || void 0,
|
|
139
333
|
stripeSubscription: subscriptionUpdated,
|
|
@@ -148,7 +342,7 @@ async function onSubscriptionUpdated(ctx, options, event) {
|
|
|
148
342
|
if (subscriptionUpdated.status === "incomplete_expired" && subscription.status === "trialing" && plan.freeTrial?.onTrialExpired) await plan.freeTrial.onTrialExpired(subscription, ctx);
|
|
149
343
|
}
|
|
150
344
|
} catch (error) {
|
|
151
|
-
logger.error(`Stripe webhook failed. Error: ${error}`);
|
|
345
|
+
ctx.context.logger.error(`Stripe webhook failed. Error: ${error}`);
|
|
152
346
|
}
|
|
153
347
|
}
|
|
154
348
|
async function onSubscriptionDeleted(ctx, options, event) {
|
|
@@ -172,7 +366,11 @@ async function onSubscriptionDeleted(ctx, options, event) {
|
|
|
172
366
|
}],
|
|
173
367
|
update: {
|
|
174
368
|
status: "canceled",
|
|
175
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
369
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
370
|
+
cancelAtPeriodEnd: subscriptionDeleted.cancel_at_period_end,
|
|
371
|
+
cancelAt: subscriptionDeleted.cancel_at ? /* @__PURE__ */ new Date(subscriptionDeleted.cancel_at * 1e3) : null,
|
|
372
|
+
canceledAt: subscriptionDeleted.canceled_at ? /* @__PURE__ */ new Date(subscriptionDeleted.canceled_at * 1e3) : null,
|
|
373
|
+
endedAt: subscriptionDeleted.ended_at ? /* @__PURE__ */ new Date(subscriptionDeleted.ended_at * 1e3) : null
|
|
176
374
|
}
|
|
177
375
|
});
|
|
178
376
|
await options.subscription.onSubscriptionDeleted?.({
|
|
@@ -180,43 +378,99 @@ async function onSubscriptionDeleted(ctx, options, event) {
|
|
|
180
378
|
stripeSubscription: subscriptionDeleted,
|
|
181
379
|
subscription
|
|
182
380
|
});
|
|
183
|
-
} else logger.warn(`Stripe webhook error: Subscription not found for subscriptionId: ${subscriptionId}`);
|
|
381
|
+
} else ctx.context.logger.warn(`Stripe webhook error: Subscription not found for subscriptionId: ${subscriptionId}`);
|
|
184
382
|
} catch (error) {
|
|
185
|
-
logger.error(`Stripe webhook failed. Error: ${error}`);
|
|
383
|
+
ctx.context.logger.error(`Stripe webhook failed. Error: ${error}`);
|
|
186
384
|
}
|
|
187
385
|
}
|
|
188
386
|
|
|
189
387
|
//#endregion
|
|
190
388
|
//#region src/middleware.ts
|
|
389
|
+
const stripeSessionMiddleware = createAuthMiddleware({ use: [sessionMiddleware] }, async (ctx) => {
|
|
390
|
+
return { session: ctx.context.session };
|
|
391
|
+
});
|
|
191
392
|
const referenceMiddleware = (subscriptionOptions, action) => createAuthMiddleware(async (ctx) => {
|
|
192
|
-
const
|
|
193
|
-
if (!
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
393
|
+
const ctxSession = ctx.context.session;
|
|
394
|
+
if (!ctxSession) throw APIError$1.from("UNAUTHORIZED", STRIPE_ERROR_CODES.UNAUTHORIZED);
|
|
395
|
+
const customerType = ctx.body?.customerType || ctx.query?.customerType;
|
|
396
|
+
const explicitReferenceId = ctx.body?.referenceId || ctx.query?.referenceId;
|
|
397
|
+
if (customerType === "organization") {
|
|
398
|
+
if (!subscriptionOptions.authorizeReference) {
|
|
399
|
+
ctx.context.logger.error(`Organization subscriptions require authorizeReference to be defined in your stripe plugin config.`);
|
|
400
|
+
throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.ORGANIZATION_SUBSCRIPTION_NOT_ENABLED);
|
|
401
|
+
}
|
|
402
|
+
const referenceId = explicitReferenceId || ctxSession.session.activeOrganizationId;
|
|
403
|
+
if (!referenceId) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.ORGANIZATION_REFERENCE_ID_REQUIRED);
|
|
404
|
+
if (!await subscriptionOptions.authorizeReference({
|
|
405
|
+
user: ctxSession.user,
|
|
406
|
+
session: ctxSession.session,
|
|
407
|
+
referenceId,
|
|
408
|
+
action
|
|
409
|
+
}, ctx)) throw APIError$1.from("UNAUTHORIZED", STRIPE_ERROR_CODES.UNAUTHORIZED);
|
|
410
|
+
return;
|
|
198
411
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
412
|
+
if (!explicitReferenceId) return;
|
|
413
|
+
if (explicitReferenceId === ctxSession.user.id) return;
|
|
414
|
+
if (!subscriptionOptions.authorizeReference) {
|
|
415
|
+
ctx.context.logger.error(`Passing referenceId into a subscription action isn't allowed if subscription.authorizeReference isn't defined in your stripe plugin config.`);
|
|
416
|
+
throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.REFERENCE_ID_NOT_ALLOWED);
|
|
417
|
+
}
|
|
418
|
+
if (!await subscriptionOptions.authorizeReference({
|
|
419
|
+
user: ctxSession.user,
|
|
420
|
+
session: ctxSession.session,
|
|
421
|
+
referenceId: explicitReferenceId,
|
|
207
422
|
action
|
|
208
|
-
}, ctx)
|
|
423
|
+
}, ctx)) throw APIError$1.from("UNAUTHORIZED", STRIPE_ERROR_CODES.UNAUTHORIZED);
|
|
209
424
|
});
|
|
210
425
|
|
|
211
426
|
//#endregion
|
|
212
427
|
//#region src/routes.ts
|
|
428
|
+
/**
|
|
429
|
+
* Converts a relative URL to an absolute URL using baseURL.
|
|
430
|
+
* @internal
|
|
431
|
+
*/
|
|
432
|
+
function getUrl(ctx, url) {
|
|
433
|
+
if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(url)) return url;
|
|
434
|
+
return `${ctx.context.options.baseURL}${url.startsWith("/") ? url : `/${url}`}`;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Resolves a Stripe price ID from a lookup key.
|
|
438
|
+
* @internal
|
|
439
|
+
*/
|
|
440
|
+
async function resolvePriceIdFromLookupKey(stripeClient, lookupKey) {
|
|
441
|
+
if (!lookupKey) return void 0;
|
|
442
|
+
return (await stripeClient.prices.list({
|
|
443
|
+
lookup_keys: [lookupKey],
|
|
444
|
+
active: true,
|
|
445
|
+
limit: 1
|
|
446
|
+
})).data[0]?.id;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Determines the reference ID based on customer type.
|
|
450
|
+
* - `user` (default): uses userId
|
|
451
|
+
* - `organization`: uses activeOrganizationId from session
|
|
452
|
+
* @internal
|
|
453
|
+
*/
|
|
454
|
+
function getReferenceId(ctxSession, customerType, options) {
|
|
455
|
+
const { user, session } = ctxSession;
|
|
456
|
+
if ((customerType || "user") === "organization") {
|
|
457
|
+
if (!options.organization?.enabled) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.ORGANIZATION_SUBSCRIPTION_NOT_ENABLED);
|
|
458
|
+
if (!session.activeOrganizationId) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.ORGANIZATION_NOT_FOUND);
|
|
459
|
+
return session.activeOrganizationId;
|
|
460
|
+
}
|
|
461
|
+
return user.id;
|
|
462
|
+
}
|
|
213
463
|
const upgradeSubscriptionBodySchema = z.object({
|
|
214
464
|
plan: z.string().meta({ description: "The name of the plan to upgrade to. Eg: \"pro\"" }),
|
|
215
465
|
annual: z.boolean().meta({ description: "Whether to upgrade to an annual plan. Eg: true" }).optional(),
|
|
216
|
-
referenceId: z.string().meta({ description: "Reference
|
|
466
|
+
referenceId: z.string().meta({ description: "Reference ID for the subscription. Eg: \"org_123\"" }).optional(),
|
|
217
467
|
subscriptionId: z.string().meta({ description: "The Stripe subscription ID to upgrade. Eg: \"sub_1ABC2DEF3GHI4JKL\"" }).optional(),
|
|
468
|
+
customerType: z.enum(["user", "organization"]).meta({ description: "Customer type for the subscription. Eg: \"user\" or \"organization\"" }).optional(),
|
|
218
469
|
metadata: z.record(z.string(), z.any()).optional(),
|
|
219
470
|
seats: z.number().meta({ description: "Number of seats to upgrade to (if applicable). Eg: 1" }).optional(),
|
|
471
|
+
locale: z.custom((localization) => {
|
|
472
|
+
return typeof localization === "string";
|
|
473
|
+
}).meta({ description: "The locale to display Checkout in. Eg: 'en', 'ko'. If not provided or set to `auto`, the browser's locale is used." }).optional(),
|
|
220
474
|
successUrl: z.string().meta({ description: "Callback URL to redirect back after successful subscription. Eg: \"https://example.com/success\"" }).default("/"),
|
|
221
475
|
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("/"),
|
|
222
476
|
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(),
|
|
@@ -245,75 +499,133 @@ const upgradeSubscription = (options) => {
|
|
|
245
499
|
body: upgradeSubscriptionBodySchema,
|
|
246
500
|
metadata: { openapi: { operationId: "upgradeSubscription" } },
|
|
247
501
|
use: [
|
|
248
|
-
|
|
502
|
+
stripeSessionMiddleware,
|
|
503
|
+
referenceMiddleware(subscriptionOptions, "upgrade-subscription"),
|
|
249
504
|
originCheck((c) => {
|
|
250
505
|
return [c.body.successUrl, c.body.cancelUrl];
|
|
251
|
-
})
|
|
252
|
-
referenceMiddleware(subscriptionOptions, "upgrade-subscription")
|
|
506
|
+
})
|
|
253
507
|
]
|
|
254
508
|
}, async (ctx) => {
|
|
255
|
-
const { user
|
|
256
|
-
|
|
257
|
-
const referenceId = ctx.body.referenceId ||
|
|
509
|
+
const { user, session } = ctx.context.session;
|
|
510
|
+
const customerType = ctx.body.customerType || "user";
|
|
511
|
+
const referenceId = ctx.body.referenceId || getReferenceId(ctx.context.session, customerType, options);
|
|
512
|
+
if (!user.emailVerified && subscriptionOptions.requireEmailVerification) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED);
|
|
258
513
|
const plan = await getPlanByName(options, ctx.body.plan);
|
|
259
|
-
if (!plan) throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES
|
|
260
|
-
|
|
514
|
+
if (!plan) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_PLAN_NOT_FOUND);
|
|
515
|
+
const subscriptionToUpdate = ctx.body.subscriptionId ? await ctx.context.adapter.findOne({
|
|
261
516
|
model: "subscription",
|
|
262
517
|
where: [{
|
|
263
518
|
field: "stripeSubscriptionId",
|
|
264
519
|
value: ctx.body.subscriptionId
|
|
265
520
|
}]
|
|
266
|
-
}) : referenceId ? await ctx.context.adapter.findOne({
|
|
267
|
-
model: "subscription",
|
|
268
|
-
where: [{
|
|
269
|
-
field: "referenceId",
|
|
270
|
-
value: referenceId
|
|
271
|
-
}]
|
|
272
521
|
}) : null;
|
|
273
|
-
if (ctx.body.subscriptionId && subscriptionToUpdate
|
|
274
|
-
if (ctx.body.subscriptionId &&
|
|
275
|
-
let customerId
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
522
|
+
if (ctx.body.subscriptionId && !subscriptionToUpdate) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
|
|
523
|
+
if (ctx.body.subscriptionId && subscriptionToUpdate && subscriptionToUpdate.referenceId !== referenceId) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
|
|
524
|
+
let customerId;
|
|
525
|
+
if (customerType === "organization") {
|
|
526
|
+
customerId = subscriptionToUpdate?.stripeCustomerId;
|
|
527
|
+
if (!customerId) {
|
|
528
|
+
const org = await ctx.context.adapter.findOne({
|
|
529
|
+
model: "organization",
|
|
530
|
+
where: [{
|
|
531
|
+
field: "id",
|
|
532
|
+
value: referenceId
|
|
533
|
+
}]
|
|
534
|
+
});
|
|
535
|
+
if (!org) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.ORGANIZATION_NOT_FOUND);
|
|
536
|
+
customerId = org.stripeCustomerId;
|
|
537
|
+
if (!customerId) try {
|
|
538
|
+
let stripeCustomer = (await client.customers.search({
|
|
539
|
+
query: `metadata["${customerMetadata.keys.organizationId}"]:"${org.id}"`,
|
|
540
|
+
limit: 1
|
|
541
|
+
})).data[0];
|
|
542
|
+
if (!stripeCustomer) {
|
|
543
|
+
let extraCreateParams = {};
|
|
544
|
+
if (options.organization?.getCustomerCreateParams) extraCreateParams = await options.organization.getCustomerCreateParams(org, ctx);
|
|
545
|
+
const customerParams = defu({
|
|
546
|
+
name: org.name,
|
|
547
|
+
metadata: customerMetadata.set({
|
|
548
|
+
organizationId: org.id,
|
|
549
|
+
customerType: "organization"
|
|
550
|
+
}, ctx.body.metadata)
|
|
551
|
+
}, extraCreateParams);
|
|
552
|
+
stripeCustomer = await client.customers.create(customerParams);
|
|
553
|
+
await options.organization?.onCustomerCreate?.({
|
|
554
|
+
stripeCustomer,
|
|
555
|
+
organization: {
|
|
556
|
+
...org,
|
|
557
|
+
stripeCustomerId: stripeCustomer.id
|
|
558
|
+
}
|
|
559
|
+
}, ctx);
|
|
560
|
+
}
|
|
561
|
+
await ctx.context.adapter.update({
|
|
562
|
+
model: "organization",
|
|
563
|
+
update: { stripeCustomerId: stripeCustomer.id },
|
|
564
|
+
where: [{
|
|
565
|
+
field: "id",
|
|
566
|
+
value: org.id
|
|
567
|
+
}]
|
|
568
|
+
});
|
|
569
|
+
customerId = stripeCustomer.id;
|
|
570
|
+
} catch (e) {
|
|
571
|
+
ctx.context.logger.error(e);
|
|
572
|
+
throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER);
|
|
287
573
|
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}]
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
574
|
+
}
|
|
575
|
+
} else {
|
|
576
|
+
customerId = subscriptionToUpdate?.stripeCustomerId || user.stripeCustomerId;
|
|
577
|
+
if (!customerId) try {
|
|
578
|
+
let stripeCustomer = (await client.customers.search({
|
|
579
|
+
query: `email:"${escapeStripeSearchValue(user.email)}" AND -metadata["${customerMetadata.keys.customerType}"]:"organization"`,
|
|
580
|
+
limit: 1
|
|
581
|
+
})).data[0];
|
|
582
|
+
if (!stripeCustomer) stripeCustomer = await client.customers.create({
|
|
583
|
+
email: user.email,
|
|
584
|
+
name: user.name,
|
|
585
|
+
metadata: customerMetadata.set({
|
|
586
|
+
userId: user.id,
|
|
587
|
+
customerType: "user"
|
|
588
|
+
}, ctx.body.metadata)
|
|
589
|
+
});
|
|
590
|
+
await ctx.context.adapter.update({
|
|
591
|
+
model: "user",
|
|
592
|
+
update: { stripeCustomerId: stripeCustomer.id },
|
|
593
|
+
where: [{
|
|
594
|
+
field: "id",
|
|
595
|
+
value: user.id
|
|
596
|
+
}]
|
|
597
|
+
});
|
|
598
|
+
customerId = stripeCustomer.id;
|
|
599
|
+
} catch (e) {
|
|
600
|
+
ctx.context.logger.error(e);
|
|
601
|
+
throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER);
|
|
602
|
+
}
|
|
301
603
|
}
|
|
302
|
-
const subscriptions
|
|
604
|
+
const subscriptions = subscriptionToUpdate ? [subscriptionToUpdate] : await ctx.context.adapter.findMany({
|
|
303
605
|
model: "subscription",
|
|
304
606
|
where: [{
|
|
305
607
|
field: "referenceId",
|
|
306
|
-
value:
|
|
608
|
+
value: referenceId
|
|
307
609
|
}]
|
|
308
610
|
});
|
|
309
|
-
const activeOrTrialingSubscription = subscriptions
|
|
310
|
-
const activeSubscription = (await client.subscriptions.list({ customer: customerId }).then((res) => res.data.filter((sub) => sub
|
|
611
|
+
const activeOrTrialingSubscription = subscriptions.find((sub) => isActiveOrTrialing(sub));
|
|
612
|
+
const activeSubscription = (await client.subscriptions.list({ customer: customerId }).then((res) => res.data.filter((sub) => isActiveOrTrialing(sub)))).find((sub) => {
|
|
311
613
|
if (subscriptionToUpdate?.stripeSubscriptionId || ctx.body.subscriptionId) return sub.id === subscriptionToUpdate?.stripeSubscriptionId || sub.id === ctx.body.subscriptionId;
|
|
312
614
|
if (activeOrTrialingSubscription?.stripeSubscriptionId) return sub.id === activeOrTrialingSubscription.stripeSubscriptionId;
|
|
313
615
|
return false;
|
|
314
616
|
});
|
|
315
|
-
const
|
|
316
|
-
|
|
617
|
+
const stripeSubscriptionPriceId = activeSubscription?.items.data[0]?.price.id;
|
|
618
|
+
const incompleteSubscription = subscriptions.find((sub) => sub.status === "incomplete");
|
|
619
|
+
const priceId = ctx.body.annual ? plan.annualDiscountPriceId : plan.priceId;
|
|
620
|
+
const lookupKey = ctx.body.annual ? plan.annualDiscountLookupKey : plan.lookupKey;
|
|
621
|
+
const resolvedPriceId = lookupKey ? await resolvePriceIdFromLookupKey(client, lookupKey) : void 0;
|
|
622
|
+
const priceIdToUse = priceId || resolvedPriceId;
|
|
623
|
+
if (!priceIdToUse) throw ctx.error("BAD_REQUEST", { message: "Price ID not found for the selected plan" });
|
|
624
|
+
const isSamePlan = activeOrTrialingSubscription?.plan === ctx.body.plan;
|
|
625
|
+
const isSameSeats = activeOrTrialingSubscription?.seats === (ctx.body.seats || 1);
|
|
626
|
+
const isSamePriceId = stripeSubscriptionPriceId === priceIdToUse;
|
|
627
|
+
const isSubscriptionStillValid = !activeOrTrialingSubscription?.periodEnd || activeOrTrialingSubscription.periodEnd > /* @__PURE__ */ new Date();
|
|
628
|
+
if (activeOrTrialingSubscription?.status === "active" && isSamePlan && isSameSeats && isSamePriceId && isSubscriptionStillValid) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.ALREADY_SUBSCRIBED_PLAN);
|
|
317
629
|
if (activeSubscription && customerId) {
|
|
318
630
|
let dbSubscription = await ctx.context.adapter.findOne({
|
|
319
631
|
model: "subscription",
|
|
@@ -336,15 +648,6 @@ const upgradeSubscription = (options) => {
|
|
|
336
648
|
});
|
|
337
649
|
dbSubscription = activeOrTrialingSubscription;
|
|
338
650
|
}
|
|
339
|
-
let priceIdToUse$1 = void 0;
|
|
340
|
-
if (ctx.body.annual) {
|
|
341
|
-
priceIdToUse$1 = plan.annualDiscountPriceId;
|
|
342
|
-
if (!priceIdToUse$1 && plan.annualDiscountLookupKey) priceIdToUse$1 = await resolvePriceIdFromLookupKey(client, plan.annualDiscountLookupKey);
|
|
343
|
-
} else {
|
|
344
|
-
priceIdToUse$1 = plan.priceId;
|
|
345
|
-
if (!priceIdToUse$1 && plan.lookupKey) priceIdToUse$1 = await resolvePriceIdFromLookupKey(client, plan.lookupKey);
|
|
346
|
-
}
|
|
347
|
-
if (!priceIdToUse$1) throw ctx.error("BAD_REQUEST", { message: "Price ID not found for the selected plan" });
|
|
348
651
|
const { url } = await client.billingPortal.sessions.create({
|
|
349
652
|
customer: customerId,
|
|
350
653
|
return_url: getUrl(ctx, ctx.body.returnUrl || "/"),
|
|
@@ -359,7 +662,7 @@ const upgradeSubscription = (options) => {
|
|
|
359
662
|
items: [{
|
|
360
663
|
id: activeSubscription.items.data[0]?.id,
|
|
361
664
|
quantity: ctx.body.seats || 1,
|
|
362
|
-
price: priceIdToUse
|
|
665
|
+
price: priceIdToUse
|
|
363
666
|
}]
|
|
364
667
|
}
|
|
365
668
|
}
|
|
@@ -371,7 +674,7 @@ const upgradeSubscription = (options) => {
|
|
|
371
674
|
});
|
|
372
675
|
return ctx.json({
|
|
373
676
|
url,
|
|
374
|
-
redirect:
|
|
677
|
+
redirect: !ctx.body.disableRedirect
|
|
375
678
|
});
|
|
376
679
|
}
|
|
377
680
|
let subscription = activeOrTrialingSubscription || incompleteSubscription;
|
|
@@ -399,49 +702,54 @@ const upgradeSubscription = (options) => {
|
|
|
399
702
|
});
|
|
400
703
|
if (!subscription) {
|
|
401
704
|
ctx.context.logger.error("Subscription ID not found");
|
|
402
|
-
throw
|
|
705
|
+
throw APIError$1.from("NOT_FOUND", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
|
|
403
706
|
}
|
|
404
707
|
const params = await subscriptionOptions.getCheckoutSessionParams?.({
|
|
405
|
-
user
|
|
708
|
+
user,
|
|
406
709
|
session,
|
|
407
710
|
plan,
|
|
408
711
|
subscription
|
|
409
712
|
}, ctx.request, ctx);
|
|
410
|
-
const freeTrial = !
|
|
713
|
+
const freeTrial = !(await ctx.context.adapter.findMany({
|
|
714
|
+
model: "subscription",
|
|
715
|
+
where: [{
|
|
716
|
+
field: "referenceId",
|
|
717
|
+
value: referenceId
|
|
718
|
+
}]
|
|
719
|
+
})).some((s) => {
|
|
411
720
|
return !!(s.trialStart || s.trialEnd) || s.status === "trialing";
|
|
412
721
|
}) && plan.freeTrial ? { trial_period_days: plan.freeTrial.days } : void 0;
|
|
413
|
-
let priceIdToUse = void 0;
|
|
414
|
-
if (ctx.body.annual) {
|
|
415
|
-
priceIdToUse = plan.annualDiscountPriceId;
|
|
416
|
-
if (!priceIdToUse && plan.annualDiscountLookupKey) priceIdToUse = await resolvePriceIdFromLookupKey(client, plan.annualDiscountLookupKey);
|
|
417
|
-
} else {
|
|
418
|
-
priceIdToUse = plan.priceId;
|
|
419
|
-
if (!priceIdToUse && plan.lookupKey) priceIdToUse = await resolvePriceIdFromLookupKey(client, plan.lookupKey);
|
|
420
|
-
}
|
|
421
722
|
const checkoutSession = await client.checkout.sessions.create({
|
|
422
723
|
...customerId ? {
|
|
423
724
|
customer: customerId,
|
|
424
|
-
customer_update: {
|
|
725
|
+
customer_update: customerType !== "user" ? { address: "auto" } : {
|
|
425
726
|
name: "auto",
|
|
426
727
|
address: "auto"
|
|
427
728
|
}
|
|
428
|
-
} : { customer_email:
|
|
729
|
+
} : { customer_email: user.email },
|
|
730
|
+
locale: ctx.body.locale,
|
|
429
731
|
success_url: getUrl(ctx, `${ctx.context.baseURL}/subscription/success?callbackURL=${encodeURIComponent(ctx.body.successUrl)}&subscriptionId=${encodeURIComponent(subscription.id)}`),
|
|
430
732
|
cancel_url: getUrl(ctx, ctx.body.cancelUrl),
|
|
431
733
|
line_items: [{
|
|
432
734
|
price: priceIdToUse,
|
|
433
735
|
quantity: ctx.body.seats || 1
|
|
434
736
|
}],
|
|
435
|
-
subscription_data: {
|
|
737
|
+
subscription_data: {
|
|
738
|
+
...freeTrial,
|
|
739
|
+
metadata: subscriptionMetadata.set({
|
|
740
|
+
userId: user.id,
|
|
741
|
+
subscriptionId: subscription.id,
|
|
742
|
+
referenceId
|
|
743
|
+
}, ctx.body.metadata, params?.params?.subscription_data?.metadata)
|
|
744
|
+
},
|
|
436
745
|
mode: "subscription",
|
|
437
746
|
client_reference_id: referenceId,
|
|
438
747
|
...params?.params,
|
|
439
|
-
metadata: {
|
|
440
|
-
userId: user
|
|
748
|
+
metadata: subscriptionMetadata.set({
|
|
749
|
+
userId: user.id,
|
|
441
750
|
subscriptionId: subscription.id,
|
|
442
|
-
referenceId
|
|
443
|
-
|
|
444
|
-
}
|
|
751
|
+
referenceId
|
|
752
|
+
}, ctx.body.metadata, params?.params?.metadata)
|
|
445
753
|
}, params?.options).catch(async (e) => {
|
|
446
754
|
throw ctx.error("BAD_REQUEST", {
|
|
447
755
|
message: e.message,
|
|
@@ -467,9 +775,9 @@ const cancelSubscriptionCallback = (options) => {
|
|
|
467
775
|
if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
|
|
468
776
|
const session = await getSessionFromCtx(ctx);
|
|
469
777
|
if (!session) throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
|
|
470
|
-
const { user
|
|
778
|
+
const { user } = session;
|
|
471
779
|
const { callbackURL, subscriptionId } = ctx.query;
|
|
472
|
-
if (user
|
|
780
|
+
if (user?.stripeCustomerId) try {
|
|
473
781
|
const subscription = await ctx.context.adapter.findOne({
|
|
474
782
|
model: "subscription",
|
|
475
783
|
where: [{
|
|
@@ -477,17 +785,19 @@ const cancelSubscriptionCallback = (options) => {
|
|
|
477
785
|
value: subscriptionId
|
|
478
786
|
}]
|
|
479
787
|
});
|
|
480
|
-
if (!subscription || subscription.
|
|
788
|
+
if (!subscription || subscription.status === "canceled" || isPendingCancel(subscription)) throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
481
789
|
const currentSubscription = (await client.subscriptions.list({
|
|
482
|
-
customer: user
|
|
790
|
+
customer: user.stripeCustomerId,
|
|
483
791
|
status: "active"
|
|
484
792
|
})).data.find((sub) => sub.id === subscription.stripeSubscriptionId);
|
|
485
|
-
if (currentSubscription
|
|
793
|
+
if (currentSubscription && isStripePendingCancel(currentSubscription) && !isPendingCancel(subscription)) {
|
|
486
794
|
await ctx.context.adapter.update({
|
|
487
795
|
model: "subscription",
|
|
488
796
|
update: {
|
|
489
797
|
status: currentSubscription?.status,
|
|
490
|
-
cancelAtPeriodEnd:
|
|
798
|
+
cancelAtPeriodEnd: currentSubscription?.cancel_at_period_end || false,
|
|
799
|
+
cancelAt: currentSubscription?.cancel_at ? /* @__PURE__ */ new Date(currentSubscription.cancel_at * 1e3) : null,
|
|
800
|
+
canceledAt: currentSubscription?.canceled_at ? /* @__PURE__ */ new Date(currentSubscription.canceled_at * 1e3) : null
|
|
491
801
|
},
|
|
492
802
|
where: [{
|
|
493
803
|
field: "id",
|
|
@@ -510,7 +820,9 @@ const cancelSubscriptionCallback = (options) => {
|
|
|
510
820
|
const cancelSubscriptionBodySchema = z.object({
|
|
511
821
|
referenceId: z.string().meta({ description: "Reference id of the subscription to cancel. Eg: '123'" }).optional(),
|
|
512
822
|
subscriptionId: z.string().meta({ description: "The Stripe subscription ID to cancel. Eg: 'sub_1ABC2DEF3GHI4JKL'" }).optional(),
|
|
513
|
-
|
|
823
|
+
customerType: z.enum(["user", "organization"]).meta({ description: "Customer type for the subscription. Eg: \"user\" or \"organization\"" }).optional(),
|
|
824
|
+
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: \"/account\"" }),
|
|
825
|
+
disableRedirect: z.boolean().meta({ description: "Disable redirect after successful subscription cancellation. Eg: true" }).default(false)
|
|
514
826
|
});
|
|
515
827
|
/**
|
|
516
828
|
* ### Endpoint
|
|
@@ -535,12 +847,13 @@ const cancelSubscription = (options) => {
|
|
|
535
847
|
body: cancelSubscriptionBodySchema,
|
|
536
848
|
metadata: { openapi: { operationId: "cancelSubscription" } },
|
|
537
849
|
use: [
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
850
|
+
stripeSessionMiddleware,
|
|
851
|
+
referenceMiddleware(subscriptionOptions, "cancel-subscription"),
|
|
852
|
+
originCheck((ctx) => ctx.body.returnUrl)
|
|
541
853
|
]
|
|
542
854
|
}, async (ctx) => {
|
|
543
|
-
const
|
|
855
|
+
const customerType = ctx.body.customerType || "user";
|
|
856
|
+
const referenceId = ctx.body.referenceId || getReferenceId(ctx.context.session, customerType, options);
|
|
544
857
|
let subscription = ctx.body.subscriptionId ? await ctx.context.adapter.findOne({
|
|
545
858
|
model: "subscription",
|
|
546
859
|
where: [{
|
|
@@ -553,10 +866,10 @@ const cancelSubscription = (options) => {
|
|
|
553
866
|
field: "referenceId",
|
|
554
867
|
value: referenceId
|
|
555
868
|
}]
|
|
556
|
-
}).then((subs) => subs.find((sub) => sub
|
|
869
|
+
}).then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
|
|
557
870
|
if (ctx.body.subscriptionId && subscription && subscription.referenceId !== referenceId) subscription = void 0;
|
|
558
|
-
if (!subscription || !subscription.stripeCustomerId) throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES
|
|
559
|
-
const activeSubscriptions = await client.subscriptions.list({ customer: subscription.stripeCustomerId }).then((res) => res.data.filter((sub) => sub
|
|
871
|
+
if (!subscription || !subscription.stripeCustomerId) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
|
|
872
|
+
const activeSubscriptions = await client.subscriptions.list({ customer: subscription.stripeCustomerId }).then((res) => res.data.filter((sub) => isActiveOrTrialing(sub)));
|
|
560
873
|
if (!activeSubscriptions.length) {
|
|
561
874
|
/**
|
|
562
875
|
* If the subscription is not found, we need to delete the subscription
|
|
@@ -569,10 +882,10 @@ const cancelSubscription = (options) => {
|
|
|
569
882
|
value: referenceId
|
|
570
883
|
}]
|
|
571
884
|
});
|
|
572
|
-
throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES
|
|
885
|
+
throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
|
|
573
886
|
}
|
|
574
887
|
const activeSubscription = activeSubscriptions.find((sub) => sub.id === subscription.stripeSubscriptionId);
|
|
575
|
-
if (!activeSubscription) throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES
|
|
888
|
+
if (!activeSubscription) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
|
|
576
889
|
const { url } = await client.billingPortal.sessions.create({
|
|
577
890
|
customer: subscription.stripeCustomerId,
|
|
578
891
|
return_url: getUrl(ctx, `${ctx.context.baseURL}/subscription/cancel/callback?callbackURL=${encodeURIComponent(ctx.body?.returnUrl || "/")}&subscriptionId=${encodeURIComponent(subscription.id)}`),
|
|
@@ -581,34 +894,42 @@ const cancelSubscription = (options) => {
|
|
|
581
894
|
subscription_cancel: { subscription: activeSubscription.id }
|
|
582
895
|
}
|
|
583
896
|
}).catch(async (e) => {
|
|
584
|
-
if (e.message
|
|
897
|
+
if (e.message?.includes("already set to be canceled")) {
|
|
585
898
|
/**
|
|
586
|
-
* in-case we missed the event from stripe, we
|
|
899
|
+
* in-case we missed the event from stripe, we sync the actual state
|
|
587
900
|
* this is a rare case and should not happen
|
|
588
901
|
*/
|
|
589
|
-
if (!subscription
|
|
590
|
-
|
|
591
|
-
update
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
902
|
+
if (!isPendingCancel(subscription)) {
|
|
903
|
+
const stripeSub = await client.subscriptions.retrieve(activeSubscription.id);
|
|
904
|
+
await ctx.context.adapter.update({
|
|
905
|
+
model: "subscription",
|
|
906
|
+
update: {
|
|
907
|
+
cancelAtPeriodEnd: stripeSub.cancel_at_period_end,
|
|
908
|
+
cancelAt: stripeSub.cancel_at ? /* @__PURE__ */ new Date(stripeSub.cancel_at * 1e3) : null,
|
|
909
|
+
canceledAt: stripeSub.canceled_at ? /* @__PURE__ */ new Date(stripeSub.canceled_at * 1e3) : null
|
|
910
|
+
},
|
|
911
|
+
where: [{
|
|
912
|
+
field: "id",
|
|
913
|
+
value: subscription.id
|
|
914
|
+
}]
|
|
915
|
+
});
|
|
916
|
+
}
|
|
597
917
|
}
|
|
598
918
|
throw ctx.error("BAD_REQUEST", {
|
|
599
919
|
message: e.message,
|
|
600
920
|
code: e.code
|
|
601
921
|
});
|
|
602
922
|
});
|
|
603
|
-
return {
|
|
923
|
+
return ctx.json({
|
|
604
924
|
url,
|
|
605
|
-
redirect:
|
|
606
|
-
};
|
|
925
|
+
redirect: !ctx.body.disableRedirect
|
|
926
|
+
});
|
|
607
927
|
});
|
|
608
928
|
};
|
|
609
929
|
const restoreSubscriptionBodySchema = z.object({
|
|
610
930
|
referenceId: z.string().meta({ description: "Reference id of the subscription to restore. Eg: '123'" }).optional(),
|
|
611
|
-
subscriptionId: z.string().meta({ description: "The Stripe subscription ID to restore. Eg: 'sub_1ABC2DEF3GHI4JKL'" }).optional()
|
|
931
|
+
subscriptionId: z.string().meta({ description: "The Stripe subscription ID to restore. Eg: 'sub_1ABC2DEF3GHI4JKL'" }).optional(),
|
|
932
|
+
customerType: z.enum(["user", "organization"]).meta({ description: "Customer type for the subscription. Eg: \"user\" or \"organization\"" }).optional()
|
|
612
933
|
});
|
|
613
934
|
const restoreSubscription = (options) => {
|
|
614
935
|
const client = options.stripeClient;
|
|
@@ -617,9 +938,10 @@ const restoreSubscription = (options) => {
|
|
|
617
938
|
method: "POST",
|
|
618
939
|
body: restoreSubscriptionBodySchema,
|
|
619
940
|
metadata: { openapi: { operationId: "restoreSubscription" } },
|
|
620
|
-
use: [
|
|
941
|
+
use: [stripeSessionMiddleware, referenceMiddleware(subscriptionOptions, "restore-subscription")]
|
|
621
942
|
}, async (ctx) => {
|
|
622
|
-
const
|
|
943
|
+
const customerType = ctx.body.customerType || "user";
|
|
944
|
+
const referenceId = ctx.body.referenceId || getReferenceId(ctx.context.session, customerType, options);
|
|
623
945
|
let subscription = ctx.body.subscriptionId ? await ctx.context.adapter.findOne({
|
|
624
946
|
model: "subscription",
|
|
625
947
|
where: [{
|
|
@@ -632,34 +954,42 @@ const restoreSubscription = (options) => {
|
|
|
632
954
|
field: "referenceId",
|
|
633
955
|
value: referenceId
|
|
634
956
|
}]
|
|
635
|
-
}).then((subs) => subs.find((sub) => sub
|
|
957
|
+
}).then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
|
|
636
958
|
if (ctx.body.subscriptionId && subscription && subscription.referenceId !== referenceId) subscription = void 0;
|
|
637
|
-
if (!subscription || !subscription.stripeCustomerId) throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES
|
|
638
|
-
if (subscription
|
|
639
|
-
if (!subscription
|
|
640
|
-
const activeSubscription = await client.subscriptions.list({ customer: subscription.stripeCustomerId }).then((res) => res.data.filter((sub) => sub
|
|
641
|
-
if (!activeSubscription) throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
},
|
|
650
|
-
where: [{
|
|
651
|
-
field: "id",
|
|
652
|
-
value: subscription.id
|
|
653
|
-
}]
|
|
959
|
+
if (!subscription || !subscription.stripeCustomerId) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
|
|
960
|
+
if (!isActiveOrTrialing(subscription)) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_ACTIVE);
|
|
961
|
+
if (!isPendingCancel(subscription)) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION);
|
|
962
|
+
const activeSubscription = await client.subscriptions.list({ customer: subscription.stripeCustomerId }).then((res) => res.data.filter((sub) => isActiveOrTrialing(sub))[0]);
|
|
963
|
+
if (!activeSubscription) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
|
|
964
|
+
const updateParams = {};
|
|
965
|
+
if (activeSubscription.cancel_at) updateParams.cancel_at = "";
|
|
966
|
+
else if (activeSubscription.cancel_at_period_end) updateParams.cancel_at_period_end = false;
|
|
967
|
+
const newSub = await client.subscriptions.update(activeSubscription.id, updateParams).catch((e) => {
|
|
968
|
+
throw ctx.error("BAD_REQUEST", {
|
|
969
|
+
message: e.message,
|
|
970
|
+
code: e.code
|
|
654
971
|
});
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
972
|
+
});
|
|
973
|
+
await ctx.context.adapter.update({
|
|
974
|
+
model: "subscription",
|
|
975
|
+
update: {
|
|
976
|
+
cancelAtPeriodEnd: false,
|
|
977
|
+
cancelAt: null,
|
|
978
|
+
canceledAt: null,
|
|
979
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
980
|
+
},
|
|
981
|
+
where: [{
|
|
982
|
+
field: "id",
|
|
983
|
+
value: subscription.id
|
|
984
|
+
}]
|
|
985
|
+
});
|
|
986
|
+
return ctx.json(newSub);
|
|
660
987
|
});
|
|
661
988
|
};
|
|
662
|
-
const listActiveSubscriptionsQuerySchema = z.optional(z.object({
|
|
989
|
+
const listActiveSubscriptionsQuerySchema = z.optional(z.object({
|
|
990
|
+
referenceId: z.string().meta({ description: "Reference id of the subscription to list. Eg: '123'" }).optional(),
|
|
991
|
+
customerType: z.enum(["user", "organization"]).meta({ description: "Customer type for the subscription. Eg: \"user\" or \"organization\"" }).optional()
|
|
992
|
+
}));
|
|
663
993
|
/**
|
|
664
994
|
* ### Endpoint
|
|
665
995
|
*
|
|
@@ -681,28 +1011,28 @@ const listActiveSubscriptions = (options) => {
|
|
|
681
1011
|
method: "GET",
|
|
682
1012
|
query: listActiveSubscriptionsQuerySchema,
|
|
683
1013
|
metadata: { openapi: { operationId: "listActiveSubscriptions" } },
|
|
684
|
-
use: [
|
|
1014
|
+
use: [stripeSessionMiddleware, referenceMiddleware(subscriptionOptions, "list-subscription")]
|
|
685
1015
|
}, async (ctx) => {
|
|
686
|
-
const
|
|
1016
|
+
const customerType = ctx.query?.customerType || "user";
|
|
1017
|
+
const referenceId = ctx.query?.referenceId || getReferenceId(ctx.context.session, customerType, options);
|
|
1018
|
+
const subscriptions = await ctx.context.adapter.findMany({
|
|
687
1019
|
model: "subscription",
|
|
688
1020
|
where: [{
|
|
689
1021
|
field: "referenceId",
|
|
690
|
-
value:
|
|
1022
|
+
value: referenceId
|
|
691
1023
|
}]
|
|
692
1024
|
});
|
|
693
|
-
if (!subscriptions
|
|
1025
|
+
if (!subscriptions.length) return [];
|
|
694
1026
|
const plans = await getPlans(options.subscription);
|
|
695
1027
|
if (!plans) return [];
|
|
696
|
-
const subs = subscriptions
|
|
1028
|
+
const subs = subscriptions.map((sub) => {
|
|
697
1029
|
const plan = plans.find((p) => p.name.toLowerCase() === sub.plan.toLowerCase());
|
|
698
1030
|
return {
|
|
699
1031
|
...sub,
|
|
700
1032
|
limits: plan?.limits,
|
|
701
1033
|
priceId: plan?.priceId
|
|
702
1034
|
};
|
|
703
|
-
}).filter((sub) =>
|
|
704
|
-
return sub.status === "active" || sub.status === "trialing";
|
|
705
|
-
});
|
|
1035
|
+
}).filter((sub) => isActiveOrTrialing(sub));
|
|
706
1036
|
return ctx.json(subs);
|
|
707
1037
|
});
|
|
708
1038
|
};
|
|
@@ -716,10 +1046,9 @@ const subscriptionSuccess = (options) => {
|
|
|
716
1046
|
use: [originCheck((ctx) => ctx.query.callbackURL)]
|
|
717
1047
|
}, async (ctx) => {
|
|
718
1048
|
if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
|
|
1049
|
+
const { callbackURL, subscriptionId } = ctx.query;
|
|
719
1050
|
const session = await getSessionFromCtx(ctx);
|
|
720
1051
|
if (!session) throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
|
|
721
|
-
const { user: user$1 } = session;
|
|
722
|
-
const { callbackURL, subscriptionId } = ctx.query;
|
|
723
1052
|
const subscription = await ctx.context.adapter.findOne({
|
|
724
1053
|
model: "subscription",
|
|
725
1054
|
where: [{
|
|
@@ -727,47 +1056,64 @@ const subscriptionSuccess = (options) => {
|
|
|
727
1056
|
value: subscriptionId
|
|
728
1057
|
}]
|
|
729
1058
|
});
|
|
730
|
-
if (subscription
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
update: {
|
|
742
|
-
status: stripeSubscription.status,
|
|
743
|
-
seats: stripeSubscription.items.data[0]?.quantity || 1,
|
|
744
|
-
plan: plan.name.toLowerCase(),
|
|
745
|
-
periodEnd: /* @__PURE__ */ new Date(stripeSubscription.items.data[0]?.current_period_end * 1e3),
|
|
746
|
-
periodStart: /* @__PURE__ */ new Date(stripeSubscription.items.data[0]?.current_period_start * 1e3),
|
|
747
|
-
stripeSubscriptionId: stripeSubscription.id,
|
|
748
|
-
...stripeSubscription.trial_start && stripeSubscription.trial_end ? {
|
|
749
|
-
trialStart: /* @__PURE__ */ new Date(stripeSubscription.trial_start * 1e3),
|
|
750
|
-
trialEnd: /* @__PURE__ */ new Date(stripeSubscription.trial_end * 1e3)
|
|
751
|
-
} : {}
|
|
752
|
-
},
|
|
753
|
-
where: [{
|
|
754
|
-
field: "id",
|
|
755
|
-
value: subscription.id
|
|
756
|
-
}]
|
|
757
|
-
});
|
|
758
|
-
}
|
|
759
|
-
} catch (error) {
|
|
1059
|
+
if (!subscription) {
|
|
1060
|
+
ctx.context.logger.warn(`Subscription record not found for subscriptionId: ${subscriptionId}`);
|
|
1061
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1062
|
+
}
|
|
1063
|
+
if (isActiveOrTrialing(subscription)) throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1064
|
+
const customerId = subscription.stripeCustomerId || session.user.stripeCustomerId;
|
|
1065
|
+
if (!customerId) throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1066
|
+
const stripeSubscription = await client.subscriptions.list({
|
|
1067
|
+
customer: customerId,
|
|
1068
|
+
status: "active"
|
|
1069
|
+
}).then((res) => res.data[0]).catch((error) => {
|
|
760
1070
|
ctx.context.logger.error("Error fetching subscription from Stripe", error);
|
|
1071
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1072
|
+
});
|
|
1073
|
+
if (!stripeSubscription) throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1074
|
+
const subscriptionItem = stripeSubscription.items.data[0];
|
|
1075
|
+
if (!subscriptionItem) {
|
|
1076
|
+
ctx.context.logger.warn(`No subscription items found for Stripe subscription ${stripeSubscription.id}`);
|
|
1077
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1078
|
+
}
|
|
1079
|
+
const plan = await getPlanByPriceInfo(options, subscriptionItem.price.id, subscriptionItem.price.lookup_key);
|
|
1080
|
+
if (!plan) {
|
|
1081
|
+
ctx.context.logger.warn(`Plan not found for price ${subscriptionItem.price.id}`);
|
|
1082
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
761
1083
|
}
|
|
1084
|
+
await ctx.context.adapter.update({
|
|
1085
|
+
model: "subscription",
|
|
1086
|
+
update: {
|
|
1087
|
+
status: stripeSubscription.status,
|
|
1088
|
+
seats: subscriptionItem.quantity || 1,
|
|
1089
|
+
plan: plan.name.toLowerCase(),
|
|
1090
|
+
periodEnd: /* @__PURE__ */ new Date(subscriptionItem.current_period_end * 1e3),
|
|
1091
|
+
periodStart: /* @__PURE__ */ new Date(subscriptionItem.current_period_start * 1e3),
|
|
1092
|
+
stripeSubscriptionId: stripeSubscription.id,
|
|
1093
|
+
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
|
|
1094
|
+
cancelAt: stripeSubscription.cancel_at ? /* @__PURE__ */ new Date(stripeSubscription.cancel_at * 1e3) : null,
|
|
1095
|
+
canceledAt: stripeSubscription.canceled_at ? /* @__PURE__ */ new Date(stripeSubscription.canceled_at * 1e3) : null,
|
|
1096
|
+
...stripeSubscription.trial_start && stripeSubscription.trial_end ? {
|
|
1097
|
+
trialStart: /* @__PURE__ */ new Date(stripeSubscription.trial_start * 1e3),
|
|
1098
|
+
trialEnd: /* @__PURE__ */ new Date(stripeSubscription.trial_end * 1e3)
|
|
1099
|
+
} : {}
|
|
1100
|
+
},
|
|
1101
|
+
where: [{
|
|
1102
|
+
field: "id",
|
|
1103
|
+
value: subscription.id
|
|
1104
|
+
}]
|
|
1105
|
+
});
|
|
762
1106
|
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
763
1107
|
});
|
|
764
1108
|
};
|
|
765
1109
|
const createBillingPortalBodySchema = z.object({
|
|
766
1110
|
locale: z.custom((localization) => {
|
|
767
1111
|
return typeof localization === "string";
|
|
768
|
-
}).optional(),
|
|
1112
|
+
}).meta({ description: "The IETF language tag of the locale Customer Portal is displayed in. Eg: 'en', 'ko'. If not provided or set to `auto`, the browser's locale is used." }).optional(),
|
|
769
1113
|
referenceId: z.string().optional(),
|
|
770
|
-
|
|
1114
|
+
customerType: z.enum(["user", "organization"]).meta({ description: "Customer type for the subscription. Eg: \"user\" or \"organization\"" }).optional(),
|
|
1115
|
+
returnUrl: z.string().default("/"),
|
|
1116
|
+
disableRedirect: z.boolean().meta({ description: "Disable redirect after creating billing portal session. Eg: true" }).default(false)
|
|
771
1117
|
});
|
|
772
1118
|
const createBillingPortal = (options) => {
|
|
773
1119
|
const client = options.stripeClient;
|
|
@@ -777,22 +1123,41 @@ const createBillingPortal = (options) => {
|
|
|
777
1123
|
body: createBillingPortalBodySchema,
|
|
778
1124
|
metadata: { openapi: { operationId: "createBillingPortal" } },
|
|
779
1125
|
use: [
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
1126
|
+
stripeSessionMiddleware,
|
|
1127
|
+
referenceMiddleware(subscriptionOptions, "billing-portal"),
|
|
1128
|
+
originCheck((ctx) => ctx.body.returnUrl)
|
|
783
1129
|
]
|
|
784
1130
|
}, async (ctx) => {
|
|
785
|
-
const { user
|
|
786
|
-
const
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
1131
|
+
const { user } = ctx.context.session;
|
|
1132
|
+
const customerType = ctx.body.customerType || "user";
|
|
1133
|
+
const referenceId = ctx.body.referenceId || getReferenceId(ctx.context.session, customerType, options);
|
|
1134
|
+
let customerId;
|
|
1135
|
+
if (customerType === "organization") {
|
|
1136
|
+
customerId = (await ctx.context.adapter.findOne({
|
|
1137
|
+
model: "organization",
|
|
1138
|
+
where: [{
|
|
1139
|
+
field: "id",
|
|
1140
|
+
value: referenceId
|
|
1141
|
+
}]
|
|
1142
|
+
}))?.stripeCustomerId;
|
|
1143
|
+
if (!customerId) customerId = (await ctx.context.adapter.findMany({
|
|
1144
|
+
model: "subscription",
|
|
1145
|
+
where: [{
|
|
1146
|
+
field: "referenceId",
|
|
1147
|
+
value: referenceId
|
|
1148
|
+
}]
|
|
1149
|
+
}).then((subs) => subs.find((sub) => isActiveOrTrialing(sub))))?.stripeCustomerId;
|
|
1150
|
+
} else {
|
|
1151
|
+
customerId = user.stripeCustomerId;
|
|
1152
|
+
if (!customerId) customerId = (await ctx.context.adapter.findMany({
|
|
1153
|
+
model: "subscription",
|
|
1154
|
+
where: [{
|
|
1155
|
+
field: "referenceId",
|
|
1156
|
+
value: referenceId
|
|
1157
|
+
}]
|
|
1158
|
+
}).then((subs) => subs.find((sub) => isActiveOrTrialing(sub))))?.stripeCustomerId;
|
|
1159
|
+
}
|
|
1160
|
+
if (!customerId) throw APIError$1.from("NOT_FOUND", STRIPE_ERROR_CODES.CUSTOMER_NOT_FOUND);
|
|
796
1161
|
try {
|
|
797
1162
|
const { url } = await client.billingPortal.sessions.create({
|
|
798
1163
|
locale: ctx.body.locale,
|
|
@@ -801,11 +1166,11 @@ const createBillingPortal = (options) => {
|
|
|
801
1166
|
});
|
|
802
1167
|
return ctx.json({
|
|
803
1168
|
url,
|
|
804
|
-
redirect:
|
|
1169
|
+
redirect: !ctx.body.disableRedirect
|
|
805
1170
|
});
|
|
806
1171
|
} catch (error) {
|
|
807
1172
|
ctx.context.logger.error("Error creating billing portal session", error);
|
|
808
|
-
throw
|
|
1173
|
+
throw APIError$1.from("INTERNAL_SERVER_ERROR", STRIPE_ERROR_CODES.UNABLE_TO_CREATE_BILLING_PORTAL);
|
|
809
1174
|
}
|
|
810
1175
|
});
|
|
811
1176
|
};
|
|
@@ -820,26 +1185,31 @@ const stripeWebhook = (options) => {
|
|
|
820
1185
|
cloneRequest: true,
|
|
821
1186
|
disableBody: true
|
|
822
1187
|
}, async (ctx) => {
|
|
823
|
-
if (!ctx.request?.body) throw
|
|
824
|
-
const buf = await ctx.request.text();
|
|
1188
|
+
if (!ctx.request?.body) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.INVALID_REQUEST_BODY);
|
|
825
1189
|
const sig = ctx.request.headers.get("stripe-signature");
|
|
1190
|
+
if (!sig) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.STRIPE_SIGNATURE_NOT_FOUND);
|
|
826
1191
|
const webhookSecret = options.stripeWebhookSecret;
|
|
1192
|
+
if (!webhookSecret) throw APIError$1.from("INTERNAL_SERVER_ERROR", STRIPE_ERROR_CODES.STRIPE_WEBHOOK_SECRET_NOT_FOUND);
|
|
1193
|
+
const payload = await ctx.request.text();
|
|
827
1194
|
let event;
|
|
828
1195
|
try {
|
|
829
|
-
if (
|
|
830
|
-
|
|
831
|
-
else event = client.webhooks.constructEvent(buf, sig, webhookSecret);
|
|
1196
|
+
if (typeof client.webhooks.constructEventAsync === "function") event = await client.webhooks.constructEventAsync(payload, sig, webhookSecret);
|
|
1197
|
+
else event = client.webhooks.constructEvent(payload, sig, webhookSecret);
|
|
832
1198
|
} catch (err) {
|
|
833
1199
|
ctx.context.logger.error(`${err.message}`);
|
|
834
|
-
throw
|
|
1200
|
+
throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.FAILED_TO_CONSTRUCT_STRIPE_EVENT);
|
|
835
1201
|
}
|
|
836
|
-
if (!event) throw
|
|
1202
|
+
if (!event) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.FAILED_TO_CONSTRUCT_STRIPE_EVENT);
|
|
837
1203
|
try {
|
|
838
1204
|
switch (event.type) {
|
|
839
1205
|
case "checkout.session.completed":
|
|
840
1206
|
await onCheckoutSessionCompleted(ctx, options, event);
|
|
841
1207
|
await options.onEvent?.(event);
|
|
842
1208
|
break;
|
|
1209
|
+
case "customer.subscription.created":
|
|
1210
|
+
await onSubscriptionCreated(ctx, options, event);
|
|
1211
|
+
await options.onEvent?.(event);
|
|
1212
|
+
break;
|
|
843
1213
|
case "customer.subscription.updated":
|
|
844
1214
|
await onSubscriptionUpdated(ctx, options, event);
|
|
845
1215
|
await options.onEvent?.(event);
|
|
@@ -854,23 +1224,11 @@ const stripeWebhook = (options) => {
|
|
|
854
1224
|
}
|
|
855
1225
|
} catch (e) {
|
|
856
1226
|
ctx.context.logger.error(`Stripe webhook failed. Error: ${e.message}`);
|
|
857
|
-
throw
|
|
1227
|
+
throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.STRIPE_WEBHOOK_ERROR);
|
|
858
1228
|
}
|
|
859
1229
|
return ctx.json({ success: true });
|
|
860
1230
|
});
|
|
861
1231
|
};
|
|
862
|
-
const getUrl = (ctx, url) => {
|
|
863
|
-
if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(url)) return url;
|
|
864
|
-
return `${ctx.context.options.baseURL}${url.startsWith("/") ? url : `/${url}`}`;
|
|
865
|
-
};
|
|
866
|
-
async function resolvePriceIdFromLookupKey(stripeClient, lookupKey) {
|
|
867
|
-
if (!lookupKey) return void 0;
|
|
868
|
-
return (await stripeClient.prices.list({
|
|
869
|
-
lookup_keys: [lookupKey],
|
|
870
|
-
active: true,
|
|
871
|
-
limit: 1
|
|
872
|
-
})).data[0]?.id;
|
|
873
|
-
}
|
|
874
1232
|
|
|
875
1233
|
//#endregion
|
|
876
1234
|
//#region src/schema.ts
|
|
@@ -916,6 +1274,18 @@ const subscriptions = { subscription: { fields: {
|
|
|
916
1274
|
required: false,
|
|
917
1275
|
defaultValue: false
|
|
918
1276
|
},
|
|
1277
|
+
cancelAt: {
|
|
1278
|
+
type: "date",
|
|
1279
|
+
required: false
|
|
1280
|
+
},
|
|
1281
|
+
canceledAt: {
|
|
1282
|
+
type: "date",
|
|
1283
|
+
required: false
|
|
1284
|
+
},
|
|
1285
|
+
endedAt: {
|
|
1286
|
+
type: "date",
|
|
1287
|
+
required: false
|
|
1288
|
+
},
|
|
919
1289
|
seats: {
|
|
920
1290
|
type: "number",
|
|
921
1291
|
required: false
|
|
@@ -925,6 +1295,10 @@ const user = { user: { fields: { stripeCustomerId: {
|
|
|
925
1295
|
type: "string",
|
|
926
1296
|
required: false
|
|
927
1297
|
} } } };
|
|
1298
|
+
const organization = { organization: { fields: { stripeCustomerId: {
|
|
1299
|
+
type: "string",
|
|
1300
|
+
required: false
|
|
1301
|
+
} } } };
|
|
928
1302
|
const getSchema = (options) => {
|
|
929
1303
|
let baseSchema = {};
|
|
930
1304
|
if (options.subscription?.enabled) baseSchema = {
|
|
@@ -932,6 +1306,10 @@ const getSchema = (options) => {
|
|
|
932
1306
|
...user
|
|
933
1307
|
};
|
|
934
1308
|
else baseSchema = { ...user };
|
|
1309
|
+
if (options.organization?.enabled) baseSchema = {
|
|
1310
|
+
...baseSchema,
|
|
1311
|
+
...organization
|
|
1312
|
+
};
|
|
935
1313
|
if (options.schema && !options.subscription?.enabled && "subscription" in options.schema) {
|
|
936
1314
|
const { subscription: _subscription, ...restSchema } = options.schema;
|
|
937
1315
|
return mergeSchema(baseSchema, restSchema);
|
|
@@ -941,16 +1319,6 @@ const getSchema = (options) => {
|
|
|
941
1319
|
|
|
942
1320
|
//#endregion
|
|
943
1321
|
//#region src/index.ts
|
|
944
|
-
const STRIPE_ERROR_CODES = defineErrorCodes({
|
|
945
|
-
SUBSCRIPTION_NOT_FOUND: "Subscription not found",
|
|
946
|
-
SUBSCRIPTION_PLAN_NOT_FOUND: "Subscription plan not found",
|
|
947
|
-
ALREADY_SUBSCRIBED_PLAN: "You're already subscribed to this plan",
|
|
948
|
-
UNABLE_TO_CREATE_CUSTOMER: "Unable to create customer",
|
|
949
|
-
FAILED_TO_FETCH_PLANS: "Failed to fetch plans",
|
|
950
|
-
EMAIL_VERIFICATION_REQUIRED: "Email verification is required before you can subscribe to a plan",
|
|
951
|
-
SUBSCRIPTION_NOT_ACTIVE: "Subscription is not active",
|
|
952
|
-
SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION: "Subscription is not scheduled for cancellation"
|
|
953
|
-
});
|
|
954
1322
|
const stripe = (options) => {
|
|
955
1323
|
const client = options.stripeClient;
|
|
956
1324
|
const subscriptionEndpoints = {
|
|
@@ -969,64 +1337,122 @@ const stripe = (options) => {
|
|
|
969
1337
|
...options.subscription?.enabled ? subscriptionEndpoints : {}
|
|
970
1338
|
},
|
|
971
1339
|
init(ctx) {
|
|
1340
|
+
if (options.organization?.enabled) {
|
|
1341
|
+
const orgPlugin = ctx.getPlugin("organization");
|
|
1342
|
+
if (!orgPlugin) {
|
|
1343
|
+
ctx.logger.error(`Organization plugin not found`);
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
const existingHooks = orgPlugin.options.organizationHooks ?? {};
|
|
1347
|
+
/**
|
|
1348
|
+
* Sync organization name to Stripe customer
|
|
1349
|
+
*/
|
|
1350
|
+
const afterUpdateStripeOrg = async (data) => {
|
|
1351
|
+
const { organization } = data;
|
|
1352
|
+
if (!organization?.stripeCustomerId) return;
|
|
1353
|
+
try {
|
|
1354
|
+
const stripeCustomer = await client.customers.retrieve(organization.stripeCustomerId);
|
|
1355
|
+
if (stripeCustomer.deleted) {
|
|
1356
|
+
ctx.logger.warn(`Stripe customer ${organization.stripeCustomerId} was deleted`);
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
if (organization.name !== stripeCustomer.name) {
|
|
1360
|
+
await client.customers.update(organization.stripeCustomerId, { name: organization.name });
|
|
1361
|
+
ctx.logger.info(`Synced organization name to Stripe: "${stripeCustomer.name}" → "${organization.name}"`);
|
|
1362
|
+
}
|
|
1363
|
+
} catch (e) {
|
|
1364
|
+
ctx.logger.error(`Failed to sync organization to Stripe: ${e.message}`);
|
|
1365
|
+
}
|
|
1366
|
+
};
|
|
1367
|
+
/**
|
|
1368
|
+
* Block deletion if organization has active subscriptions
|
|
1369
|
+
*/
|
|
1370
|
+
const beforeDeleteStripeOrg = async (data) => {
|
|
1371
|
+
const { organization } = data;
|
|
1372
|
+
if (!organization.stripeCustomerId) return;
|
|
1373
|
+
try {
|
|
1374
|
+
const subscriptions = await client.subscriptions.list({
|
|
1375
|
+
customer: organization.stripeCustomerId,
|
|
1376
|
+
status: "all",
|
|
1377
|
+
limit: 100
|
|
1378
|
+
});
|
|
1379
|
+
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);
|
|
1380
|
+
} catch (error) {
|
|
1381
|
+
if (error instanceof APIError) throw error;
|
|
1382
|
+
ctx.logger.error(`Failed to check organization subscriptions: ${error.message}`);
|
|
1383
|
+
throw error;
|
|
1384
|
+
}
|
|
1385
|
+
};
|
|
1386
|
+
orgPlugin.options.organizationHooks = {
|
|
1387
|
+
...existingHooks,
|
|
1388
|
+
afterUpdateOrganization: existingHooks.afterUpdateOrganization ? async (data) => {
|
|
1389
|
+
await existingHooks.afterUpdateOrganization(data);
|
|
1390
|
+
await afterUpdateStripeOrg(data);
|
|
1391
|
+
} : afterUpdateStripeOrg,
|
|
1392
|
+
beforeDeleteOrganization: existingHooks.beforeDeleteOrganization ? async (data) => {
|
|
1393
|
+
await existingHooks.beforeDeleteOrganization(data);
|
|
1394
|
+
await beforeDeleteStripeOrg(data);
|
|
1395
|
+
} : beforeDeleteStripeOrg
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
972
1398
|
return { options: { databaseHooks: { user: {
|
|
973
|
-
create: { async after(user
|
|
974
|
-
if (!ctx
|
|
1399
|
+
create: { async after(user, ctx) {
|
|
1400
|
+
if (!ctx || !options.createCustomerOnSignUp || user.stripeCustomerId) return;
|
|
975
1401
|
try {
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
email: user$1.email,
|
|
1402
|
+
let stripeCustomer = (await client.customers.search({
|
|
1403
|
+
query: `email:"${escapeStripeSearchValue(user.email)}" AND -metadata["${customerMetadata.keys.customerType}"]:"organization"`,
|
|
979
1404
|
limit: 1
|
|
980
1405
|
})).data[0];
|
|
981
1406
|
if (stripeCustomer) {
|
|
982
|
-
await ctx
|
|
1407
|
+
await ctx.context.internalAdapter.updateUser(user.id, { stripeCustomerId: stripeCustomer.id });
|
|
983
1408
|
await options.onCustomerCreate?.({
|
|
984
1409
|
stripeCustomer,
|
|
985
1410
|
user: {
|
|
986
|
-
...user
|
|
1411
|
+
...user,
|
|
987
1412
|
stripeCustomerId: stripeCustomer.id
|
|
988
1413
|
}
|
|
989
|
-
}, ctx
|
|
990
|
-
ctx
|
|
1414
|
+
}, ctx);
|
|
1415
|
+
ctx.context.logger.info(`Linked existing Stripe customer ${stripeCustomer.id} to user ${user.id}`);
|
|
991
1416
|
return;
|
|
992
1417
|
}
|
|
993
1418
|
let extraCreateParams = {};
|
|
994
|
-
if (options.getCustomerCreateParams) extraCreateParams = await options.getCustomerCreateParams(user
|
|
1419
|
+
if (options.getCustomerCreateParams) extraCreateParams = await options.getCustomerCreateParams(user, ctx);
|
|
995
1420
|
const params = defu({
|
|
996
|
-
email: user
|
|
997
|
-
name: user
|
|
998
|
-
metadata: {
|
|
1421
|
+
email: user.email,
|
|
1422
|
+
name: user.name,
|
|
1423
|
+
metadata: customerMetadata.set({
|
|
1424
|
+
userId: user.id,
|
|
1425
|
+
customerType: "user"
|
|
1426
|
+
}, extraCreateParams?.metadata)
|
|
999
1427
|
}, extraCreateParams);
|
|
1000
1428
|
stripeCustomer = await client.customers.create(params);
|
|
1001
|
-
await ctx
|
|
1429
|
+
await ctx.context.internalAdapter.updateUser(user.id, { stripeCustomerId: stripeCustomer.id });
|
|
1002
1430
|
await options.onCustomerCreate?.({
|
|
1003
1431
|
stripeCustomer,
|
|
1004
1432
|
user: {
|
|
1005
|
-
...user
|
|
1433
|
+
...user,
|
|
1006
1434
|
stripeCustomerId: stripeCustomer.id
|
|
1007
1435
|
}
|
|
1008
|
-
}, ctx
|
|
1009
|
-
ctx
|
|
1436
|
+
}, ctx);
|
|
1437
|
+
ctx.context.logger.info(`Created new Stripe customer ${stripeCustomer.id} for user ${user.id}`);
|
|
1010
1438
|
} catch (e) {
|
|
1011
|
-
ctx
|
|
1439
|
+
ctx.context.logger.error(`Failed to create or link Stripe customer: ${e.message}`, e);
|
|
1012
1440
|
}
|
|
1013
1441
|
} },
|
|
1014
|
-
update: { async after(user
|
|
1015
|
-
if (!ctx
|
|
1442
|
+
update: { async after(user, ctx) {
|
|
1443
|
+
if (!ctx || !user.stripeCustomerId) return;
|
|
1016
1444
|
try {
|
|
1017
|
-
const
|
|
1018
|
-
if (!userWithStripe.stripeCustomerId) return;
|
|
1019
|
-
const stripeCustomer = await client.customers.retrieve(userWithStripe.stripeCustomerId);
|
|
1445
|
+
const stripeCustomer = await client.customers.retrieve(user.stripeCustomerId);
|
|
1020
1446
|
if (stripeCustomer.deleted) {
|
|
1021
|
-
ctx
|
|
1447
|
+
ctx.context.logger.warn(`Stripe customer ${user.stripeCustomerId} was deleted, cannot update email`);
|
|
1022
1448
|
return;
|
|
1023
1449
|
}
|
|
1024
|
-
if (stripeCustomer.email !== user
|
|
1025
|
-
await client.customers.update(
|
|
1026
|
-
ctx
|
|
1450
|
+
if (stripeCustomer.email !== user.email) {
|
|
1451
|
+
await client.customers.update(user.stripeCustomerId, { email: user.email });
|
|
1452
|
+
ctx.context.logger.info(`Updated Stripe customer email from ${stripeCustomer.email} to ${user.email}`);
|
|
1027
1453
|
}
|
|
1028
1454
|
} catch (e) {
|
|
1029
|
-
ctx
|
|
1455
|
+
ctx.context.logger.error(`Failed to sync email to Stripe customer: ${e.message}`, e);
|
|
1030
1456
|
}
|
|
1031
1457
|
} }
|
|
1032
1458
|
} } } };
|
|
@@ -1038,4 +1464,5 @@ const stripe = (options) => {
|
|
|
1038
1464
|
};
|
|
1039
1465
|
|
|
1040
1466
|
//#endregion
|
|
1041
|
-
export { stripe };
|
|
1467
|
+
export { stripe };
|
|
1468
|
+
//# sourceMappingURL=index.mjs.map
|