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