@better-auth/stripe 1.2.0-beta.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +17 -0
- package/LICENSE.md +17 -0
- package/build.config.ts +12 -0
- package/dist/client.cjs +10 -0
- package/dist/client.d.cts +25 -0
- package/dist/client.d.mts +25 -0
- package/dist/client.d.ts +25 -0
- package/dist/client.mjs +8 -0
- package/dist/index.cjs +779 -0
- package/dist/index.d.cts +884 -0
- package/dist/index.d.mts +884 -0
- package/dist/index.d.ts +884 -0
- package/dist/index.mjs +777 -0
- package/package.json +52 -0
- package/src/client.ts +31 -0
- package/src/hooks.ts +180 -0
- package/src/index.ts +642 -0
- package/src/schema.ts +62 -0
- package/src/stripe.test.ts +456 -0
- package/src/types.ts +323 -0
- package/src/utils.ts +22 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +10 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const plugins = require('better-auth/plugins');
|
|
4
|
+
const zod = require('zod');
|
|
5
|
+
const api = require('better-auth/api');
|
|
6
|
+
const crypto = require('better-auth/crypto');
|
|
7
|
+
|
|
8
|
+
async function getPlans(options) {
|
|
9
|
+
return typeof options?.subscription?.plans === "function" ? await options.subscription?.plans() : options.subscription?.plans;
|
|
10
|
+
}
|
|
11
|
+
async function getPlanByPriceId(options, priceId) {
|
|
12
|
+
return await getPlans(options).then(
|
|
13
|
+
(res) => res?.find((plan) => plan.priceId === priceId)
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
async function getPlanByName(options, name) {
|
|
17
|
+
return await getPlans(options).then(
|
|
18
|
+
(res) => res?.find((plan) => plan.name.toLowerCase() === name.toLowerCase())
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function onCheckoutSessionCompleted(ctx, options, event) {
|
|
23
|
+
const client = options.stripeClient;
|
|
24
|
+
const checkoutSession = event.data.object;
|
|
25
|
+
if (checkoutSession.mode === "setup" || !options.subscription?.enabled) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const subscription = await client.subscriptions.retrieve(
|
|
29
|
+
checkoutSession.subscription
|
|
30
|
+
);
|
|
31
|
+
const priceId = subscription.items.data[0]?.price.id;
|
|
32
|
+
const plan = await getPlanByPriceId(options, priceId);
|
|
33
|
+
if (plan) {
|
|
34
|
+
const referenceId = checkoutSession?.metadata?.referenceId;
|
|
35
|
+
const subscriptionId = checkoutSession?.metadata?.subscriptionId;
|
|
36
|
+
const seats = subscription.items.data[0].quantity;
|
|
37
|
+
if (referenceId && subscriptionId) {
|
|
38
|
+
const trial = subscription.trial_start && subscription.trial_end ? {
|
|
39
|
+
trialStart: new Date(subscription.trial_start * 1e3),
|
|
40
|
+
trialEnd: new Date(subscription.trial_end * 1e3)
|
|
41
|
+
} : {};
|
|
42
|
+
let dbSubscription = await ctx.context.adapter.update({
|
|
43
|
+
model: "subscription",
|
|
44
|
+
update: {
|
|
45
|
+
plan: plan.name.toLowerCase(),
|
|
46
|
+
status: subscription.status,
|
|
47
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
48
|
+
periodStart: new Date(subscription.current_period_start * 1e3),
|
|
49
|
+
periodEnd: new Date(subscription.current_period_end * 1e3),
|
|
50
|
+
seats,
|
|
51
|
+
...trial
|
|
52
|
+
},
|
|
53
|
+
where: [
|
|
54
|
+
{
|
|
55
|
+
field: "id",
|
|
56
|
+
value: subscriptionId
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
});
|
|
60
|
+
if (!dbSubscription) {
|
|
61
|
+
dbSubscription = await ctx.context.adapter.findOne({
|
|
62
|
+
model: "subscription",
|
|
63
|
+
where: [
|
|
64
|
+
{
|
|
65
|
+
field: "id",
|
|
66
|
+
value: subscriptionId
|
|
67
|
+
}
|
|
68
|
+
]
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
await options.subscription?.onSubscriptionComplete?.({
|
|
72
|
+
event,
|
|
73
|
+
subscription: dbSubscription,
|
|
74
|
+
stripeSubscription: subscription,
|
|
75
|
+
plan
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function onSubscriptionUpdated(ctx, options, event) {
|
|
82
|
+
if (!options.subscription?.enabled) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const subscriptionUpdated = event.data.object;
|
|
86
|
+
const priceId = subscriptionUpdated.items.data[0].price.id;
|
|
87
|
+
const plan = await getPlanByPriceId(options, priceId);
|
|
88
|
+
if (plan) {
|
|
89
|
+
const stripeId = subscriptionUpdated.customer.toString();
|
|
90
|
+
const subscription = await ctx.context.adapter.findOne({
|
|
91
|
+
model: "subscription",
|
|
92
|
+
where: [
|
|
93
|
+
{
|
|
94
|
+
field: "stripeSubscriptionId",
|
|
95
|
+
value: stripeId
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
});
|
|
99
|
+
if (!subscription) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const seats = subscriptionUpdated.items.data[0].quantity;
|
|
103
|
+
await ctx.context.adapter.update({
|
|
104
|
+
model: "subscription",
|
|
105
|
+
update: {
|
|
106
|
+
plan: plan.name.toLowerCase(),
|
|
107
|
+
limits: plan.limits,
|
|
108
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
109
|
+
status: subscriptionUpdated.status,
|
|
110
|
+
periodStart: new Date(subscriptionUpdated.current_period_start * 1e3),
|
|
111
|
+
periodEnd: new Date(subscriptionUpdated.current_period_end * 1e3),
|
|
112
|
+
cancelAtPeriodEnd: subscriptionUpdated.cancel_at_period_end,
|
|
113
|
+
seats
|
|
114
|
+
},
|
|
115
|
+
where: [
|
|
116
|
+
{
|
|
117
|
+
field: "stripeSubscriptionId",
|
|
118
|
+
value: subscriptionUpdated.id
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
});
|
|
122
|
+
const subscriptionCanceled = subscriptionUpdated.status === "active" && subscriptionUpdated.cancel_at_period_end;
|
|
123
|
+
if (subscriptionCanceled) {
|
|
124
|
+
await options.subscription.onSubscriptionCancel?.({
|
|
125
|
+
subscription,
|
|
126
|
+
cancellationDetails: subscriptionUpdated.cancellation_details || void 0,
|
|
127
|
+
stripeSubscription: subscriptionUpdated,
|
|
128
|
+
event
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
await options.subscription.onSubscriptionUpdate?.({
|
|
132
|
+
event,
|
|
133
|
+
subscription
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async function onSubscriptionDeleted(ctx, options, event) {
|
|
138
|
+
if (!options.subscription?.enabled) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const subscriptionDeleted = event.data.object;
|
|
142
|
+
const subscriptionId = subscriptionDeleted.metadata?.subscriptionId;
|
|
143
|
+
const stripeSubscription = await options.stripeClient.subscriptions.retrieve(
|
|
144
|
+
subscriptionId
|
|
145
|
+
);
|
|
146
|
+
if (stripeSubscription.status === "canceled") {
|
|
147
|
+
const subscription = await ctx.context.adapter.findOne({
|
|
148
|
+
model: "subscription",
|
|
149
|
+
where: [
|
|
150
|
+
{
|
|
151
|
+
field: "id",
|
|
152
|
+
value: subscriptionId
|
|
153
|
+
}
|
|
154
|
+
]
|
|
155
|
+
});
|
|
156
|
+
if (subscription) {
|
|
157
|
+
await ctx.context.adapter.update({
|
|
158
|
+
model: "subscription",
|
|
159
|
+
where: [
|
|
160
|
+
{
|
|
161
|
+
field: "id",
|
|
162
|
+
value: subscription.id
|
|
163
|
+
}
|
|
164
|
+
],
|
|
165
|
+
update: {
|
|
166
|
+
status: "canceled"
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
await options.subscription.onSubscriptionDeleted?.({
|
|
170
|
+
event,
|
|
171
|
+
stripeSubscription: subscriptionDeleted,
|
|
172
|
+
subscription
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const getSchema = (options) => {
|
|
179
|
+
const subscriptions = {
|
|
180
|
+
subscription: {
|
|
181
|
+
fields: {
|
|
182
|
+
plan: {
|
|
183
|
+
type: "string",
|
|
184
|
+
required: true
|
|
185
|
+
},
|
|
186
|
+
referenceId: {
|
|
187
|
+
type: "string",
|
|
188
|
+
required: true
|
|
189
|
+
},
|
|
190
|
+
stripeCustomerId: {
|
|
191
|
+
type: "string",
|
|
192
|
+
required: false
|
|
193
|
+
},
|
|
194
|
+
stripeSubscriptionId: {
|
|
195
|
+
type: "string",
|
|
196
|
+
required: false
|
|
197
|
+
},
|
|
198
|
+
status: {
|
|
199
|
+
type: "string",
|
|
200
|
+
defaultValue: "incomplete"
|
|
201
|
+
},
|
|
202
|
+
periodStart: {
|
|
203
|
+
type: "date",
|
|
204
|
+
required: false
|
|
205
|
+
},
|
|
206
|
+
periodEnd: {
|
|
207
|
+
type: "date",
|
|
208
|
+
required: false
|
|
209
|
+
},
|
|
210
|
+
cancelAtPeriodEnd: {
|
|
211
|
+
type: "boolean",
|
|
212
|
+
required: false,
|
|
213
|
+
defaultValue: false
|
|
214
|
+
},
|
|
215
|
+
seats: {
|
|
216
|
+
type: "number",
|
|
217
|
+
required: false
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
const user = {
|
|
223
|
+
user: {
|
|
224
|
+
fields: {
|
|
225
|
+
stripeCustomerId: {
|
|
226
|
+
type: "string",
|
|
227
|
+
required: false
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
return {
|
|
233
|
+
...options.subscription?.enabled ? subscriptions : {},
|
|
234
|
+
...user
|
|
235
|
+
};
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const STRIPE_ERROR_CODES = {
|
|
239
|
+
SUBSCRIPTION_NOT_FOUND: "Subscription not found",
|
|
240
|
+
SUBSCRIPTION_PLAN_NOT_FOUND: "Subscription plan not found",
|
|
241
|
+
ALREADY_SUBSCRIBED_PLAN: "You're already subscribed to this plan",
|
|
242
|
+
UNABLE_TO_CREATE_CUSTOMER: "Unable to create customer",
|
|
243
|
+
EMAIL_VERIFICATION_REQUIRED: "Email verification is required before you can subscribe to a plan"
|
|
244
|
+
};
|
|
245
|
+
const getUrl = (ctx, url) => {
|
|
246
|
+
if (url.startsWith("http")) {
|
|
247
|
+
return url;
|
|
248
|
+
}
|
|
249
|
+
return `${ctx.context.options.baseURL}${url.startsWith("/") ? url : `/${url}`}`;
|
|
250
|
+
};
|
|
251
|
+
const stripe = (options) => {
|
|
252
|
+
const client = options.stripeClient;
|
|
253
|
+
const referenceMiddleware = (action) => plugins.createAuthMiddleware(async (ctx) => {
|
|
254
|
+
const session = ctx.context.session;
|
|
255
|
+
if (!session) {
|
|
256
|
+
throw new api.APIError("UNAUTHORIZED");
|
|
257
|
+
}
|
|
258
|
+
const referenceId = ctx.body?.referenceId || ctx.query?.referenceId || session.user.id;
|
|
259
|
+
const isAuthorized = ctx.body?.referenceId ? await options.subscription?.authorizeReference?.({
|
|
260
|
+
user: session.user,
|
|
261
|
+
session: session.session,
|
|
262
|
+
referenceId,
|
|
263
|
+
action
|
|
264
|
+
}) : true;
|
|
265
|
+
if (!isAuthorized) {
|
|
266
|
+
throw new api.APIError("UNAUTHORIZED", {
|
|
267
|
+
message: "Unauthorized"
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
const subscriptionEndpoints = {
|
|
272
|
+
upgradeSubscription: plugins.createAuthEndpoint(
|
|
273
|
+
"/subscription/upgrade",
|
|
274
|
+
{
|
|
275
|
+
method: "POST",
|
|
276
|
+
body: zod.z.object({
|
|
277
|
+
plan: zod.z.string(),
|
|
278
|
+
referenceId: zod.z.string().optional(),
|
|
279
|
+
metadata: zod.z.record(zod.z.string(), zod.z.any()).optional(),
|
|
280
|
+
seats: zod.z.number({
|
|
281
|
+
description: "Number of seats to upgrade to (if applicable)"
|
|
282
|
+
}).optional(),
|
|
283
|
+
uiMode: zod.z.enum(["embedded", "hosted"]).default("hosted"),
|
|
284
|
+
successUrl: zod.z.string({
|
|
285
|
+
description: "callback url to redirect back after successful subscription"
|
|
286
|
+
}).default("/"),
|
|
287
|
+
cancelUrl: zod.z.string({
|
|
288
|
+
description: "callback url to redirect back after successful subscription"
|
|
289
|
+
}).default("/"),
|
|
290
|
+
returnUrl: zod.z.string().optional(),
|
|
291
|
+
withoutTrial: zod.z.boolean().optional(),
|
|
292
|
+
disableRedirect: zod.z.boolean().default(false)
|
|
293
|
+
}),
|
|
294
|
+
use: [
|
|
295
|
+
api.sessionMiddleware,
|
|
296
|
+
api.originCheck((c) => {
|
|
297
|
+
return [c.body.successURL, c.body.cancelURL];
|
|
298
|
+
}),
|
|
299
|
+
referenceMiddleware("upgrade-subscription")
|
|
300
|
+
]
|
|
301
|
+
},
|
|
302
|
+
async (ctx) => {
|
|
303
|
+
const { user, session } = ctx.context.session;
|
|
304
|
+
if (!user.emailVerified && options.subscription?.requireEmailVerification) {
|
|
305
|
+
throw new api.APIError("BAD_REQUEST", {
|
|
306
|
+
message: STRIPE_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
const referenceId = ctx.body.referenceId || user.id;
|
|
310
|
+
const plan = await getPlanByName(options, ctx.body.plan);
|
|
311
|
+
if (!plan) {
|
|
312
|
+
throw new api.APIError("BAD_REQUEST", {
|
|
313
|
+
message: STRIPE_ERROR_CODES.SUBSCRIPTION_PLAN_NOT_FOUND
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
let customerId = user.stripeCustomerId;
|
|
317
|
+
if (!customerId) {
|
|
318
|
+
try {
|
|
319
|
+
const stripeCustomer = await client.customers.create(
|
|
320
|
+
{
|
|
321
|
+
email: user.email,
|
|
322
|
+
name: user.name,
|
|
323
|
+
metadata: {
|
|
324
|
+
...ctx.body.metadata,
|
|
325
|
+
userId: user.id
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
idempotencyKey: crypto.generateRandomString(32, "a-z", "0-9")
|
|
330
|
+
}
|
|
331
|
+
);
|
|
332
|
+
await ctx.context.adapter.update({
|
|
333
|
+
model: "user",
|
|
334
|
+
update: {
|
|
335
|
+
stripeCustomerId: stripeCustomer.id
|
|
336
|
+
},
|
|
337
|
+
where: [
|
|
338
|
+
{
|
|
339
|
+
field: "id",
|
|
340
|
+
value: user.id
|
|
341
|
+
}
|
|
342
|
+
]
|
|
343
|
+
});
|
|
344
|
+
customerId = stripeCustomer.id;
|
|
345
|
+
} catch (e) {
|
|
346
|
+
ctx.context.logger.error(e);
|
|
347
|
+
throw new api.APIError("BAD_REQUEST", {
|
|
348
|
+
message: STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const activeSubscription = customerId ? await client.subscriptions.list({
|
|
353
|
+
customer: customerId,
|
|
354
|
+
status: "active"
|
|
355
|
+
}).then((res) => res.data[0]).catch((e) => null) : null;
|
|
356
|
+
const subscriptions = await ctx.context.adapter.findMany({
|
|
357
|
+
model: "subscription",
|
|
358
|
+
where: [
|
|
359
|
+
{
|
|
360
|
+
field: "referenceId",
|
|
361
|
+
value: ctx.body.referenceId || user.id
|
|
362
|
+
}
|
|
363
|
+
]
|
|
364
|
+
});
|
|
365
|
+
const existingSubscription = subscriptions.find(
|
|
366
|
+
(sub) => sub.status === "active" || sub.status === "trialing"
|
|
367
|
+
);
|
|
368
|
+
if (activeSubscription && customerId) {
|
|
369
|
+
const { url } = await client.billingPortal.sessions.create({
|
|
370
|
+
customer: customerId,
|
|
371
|
+
return_url: getUrl(ctx, ctx.body.returnUrl || "/"),
|
|
372
|
+
flow_data: {
|
|
373
|
+
type: "subscription_update_confirm",
|
|
374
|
+
subscription_update_confirm: {
|
|
375
|
+
subscription: activeSubscription.id,
|
|
376
|
+
items: [
|
|
377
|
+
{
|
|
378
|
+
id: activeSubscription.items.data[0]?.id,
|
|
379
|
+
quantity: 1,
|
|
380
|
+
price: plan.priceId
|
|
381
|
+
}
|
|
382
|
+
]
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}).catch(async (e) => {
|
|
386
|
+
if (e.message.includes("no changes")) {
|
|
387
|
+
const plan2 = await getPlanByPriceId(
|
|
388
|
+
options,
|
|
389
|
+
activeSubscription.items.data[0]?.plan.id
|
|
390
|
+
);
|
|
391
|
+
await ctx.context.adapter.update({
|
|
392
|
+
model: "subscription",
|
|
393
|
+
update: {
|
|
394
|
+
status: activeSubscription.status,
|
|
395
|
+
seats: activeSubscription.items.data[0]?.quantity,
|
|
396
|
+
plan: plan2?.name.toLowerCase()
|
|
397
|
+
},
|
|
398
|
+
where: [
|
|
399
|
+
{
|
|
400
|
+
field: "referenceId",
|
|
401
|
+
value: referenceId
|
|
402
|
+
}
|
|
403
|
+
]
|
|
404
|
+
});
|
|
405
|
+
throw new api.APIError("BAD_REQUEST", {
|
|
406
|
+
message: STRIPE_ERROR_CODES.ALREADY_SUBSCRIBED_PLAN
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
throw ctx.error("BAD_REQUEST", {
|
|
410
|
+
message: e.message,
|
|
411
|
+
code: e.code
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
return ctx.json({
|
|
415
|
+
url,
|
|
416
|
+
redirect: true
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
if (existingSubscription && existingSubscription.status === "active" && existingSubscription.plan === ctx.body.plan) {
|
|
420
|
+
throw new api.APIError("BAD_REQUEST", {
|
|
421
|
+
message: STRIPE_ERROR_CODES.ALREADY_SUBSCRIBED_PLAN
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
let subscription = existingSubscription;
|
|
425
|
+
if (!subscription) {
|
|
426
|
+
const newSubscription = await ctx.context.adapter.create({
|
|
427
|
+
model: "subscription",
|
|
428
|
+
data: {
|
|
429
|
+
plan: plan.name.toLowerCase(),
|
|
430
|
+
stripeCustomerId: customerId,
|
|
431
|
+
status: "incomplete",
|
|
432
|
+
referenceId,
|
|
433
|
+
seats: ctx.body.seats || 1
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
subscription = newSubscription;
|
|
437
|
+
}
|
|
438
|
+
if (!subscription) {
|
|
439
|
+
ctx.context.logger.error("Subscription ID not found");
|
|
440
|
+
throw new api.APIError("INTERNAL_SERVER_ERROR");
|
|
441
|
+
}
|
|
442
|
+
const params = await options.subscription?.getCheckoutSessionParams?.(
|
|
443
|
+
{
|
|
444
|
+
user,
|
|
445
|
+
session,
|
|
446
|
+
plan,
|
|
447
|
+
subscription
|
|
448
|
+
},
|
|
449
|
+
ctx.request
|
|
450
|
+
);
|
|
451
|
+
const checkoutSession = await client.checkout.sessions.create({
|
|
452
|
+
...customerId ? {
|
|
453
|
+
customer: customerId,
|
|
454
|
+
customer_update: {
|
|
455
|
+
name: "auto",
|
|
456
|
+
address: "auto"
|
|
457
|
+
}
|
|
458
|
+
} : {
|
|
459
|
+
customer_email: session.user.email
|
|
460
|
+
},
|
|
461
|
+
success_url: getUrl(
|
|
462
|
+
ctx,
|
|
463
|
+
`${ctx.context.baseURL}/subscription/success?callbackURL=${encodeURIComponent(
|
|
464
|
+
ctx.body.successUrl
|
|
465
|
+
)}&reference=${encodeURIComponent(referenceId)}`
|
|
466
|
+
),
|
|
467
|
+
cancel_url: getUrl(ctx, ctx.body.cancelUrl),
|
|
468
|
+
line_items: [
|
|
469
|
+
{
|
|
470
|
+
price: plan.priceId,
|
|
471
|
+
quantity: ctx.body.seats || 1
|
|
472
|
+
}
|
|
473
|
+
],
|
|
474
|
+
mode: "subscription",
|
|
475
|
+
client_reference_id: referenceId,
|
|
476
|
+
...params,
|
|
477
|
+
metadata: {
|
|
478
|
+
userId: user.id,
|
|
479
|
+
subscriptionId: subscription.id,
|
|
480
|
+
referenceId,
|
|
481
|
+
...params?.params?.metadata
|
|
482
|
+
}
|
|
483
|
+
}).catch(async (e) => {
|
|
484
|
+
throw ctx.error("BAD_REQUEST", {
|
|
485
|
+
message: e.message,
|
|
486
|
+
code: e.code
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
return ctx.json({
|
|
490
|
+
...checkoutSession,
|
|
491
|
+
redirect: !ctx.body.disableRedirect
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
),
|
|
495
|
+
cancelSubscription: plugins.createAuthEndpoint(
|
|
496
|
+
"/subscription/cancel",
|
|
497
|
+
{
|
|
498
|
+
method: "POST",
|
|
499
|
+
body: zod.z.object({
|
|
500
|
+
referenceId: zod.z.string().optional(),
|
|
501
|
+
returnUrl: zod.z.string()
|
|
502
|
+
}),
|
|
503
|
+
use: [
|
|
504
|
+
api.sessionMiddleware,
|
|
505
|
+
api.originCheck((ctx) => ctx.body.returnUrl),
|
|
506
|
+
referenceMiddleware("cancel-subscription")
|
|
507
|
+
]
|
|
508
|
+
},
|
|
509
|
+
async (ctx) => {
|
|
510
|
+
const referenceId = ctx.body?.referenceId || ctx.context.session.user.id;
|
|
511
|
+
const subscription = await ctx.context.adapter.findOne({
|
|
512
|
+
model: "subscription",
|
|
513
|
+
where: [
|
|
514
|
+
{
|
|
515
|
+
field: "referenceId",
|
|
516
|
+
value: referenceId
|
|
517
|
+
}
|
|
518
|
+
]
|
|
519
|
+
});
|
|
520
|
+
if (!subscription || !subscription.stripeCustomerId) {
|
|
521
|
+
throw ctx.error("BAD_REQUEST", {
|
|
522
|
+
message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
const activeSubscription = await client.subscriptions.list({
|
|
526
|
+
customer: subscription.stripeCustomerId,
|
|
527
|
+
status: "active"
|
|
528
|
+
}).then((res) => res.data[0]);
|
|
529
|
+
if (!activeSubscription) {
|
|
530
|
+
throw ctx.error("BAD_REQUEST", {
|
|
531
|
+
message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
const { url } = await client.billingPortal.sessions.create({
|
|
535
|
+
customer: subscription.stripeCustomerId,
|
|
536
|
+
return_url: getUrl(ctx, ctx.body?.returnUrl || "/"),
|
|
537
|
+
flow_data: {
|
|
538
|
+
type: "subscription_cancel",
|
|
539
|
+
subscription_cancel: {
|
|
540
|
+
subscription: activeSubscription.id
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
return {
|
|
545
|
+
url,
|
|
546
|
+
redirect: true
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
),
|
|
550
|
+
listActiveSubscriptions: plugins.createAuthEndpoint(
|
|
551
|
+
"/subscription/list",
|
|
552
|
+
{
|
|
553
|
+
method: "GET",
|
|
554
|
+
query: zod.z.optional(
|
|
555
|
+
zod.z.object({
|
|
556
|
+
referenceId: zod.z.string().optional()
|
|
557
|
+
})
|
|
558
|
+
),
|
|
559
|
+
use: [api.sessionMiddleware, referenceMiddleware("list-subscription")]
|
|
560
|
+
},
|
|
561
|
+
async (ctx) => {
|
|
562
|
+
const subscriptions = await ctx.context.adapter.findMany({
|
|
563
|
+
model: "subscription",
|
|
564
|
+
where: [
|
|
565
|
+
{
|
|
566
|
+
field: "referenceId",
|
|
567
|
+
value: ctx.query?.referenceId || ctx.context.session.user.id
|
|
568
|
+
}
|
|
569
|
+
]
|
|
570
|
+
});
|
|
571
|
+
if (!subscriptions.length) {
|
|
572
|
+
return [];
|
|
573
|
+
}
|
|
574
|
+
const plans = await getPlans(options);
|
|
575
|
+
if (!plans) {
|
|
576
|
+
return [];
|
|
577
|
+
}
|
|
578
|
+
const subs = subscriptions.map((sub) => {
|
|
579
|
+
const plan = plans.find(
|
|
580
|
+
(p) => p.name.toLowerCase() === sub.plan.toLowerCase()
|
|
581
|
+
);
|
|
582
|
+
return {
|
|
583
|
+
...sub,
|
|
584
|
+
limits: plan?.limits
|
|
585
|
+
};
|
|
586
|
+
}).filter((sub) => {
|
|
587
|
+
return sub.status === "active" || sub.status === "trialing";
|
|
588
|
+
});
|
|
589
|
+
return ctx.json(subs);
|
|
590
|
+
}
|
|
591
|
+
),
|
|
592
|
+
subscriptionSuccess: plugins.createAuthEndpoint(
|
|
593
|
+
"/subscription/success",
|
|
594
|
+
{
|
|
595
|
+
method: "GET",
|
|
596
|
+
query: zod.z.record(zod.z.string(), zod.z.any()).optional()
|
|
597
|
+
},
|
|
598
|
+
async (ctx) => {
|
|
599
|
+
if (!ctx.query || !ctx.query.callbackURL || !ctx.query.reference) {
|
|
600
|
+
throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
|
|
601
|
+
}
|
|
602
|
+
const session = await api.getSessionFromCtx(
|
|
603
|
+
ctx
|
|
604
|
+
);
|
|
605
|
+
if (!session) {
|
|
606
|
+
throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
|
|
607
|
+
}
|
|
608
|
+
const { user } = session;
|
|
609
|
+
const { callbackURL, reference } = ctx.query;
|
|
610
|
+
const subscriptions = await ctx.context.adapter.findMany({
|
|
611
|
+
model: "subscription",
|
|
612
|
+
where: [
|
|
613
|
+
{
|
|
614
|
+
field: "referenceId",
|
|
615
|
+
value: reference
|
|
616
|
+
}
|
|
617
|
+
]
|
|
618
|
+
});
|
|
619
|
+
const activeSubscription = subscriptions.find(
|
|
620
|
+
(sub) => sub.status === "active" || sub.status === "trialing"
|
|
621
|
+
);
|
|
622
|
+
if (activeSubscription) {
|
|
623
|
+
return ctx.redirect(getUrl(ctx, callbackURL));
|
|
624
|
+
}
|
|
625
|
+
if (user?.stripeCustomerId) {
|
|
626
|
+
try {
|
|
627
|
+
const subscription = await ctx.context.adapter.findOne({
|
|
628
|
+
model: "subscription",
|
|
629
|
+
where: [
|
|
630
|
+
{
|
|
631
|
+
field: "referenceId",
|
|
632
|
+
value: reference
|
|
633
|
+
}
|
|
634
|
+
]
|
|
635
|
+
});
|
|
636
|
+
if (!subscription || subscription.status === "active") {
|
|
637
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
638
|
+
}
|
|
639
|
+
const stripeSubscription = await client.subscriptions.list({
|
|
640
|
+
customer: user.stripeCustomerId,
|
|
641
|
+
status: "active"
|
|
642
|
+
}).then((res) => res.data[0]);
|
|
643
|
+
if (stripeSubscription) {
|
|
644
|
+
const plan = await getPlanByPriceId(
|
|
645
|
+
options,
|
|
646
|
+
stripeSubscription.items.data[0]?.plan.id
|
|
647
|
+
);
|
|
648
|
+
if (plan && subscriptions.length > 0) {
|
|
649
|
+
await ctx.context.adapter.update({
|
|
650
|
+
model: "subscription",
|
|
651
|
+
update: {
|
|
652
|
+
status: stripeSubscription.status,
|
|
653
|
+
seats: stripeSubscription.items.data[0]?.quantity || 1,
|
|
654
|
+
plan: plan.name.toLowerCase()
|
|
655
|
+
},
|
|
656
|
+
where: [
|
|
657
|
+
{
|
|
658
|
+
field: "referenceId",
|
|
659
|
+
value: reference
|
|
660
|
+
}
|
|
661
|
+
]
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
} catch (error) {
|
|
666
|
+
ctx.context.logger.error(
|
|
667
|
+
"Error fetching subscription from Stripe",
|
|
668
|
+
error
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
673
|
+
}
|
|
674
|
+
)
|
|
675
|
+
};
|
|
676
|
+
return {
|
|
677
|
+
id: "stripe",
|
|
678
|
+
endpoints: {
|
|
679
|
+
stripeWebhook: plugins.createAuthEndpoint(
|
|
680
|
+
"/stripe/webhook",
|
|
681
|
+
{
|
|
682
|
+
method: "POST",
|
|
683
|
+
metadata: {
|
|
684
|
+
isAction: false
|
|
685
|
+
},
|
|
686
|
+
cloneRequest: true
|
|
687
|
+
},
|
|
688
|
+
async (ctx) => {
|
|
689
|
+
if (!ctx.request?.body) {
|
|
690
|
+
throw new api.APIError("INTERNAL_SERVER_ERROR");
|
|
691
|
+
}
|
|
692
|
+
const buf = await ctx.request.text();
|
|
693
|
+
const sig = ctx.request.headers.get("stripe-signature");
|
|
694
|
+
const webhookSecret = options.stripeWebhookSecret;
|
|
695
|
+
let event;
|
|
696
|
+
try {
|
|
697
|
+
if (!sig || !webhookSecret) {
|
|
698
|
+
throw new api.APIError("BAD_REQUEST", {
|
|
699
|
+
message: "Stripe webhook secret not found"
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
event = client.webhooks.constructEvent(buf, sig, webhookSecret);
|
|
703
|
+
} catch (err) {
|
|
704
|
+
ctx.context.logger.error(`${err.message}`);
|
|
705
|
+
throw new api.APIError("BAD_REQUEST", {
|
|
706
|
+
message: `Webhook Error: ${err.message}`
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
try {
|
|
710
|
+
switch (event.type) {
|
|
711
|
+
case "checkout.session.completed":
|
|
712
|
+
await onCheckoutSessionCompleted(ctx, options, event);
|
|
713
|
+
await options.onEvent?.(event);
|
|
714
|
+
break;
|
|
715
|
+
case "customer.subscription.updated":
|
|
716
|
+
await onSubscriptionUpdated(ctx, options, event);
|
|
717
|
+
await options.onEvent?.(event);
|
|
718
|
+
break;
|
|
719
|
+
case "customer.subscription.deleted":
|
|
720
|
+
await onSubscriptionDeleted(ctx, options, event);
|
|
721
|
+
await options.onEvent?.(event);
|
|
722
|
+
break;
|
|
723
|
+
default:
|
|
724
|
+
await options.onEvent?.(event);
|
|
725
|
+
break;
|
|
726
|
+
}
|
|
727
|
+
} catch (e) {
|
|
728
|
+
ctx.context.logger.error(
|
|
729
|
+
`Stripe webhook failed. Error: ${e.message}`
|
|
730
|
+
);
|
|
731
|
+
throw new api.APIError("BAD_REQUEST", {
|
|
732
|
+
message: "Webhook error: See server logs for more information."
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
return ctx.json({ success: true });
|
|
736
|
+
}
|
|
737
|
+
),
|
|
738
|
+
...options.subscription?.enabled ? subscriptionEndpoints : {}
|
|
739
|
+
},
|
|
740
|
+
init(ctx) {
|
|
741
|
+
return {
|
|
742
|
+
options: {
|
|
743
|
+
databaseHooks: {
|
|
744
|
+
user: {
|
|
745
|
+
create: {
|
|
746
|
+
async after(user, ctx2) {
|
|
747
|
+
if (ctx2 && options.createCustomerOnSignUp) {
|
|
748
|
+
const stripeCustomer = await client.customers.create({
|
|
749
|
+
email: user.email,
|
|
750
|
+
name: user.name,
|
|
751
|
+
metadata: {
|
|
752
|
+
userId: user.id
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
await ctx2.context.adapter.update({
|
|
756
|
+
model: "user",
|
|
757
|
+
update: {
|
|
758
|
+
stripeCustomerId: stripeCustomer.id
|
|
759
|
+
},
|
|
760
|
+
where: [
|
|
761
|
+
{
|
|
762
|
+
field: "id",
|
|
763
|
+
value: user.id
|
|
764
|
+
}
|
|
765
|
+
]
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
},
|
|
775
|
+
schema: getSchema(options)
|
|
776
|
+
};
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
exports.stripe = stripe;
|