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