@faire-auth/stripe 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +24 -0
- package/.turbo/turbo-test.log +1578 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +22 -0
- package/LICENSE +661 -0
- package/dist/client.cjs +15 -0
- package/dist/client.d.cts +25 -0
- package/dist/client.d.mts +25 -0
- package/dist/client.mjs +14 -0
- package/dist/index-6UTHxaqg.d.cts +1110 -0
- package/dist/index-CYV4awaB.d.mts +1110 -0
- package/dist/index.cjs +992 -0
- package/dist/index.d.cts +2 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +968 -0
- package/overrides.d.ts +32 -0
- package/package.json +69 -0
- package/src/client.ts +35 -0
- package/src/hooks.ts +265 -0
- package/src/index.ts +1533 -0
- package/src/schema.ts +92 -0
- package/src/stripe.test.ts +1425 -0
- package/src/types.ts +355 -0
- package/src/utils.ts +70 -0
- package/tsconfig.json +17 -0
- package/tsdown.config.ts +16 -0
- package/vitest.config.ts +9 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1533 @@
|
|
|
1
|
+
import {
|
|
2
|
+
callbackURLSchema,
|
|
3
|
+
createRoute,
|
|
4
|
+
redirectUrlSchema,
|
|
5
|
+
req,
|
|
6
|
+
res,
|
|
7
|
+
} from "@faire-auth/core/factory";
|
|
8
|
+
import { False, True } from "@faire-auth/core/static";
|
|
9
|
+
import type { FaireAuthOptions, FaireAuthPlugin } from "faire-auth";
|
|
10
|
+
import { logger } from "faire-auth";
|
|
11
|
+
import type { Session, User } from "faire-auth/db";
|
|
12
|
+
import {
|
|
13
|
+
getSessionFromCtx,
|
|
14
|
+
originCheck,
|
|
15
|
+
sessionMiddleware,
|
|
16
|
+
} from "faire-auth/middleware";
|
|
17
|
+
import { createEndpoint, createMiddleware } from "faire-auth/plugins";
|
|
18
|
+
import Stripe, { type Stripe as StripeType } from "stripe";
|
|
19
|
+
import * as z from "zod";
|
|
20
|
+
import {
|
|
21
|
+
onCheckoutSessionCompleted,
|
|
22
|
+
onSubscriptionDeleted,
|
|
23
|
+
onSubscriptionUpdated,
|
|
24
|
+
} from "./hooks";
|
|
25
|
+
import { getSchema } from "./schema";
|
|
26
|
+
import type {
|
|
27
|
+
InputSubscription,
|
|
28
|
+
StripeOptions,
|
|
29
|
+
StripePlan,
|
|
30
|
+
Subscription,
|
|
31
|
+
SubscriptionOptions,
|
|
32
|
+
} from "./types";
|
|
33
|
+
import { getPlanByName, getPlanByPriceInfo, getPlans } from "./utils";
|
|
34
|
+
|
|
35
|
+
const STRIPE_ERROR_CODES = {
|
|
36
|
+
SUBSCRIPTION_NOT_FOUND: "Subscription not found",
|
|
37
|
+
SUBSCRIPTION_PLAN_NOT_FOUND: "Subscription plan not found",
|
|
38
|
+
ALREADY_SUBSCRIBED_PLAN: "You're already subscribed to this plan",
|
|
39
|
+
UNABLE_TO_CREATE_CUSTOMER: "Unable to create customer",
|
|
40
|
+
FAILED_TO_FETCH_PLANS: "Failed to fetch plans",
|
|
41
|
+
EMAIL_VERIFICATION_REQUIRED:
|
|
42
|
+
"Email verification is required before you can subscribe to a plan",
|
|
43
|
+
SUBSCRIPTION_NOT_ACTIVE: "Subscription is not active",
|
|
44
|
+
SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION:
|
|
45
|
+
"Subscription is not scheduled for cancellation",
|
|
46
|
+
} as const;
|
|
47
|
+
|
|
48
|
+
const getUrl = (options: FaireAuthOptions, url: string) => {
|
|
49
|
+
if (url.startsWith("http")) {
|
|
50
|
+
return url;
|
|
51
|
+
}
|
|
52
|
+
return `${options.baseURL}${url.startsWith("/") ? url : `/${url}`}`;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
async function resolvePriceIdFromLookupKey(
|
|
56
|
+
stripeClient: Stripe,
|
|
57
|
+
lookupKey: string,
|
|
58
|
+
): Promise<string | undefined> {
|
|
59
|
+
if (!lookupKey) return undefined;
|
|
60
|
+
const prices = await stripeClient.prices.list({
|
|
61
|
+
lookup_keys: [lookupKey],
|
|
62
|
+
active: true,
|
|
63
|
+
limit: 1,
|
|
64
|
+
});
|
|
65
|
+
return prices.data[0]?.id;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const stripe = <O extends StripeOptions>(options: O) => {
|
|
69
|
+
const client = options.stripeClient;
|
|
70
|
+
|
|
71
|
+
const referenceMiddleware = (
|
|
72
|
+
action:
|
|
73
|
+
| "upgrade-subscription"
|
|
74
|
+
| "list-subscription"
|
|
75
|
+
| "cancel-subscription"
|
|
76
|
+
| "restore-subscription"
|
|
77
|
+
| "billing-portal",
|
|
78
|
+
) =>
|
|
79
|
+
createMiddleware<
|
|
80
|
+
{ session?: { session: Session; user: User } },
|
|
81
|
+
| "/subscription/upgrade"
|
|
82
|
+
| "/subscription/cancel"
|
|
83
|
+
| "/subscription/list"
|
|
84
|
+
| "/subscription/restore"
|
|
85
|
+
| "/subscription/billing-portal",
|
|
86
|
+
{
|
|
87
|
+
out: {
|
|
88
|
+
json: { referenceId?: string };
|
|
89
|
+
query: { referenceId?: string };
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
>()(async (ctx, next) => {
|
|
93
|
+
const session = ctx.get("session");
|
|
94
|
+
if (!session) return ctx.json({ success: False }, 401);
|
|
95
|
+
|
|
96
|
+
if (!options.subscription?.enabled) {
|
|
97
|
+
logger.error(
|
|
98
|
+
`Subscription actions are not enabled in your stripe plugin config.`,
|
|
99
|
+
);
|
|
100
|
+
return ctx.json({ success: False }, 400);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let body: { referenceId?: string } = {};
|
|
104
|
+
let bodyHasId = false;
|
|
105
|
+
try {
|
|
106
|
+
body = await ctx.req.raw.clone().json();
|
|
107
|
+
} catch {}
|
|
108
|
+
if (body.referenceId) bodyHasId = true;
|
|
109
|
+
else body.referenceId = ctx.req.query("referenceId") ?? session.user.id;
|
|
110
|
+
|
|
111
|
+
if (bodyHasId && !options.subscription?.authorizeReference) {
|
|
112
|
+
logger.error(
|
|
113
|
+
`Passing referenceId into a subscription action isn't allowed if subscription.authorizeReference isn't defined in your stripe plugin config.`,
|
|
114
|
+
);
|
|
115
|
+
return ctx.json(
|
|
116
|
+
{
|
|
117
|
+
success: False,
|
|
118
|
+
message:
|
|
119
|
+
"Reference id is not allowed. Read server logs for more details.",
|
|
120
|
+
},
|
|
121
|
+
400,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const isAuthorized = bodyHasId
|
|
125
|
+
? await options.subscription?.authorizeReference?.(
|
|
126
|
+
{
|
|
127
|
+
user: session.user,
|
|
128
|
+
session: session.session,
|
|
129
|
+
referenceId: body.referenceId,
|
|
130
|
+
action,
|
|
131
|
+
},
|
|
132
|
+
ctx,
|
|
133
|
+
)
|
|
134
|
+
: true;
|
|
135
|
+
if (!isAuthorized) return ctx.json({ success: False }, 401);
|
|
136
|
+
|
|
137
|
+
return await next();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const subscriptionEndpoints = {
|
|
141
|
+
/**
|
|
142
|
+
* ### Endpoint
|
|
143
|
+
*
|
|
144
|
+
* POST `/subscription/upgrade`
|
|
145
|
+
*
|
|
146
|
+
* ### API Methods
|
|
147
|
+
*
|
|
148
|
+
* **server:**
|
|
149
|
+
* `auth.api.upgradeSubscription`
|
|
150
|
+
*
|
|
151
|
+
* **client:**
|
|
152
|
+
* `authClient.subscription.upgrade`
|
|
153
|
+
*
|
|
154
|
+
* @see [Read our docs to learn more.](https://faire-auth.com/docs/plugins/stripe#api-method-subscription-upgrade)
|
|
155
|
+
*/
|
|
156
|
+
upgradeSubscription: createEndpoint(
|
|
157
|
+
createRoute({
|
|
158
|
+
operationId: "upgradeSubscription",
|
|
159
|
+
method: "post",
|
|
160
|
+
path: "/subscription/upgrade",
|
|
161
|
+
middleware: [
|
|
162
|
+
sessionMiddleware<{
|
|
163
|
+
session: { user: { stripeCustomerId?: string } };
|
|
164
|
+
}>(),
|
|
165
|
+
originCheck(async (ctx) => {
|
|
166
|
+
const { successURL, cancelURL } = await ctx.req.raw.clone().json();
|
|
167
|
+
return [successURL as string, cancelURL as string];
|
|
168
|
+
}),
|
|
169
|
+
referenceMiddleware("upgrade-subscription"),
|
|
170
|
+
],
|
|
171
|
+
request: req()
|
|
172
|
+
.bdy(
|
|
173
|
+
z.object({
|
|
174
|
+
/**
|
|
175
|
+
* The name of the plan to subscribe
|
|
176
|
+
*/
|
|
177
|
+
plan: z.string().meta({
|
|
178
|
+
description: 'The name of the plan to upgrade to. Eg: "pro"',
|
|
179
|
+
}),
|
|
180
|
+
/**
|
|
181
|
+
* If annual plan should be applied.
|
|
182
|
+
*/
|
|
183
|
+
annual: z
|
|
184
|
+
.boolean()
|
|
185
|
+
.meta({
|
|
186
|
+
description: "Whether to upgrade to an annual plan. Eg: true",
|
|
187
|
+
})
|
|
188
|
+
.optional(),
|
|
189
|
+
/**
|
|
190
|
+
* Reference id of the subscription to upgrade
|
|
191
|
+
* This is used to identify the subscription to upgrade
|
|
192
|
+
* If not provided, the user's id will be used
|
|
193
|
+
*/
|
|
194
|
+
referenceId: z
|
|
195
|
+
.string()
|
|
196
|
+
.meta({
|
|
197
|
+
description:
|
|
198
|
+
'Reference id of the subscription to upgrade. Eg: "123"',
|
|
199
|
+
})
|
|
200
|
+
.optional(),
|
|
201
|
+
/**
|
|
202
|
+
* This is to allow a specific subscription to be upgrade.
|
|
203
|
+
* If subscription id is provided, and subscription isn't found,
|
|
204
|
+
* it'll throw an error.
|
|
205
|
+
*/
|
|
206
|
+
subscriptionId: z
|
|
207
|
+
.string()
|
|
208
|
+
.meta({
|
|
209
|
+
description:
|
|
210
|
+
'The id of the subscription to upgrade. Eg: "sub_123"',
|
|
211
|
+
})
|
|
212
|
+
.optional(),
|
|
213
|
+
/**
|
|
214
|
+
* Any additional data you want to store in your database
|
|
215
|
+
* subscriptions
|
|
216
|
+
*/
|
|
217
|
+
metadata: z.record(z.string(), z.any()).optional(),
|
|
218
|
+
/**
|
|
219
|
+
* If a subscription
|
|
220
|
+
*/
|
|
221
|
+
seats: z
|
|
222
|
+
.number()
|
|
223
|
+
.meta({
|
|
224
|
+
description:
|
|
225
|
+
"Number of seats to upgrade to (if applicable). Eg: 1",
|
|
226
|
+
})
|
|
227
|
+
.optional(),
|
|
228
|
+
/**
|
|
229
|
+
* Success URL to redirect back after successful subscription
|
|
230
|
+
*/
|
|
231
|
+
successUrl: z
|
|
232
|
+
.string()
|
|
233
|
+
.meta({
|
|
234
|
+
description:
|
|
235
|
+
'Callback URL to redirect back after successful subscription. Eg: "https://example.com/success"',
|
|
236
|
+
})
|
|
237
|
+
.default("/"),
|
|
238
|
+
/**
|
|
239
|
+
* Cancel URL
|
|
240
|
+
*/
|
|
241
|
+
cancelUrl: z
|
|
242
|
+
.string()
|
|
243
|
+
.meta({
|
|
244
|
+
description:
|
|
245
|
+
'If set, checkout shows a back button and customers will be directed here if they cancel payment. Eg: "https://example.com/pricing"',
|
|
246
|
+
})
|
|
247
|
+
.default("/"),
|
|
248
|
+
/**
|
|
249
|
+
* Return URL
|
|
250
|
+
*/
|
|
251
|
+
returnUrl: z
|
|
252
|
+
.string()
|
|
253
|
+
.meta({
|
|
254
|
+
description:
|
|
255
|
+
'URL to take customers to when they click on the billing portal’s link to return to your website. Eg: "https://example.com/dashboard"',
|
|
256
|
+
})
|
|
257
|
+
.optional(),
|
|
258
|
+
/**
|
|
259
|
+
* Disable Redirect
|
|
260
|
+
*/
|
|
261
|
+
disableRedirect: z
|
|
262
|
+
.boolean()
|
|
263
|
+
.meta({
|
|
264
|
+
description:
|
|
265
|
+
"Disable redirect after successful subscription. Eg: true",
|
|
266
|
+
})
|
|
267
|
+
.default(false),
|
|
268
|
+
}),
|
|
269
|
+
)
|
|
270
|
+
.bld(),
|
|
271
|
+
responses: res(
|
|
272
|
+
z.intersection(
|
|
273
|
+
redirectUrlSchema,
|
|
274
|
+
z.record(z.string(), z.any()) as unknown as z.ZodType<
|
|
275
|
+
Partial<StripeType.Checkout.Session>,
|
|
276
|
+
Partial<StripeType.Checkout.Session>
|
|
277
|
+
>,
|
|
278
|
+
),
|
|
279
|
+
)
|
|
280
|
+
.err(400)
|
|
281
|
+
.err(401)
|
|
282
|
+
.err(500)
|
|
283
|
+
.bld(),
|
|
284
|
+
}),
|
|
285
|
+
(authOptions) => async (ctx) => {
|
|
286
|
+
if (!options.subscription?.enabled) {
|
|
287
|
+
logger.error(
|
|
288
|
+
`Subscription actions are not enabled in your stripe plugin config.`,
|
|
289
|
+
);
|
|
290
|
+
return ctx.json({ success: False }, 400);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const { user, session } = ctx.get("session");
|
|
294
|
+
if (
|
|
295
|
+
!user.emailVerified &&
|
|
296
|
+
options.subscription?.enabled &&
|
|
297
|
+
options.subscription.requireEmailVerification
|
|
298
|
+
)
|
|
299
|
+
return ctx.json(
|
|
300
|
+
{
|
|
301
|
+
success: False,
|
|
302
|
+
message: STRIPE_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED,
|
|
303
|
+
},
|
|
304
|
+
400,
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const {
|
|
308
|
+
referenceId = user.id,
|
|
309
|
+
plan: requestPlan,
|
|
310
|
+
subscriptionId,
|
|
311
|
+
metadata,
|
|
312
|
+
seats,
|
|
313
|
+
returnUrl,
|
|
314
|
+
cancelUrl,
|
|
315
|
+
disableRedirect,
|
|
316
|
+
successUrl,
|
|
317
|
+
annual,
|
|
318
|
+
} = ctx.req.valid("json");
|
|
319
|
+
|
|
320
|
+
const plan = await getPlanByName(options, requestPlan);
|
|
321
|
+
if (!plan)
|
|
322
|
+
return ctx.json(
|
|
323
|
+
{
|
|
324
|
+
success: False,
|
|
325
|
+
message: STRIPE_ERROR_CODES.SUBSCRIPTION_PLAN_NOT_FOUND,
|
|
326
|
+
},
|
|
327
|
+
400,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const subscriptionToUpdate = subscriptionId
|
|
331
|
+
? await ctx.get("context").adapter.findOne<Subscription>({
|
|
332
|
+
model: "subscription",
|
|
333
|
+
where: [
|
|
334
|
+
{
|
|
335
|
+
field: "id",
|
|
336
|
+
value: subscriptionId,
|
|
337
|
+
connector: "OR",
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
field: "stripeSubscriptionId",
|
|
341
|
+
value: subscriptionId,
|
|
342
|
+
connector: "OR",
|
|
343
|
+
},
|
|
344
|
+
],
|
|
345
|
+
})
|
|
346
|
+
: referenceId
|
|
347
|
+
? await ctx.get("context").adapter.findOne<Subscription>({
|
|
348
|
+
model: "subscription",
|
|
349
|
+
where: [{ field: "referenceId", value: referenceId }],
|
|
350
|
+
})
|
|
351
|
+
: null;
|
|
352
|
+
|
|
353
|
+
if (subscriptionId && !subscriptionToUpdate)
|
|
354
|
+
return ctx.json(
|
|
355
|
+
{
|
|
356
|
+
success: False,
|
|
357
|
+
message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
358
|
+
},
|
|
359
|
+
400,
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
let customerId =
|
|
363
|
+
subscriptionToUpdate?.stripeCustomerId || user.stripeCustomerId;
|
|
364
|
+
|
|
365
|
+
if (!customerId) {
|
|
366
|
+
try {
|
|
367
|
+
// Try to find existing Stripe customer by email
|
|
368
|
+
const existingCustomers = await client.customers.list({
|
|
369
|
+
email: user.email,
|
|
370
|
+
limit: 1,
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
let stripeCustomer = existingCustomers.data[0];
|
|
374
|
+
|
|
375
|
+
if (!stripeCustomer) {
|
|
376
|
+
stripeCustomer = await client.customers.create({
|
|
377
|
+
email: user.email,
|
|
378
|
+
...(user.name && { name: user.name }),
|
|
379
|
+
metadata: {
|
|
380
|
+
...metadata,
|
|
381
|
+
userId: user.id,
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Update local DB with Stripe customer ID
|
|
387
|
+
await ctx.get("context").adapter.update({
|
|
388
|
+
model: "user",
|
|
389
|
+
update: {
|
|
390
|
+
stripeCustomerId: stripeCustomer.id,
|
|
391
|
+
},
|
|
392
|
+
where: [
|
|
393
|
+
{
|
|
394
|
+
field: "id",
|
|
395
|
+
value: user.id,
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
customerId = stripeCustomer.id;
|
|
401
|
+
} catch (e: any) {
|
|
402
|
+
ctx.get("context").logger.error(e);
|
|
403
|
+
return ctx.json(
|
|
404
|
+
{
|
|
405
|
+
success: False,
|
|
406
|
+
message: STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER,
|
|
407
|
+
},
|
|
408
|
+
400,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const activeSubscriptions = await client.subscriptions
|
|
414
|
+
.list({
|
|
415
|
+
customer: customerId,
|
|
416
|
+
})
|
|
417
|
+
.then((res) =>
|
|
418
|
+
res.data.filter(
|
|
419
|
+
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
420
|
+
),
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const activeSubscription = activeSubscriptions.find((sub) =>
|
|
424
|
+
subscriptionToUpdate?.stripeSubscriptionId || subscriptionId
|
|
425
|
+
? sub.id === subscriptionToUpdate?.stripeSubscriptionId ||
|
|
426
|
+
sub.id === subscriptionId
|
|
427
|
+
: false,
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
const subscriptions = subscriptionToUpdate
|
|
431
|
+
? [subscriptionToUpdate]
|
|
432
|
+
: await ctx.get("context").adapter.findMany<Subscription>({
|
|
433
|
+
model: "subscription",
|
|
434
|
+
where: [
|
|
435
|
+
{
|
|
436
|
+
field: "referenceId",
|
|
437
|
+
value: referenceId || user.id,
|
|
438
|
+
},
|
|
439
|
+
],
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const activeOrTrialingSubscription = subscriptions.find(
|
|
443
|
+
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
// Also find any incomplete subscription that we can reuse
|
|
447
|
+
const incompleteSubscription = subscriptions.find(
|
|
448
|
+
(sub) => sub.status === "incomplete",
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
if (
|
|
452
|
+
activeOrTrialingSubscription &&
|
|
453
|
+
activeOrTrialingSubscription.status === "active" &&
|
|
454
|
+
activeOrTrialingSubscription.plan === requestPlan &&
|
|
455
|
+
activeOrTrialingSubscription.seats === (seats || 1)
|
|
456
|
+
)
|
|
457
|
+
return ctx.json(
|
|
458
|
+
{
|
|
459
|
+
success: False,
|
|
460
|
+
message: STRIPE_ERROR_CODES.ALREADY_SUBSCRIBED_PLAN,
|
|
461
|
+
},
|
|
462
|
+
400,
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
if (activeSubscription && customerId) {
|
|
466
|
+
const res = await client.billingPortal.sessions
|
|
467
|
+
.create({
|
|
468
|
+
customer: customerId,
|
|
469
|
+
return_url: getUrl(authOptions, returnUrl || "/"),
|
|
470
|
+
flow_data: {
|
|
471
|
+
type: "subscription_update_confirm",
|
|
472
|
+
after_completion: {
|
|
473
|
+
type: "redirect",
|
|
474
|
+
redirect: {
|
|
475
|
+
return_url: getUrl(authOptions, returnUrl || "/"),
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
subscription_update_confirm: {
|
|
479
|
+
subscription: activeSubscription.id,
|
|
480
|
+
items: [
|
|
481
|
+
{
|
|
482
|
+
id: activeSubscription.items.data[0]?.id as string,
|
|
483
|
+
quantity: seats || 1,
|
|
484
|
+
price: annual
|
|
485
|
+
? plan.annualDiscountPriceId!
|
|
486
|
+
: plan.priceId!,
|
|
487
|
+
},
|
|
488
|
+
],
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
})
|
|
492
|
+
.catch(async (e: Stripe.errors.StripeAPIError) =>
|
|
493
|
+
ctx.json(
|
|
494
|
+
{ success: False, message: e.message, code: e.code },
|
|
495
|
+
400,
|
|
496
|
+
),
|
|
497
|
+
);
|
|
498
|
+
if (res instanceof Response) return res;
|
|
499
|
+
return ctx.json(
|
|
500
|
+
{
|
|
501
|
+
url: res.url,
|
|
502
|
+
redirect: True,
|
|
503
|
+
},
|
|
504
|
+
200,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
let subscription: Subscription | undefined =
|
|
509
|
+
activeOrTrialingSubscription || incompleteSubscription;
|
|
510
|
+
|
|
511
|
+
if (incompleteSubscription && !activeOrTrialingSubscription) {
|
|
512
|
+
const updated = await ctx
|
|
513
|
+
.get("context")
|
|
514
|
+
.adapter.update<InputSubscription>({
|
|
515
|
+
model: "subscription",
|
|
516
|
+
update: {
|
|
517
|
+
plan: plan.name.toLowerCase(),
|
|
518
|
+
seats: seats || 1,
|
|
519
|
+
updatedAt: new Date(),
|
|
520
|
+
},
|
|
521
|
+
where: [
|
|
522
|
+
{
|
|
523
|
+
field: "id",
|
|
524
|
+
value: incompleteSubscription.id,
|
|
525
|
+
},
|
|
526
|
+
],
|
|
527
|
+
});
|
|
528
|
+
subscription = (updated as Subscription) || incompleteSubscription;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (!subscription) {
|
|
532
|
+
subscription = await ctx
|
|
533
|
+
.get("context")
|
|
534
|
+
.adapter.create<InputSubscription, Subscription>({
|
|
535
|
+
model: "subscription",
|
|
536
|
+
data: {
|
|
537
|
+
plan: plan.name.toLowerCase(),
|
|
538
|
+
stripeCustomerId: customerId,
|
|
539
|
+
status: "incomplete",
|
|
540
|
+
referenceId,
|
|
541
|
+
seats: seats || 1,
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (!subscription) {
|
|
547
|
+
ctx.get("context").logger.error("Subscription ID not found");
|
|
548
|
+
return ctx.json({ success: False }, 500);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const params = await options.subscription?.getCheckoutSessionParams?.(
|
|
552
|
+
{
|
|
553
|
+
user,
|
|
554
|
+
session,
|
|
555
|
+
plan,
|
|
556
|
+
subscription,
|
|
557
|
+
},
|
|
558
|
+
ctx,
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
const hasEverTrialed = subscriptions.some((s) => {
|
|
562
|
+
// Check if user has ever had a trial for any plan (not just the same plan)
|
|
563
|
+
// This prevents users from getting multiple trials by switching plans
|
|
564
|
+
const hadTrial =
|
|
565
|
+
!!(s.trialStart || s.trialEnd) || s.status === "trialing";
|
|
566
|
+
return hadTrial;
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
const freeTrial =
|
|
570
|
+
!hasEverTrialed && plan.freeTrial
|
|
571
|
+
? { trial_period_days: plan.freeTrial.days }
|
|
572
|
+
: undefined;
|
|
573
|
+
|
|
574
|
+
let priceIdToUse: string | undefined = undefined;
|
|
575
|
+
if (annual) {
|
|
576
|
+
priceIdToUse = plan.annualDiscountPriceId;
|
|
577
|
+
if (!priceIdToUse && plan.annualDiscountLookupKey) {
|
|
578
|
+
priceIdToUse = await resolvePriceIdFromLookupKey(
|
|
579
|
+
client,
|
|
580
|
+
plan.annualDiscountLookupKey,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
} else {
|
|
584
|
+
priceIdToUse = plan.priceId;
|
|
585
|
+
if (!priceIdToUse && plan.lookupKey) {
|
|
586
|
+
priceIdToUse = await resolvePriceIdFromLookupKey(
|
|
587
|
+
client,
|
|
588
|
+
plan.lookupKey,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const checkoutSession = await client.checkout.sessions
|
|
593
|
+
.create(
|
|
594
|
+
{
|
|
595
|
+
...(customerId
|
|
596
|
+
? {
|
|
597
|
+
customer: customerId,
|
|
598
|
+
customer_update: {
|
|
599
|
+
name: "auto",
|
|
600
|
+
address: "auto",
|
|
601
|
+
},
|
|
602
|
+
}
|
|
603
|
+
: {
|
|
604
|
+
customer_email: user.email,
|
|
605
|
+
}),
|
|
606
|
+
success_url: getUrl(
|
|
607
|
+
authOptions,
|
|
608
|
+
`${
|
|
609
|
+
ctx.get("context").baseURL
|
|
610
|
+
}/subscription/success?callbackURL=${encodeURIComponent(
|
|
611
|
+
successUrl,
|
|
612
|
+
)}&subscriptionId=${encodeURIComponent(subscription.id)}`,
|
|
613
|
+
),
|
|
614
|
+
cancel_url: getUrl(authOptions, cancelUrl),
|
|
615
|
+
line_items: [
|
|
616
|
+
{
|
|
617
|
+
price: priceIdToUse!,
|
|
618
|
+
quantity: seats || 1,
|
|
619
|
+
},
|
|
620
|
+
],
|
|
621
|
+
subscription_data: {
|
|
622
|
+
...freeTrial,
|
|
623
|
+
},
|
|
624
|
+
mode: "subscription",
|
|
625
|
+
client_reference_id: referenceId,
|
|
626
|
+
...params?.params,
|
|
627
|
+
metadata: {
|
|
628
|
+
userId: user.id,
|
|
629
|
+
subscriptionId: subscription.id,
|
|
630
|
+
referenceId,
|
|
631
|
+
...params?.params?.metadata,
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
params?.options,
|
|
635
|
+
)
|
|
636
|
+
.catch(async (e: Stripe.errors.StripeAPIError) =>
|
|
637
|
+
ctx.json({ success: False, message: e.message, code: e.code }, 400),
|
|
638
|
+
);
|
|
639
|
+
if (checkoutSession instanceof Response) return checkoutSession;
|
|
640
|
+
return ctx.json(
|
|
641
|
+
{
|
|
642
|
+
...checkoutSession,
|
|
643
|
+
redirect: !disableRedirect,
|
|
644
|
+
} as Omit<typeof checkoutSession, "url"> &
|
|
645
|
+
(
|
|
646
|
+
| {
|
|
647
|
+
url: string;
|
|
648
|
+
redirect: true;
|
|
649
|
+
}
|
|
650
|
+
| {
|
|
651
|
+
url: null;
|
|
652
|
+
redirect: false;
|
|
653
|
+
}
|
|
654
|
+
), // url must be present if redirect is true, but stripe types as string | null
|
|
655
|
+
200,
|
|
656
|
+
);
|
|
657
|
+
},
|
|
658
|
+
),
|
|
659
|
+
cancelSubscriptionCallback: createEndpoint(
|
|
660
|
+
createRoute({
|
|
661
|
+
operationId: "cancelSubscriptionCallback",
|
|
662
|
+
method: "get",
|
|
663
|
+
path: "/subscription/cancel/callback",
|
|
664
|
+
middleware: [originCheck((ctx) => ctx.req.query("callbackURL")!)],
|
|
665
|
+
request: req()
|
|
666
|
+
.qry(
|
|
667
|
+
z.object({
|
|
668
|
+
callbackURL: callbackURLSchema(true),
|
|
669
|
+
subscriptionId: z.string().optional(),
|
|
670
|
+
}),
|
|
671
|
+
)
|
|
672
|
+
.bld(),
|
|
673
|
+
responses: res().rdr().bld(),
|
|
674
|
+
}),
|
|
675
|
+
(authOptions) => async (ctx) => {
|
|
676
|
+
if (!options.subscription?.enabled) {
|
|
677
|
+
logger.error(
|
|
678
|
+
`Subscription actions are not enabled in your stripe plugin config.`,
|
|
679
|
+
);
|
|
680
|
+
return ctx.json({ success: False }, 400);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const { callbackURL, subscriptionId } = ctx.req.valid("query");
|
|
684
|
+
if (!callbackURL || !subscriptionId)
|
|
685
|
+
return ctx.redirect(getUrl(authOptions, callbackURL || "/"), 302);
|
|
686
|
+
|
|
687
|
+
const session = await getSessionFromCtx<
|
|
688
|
+
{ stripeCustomerId: string } & User
|
|
689
|
+
>(ctx);
|
|
690
|
+
if (session instanceof Response)
|
|
691
|
+
return ctx.redirect(getUrl(authOptions, callbackURL || "/"), 302);
|
|
692
|
+
|
|
693
|
+
if (session.user?.stripeCustomerId) {
|
|
694
|
+
try {
|
|
695
|
+
const subscription = await ctx
|
|
696
|
+
.get("context")
|
|
697
|
+
.adapter.findOne<Subscription>({
|
|
698
|
+
model: "subscription",
|
|
699
|
+
where: [
|
|
700
|
+
{
|
|
701
|
+
field: "id",
|
|
702
|
+
value: subscriptionId,
|
|
703
|
+
},
|
|
704
|
+
],
|
|
705
|
+
});
|
|
706
|
+
if (
|
|
707
|
+
!subscription ||
|
|
708
|
+
subscription.cancelAtPeriodEnd ||
|
|
709
|
+
subscription.status === "canceled"
|
|
710
|
+
)
|
|
711
|
+
return ctx.redirect(getUrl(authOptions, callbackURL), 302);
|
|
712
|
+
|
|
713
|
+
const stripeSubscription = await client.subscriptions.list({
|
|
714
|
+
customer: session.user.stripeCustomerId,
|
|
715
|
+
status: "active",
|
|
716
|
+
});
|
|
717
|
+
const currentSubscription = stripeSubscription.data.find(
|
|
718
|
+
(sub) => sub.id === subscription.stripeSubscriptionId,
|
|
719
|
+
);
|
|
720
|
+
if (currentSubscription?.cancel_at_period_end === true) {
|
|
721
|
+
await ctx.get("context").adapter.update({
|
|
722
|
+
model: "subscription",
|
|
723
|
+
update: {
|
|
724
|
+
status: currentSubscription?.status,
|
|
725
|
+
cancelAtPeriodEnd: true,
|
|
726
|
+
},
|
|
727
|
+
where: [
|
|
728
|
+
{
|
|
729
|
+
field: "id",
|
|
730
|
+
value: subscription.id,
|
|
731
|
+
},
|
|
732
|
+
],
|
|
733
|
+
});
|
|
734
|
+
await options.subscription?.onSubscriptionCancel?.({
|
|
735
|
+
subscription,
|
|
736
|
+
cancellationDetails: currentSubscription.cancellation_details,
|
|
737
|
+
stripeSubscription: currentSubscription,
|
|
738
|
+
event: undefined,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
} catch (error) {
|
|
742
|
+
ctx
|
|
743
|
+
.get("context")
|
|
744
|
+
.logger.error(
|
|
745
|
+
"Error checking subscription status from Stripe",
|
|
746
|
+
error,
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return ctx.redirect(getUrl(authOptions, callbackURL), 302);
|
|
751
|
+
},
|
|
752
|
+
),
|
|
753
|
+
/**
|
|
754
|
+
* ### Endpoint
|
|
755
|
+
*
|
|
756
|
+
* POST `/subscription/cancel`
|
|
757
|
+
*
|
|
758
|
+
* ### API Methods
|
|
759
|
+
*
|
|
760
|
+
* **server:**
|
|
761
|
+
* `auth.api.cancelSubscription`
|
|
762
|
+
*
|
|
763
|
+
* **client:**
|
|
764
|
+
* `authClient.subscription.cancel`
|
|
765
|
+
*
|
|
766
|
+
* @see [Read our docs to learn more.](https://faire-auth.com/docs/plugins/stripe#api-method-subscription-cancel)
|
|
767
|
+
*/
|
|
768
|
+
cancelSubscription: createEndpoint(
|
|
769
|
+
createRoute({
|
|
770
|
+
operationId: "cancelSubscription",
|
|
771
|
+
method: "post",
|
|
772
|
+
path: "/subscription/cancel",
|
|
773
|
+
middleware: [
|
|
774
|
+
sessionMiddleware(),
|
|
775
|
+
originCheck((ctx) =>
|
|
776
|
+
ctx.req.raw
|
|
777
|
+
.clone()
|
|
778
|
+
.json()
|
|
779
|
+
.then((v) => v.returnUrl),
|
|
780
|
+
),
|
|
781
|
+
referenceMiddleware("cancel-subscription"),
|
|
782
|
+
],
|
|
783
|
+
request: req()
|
|
784
|
+
.bdy(
|
|
785
|
+
z.object({
|
|
786
|
+
referenceId: z
|
|
787
|
+
.string()
|
|
788
|
+
.meta({
|
|
789
|
+
description:
|
|
790
|
+
"Reference id of the subscription to cancel. Eg: '123'",
|
|
791
|
+
})
|
|
792
|
+
.optional(),
|
|
793
|
+
subscriptionId: z
|
|
794
|
+
.string()
|
|
795
|
+
.meta({
|
|
796
|
+
description:
|
|
797
|
+
"The id of the subscription to cancel. Eg: 'sub_123'",
|
|
798
|
+
})
|
|
799
|
+
.optional(),
|
|
800
|
+
returnUrl: z.string().meta({
|
|
801
|
+
description:
|
|
802
|
+
'URL to take customers to when they click on the billing portal’s link to return to your website. Eg: "https://example.com/dashboard"',
|
|
803
|
+
}),
|
|
804
|
+
}),
|
|
805
|
+
)
|
|
806
|
+
.bld(),
|
|
807
|
+
responses: res(
|
|
808
|
+
z.object({
|
|
809
|
+
url: z.string(),
|
|
810
|
+
redirect: z.literal(true),
|
|
811
|
+
}),
|
|
812
|
+
)
|
|
813
|
+
.err(400)
|
|
814
|
+
.err(401)
|
|
815
|
+
.bld(),
|
|
816
|
+
}),
|
|
817
|
+
(authOptions) => async (ctx) => {
|
|
818
|
+
const session = ctx.get("session");
|
|
819
|
+
const {
|
|
820
|
+
referenceId = session.user.id,
|
|
821
|
+
subscriptionId,
|
|
822
|
+
returnUrl,
|
|
823
|
+
} = ctx.req.valid("json");
|
|
824
|
+
|
|
825
|
+
const subscription = subscriptionId
|
|
826
|
+
? await ctx.get("context").adapter.findOne<Subscription>({
|
|
827
|
+
model: "subscription",
|
|
828
|
+
where: [
|
|
829
|
+
{
|
|
830
|
+
field: "id",
|
|
831
|
+
value: subscriptionId,
|
|
832
|
+
},
|
|
833
|
+
],
|
|
834
|
+
})
|
|
835
|
+
: await ctx
|
|
836
|
+
.get("context")
|
|
837
|
+
.adapter.findMany<Subscription>({
|
|
838
|
+
model: "subscription",
|
|
839
|
+
where: [{ field: "referenceId", value: referenceId }],
|
|
840
|
+
})
|
|
841
|
+
.then((subs) =>
|
|
842
|
+
subs.find(
|
|
843
|
+
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
844
|
+
),
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
if (!subscription || !subscription.stripeCustomerId)
|
|
848
|
+
return ctx.json(
|
|
849
|
+
{
|
|
850
|
+
success: False,
|
|
851
|
+
message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
852
|
+
},
|
|
853
|
+
400,
|
|
854
|
+
);
|
|
855
|
+
|
|
856
|
+
const activeSubscriptions = await client.subscriptions
|
|
857
|
+
.list({
|
|
858
|
+
customer: subscription.stripeCustomerId,
|
|
859
|
+
})
|
|
860
|
+
.then((res) =>
|
|
861
|
+
res.data.filter(
|
|
862
|
+
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
863
|
+
),
|
|
864
|
+
);
|
|
865
|
+
if (!activeSubscriptions.length) {
|
|
866
|
+
/**
|
|
867
|
+
* If the subscription is not found, we need to delete the subscription
|
|
868
|
+
* from the database. This is a rare case and should not happen.
|
|
869
|
+
*/
|
|
870
|
+
await ctx.get("context").adapter.deleteMany({
|
|
871
|
+
model: "subscription",
|
|
872
|
+
where: [
|
|
873
|
+
{
|
|
874
|
+
field: "referenceId",
|
|
875
|
+
value: referenceId,
|
|
876
|
+
},
|
|
877
|
+
],
|
|
878
|
+
});
|
|
879
|
+
return ctx.json(
|
|
880
|
+
{
|
|
881
|
+
success: False,
|
|
882
|
+
message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
883
|
+
},
|
|
884
|
+
400,
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
const activeSubscription = activeSubscriptions.find(
|
|
888
|
+
(sub) => sub.id === subscription.stripeSubscriptionId,
|
|
889
|
+
);
|
|
890
|
+
if (!activeSubscription)
|
|
891
|
+
return ctx.json(
|
|
892
|
+
{
|
|
893
|
+
success: False,
|
|
894
|
+
message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
895
|
+
},
|
|
896
|
+
400,
|
|
897
|
+
);
|
|
898
|
+
const res = await client.billingPortal.sessions
|
|
899
|
+
.create({
|
|
900
|
+
customer: subscription.stripeCustomerId,
|
|
901
|
+
return_url: getUrl(
|
|
902
|
+
authOptions,
|
|
903
|
+
`${
|
|
904
|
+
ctx.get("context").baseURL
|
|
905
|
+
}/subscription/cancel/callback?callbackURL=${encodeURIComponent(
|
|
906
|
+
returnUrl || "/",
|
|
907
|
+
)}&subscriptionId=${encodeURIComponent(subscription.id)}`,
|
|
908
|
+
),
|
|
909
|
+
flow_data: {
|
|
910
|
+
type: "subscription_cancel",
|
|
911
|
+
subscription_cancel: {
|
|
912
|
+
subscription: activeSubscription.id,
|
|
913
|
+
},
|
|
914
|
+
},
|
|
915
|
+
})
|
|
916
|
+
.catch(async (e: Stripe.errors.StripeAPIError) => {
|
|
917
|
+
if (e.message.includes("already set to be cancel")) {
|
|
918
|
+
/**
|
|
919
|
+
* incase we missed the event from stripe, we set it manually
|
|
920
|
+
* this is a rare case and should not happen
|
|
921
|
+
*/
|
|
922
|
+
if (!subscription.cancelAtPeriodEnd) {
|
|
923
|
+
await ctx.get("context").adapter.update({
|
|
924
|
+
model: "subscription",
|
|
925
|
+
update: {
|
|
926
|
+
cancelAtPeriodEnd: true,
|
|
927
|
+
},
|
|
928
|
+
where: [
|
|
929
|
+
{
|
|
930
|
+
field: "referenceId",
|
|
931
|
+
value: referenceId,
|
|
932
|
+
},
|
|
933
|
+
],
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return ctx.json(
|
|
938
|
+
{ success: False, message: e.message, code: e.code },
|
|
939
|
+
400,
|
|
940
|
+
);
|
|
941
|
+
});
|
|
942
|
+
if (res instanceof Response) return res;
|
|
943
|
+
return ctx.json(
|
|
944
|
+
{
|
|
945
|
+
url: res.url,
|
|
946
|
+
redirect: True,
|
|
947
|
+
},
|
|
948
|
+
200,
|
|
949
|
+
);
|
|
950
|
+
},
|
|
951
|
+
),
|
|
952
|
+
restoreSubscription: createEndpoint(
|
|
953
|
+
createRoute({
|
|
954
|
+
operationId: "restoreSubscription",
|
|
955
|
+
method: "post",
|
|
956
|
+
path: "/subscription/restore",
|
|
957
|
+
middleware: [
|
|
958
|
+
sessionMiddleware(),
|
|
959
|
+
referenceMiddleware("restore-subscription"),
|
|
960
|
+
],
|
|
961
|
+
request: req()
|
|
962
|
+
.bdy(
|
|
963
|
+
z.object({
|
|
964
|
+
referenceId: z
|
|
965
|
+
.string()
|
|
966
|
+
.meta({
|
|
967
|
+
description:
|
|
968
|
+
"Reference id of the subscription to restore. Eg: '123'",
|
|
969
|
+
})
|
|
970
|
+
.optional(),
|
|
971
|
+
subscriptionId: z
|
|
972
|
+
.string()
|
|
973
|
+
.meta({
|
|
974
|
+
description:
|
|
975
|
+
"The id of the subscription to restore. Eg: 'sub_123'",
|
|
976
|
+
})
|
|
977
|
+
.optional(),
|
|
978
|
+
}),
|
|
979
|
+
)
|
|
980
|
+
.bld(),
|
|
981
|
+
responses: res(
|
|
982
|
+
z.looseObject({}) as unknown as z.ZodType<StripeType.Subscription>,
|
|
983
|
+
)
|
|
984
|
+
.err(400)
|
|
985
|
+
.err(401)
|
|
986
|
+
.bld(),
|
|
987
|
+
}),
|
|
988
|
+
(authOptions) => async (ctx) => {
|
|
989
|
+
const session = ctx.get("session");
|
|
990
|
+
const { referenceId = session.user.id, subscriptionId } =
|
|
991
|
+
ctx.req.valid("json");
|
|
992
|
+
|
|
993
|
+
const subscription = subscriptionId
|
|
994
|
+
? await ctx.get("context").adapter.findOne<Subscription>({
|
|
995
|
+
model: "subscription",
|
|
996
|
+
where: [
|
|
997
|
+
{
|
|
998
|
+
field: "id",
|
|
999
|
+
value: subscriptionId,
|
|
1000
|
+
},
|
|
1001
|
+
],
|
|
1002
|
+
})
|
|
1003
|
+
: await ctx
|
|
1004
|
+
.get("context")
|
|
1005
|
+
.adapter.findMany<Subscription>({
|
|
1006
|
+
model: "subscription",
|
|
1007
|
+
where: [
|
|
1008
|
+
{
|
|
1009
|
+
field: "referenceId",
|
|
1010
|
+
value: referenceId,
|
|
1011
|
+
},
|
|
1012
|
+
],
|
|
1013
|
+
})
|
|
1014
|
+
.then((subs) =>
|
|
1015
|
+
subs.find(
|
|
1016
|
+
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
1017
|
+
),
|
|
1018
|
+
);
|
|
1019
|
+
if (!subscription || !subscription.stripeCustomerId)
|
|
1020
|
+
return ctx.json(
|
|
1021
|
+
{
|
|
1022
|
+
success: False,
|
|
1023
|
+
message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
1024
|
+
},
|
|
1025
|
+
400,
|
|
1026
|
+
);
|
|
1027
|
+
if (
|
|
1028
|
+
subscription.status != "active" &&
|
|
1029
|
+
subscription.status != "trialing"
|
|
1030
|
+
)
|
|
1031
|
+
return ctx.json(
|
|
1032
|
+
{
|
|
1033
|
+
success: False,
|
|
1034
|
+
message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_ACTIVE,
|
|
1035
|
+
},
|
|
1036
|
+
400,
|
|
1037
|
+
);
|
|
1038
|
+
if (!subscription.cancelAtPeriodEnd)
|
|
1039
|
+
return ctx.json(
|
|
1040
|
+
{
|
|
1041
|
+
success: False,
|
|
1042
|
+
message:
|
|
1043
|
+
STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION,
|
|
1044
|
+
},
|
|
1045
|
+
400,
|
|
1046
|
+
);
|
|
1047
|
+
|
|
1048
|
+
const activeSubscription = await client.subscriptions
|
|
1049
|
+
.list({
|
|
1050
|
+
customer: subscription.stripeCustomerId,
|
|
1051
|
+
})
|
|
1052
|
+
.then(
|
|
1053
|
+
(res) =>
|
|
1054
|
+
res.data.filter(
|
|
1055
|
+
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
1056
|
+
)[0],
|
|
1057
|
+
);
|
|
1058
|
+
if (!activeSubscription)
|
|
1059
|
+
return ctx.json(
|
|
1060
|
+
{
|
|
1061
|
+
success: False,
|
|
1062
|
+
message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
1063
|
+
},
|
|
1064
|
+
400,
|
|
1065
|
+
);
|
|
1066
|
+
|
|
1067
|
+
try {
|
|
1068
|
+
const newSub = await client.subscriptions.update(
|
|
1069
|
+
activeSubscription.id,
|
|
1070
|
+
{
|
|
1071
|
+
cancel_at_period_end: false,
|
|
1072
|
+
},
|
|
1073
|
+
);
|
|
1074
|
+
|
|
1075
|
+
await ctx.get("context").adapter.update({
|
|
1076
|
+
model: "subscription",
|
|
1077
|
+
update: {
|
|
1078
|
+
cancelAtPeriodEnd: false,
|
|
1079
|
+
updatedAt: new Date(),
|
|
1080
|
+
},
|
|
1081
|
+
where: [
|
|
1082
|
+
{
|
|
1083
|
+
field: "id",
|
|
1084
|
+
value: subscription.id,
|
|
1085
|
+
},
|
|
1086
|
+
],
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
return ctx.json(newSub, 200);
|
|
1090
|
+
} catch (error) {
|
|
1091
|
+
ctx
|
|
1092
|
+
.get("context")
|
|
1093
|
+
.logger.error("Error restoring subscription", error);
|
|
1094
|
+
return ctx.json(
|
|
1095
|
+
{
|
|
1096
|
+
success: False,
|
|
1097
|
+
message: STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER,
|
|
1098
|
+
},
|
|
1099
|
+
400,
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
},
|
|
1103
|
+
),
|
|
1104
|
+
/**
|
|
1105
|
+
* ### Endpoint
|
|
1106
|
+
*
|
|
1107
|
+
* GET `/subscription/list`
|
|
1108
|
+
*
|
|
1109
|
+
* ### API Methods
|
|
1110
|
+
*
|
|
1111
|
+
* **server:**
|
|
1112
|
+
* `auth.api.listActiveSubscriptions`
|
|
1113
|
+
*
|
|
1114
|
+
* **client:**
|
|
1115
|
+
* `authClient.subscription.list`
|
|
1116
|
+
*
|
|
1117
|
+
* @see [Read our docs to learn more.](https://faire-auth.com/docs/plugins/stripe#api-method-subscription-list)
|
|
1118
|
+
*/
|
|
1119
|
+
listActiveSubscriptions: createEndpoint(
|
|
1120
|
+
createRoute({
|
|
1121
|
+
operationId: "listActiveSubscriptions",
|
|
1122
|
+
method: "get",
|
|
1123
|
+
path: "/subscription/list",
|
|
1124
|
+
middleware: [
|
|
1125
|
+
sessionMiddleware(),
|
|
1126
|
+
referenceMiddleware("list-subscription"),
|
|
1127
|
+
],
|
|
1128
|
+
request: req()
|
|
1129
|
+
.qry(
|
|
1130
|
+
z.object({
|
|
1131
|
+
referenceId: z
|
|
1132
|
+
.string()
|
|
1133
|
+
.meta({
|
|
1134
|
+
description:
|
|
1135
|
+
"Reference id of the subscription to list. Eg: '123'",
|
|
1136
|
+
})
|
|
1137
|
+
.optional(),
|
|
1138
|
+
}),
|
|
1139
|
+
)
|
|
1140
|
+
.bld(),
|
|
1141
|
+
responses: res(
|
|
1142
|
+
z.array(z.looseObject({})) as unknown as z.ZodType<Subscription[]>,
|
|
1143
|
+
)
|
|
1144
|
+
.err(400)
|
|
1145
|
+
.err(401)
|
|
1146
|
+
.bld(),
|
|
1147
|
+
}),
|
|
1148
|
+
(_authOptions) => async (ctx) => {
|
|
1149
|
+
if (!options.subscription?.enabled) {
|
|
1150
|
+
logger.error(
|
|
1151
|
+
`Subscription actions are not enabled in your stripe plugin config.`,
|
|
1152
|
+
);
|
|
1153
|
+
return ctx.json({ success: False }, 400);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const session = ctx.get("session");
|
|
1157
|
+
const { referenceId = session.user.id } = ctx.req.valid("query");
|
|
1158
|
+
|
|
1159
|
+
const subscriptions = await ctx
|
|
1160
|
+
.get("context")
|
|
1161
|
+
.adapter.findMany<Subscription>({
|
|
1162
|
+
model: "subscription",
|
|
1163
|
+
where: [
|
|
1164
|
+
{
|
|
1165
|
+
field: "referenceId",
|
|
1166
|
+
value: referenceId,
|
|
1167
|
+
},
|
|
1168
|
+
],
|
|
1169
|
+
});
|
|
1170
|
+
if (!subscriptions.length) return ctx.json([], 200);
|
|
1171
|
+
|
|
1172
|
+
const plans = await getPlans(
|
|
1173
|
+
options.subscription as O["subscription"] & SubscriptionOptions,
|
|
1174
|
+
);
|
|
1175
|
+
if (!plans) return ctx.json([], 200);
|
|
1176
|
+
|
|
1177
|
+
const subs = subscriptions
|
|
1178
|
+
.map((sub) => {
|
|
1179
|
+
const plan = plans.find(
|
|
1180
|
+
(p) => p.name.toLowerCase() === sub.plan.toLowerCase(),
|
|
1181
|
+
);
|
|
1182
|
+
return {
|
|
1183
|
+
...sub,
|
|
1184
|
+
limits: plan?.limits,
|
|
1185
|
+
priceId: plan?.priceId,
|
|
1186
|
+
};
|
|
1187
|
+
})
|
|
1188
|
+
.filter(
|
|
1189
|
+
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
1190
|
+
);
|
|
1191
|
+
return ctx.json(subs, 200);
|
|
1192
|
+
},
|
|
1193
|
+
),
|
|
1194
|
+
subscriptionSuccess: createEndpoint(
|
|
1195
|
+
createRoute({
|
|
1196
|
+
operationId: "subscriptionSuccess",
|
|
1197
|
+
method: "get",
|
|
1198
|
+
path: "/subscription/success",
|
|
1199
|
+
middleware: [originCheck((ctx) => ctx.req.query("callbackURL")!)],
|
|
1200
|
+
request: req()
|
|
1201
|
+
.qry(
|
|
1202
|
+
z.object({
|
|
1203
|
+
callbackURL: callbackURLSchema(true),
|
|
1204
|
+
subscriptionId: z.string().optional(),
|
|
1205
|
+
}),
|
|
1206
|
+
)
|
|
1207
|
+
.bld(),
|
|
1208
|
+
responses: res().rdr().bld(),
|
|
1209
|
+
}),
|
|
1210
|
+
|
|
1211
|
+
(authOptions) => async (ctx) => {
|
|
1212
|
+
const { callbackURL, subscriptionId } = ctx.req.valid("query");
|
|
1213
|
+
if (!callbackURL || !subscriptionId)
|
|
1214
|
+
return ctx.redirect(getUrl(authOptions, callbackURL || "/"), 302);
|
|
1215
|
+
|
|
1216
|
+
const session = await getSessionFromCtx<
|
|
1217
|
+
{ stripeCustomerId?: string } & User
|
|
1218
|
+
>(ctx);
|
|
1219
|
+
if (session instanceof Response)
|
|
1220
|
+
return ctx.redirect(getUrl(authOptions, callbackURL || "/"), 302);
|
|
1221
|
+
|
|
1222
|
+
const subscription = await ctx
|
|
1223
|
+
.get("context")
|
|
1224
|
+
.adapter.findOne<Subscription>({
|
|
1225
|
+
model: "subscription",
|
|
1226
|
+
where: [
|
|
1227
|
+
{
|
|
1228
|
+
field: "id",
|
|
1229
|
+
value: subscriptionId,
|
|
1230
|
+
},
|
|
1231
|
+
],
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
if (
|
|
1235
|
+
subscription?.status === "active" ||
|
|
1236
|
+
subscription?.status === "trialing"
|
|
1237
|
+
)
|
|
1238
|
+
return ctx.redirect(getUrl(authOptions, callbackURL), 302);
|
|
1239
|
+
|
|
1240
|
+
const customerId =
|
|
1241
|
+
subscription?.stripeCustomerId || session.user.stripeCustomerId;
|
|
1242
|
+
|
|
1243
|
+
if (customerId) {
|
|
1244
|
+
try {
|
|
1245
|
+
const stripeSubscription = await client.subscriptions
|
|
1246
|
+
.list({
|
|
1247
|
+
customer: customerId,
|
|
1248
|
+
status: "active",
|
|
1249
|
+
})
|
|
1250
|
+
.then((res) => res.data[0]);
|
|
1251
|
+
|
|
1252
|
+
if (stripeSubscription) {
|
|
1253
|
+
const firstItem = stripeSubscription.items.data[0];
|
|
1254
|
+
if (firstItem) {
|
|
1255
|
+
const plan = await getPlanByPriceInfo(
|
|
1256
|
+
options,
|
|
1257
|
+
firstItem.price.id,
|
|
1258
|
+
firstItem.price.lookup_key,
|
|
1259
|
+
);
|
|
1260
|
+
|
|
1261
|
+
if (plan && subscription) {
|
|
1262
|
+
await ctx.get("context").adapter.update({
|
|
1263
|
+
model: "subscription",
|
|
1264
|
+
update: {
|
|
1265
|
+
status: stripeSubscription.status,
|
|
1266
|
+
seats: firstItem.quantity || 1,
|
|
1267
|
+
plan: plan.name.toLowerCase(),
|
|
1268
|
+
periodEnd: new Date(firstItem.current_period_end * 1000),
|
|
1269
|
+
periodStart: new Date(
|
|
1270
|
+
firstItem.current_period_start * 1000,
|
|
1271
|
+
),
|
|
1272
|
+
stripeSubscriptionId: stripeSubscription.id,
|
|
1273
|
+
...(stripeSubscription.trial_start &&
|
|
1274
|
+
stripeSubscription.trial_end
|
|
1275
|
+
? {
|
|
1276
|
+
trialStart: new Date(
|
|
1277
|
+
stripeSubscription.trial_start * 1000,
|
|
1278
|
+
),
|
|
1279
|
+
trialEnd: new Date(
|
|
1280
|
+
stripeSubscription.trial_end * 1000,
|
|
1281
|
+
),
|
|
1282
|
+
}
|
|
1283
|
+
: {}),
|
|
1284
|
+
},
|
|
1285
|
+
where: [
|
|
1286
|
+
{
|
|
1287
|
+
field: "id",
|
|
1288
|
+
value: subscription.id,
|
|
1289
|
+
},
|
|
1290
|
+
],
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
} catch (error) {
|
|
1296
|
+
ctx
|
|
1297
|
+
.get("context")
|
|
1298
|
+
.logger.error("Error fetching subscription from Stripe", error);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
return ctx.redirect(getUrl(authOptions, callbackURL), 302);
|
|
1302
|
+
},
|
|
1303
|
+
),
|
|
1304
|
+
createBillingPortal: createEndpoint(
|
|
1305
|
+
createRoute({
|
|
1306
|
+
operationId: "createBillingPortal",
|
|
1307
|
+
method: "post",
|
|
1308
|
+
path: "/subscription/billing-portal",
|
|
1309
|
+
middleware: [
|
|
1310
|
+
sessionMiddleware<{
|
|
1311
|
+
session: { user: { stripeCustomerId?: string } };
|
|
1312
|
+
}>(),
|
|
1313
|
+
originCheck((ctx) =>
|
|
1314
|
+
ctx.req.raw
|
|
1315
|
+
.clone()
|
|
1316
|
+
.json()
|
|
1317
|
+
.then((v) => v.returnUrl),
|
|
1318
|
+
),
|
|
1319
|
+
referenceMiddleware("billing-portal"),
|
|
1320
|
+
],
|
|
1321
|
+
request: req()
|
|
1322
|
+
.bdy(
|
|
1323
|
+
z.object({
|
|
1324
|
+
locale: z
|
|
1325
|
+
.custom<StripeType.Checkout.Session.Locale>((localization) => {
|
|
1326
|
+
return typeof localization === "string";
|
|
1327
|
+
})
|
|
1328
|
+
.optional(),
|
|
1329
|
+
referenceId: z.string().optional(),
|
|
1330
|
+
returnUrl: z.string().default("/"),
|
|
1331
|
+
}),
|
|
1332
|
+
)
|
|
1333
|
+
.bld(),
|
|
1334
|
+
responses: res(z.object({ url: z.string(), redirect: z.literal(true) }))
|
|
1335
|
+
.err(400)
|
|
1336
|
+
.err(401)
|
|
1337
|
+
.bld(),
|
|
1338
|
+
}),
|
|
1339
|
+
(authOptions) => async (ctx) => {
|
|
1340
|
+
const session = ctx.get("session");
|
|
1341
|
+
const {
|
|
1342
|
+
referenceId = session.user.id,
|
|
1343
|
+
locale,
|
|
1344
|
+
returnUrl,
|
|
1345
|
+
} = ctx.req.valid("json");
|
|
1346
|
+
let customerId = session.user.stripeCustomerId;
|
|
1347
|
+
|
|
1348
|
+
if (!customerId) {
|
|
1349
|
+
const subscription = await ctx
|
|
1350
|
+
.get("context")
|
|
1351
|
+
.adapter.findMany<Subscription>({
|
|
1352
|
+
model: "subscription",
|
|
1353
|
+
where: [
|
|
1354
|
+
{
|
|
1355
|
+
field: "referenceId",
|
|
1356
|
+
value: referenceId,
|
|
1357
|
+
},
|
|
1358
|
+
],
|
|
1359
|
+
})
|
|
1360
|
+
.then((subs) =>
|
|
1361
|
+
subs.find(
|
|
1362
|
+
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
1363
|
+
),
|
|
1364
|
+
);
|
|
1365
|
+
|
|
1366
|
+
customerId = subscription?.stripeCustomerId;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
if (!customerId)
|
|
1370
|
+
return ctx.json(
|
|
1371
|
+
{
|
|
1372
|
+
success: False,
|
|
1373
|
+
message: "No Stripe customer found for this user",
|
|
1374
|
+
},
|
|
1375
|
+
400,
|
|
1376
|
+
);
|
|
1377
|
+
|
|
1378
|
+
try {
|
|
1379
|
+
const { url } = await client.billingPortal.sessions.create({
|
|
1380
|
+
customer: customerId,
|
|
1381
|
+
return_url: getUrl(authOptions, returnUrl),
|
|
1382
|
+
...(locale && { locale }),
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
return ctx.json(
|
|
1386
|
+
{
|
|
1387
|
+
url,
|
|
1388
|
+
redirect: True,
|
|
1389
|
+
},
|
|
1390
|
+
200,
|
|
1391
|
+
);
|
|
1392
|
+
} catch (error: any) {
|
|
1393
|
+
ctx
|
|
1394
|
+
.get("context")
|
|
1395
|
+
.logger.error("Error creating billing portal session", error);
|
|
1396
|
+
return ctx.json({ success: False, message: error.message }, 400);
|
|
1397
|
+
}
|
|
1398
|
+
},
|
|
1399
|
+
),
|
|
1400
|
+
} as const;
|
|
1401
|
+
|
|
1402
|
+
return {
|
|
1403
|
+
id: "stripe",
|
|
1404
|
+
routes: {
|
|
1405
|
+
stripeWebhook: createEndpoint(
|
|
1406
|
+
createRoute({
|
|
1407
|
+
operationId: "stripeWebhook",
|
|
1408
|
+
method: "post",
|
|
1409
|
+
path: "/stripe/webhook",
|
|
1410
|
+
client: false,
|
|
1411
|
+
// cloneRequest: true,
|
|
1412
|
+
// //don't parse the body
|
|
1413
|
+
// disableBody: true,
|
|
1414
|
+
responses: res(z.object({ success: z.literal(true) }))
|
|
1415
|
+
.err(400)
|
|
1416
|
+
.err(500)
|
|
1417
|
+
.bld(),
|
|
1418
|
+
}),
|
|
1419
|
+
(_authOptions) => async (ctx) => {
|
|
1420
|
+
if (!ctx.req.raw?.body) return ctx.json({ success: False }, 500);
|
|
1421
|
+
|
|
1422
|
+
const buf = await ctx.req.text();
|
|
1423
|
+
const sig = ctx.req.header("stripe-signature") as string;
|
|
1424
|
+
const webhookSecret = options.stripeWebhookSecret;
|
|
1425
|
+
let event: Stripe.Event;
|
|
1426
|
+
try {
|
|
1427
|
+
if (!sig || !webhookSecret)
|
|
1428
|
+
return ctx.json(
|
|
1429
|
+
{ success: False, message: "Stripe webhook secret not found" },
|
|
1430
|
+
400,
|
|
1431
|
+
);
|
|
1432
|
+
|
|
1433
|
+
event = await client.webhooks.constructEventAsync(
|
|
1434
|
+
buf,
|
|
1435
|
+
sig,
|
|
1436
|
+
webhookSecret,
|
|
1437
|
+
);
|
|
1438
|
+
} catch (err: any) {
|
|
1439
|
+
ctx.get("context").logger.error(`${err.message}`);
|
|
1440
|
+
return ctx.json(
|
|
1441
|
+
{ success: False, message: `Webhook Error: ${err.message}` },
|
|
1442
|
+
400,
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
try {
|
|
1446
|
+
switch (event.type) {
|
|
1447
|
+
case "checkout.session.completed":
|
|
1448
|
+
await onCheckoutSessionCompleted(ctx, options, event);
|
|
1449
|
+
await options.onEvent?.(event);
|
|
1450
|
+
break;
|
|
1451
|
+
case "customer.subscription.updated":
|
|
1452
|
+
await onSubscriptionUpdated(ctx, options, event);
|
|
1453
|
+
await options.onEvent?.(event);
|
|
1454
|
+
break;
|
|
1455
|
+
case "customer.subscription.deleted":
|
|
1456
|
+
await onSubscriptionDeleted(ctx, options, event);
|
|
1457
|
+
await options.onEvent?.(event);
|
|
1458
|
+
break;
|
|
1459
|
+
default:
|
|
1460
|
+
await options.onEvent?.(event);
|
|
1461
|
+
break;
|
|
1462
|
+
}
|
|
1463
|
+
} catch (e: any) {
|
|
1464
|
+
ctx
|
|
1465
|
+
.get("context")
|
|
1466
|
+
.logger.error(`Stripe webhook failed. Error: ${e.message}`);
|
|
1467
|
+
return ctx.json(
|
|
1468
|
+
{
|
|
1469
|
+
success: False,
|
|
1470
|
+
message: "Webhook error: See server logs for more information.",
|
|
1471
|
+
},
|
|
1472
|
+
400,
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
return ctx.json({ success: True }, 200);
|
|
1476
|
+
},
|
|
1477
|
+
),
|
|
1478
|
+
...((options.subscription?.enabled
|
|
1479
|
+
? subscriptionEndpoints
|
|
1480
|
+
: {}) as O["subscription"] extends {
|
|
1481
|
+
enabled: boolean;
|
|
1482
|
+
}
|
|
1483
|
+
? typeof subscriptionEndpoints
|
|
1484
|
+
: {}),
|
|
1485
|
+
},
|
|
1486
|
+
init(ctx) {
|
|
1487
|
+
return {
|
|
1488
|
+
options: {
|
|
1489
|
+
databaseHooks: {
|
|
1490
|
+
user: {
|
|
1491
|
+
create: {
|
|
1492
|
+
async after(user, ctx) {
|
|
1493
|
+
if (ctx && options.createCustomerOnSignUp) {
|
|
1494
|
+
const stripeCustomer = await client.customers.create({
|
|
1495
|
+
email: user.email,
|
|
1496
|
+
...(user.name && { name: user.name }),
|
|
1497
|
+
metadata: {
|
|
1498
|
+
userId: user.id,
|
|
1499
|
+
},
|
|
1500
|
+
});
|
|
1501
|
+
const updatedUser = await ctx
|
|
1502
|
+
.get("context")
|
|
1503
|
+
.internalAdapter.updateUser(user.id, {
|
|
1504
|
+
stripeCustomerId: stripeCustomer.id,
|
|
1505
|
+
});
|
|
1506
|
+
if (!updatedUser)
|
|
1507
|
+
logger.error("#FAIRE_AUTH: Failed to create customer");
|
|
1508
|
+
else if (options.onCustomerCreate)
|
|
1509
|
+
await options.onCustomerCreate(
|
|
1510
|
+
{
|
|
1511
|
+
stripeCustomer,
|
|
1512
|
+
user,
|
|
1513
|
+
},
|
|
1514
|
+
// @ts-expect-error Doesn't have ContextVars
|
|
1515
|
+
ctx,
|
|
1516
|
+
);
|
|
1517
|
+
}
|
|
1518
|
+
},
|
|
1519
|
+
},
|
|
1520
|
+
},
|
|
1521
|
+
},
|
|
1522
|
+
},
|
|
1523
|
+
};
|
|
1524
|
+
},
|
|
1525
|
+
schema: getSchema(options),
|
|
1526
|
+
} satisfies FaireAuthPlugin;
|
|
1527
|
+
};
|
|
1528
|
+
|
|
1529
|
+
export type StripePlugin<O extends StripeOptions> = ReturnType<
|
|
1530
|
+
typeof stripe<O>
|
|
1531
|
+
>;
|
|
1532
|
+
|
|
1533
|
+
export type { StripePlan, Subscription };
|