@better-auth/stripe 1.5.0-beta.1 → 1.5.0-beta.3
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 +9 -9
- package/CHANGELOG.md +15 -13
- package/LICENSE.md +15 -12
- package/dist/client.d.mts +106 -1
- package/dist/client.mjs +5 -2
- package/dist/error-codes-Bkj5yJMT.mjs +29 -0
- package/dist/{index-DpiQGYLJ.d.mts → index-BnHmwMru.d.mts} +314 -155
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +628 -231
- package/package.json +6 -6
- package/src/client.ts +1 -0
- package/src/error-codes.ts +16 -0
- package/src/hooks.ts +229 -53
- package/src/index.ts +142 -45
- package/src/middleware.ts +91 -45
- package/src/routes.ts +620 -300
- package/src/schema.ts +30 -0
- 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 +3350 -1404
- package/dist/error-codes-qqooUh6R.mjs +0 -16
package/src/routes.ts
CHANGED
|
@@ -1,29 +1,102 @@
|
|
|
1
1
|
import { createAuthEndpoint } from "@better-auth/core/api";
|
|
2
2
|
import { APIError } from "@better-auth/core/error";
|
|
3
|
-
import type { GenericEndpointContext } from "better-auth";
|
|
3
|
+
import type { GenericEndpointContext, User } from "better-auth";
|
|
4
4
|
import { HIDE_METADATA } from "better-auth";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
sessionMiddleware,
|
|
9
|
-
} from "better-auth/api";
|
|
5
|
+
import { getSessionFromCtx, originCheck } from "better-auth/api";
|
|
6
|
+
import type { Organization } from "better-auth/plugins/organization";
|
|
7
|
+
import { defu } from "defu";
|
|
10
8
|
import type Stripe from "stripe";
|
|
11
9
|
import type { Stripe as StripeType } from "stripe";
|
|
12
10
|
import * as z from "zod/v4";
|
|
13
11
|
import { STRIPE_ERROR_CODES } from "./error-codes";
|
|
14
12
|
import {
|
|
15
13
|
onCheckoutSessionCompleted,
|
|
14
|
+
onSubscriptionCreated,
|
|
16
15
|
onSubscriptionDeleted,
|
|
17
16
|
onSubscriptionUpdated,
|
|
18
17
|
} from "./hooks";
|
|
19
|
-
import { referenceMiddleware } from "./middleware";
|
|
18
|
+
import { referenceMiddleware, stripeSessionMiddleware } from "./middleware";
|
|
20
19
|
import type {
|
|
21
|
-
|
|
20
|
+
CustomerType,
|
|
21
|
+
StripeCtxSession,
|
|
22
22
|
StripeOptions,
|
|
23
23
|
Subscription,
|
|
24
24
|
SubscriptionOptions,
|
|
25
|
+
WithStripeCustomerId,
|
|
25
26
|
} from "./types";
|
|
26
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
escapeStripeSearchValue,
|
|
29
|
+
getPlanByName,
|
|
30
|
+
getPlanByPriceInfo,
|
|
31
|
+
getPlans,
|
|
32
|
+
isActiveOrTrialing,
|
|
33
|
+
isPendingCancel,
|
|
34
|
+
isStripePendingCancel,
|
|
35
|
+
} from "./utils";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Converts a relative URL to an absolute URL using baseURL.
|
|
39
|
+
* @internal
|
|
40
|
+
*/
|
|
41
|
+
function getUrl(ctx: GenericEndpointContext, url: string) {
|
|
42
|
+
if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(url)) {
|
|
43
|
+
return url;
|
|
44
|
+
}
|
|
45
|
+
return `${ctx.context.options.baseURL}${
|
|
46
|
+
url.startsWith("/") ? url : `/${url}`
|
|
47
|
+
}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolves a Stripe price ID from a lookup key.
|
|
52
|
+
* @internal
|
|
53
|
+
*/
|
|
54
|
+
async function resolvePriceIdFromLookupKey(
|
|
55
|
+
stripeClient: Stripe,
|
|
56
|
+
lookupKey: string,
|
|
57
|
+
): Promise<string | undefined> {
|
|
58
|
+
if (!lookupKey) return undefined;
|
|
59
|
+
const prices = await stripeClient.prices.list({
|
|
60
|
+
lookup_keys: [lookupKey],
|
|
61
|
+
active: true,
|
|
62
|
+
limit: 1,
|
|
63
|
+
});
|
|
64
|
+
return prices.data[0]?.id;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Determines the reference ID based on customer type.
|
|
69
|
+
* - `user` (default): uses userId
|
|
70
|
+
* - `organization`: uses activeOrganizationId from session
|
|
71
|
+
* @internal
|
|
72
|
+
*/
|
|
73
|
+
function getReferenceId(
|
|
74
|
+
ctxSession: StripeCtxSession,
|
|
75
|
+
customerType: CustomerType | undefined,
|
|
76
|
+
options: StripeOptions,
|
|
77
|
+
): string {
|
|
78
|
+
const { user, session } = ctxSession;
|
|
79
|
+
const type = customerType || "user";
|
|
80
|
+
|
|
81
|
+
if (type === "organization") {
|
|
82
|
+
if (!options.organization?.enabled) {
|
|
83
|
+
throw APIError.from(
|
|
84
|
+
"BAD_REQUEST",
|
|
85
|
+
STRIPE_ERROR_CODES.ORGANIZATION_SUBSCRIPTION_NOT_ENABLED,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!session.activeOrganizationId) {
|
|
90
|
+
throw APIError.from(
|
|
91
|
+
"BAD_REQUEST",
|
|
92
|
+
STRIPE_ERROR_CODES.ORGANIZATION_NOT_FOUND,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return session.activeOrganizationId;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return user.id;
|
|
99
|
+
}
|
|
27
100
|
|
|
28
101
|
const upgradeSubscriptionBodySchema = z.object({
|
|
29
102
|
/**
|
|
@@ -42,14 +115,14 @@ const upgradeSubscriptionBodySchema = z.object({
|
|
|
42
115
|
})
|
|
43
116
|
.optional(),
|
|
44
117
|
/**
|
|
45
|
-
* Reference
|
|
46
|
-
*
|
|
47
|
-
*
|
|
118
|
+
* Reference ID for the subscription based on customerType:
|
|
119
|
+
* - `user`: defaults to `user.id`
|
|
120
|
+
* - `organization`: defaults to `session.activeOrganizationId`
|
|
48
121
|
*/
|
|
49
122
|
referenceId: z
|
|
50
123
|
.string()
|
|
51
124
|
.meta({
|
|
52
|
-
description: 'Reference
|
|
125
|
+
description: 'Reference ID for the subscription. Eg: "org_123"',
|
|
53
126
|
})
|
|
54
127
|
.optional(),
|
|
55
128
|
/**
|
|
@@ -64,12 +137,23 @@ const upgradeSubscriptionBodySchema = z.object({
|
|
|
64
137
|
})
|
|
65
138
|
.optional(),
|
|
66
139
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
140
|
+
* Customer type for the subscription.
|
|
141
|
+
* - `user`: User owns the subscription (default)
|
|
142
|
+
* - `organization`: Organization owns the subscription (requires referenceId)
|
|
143
|
+
*/
|
|
144
|
+
customerType: z
|
|
145
|
+
.enum(["user", "organization"])
|
|
146
|
+
.meta({
|
|
147
|
+
description:
|
|
148
|
+
'Customer type for the subscription. Eg: "user" or "organization"',
|
|
149
|
+
})
|
|
150
|
+
.optional(),
|
|
151
|
+
/**
|
|
152
|
+
* Additional metadata to store with the subscription.
|
|
69
153
|
*/
|
|
70
154
|
metadata: z.record(z.string(), z.any()).optional(),
|
|
71
155
|
/**
|
|
72
|
-
*
|
|
156
|
+
* Number of seats for subscriptions.
|
|
73
157
|
*/
|
|
74
158
|
seats: z
|
|
75
159
|
.number()
|
|
@@ -148,22 +232,27 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
148
232
|
},
|
|
149
233
|
},
|
|
150
234
|
use: [
|
|
151
|
-
|
|
235
|
+
stripeSessionMiddleware,
|
|
236
|
+
referenceMiddleware(subscriptionOptions, "upgrade-subscription"),
|
|
152
237
|
originCheck((c) => {
|
|
153
238
|
return [c.body.successUrl as string, c.body.cancelUrl as string];
|
|
154
239
|
}),
|
|
155
|
-
referenceMiddleware(subscriptionOptions, "upgrade-subscription"),
|
|
156
240
|
],
|
|
157
241
|
},
|
|
158
242
|
async (ctx) => {
|
|
159
243
|
const { user, session } = ctx.context.session;
|
|
244
|
+
const customerType = ctx.body.customerType || "user";
|
|
245
|
+
const referenceId =
|
|
246
|
+
ctx.body.referenceId ||
|
|
247
|
+
getReferenceId(ctx.context.session, customerType, options);
|
|
248
|
+
|
|
160
249
|
if (!user.emailVerified && subscriptionOptions.requireEmailVerification) {
|
|
161
250
|
throw APIError.from(
|
|
162
251
|
"BAD_REQUEST",
|
|
163
252
|
STRIPE_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED,
|
|
164
253
|
);
|
|
165
254
|
}
|
|
166
|
-
|
|
255
|
+
|
|
167
256
|
const plan = await getPlanByName(options, ctx.body.plan);
|
|
168
257
|
if (!plan) {
|
|
169
258
|
throw APIError.from(
|
|
@@ -171,6 +260,8 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
171
260
|
STRIPE_ERROR_CODES.SUBSCRIPTION_PLAN_NOT_FOUND,
|
|
172
261
|
);
|
|
173
262
|
}
|
|
263
|
+
|
|
264
|
+
// Find existing subscription by Stripe ID or reference ID
|
|
174
265
|
let subscriptionToUpdate = ctx.body.subscriptionId
|
|
175
266
|
? await ctx.context.adapter.findOne<Subscription>({
|
|
176
267
|
model: "subscription",
|
|
@@ -184,7 +275,12 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
184
275
|
: referenceId
|
|
185
276
|
? await ctx.context.adapter.findOne<Subscription>({
|
|
186
277
|
model: "subscription",
|
|
187
|
-
where: [
|
|
278
|
+
where: [
|
|
279
|
+
{
|
|
280
|
+
field: "referenceId",
|
|
281
|
+
value: referenceId,
|
|
282
|
+
},
|
|
283
|
+
],
|
|
188
284
|
})
|
|
189
285
|
: null;
|
|
190
286
|
|
|
@@ -195,7 +291,6 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
195
291
|
) {
|
|
196
292
|
subscriptionToUpdate = null;
|
|
197
293
|
}
|
|
198
|
-
|
|
199
294
|
if (ctx.body.subscriptionId && !subscriptionToUpdate) {
|
|
200
295
|
throw APIError.from(
|
|
201
296
|
"BAD_REQUEST",
|
|
@@ -203,51 +298,154 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
203
298
|
);
|
|
204
299
|
}
|
|
205
300
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
let stripeCustomer = existingCustomers.data[0];
|
|
218
|
-
|
|
219
|
-
if (!stripeCustomer) {
|
|
220
|
-
stripeCustomer = await client.customers.create({
|
|
221
|
-
email: user.email,
|
|
222
|
-
name: user.name,
|
|
223
|
-
metadata: {
|
|
224
|
-
...ctx.body.metadata,
|
|
225
|
-
userId: user.id,
|
|
226
|
-
},
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Update local DB with Stripe customer ID
|
|
231
|
-
await ctx.context.adapter.update({
|
|
232
|
-
model: "user",
|
|
233
|
-
update: {
|
|
234
|
-
stripeCustomerId: stripeCustomer.id,
|
|
235
|
-
},
|
|
301
|
+
// Determine customer id
|
|
302
|
+
let customerId: string | undefined;
|
|
303
|
+
if (customerType === "organization") {
|
|
304
|
+
// Organization subscription - get customer ID from organization
|
|
305
|
+
customerId = subscriptionToUpdate?.stripeCustomerId;
|
|
306
|
+
if (!customerId) {
|
|
307
|
+
const org = await ctx.context.adapter.findOne<
|
|
308
|
+
Organization & WithStripeCustomerId
|
|
309
|
+
>({
|
|
310
|
+
model: "organization",
|
|
236
311
|
where: [
|
|
237
312
|
{
|
|
238
313
|
field: "id",
|
|
239
|
-
value:
|
|
314
|
+
value: referenceId,
|
|
240
315
|
},
|
|
241
316
|
],
|
|
242
317
|
});
|
|
318
|
+
if (!org) {
|
|
319
|
+
throw APIError.from(
|
|
320
|
+
"BAD_REQUEST",
|
|
321
|
+
STRIPE_ERROR_CODES.ORGANIZATION_NOT_FOUND,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
customerId = org.stripeCustomerId;
|
|
325
|
+
|
|
326
|
+
// If org doesn't have a customer ID, create one
|
|
327
|
+
if (!customerId) {
|
|
328
|
+
try {
|
|
329
|
+
// First, search for existing organization customer by organizationId
|
|
330
|
+
const existingOrgCustomers = await client.customers.search({
|
|
331
|
+
query: `metadata["organizationId"]:"${org.id}"`,
|
|
332
|
+
limit: 1,
|
|
333
|
+
});
|
|
243
334
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
335
|
+
let stripeCustomer = existingOrgCustomers.data[0];
|
|
336
|
+
|
|
337
|
+
if (!stripeCustomer) {
|
|
338
|
+
// Get custom params if provided
|
|
339
|
+
let extraCreateParams: Partial<StripeType.CustomerCreateParams> =
|
|
340
|
+
{};
|
|
341
|
+
if (options.organization?.getCustomerCreateParams) {
|
|
342
|
+
extraCreateParams =
|
|
343
|
+
await options.organization.getCustomerCreateParams(
|
|
344
|
+
org,
|
|
345
|
+
ctx,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Create Stripe customer for organization
|
|
350
|
+
// Email can be set via getCustomerCreateParams or updated in billing portal
|
|
351
|
+
// Use defu to ensure internal metadata fields are preserved
|
|
352
|
+
const customerParams: StripeType.CustomerCreateParams = defu(
|
|
353
|
+
{
|
|
354
|
+
name: org.name,
|
|
355
|
+
metadata: {
|
|
356
|
+
...ctx.body.metadata,
|
|
357
|
+
organizationId: org.id,
|
|
358
|
+
customerType: "organization",
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
extraCreateParams,
|
|
362
|
+
);
|
|
363
|
+
stripeCustomer = await client.customers.create(customerParams);
|
|
364
|
+
|
|
365
|
+
// Call onCustomerCreate callback only for newly created customers
|
|
366
|
+
await options.organization?.onCustomerCreate?.(
|
|
367
|
+
{
|
|
368
|
+
stripeCustomer,
|
|
369
|
+
organization: {
|
|
370
|
+
...org,
|
|
371
|
+
stripeCustomerId: stripeCustomer.id,
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
ctx,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
await ctx.context.adapter.update({
|
|
379
|
+
model: "organization",
|
|
380
|
+
update: {
|
|
381
|
+
stripeCustomerId: stripeCustomer.id,
|
|
382
|
+
},
|
|
383
|
+
where: [
|
|
384
|
+
{
|
|
385
|
+
field: "id",
|
|
386
|
+
value: org.id,
|
|
387
|
+
},
|
|
388
|
+
],
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
customerId = stripeCustomer.id;
|
|
392
|
+
} catch (e: any) {
|
|
393
|
+
ctx.context.logger.error(e);
|
|
394
|
+
throw APIError.from(
|
|
395
|
+
"BAD_REQUEST",
|
|
396
|
+
STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
} else {
|
|
402
|
+
// User subscription - get customer ID from user
|
|
403
|
+
customerId =
|
|
404
|
+
subscriptionToUpdate?.stripeCustomerId || user.stripeCustomerId;
|
|
405
|
+
if (!customerId) {
|
|
406
|
+
try {
|
|
407
|
+
// Try to find existing user Stripe customer by email
|
|
408
|
+
const existingCustomers = await client.customers.search({
|
|
409
|
+
query: `email:"${escapeStripeSearchValue(user.email)}" AND -metadata["customerType"]:"organization"`,
|
|
410
|
+
limit: 1,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
let stripeCustomer = existingCustomers.data[0];
|
|
414
|
+
|
|
415
|
+
if (!stripeCustomer) {
|
|
416
|
+
stripeCustomer = await client.customers.create({
|
|
417
|
+
email: user.email,
|
|
418
|
+
name: user.name,
|
|
419
|
+
metadata: {
|
|
420
|
+
...ctx.body.metadata,
|
|
421
|
+
userId: user.id,
|
|
422
|
+
customerType: "user",
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Update local DB with Stripe customer ID
|
|
428
|
+
await ctx.context.adapter.update({
|
|
429
|
+
model: "user",
|
|
430
|
+
update: {
|
|
431
|
+
stripeCustomerId: stripeCustomer.id,
|
|
432
|
+
},
|
|
433
|
+
where: [
|
|
434
|
+
{
|
|
435
|
+
field: "id",
|
|
436
|
+
value: user.id,
|
|
437
|
+
},
|
|
438
|
+
],
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
customerId = stripeCustomer.id;
|
|
442
|
+
} catch (e: any) {
|
|
443
|
+
ctx.context.logger.error(e);
|
|
444
|
+
throw APIError.from(
|
|
445
|
+
"BAD_REQUEST",
|
|
446
|
+
STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
251
449
|
}
|
|
252
450
|
}
|
|
253
451
|
|
|
@@ -258,24 +456,20 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
258
456
|
where: [
|
|
259
457
|
{
|
|
260
458
|
field: "referenceId",
|
|
261
|
-
value:
|
|
459
|
+
value: referenceId,
|
|
262
460
|
},
|
|
263
461
|
],
|
|
264
462
|
});
|
|
265
463
|
|
|
266
|
-
const activeOrTrialingSubscription = subscriptions.find(
|
|
267
|
-
(sub)
|
|
464
|
+
const activeOrTrialingSubscription = subscriptions.find((sub) =>
|
|
465
|
+
isActiveOrTrialing(sub),
|
|
268
466
|
);
|
|
269
467
|
|
|
270
468
|
const activeSubscriptions = await client.subscriptions
|
|
271
469
|
.list({
|
|
272
470
|
customer: customerId,
|
|
273
471
|
})
|
|
274
|
-
.then((res) =>
|
|
275
|
-
res.data.filter(
|
|
276
|
-
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
277
|
-
),
|
|
278
|
-
);
|
|
472
|
+
.then((res) => res.data.filter((sub) => isActiveOrTrialing(sub)));
|
|
279
473
|
|
|
280
474
|
const activeSubscription = activeSubscriptions.find((sub) => {
|
|
281
475
|
// If we have a specific subscription to update, match by ID
|
|
@@ -326,7 +520,7 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
326
520
|
|
|
327
521
|
// If no database record exists for this Stripe subscription, update the existing one
|
|
328
522
|
if (!dbSubscription && activeOrTrialingSubscription) {
|
|
329
|
-
await ctx.context.adapter.update<
|
|
523
|
+
await ctx.context.adapter.update<Subscription>({
|
|
330
524
|
model: "subscription",
|
|
331
525
|
update: {
|
|
332
526
|
stripeSubscriptionId: activeSubscription.id,
|
|
@@ -400,7 +594,7 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
400
594
|
});
|
|
401
595
|
return ctx.json({
|
|
402
596
|
url,
|
|
403
|
-
redirect:
|
|
597
|
+
redirect: !ctx.body.disableRedirect,
|
|
404
598
|
});
|
|
405
599
|
}
|
|
406
600
|
|
|
@@ -408,7 +602,7 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
408
602
|
activeOrTrialingSubscription || incompleteSubscription;
|
|
409
603
|
|
|
410
604
|
if (incompleteSubscription && !activeOrTrialingSubscription) {
|
|
411
|
-
const updated = await ctx.context.adapter.update<
|
|
605
|
+
const updated = await ctx.context.adapter.update<Subscription>({
|
|
412
606
|
model: "subscription",
|
|
413
607
|
update: {
|
|
414
608
|
plan: plan.name.toLowerCase(),
|
|
@@ -426,10 +620,7 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
426
620
|
}
|
|
427
621
|
|
|
428
622
|
if (!subscription) {
|
|
429
|
-
subscription = await ctx.context.adapter.create<
|
|
430
|
-
InputSubscription,
|
|
431
|
-
Subscription
|
|
432
|
-
>({
|
|
623
|
+
subscription = await ctx.context.adapter.create<Subscription>({
|
|
433
624
|
model: "subscription",
|
|
434
625
|
data: {
|
|
435
626
|
plan: plan.name.toLowerCase(),
|
|
@@ -443,7 +634,10 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
443
634
|
|
|
444
635
|
if (!subscription) {
|
|
445
636
|
ctx.context.logger.error("Subscription ID not found");
|
|
446
|
-
throw
|
|
637
|
+
throw APIError.from(
|
|
638
|
+
"NOT_FOUND",
|
|
639
|
+
STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
640
|
+
);
|
|
447
641
|
}
|
|
448
642
|
|
|
449
643
|
const params = await subscriptionOptions.getCheckoutSessionParams?.(
|
|
@@ -457,7 +651,13 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
457
651
|
ctx,
|
|
458
652
|
);
|
|
459
653
|
|
|
460
|
-
const
|
|
654
|
+
const allSubscriptions = await ctx.context.adapter.findMany<Subscription>(
|
|
655
|
+
{
|
|
656
|
+
model: "subscription",
|
|
657
|
+
where: [{ field: "referenceId", value: referenceId }],
|
|
658
|
+
},
|
|
659
|
+
);
|
|
660
|
+
const hasEverTrialed = allSubscriptions.some((s) => {
|
|
461
661
|
// Check if user has ever had a trial for any plan (not just the same plan)
|
|
462
662
|
// This prevents users from getting multiple trials by switching plans
|
|
463
663
|
const hadTrial =
|
|
@@ -494,13 +694,13 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
494
694
|
...(customerId
|
|
495
695
|
? {
|
|
496
696
|
customer: customerId,
|
|
497
|
-
customer_update:
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
697
|
+
customer_update:
|
|
698
|
+
customerType !== "user"
|
|
699
|
+
? ({ address: "auto" } as const)
|
|
700
|
+
: ({ name: "auto", address: "auto" } as const), // The customer name is automatically set only for users
|
|
501
701
|
}
|
|
502
702
|
: {
|
|
503
|
-
customer_email:
|
|
703
|
+
customer_email: user.email,
|
|
504
704
|
}),
|
|
505
705
|
success_url: getUrl(
|
|
506
706
|
ctx,
|
|
@@ -519,15 +719,23 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
519
719
|
],
|
|
520
720
|
subscription_data: {
|
|
521
721
|
...freeTrial,
|
|
722
|
+
metadata: {
|
|
723
|
+
...ctx.body.metadata,
|
|
724
|
+
...params?.params?.subscription_data?.metadata,
|
|
725
|
+
userId: user.id,
|
|
726
|
+
subscriptionId: subscription.id,
|
|
727
|
+
referenceId,
|
|
728
|
+
},
|
|
522
729
|
},
|
|
523
730
|
mode: "subscription",
|
|
524
731
|
client_reference_id: referenceId,
|
|
525
732
|
...params?.params,
|
|
526
733
|
metadata: {
|
|
734
|
+
...ctx.body.metadata,
|
|
735
|
+
...params?.params?.metadata,
|
|
527
736
|
userId: user.id,
|
|
528
737
|
subscriptionId: subscription.id,
|
|
529
738
|
referenceId,
|
|
530
|
-
...params?.params?.metadata,
|
|
531
739
|
},
|
|
532
740
|
},
|
|
533
741
|
params?.options,
|
|
@@ -569,9 +777,7 @@ export const cancelSubscriptionCallback = (options: StripeOptions) => {
|
|
|
569
777
|
if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) {
|
|
570
778
|
throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
|
|
571
779
|
}
|
|
572
|
-
const session = await getSessionFromCtx<
|
|
573
|
-
ctx,
|
|
574
|
-
);
|
|
780
|
+
const session = await getSessionFromCtx<User & WithStripeCustomerId>(ctx);
|
|
575
781
|
if (!session) {
|
|
576
782
|
throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
|
|
577
783
|
}
|
|
@@ -591,8 +797,8 @@ export const cancelSubscriptionCallback = (options: StripeOptions) => {
|
|
|
591
797
|
});
|
|
592
798
|
if (
|
|
593
799
|
!subscription ||
|
|
594
|
-
subscription.
|
|
595
|
-
subscription
|
|
800
|
+
subscription.status === "canceled" ||
|
|
801
|
+
isPendingCancel(subscription)
|
|
596
802
|
) {
|
|
597
803
|
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
598
804
|
}
|
|
@@ -604,12 +810,24 @@ export const cancelSubscriptionCallback = (options: StripeOptions) => {
|
|
|
604
810
|
const currentSubscription = stripeSubscription.data.find(
|
|
605
811
|
(sub) => sub.id === subscription.stripeSubscriptionId,
|
|
606
812
|
);
|
|
607
|
-
|
|
813
|
+
|
|
814
|
+
const isNewCancellation =
|
|
815
|
+
currentSubscription &&
|
|
816
|
+
isStripePendingCancel(currentSubscription) &&
|
|
817
|
+
!isPendingCancel(subscription);
|
|
818
|
+
if (isNewCancellation) {
|
|
608
819
|
await ctx.context.adapter.update({
|
|
609
820
|
model: "subscription",
|
|
610
821
|
update: {
|
|
611
822
|
status: currentSubscription?.status,
|
|
612
|
-
cancelAtPeriodEnd:
|
|
823
|
+
cancelAtPeriodEnd:
|
|
824
|
+
currentSubscription?.cancel_at_period_end || false,
|
|
825
|
+
cancelAt: currentSubscription?.cancel_at
|
|
826
|
+
? new Date(currentSubscription.cancel_at * 1000)
|
|
827
|
+
: null,
|
|
828
|
+
canceledAt: currentSubscription?.canceled_at
|
|
829
|
+
? new Date(currentSubscription.canceled_at * 1000)
|
|
830
|
+
: null,
|
|
613
831
|
},
|
|
614
832
|
where: [
|
|
615
833
|
{
|
|
@@ -651,10 +869,32 @@ const cancelSubscriptionBodySchema = z.object({
|
|
|
651
869
|
"The Stripe subscription ID to cancel. Eg: 'sub_1ABC2DEF3GHI4JKL'",
|
|
652
870
|
})
|
|
653
871
|
.optional(),
|
|
872
|
+
/**
|
|
873
|
+
* Customer type for the subscription.
|
|
874
|
+
* - `user`: User owns the subscription (default)
|
|
875
|
+
* - `organization`: Organization owns the subscription
|
|
876
|
+
*/
|
|
877
|
+
customerType: z
|
|
878
|
+
.enum(["user", "organization"])
|
|
879
|
+
.meta({
|
|
880
|
+
description:
|
|
881
|
+
'Customer type for the subscription. Eg: "user" or "organization"',
|
|
882
|
+
})
|
|
883
|
+
.optional(),
|
|
654
884
|
returnUrl: z.string().meta({
|
|
655
885
|
description:
|
|
656
886
|
'URL to take customers to when they click on the billing portal\'s link to return to your website. Eg: "/account"',
|
|
657
887
|
}),
|
|
888
|
+
/**
|
|
889
|
+
* Disable Redirect
|
|
890
|
+
*/
|
|
891
|
+
disableRedirect: z
|
|
892
|
+
.boolean()
|
|
893
|
+
.meta({
|
|
894
|
+
description:
|
|
895
|
+
"Disable redirect after successful subscription cancellation. Eg: true",
|
|
896
|
+
})
|
|
897
|
+
.default(false),
|
|
658
898
|
});
|
|
659
899
|
|
|
660
900
|
/**
|
|
@@ -686,13 +926,17 @@ export const cancelSubscription = (options: StripeOptions) => {
|
|
|
686
926
|
},
|
|
687
927
|
},
|
|
688
928
|
use: [
|
|
689
|
-
|
|
690
|
-
originCheck((ctx) => ctx.body.returnUrl),
|
|
929
|
+
stripeSessionMiddleware,
|
|
691
930
|
referenceMiddleware(subscriptionOptions, "cancel-subscription"),
|
|
931
|
+
originCheck((ctx) => ctx.body.returnUrl),
|
|
692
932
|
],
|
|
693
933
|
},
|
|
694
934
|
async (ctx) => {
|
|
695
|
-
const
|
|
935
|
+
const customerType = ctx.body.customerType || "user";
|
|
936
|
+
const referenceId =
|
|
937
|
+
ctx.body.referenceId ||
|
|
938
|
+
getReferenceId(ctx.context.session, customerType, options);
|
|
939
|
+
|
|
696
940
|
let subscription = ctx.body.subscriptionId
|
|
697
941
|
? await ctx.context.adapter.findOne<Subscription>({
|
|
698
942
|
model: "subscription",
|
|
@@ -708,13 +952,7 @@ export const cancelSubscription = (options: StripeOptions) => {
|
|
|
708
952
|
model: "subscription",
|
|
709
953
|
where: [{ field: "referenceId", value: referenceId }],
|
|
710
954
|
})
|
|
711
|
-
.then((subs) =>
|
|
712
|
-
subs.find(
|
|
713
|
-
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
714
|
-
),
|
|
715
|
-
);
|
|
716
|
-
|
|
717
|
-
// Ensure the specified subscription belongs to the (validated) referenceId.
|
|
955
|
+
.then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
|
|
718
956
|
if (
|
|
719
957
|
ctx.body.subscriptionId &&
|
|
720
958
|
subscription &&
|
|
@@ -733,11 +971,7 @@ export const cancelSubscription = (options: StripeOptions) => {
|
|
|
733
971
|
.list({
|
|
734
972
|
customer: subscription.stripeCustomerId,
|
|
735
973
|
})
|
|
736
|
-
.then((res) =>
|
|
737
|
-
res.data.filter(
|
|
738
|
-
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
739
|
-
),
|
|
740
|
-
);
|
|
974
|
+
.then((res) => res.data.filter((sub) => isActiveOrTrialing(sub)));
|
|
741
975
|
if (!activeSubscriptions.length) {
|
|
742
976
|
/**
|
|
743
977
|
* If the subscription is not found, we need to delete the subscription
|
|
@@ -785,21 +1019,30 @@ export const cancelSubscription = (options: StripeOptions) => {
|
|
|
785
1019
|
},
|
|
786
1020
|
})
|
|
787
1021
|
.catch(async (e) => {
|
|
788
|
-
if (e.message
|
|
1022
|
+
if (e.message?.includes("already set to be canceled")) {
|
|
789
1023
|
/**
|
|
790
|
-
* in-case we missed the event from stripe, we
|
|
1024
|
+
* in-case we missed the event from stripe, we sync the actual state
|
|
791
1025
|
* this is a rare case and should not happen
|
|
792
1026
|
*/
|
|
793
|
-
if (!subscription
|
|
794
|
-
await
|
|
1027
|
+
if (!isPendingCancel(subscription)) {
|
|
1028
|
+
const stripeSub = await client.subscriptions.retrieve(
|
|
1029
|
+
activeSubscription.id,
|
|
1030
|
+
);
|
|
1031
|
+
await ctx.context.adapter.update({
|
|
795
1032
|
model: "subscription",
|
|
796
1033
|
update: {
|
|
797
|
-
cancelAtPeriodEnd:
|
|
1034
|
+
cancelAtPeriodEnd: stripeSub.cancel_at_period_end,
|
|
1035
|
+
cancelAt: stripeSub.cancel_at
|
|
1036
|
+
? new Date(stripeSub.cancel_at * 1000)
|
|
1037
|
+
: null,
|
|
1038
|
+
canceledAt: stripeSub.canceled_at
|
|
1039
|
+
? new Date(stripeSub.canceled_at * 1000)
|
|
1040
|
+
: null,
|
|
798
1041
|
},
|
|
799
1042
|
where: [
|
|
800
1043
|
{
|
|
801
|
-
field: "
|
|
802
|
-
value:
|
|
1044
|
+
field: "id",
|
|
1045
|
+
value: subscription.id,
|
|
803
1046
|
},
|
|
804
1047
|
],
|
|
805
1048
|
});
|
|
@@ -810,10 +1053,10 @@ export const cancelSubscription = (options: StripeOptions) => {
|
|
|
810
1053
|
code: e.code,
|
|
811
1054
|
});
|
|
812
1055
|
});
|
|
813
|
-
return {
|
|
1056
|
+
return ctx.json({
|
|
814
1057
|
url,
|
|
815
|
-
redirect:
|
|
816
|
-
};
|
|
1058
|
+
redirect: !ctx.body.disableRedirect,
|
|
1059
|
+
});
|
|
817
1060
|
},
|
|
818
1061
|
);
|
|
819
1062
|
};
|
|
@@ -832,6 +1075,18 @@ const restoreSubscriptionBodySchema = z.object({
|
|
|
832
1075
|
"The Stripe subscription ID to restore. Eg: 'sub_1ABC2DEF3GHI4JKL'",
|
|
833
1076
|
})
|
|
834
1077
|
.optional(),
|
|
1078
|
+
/**
|
|
1079
|
+
* Customer type for the subscription.
|
|
1080
|
+
* - `user`: User owns the subscription (default)
|
|
1081
|
+
* - `organization`: Organization owns the subscription
|
|
1082
|
+
*/
|
|
1083
|
+
customerType: z
|
|
1084
|
+
.enum(["user", "organization"])
|
|
1085
|
+
.meta({
|
|
1086
|
+
description:
|
|
1087
|
+
'Customer type for the subscription. Eg: "user" or "organization"',
|
|
1088
|
+
})
|
|
1089
|
+
.optional(),
|
|
835
1090
|
});
|
|
836
1091
|
|
|
837
1092
|
export const restoreSubscription = (options: StripeOptions) => {
|
|
@@ -848,12 +1103,15 @@ export const restoreSubscription = (options: StripeOptions) => {
|
|
|
848
1103
|
},
|
|
849
1104
|
},
|
|
850
1105
|
use: [
|
|
851
|
-
|
|
1106
|
+
stripeSessionMiddleware,
|
|
852
1107
|
referenceMiddleware(subscriptionOptions, "restore-subscription"),
|
|
853
1108
|
],
|
|
854
1109
|
},
|
|
855
1110
|
async (ctx) => {
|
|
856
|
-
const
|
|
1111
|
+
const customerType = ctx.body.customerType || "user";
|
|
1112
|
+
const referenceId =
|
|
1113
|
+
ctx.body.referenceId ||
|
|
1114
|
+
getReferenceId(ctx.context.session, customerType, options);
|
|
857
1115
|
|
|
858
1116
|
let subscription = ctx.body.subscriptionId
|
|
859
1117
|
? await ctx.context.adapter.findOne<Subscription>({
|
|
@@ -875,11 +1133,7 @@ export const restoreSubscription = (options: StripeOptions) => {
|
|
|
875
1133
|
},
|
|
876
1134
|
],
|
|
877
1135
|
})
|
|
878
|
-
.then((subs) =>
|
|
879
|
-
subs.find(
|
|
880
|
-
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
881
|
-
),
|
|
882
|
-
);
|
|
1136
|
+
.then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
|
|
883
1137
|
if (
|
|
884
1138
|
ctx.body.subscriptionId &&
|
|
885
1139
|
subscription &&
|
|
@@ -893,16 +1147,13 @@ export const restoreSubscription = (options: StripeOptions) => {
|
|
|
893
1147
|
STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
894
1148
|
);
|
|
895
1149
|
}
|
|
896
|
-
if (
|
|
897
|
-
subscription.status != "active" &&
|
|
898
|
-
subscription.status != "trialing"
|
|
899
|
-
) {
|
|
1150
|
+
if (!isActiveOrTrialing(subscription)) {
|
|
900
1151
|
throw APIError.from(
|
|
901
1152
|
"BAD_REQUEST",
|
|
902
1153
|
STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_ACTIVE,
|
|
903
1154
|
);
|
|
904
1155
|
}
|
|
905
|
-
if (!subscription
|
|
1156
|
+
if (!isPendingCancel(subscription)) {
|
|
906
1157
|
throw APIError.from(
|
|
907
1158
|
"BAD_REQUEST",
|
|
908
1159
|
STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION,
|
|
@@ -913,12 +1164,7 @@ export const restoreSubscription = (options: StripeOptions) => {
|
|
|
913
1164
|
.list({
|
|
914
1165
|
customer: subscription.stripeCustomerId,
|
|
915
1166
|
})
|
|
916
|
-
.then(
|
|
917
|
-
(res) =>
|
|
918
|
-
res.data.filter(
|
|
919
|
-
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
920
|
-
)[0],
|
|
921
|
-
);
|
|
1167
|
+
.then((res) => res.data.filter((sub) => isActiveOrTrialing(sub))[0]);
|
|
922
1168
|
if (!activeSubscription) {
|
|
923
1169
|
throw APIError.from(
|
|
924
1170
|
"BAD_REQUEST",
|
|
@@ -926,36 +1172,41 @@ export const restoreSubscription = (options: StripeOptions) => {
|
|
|
926
1172
|
);
|
|
927
1173
|
}
|
|
928
1174
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1175
|
+
// Clear scheduled cancellation based on Stripe subscription state
|
|
1176
|
+
// Note: Stripe doesn't accept both `cancel_at` and `cancel_at_period_end` simultaneously
|
|
1177
|
+
const updateParams: Stripe.SubscriptionUpdateParams = {};
|
|
1178
|
+
if (activeSubscription.cancel_at) {
|
|
1179
|
+
updateParams.cancel_at = "";
|
|
1180
|
+
} else if (activeSubscription.cancel_at_period_end) {
|
|
1181
|
+
updateParams.cancel_at_period_end = false;
|
|
1182
|
+
}
|
|
936
1183
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
{
|
|
945
|
-
field: "id",
|
|
946
|
-
value: subscription.id,
|
|
947
|
-
},
|
|
948
|
-
],
|
|
1184
|
+
const newSub = await client.subscriptions
|
|
1185
|
+
.update(activeSubscription.id, updateParams)
|
|
1186
|
+
.catch((e) => {
|
|
1187
|
+
throw ctx.error("BAD_REQUEST", {
|
|
1188
|
+
message: e.message,
|
|
1189
|
+
code: e.code,
|
|
1190
|
+
});
|
|
949
1191
|
});
|
|
950
1192
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1193
|
+
await ctx.context.adapter.update({
|
|
1194
|
+
model: "subscription",
|
|
1195
|
+
update: {
|
|
1196
|
+
cancelAtPeriodEnd: false,
|
|
1197
|
+
cancelAt: null,
|
|
1198
|
+
canceledAt: null,
|
|
1199
|
+
updatedAt: new Date(),
|
|
1200
|
+
},
|
|
1201
|
+
where: [
|
|
1202
|
+
{
|
|
1203
|
+
field: "id",
|
|
1204
|
+
value: subscription.id,
|
|
1205
|
+
},
|
|
1206
|
+
],
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
return ctx.json(newSub);
|
|
959
1210
|
},
|
|
960
1211
|
);
|
|
961
1212
|
};
|
|
@@ -968,6 +1219,18 @@ const listActiveSubscriptionsQuerySchema = z.optional(
|
|
|
968
1219
|
description: "Reference id of the subscription to list. Eg: '123'",
|
|
969
1220
|
})
|
|
970
1221
|
.optional(),
|
|
1222
|
+
/**
|
|
1223
|
+
* Customer type for the subscription.
|
|
1224
|
+
* - `user`: User owns the subscription (default)
|
|
1225
|
+
* - `organization`: Organization owns the subscription
|
|
1226
|
+
*/
|
|
1227
|
+
customerType: z
|
|
1228
|
+
.enum(["user", "organization"])
|
|
1229
|
+
.meta({
|
|
1230
|
+
description:
|
|
1231
|
+
'Customer type for the subscription. Eg: "user" or "organization"',
|
|
1232
|
+
})
|
|
1233
|
+
.optional(),
|
|
971
1234
|
}),
|
|
972
1235
|
);
|
|
973
1236
|
/**
|
|
@@ -998,17 +1261,22 @@ export const listActiveSubscriptions = (options: StripeOptions) => {
|
|
|
998
1261
|
},
|
|
999
1262
|
},
|
|
1000
1263
|
use: [
|
|
1001
|
-
|
|
1264
|
+
stripeSessionMiddleware,
|
|
1002
1265
|
referenceMiddleware(subscriptionOptions, "list-subscription"),
|
|
1003
1266
|
],
|
|
1004
1267
|
},
|
|
1005
1268
|
async (ctx) => {
|
|
1269
|
+
const customerType = ctx.query?.customerType || "user";
|
|
1270
|
+
const referenceId =
|
|
1271
|
+
ctx.query?.referenceId ||
|
|
1272
|
+
getReferenceId(ctx.context.session, customerType, options);
|
|
1273
|
+
|
|
1006
1274
|
const subscriptions = await ctx.context.adapter.findMany<Subscription>({
|
|
1007
1275
|
model: "subscription",
|
|
1008
1276
|
where: [
|
|
1009
1277
|
{
|
|
1010
1278
|
field: "referenceId",
|
|
1011
|
-
value:
|
|
1279
|
+
value: referenceId,
|
|
1012
1280
|
},
|
|
1013
1281
|
],
|
|
1014
1282
|
});
|
|
@@ -1030,9 +1298,7 @@ export const listActiveSubscriptions = (options: StripeOptions) => {
|
|
|
1030
1298
|
priceId: plan?.priceId,
|
|
1031
1299
|
};
|
|
1032
1300
|
})
|
|
1033
|
-
.filter((sub) =>
|
|
1034
|
-
return sub.status === "active" || sub.status === "trialing";
|
|
1035
|
-
});
|
|
1301
|
+
.filter((sub) => isActiveOrTrialing(sub));
|
|
1036
1302
|
return ctx.json(subs);
|
|
1037
1303
|
},
|
|
1038
1304
|
);
|
|
@@ -1058,14 +1324,12 @@ export const subscriptionSuccess = (options: StripeOptions) => {
|
|
|
1058
1324
|
if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) {
|
|
1059
1325
|
throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
|
|
1060
1326
|
}
|
|
1061
|
-
const
|
|
1062
|
-
|
|
1063
|
-
);
|
|
1327
|
+
const { callbackURL, subscriptionId } = ctx.query;
|
|
1328
|
+
|
|
1329
|
+
const session = await getSessionFromCtx<User & WithStripeCustomerId>(ctx);
|
|
1064
1330
|
if (!session) {
|
|
1065
1331
|
throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
|
|
1066
1332
|
}
|
|
1067
|
-
const { user } = session;
|
|
1068
|
-
const { callbackURL, subscriptionId } = ctx.query;
|
|
1069
1333
|
|
|
1070
1334
|
const subscription = await ctx.context.adapter.findOne<Subscription>({
|
|
1071
1335
|
model: "subscription",
|
|
@@ -1076,74 +1340,89 @@ export const subscriptionSuccess = (options: StripeOptions) => {
|
|
|
1076
1340
|
},
|
|
1077
1341
|
],
|
|
1078
1342
|
});
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
return ctx.redirect(getUrl(ctx, callbackURL));
|
|
1343
|
+
if (!subscription) {
|
|
1344
|
+
ctx.context.logger.warn(
|
|
1345
|
+
`Subscription record not found for subscriptionId: ${subscriptionId}`,
|
|
1346
|
+
);
|
|
1347
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1085
1348
|
}
|
|
1086
|
-
const customerId =
|
|
1087
|
-
subscription?.stripeCustomerId || user.stripeCustomerId;
|
|
1088
1349
|
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
customer: customerId,
|
|
1094
|
-
status: "active",
|
|
1095
|
-
})
|
|
1096
|
-
.then((res) => res.data[0]);
|
|
1350
|
+
// Already active or trialing, no need to update
|
|
1351
|
+
if (isActiveOrTrialing(subscription)) {
|
|
1352
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1353
|
+
}
|
|
1097
1354
|
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
);
|
|
1355
|
+
const customerId =
|
|
1356
|
+
subscription.stripeCustomerId || session.user.stripeCustomerId;
|
|
1357
|
+
if (!customerId) {
|
|
1358
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1359
|
+
}
|
|
1104
1360
|
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
status: stripeSubscription.status,
|
|
1110
|
-
seats: stripeSubscription.items.data[0]?.quantity || 1,
|
|
1111
|
-
plan: plan.name.toLowerCase(),
|
|
1112
|
-
periodEnd: new Date(
|
|
1113
|
-
stripeSubscription.items.data[0]?.current_period_end! *
|
|
1114
|
-
1000,
|
|
1115
|
-
),
|
|
1116
|
-
periodStart: new Date(
|
|
1117
|
-
stripeSubscription.items.data[0]?.current_period_start! *
|
|
1118
|
-
1000,
|
|
1119
|
-
),
|
|
1120
|
-
stripeSubscriptionId: stripeSubscription.id,
|
|
1121
|
-
...(stripeSubscription.trial_start &&
|
|
1122
|
-
stripeSubscription.trial_end
|
|
1123
|
-
? {
|
|
1124
|
-
trialStart: new Date(
|
|
1125
|
-
stripeSubscription.trial_start * 1000,
|
|
1126
|
-
),
|
|
1127
|
-
trialEnd: new Date(stripeSubscription.trial_end * 1000),
|
|
1128
|
-
}
|
|
1129
|
-
: {}),
|
|
1130
|
-
},
|
|
1131
|
-
where: [
|
|
1132
|
-
{
|
|
1133
|
-
field: "id",
|
|
1134
|
-
value: subscription.id,
|
|
1135
|
-
},
|
|
1136
|
-
],
|
|
1137
|
-
});
|
|
1138
|
-
}
|
|
1139
|
-
}
|
|
1140
|
-
} catch (error) {
|
|
1361
|
+
const stripeSubscription = await client.subscriptions
|
|
1362
|
+
.list({ customer: customerId, status: "active" })
|
|
1363
|
+
.then((res) => res.data[0])
|
|
1364
|
+
.catch((error) => {
|
|
1141
1365
|
ctx.context.logger.error(
|
|
1142
1366
|
"Error fetching subscription from Stripe",
|
|
1143
1367
|
error,
|
|
1144
1368
|
);
|
|
1145
|
-
|
|
1369
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1370
|
+
});
|
|
1371
|
+
if (!stripeSubscription) {
|
|
1372
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
const subscriptionItem = stripeSubscription.items.data[0];
|
|
1376
|
+
if (!subscriptionItem) {
|
|
1377
|
+
ctx.context.logger.warn(
|
|
1378
|
+
`No subscription items found for Stripe subscription ${stripeSubscription.id}`,
|
|
1379
|
+
);
|
|
1380
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
const plan = await getPlanByPriceInfo(
|
|
1384
|
+
options,
|
|
1385
|
+
subscriptionItem.price.id,
|
|
1386
|
+
subscriptionItem.price.lookup_key,
|
|
1387
|
+
);
|
|
1388
|
+
if (!plan) {
|
|
1389
|
+
ctx.context.logger.warn(
|
|
1390
|
+
`Plan not found for price ${subscriptionItem.price.id}`,
|
|
1391
|
+
);
|
|
1392
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1146
1393
|
}
|
|
1394
|
+
|
|
1395
|
+
await ctx.context.adapter.update({
|
|
1396
|
+
model: "subscription",
|
|
1397
|
+
update: {
|
|
1398
|
+
status: stripeSubscription.status,
|
|
1399
|
+
seats: subscriptionItem.quantity || 1,
|
|
1400
|
+
plan: plan.name.toLowerCase(),
|
|
1401
|
+
periodEnd: new Date(subscriptionItem.current_period_end * 1000),
|
|
1402
|
+
periodStart: new Date(subscriptionItem.current_period_start * 1000),
|
|
1403
|
+
stripeSubscriptionId: stripeSubscription.id,
|
|
1404
|
+
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
|
|
1405
|
+
cancelAt: stripeSubscription.cancel_at
|
|
1406
|
+
? new Date(stripeSubscription.cancel_at * 1000)
|
|
1407
|
+
: null,
|
|
1408
|
+
canceledAt: stripeSubscription.canceled_at
|
|
1409
|
+
? new Date(stripeSubscription.canceled_at * 1000)
|
|
1410
|
+
: null,
|
|
1411
|
+
...(stripeSubscription.trial_start && stripeSubscription.trial_end
|
|
1412
|
+
? {
|
|
1413
|
+
trialStart: new Date(stripeSubscription.trial_start * 1000),
|
|
1414
|
+
trialEnd: new Date(stripeSubscription.trial_end * 1000),
|
|
1415
|
+
}
|
|
1416
|
+
: {}),
|
|
1417
|
+
},
|
|
1418
|
+
where: [
|
|
1419
|
+
{
|
|
1420
|
+
field: "id",
|
|
1421
|
+
value: subscription.id,
|
|
1422
|
+
},
|
|
1423
|
+
],
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1147
1426
|
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1148
1427
|
},
|
|
1149
1428
|
);
|
|
@@ -1156,7 +1435,29 @@ const createBillingPortalBodySchema = z.object({
|
|
|
1156
1435
|
})
|
|
1157
1436
|
.optional(),
|
|
1158
1437
|
referenceId: z.string().optional(),
|
|
1438
|
+
/**
|
|
1439
|
+
* Customer type for the subscription.
|
|
1440
|
+
* - `user`: User owns the subscription (default)
|
|
1441
|
+
* - `organization`: Organization owns the subscription
|
|
1442
|
+
*/
|
|
1443
|
+
customerType: z
|
|
1444
|
+
.enum(["user", "organization"])
|
|
1445
|
+
.meta({
|
|
1446
|
+
description:
|
|
1447
|
+
'Customer type for the subscription. Eg: "user" or "organization"',
|
|
1448
|
+
})
|
|
1449
|
+
.optional(),
|
|
1159
1450
|
returnUrl: z.string().default("/"),
|
|
1451
|
+
/**
|
|
1452
|
+
* Disable Redirect
|
|
1453
|
+
*/
|
|
1454
|
+
disableRedirect: z
|
|
1455
|
+
.boolean()
|
|
1456
|
+
.meta({
|
|
1457
|
+
description:
|
|
1458
|
+
"Disable redirect after creating billing portal session. Eg: true",
|
|
1459
|
+
})
|
|
1460
|
+
.default(false),
|
|
1160
1461
|
});
|
|
1161
1462
|
|
|
1162
1463
|
export const createBillingPortal = (options: StripeOptions) => {
|
|
@@ -1173,41 +1474,61 @@ export const createBillingPortal = (options: StripeOptions) => {
|
|
|
1173
1474
|
},
|
|
1174
1475
|
},
|
|
1175
1476
|
use: [
|
|
1176
|
-
|
|
1177
|
-
originCheck((ctx) => ctx.body.returnUrl),
|
|
1477
|
+
stripeSessionMiddleware,
|
|
1178
1478
|
referenceMiddleware(subscriptionOptions, "billing-portal"),
|
|
1479
|
+
originCheck((ctx) => ctx.body.returnUrl),
|
|
1179
1480
|
],
|
|
1180
1481
|
},
|
|
1181
1482
|
async (ctx) => {
|
|
1182
1483
|
const { user } = ctx.context.session;
|
|
1183
|
-
const
|
|
1484
|
+
const customerType = ctx.body.customerType || "user";
|
|
1485
|
+
const referenceId =
|
|
1486
|
+
ctx.body.referenceId ||
|
|
1487
|
+
getReferenceId(ctx.context.session, customerType, options);
|
|
1184
1488
|
|
|
1185
|
-
let customerId
|
|
1489
|
+
let customerId: string | undefined;
|
|
1186
1490
|
|
|
1187
|
-
if (
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
],
|
|
1197
|
-
})
|
|
1198
|
-
.then((subs) =>
|
|
1199
|
-
subs.find(
|
|
1200
|
-
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
1201
|
-
),
|
|
1202
|
-
);
|
|
1491
|
+
if (customerType === "organization") {
|
|
1492
|
+
// Organization billing portal - get customer ID from organization
|
|
1493
|
+
const org = await ctx.context.adapter.findOne<
|
|
1494
|
+
Organization & WithStripeCustomerId
|
|
1495
|
+
>({
|
|
1496
|
+
model: "organization",
|
|
1497
|
+
where: [{ field: "id", value: referenceId }],
|
|
1498
|
+
});
|
|
1499
|
+
customerId = org?.stripeCustomerId;
|
|
1203
1500
|
|
|
1204
|
-
customerId
|
|
1205
|
-
|
|
1501
|
+
if (!customerId) {
|
|
1502
|
+
// Fallback to subscription's stripeCustomerId
|
|
1503
|
+
const subscription = await ctx.context.adapter
|
|
1504
|
+
.findMany<Subscription>({
|
|
1505
|
+
model: "subscription",
|
|
1506
|
+
where: [{ field: "referenceId", value: referenceId }],
|
|
1507
|
+
})
|
|
1508
|
+
.then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
|
|
1509
|
+
customerId = subscription?.stripeCustomerId;
|
|
1510
|
+
}
|
|
1511
|
+
} else {
|
|
1512
|
+
// User billing portal
|
|
1513
|
+
customerId = user.stripeCustomerId;
|
|
1514
|
+
if (!customerId) {
|
|
1515
|
+
const subscription = await ctx.context.adapter
|
|
1516
|
+
.findMany<Subscription>({
|
|
1517
|
+
model: "subscription",
|
|
1518
|
+
where: [
|
|
1519
|
+
{
|
|
1520
|
+
field: "referenceId",
|
|
1521
|
+
value: referenceId,
|
|
1522
|
+
},
|
|
1523
|
+
],
|
|
1524
|
+
})
|
|
1525
|
+
.then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
|
|
1206
1526
|
|
|
1527
|
+
customerId = subscription?.stripeCustomerId;
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1207
1530
|
if (!customerId) {
|
|
1208
|
-
throw
|
|
1209
|
-
message: "No Stripe customer found for this user",
|
|
1210
|
-
});
|
|
1531
|
+
throw APIError.from("NOT_FOUND", STRIPE_ERROR_CODES.CUSTOMER_NOT_FOUND);
|
|
1211
1532
|
}
|
|
1212
1533
|
|
|
1213
1534
|
try {
|
|
@@ -1219,16 +1540,17 @@ export const createBillingPortal = (options: StripeOptions) => {
|
|
|
1219
1540
|
|
|
1220
1541
|
return ctx.json({
|
|
1221
1542
|
url,
|
|
1222
|
-
redirect:
|
|
1543
|
+
redirect: !ctx.body.disableRedirect,
|
|
1223
1544
|
});
|
|
1224
1545
|
} catch (error: any) {
|
|
1225
1546
|
ctx.context.logger.error(
|
|
1226
1547
|
"Error creating billing portal session",
|
|
1227
1548
|
error,
|
|
1228
1549
|
);
|
|
1229
|
-
throw
|
|
1230
|
-
|
|
1231
|
-
|
|
1550
|
+
throw APIError.from(
|
|
1551
|
+
"INTERNAL_SERVER_ERROR",
|
|
1552
|
+
STRIPE_ERROR_CODES.UNABLE_TO_CREATE_BILLING_PORTAL,
|
|
1553
|
+
);
|
|
1232
1554
|
}
|
|
1233
1555
|
},
|
|
1234
1556
|
);
|
|
@@ -1247,45 +1569,60 @@ export const stripeWebhook = (options: StripeOptions) => {
|
|
|
1247
1569
|
},
|
|
1248
1570
|
},
|
|
1249
1571
|
cloneRequest: true,
|
|
1250
|
-
//
|
|
1251
|
-
disableBody: true,
|
|
1572
|
+
disableBody: true, // Don't parse the body
|
|
1252
1573
|
},
|
|
1253
1574
|
async (ctx) => {
|
|
1254
1575
|
if (!ctx.request?.body) {
|
|
1255
|
-
throw
|
|
1576
|
+
throw APIError.from(
|
|
1577
|
+
"BAD_REQUEST",
|
|
1578
|
+
STRIPE_ERROR_CODES.INVALID_REQUEST_BODY,
|
|
1579
|
+
);
|
|
1256
1580
|
}
|
|
1257
|
-
|
|
1258
|
-
const sig = ctx.request.headers.get("stripe-signature")
|
|
1581
|
+
|
|
1582
|
+
const sig = ctx.request.headers.get("stripe-signature");
|
|
1583
|
+
if (!sig) {
|
|
1584
|
+
throw APIError.from(
|
|
1585
|
+
"BAD_REQUEST",
|
|
1586
|
+
STRIPE_ERROR_CODES.STRIPE_SIGNATURE_NOT_FOUND,
|
|
1587
|
+
);
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1259
1590
|
const webhookSecret = options.stripeWebhookSecret;
|
|
1591
|
+
if (!webhookSecret) {
|
|
1592
|
+
throw APIError.from(
|
|
1593
|
+
"INTERNAL_SERVER_ERROR",
|
|
1594
|
+
STRIPE_ERROR_CODES.STRIPE_WEBHOOK_SECRET_NOT_FOUND,
|
|
1595
|
+
);
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
const payload = await ctx.request.text();
|
|
1599
|
+
|
|
1260
1600
|
let event: Stripe.Event;
|
|
1261
1601
|
try {
|
|
1262
|
-
if (!sig || !webhookSecret) {
|
|
1263
|
-
throw new APIError("BAD_REQUEST", {
|
|
1264
|
-
message: "Stripe webhook secret not found",
|
|
1265
|
-
});
|
|
1266
|
-
}
|
|
1267
1602
|
// Support both Stripe v18 (constructEvent) and v19+ (constructEventAsync)
|
|
1268
1603
|
if (typeof client.webhooks.constructEventAsync === "function") {
|
|
1269
1604
|
// Stripe v19+ - use async method
|
|
1270
1605
|
event = await client.webhooks.constructEventAsync(
|
|
1271
|
-
|
|
1606
|
+
payload,
|
|
1272
1607
|
sig,
|
|
1273
1608
|
webhookSecret,
|
|
1274
1609
|
);
|
|
1275
1610
|
} else {
|
|
1276
1611
|
// Stripe v18 - use sync method
|
|
1277
|
-
event = client.webhooks.constructEvent(
|
|
1612
|
+
event = client.webhooks.constructEvent(payload, sig, webhookSecret);
|
|
1278
1613
|
}
|
|
1279
1614
|
} catch (err: any) {
|
|
1280
1615
|
ctx.context.logger.error(`${err.message}`);
|
|
1281
|
-
throw
|
|
1282
|
-
|
|
1283
|
-
|
|
1616
|
+
throw APIError.from(
|
|
1617
|
+
"BAD_REQUEST",
|
|
1618
|
+
STRIPE_ERROR_CODES.FAILED_TO_CONSTRUCT_STRIPE_EVENT,
|
|
1619
|
+
);
|
|
1284
1620
|
}
|
|
1285
1621
|
if (!event) {
|
|
1286
|
-
throw
|
|
1287
|
-
|
|
1288
|
-
|
|
1622
|
+
throw APIError.from(
|
|
1623
|
+
"BAD_REQUEST",
|
|
1624
|
+
STRIPE_ERROR_CODES.FAILED_TO_CONSTRUCT_STRIPE_EVENT,
|
|
1625
|
+
);
|
|
1289
1626
|
}
|
|
1290
1627
|
try {
|
|
1291
1628
|
switch (event.type) {
|
|
@@ -1293,6 +1630,10 @@ export const stripeWebhook = (options: StripeOptions) => {
|
|
|
1293
1630
|
await onCheckoutSessionCompleted(ctx, options, event);
|
|
1294
1631
|
await options.onEvent?.(event);
|
|
1295
1632
|
break;
|
|
1633
|
+
case "customer.subscription.created":
|
|
1634
|
+
await onSubscriptionCreated(ctx, options, event);
|
|
1635
|
+
await options.onEvent?.(event);
|
|
1636
|
+
break;
|
|
1296
1637
|
case "customer.subscription.updated":
|
|
1297
1638
|
await onSubscriptionUpdated(ctx, options, event);
|
|
1298
1639
|
await options.onEvent?.(event);
|
|
@@ -1307,33 +1648,12 @@ export const stripeWebhook = (options: StripeOptions) => {
|
|
|
1307
1648
|
}
|
|
1308
1649
|
} catch (e: any) {
|
|
1309
1650
|
ctx.context.logger.error(`Stripe webhook failed. Error: ${e.message}`);
|
|
1310
|
-
throw
|
|
1311
|
-
|
|
1312
|
-
|
|
1651
|
+
throw APIError.from(
|
|
1652
|
+
"BAD_REQUEST",
|
|
1653
|
+
STRIPE_ERROR_CODES.STRIPE_WEBHOOK_ERROR,
|
|
1654
|
+
);
|
|
1313
1655
|
}
|
|
1314
1656
|
return ctx.json({ success: true });
|
|
1315
1657
|
},
|
|
1316
1658
|
);
|
|
1317
1659
|
};
|
|
1318
|
-
|
|
1319
|
-
const getUrl = (ctx: GenericEndpointContext, url: string) => {
|
|
1320
|
-
if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(url)) {
|
|
1321
|
-
return url;
|
|
1322
|
-
}
|
|
1323
|
-
return `${ctx.context.options.baseURL}${
|
|
1324
|
-
url.startsWith("/") ? url : `/${url}`
|
|
1325
|
-
}`;
|
|
1326
|
-
};
|
|
1327
|
-
|
|
1328
|
-
async function resolvePriceIdFromLookupKey(
|
|
1329
|
-
stripeClient: Stripe,
|
|
1330
|
-
lookupKey: string,
|
|
1331
|
-
): Promise<string | undefined> {
|
|
1332
|
-
if (!lookupKey) return undefined;
|
|
1333
|
-
const prices = await stripeClient.prices.list({
|
|
1334
|
-
lookup_keys: [lookupKey],
|
|
1335
|
-
active: true,
|
|
1336
|
-
limit: 1,
|
|
1337
|
-
});
|
|
1338
|
-
return prices.data[0]?.id;
|
|
1339
|
-
}
|