@better-auth/stripe 1.5.0-beta.1 → 1.5.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +15 -10
- package/LICENSE.md +15 -12
- package/dist/client.d.mts +47 -66
- package/dist/client.mjs +7 -3
- package/dist/client.mjs.map +1 -0
- package/dist/error-codes-CMowBCzF.mjs +30 -0
- package/dist/error-codes-CMowBCzF.mjs.map +1 -0
- package/dist/{index-DpiQGYLJ.d.mts → index-D9Pr9jIc.d.mts} +371 -188
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +720 -293
- package/dist/index.mjs.map +1 -0
- package/package.json +10 -10
- package/src/client.ts +2 -1
- package/src/error-codes.ts +17 -1
- package/src/hooks.ts +238 -57
- package/src/index.ts +149 -49
- package/src/metadata.ts +94 -0
- package/src/middleware.ts +91 -45
- package/src/routes.ts +699 -368
- package/src/schema.ts +40 -4
- package/src/types.ts +105 -20
- package/src/utils.ts +36 -1
- package/test/stripe-organization.test.ts +1993 -0
- package/{src → test}/stripe.test.ts +3775 -1393
- package/tsdown.config.ts +1 -0
- package/CHANGELOG.md +0 -22
- package/dist/error-codes-qqooUh6R.mjs +0 -16
package/src/routes.ts
CHANGED
|
@@ -1,29 +1,103 @@
|
|
|
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 {
|
|
18
|
+
import { customerMetadata, subscriptionMetadata } from "./metadata";
|
|
19
|
+
import { referenceMiddleware, stripeSessionMiddleware } from "./middleware";
|
|
20
20
|
import type {
|
|
21
|
-
|
|
21
|
+
CustomerType,
|
|
22
|
+
StripeCtxSession,
|
|
22
23
|
StripeOptions,
|
|
23
24
|
Subscription,
|
|
24
25
|
SubscriptionOptions,
|
|
26
|
+
WithStripeCustomerId,
|
|
25
27
|
} from "./types";
|
|
26
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
escapeStripeSearchValue,
|
|
30
|
+
getPlanByName,
|
|
31
|
+
getPlanByPriceInfo,
|
|
32
|
+
getPlans,
|
|
33
|
+
isActiveOrTrialing,
|
|
34
|
+
isPendingCancel,
|
|
35
|
+
isStripePendingCancel,
|
|
36
|
+
} from "./utils";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Converts a relative URL to an absolute URL using baseURL.
|
|
40
|
+
* @internal
|
|
41
|
+
*/
|
|
42
|
+
function getUrl(ctx: GenericEndpointContext, url: string) {
|
|
43
|
+
if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(url)) {
|
|
44
|
+
return url;
|
|
45
|
+
}
|
|
46
|
+
return `${ctx.context.options.baseURL}${
|
|
47
|
+
url.startsWith("/") ? url : `/${url}`
|
|
48
|
+
}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolves a Stripe price ID from a lookup key.
|
|
53
|
+
* @internal
|
|
54
|
+
*/
|
|
55
|
+
async function resolvePriceIdFromLookupKey(
|
|
56
|
+
stripeClient: Stripe,
|
|
57
|
+
lookupKey: string,
|
|
58
|
+
): Promise<string | undefined> {
|
|
59
|
+
if (!lookupKey) return undefined;
|
|
60
|
+
const prices = await stripeClient.prices.list({
|
|
61
|
+
lookup_keys: [lookupKey],
|
|
62
|
+
active: true,
|
|
63
|
+
limit: 1,
|
|
64
|
+
});
|
|
65
|
+
return prices.data[0]?.id;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Determines the reference ID based on customer type.
|
|
70
|
+
* - `user` (default): uses userId
|
|
71
|
+
* - `organization`: uses activeOrganizationId from session
|
|
72
|
+
* @internal
|
|
73
|
+
*/
|
|
74
|
+
function getReferenceId(
|
|
75
|
+
ctxSession: StripeCtxSession,
|
|
76
|
+
customerType: CustomerType | undefined,
|
|
77
|
+
options: StripeOptions,
|
|
78
|
+
): string {
|
|
79
|
+
const { user, session } = ctxSession;
|
|
80
|
+
const type = customerType || "user";
|
|
81
|
+
|
|
82
|
+
if (type === "organization") {
|
|
83
|
+
if (!options.organization?.enabled) {
|
|
84
|
+
throw APIError.from(
|
|
85
|
+
"BAD_REQUEST",
|
|
86
|
+
STRIPE_ERROR_CODES.ORGANIZATION_SUBSCRIPTION_NOT_ENABLED,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!session.activeOrganizationId) {
|
|
91
|
+
throw APIError.from(
|
|
92
|
+
"BAD_REQUEST",
|
|
93
|
+
STRIPE_ERROR_CODES.ORGANIZATION_NOT_FOUND,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return session.activeOrganizationId;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return user.id;
|
|
100
|
+
}
|
|
27
101
|
|
|
28
102
|
const upgradeSubscriptionBodySchema = z.object({
|
|
29
103
|
/**
|
|
@@ -42,14 +116,14 @@ const upgradeSubscriptionBodySchema = z.object({
|
|
|
42
116
|
})
|
|
43
117
|
.optional(),
|
|
44
118
|
/**
|
|
45
|
-
* Reference
|
|
46
|
-
*
|
|
47
|
-
*
|
|
119
|
+
* Reference ID for the subscription based on customerType:
|
|
120
|
+
* - `user`: defaults to `user.id`
|
|
121
|
+
* - `organization`: defaults to `session.activeOrganizationId`
|
|
48
122
|
*/
|
|
49
123
|
referenceId: z
|
|
50
124
|
.string()
|
|
51
125
|
.meta({
|
|
52
|
-
description: 'Reference
|
|
126
|
+
description: 'Reference ID for the subscription. Eg: "org_123"',
|
|
53
127
|
})
|
|
54
128
|
.optional(),
|
|
55
129
|
/**
|
|
@@ -64,12 +138,23 @@ const upgradeSubscriptionBodySchema = z.object({
|
|
|
64
138
|
})
|
|
65
139
|
.optional(),
|
|
66
140
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
141
|
+
* Customer type for the subscription.
|
|
142
|
+
* - `user`: User owns the subscription (default)
|
|
143
|
+
* - `organization`: Organization owns the subscription (requires referenceId)
|
|
144
|
+
*/
|
|
145
|
+
customerType: z
|
|
146
|
+
.enum(["user", "organization"])
|
|
147
|
+
.meta({
|
|
148
|
+
description:
|
|
149
|
+
'Customer type for the subscription. Eg: "user" or "organization"',
|
|
150
|
+
})
|
|
151
|
+
.optional(),
|
|
152
|
+
/**
|
|
153
|
+
* Additional metadata to store with the subscription.
|
|
69
154
|
*/
|
|
70
155
|
metadata: z.record(z.string(), z.any()).optional(),
|
|
71
156
|
/**
|
|
72
|
-
*
|
|
157
|
+
* Number of seats for subscriptions.
|
|
73
158
|
*/
|
|
74
159
|
seats: z
|
|
75
160
|
.number()
|
|
@@ -78,7 +163,20 @@ const upgradeSubscriptionBodySchema = z.object({
|
|
|
78
163
|
})
|
|
79
164
|
.optional(),
|
|
80
165
|
/**
|
|
81
|
-
*
|
|
166
|
+
* The IETF language tag of the locale Checkout is displayed in.
|
|
167
|
+
* If not provided or set to `auto`, the browser's locale is used.
|
|
168
|
+
*/
|
|
169
|
+
locale: z
|
|
170
|
+
.custom<StripeType.Checkout.Session.Locale>((localization) => {
|
|
171
|
+
return typeof localization === "string";
|
|
172
|
+
})
|
|
173
|
+
.meta({
|
|
174
|
+
description:
|
|
175
|
+
"The locale to display Checkout in. Eg: 'en', 'ko'. If not provided or set to `auto`, the browser's locale is used.",
|
|
176
|
+
})
|
|
177
|
+
.optional(),
|
|
178
|
+
/**
|
|
179
|
+
* The URL to which Stripe should send customers when payment or setup is complete.
|
|
82
180
|
*/
|
|
83
181
|
successUrl: z
|
|
84
182
|
.string()
|
|
@@ -88,7 +186,7 @@ const upgradeSubscriptionBodySchema = z.object({
|
|
|
88
186
|
})
|
|
89
187
|
.default("/"),
|
|
90
188
|
/**
|
|
91
|
-
*
|
|
189
|
+
* If set, checkout shows a back button and customers will be directed here if they cancel payment.
|
|
92
190
|
*/
|
|
93
191
|
cancelUrl: z
|
|
94
192
|
.string()
|
|
@@ -98,7 +196,7 @@ const upgradeSubscriptionBodySchema = z.object({
|
|
|
98
196
|
})
|
|
99
197
|
.default("/"),
|
|
100
198
|
/**
|
|
101
|
-
*
|
|
199
|
+
* The URL to return to from the Billing Portal (used when upgrading existing subscriptions)
|
|
102
200
|
*/
|
|
103
201
|
returnUrl: z
|
|
104
202
|
.string()
|
|
@@ -148,22 +246,27 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
148
246
|
},
|
|
149
247
|
},
|
|
150
248
|
use: [
|
|
151
|
-
|
|
249
|
+
stripeSessionMiddleware,
|
|
250
|
+
referenceMiddleware(subscriptionOptions, "upgrade-subscription"),
|
|
152
251
|
originCheck((c) => {
|
|
153
252
|
return [c.body.successUrl as string, c.body.cancelUrl as string];
|
|
154
253
|
}),
|
|
155
|
-
referenceMiddleware(subscriptionOptions, "upgrade-subscription"),
|
|
156
254
|
],
|
|
157
255
|
},
|
|
158
256
|
async (ctx) => {
|
|
159
257
|
const { user, session } = ctx.context.session;
|
|
258
|
+
const customerType = ctx.body.customerType || "user";
|
|
259
|
+
const referenceId =
|
|
260
|
+
ctx.body.referenceId ||
|
|
261
|
+
getReferenceId(ctx.context.session, customerType, options);
|
|
262
|
+
|
|
160
263
|
if (!user.emailVerified && subscriptionOptions.requireEmailVerification) {
|
|
161
264
|
throw APIError.from(
|
|
162
265
|
"BAD_REQUEST",
|
|
163
266
|
STRIPE_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED,
|
|
164
267
|
);
|
|
165
268
|
}
|
|
166
|
-
|
|
269
|
+
|
|
167
270
|
const plan = await getPlanByName(options, ctx.body.plan);
|
|
168
271
|
if (!plan) {
|
|
169
272
|
throw APIError.from(
|
|
@@ -171,7 +274,10 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
171
274
|
STRIPE_ERROR_CODES.SUBSCRIPTION_PLAN_NOT_FOUND,
|
|
172
275
|
);
|
|
173
276
|
}
|
|
174
|
-
|
|
277
|
+
|
|
278
|
+
// If subscriptionId is provided, find that specific subscription.
|
|
279
|
+
// Otherwise, active subscription will be resolved by referenceId later.
|
|
280
|
+
const subscriptionToUpdate = ctx.body.subscriptionId
|
|
175
281
|
? await ctx.context.adapter.findOne<Subscription>({
|
|
176
282
|
model: "subscription",
|
|
177
283
|
where: [
|
|
@@ -181,73 +287,176 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
181
287
|
},
|
|
182
288
|
],
|
|
183
289
|
})
|
|
184
|
-
:
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
290
|
+
: null;
|
|
291
|
+
if (ctx.body.subscriptionId && !subscriptionToUpdate) {
|
|
292
|
+
throw APIError.from(
|
|
293
|
+
"BAD_REQUEST",
|
|
294
|
+
STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
191
297
|
if (
|
|
192
298
|
ctx.body.subscriptionId &&
|
|
193
299
|
subscriptionToUpdate &&
|
|
194
300
|
subscriptionToUpdate.referenceId !== referenceId
|
|
195
301
|
) {
|
|
196
|
-
subscriptionToUpdate = null;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
if (ctx.body.subscriptionId && !subscriptionToUpdate) {
|
|
200
302
|
throw APIError.from(
|
|
201
303
|
"BAD_REQUEST",
|
|
202
304
|
STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
203
305
|
);
|
|
204
306
|
}
|
|
205
307
|
|
|
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
|
-
},
|
|
308
|
+
// Determine customer id
|
|
309
|
+
let customerId: string | undefined;
|
|
310
|
+
if (customerType === "organization") {
|
|
311
|
+
// Organization subscription - get customer ID from organization
|
|
312
|
+
customerId = subscriptionToUpdate?.stripeCustomerId;
|
|
313
|
+
if (!customerId) {
|
|
314
|
+
const org = await ctx.context.adapter.findOne<
|
|
315
|
+
Organization & WithStripeCustomerId
|
|
316
|
+
>({
|
|
317
|
+
model: "organization",
|
|
236
318
|
where: [
|
|
237
319
|
{
|
|
238
320
|
field: "id",
|
|
239
|
-
value:
|
|
321
|
+
value: referenceId,
|
|
240
322
|
},
|
|
241
323
|
],
|
|
242
324
|
});
|
|
325
|
+
if (!org) {
|
|
326
|
+
throw APIError.from(
|
|
327
|
+
"BAD_REQUEST",
|
|
328
|
+
STRIPE_ERROR_CODES.ORGANIZATION_NOT_FOUND,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
customerId = org.stripeCustomerId;
|
|
332
|
+
|
|
333
|
+
// If org doesn't have a customer ID, create one
|
|
334
|
+
if (!customerId) {
|
|
335
|
+
try {
|
|
336
|
+
// First, search for existing organization customer by organizationId
|
|
337
|
+
const existingOrgCustomers = await client.customers.search({
|
|
338
|
+
query: `metadata["${customerMetadata.keys.organizationId}"]:"${org.id}"`,
|
|
339
|
+
limit: 1,
|
|
340
|
+
});
|
|
243
341
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
342
|
+
let stripeCustomer = existingOrgCustomers.data[0];
|
|
343
|
+
|
|
344
|
+
if (!stripeCustomer) {
|
|
345
|
+
// Get custom params if provided
|
|
346
|
+
let extraCreateParams: Partial<StripeType.CustomerCreateParams> =
|
|
347
|
+
{};
|
|
348
|
+
if (options.organization?.getCustomerCreateParams) {
|
|
349
|
+
extraCreateParams =
|
|
350
|
+
await options.organization.getCustomerCreateParams(
|
|
351
|
+
org,
|
|
352
|
+
ctx,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Create Stripe customer for organization
|
|
357
|
+
// Email can be set via getCustomerCreateParams or updated in billing portal
|
|
358
|
+
// Use defu to merge params (first argument takes priority)
|
|
359
|
+
const customerParams = defu(
|
|
360
|
+
{
|
|
361
|
+
name: org.name,
|
|
362
|
+
metadata: customerMetadata.set(
|
|
363
|
+
{
|
|
364
|
+
organizationId: org.id,
|
|
365
|
+
customerType: "organization",
|
|
366
|
+
},
|
|
367
|
+
ctx.body.metadata,
|
|
368
|
+
),
|
|
369
|
+
},
|
|
370
|
+
extraCreateParams,
|
|
371
|
+
);
|
|
372
|
+
stripeCustomer = await client.customers.create(customerParams);
|
|
373
|
+
|
|
374
|
+
// Call onCustomerCreate callback only for newly created customers
|
|
375
|
+
await options.organization?.onCustomerCreate?.(
|
|
376
|
+
{
|
|
377
|
+
stripeCustomer,
|
|
378
|
+
organization: {
|
|
379
|
+
...org,
|
|
380
|
+
stripeCustomerId: stripeCustomer.id,
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
ctx,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
await ctx.context.adapter.update({
|
|
388
|
+
model: "organization",
|
|
389
|
+
update: {
|
|
390
|
+
stripeCustomerId: stripeCustomer.id,
|
|
391
|
+
},
|
|
392
|
+
where: [
|
|
393
|
+
{
|
|
394
|
+
field: "id",
|
|
395
|
+
value: org.id,
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
customerId = stripeCustomer.id;
|
|
401
|
+
} catch (e: any) {
|
|
402
|
+
ctx.context.logger.error(e);
|
|
403
|
+
throw APIError.from(
|
|
404
|
+
"BAD_REQUEST",
|
|
405
|
+
STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
// User subscription - get customer ID from user
|
|
412
|
+
customerId =
|
|
413
|
+
subscriptionToUpdate?.stripeCustomerId || user.stripeCustomerId;
|
|
414
|
+
if (!customerId) {
|
|
415
|
+
try {
|
|
416
|
+
// Try to find existing user Stripe customer by email
|
|
417
|
+
const existingCustomers = await client.customers.search({
|
|
418
|
+
query: `email:"${escapeStripeSearchValue(user.email)}" AND -metadata["${customerMetadata.keys.customerType}"]:"organization"`,
|
|
419
|
+
limit: 1,
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
let stripeCustomer = existingCustomers.data[0];
|
|
423
|
+
|
|
424
|
+
if (!stripeCustomer) {
|
|
425
|
+
stripeCustomer = await client.customers.create({
|
|
426
|
+
email: user.email,
|
|
427
|
+
name: user.name,
|
|
428
|
+
metadata: customerMetadata.set(
|
|
429
|
+
{
|
|
430
|
+
userId: user.id,
|
|
431
|
+
customerType: "user",
|
|
432
|
+
},
|
|
433
|
+
ctx.body.metadata,
|
|
434
|
+
),
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Update local DB with Stripe customer ID
|
|
439
|
+
await ctx.context.adapter.update({
|
|
440
|
+
model: "user",
|
|
441
|
+
update: {
|
|
442
|
+
stripeCustomerId: stripeCustomer.id,
|
|
443
|
+
},
|
|
444
|
+
where: [
|
|
445
|
+
{
|
|
446
|
+
field: "id",
|
|
447
|
+
value: user.id,
|
|
448
|
+
},
|
|
449
|
+
],
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
customerId = stripeCustomer.id;
|
|
453
|
+
} catch (e: any) {
|
|
454
|
+
ctx.context.logger.error(e);
|
|
455
|
+
throw APIError.from(
|
|
456
|
+
"BAD_REQUEST",
|
|
457
|
+
STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER,
|
|
458
|
+
);
|
|
459
|
+
}
|
|
251
460
|
}
|
|
252
461
|
}
|
|
253
462
|
|
|
@@ -258,24 +467,20 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
258
467
|
where: [
|
|
259
468
|
{
|
|
260
469
|
field: "referenceId",
|
|
261
|
-
value:
|
|
470
|
+
value: referenceId,
|
|
262
471
|
},
|
|
263
472
|
],
|
|
264
473
|
});
|
|
265
474
|
|
|
266
|
-
const activeOrTrialingSubscription = subscriptions.find(
|
|
267
|
-
(sub)
|
|
475
|
+
const activeOrTrialingSubscription = subscriptions.find((sub) =>
|
|
476
|
+
isActiveOrTrialing(sub),
|
|
268
477
|
);
|
|
269
478
|
|
|
270
479
|
const activeSubscriptions = await client.subscriptions
|
|
271
480
|
.list({
|
|
272
481
|
customer: customerId,
|
|
273
482
|
})
|
|
274
|
-
.then((res) =>
|
|
275
|
-
res.data.filter(
|
|
276
|
-
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
277
|
-
),
|
|
278
|
-
);
|
|
483
|
+
.then((res) => res.data.filter((sub) => isActiveOrTrialing(sub)));
|
|
279
484
|
|
|
280
485
|
const activeSubscription = activeSubscriptions.find((sub) => {
|
|
281
486
|
// If we have a specific subscription to update, match by ID
|
|
@@ -295,17 +500,47 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
295
500
|
return false;
|
|
296
501
|
});
|
|
297
502
|
|
|
503
|
+
// Get the current price ID from the active Stripe subscription
|
|
504
|
+
const stripeSubscriptionPriceId =
|
|
505
|
+
activeSubscription?.items.data[0]?.price.id;
|
|
506
|
+
|
|
298
507
|
// Also find any incomplete subscription that we can reuse
|
|
299
508
|
const incompleteSubscription = subscriptions.find(
|
|
300
509
|
(sub) => sub.status === "incomplete",
|
|
301
510
|
);
|
|
302
511
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
512
|
+
const priceId = ctx.body.annual
|
|
513
|
+
? plan.annualDiscountPriceId
|
|
514
|
+
: plan.priceId;
|
|
515
|
+
const lookupKey = ctx.body.annual
|
|
516
|
+
? plan.annualDiscountLookupKey
|
|
517
|
+
: plan.lookupKey;
|
|
518
|
+
const resolvedPriceId = lookupKey
|
|
519
|
+
? await resolvePriceIdFromLookupKey(client, lookupKey)
|
|
520
|
+
: undefined;
|
|
521
|
+
|
|
522
|
+
const priceIdToUse = priceId || resolvedPriceId;
|
|
523
|
+
if (!priceIdToUse) {
|
|
524
|
+
throw ctx.error("BAD_REQUEST", {
|
|
525
|
+
message: "Price ID not found for the selected plan",
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const isSamePlan = activeOrTrialingSubscription?.plan === ctx.body.plan;
|
|
530
|
+
const isSameSeats =
|
|
531
|
+
activeOrTrialingSubscription?.seats === (ctx.body.seats || 1);
|
|
532
|
+
const isSamePriceId = stripeSubscriptionPriceId === priceIdToUse;
|
|
533
|
+
const isSubscriptionStillValid =
|
|
534
|
+
!activeOrTrialingSubscription?.periodEnd ||
|
|
535
|
+
activeOrTrialingSubscription.periodEnd > new Date();
|
|
536
|
+
|
|
537
|
+
const isAlreadySubscribed =
|
|
538
|
+
activeOrTrialingSubscription?.status === "active" &&
|
|
539
|
+
isSamePlan &&
|
|
540
|
+
isSameSeats &&
|
|
541
|
+
isSamePriceId &&
|
|
542
|
+
isSubscriptionStillValid;
|
|
543
|
+
if (isAlreadySubscribed) {
|
|
309
544
|
throw APIError.from(
|
|
310
545
|
"BAD_REQUEST",
|
|
311
546
|
STRIPE_ERROR_CODES.ALREADY_SUBSCRIBED_PLAN,
|
|
@@ -326,7 +561,7 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
326
561
|
|
|
327
562
|
// If no database record exists for this Stripe subscription, update the existing one
|
|
328
563
|
if (!dbSubscription && activeOrTrialingSubscription) {
|
|
329
|
-
await ctx.context.adapter.update<
|
|
564
|
+
await ctx.context.adapter.update<Subscription>({
|
|
330
565
|
model: "subscription",
|
|
331
566
|
update: {
|
|
332
567
|
stripeSubscriptionId: activeSubscription.id,
|
|
@@ -342,32 +577,6 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
342
577
|
dbSubscription = activeOrTrialingSubscription;
|
|
343
578
|
}
|
|
344
579
|
|
|
345
|
-
// Resolve price ID if using lookup keys
|
|
346
|
-
let priceIdToUse: string | undefined = undefined;
|
|
347
|
-
if (ctx.body.annual) {
|
|
348
|
-
priceIdToUse = plan.annualDiscountPriceId;
|
|
349
|
-
if (!priceIdToUse && plan.annualDiscountLookupKey) {
|
|
350
|
-
priceIdToUse = await resolvePriceIdFromLookupKey(
|
|
351
|
-
client,
|
|
352
|
-
plan.annualDiscountLookupKey,
|
|
353
|
-
);
|
|
354
|
-
}
|
|
355
|
-
} else {
|
|
356
|
-
priceIdToUse = plan.priceId;
|
|
357
|
-
if (!priceIdToUse && plan.lookupKey) {
|
|
358
|
-
priceIdToUse = await resolvePriceIdFromLookupKey(
|
|
359
|
-
client,
|
|
360
|
-
plan.lookupKey,
|
|
361
|
-
);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
if (!priceIdToUse) {
|
|
366
|
-
throw ctx.error("BAD_REQUEST", {
|
|
367
|
-
message: "Price ID not found for the selected plan",
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
|
|
371
580
|
const { url } = await client.billingPortal.sessions
|
|
372
581
|
.create({
|
|
373
582
|
customer: customerId,
|
|
@@ -400,7 +609,7 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
400
609
|
});
|
|
401
610
|
return ctx.json({
|
|
402
611
|
url,
|
|
403
|
-
redirect:
|
|
612
|
+
redirect: !ctx.body.disableRedirect,
|
|
404
613
|
});
|
|
405
614
|
}
|
|
406
615
|
|
|
@@ -408,7 +617,7 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
408
617
|
activeOrTrialingSubscription || incompleteSubscription;
|
|
409
618
|
|
|
410
619
|
if (incompleteSubscription && !activeOrTrialingSubscription) {
|
|
411
|
-
const updated = await ctx.context.adapter.update<
|
|
620
|
+
const updated = await ctx.context.adapter.update<Subscription>({
|
|
412
621
|
model: "subscription",
|
|
413
622
|
update: {
|
|
414
623
|
plan: plan.name.toLowerCase(),
|
|
@@ -426,10 +635,7 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
426
635
|
}
|
|
427
636
|
|
|
428
637
|
if (!subscription) {
|
|
429
|
-
subscription = await ctx.context.adapter.create<
|
|
430
|
-
InputSubscription,
|
|
431
|
-
Subscription
|
|
432
|
-
>({
|
|
638
|
+
subscription = await ctx.context.adapter.create<Subscription>({
|
|
433
639
|
model: "subscription",
|
|
434
640
|
data: {
|
|
435
641
|
plan: plan.name.toLowerCase(),
|
|
@@ -443,7 +649,10 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
443
649
|
|
|
444
650
|
if (!subscription) {
|
|
445
651
|
ctx.context.logger.error("Subscription ID not found");
|
|
446
|
-
throw
|
|
652
|
+
throw APIError.from(
|
|
653
|
+
"NOT_FOUND",
|
|
654
|
+
STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
655
|
+
);
|
|
447
656
|
}
|
|
448
657
|
|
|
449
658
|
const params = await subscriptionOptions.getCheckoutSessionParams?.(
|
|
@@ -457,7 +666,13 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
457
666
|
ctx,
|
|
458
667
|
);
|
|
459
668
|
|
|
460
|
-
const
|
|
669
|
+
const allSubscriptions = await ctx.context.adapter.findMany<Subscription>(
|
|
670
|
+
{
|
|
671
|
+
model: "subscription",
|
|
672
|
+
where: [{ field: "referenceId", value: referenceId }],
|
|
673
|
+
},
|
|
674
|
+
);
|
|
675
|
+
const hasEverTrialed = allSubscriptions.some((s) => {
|
|
461
676
|
// Check if user has ever had a trial for any plan (not just the same plan)
|
|
462
677
|
// This prevents users from getting multiple trials by switching plans
|
|
463
678
|
const hadTrial =
|
|
@@ -470,38 +685,21 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
470
685
|
? { trial_period_days: plan.freeTrial.days }
|
|
471
686
|
: undefined;
|
|
472
687
|
|
|
473
|
-
let priceIdToUse: string | undefined = undefined;
|
|
474
|
-
if (ctx.body.annual) {
|
|
475
|
-
priceIdToUse = plan.annualDiscountPriceId;
|
|
476
|
-
if (!priceIdToUse && plan.annualDiscountLookupKey) {
|
|
477
|
-
priceIdToUse = await resolvePriceIdFromLookupKey(
|
|
478
|
-
client,
|
|
479
|
-
plan.annualDiscountLookupKey,
|
|
480
|
-
);
|
|
481
|
-
}
|
|
482
|
-
} else {
|
|
483
|
-
priceIdToUse = plan.priceId;
|
|
484
|
-
if (!priceIdToUse && plan.lookupKey) {
|
|
485
|
-
priceIdToUse = await resolvePriceIdFromLookupKey(
|
|
486
|
-
client,
|
|
487
|
-
plan.lookupKey,
|
|
488
|
-
);
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
688
|
const checkoutSession = await client.checkout.sessions
|
|
492
689
|
.create(
|
|
493
690
|
{
|
|
494
691
|
...(customerId
|
|
495
692
|
? {
|
|
496
693
|
customer: customerId,
|
|
497
|
-
customer_update:
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
694
|
+
customer_update:
|
|
695
|
+
customerType !== "user"
|
|
696
|
+
? ({ address: "auto" } as const)
|
|
697
|
+
: ({ name: "auto", address: "auto" } as const), // The customer name is automatically set only for users
|
|
501
698
|
}
|
|
502
699
|
: {
|
|
503
|
-
customer_email:
|
|
700
|
+
customer_email: user.email,
|
|
504
701
|
}),
|
|
702
|
+
locale: ctx.body.locale,
|
|
505
703
|
success_url: getUrl(
|
|
506
704
|
ctx,
|
|
507
705
|
`${
|
|
@@ -519,16 +717,29 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
519
717
|
],
|
|
520
718
|
subscription_data: {
|
|
521
719
|
...freeTrial,
|
|
720
|
+
metadata: subscriptionMetadata.set(
|
|
721
|
+
{
|
|
722
|
+
userId: user.id,
|
|
723
|
+
subscriptionId: subscription.id,
|
|
724
|
+
referenceId,
|
|
725
|
+
},
|
|
726
|
+
ctx.body.metadata,
|
|
727
|
+
params?.params?.subscription_data?.metadata,
|
|
728
|
+
),
|
|
522
729
|
},
|
|
523
730
|
mode: "subscription",
|
|
524
731
|
client_reference_id: referenceId,
|
|
525
732
|
...params?.params,
|
|
526
|
-
metadata
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
733
|
+
// metadata should come after spread to protect internal fields
|
|
734
|
+
metadata: subscriptionMetadata.set(
|
|
735
|
+
{
|
|
736
|
+
userId: user.id,
|
|
737
|
+
subscriptionId: subscription.id,
|
|
738
|
+
referenceId,
|
|
739
|
+
},
|
|
740
|
+
ctx.body.metadata,
|
|
741
|
+
params?.params?.metadata,
|
|
742
|
+
),
|
|
532
743
|
},
|
|
533
744
|
params?.options,
|
|
534
745
|
)
|
|
@@ -569,9 +780,7 @@ export const cancelSubscriptionCallback = (options: StripeOptions) => {
|
|
|
569
780
|
if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) {
|
|
570
781
|
throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
|
|
571
782
|
}
|
|
572
|
-
const session = await getSessionFromCtx<
|
|
573
|
-
ctx,
|
|
574
|
-
);
|
|
783
|
+
const session = await getSessionFromCtx<User & WithStripeCustomerId>(ctx);
|
|
575
784
|
if (!session) {
|
|
576
785
|
throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
|
|
577
786
|
}
|
|
@@ -591,8 +800,8 @@ export const cancelSubscriptionCallback = (options: StripeOptions) => {
|
|
|
591
800
|
});
|
|
592
801
|
if (
|
|
593
802
|
!subscription ||
|
|
594
|
-
subscription.
|
|
595
|
-
subscription
|
|
803
|
+
subscription.status === "canceled" ||
|
|
804
|
+
isPendingCancel(subscription)
|
|
596
805
|
) {
|
|
597
806
|
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
598
807
|
}
|
|
@@ -604,12 +813,24 @@ export const cancelSubscriptionCallback = (options: StripeOptions) => {
|
|
|
604
813
|
const currentSubscription = stripeSubscription.data.find(
|
|
605
814
|
(sub) => sub.id === subscription.stripeSubscriptionId,
|
|
606
815
|
);
|
|
607
|
-
|
|
816
|
+
|
|
817
|
+
const isNewCancellation =
|
|
818
|
+
currentSubscription &&
|
|
819
|
+
isStripePendingCancel(currentSubscription) &&
|
|
820
|
+
!isPendingCancel(subscription);
|
|
821
|
+
if (isNewCancellation) {
|
|
608
822
|
await ctx.context.adapter.update({
|
|
609
823
|
model: "subscription",
|
|
610
824
|
update: {
|
|
611
825
|
status: currentSubscription?.status,
|
|
612
|
-
cancelAtPeriodEnd:
|
|
826
|
+
cancelAtPeriodEnd:
|
|
827
|
+
currentSubscription?.cancel_at_period_end || false,
|
|
828
|
+
cancelAt: currentSubscription?.cancel_at
|
|
829
|
+
? new Date(currentSubscription.cancel_at * 1000)
|
|
830
|
+
: null,
|
|
831
|
+
canceledAt: currentSubscription?.canceled_at
|
|
832
|
+
? new Date(currentSubscription.canceled_at * 1000)
|
|
833
|
+
: null,
|
|
613
834
|
},
|
|
614
835
|
where: [
|
|
615
836
|
{
|
|
@@ -651,10 +872,32 @@ const cancelSubscriptionBodySchema = z.object({
|
|
|
651
872
|
"The Stripe subscription ID to cancel. Eg: 'sub_1ABC2DEF3GHI4JKL'",
|
|
652
873
|
})
|
|
653
874
|
.optional(),
|
|
875
|
+
/**
|
|
876
|
+
* Customer type for the subscription.
|
|
877
|
+
* - `user`: User owns the subscription (default)
|
|
878
|
+
* - `organization`: Organization owns the subscription
|
|
879
|
+
*/
|
|
880
|
+
customerType: z
|
|
881
|
+
.enum(["user", "organization"])
|
|
882
|
+
.meta({
|
|
883
|
+
description:
|
|
884
|
+
'Customer type for the subscription. Eg: "user" or "organization"',
|
|
885
|
+
})
|
|
886
|
+
.optional(),
|
|
654
887
|
returnUrl: z.string().meta({
|
|
655
888
|
description:
|
|
656
889
|
'URL to take customers to when they click on the billing portal\'s link to return to your website. Eg: "/account"',
|
|
657
890
|
}),
|
|
891
|
+
/**
|
|
892
|
+
* Disable Redirect
|
|
893
|
+
*/
|
|
894
|
+
disableRedirect: z
|
|
895
|
+
.boolean()
|
|
896
|
+
.meta({
|
|
897
|
+
description:
|
|
898
|
+
"Disable redirect after successful subscription cancellation. Eg: true",
|
|
899
|
+
})
|
|
900
|
+
.default(false),
|
|
658
901
|
});
|
|
659
902
|
|
|
660
903
|
/**
|
|
@@ -686,13 +929,17 @@ export const cancelSubscription = (options: StripeOptions) => {
|
|
|
686
929
|
},
|
|
687
930
|
},
|
|
688
931
|
use: [
|
|
689
|
-
|
|
690
|
-
originCheck((ctx) => ctx.body.returnUrl),
|
|
932
|
+
stripeSessionMiddleware,
|
|
691
933
|
referenceMiddleware(subscriptionOptions, "cancel-subscription"),
|
|
934
|
+
originCheck((ctx) => ctx.body.returnUrl),
|
|
692
935
|
],
|
|
693
936
|
},
|
|
694
937
|
async (ctx) => {
|
|
695
|
-
const
|
|
938
|
+
const customerType = ctx.body.customerType || "user";
|
|
939
|
+
const referenceId =
|
|
940
|
+
ctx.body.referenceId ||
|
|
941
|
+
getReferenceId(ctx.context.session, customerType, options);
|
|
942
|
+
|
|
696
943
|
let subscription = ctx.body.subscriptionId
|
|
697
944
|
? await ctx.context.adapter.findOne<Subscription>({
|
|
698
945
|
model: "subscription",
|
|
@@ -708,13 +955,7 @@ export const cancelSubscription = (options: StripeOptions) => {
|
|
|
708
955
|
model: "subscription",
|
|
709
956
|
where: [{ field: "referenceId", value: referenceId }],
|
|
710
957
|
})
|
|
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.
|
|
958
|
+
.then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
|
|
718
959
|
if (
|
|
719
960
|
ctx.body.subscriptionId &&
|
|
720
961
|
subscription &&
|
|
@@ -733,11 +974,7 @@ export const cancelSubscription = (options: StripeOptions) => {
|
|
|
733
974
|
.list({
|
|
734
975
|
customer: subscription.stripeCustomerId,
|
|
735
976
|
})
|
|
736
|
-
.then((res) =>
|
|
737
|
-
res.data.filter(
|
|
738
|
-
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
739
|
-
),
|
|
740
|
-
);
|
|
977
|
+
.then((res) => res.data.filter((sub) => isActiveOrTrialing(sub)));
|
|
741
978
|
if (!activeSubscriptions.length) {
|
|
742
979
|
/**
|
|
743
980
|
* If the subscription is not found, we need to delete the subscription
|
|
@@ -785,21 +1022,30 @@ export const cancelSubscription = (options: StripeOptions) => {
|
|
|
785
1022
|
},
|
|
786
1023
|
})
|
|
787
1024
|
.catch(async (e) => {
|
|
788
|
-
if (e.message
|
|
1025
|
+
if (e.message?.includes("already set to be canceled")) {
|
|
789
1026
|
/**
|
|
790
|
-
* in-case we missed the event from stripe, we
|
|
1027
|
+
* in-case we missed the event from stripe, we sync the actual state
|
|
791
1028
|
* this is a rare case and should not happen
|
|
792
1029
|
*/
|
|
793
|
-
if (!subscription
|
|
794
|
-
await
|
|
1030
|
+
if (!isPendingCancel(subscription)) {
|
|
1031
|
+
const stripeSub = await client.subscriptions.retrieve(
|
|
1032
|
+
activeSubscription.id,
|
|
1033
|
+
);
|
|
1034
|
+
await ctx.context.adapter.update({
|
|
795
1035
|
model: "subscription",
|
|
796
1036
|
update: {
|
|
797
|
-
cancelAtPeriodEnd:
|
|
1037
|
+
cancelAtPeriodEnd: stripeSub.cancel_at_period_end,
|
|
1038
|
+
cancelAt: stripeSub.cancel_at
|
|
1039
|
+
? new Date(stripeSub.cancel_at * 1000)
|
|
1040
|
+
: null,
|
|
1041
|
+
canceledAt: stripeSub.canceled_at
|
|
1042
|
+
? new Date(stripeSub.canceled_at * 1000)
|
|
1043
|
+
: null,
|
|
798
1044
|
},
|
|
799
1045
|
where: [
|
|
800
1046
|
{
|
|
801
|
-
field: "
|
|
802
|
-
value:
|
|
1047
|
+
field: "id",
|
|
1048
|
+
value: subscription.id,
|
|
803
1049
|
},
|
|
804
1050
|
],
|
|
805
1051
|
});
|
|
@@ -810,10 +1056,10 @@ export const cancelSubscription = (options: StripeOptions) => {
|
|
|
810
1056
|
code: e.code,
|
|
811
1057
|
});
|
|
812
1058
|
});
|
|
813
|
-
return {
|
|
1059
|
+
return ctx.json({
|
|
814
1060
|
url,
|
|
815
|
-
redirect:
|
|
816
|
-
};
|
|
1061
|
+
redirect: !ctx.body.disableRedirect,
|
|
1062
|
+
});
|
|
817
1063
|
},
|
|
818
1064
|
);
|
|
819
1065
|
};
|
|
@@ -832,6 +1078,18 @@ const restoreSubscriptionBodySchema = z.object({
|
|
|
832
1078
|
"The Stripe subscription ID to restore. Eg: 'sub_1ABC2DEF3GHI4JKL'",
|
|
833
1079
|
})
|
|
834
1080
|
.optional(),
|
|
1081
|
+
/**
|
|
1082
|
+
* Customer type for the subscription.
|
|
1083
|
+
* - `user`: User owns the subscription (default)
|
|
1084
|
+
* - `organization`: Organization owns the subscription
|
|
1085
|
+
*/
|
|
1086
|
+
customerType: z
|
|
1087
|
+
.enum(["user", "organization"])
|
|
1088
|
+
.meta({
|
|
1089
|
+
description:
|
|
1090
|
+
'Customer type for the subscription. Eg: "user" or "organization"',
|
|
1091
|
+
})
|
|
1092
|
+
.optional(),
|
|
835
1093
|
});
|
|
836
1094
|
|
|
837
1095
|
export const restoreSubscription = (options: StripeOptions) => {
|
|
@@ -848,12 +1106,15 @@ export const restoreSubscription = (options: StripeOptions) => {
|
|
|
848
1106
|
},
|
|
849
1107
|
},
|
|
850
1108
|
use: [
|
|
851
|
-
|
|
1109
|
+
stripeSessionMiddleware,
|
|
852
1110
|
referenceMiddleware(subscriptionOptions, "restore-subscription"),
|
|
853
1111
|
],
|
|
854
1112
|
},
|
|
855
1113
|
async (ctx) => {
|
|
856
|
-
const
|
|
1114
|
+
const customerType = ctx.body.customerType || "user";
|
|
1115
|
+
const referenceId =
|
|
1116
|
+
ctx.body.referenceId ||
|
|
1117
|
+
getReferenceId(ctx.context.session, customerType, options);
|
|
857
1118
|
|
|
858
1119
|
let subscription = ctx.body.subscriptionId
|
|
859
1120
|
? await ctx.context.adapter.findOne<Subscription>({
|
|
@@ -875,11 +1136,7 @@ export const restoreSubscription = (options: StripeOptions) => {
|
|
|
875
1136
|
},
|
|
876
1137
|
],
|
|
877
1138
|
})
|
|
878
|
-
.then((subs) =>
|
|
879
|
-
subs.find(
|
|
880
|
-
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
881
|
-
),
|
|
882
|
-
);
|
|
1139
|
+
.then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
|
|
883
1140
|
if (
|
|
884
1141
|
ctx.body.subscriptionId &&
|
|
885
1142
|
subscription &&
|
|
@@ -893,16 +1150,13 @@ export const restoreSubscription = (options: StripeOptions) => {
|
|
|
893
1150
|
STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
894
1151
|
);
|
|
895
1152
|
}
|
|
896
|
-
if (
|
|
897
|
-
subscription.status != "active" &&
|
|
898
|
-
subscription.status != "trialing"
|
|
899
|
-
) {
|
|
1153
|
+
if (!isActiveOrTrialing(subscription)) {
|
|
900
1154
|
throw APIError.from(
|
|
901
1155
|
"BAD_REQUEST",
|
|
902
1156
|
STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_ACTIVE,
|
|
903
1157
|
);
|
|
904
1158
|
}
|
|
905
|
-
if (!subscription
|
|
1159
|
+
if (!isPendingCancel(subscription)) {
|
|
906
1160
|
throw APIError.from(
|
|
907
1161
|
"BAD_REQUEST",
|
|
908
1162
|
STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION,
|
|
@@ -913,12 +1167,7 @@ export const restoreSubscription = (options: StripeOptions) => {
|
|
|
913
1167
|
.list({
|
|
914
1168
|
customer: subscription.stripeCustomerId,
|
|
915
1169
|
})
|
|
916
|
-
.then(
|
|
917
|
-
(res) =>
|
|
918
|
-
res.data.filter(
|
|
919
|
-
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
920
|
-
)[0],
|
|
921
|
-
);
|
|
1170
|
+
.then((res) => res.data.filter((sub) => isActiveOrTrialing(sub))[0]);
|
|
922
1171
|
if (!activeSubscription) {
|
|
923
1172
|
throw APIError.from(
|
|
924
1173
|
"BAD_REQUEST",
|
|
@@ -926,36 +1175,41 @@ export const restoreSubscription = (options: StripeOptions) => {
|
|
|
926
1175
|
);
|
|
927
1176
|
}
|
|
928
1177
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1178
|
+
// Clear scheduled cancellation based on Stripe subscription state
|
|
1179
|
+
// Note: Stripe doesn't accept both `cancel_at` and `cancel_at_period_end` simultaneously
|
|
1180
|
+
const updateParams: Stripe.SubscriptionUpdateParams = {};
|
|
1181
|
+
if (activeSubscription.cancel_at) {
|
|
1182
|
+
updateParams.cancel_at = "";
|
|
1183
|
+
} else if (activeSubscription.cancel_at_period_end) {
|
|
1184
|
+
updateParams.cancel_at_period_end = false;
|
|
1185
|
+
}
|
|
936
1186
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
{
|
|
945
|
-
field: "id",
|
|
946
|
-
value: subscription.id,
|
|
947
|
-
},
|
|
948
|
-
],
|
|
1187
|
+
const newSub = await client.subscriptions
|
|
1188
|
+
.update(activeSubscription.id, updateParams)
|
|
1189
|
+
.catch((e) => {
|
|
1190
|
+
throw ctx.error("BAD_REQUEST", {
|
|
1191
|
+
message: e.message,
|
|
1192
|
+
code: e.code,
|
|
1193
|
+
});
|
|
949
1194
|
});
|
|
950
1195
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1196
|
+
await ctx.context.adapter.update({
|
|
1197
|
+
model: "subscription",
|
|
1198
|
+
update: {
|
|
1199
|
+
cancelAtPeriodEnd: false,
|
|
1200
|
+
cancelAt: null,
|
|
1201
|
+
canceledAt: null,
|
|
1202
|
+
updatedAt: new Date(),
|
|
1203
|
+
},
|
|
1204
|
+
where: [
|
|
1205
|
+
{
|
|
1206
|
+
field: "id",
|
|
1207
|
+
value: subscription.id,
|
|
1208
|
+
},
|
|
1209
|
+
],
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
return ctx.json(newSub);
|
|
959
1213
|
},
|
|
960
1214
|
);
|
|
961
1215
|
};
|
|
@@ -968,6 +1222,18 @@ const listActiveSubscriptionsQuerySchema = z.optional(
|
|
|
968
1222
|
description: "Reference id of the subscription to list. Eg: '123'",
|
|
969
1223
|
})
|
|
970
1224
|
.optional(),
|
|
1225
|
+
/**
|
|
1226
|
+
* Customer type for the subscription.
|
|
1227
|
+
* - `user`: User owns the subscription (default)
|
|
1228
|
+
* - `organization`: Organization owns the subscription
|
|
1229
|
+
*/
|
|
1230
|
+
customerType: z
|
|
1231
|
+
.enum(["user", "organization"])
|
|
1232
|
+
.meta({
|
|
1233
|
+
description:
|
|
1234
|
+
'Customer type for the subscription. Eg: "user" or "organization"',
|
|
1235
|
+
})
|
|
1236
|
+
.optional(),
|
|
971
1237
|
}),
|
|
972
1238
|
);
|
|
973
1239
|
/**
|
|
@@ -998,17 +1264,22 @@ export const listActiveSubscriptions = (options: StripeOptions) => {
|
|
|
998
1264
|
},
|
|
999
1265
|
},
|
|
1000
1266
|
use: [
|
|
1001
|
-
|
|
1267
|
+
stripeSessionMiddleware,
|
|
1002
1268
|
referenceMiddleware(subscriptionOptions, "list-subscription"),
|
|
1003
1269
|
],
|
|
1004
1270
|
},
|
|
1005
1271
|
async (ctx) => {
|
|
1272
|
+
const customerType = ctx.query?.customerType || "user";
|
|
1273
|
+
const referenceId =
|
|
1274
|
+
ctx.query?.referenceId ||
|
|
1275
|
+
getReferenceId(ctx.context.session, customerType, options);
|
|
1276
|
+
|
|
1006
1277
|
const subscriptions = await ctx.context.adapter.findMany<Subscription>({
|
|
1007
1278
|
model: "subscription",
|
|
1008
1279
|
where: [
|
|
1009
1280
|
{
|
|
1010
1281
|
field: "referenceId",
|
|
1011
|
-
value:
|
|
1282
|
+
value: referenceId,
|
|
1012
1283
|
},
|
|
1013
1284
|
],
|
|
1014
1285
|
});
|
|
@@ -1030,9 +1301,7 @@ export const listActiveSubscriptions = (options: StripeOptions) => {
|
|
|
1030
1301
|
priceId: plan?.priceId,
|
|
1031
1302
|
};
|
|
1032
1303
|
})
|
|
1033
|
-
.filter((sub) =>
|
|
1034
|
-
return sub.status === "active" || sub.status === "trialing";
|
|
1035
|
-
});
|
|
1304
|
+
.filter((sub) => isActiveOrTrialing(sub));
|
|
1036
1305
|
return ctx.json(subs);
|
|
1037
1306
|
},
|
|
1038
1307
|
);
|
|
@@ -1058,14 +1327,12 @@ export const subscriptionSuccess = (options: StripeOptions) => {
|
|
|
1058
1327
|
if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) {
|
|
1059
1328
|
throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
|
|
1060
1329
|
}
|
|
1061
|
-
const
|
|
1062
|
-
|
|
1063
|
-
);
|
|
1330
|
+
const { callbackURL, subscriptionId } = ctx.query;
|
|
1331
|
+
|
|
1332
|
+
const session = await getSessionFromCtx<User & WithStripeCustomerId>(ctx);
|
|
1064
1333
|
if (!session) {
|
|
1065
1334
|
throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
|
|
1066
1335
|
}
|
|
1067
|
-
const { user } = session;
|
|
1068
|
-
const { callbackURL, subscriptionId } = ctx.query;
|
|
1069
1336
|
|
|
1070
1337
|
const subscription = await ctx.context.adapter.findOne<Subscription>({
|
|
1071
1338
|
model: "subscription",
|
|
@@ -1076,87 +1343,132 @@ export const subscriptionSuccess = (options: StripeOptions) => {
|
|
|
1076
1343
|
},
|
|
1077
1344
|
],
|
|
1078
1345
|
});
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
return ctx.redirect(getUrl(ctx, callbackURL));
|
|
1346
|
+
if (!subscription) {
|
|
1347
|
+
ctx.context.logger.warn(
|
|
1348
|
+
`Subscription record not found for subscriptionId: ${subscriptionId}`,
|
|
1349
|
+
);
|
|
1350
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1085
1351
|
}
|
|
1086
|
-
const customerId =
|
|
1087
|
-
subscription?.stripeCustomerId || user.stripeCustomerId;
|
|
1088
1352
|
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
customer: customerId,
|
|
1094
|
-
status: "active",
|
|
1095
|
-
})
|
|
1096
|
-
.then((res) => res.data[0]);
|
|
1353
|
+
// Already active or trialing, no need to update
|
|
1354
|
+
if (isActiveOrTrialing(subscription)) {
|
|
1355
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1356
|
+
}
|
|
1097
1357
|
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
);
|
|
1358
|
+
const customerId =
|
|
1359
|
+
subscription.stripeCustomerId || session.user.stripeCustomerId;
|
|
1360
|
+
if (!customerId) {
|
|
1361
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1362
|
+
}
|
|
1104
1363
|
|
|
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) {
|
|
1364
|
+
const stripeSubscription = await client.subscriptions
|
|
1365
|
+
.list({ customer: customerId, status: "active" })
|
|
1366
|
+
.then((res) => res.data[0])
|
|
1367
|
+
.catch((error) => {
|
|
1141
1368
|
ctx.context.logger.error(
|
|
1142
1369
|
"Error fetching subscription from Stripe",
|
|
1143
1370
|
error,
|
|
1144
1371
|
);
|
|
1145
|
-
|
|
1372
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1373
|
+
});
|
|
1374
|
+
if (!stripeSubscription) {
|
|
1375
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1146
1376
|
}
|
|
1377
|
+
|
|
1378
|
+
const subscriptionItem = stripeSubscription.items.data[0];
|
|
1379
|
+
if (!subscriptionItem) {
|
|
1380
|
+
ctx.context.logger.warn(
|
|
1381
|
+
`No subscription items found for Stripe subscription ${stripeSubscription.id}`,
|
|
1382
|
+
);
|
|
1383
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
const plan = await getPlanByPriceInfo(
|
|
1387
|
+
options,
|
|
1388
|
+
subscriptionItem.price.id,
|
|
1389
|
+
subscriptionItem.price.lookup_key,
|
|
1390
|
+
);
|
|
1391
|
+
if (!plan) {
|
|
1392
|
+
ctx.context.logger.warn(
|
|
1393
|
+
`Plan not found for price ${subscriptionItem.price.id}`,
|
|
1394
|
+
);
|
|
1395
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
await ctx.context.adapter.update({
|
|
1399
|
+
model: "subscription",
|
|
1400
|
+
update: {
|
|
1401
|
+
status: stripeSubscription.status,
|
|
1402
|
+
seats: subscriptionItem.quantity || 1,
|
|
1403
|
+
plan: plan.name.toLowerCase(),
|
|
1404
|
+
periodEnd: new Date(subscriptionItem.current_period_end * 1000),
|
|
1405
|
+
periodStart: new Date(subscriptionItem.current_period_start * 1000),
|
|
1406
|
+
stripeSubscriptionId: stripeSubscription.id,
|
|
1407
|
+
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
|
|
1408
|
+
cancelAt: stripeSubscription.cancel_at
|
|
1409
|
+
? new Date(stripeSubscription.cancel_at * 1000)
|
|
1410
|
+
: null,
|
|
1411
|
+
canceledAt: stripeSubscription.canceled_at
|
|
1412
|
+
? new Date(stripeSubscription.canceled_at * 1000)
|
|
1413
|
+
: null,
|
|
1414
|
+
...(stripeSubscription.trial_start && stripeSubscription.trial_end
|
|
1415
|
+
? {
|
|
1416
|
+
trialStart: new Date(stripeSubscription.trial_start * 1000),
|
|
1417
|
+
trialEnd: new Date(stripeSubscription.trial_end * 1000),
|
|
1418
|
+
}
|
|
1419
|
+
: {}),
|
|
1420
|
+
},
|
|
1421
|
+
where: [
|
|
1422
|
+
{
|
|
1423
|
+
field: "id",
|
|
1424
|
+
value: subscription.id,
|
|
1425
|
+
},
|
|
1426
|
+
],
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1147
1429
|
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1148
1430
|
},
|
|
1149
1431
|
);
|
|
1150
1432
|
};
|
|
1151
1433
|
|
|
1152
1434
|
const createBillingPortalBodySchema = z.object({
|
|
1435
|
+
/**
|
|
1436
|
+
* The IETF language tag of the locale Customer Portal is displayed in.
|
|
1437
|
+
* If not provided or set to `auto`, the browser's locale is used.
|
|
1438
|
+
*/
|
|
1153
1439
|
locale: z
|
|
1154
1440
|
.custom<StripeType.Checkout.Session.Locale>((localization) => {
|
|
1155
1441
|
return typeof localization === "string";
|
|
1156
1442
|
})
|
|
1443
|
+
.meta({
|
|
1444
|
+
description:
|
|
1445
|
+
"The IETF language tag of the locale Customer Portal is displayed in. Eg: 'en', 'ko'. If not provided or set to `auto`, the browser's locale is used.",
|
|
1446
|
+
})
|
|
1157
1447
|
.optional(),
|
|
1158
1448
|
referenceId: z.string().optional(),
|
|
1449
|
+
/**
|
|
1450
|
+
* Customer type for the subscription.
|
|
1451
|
+
* - `user`: User owns the subscription (default)
|
|
1452
|
+
* - `organization`: Organization owns the subscription
|
|
1453
|
+
*/
|
|
1454
|
+
customerType: z
|
|
1455
|
+
.enum(["user", "organization"])
|
|
1456
|
+
.meta({
|
|
1457
|
+
description:
|
|
1458
|
+
'Customer type for the subscription. Eg: "user" or "organization"',
|
|
1459
|
+
})
|
|
1460
|
+
.optional(),
|
|
1159
1461
|
returnUrl: z.string().default("/"),
|
|
1462
|
+
/**
|
|
1463
|
+
* Disable Redirect
|
|
1464
|
+
*/
|
|
1465
|
+
disableRedirect: z
|
|
1466
|
+
.boolean()
|
|
1467
|
+
.meta({
|
|
1468
|
+
description:
|
|
1469
|
+
"Disable redirect after creating billing portal session. Eg: true",
|
|
1470
|
+
})
|
|
1471
|
+
.default(false),
|
|
1160
1472
|
});
|
|
1161
1473
|
|
|
1162
1474
|
export const createBillingPortal = (options: StripeOptions) => {
|
|
@@ -1173,41 +1485,61 @@ export const createBillingPortal = (options: StripeOptions) => {
|
|
|
1173
1485
|
},
|
|
1174
1486
|
},
|
|
1175
1487
|
use: [
|
|
1176
|
-
|
|
1177
|
-
originCheck((ctx) => ctx.body.returnUrl),
|
|
1488
|
+
stripeSessionMiddleware,
|
|
1178
1489
|
referenceMiddleware(subscriptionOptions, "billing-portal"),
|
|
1490
|
+
originCheck((ctx) => ctx.body.returnUrl),
|
|
1179
1491
|
],
|
|
1180
1492
|
},
|
|
1181
1493
|
async (ctx) => {
|
|
1182
1494
|
const { user } = ctx.context.session;
|
|
1183
|
-
const
|
|
1495
|
+
const customerType = ctx.body.customerType || "user";
|
|
1496
|
+
const referenceId =
|
|
1497
|
+
ctx.body.referenceId ||
|
|
1498
|
+
getReferenceId(ctx.context.session, customerType, options);
|
|
1184
1499
|
|
|
1185
|
-
let customerId
|
|
1500
|
+
let customerId: string | undefined;
|
|
1186
1501
|
|
|
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
|
-
);
|
|
1502
|
+
if (customerType === "organization") {
|
|
1503
|
+
// Organization billing portal - get customer ID from organization
|
|
1504
|
+
const org = await ctx.context.adapter.findOne<
|
|
1505
|
+
Organization & WithStripeCustomerId
|
|
1506
|
+
>({
|
|
1507
|
+
model: "organization",
|
|
1508
|
+
where: [{ field: "id", value: referenceId }],
|
|
1509
|
+
});
|
|
1510
|
+
customerId = org?.stripeCustomerId;
|
|
1203
1511
|
|
|
1204
|
-
customerId
|
|
1205
|
-
|
|
1512
|
+
if (!customerId) {
|
|
1513
|
+
// Fallback to subscription's stripeCustomerId
|
|
1514
|
+
const subscription = await ctx.context.adapter
|
|
1515
|
+
.findMany<Subscription>({
|
|
1516
|
+
model: "subscription",
|
|
1517
|
+
where: [{ field: "referenceId", value: referenceId }],
|
|
1518
|
+
})
|
|
1519
|
+
.then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
|
|
1520
|
+
customerId = subscription?.stripeCustomerId;
|
|
1521
|
+
}
|
|
1522
|
+
} else {
|
|
1523
|
+
// User billing portal
|
|
1524
|
+
customerId = user.stripeCustomerId;
|
|
1525
|
+
if (!customerId) {
|
|
1526
|
+
const subscription = await ctx.context.adapter
|
|
1527
|
+
.findMany<Subscription>({
|
|
1528
|
+
model: "subscription",
|
|
1529
|
+
where: [
|
|
1530
|
+
{
|
|
1531
|
+
field: "referenceId",
|
|
1532
|
+
value: referenceId,
|
|
1533
|
+
},
|
|
1534
|
+
],
|
|
1535
|
+
})
|
|
1536
|
+
.then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
|
|
1206
1537
|
|
|
1538
|
+
customerId = subscription?.stripeCustomerId;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1207
1541
|
if (!customerId) {
|
|
1208
|
-
throw
|
|
1209
|
-
message: "No Stripe customer found for this user",
|
|
1210
|
-
});
|
|
1542
|
+
throw APIError.from("NOT_FOUND", STRIPE_ERROR_CODES.CUSTOMER_NOT_FOUND);
|
|
1211
1543
|
}
|
|
1212
1544
|
|
|
1213
1545
|
try {
|
|
@@ -1219,16 +1551,17 @@ export const createBillingPortal = (options: StripeOptions) => {
|
|
|
1219
1551
|
|
|
1220
1552
|
return ctx.json({
|
|
1221
1553
|
url,
|
|
1222
|
-
redirect:
|
|
1554
|
+
redirect: !ctx.body.disableRedirect,
|
|
1223
1555
|
});
|
|
1224
1556
|
} catch (error: any) {
|
|
1225
1557
|
ctx.context.logger.error(
|
|
1226
1558
|
"Error creating billing portal session",
|
|
1227
1559
|
error,
|
|
1228
1560
|
);
|
|
1229
|
-
throw
|
|
1230
|
-
|
|
1231
|
-
|
|
1561
|
+
throw APIError.from(
|
|
1562
|
+
"INTERNAL_SERVER_ERROR",
|
|
1563
|
+
STRIPE_ERROR_CODES.UNABLE_TO_CREATE_BILLING_PORTAL,
|
|
1564
|
+
);
|
|
1232
1565
|
}
|
|
1233
1566
|
},
|
|
1234
1567
|
);
|
|
@@ -1247,45 +1580,60 @@ export const stripeWebhook = (options: StripeOptions) => {
|
|
|
1247
1580
|
},
|
|
1248
1581
|
},
|
|
1249
1582
|
cloneRequest: true,
|
|
1250
|
-
//
|
|
1251
|
-
disableBody: true,
|
|
1583
|
+
disableBody: true, // Don't parse the body
|
|
1252
1584
|
},
|
|
1253
1585
|
async (ctx) => {
|
|
1254
1586
|
if (!ctx.request?.body) {
|
|
1255
|
-
throw
|
|
1587
|
+
throw APIError.from(
|
|
1588
|
+
"BAD_REQUEST",
|
|
1589
|
+
STRIPE_ERROR_CODES.INVALID_REQUEST_BODY,
|
|
1590
|
+
);
|
|
1256
1591
|
}
|
|
1257
|
-
|
|
1258
|
-
const sig = ctx.request.headers.get("stripe-signature")
|
|
1592
|
+
|
|
1593
|
+
const sig = ctx.request.headers.get("stripe-signature");
|
|
1594
|
+
if (!sig) {
|
|
1595
|
+
throw APIError.from(
|
|
1596
|
+
"BAD_REQUEST",
|
|
1597
|
+
STRIPE_ERROR_CODES.STRIPE_SIGNATURE_NOT_FOUND,
|
|
1598
|
+
);
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1259
1601
|
const webhookSecret = options.stripeWebhookSecret;
|
|
1602
|
+
if (!webhookSecret) {
|
|
1603
|
+
throw APIError.from(
|
|
1604
|
+
"INTERNAL_SERVER_ERROR",
|
|
1605
|
+
STRIPE_ERROR_CODES.STRIPE_WEBHOOK_SECRET_NOT_FOUND,
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
const payload = await ctx.request.text();
|
|
1610
|
+
|
|
1260
1611
|
let event: Stripe.Event;
|
|
1261
1612
|
try {
|
|
1262
|
-
if (!sig || !webhookSecret) {
|
|
1263
|
-
throw new APIError("BAD_REQUEST", {
|
|
1264
|
-
message: "Stripe webhook secret not found",
|
|
1265
|
-
});
|
|
1266
|
-
}
|
|
1267
1613
|
// Support both Stripe v18 (constructEvent) and v19+ (constructEventAsync)
|
|
1268
1614
|
if (typeof client.webhooks.constructEventAsync === "function") {
|
|
1269
1615
|
// Stripe v19+ - use async method
|
|
1270
1616
|
event = await client.webhooks.constructEventAsync(
|
|
1271
|
-
|
|
1617
|
+
payload,
|
|
1272
1618
|
sig,
|
|
1273
1619
|
webhookSecret,
|
|
1274
1620
|
);
|
|
1275
1621
|
} else {
|
|
1276
1622
|
// Stripe v18 - use sync method
|
|
1277
|
-
event = client.webhooks.constructEvent(
|
|
1623
|
+
event = client.webhooks.constructEvent(payload, sig, webhookSecret);
|
|
1278
1624
|
}
|
|
1279
1625
|
} catch (err: any) {
|
|
1280
1626
|
ctx.context.logger.error(`${err.message}`);
|
|
1281
|
-
throw
|
|
1282
|
-
|
|
1283
|
-
|
|
1627
|
+
throw APIError.from(
|
|
1628
|
+
"BAD_REQUEST",
|
|
1629
|
+
STRIPE_ERROR_CODES.FAILED_TO_CONSTRUCT_STRIPE_EVENT,
|
|
1630
|
+
);
|
|
1284
1631
|
}
|
|
1285
1632
|
if (!event) {
|
|
1286
|
-
throw
|
|
1287
|
-
|
|
1288
|
-
|
|
1633
|
+
throw APIError.from(
|
|
1634
|
+
"BAD_REQUEST",
|
|
1635
|
+
STRIPE_ERROR_CODES.FAILED_TO_CONSTRUCT_STRIPE_EVENT,
|
|
1636
|
+
);
|
|
1289
1637
|
}
|
|
1290
1638
|
try {
|
|
1291
1639
|
switch (event.type) {
|
|
@@ -1293,6 +1641,10 @@ export const stripeWebhook = (options: StripeOptions) => {
|
|
|
1293
1641
|
await onCheckoutSessionCompleted(ctx, options, event);
|
|
1294
1642
|
await options.onEvent?.(event);
|
|
1295
1643
|
break;
|
|
1644
|
+
case "customer.subscription.created":
|
|
1645
|
+
await onSubscriptionCreated(ctx, options, event);
|
|
1646
|
+
await options.onEvent?.(event);
|
|
1647
|
+
break;
|
|
1296
1648
|
case "customer.subscription.updated":
|
|
1297
1649
|
await onSubscriptionUpdated(ctx, options, event);
|
|
1298
1650
|
await options.onEvent?.(event);
|
|
@@ -1307,33 +1659,12 @@ export const stripeWebhook = (options: StripeOptions) => {
|
|
|
1307
1659
|
}
|
|
1308
1660
|
} catch (e: any) {
|
|
1309
1661
|
ctx.context.logger.error(`Stripe webhook failed. Error: ${e.message}`);
|
|
1310
|
-
throw
|
|
1311
|
-
|
|
1312
|
-
|
|
1662
|
+
throw APIError.from(
|
|
1663
|
+
"BAD_REQUEST",
|
|
1664
|
+
STRIPE_ERROR_CODES.STRIPE_WEBHOOK_ERROR,
|
|
1665
|
+
);
|
|
1313
1666
|
}
|
|
1314
1667
|
return ctx.json({ success: true });
|
|
1315
1668
|
},
|
|
1316
1669
|
);
|
|
1317
1670
|
};
|
|
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
|
-
}
|