@better-auth/stripe 1.5.0-beta.2 → 1.5.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +9 -9
- package/CHANGELOG.md +15 -13
- package/LICENSE.md +15 -12
- package/dist/client.d.mts +105 -1
- package/dist/client.mjs +1 -1
- package/dist/error-codes-Bkj5yJMT.mjs +29 -0
- package/dist/{index-SbT5j9k6.d.mts → index-BnHmwMru.d.mts} +269 -154
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +449 -194
- package/package.json +6 -6
- package/src/error-codes.ts +16 -0
- package/src/hooks.ts +98 -71
- package/src/index.ts +142 -45
- package/src/middleware.ts +89 -42
- package/src/routes.ts +502 -224
- package/src/schema.ts +18 -0
- package/src/types.ts +75 -19
- package/src/utils.ts +11 -0
- package/test/stripe-organization.test.ts +1993 -0
- package/{src → test}/stripe.test.ts +821 -18
- package/dist/error-codes-qqooUh6R.mjs +0 -16
package/dist/index.mjs
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { t as STRIPE_ERROR_CODES
|
|
2
|
-
import {
|
|
1
|
+
import { t as STRIPE_ERROR_CODES } from "./error-codes-Bkj5yJMT.mjs";
|
|
2
|
+
import { APIError, HIDE_METADATA } from "better-auth";
|
|
3
3
|
import { defu } from "defu";
|
|
4
4
|
import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
|
|
5
|
-
import { APIError } from "@better-auth/core/error";
|
|
6
|
-
import {
|
|
7
|
-
import { APIError as APIError$1, getSessionFromCtx, originCheck, sessionMiddleware } from "better-auth/api";
|
|
5
|
+
import { APIError as APIError$1 } from "@better-auth/core/error";
|
|
6
|
+
import { getSessionFromCtx, originCheck, sessionMiddleware } from "better-auth/api";
|
|
8
7
|
import * as z from "zod/v4";
|
|
9
8
|
import { mergeSchema } from "better-auth/db";
|
|
10
9
|
|
|
@@ -37,21 +36,68 @@ function isPendingCancel(sub) {
|
|
|
37
36
|
function isStripePendingCancel(stripeSub) {
|
|
38
37
|
return !!(stripeSub.cancel_at_period_end || stripeSub.cancel_at);
|
|
39
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Escapes a value for use in Stripe search queries.
|
|
41
|
+
* Stripe search query uses double quotes for string values,
|
|
42
|
+
* and double quotes within the value need to be escaped with backslash.
|
|
43
|
+
*
|
|
44
|
+
* @see https://docs.stripe.com/search#search-query-language
|
|
45
|
+
*/
|
|
46
|
+
function escapeStripeSearchValue(value) {
|
|
47
|
+
return value.replace(/"/g, "\\\"");
|
|
48
|
+
}
|
|
40
49
|
|
|
41
50
|
//#endregion
|
|
42
51
|
//#region src/hooks.ts
|
|
52
|
+
/**
|
|
53
|
+
* Find organization or user by stripeCustomerId.
|
|
54
|
+
* @internal
|
|
55
|
+
*/
|
|
56
|
+
async function findReferenceByStripeCustomerId(ctx, options, stripeCustomerId) {
|
|
57
|
+
if (options.organization?.enabled) {
|
|
58
|
+
const org = await ctx.context.adapter.findOne({
|
|
59
|
+
model: "organization",
|
|
60
|
+
where: [{
|
|
61
|
+
field: "stripeCustomerId",
|
|
62
|
+
value: stripeCustomerId
|
|
63
|
+
}]
|
|
64
|
+
});
|
|
65
|
+
if (org) return {
|
|
66
|
+
customerType: "organization",
|
|
67
|
+
referenceId: org.id
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const user$1 = await ctx.context.adapter.findOne({
|
|
71
|
+
model: "user",
|
|
72
|
+
where: [{
|
|
73
|
+
field: "stripeCustomerId",
|
|
74
|
+
value: stripeCustomerId
|
|
75
|
+
}]
|
|
76
|
+
});
|
|
77
|
+
if (user$1) return {
|
|
78
|
+
customerType: "user",
|
|
79
|
+
referenceId: user$1.id
|
|
80
|
+
};
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
43
83
|
async function onCheckoutSessionCompleted(ctx, options, event) {
|
|
44
84
|
try {
|
|
45
85
|
const client = options.stripeClient;
|
|
46
86
|
const checkoutSession = event.data.object;
|
|
47
87
|
if (checkoutSession.mode === "setup" || !options.subscription?.enabled) return;
|
|
48
88
|
const subscription = await client.subscriptions.retrieve(checkoutSession.subscription);
|
|
49
|
-
const
|
|
50
|
-
|
|
89
|
+
const subscriptionItem = subscription.items.data[0];
|
|
90
|
+
if (!subscriptionItem) {
|
|
91
|
+
ctx.context.logger.warn(`Stripe webhook warning: Subscription ${subscription.id} has no items`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const priceId = subscriptionItem.price.id;
|
|
95
|
+
const priceLookupKey = subscriptionItem.price.lookup_key;
|
|
96
|
+
const plan = await getPlanByPriceInfo(options, priceId, priceLookupKey);
|
|
51
97
|
if (plan) {
|
|
52
98
|
const referenceId = checkoutSession?.client_reference_id || checkoutSession?.metadata?.referenceId;
|
|
53
99
|
const subscriptionId = checkoutSession?.metadata?.subscriptionId;
|
|
54
|
-
const seats =
|
|
100
|
+
const seats = subscriptionItem.quantity;
|
|
55
101
|
if (referenceId && subscriptionId) {
|
|
56
102
|
const trial = subscription.trial_start && subscription.trial_end ? {
|
|
57
103
|
trialStart: /* @__PURE__ */ new Date(subscription.trial_start * 1e3),
|
|
@@ -63,8 +109,8 @@ async function onCheckoutSessionCompleted(ctx, options, event) {
|
|
|
63
109
|
plan: plan.name.toLowerCase(),
|
|
64
110
|
status: subscription.status,
|
|
65
111
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
66
|
-
periodStart: /* @__PURE__ */ new Date(
|
|
67
|
-
periodEnd: /* @__PURE__ */ new Date(
|
|
112
|
+
periodStart: /* @__PURE__ */ new Date(subscriptionItem.current_period_start * 1e3),
|
|
113
|
+
periodEnd: /* @__PURE__ */ new Date(subscriptionItem.current_period_end * 1e3),
|
|
68
114
|
stripeSubscriptionId: checkoutSession.subscription,
|
|
69
115
|
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
70
116
|
cancelAt: subscription.cancel_at ? /* @__PURE__ */ new Date(subscription.cancel_at * 1e3) : null,
|
|
@@ -108,27 +154,27 @@ async function onSubscriptionCreated(ctx, options, event) {
|
|
|
108
154
|
ctx.context.logger.warn(`Stripe webhook warning: customer.subscription.created event received without customer ID`);
|
|
109
155
|
return;
|
|
110
156
|
}
|
|
111
|
-
|
|
157
|
+
const subscriptionId = subscriptionCreated.metadata?.subscriptionId;
|
|
158
|
+
const existingSubscription = await ctx.context.adapter.findOne({
|
|
112
159
|
model: "subscription",
|
|
113
|
-
where: [{
|
|
160
|
+
where: subscriptionId ? [{
|
|
161
|
+
field: "id",
|
|
162
|
+
value: subscriptionId
|
|
163
|
+
}] : [{
|
|
114
164
|
field: "stripeSubscriptionId",
|
|
115
165
|
value: subscriptionCreated.id
|
|
116
166
|
}]
|
|
117
|
-
})
|
|
118
|
-
|
|
167
|
+
});
|
|
168
|
+
if (existingSubscription) {
|
|
169
|
+
ctx.context.logger.info(`Stripe webhook: Subscription already exists in database (id: ${existingSubscription.id}), skipping creation`);
|
|
119
170
|
return;
|
|
120
171
|
}
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
field: "stripeCustomerId",
|
|
125
|
-
value: stripeCustomerId
|
|
126
|
-
}]
|
|
127
|
-
});
|
|
128
|
-
if (!user$1) {
|
|
129
|
-
ctx.context.logger.warn(`Stripe webhook warning: No user found with stripeCustomerId: ${stripeCustomerId}`);
|
|
172
|
+
const reference = await findReferenceByStripeCustomerId(ctx, options, stripeCustomerId);
|
|
173
|
+
if (!reference) {
|
|
174
|
+
ctx.context.logger.warn(`Stripe webhook warning: No user or organization found with stripeCustomerId: ${stripeCustomerId}`);
|
|
130
175
|
return;
|
|
131
176
|
}
|
|
177
|
+
const { referenceId, customerType } = reference;
|
|
132
178
|
const subscriptionItem = subscriptionCreated.items.data[0];
|
|
133
179
|
if (!subscriptionItem) {
|
|
134
180
|
ctx.context.logger.warn(`Stripe webhook warning: Subscription ${subscriptionCreated.id} has no items`);
|
|
@@ -150,7 +196,7 @@ async function onSubscriptionCreated(ctx, options, event) {
|
|
|
150
196
|
const newSubscription = await ctx.context.adapter.create({
|
|
151
197
|
model: "subscription",
|
|
152
198
|
data: {
|
|
153
|
-
referenceId
|
|
199
|
+
referenceId,
|
|
154
200
|
stripeCustomerId,
|
|
155
201
|
stripeSubscriptionId: subscriptionCreated.id,
|
|
156
202
|
status: subscriptionCreated.status,
|
|
@@ -162,8 +208,8 @@ async function onSubscriptionCreated(ctx, options, event) {
|
|
|
162
208
|
...trial
|
|
163
209
|
}
|
|
164
210
|
});
|
|
165
|
-
ctx.context.logger.info(`Stripe webhook: Created subscription ${subscriptionCreated.id} for
|
|
166
|
-
await options.subscription
|
|
211
|
+
ctx.context.logger.info(`Stripe webhook: Created subscription ${subscriptionCreated.id} for ${customerType} ${referenceId} from dashboard`);
|
|
212
|
+
await options.subscription.onSubscriptionCreated?.({
|
|
167
213
|
event,
|
|
168
214
|
subscription: newSubscription,
|
|
169
215
|
stripeSubscription: subscriptionCreated,
|
|
@@ -177,8 +223,14 @@ async function onSubscriptionUpdated(ctx, options, event) {
|
|
|
177
223
|
try {
|
|
178
224
|
if (!options.subscription?.enabled) return;
|
|
179
225
|
const subscriptionUpdated = event.data.object;
|
|
180
|
-
const
|
|
181
|
-
|
|
226
|
+
const subscriptionItem = subscriptionUpdated.items.data[0];
|
|
227
|
+
if (!subscriptionItem) {
|
|
228
|
+
ctx.context.logger.warn(`Stripe webhook warning: Subscription ${subscriptionUpdated.id} has no items`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const priceId = subscriptionItem.price.id;
|
|
232
|
+
const priceLookupKey = subscriptionItem.price.lookup_key;
|
|
233
|
+
const plan = await getPlanByPriceInfo(options, priceId, priceLookupKey);
|
|
182
234
|
const subscriptionId = subscriptionUpdated.metadata?.subscriptionId;
|
|
183
235
|
const customerId = subscriptionUpdated.customer?.toString();
|
|
184
236
|
let subscription = await ctx.context.adapter.findOne({
|
|
@@ -208,7 +260,6 @@ async function onSubscriptionUpdated(ctx, options, event) {
|
|
|
208
260
|
subscription = activeSub;
|
|
209
261
|
} else subscription = subs[0];
|
|
210
262
|
}
|
|
211
|
-
const seats = subscriptionUpdated.items.data[0].quantity;
|
|
212
263
|
const updatedSubscription = await ctx.context.adapter.update({
|
|
213
264
|
model: "subscription",
|
|
214
265
|
update: {
|
|
@@ -218,13 +269,13 @@ async function onSubscriptionUpdated(ctx, options, event) {
|
|
|
218
269
|
} : {},
|
|
219
270
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
220
271
|
status: subscriptionUpdated.status,
|
|
221
|
-
periodStart: /* @__PURE__ */ new Date(
|
|
222
|
-
periodEnd: /* @__PURE__ */ new Date(
|
|
272
|
+
periodStart: /* @__PURE__ */ new Date(subscriptionItem.current_period_start * 1e3),
|
|
273
|
+
periodEnd: /* @__PURE__ */ new Date(subscriptionItem.current_period_end * 1e3),
|
|
223
274
|
cancelAtPeriodEnd: subscriptionUpdated.cancel_at_period_end,
|
|
224
275
|
cancelAt: subscriptionUpdated.cancel_at ? /* @__PURE__ */ new Date(subscriptionUpdated.cancel_at * 1e3) : null,
|
|
225
276
|
canceledAt: subscriptionUpdated.canceled_at ? /* @__PURE__ */ new Date(subscriptionUpdated.canceled_at * 1e3) : null,
|
|
226
277
|
endedAt: subscriptionUpdated.ended_at ? /* @__PURE__ */ new Date(subscriptionUpdated.ended_at * 1e3) : null,
|
|
227
|
-
seats,
|
|
278
|
+
seats: subscriptionItem.quantity,
|
|
228
279
|
stripeSubscriptionId: subscriptionUpdated.id
|
|
229
280
|
},
|
|
230
281
|
where: [{
|
|
@@ -291,33 +342,86 @@ async function onSubscriptionDeleted(ctx, options, event) {
|
|
|
291
342
|
|
|
292
343
|
//#endregion
|
|
293
344
|
//#region src/middleware.ts
|
|
345
|
+
const stripeSessionMiddleware = createAuthMiddleware({ use: [sessionMiddleware] }, async (ctx) => {
|
|
346
|
+
return { session: ctx.context.session };
|
|
347
|
+
});
|
|
294
348
|
const referenceMiddleware = (subscriptionOptions, action) => createAuthMiddleware(async (ctx) => {
|
|
295
|
-
const
|
|
296
|
-
if (!
|
|
297
|
-
const
|
|
298
|
-
|
|
349
|
+
const ctxSession = ctx.context.session;
|
|
350
|
+
if (!ctxSession) throw APIError$1.from("UNAUTHORIZED", STRIPE_ERROR_CODES.UNAUTHORIZED);
|
|
351
|
+
const customerType = ctx.body?.customerType || ctx.query?.customerType;
|
|
352
|
+
const explicitReferenceId = ctx.body?.referenceId || ctx.query?.referenceId;
|
|
353
|
+
if (customerType === "organization") {
|
|
354
|
+
if (!subscriptionOptions.authorizeReference) {
|
|
355
|
+
ctx.context.logger.error(`Organization subscriptions require authorizeReference to be defined in your stripe plugin config.`);
|
|
356
|
+
throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.ORGANIZATION_SUBSCRIPTION_NOT_ENABLED);
|
|
357
|
+
}
|
|
358
|
+
const referenceId = explicitReferenceId || ctxSession.session.activeOrganizationId;
|
|
359
|
+
if (!referenceId) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.ORGANIZATION_REFERENCE_ID_REQUIRED);
|
|
360
|
+
if (!await subscriptionOptions.authorizeReference({
|
|
361
|
+
user: ctxSession.user,
|
|
362
|
+
session: ctxSession.session,
|
|
363
|
+
referenceId,
|
|
364
|
+
action
|
|
365
|
+
}, ctx)) throw APIError$1.from("UNAUTHORIZED", STRIPE_ERROR_CODES.UNAUTHORIZED);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (!explicitReferenceId) return;
|
|
369
|
+
if (explicitReferenceId === ctxSession.user.id) return;
|
|
370
|
+
if (!subscriptionOptions.authorizeReference) {
|
|
299
371
|
ctx.context.logger.error(`Passing referenceId into a subscription action isn't allowed if subscription.authorizeReference isn't defined in your stripe plugin config.`);
|
|
300
|
-
throw
|
|
372
|
+
throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.REFERENCE_ID_NOT_ALLOWED);
|
|
301
373
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if (!(ctx.body?.referenceId || ctx.query?.referenceId ? await subscriptionOptions.authorizeReference?.({
|
|
307
|
-
user: session.user,
|
|
308
|
-
session: session.session,
|
|
309
|
-
referenceId,
|
|
374
|
+
if (!await subscriptionOptions.authorizeReference({
|
|
375
|
+
user: ctxSession.user,
|
|
376
|
+
session: ctxSession.session,
|
|
377
|
+
referenceId: explicitReferenceId,
|
|
310
378
|
action
|
|
311
|
-
}, ctx)
|
|
379
|
+
}, ctx)) throw APIError$1.from("UNAUTHORIZED", STRIPE_ERROR_CODES.UNAUTHORIZED);
|
|
312
380
|
});
|
|
313
381
|
|
|
314
382
|
//#endregion
|
|
315
383
|
//#region src/routes.ts
|
|
384
|
+
/**
|
|
385
|
+
* Converts a relative URL to an absolute URL using baseURL.
|
|
386
|
+
* @internal
|
|
387
|
+
*/
|
|
388
|
+
function getUrl(ctx, url) {
|
|
389
|
+
if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(url)) return url;
|
|
390
|
+
return `${ctx.context.options.baseURL}${url.startsWith("/") ? url : `/${url}`}`;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Resolves a Stripe price ID from a lookup key.
|
|
394
|
+
* @internal
|
|
395
|
+
*/
|
|
396
|
+
async function resolvePriceIdFromLookupKey(stripeClient, lookupKey) {
|
|
397
|
+
if (!lookupKey) return void 0;
|
|
398
|
+
return (await stripeClient.prices.list({
|
|
399
|
+
lookup_keys: [lookupKey],
|
|
400
|
+
active: true,
|
|
401
|
+
limit: 1
|
|
402
|
+
})).data[0]?.id;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Determines the reference ID based on customer type.
|
|
406
|
+
* - `user` (default): uses userId
|
|
407
|
+
* - `organization`: uses activeOrganizationId from session
|
|
408
|
+
* @internal
|
|
409
|
+
*/
|
|
410
|
+
function getReferenceId(ctxSession, customerType, options) {
|
|
411
|
+
const { user: user$1, session } = ctxSession;
|
|
412
|
+
if ((customerType || "user") === "organization") {
|
|
413
|
+
if (!options.organization?.enabled) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.ORGANIZATION_SUBSCRIPTION_NOT_ENABLED);
|
|
414
|
+
if (!session.activeOrganizationId) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.ORGANIZATION_NOT_FOUND);
|
|
415
|
+
return session.activeOrganizationId;
|
|
416
|
+
}
|
|
417
|
+
return user$1.id;
|
|
418
|
+
}
|
|
316
419
|
const upgradeSubscriptionBodySchema = z.object({
|
|
317
420
|
plan: z.string().meta({ description: "The name of the plan to upgrade to. Eg: \"pro\"" }),
|
|
318
421
|
annual: z.boolean().meta({ description: "Whether to upgrade to an annual plan. Eg: true" }).optional(),
|
|
319
|
-
referenceId: z.string().meta({ description: "Reference
|
|
422
|
+
referenceId: z.string().meta({ description: "Reference ID for the subscription. Eg: \"org_123\"" }).optional(),
|
|
320
423
|
subscriptionId: z.string().meta({ description: "The Stripe subscription ID to upgrade. Eg: \"sub_1ABC2DEF3GHI4JKL\"" }).optional(),
|
|
424
|
+
customerType: z.enum(["user", "organization"]).meta({ description: "Customer type for the subscription. Eg: \"user\" or \"organization\"" }).optional(),
|
|
321
425
|
metadata: z.record(z.string(), z.any()).optional(),
|
|
322
426
|
seats: z.number().meta({ description: "Number of seats to upgrade to (if applicable). Eg: 1" }).optional(),
|
|
323
427
|
successUrl: z.string().meta({ description: "Callback URL to redirect back after successful subscription. Eg: \"https://example.com/success\"" }).default("/"),
|
|
@@ -348,18 +452,19 @@ const upgradeSubscription = (options) => {
|
|
|
348
452
|
body: upgradeSubscriptionBodySchema,
|
|
349
453
|
metadata: { openapi: { operationId: "upgradeSubscription" } },
|
|
350
454
|
use: [
|
|
351
|
-
|
|
455
|
+
stripeSessionMiddleware,
|
|
456
|
+
referenceMiddleware(subscriptionOptions, "upgrade-subscription"),
|
|
352
457
|
originCheck((c) => {
|
|
353
458
|
return [c.body.successUrl, c.body.cancelUrl];
|
|
354
|
-
})
|
|
355
|
-
referenceMiddleware(subscriptionOptions, "upgrade-subscription")
|
|
459
|
+
})
|
|
356
460
|
]
|
|
357
461
|
}, async (ctx) => {
|
|
358
462
|
const { user: user$1, session } = ctx.context.session;
|
|
359
|
-
|
|
360
|
-
const referenceId = ctx.body.referenceId ||
|
|
463
|
+
const customerType = ctx.body.customerType || "user";
|
|
464
|
+
const referenceId = ctx.body.referenceId || getReferenceId(ctx.context.session, customerType, options);
|
|
465
|
+
if (!user$1.emailVerified && subscriptionOptions.requireEmailVerification) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED);
|
|
361
466
|
const plan = await getPlanByName(options, ctx.body.plan);
|
|
362
|
-
if (!plan) throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES
|
|
467
|
+
if (!plan) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_PLAN_NOT_FOUND);
|
|
363
468
|
let subscriptionToUpdate = ctx.body.subscriptionId ? await ctx.context.adapter.findOne({
|
|
364
469
|
model: "subscription",
|
|
365
470
|
where: [{
|
|
@@ -374,39 +479,94 @@ const upgradeSubscription = (options) => {
|
|
|
374
479
|
}]
|
|
375
480
|
}) : null;
|
|
376
481
|
if (ctx.body.subscriptionId && subscriptionToUpdate && subscriptionToUpdate.referenceId !== referenceId) subscriptionToUpdate = null;
|
|
377
|
-
if (ctx.body.subscriptionId && !subscriptionToUpdate) throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES
|
|
378
|
-
let customerId
|
|
379
|
-
if (
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
482
|
+
if (ctx.body.subscriptionId && !subscriptionToUpdate) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
|
|
483
|
+
let customerId;
|
|
484
|
+
if (customerType === "organization") {
|
|
485
|
+
customerId = subscriptionToUpdate?.stripeCustomerId;
|
|
486
|
+
if (!customerId) {
|
|
487
|
+
const org = await ctx.context.adapter.findOne({
|
|
488
|
+
model: "organization",
|
|
489
|
+
where: [{
|
|
490
|
+
field: "id",
|
|
491
|
+
value: referenceId
|
|
492
|
+
}]
|
|
493
|
+
});
|
|
494
|
+
if (!org) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.ORGANIZATION_NOT_FOUND);
|
|
495
|
+
customerId = org.stripeCustomerId;
|
|
496
|
+
if (!customerId) try {
|
|
497
|
+
let stripeCustomer = (await client.customers.search({
|
|
498
|
+
query: `metadata["organizationId"]:"${org.id}"`,
|
|
499
|
+
limit: 1
|
|
500
|
+
})).data[0];
|
|
501
|
+
if (!stripeCustomer) {
|
|
502
|
+
let extraCreateParams = {};
|
|
503
|
+
if (options.organization?.getCustomerCreateParams) extraCreateParams = await options.organization.getCustomerCreateParams(org, ctx);
|
|
504
|
+
const customerParams = defu({
|
|
505
|
+
name: org.name,
|
|
506
|
+
metadata: {
|
|
507
|
+
...ctx.body.metadata,
|
|
508
|
+
organizationId: org.id,
|
|
509
|
+
customerType: "organization"
|
|
510
|
+
}
|
|
511
|
+
}, extraCreateParams);
|
|
512
|
+
stripeCustomer = await client.customers.create(customerParams);
|
|
513
|
+
await options.organization?.onCustomerCreate?.({
|
|
514
|
+
stripeCustomer,
|
|
515
|
+
organization: {
|
|
516
|
+
...org,
|
|
517
|
+
stripeCustomerId: stripeCustomer.id
|
|
518
|
+
}
|
|
519
|
+
}, ctx);
|
|
520
|
+
}
|
|
521
|
+
await ctx.context.adapter.update({
|
|
522
|
+
model: "organization",
|
|
523
|
+
update: { stripeCustomerId: stripeCustomer.id },
|
|
524
|
+
where: [{
|
|
525
|
+
field: "id",
|
|
526
|
+
value: org.id
|
|
527
|
+
}]
|
|
528
|
+
});
|
|
529
|
+
customerId = stripeCustomer.id;
|
|
530
|
+
} catch (e) {
|
|
531
|
+
ctx.context.logger.error(e);
|
|
532
|
+
throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER);
|
|
390
533
|
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}]
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
534
|
+
}
|
|
535
|
+
} else {
|
|
536
|
+
customerId = subscriptionToUpdate?.stripeCustomerId || user$1.stripeCustomerId;
|
|
537
|
+
if (!customerId) try {
|
|
538
|
+
let stripeCustomer = (await client.customers.search({
|
|
539
|
+
query: `email:"${escapeStripeSearchValue(user$1.email)}" AND -metadata["customerType"]:"organization"`,
|
|
540
|
+
limit: 1
|
|
541
|
+
})).data[0];
|
|
542
|
+
if (!stripeCustomer) stripeCustomer = await client.customers.create({
|
|
543
|
+
email: user$1.email,
|
|
544
|
+
name: user$1.name,
|
|
545
|
+
metadata: {
|
|
546
|
+
...ctx.body.metadata,
|
|
547
|
+
userId: user$1.id,
|
|
548
|
+
customerType: "user"
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
await ctx.context.adapter.update({
|
|
552
|
+
model: "user",
|
|
553
|
+
update: { stripeCustomerId: stripeCustomer.id },
|
|
554
|
+
where: [{
|
|
555
|
+
field: "id",
|
|
556
|
+
value: user$1.id
|
|
557
|
+
}]
|
|
558
|
+
});
|
|
559
|
+
customerId = stripeCustomer.id;
|
|
560
|
+
} catch (e) {
|
|
561
|
+
ctx.context.logger.error(e);
|
|
562
|
+
throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER);
|
|
563
|
+
}
|
|
404
564
|
}
|
|
405
565
|
const subscriptions$1 = subscriptionToUpdate ? [subscriptionToUpdate] : await ctx.context.adapter.findMany({
|
|
406
566
|
model: "subscription",
|
|
407
567
|
where: [{
|
|
408
568
|
field: "referenceId",
|
|
409
|
-
value:
|
|
569
|
+
value: referenceId
|
|
410
570
|
}]
|
|
411
571
|
});
|
|
412
572
|
const activeOrTrialingSubscription = subscriptions$1.find((sub) => isActiveOrTrialing(sub));
|
|
@@ -416,7 +576,7 @@ const upgradeSubscription = (options) => {
|
|
|
416
576
|
return false;
|
|
417
577
|
});
|
|
418
578
|
const incompleteSubscription = subscriptions$1.find((sub) => sub.status === "incomplete");
|
|
419
|
-
if (activeOrTrialingSubscription && activeOrTrialingSubscription.status === "active" && activeOrTrialingSubscription.plan === ctx.body.plan && activeOrTrialingSubscription.seats === (ctx.body.seats || 1)) throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES
|
|
579
|
+
if (activeOrTrialingSubscription && activeOrTrialingSubscription.status === "active" && activeOrTrialingSubscription.plan === ctx.body.plan && activeOrTrialingSubscription.seats === (ctx.body.seats || 1)) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.ALREADY_SUBSCRIBED_PLAN);
|
|
420
580
|
if (activeSubscription && customerId) {
|
|
421
581
|
let dbSubscription = await ctx.context.adapter.findOne({
|
|
422
582
|
model: "subscription",
|
|
@@ -502,7 +662,7 @@ const upgradeSubscription = (options) => {
|
|
|
502
662
|
});
|
|
503
663
|
if (!subscription) {
|
|
504
664
|
ctx.context.logger.error("Subscription ID not found");
|
|
505
|
-
throw
|
|
665
|
+
throw APIError$1.from("NOT_FOUND", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
|
|
506
666
|
}
|
|
507
667
|
const params = await subscriptionOptions.getCheckoutSessionParams?.({
|
|
508
668
|
user: user$1,
|
|
@@ -530,26 +690,36 @@ const upgradeSubscription = (options) => {
|
|
|
530
690
|
const checkoutSession = await client.checkout.sessions.create({
|
|
531
691
|
...customerId ? {
|
|
532
692
|
customer: customerId,
|
|
533
|
-
customer_update: {
|
|
693
|
+
customer_update: customerType !== "user" ? { address: "auto" } : {
|
|
534
694
|
name: "auto",
|
|
535
695
|
address: "auto"
|
|
536
696
|
}
|
|
537
|
-
} : { customer_email:
|
|
697
|
+
} : { customer_email: user$1.email },
|
|
538
698
|
success_url: getUrl(ctx, `${ctx.context.baseURL}/subscription/success?callbackURL=${encodeURIComponent(ctx.body.successUrl)}&subscriptionId=${encodeURIComponent(subscription.id)}`),
|
|
539
699
|
cancel_url: getUrl(ctx, ctx.body.cancelUrl),
|
|
540
700
|
line_items: [{
|
|
541
701
|
price: priceIdToUse,
|
|
542
702
|
quantity: ctx.body.seats || 1
|
|
543
703
|
}],
|
|
544
|
-
subscription_data: {
|
|
704
|
+
subscription_data: {
|
|
705
|
+
...freeTrial,
|
|
706
|
+
metadata: {
|
|
707
|
+
...ctx.body.metadata,
|
|
708
|
+
...params?.params?.subscription_data?.metadata,
|
|
709
|
+
userId: user$1.id,
|
|
710
|
+
subscriptionId: subscription.id,
|
|
711
|
+
referenceId
|
|
712
|
+
}
|
|
713
|
+
},
|
|
545
714
|
mode: "subscription",
|
|
546
715
|
client_reference_id: referenceId,
|
|
547
716
|
...params?.params,
|
|
548
717
|
metadata: {
|
|
718
|
+
...ctx.body.metadata,
|
|
719
|
+
...params?.params?.metadata,
|
|
549
720
|
userId: user$1.id,
|
|
550
721
|
subscriptionId: subscription.id,
|
|
551
|
-
referenceId
|
|
552
|
-
...params?.params?.metadata
|
|
722
|
+
referenceId
|
|
553
723
|
}
|
|
554
724
|
}, params?.options).catch(async (e) => {
|
|
555
725
|
throw ctx.error("BAD_REQUEST", {
|
|
@@ -621,6 +791,7 @@ const cancelSubscriptionCallback = (options) => {
|
|
|
621
791
|
const cancelSubscriptionBodySchema = z.object({
|
|
622
792
|
referenceId: z.string().meta({ description: "Reference id of the subscription to cancel. Eg: '123'" }).optional(),
|
|
623
793
|
subscriptionId: z.string().meta({ description: "The Stripe subscription ID to cancel. Eg: 'sub_1ABC2DEF3GHI4JKL'" }).optional(),
|
|
794
|
+
customerType: z.enum(["user", "organization"]).meta({ description: "Customer type for the subscription. Eg: \"user\" or \"organization\"" }).optional(),
|
|
624
795
|
returnUrl: z.string().meta({ description: "URL to take customers to when they click on the billing portal's link to return to your website. Eg: \"/account\"" }),
|
|
625
796
|
disableRedirect: z.boolean().meta({ description: "Disable redirect after successful subscription cancellation. Eg: true" }).default(false)
|
|
626
797
|
});
|
|
@@ -647,12 +818,13 @@ const cancelSubscription = (options) => {
|
|
|
647
818
|
body: cancelSubscriptionBodySchema,
|
|
648
819
|
metadata: { openapi: { operationId: "cancelSubscription" } },
|
|
649
820
|
use: [
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
821
|
+
stripeSessionMiddleware,
|
|
822
|
+
referenceMiddleware(subscriptionOptions, "cancel-subscription"),
|
|
823
|
+
originCheck((ctx) => ctx.body.returnUrl)
|
|
653
824
|
]
|
|
654
825
|
}, async (ctx) => {
|
|
655
|
-
const
|
|
826
|
+
const customerType = ctx.body.customerType || "user";
|
|
827
|
+
const referenceId = ctx.body.referenceId || getReferenceId(ctx.context.session, customerType, options);
|
|
656
828
|
let subscription = ctx.body.subscriptionId ? await ctx.context.adapter.findOne({
|
|
657
829
|
model: "subscription",
|
|
658
830
|
where: [{
|
|
@@ -667,7 +839,7 @@ const cancelSubscription = (options) => {
|
|
|
667
839
|
}]
|
|
668
840
|
}).then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
|
|
669
841
|
if (ctx.body.subscriptionId && subscription && subscription.referenceId !== referenceId) subscription = void 0;
|
|
670
|
-
if (!subscription || !subscription.stripeCustomerId) throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES
|
|
842
|
+
if (!subscription || !subscription.stripeCustomerId) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
|
|
671
843
|
const activeSubscriptions = await client.subscriptions.list({ customer: subscription.stripeCustomerId }).then((res) => res.data.filter((sub) => isActiveOrTrialing(sub)));
|
|
672
844
|
if (!activeSubscriptions.length) {
|
|
673
845
|
/**
|
|
@@ -681,10 +853,10 @@ const cancelSubscription = (options) => {
|
|
|
681
853
|
value: referenceId
|
|
682
854
|
}]
|
|
683
855
|
});
|
|
684
|
-
throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES
|
|
856
|
+
throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
|
|
685
857
|
}
|
|
686
858
|
const activeSubscription = activeSubscriptions.find((sub) => sub.id === subscription.stripeSubscriptionId);
|
|
687
|
-
if (!activeSubscription) throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES
|
|
859
|
+
if (!activeSubscription) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
|
|
688
860
|
const { url } = await client.billingPortal.sessions.create({
|
|
689
861
|
customer: subscription.stripeCustomerId,
|
|
690
862
|
return_url: getUrl(ctx, `${ctx.context.baseURL}/subscription/cancel/callback?callbackURL=${encodeURIComponent(ctx.body?.returnUrl || "/")}&subscriptionId=${encodeURIComponent(subscription.id)}`),
|
|
@@ -727,7 +899,8 @@ const cancelSubscription = (options) => {
|
|
|
727
899
|
};
|
|
728
900
|
const restoreSubscriptionBodySchema = z.object({
|
|
729
901
|
referenceId: z.string().meta({ description: "Reference id of the subscription to restore. Eg: '123'" }).optional(),
|
|
730
|
-
subscriptionId: z.string().meta({ description: "The Stripe subscription ID to restore. Eg: 'sub_1ABC2DEF3GHI4JKL'" }).optional()
|
|
902
|
+
subscriptionId: z.string().meta({ description: "The Stripe subscription ID to restore. Eg: 'sub_1ABC2DEF3GHI4JKL'" }).optional(),
|
|
903
|
+
customerType: z.enum(["user", "organization"]).meta({ description: "Customer type for the subscription. Eg: \"user\" or \"organization\"" }).optional()
|
|
731
904
|
});
|
|
732
905
|
const restoreSubscription = (options) => {
|
|
733
906
|
const client = options.stripeClient;
|
|
@@ -736,9 +909,10 @@ const restoreSubscription = (options) => {
|
|
|
736
909
|
method: "POST",
|
|
737
910
|
body: restoreSubscriptionBodySchema,
|
|
738
911
|
metadata: { openapi: { operationId: "restoreSubscription" } },
|
|
739
|
-
use: [
|
|
912
|
+
use: [stripeSessionMiddleware, referenceMiddleware(subscriptionOptions, "restore-subscription")]
|
|
740
913
|
}, async (ctx) => {
|
|
741
|
-
const
|
|
914
|
+
const customerType = ctx.body.customerType || "user";
|
|
915
|
+
const referenceId = ctx.body.referenceId || getReferenceId(ctx.context.session, customerType, options);
|
|
742
916
|
let subscription = ctx.body.subscriptionId ? await ctx.context.adapter.findOne({
|
|
743
917
|
model: "subscription",
|
|
744
918
|
where: [{
|
|
@@ -753,11 +927,11 @@ const restoreSubscription = (options) => {
|
|
|
753
927
|
}]
|
|
754
928
|
}).then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
|
|
755
929
|
if (ctx.body.subscriptionId && subscription && subscription.referenceId !== referenceId) subscription = void 0;
|
|
756
|
-
if (!subscription || !subscription.stripeCustomerId) throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES
|
|
757
|
-
if (subscription
|
|
758
|
-
if (!isPendingCancel(subscription)) throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES
|
|
930
|
+
if (!subscription || !subscription.stripeCustomerId) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
|
|
931
|
+
if (!isActiveOrTrialing(subscription)) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_ACTIVE);
|
|
932
|
+
if (!isPendingCancel(subscription)) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION);
|
|
759
933
|
const activeSubscription = await client.subscriptions.list({ customer: subscription.stripeCustomerId }).then((res) => res.data.filter((sub) => isActiveOrTrialing(sub))[0]);
|
|
760
|
-
if (!activeSubscription) throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES
|
|
934
|
+
if (!activeSubscription) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
|
|
761
935
|
const updateParams = {};
|
|
762
936
|
if (activeSubscription.cancel_at) updateParams.cancel_at = "";
|
|
763
937
|
else if (activeSubscription.cancel_at_period_end) updateParams.cancel_at_period_end = false;
|
|
@@ -783,7 +957,10 @@ const restoreSubscription = (options) => {
|
|
|
783
957
|
return ctx.json(newSub);
|
|
784
958
|
});
|
|
785
959
|
};
|
|
786
|
-
const listActiveSubscriptionsQuerySchema = z.optional(z.object({
|
|
960
|
+
const listActiveSubscriptionsQuerySchema = z.optional(z.object({
|
|
961
|
+
referenceId: z.string().meta({ description: "Reference id of the subscription to list. Eg: '123'" }).optional(),
|
|
962
|
+
customerType: z.enum(["user", "organization"]).meta({ description: "Customer type for the subscription. Eg: \"user\" or \"organization\"" }).optional()
|
|
963
|
+
}));
|
|
787
964
|
/**
|
|
788
965
|
* ### Endpoint
|
|
789
966
|
*
|
|
@@ -805,13 +982,15 @@ const listActiveSubscriptions = (options) => {
|
|
|
805
982
|
method: "GET",
|
|
806
983
|
query: listActiveSubscriptionsQuerySchema,
|
|
807
984
|
metadata: { openapi: { operationId: "listActiveSubscriptions" } },
|
|
808
|
-
use: [
|
|
985
|
+
use: [stripeSessionMiddleware, referenceMiddleware(subscriptionOptions, "list-subscription")]
|
|
809
986
|
}, async (ctx) => {
|
|
987
|
+
const customerType = ctx.query?.customerType || "user";
|
|
988
|
+
const referenceId = ctx.query?.referenceId || getReferenceId(ctx.context.session, customerType, options);
|
|
810
989
|
const subscriptions$1 = await ctx.context.adapter.findMany({
|
|
811
990
|
model: "subscription",
|
|
812
991
|
where: [{
|
|
813
992
|
field: "referenceId",
|
|
814
|
-
value:
|
|
993
|
+
value: referenceId
|
|
815
994
|
}]
|
|
816
995
|
});
|
|
817
996
|
if (!subscriptions$1.length) return [];
|
|
@@ -838,10 +1017,9 @@ const subscriptionSuccess = (options) => {
|
|
|
838
1017
|
use: [originCheck((ctx) => ctx.query.callbackURL)]
|
|
839
1018
|
}, async (ctx) => {
|
|
840
1019
|
if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
|
|
1020
|
+
const { callbackURL, subscriptionId } = ctx.query;
|
|
841
1021
|
const session = await getSessionFromCtx(ctx);
|
|
842
1022
|
if (!session) throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
|
|
843
|
-
const { user: user$1 } = session;
|
|
844
|
-
const { callbackURL, subscriptionId } = ctx.query;
|
|
845
1023
|
const subscription = await ctx.context.adapter.findOne({
|
|
846
1024
|
model: "subscription",
|
|
847
1025
|
where: [{
|
|
@@ -849,41 +1027,53 @@ const subscriptionSuccess = (options) => {
|
|
|
849
1027
|
value: subscriptionId
|
|
850
1028
|
}]
|
|
851
1029
|
});
|
|
852
|
-
if (subscription
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
update: {
|
|
864
|
-
status: stripeSubscription.status,
|
|
865
|
-
seats: stripeSubscription.items.data[0]?.quantity || 1,
|
|
866
|
-
plan: plan.name.toLowerCase(),
|
|
867
|
-
periodEnd: /* @__PURE__ */ new Date(stripeSubscription.items.data[0]?.current_period_end * 1e3),
|
|
868
|
-
periodStart: /* @__PURE__ */ new Date(stripeSubscription.items.data[0]?.current_period_start * 1e3),
|
|
869
|
-
stripeSubscriptionId: stripeSubscription.id,
|
|
870
|
-
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
|
|
871
|
-
cancelAt: stripeSubscription.cancel_at ? /* @__PURE__ */ new Date(stripeSubscription.cancel_at * 1e3) : null,
|
|
872
|
-
canceledAt: stripeSubscription.canceled_at ? /* @__PURE__ */ new Date(stripeSubscription.canceled_at * 1e3) : null,
|
|
873
|
-
...stripeSubscription.trial_start && stripeSubscription.trial_end ? {
|
|
874
|
-
trialStart: /* @__PURE__ */ new Date(stripeSubscription.trial_start * 1e3),
|
|
875
|
-
trialEnd: /* @__PURE__ */ new Date(stripeSubscription.trial_end * 1e3)
|
|
876
|
-
} : {}
|
|
877
|
-
},
|
|
878
|
-
where: [{
|
|
879
|
-
field: "id",
|
|
880
|
-
value: subscription.id
|
|
881
|
-
}]
|
|
882
|
-
});
|
|
883
|
-
}
|
|
884
|
-
} catch (error) {
|
|
1030
|
+
if (!subscription) {
|
|
1031
|
+
ctx.context.logger.warn(`Subscription record not found for subscriptionId: ${subscriptionId}`);
|
|
1032
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1033
|
+
}
|
|
1034
|
+
if (isActiveOrTrialing(subscription)) throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1035
|
+
const customerId = subscription.stripeCustomerId || session.user.stripeCustomerId;
|
|
1036
|
+
if (!customerId) throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1037
|
+
const stripeSubscription = await client.subscriptions.list({
|
|
1038
|
+
customer: customerId,
|
|
1039
|
+
status: "active"
|
|
1040
|
+
}).then((res) => res.data[0]).catch((error) => {
|
|
885
1041
|
ctx.context.logger.error("Error fetching subscription from Stripe", error);
|
|
1042
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1043
|
+
});
|
|
1044
|
+
if (!stripeSubscription) throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1045
|
+
const subscriptionItem = stripeSubscription.items.data[0];
|
|
1046
|
+
if (!subscriptionItem) {
|
|
1047
|
+
ctx.context.logger.warn(`No subscription items found for Stripe subscription ${stripeSubscription.id}`);
|
|
1048
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
1049
|
+
}
|
|
1050
|
+
const plan = await getPlanByPriceInfo(options, subscriptionItem.price.id, subscriptionItem.price.lookup_key);
|
|
1051
|
+
if (!plan) {
|
|
1052
|
+
ctx.context.logger.warn(`Plan not found for price ${subscriptionItem.price.id}`);
|
|
1053
|
+
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
886
1054
|
}
|
|
1055
|
+
await ctx.context.adapter.update({
|
|
1056
|
+
model: "subscription",
|
|
1057
|
+
update: {
|
|
1058
|
+
status: stripeSubscription.status,
|
|
1059
|
+
seats: subscriptionItem.quantity || 1,
|
|
1060
|
+
plan: plan.name.toLowerCase(),
|
|
1061
|
+
periodEnd: /* @__PURE__ */ new Date(subscriptionItem.current_period_end * 1e3),
|
|
1062
|
+
periodStart: /* @__PURE__ */ new Date(subscriptionItem.current_period_start * 1e3),
|
|
1063
|
+
stripeSubscriptionId: stripeSubscription.id,
|
|
1064
|
+
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
|
|
1065
|
+
cancelAt: stripeSubscription.cancel_at ? /* @__PURE__ */ new Date(stripeSubscription.cancel_at * 1e3) : null,
|
|
1066
|
+
canceledAt: stripeSubscription.canceled_at ? /* @__PURE__ */ new Date(stripeSubscription.canceled_at * 1e3) : null,
|
|
1067
|
+
...stripeSubscription.trial_start && stripeSubscription.trial_end ? {
|
|
1068
|
+
trialStart: /* @__PURE__ */ new Date(stripeSubscription.trial_start * 1e3),
|
|
1069
|
+
trialEnd: /* @__PURE__ */ new Date(stripeSubscription.trial_end * 1e3)
|
|
1070
|
+
} : {}
|
|
1071
|
+
},
|
|
1072
|
+
where: [{
|
|
1073
|
+
field: "id",
|
|
1074
|
+
value: subscription.id
|
|
1075
|
+
}]
|
|
1076
|
+
});
|
|
887
1077
|
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
888
1078
|
});
|
|
889
1079
|
};
|
|
@@ -892,6 +1082,7 @@ const createBillingPortalBodySchema = z.object({
|
|
|
892
1082
|
return typeof localization === "string";
|
|
893
1083
|
}).optional(),
|
|
894
1084
|
referenceId: z.string().optional(),
|
|
1085
|
+
customerType: z.enum(["user", "organization"]).meta({ description: "Customer type for the subscription. Eg: \"user\" or \"organization\"" }).optional(),
|
|
895
1086
|
returnUrl: z.string().default("/"),
|
|
896
1087
|
disableRedirect: z.boolean().meta({ description: "Disable redirect after creating billing portal session. Eg: true" }).default(false)
|
|
897
1088
|
});
|
|
@@ -903,22 +1094,41 @@ const createBillingPortal = (options) => {
|
|
|
903
1094
|
body: createBillingPortalBodySchema,
|
|
904
1095
|
metadata: { openapi: { operationId: "createBillingPortal" } },
|
|
905
1096
|
use: [
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
1097
|
+
stripeSessionMiddleware,
|
|
1098
|
+
referenceMiddleware(subscriptionOptions, "billing-portal"),
|
|
1099
|
+
originCheck((ctx) => ctx.body.returnUrl)
|
|
909
1100
|
]
|
|
910
1101
|
}, async (ctx) => {
|
|
911
1102
|
const { user: user$1 } = ctx.context.session;
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1103
|
+
const customerType = ctx.body.customerType || "user";
|
|
1104
|
+
const referenceId = ctx.body.referenceId || getReferenceId(ctx.context.session, customerType, options);
|
|
1105
|
+
let customerId;
|
|
1106
|
+
if (customerType === "organization") {
|
|
1107
|
+
customerId = (await ctx.context.adapter.findOne({
|
|
1108
|
+
model: "organization",
|
|
1109
|
+
where: [{
|
|
1110
|
+
field: "id",
|
|
1111
|
+
value: referenceId
|
|
1112
|
+
}]
|
|
1113
|
+
}))?.stripeCustomerId;
|
|
1114
|
+
if (!customerId) customerId = (await ctx.context.adapter.findMany({
|
|
1115
|
+
model: "subscription",
|
|
1116
|
+
where: [{
|
|
1117
|
+
field: "referenceId",
|
|
1118
|
+
value: referenceId
|
|
1119
|
+
}]
|
|
1120
|
+
}).then((subs) => subs.find((sub) => isActiveOrTrialing(sub))))?.stripeCustomerId;
|
|
1121
|
+
} else {
|
|
1122
|
+
customerId = user$1.stripeCustomerId;
|
|
1123
|
+
if (!customerId) customerId = (await ctx.context.adapter.findMany({
|
|
1124
|
+
model: "subscription",
|
|
1125
|
+
where: [{
|
|
1126
|
+
field: "referenceId",
|
|
1127
|
+
value: referenceId
|
|
1128
|
+
}]
|
|
1129
|
+
}).then((subs) => subs.find((sub) => isActiveOrTrialing(sub))))?.stripeCustomerId;
|
|
1130
|
+
}
|
|
1131
|
+
if (!customerId) throw APIError$1.from("NOT_FOUND", STRIPE_ERROR_CODES.CUSTOMER_NOT_FOUND);
|
|
922
1132
|
try {
|
|
923
1133
|
const { url } = await client.billingPortal.sessions.create({
|
|
924
1134
|
locale: ctx.body.locale,
|
|
@@ -931,7 +1141,7 @@ const createBillingPortal = (options) => {
|
|
|
931
1141
|
});
|
|
932
1142
|
} catch (error) {
|
|
933
1143
|
ctx.context.logger.error("Error creating billing portal session", error);
|
|
934
|
-
throw
|
|
1144
|
+
throw APIError$1.from("INTERNAL_SERVER_ERROR", STRIPE_ERROR_CODES.UNABLE_TO_CREATE_BILLING_PORTAL);
|
|
935
1145
|
}
|
|
936
1146
|
});
|
|
937
1147
|
};
|
|
@@ -946,20 +1156,21 @@ const stripeWebhook = (options) => {
|
|
|
946
1156
|
cloneRequest: true,
|
|
947
1157
|
disableBody: true
|
|
948
1158
|
}, async (ctx) => {
|
|
949
|
-
if (!ctx.request?.body) throw
|
|
950
|
-
const buf = await ctx.request.text();
|
|
1159
|
+
if (!ctx.request?.body) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.INVALID_REQUEST_BODY);
|
|
951
1160
|
const sig = ctx.request.headers.get("stripe-signature");
|
|
1161
|
+
if (!sig) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.STRIPE_SIGNATURE_NOT_FOUND);
|
|
952
1162
|
const webhookSecret = options.stripeWebhookSecret;
|
|
1163
|
+
if (!webhookSecret) throw APIError$1.from("INTERNAL_SERVER_ERROR", STRIPE_ERROR_CODES.STRIPE_WEBHOOK_SECRET_NOT_FOUND);
|
|
1164
|
+
const payload = await ctx.request.text();
|
|
953
1165
|
let event;
|
|
954
1166
|
try {
|
|
955
|
-
if (
|
|
956
|
-
|
|
957
|
-
else event = client.webhooks.constructEvent(buf, sig, webhookSecret);
|
|
1167
|
+
if (typeof client.webhooks.constructEventAsync === "function") event = await client.webhooks.constructEventAsync(payload, sig, webhookSecret);
|
|
1168
|
+
else event = client.webhooks.constructEvent(payload, sig, webhookSecret);
|
|
958
1169
|
} catch (err) {
|
|
959
1170
|
ctx.context.logger.error(`${err.message}`);
|
|
960
|
-
throw
|
|
1171
|
+
throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.FAILED_TO_CONSTRUCT_STRIPE_EVENT);
|
|
961
1172
|
}
|
|
962
|
-
if (!event) throw
|
|
1173
|
+
if (!event) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.FAILED_TO_CONSTRUCT_STRIPE_EVENT);
|
|
963
1174
|
try {
|
|
964
1175
|
switch (event.type) {
|
|
965
1176
|
case "checkout.session.completed":
|
|
@@ -984,23 +1195,11 @@ const stripeWebhook = (options) => {
|
|
|
984
1195
|
}
|
|
985
1196
|
} catch (e) {
|
|
986
1197
|
ctx.context.logger.error(`Stripe webhook failed. Error: ${e.message}`);
|
|
987
|
-
throw
|
|
1198
|
+
throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.STRIPE_WEBHOOK_ERROR);
|
|
988
1199
|
}
|
|
989
1200
|
return ctx.json({ success: true });
|
|
990
1201
|
});
|
|
991
1202
|
};
|
|
992
|
-
const getUrl = (ctx, url) => {
|
|
993
|
-
if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(url)) return url;
|
|
994
|
-
return `${ctx.context.options.baseURL}${url.startsWith("/") ? url : `/${url}`}`;
|
|
995
|
-
};
|
|
996
|
-
async function resolvePriceIdFromLookupKey(stripeClient, lookupKey) {
|
|
997
|
-
if (!lookupKey) return void 0;
|
|
998
|
-
return (await stripeClient.prices.list({
|
|
999
|
-
lookup_keys: [lookupKey],
|
|
1000
|
-
active: true,
|
|
1001
|
-
limit: 1
|
|
1002
|
-
})).data[0]?.id;
|
|
1003
|
-
}
|
|
1004
1203
|
|
|
1005
1204
|
//#endregion
|
|
1006
1205
|
//#region src/schema.ts
|
|
@@ -1067,6 +1266,10 @@ const user = { user: { fields: { stripeCustomerId: {
|
|
|
1067
1266
|
type: "string",
|
|
1068
1267
|
required: false
|
|
1069
1268
|
} } } };
|
|
1269
|
+
const organization = { organization: { fields: { stripeCustomerId: {
|
|
1270
|
+
type: "string",
|
|
1271
|
+
required: false
|
|
1272
|
+
} } } };
|
|
1070
1273
|
const getSchema = (options) => {
|
|
1071
1274
|
let baseSchema = {};
|
|
1072
1275
|
if (options.subscription?.enabled) baseSchema = {
|
|
@@ -1074,6 +1277,10 @@ const getSchema = (options) => {
|
|
|
1074
1277
|
...user
|
|
1075
1278
|
};
|
|
1076
1279
|
else baseSchema = { ...user };
|
|
1280
|
+
if (options.organization?.enabled) baseSchema = {
|
|
1281
|
+
...baseSchema,
|
|
1282
|
+
...organization
|
|
1283
|
+
};
|
|
1077
1284
|
if (options.schema && !options.subscription?.enabled && "subscription" in options.schema) {
|
|
1078
1285
|
const { subscription: _subscription, ...restSchema } = options.schema;
|
|
1079
1286
|
return mergeSchema(baseSchema, restSchema);
|
|
@@ -1083,16 +1290,6 @@ const getSchema = (options) => {
|
|
|
1083
1290
|
|
|
1084
1291
|
//#endregion
|
|
1085
1292
|
//#region src/index.ts
|
|
1086
|
-
const STRIPE_ERROR_CODES = defineErrorCodes({
|
|
1087
|
-
SUBSCRIPTION_NOT_FOUND: "Subscription not found",
|
|
1088
|
-
SUBSCRIPTION_PLAN_NOT_FOUND: "Subscription plan not found",
|
|
1089
|
-
ALREADY_SUBSCRIBED_PLAN: "You're already subscribed to this plan",
|
|
1090
|
-
UNABLE_TO_CREATE_CUSTOMER: "Unable to create customer",
|
|
1091
|
-
FAILED_TO_FETCH_PLANS: "Failed to fetch plans",
|
|
1092
|
-
EMAIL_VERIFICATION_REQUIRED: "Email verification is required before you can subscribe to a plan",
|
|
1093
|
-
SUBSCRIPTION_NOT_ACTIVE: "Subscription is not active",
|
|
1094
|
-
SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION: "Subscription is not scheduled for cancellation"
|
|
1095
|
-
});
|
|
1096
1293
|
const stripe = (options) => {
|
|
1097
1294
|
const client = options.stripeClient;
|
|
1098
1295
|
const subscriptionEndpoints = {
|
|
@@ -1111,13 +1308,70 @@ const stripe = (options) => {
|
|
|
1111
1308
|
...options.subscription?.enabled ? subscriptionEndpoints : {}
|
|
1112
1309
|
},
|
|
1113
1310
|
init(ctx) {
|
|
1311
|
+
if (options.organization?.enabled) {
|
|
1312
|
+
const orgPlugin = ctx.getPlugin("organization");
|
|
1313
|
+
if (!orgPlugin) {
|
|
1314
|
+
ctx.logger.error(`Organization plugin not found`);
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
const existingHooks = orgPlugin.options.organizationHooks ?? {};
|
|
1318
|
+
/**
|
|
1319
|
+
* Sync organization name to Stripe customer
|
|
1320
|
+
*/
|
|
1321
|
+
const afterUpdateStripeOrg = async (data) => {
|
|
1322
|
+
const { organization: organization$1 } = data;
|
|
1323
|
+
if (!organization$1?.stripeCustomerId) return;
|
|
1324
|
+
try {
|
|
1325
|
+
const stripeCustomer = await client.customers.retrieve(organization$1.stripeCustomerId);
|
|
1326
|
+
if (stripeCustomer.deleted) {
|
|
1327
|
+
ctx.logger.warn(`Stripe customer ${organization$1.stripeCustomerId} was deleted`);
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
if (organization$1.name !== stripeCustomer.name) {
|
|
1331
|
+
await client.customers.update(organization$1.stripeCustomerId, { name: organization$1.name });
|
|
1332
|
+
ctx.logger.info(`Synced organization name to Stripe: "${stripeCustomer.name}" → "${organization$1.name}"`);
|
|
1333
|
+
}
|
|
1334
|
+
} catch (e) {
|
|
1335
|
+
ctx.logger.error(`Failed to sync organization to Stripe: ${e.message}`);
|
|
1336
|
+
}
|
|
1337
|
+
};
|
|
1338
|
+
/**
|
|
1339
|
+
* Block deletion if organization has active subscriptions
|
|
1340
|
+
*/
|
|
1341
|
+
const beforeDeleteStripeOrg = async (data) => {
|
|
1342
|
+
const { organization: organization$1 } = data;
|
|
1343
|
+
if (!organization$1.stripeCustomerId) return;
|
|
1344
|
+
try {
|
|
1345
|
+
const subscriptions$1 = await client.subscriptions.list({
|
|
1346
|
+
customer: organization$1.stripeCustomerId,
|
|
1347
|
+
status: "all",
|
|
1348
|
+
limit: 100
|
|
1349
|
+
});
|
|
1350
|
+
for (const sub of subscriptions$1.data) if (sub.status !== "canceled" && sub.status !== "incomplete" && sub.status !== "incomplete_expired") throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES.ORGANIZATION_HAS_ACTIVE_SUBSCRIPTION);
|
|
1351
|
+
} catch (error) {
|
|
1352
|
+
if (error instanceof APIError) throw error;
|
|
1353
|
+
ctx.logger.error(`Failed to check organization subscriptions: ${error.message}`);
|
|
1354
|
+
throw error;
|
|
1355
|
+
}
|
|
1356
|
+
};
|
|
1357
|
+
orgPlugin.options.organizationHooks = {
|
|
1358
|
+
...existingHooks,
|
|
1359
|
+
afterUpdateOrganization: existingHooks.afterUpdateOrganization ? async (data) => {
|
|
1360
|
+
await existingHooks.afterUpdateOrganization(data);
|
|
1361
|
+
await afterUpdateStripeOrg(data);
|
|
1362
|
+
} : afterUpdateStripeOrg,
|
|
1363
|
+
beforeDeleteOrganization: existingHooks.beforeDeleteOrganization ? async (data) => {
|
|
1364
|
+
await existingHooks.beforeDeleteOrganization(data);
|
|
1365
|
+
await beforeDeleteStripeOrg(data);
|
|
1366
|
+
} : beforeDeleteStripeOrg
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1114
1369
|
return { options: { databaseHooks: { user: {
|
|
1115
1370
|
create: { async after(user$1, ctx$1) {
|
|
1116
|
-
if (!ctx$1 || !options.createCustomerOnSignUp) return;
|
|
1371
|
+
if (!ctx$1 || !options.createCustomerOnSignUp || user$1.stripeCustomerId) return;
|
|
1117
1372
|
try {
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
email: user$1.email,
|
|
1373
|
+
let stripeCustomer = (await client.customers.search({
|
|
1374
|
+
query: `email:"${escapeStripeSearchValue(user$1.email)}" AND -metadata["customerType"]:"organization"`,
|
|
1121
1375
|
limit: 1
|
|
1122
1376
|
})).data[0];
|
|
1123
1377
|
if (stripeCustomer) {
|
|
@@ -1137,7 +1391,10 @@ const stripe = (options) => {
|
|
|
1137
1391
|
const params = defu({
|
|
1138
1392
|
email: user$1.email,
|
|
1139
1393
|
name: user$1.name,
|
|
1140
|
-
metadata: {
|
|
1394
|
+
metadata: {
|
|
1395
|
+
userId: user$1.id,
|
|
1396
|
+
customerType: "user"
|
|
1397
|
+
}
|
|
1141
1398
|
}, extraCreateParams);
|
|
1142
1399
|
stripeCustomer = await client.customers.create(params);
|
|
1143
1400
|
await ctx$1.context.internalAdapter.updateUser(user$1.id, { stripeCustomerId: stripeCustomer.id });
|
|
@@ -1154,17 +1411,15 @@ const stripe = (options) => {
|
|
|
1154
1411
|
}
|
|
1155
1412
|
} },
|
|
1156
1413
|
update: { async after(user$1, ctx$1) {
|
|
1157
|
-
if (!ctx$1) return;
|
|
1414
|
+
if (!ctx$1 || !user$1.stripeCustomerId) return;
|
|
1158
1415
|
try {
|
|
1159
|
-
const
|
|
1160
|
-
if (!userWithStripe.stripeCustomerId) return;
|
|
1161
|
-
const stripeCustomer = await client.customers.retrieve(userWithStripe.stripeCustomerId);
|
|
1416
|
+
const stripeCustomer = await client.customers.retrieve(user$1.stripeCustomerId);
|
|
1162
1417
|
if (stripeCustomer.deleted) {
|
|
1163
|
-
ctx$1.context.logger.warn(`Stripe customer ${
|
|
1418
|
+
ctx$1.context.logger.warn(`Stripe customer ${user$1.stripeCustomerId} was deleted, cannot update email`);
|
|
1164
1419
|
return;
|
|
1165
1420
|
}
|
|
1166
1421
|
if (stripeCustomer.email !== user$1.email) {
|
|
1167
|
-
await client.customers.update(
|
|
1422
|
+
await client.customers.update(user$1.stripeCustomerId, { email: user$1.email });
|
|
1168
1423
|
ctx$1.context.logger.info(`Updated Stripe customer email from ${stripeCustomer.email} to ${user$1.email}`);
|
|
1169
1424
|
}
|
|
1170
1425
|
} catch (e) {
|