@better-auth/stripe 1.5.0-beta.1 → 1.5.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +9 -9
- package/CHANGELOG.md +15 -13
- package/LICENSE.md +15 -12
- package/dist/client.d.mts +106 -1
- package/dist/client.mjs +5 -2
- package/dist/error-codes-Bkj5yJMT.mjs +29 -0
- package/dist/{index-DpiQGYLJ.d.mts → index-BnHmwMru.d.mts} +314 -155
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +628 -231
- package/package.json +6 -6
- package/src/client.ts +1 -0
- package/src/error-codes.ts +16 -0
- package/src/hooks.ts +229 -53
- package/src/index.ts +142 -45
- package/src/middleware.ts +91 -45
- package/src/routes.ts +620 -300
- package/src/schema.ts +30 -0
- package/src/types.ts +105 -20
- package/src/utils.ts +36 -1
- package/test/stripe-organization.test.ts +1993 -0
- package/{src → test}/stripe.test.ts +3350 -1404
- package/dist/error-codes-qqooUh6R.mjs +0 -16
package/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
|
|
|
@@ -19,21 +18,86 @@ async function getPlanByPriceInfo(options, priceId, priceLookupKey) {
|
|
|
19
18
|
async function getPlanByName(options, name) {
|
|
20
19
|
return await getPlans(options.subscription).then((res) => res?.find((plan) => plan.name.toLowerCase() === name.toLowerCase()));
|
|
21
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Checks if a subscription is in an available state (active or trialing)
|
|
23
|
+
*/
|
|
24
|
+
function isActiveOrTrialing(sub) {
|
|
25
|
+
return sub.status === "active" || sub.status === "trialing";
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Check if a subscription is scheduled to be canceled (DB subscription object)
|
|
29
|
+
*/
|
|
30
|
+
function isPendingCancel(sub) {
|
|
31
|
+
return !!(sub.cancelAtPeriodEnd || sub.cancelAt);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Check if a Stripe subscription is scheduled to be canceled (Stripe API response)
|
|
35
|
+
*/
|
|
36
|
+
function isStripePendingCancel(stripeSub) {
|
|
37
|
+
return !!(stripeSub.cancel_at_period_end || stripeSub.cancel_at);
|
|
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
|
+
}
|
|
22
49
|
|
|
23
50
|
//#endregion
|
|
24
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
|
+
}
|
|
25
83
|
async function onCheckoutSessionCompleted(ctx, options, event) {
|
|
26
84
|
try {
|
|
27
85
|
const client = options.stripeClient;
|
|
28
86
|
const checkoutSession = event.data.object;
|
|
29
87
|
if (checkoutSession.mode === "setup" || !options.subscription?.enabled) return;
|
|
30
88
|
const subscription = await client.subscriptions.retrieve(checkoutSession.subscription);
|
|
31
|
-
const
|
|
32
|
-
|
|
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);
|
|
33
97
|
if (plan) {
|
|
34
98
|
const referenceId = checkoutSession?.client_reference_id || checkoutSession?.metadata?.referenceId;
|
|
35
99
|
const subscriptionId = checkoutSession?.metadata?.subscriptionId;
|
|
36
|
-
const seats =
|
|
100
|
+
const seats = subscriptionItem.quantity;
|
|
37
101
|
if (referenceId && subscriptionId) {
|
|
38
102
|
const trial = subscription.trial_start && subscription.trial_end ? {
|
|
39
103
|
trialStart: /* @__PURE__ */ new Date(subscription.trial_start * 1e3),
|
|
@@ -45,9 +109,13 @@ async function onCheckoutSessionCompleted(ctx, options, event) {
|
|
|
45
109
|
plan: plan.name.toLowerCase(),
|
|
46
110
|
status: subscription.status,
|
|
47
111
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
48
|
-
periodStart: /* @__PURE__ */ new Date(
|
|
49
|
-
periodEnd: /* @__PURE__ */ new Date(
|
|
112
|
+
periodStart: /* @__PURE__ */ new Date(subscriptionItem.current_period_start * 1e3),
|
|
113
|
+
periodEnd: /* @__PURE__ */ new Date(subscriptionItem.current_period_end * 1e3),
|
|
50
114
|
stripeSubscriptionId: checkoutSession.subscription,
|
|
115
|
+
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
116
|
+
cancelAt: subscription.cancel_at ? /* @__PURE__ */ new Date(subscription.cancel_at * 1e3) : null,
|
|
117
|
+
canceledAt: subscription.canceled_at ? /* @__PURE__ */ new Date(subscription.canceled_at * 1e3) : null,
|
|
118
|
+
endedAt: subscription.ended_at ? /* @__PURE__ */ new Date(subscription.ended_at * 1e3) : null,
|
|
51
119
|
seats,
|
|
52
120
|
...trial
|
|
53
121
|
},
|
|
@@ -74,15 +142,95 @@ async function onCheckoutSessionCompleted(ctx, options, event) {
|
|
|
74
142
|
}
|
|
75
143
|
}
|
|
76
144
|
} catch (e) {
|
|
77
|
-
logger.error(`Stripe webhook failed. Error: ${e.message}`);
|
|
145
|
+
ctx.context.logger.error(`Stripe webhook failed. Error: ${e.message}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async function onSubscriptionCreated(ctx, options, event) {
|
|
149
|
+
try {
|
|
150
|
+
if (!options.subscription?.enabled) return;
|
|
151
|
+
const subscriptionCreated = event.data.object;
|
|
152
|
+
const stripeCustomerId = subscriptionCreated.customer?.toString();
|
|
153
|
+
if (!stripeCustomerId) {
|
|
154
|
+
ctx.context.logger.warn(`Stripe webhook warning: customer.subscription.created event received without customer ID`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const subscriptionId = subscriptionCreated.metadata?.subscriptionId;
|
|
158
|
+
const existingSubscription = await ctx.context.adapter.findOne({
|
|
159
|
+
model: "subscription",
|
|
160
|
+
where: subscriptionId ? [{
|
|
161
|
+
field: "id",
|
|
162
|
+
value: subscriptionId
|
|
163
|
+
}] : [{
|
|
164
|
+
field: "stripeSubscriptionId",
|
|
165
|
+
value: subscriptionCreated.id
|
|
166
|
+
}]
|
|
167
|
+
});
|
|
168
|
+
if (existingSubscription) {
|
|
169
|
+
ctx.context.logger.info(`Stripe webhook: Subscription already exists in database (id: ${existingSubscription.id}), skipping creation`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
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}`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const { referenceId, customerType } = reference;
|
|
178
|
+
const subscriptionItem = subscriptionCreated.items.data[0];
|
|
179
|
+
if (!subscriptionItem) {
|
|
180
|
+
ctx.context.logger.warn(`Stripe webhook warning: Subscription ${subscriptionCreated.id} has no items`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const priceId = subscriptionItem.price.id;
|
|
184
|
+
const plan = await getPlanByPriceInfo(options, priceId, subscriptionItem.price.lookup_key || null);
|
|
185
|
+
if (!plan) {
|
|
186
|
+
ctx.context.logger.warn(`Stripe webhook warning: No matching plan found for priceId: ${priceId}`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const seats = subscriptionItem.quantity;
|
|
190
|
+
const periodStart = /* @__PURE__ */ new Date(subscriptionItem.current_period_start * 1e3);
|
|
191
|
+
const periodEnd = /* @__PURE__ */ new Date(subscriptionItem.current_period_end * 1e3);
|
|
192
|
+
const trial = subscriptionCreated.trial_start && subscriptionCreated.trial_end ? {
|
|
193
|
+
trialStart: /* @__PURE__ */ new Date(subscriptionCreated.trial_start * 1e3),
|
|
194
|
+
trialEnd: /* @__PURE__ */ new Date(subscriptionCreated.trial_end * 1e3)
|
|
195
|
+
} : {};
|
|
196
|
+
const newSubscription = await ctx.context.adapter.create({
|
|
197
|
+
model: "subscription",
|
|
198
|
+
data: {
|
|
199
|
+
referenceId,
|
|
200
|
+
stripeCustomerId,
|
|
201
|
+
stripeSubscriptionId: subscriptionCreated.id,
|
|
202
|
+
status: subscriptionCreated.status,
|
|
203
|
+
plan: plan.name.toLowerCase(),
|
|
204
|
+
periodStart,
|
|
205
|
+
periodEnd,
|
|
206
|
+
seats,
|
|
207
|
+
...plan.limits ? { limits: plan.limits } : {},
|
|
208
|
+
...trial
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
ctx.context.logger.info(`Stripe webhook: Created subscription ${subscriptionCreated.id} for ${customerType} ${referenceId} from dashboard`);
|
|
212
|
+
await options.subscription.onSubscriptionCreated?.({
|
|
213
|
+
event,
|
|
214
|
+
subscription: newSubscription,
|
|
215
|
+
stripeSubscription: subscriptionCreated,
|
|
216
|
+
plan
|
|
217
|
+
});
|
|
218
|
+
} catch (error) {
|
|
219
|
+
ctx.context.logger.error(`Stripe webhook failed. Error: ${error}`);
|
|
78
220
|
}
|
|
79
221
|
}
|
|
80
222
|
async function onSubscriptionUpdated(ctx, options, event) {
|
|
81
223
|
try {
|
|
82
224
|
if (!options.subscription?.enabled) return;
|
|
83
225
|
const subscriptionUpdated = event.data.object;
|
|
84
|
-
const
|
|
85
|
-
|
|
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);
|
|
86
234
|
const subscriptionId = subscriptionUpdated.metadata?.subscriptionId;
|
|
87
235
|
const customerId = subscriptionUpdated.customer?.toString();
|
|
88
236
|
let subscription = await ctx.context.adapter.findOne({
|
|
@@ -104,15 +252,14 @@ async function onSubscriptionUpdated(ctx, options, event) {
|
|
|
104
252
|
}]
|
|
105
253
|
});
|
|
106
254
|
if (subs.length > 1) {
|
|
107
|
-
const activeSub = subs.find((sub) => sub
|
|
255
|
+
const activeSub = subs.find((sub) => isActiveOrTrialing(sub));
|
|
108
256
|
if (!activeSub) {
|
|
109
|
-
logger.warn(`Stripe webhook error: Multiple subscriptions found for customerId: ${customerId} and no active subscription is found`);
|
|
257
|
+
ctx.context.logger.warn(`Stripe webhook error: Multiple subscriptions found for customerId: ${customerId} and no active subscription is found`);
|
|
110
258
|
return;
|
|
111
259
|
}
|
|
112
260
|
subscription = activeSub;
|
|
113
261
|
} else subscription = subs[0];
|
|
114
262
|
}
|
|
115
|
-
const seats = subscriptionUpdated.items.data[0].quantity;
|
|
116
263
|
const updatedSubscription = await ctx.context.adapter.update({
|
|
117
264
|
model: "subscription",
|
|
118
265
|
update: {
|
|
@@ -122,10 +269,13 @@ async function onSubscriptionUpdated(ctx, options, event) {
|
|
|
122
269
|
} : {},
|
|
123
270
|
updatedAt: /* @__PURE__ */ new Date(),
|
|
124
271
|
status: subscriptionUpdated.status,
|
|
125
|
-
periodStart: /* @__PURE__ */ new Date(
|
|
126
|
-
periodEnd: /* @__PURE__ */ new Date(
|
|
272
|
+
periodStart: /* @__PURE__ */ new Date(subscriptionItem.current_period_start * 1e3),
|
|
273
|
+
periodEnd: /* @__PURE__ */ new Date(subscriptionItem.current_period_end * 1e3),
|
|
127
274
|
cancelAtPeriodEnd: subscriptionUpdated.cancel_at_period_end,
|
|
128
|
-
|
|
275
|
+
cancelAt: subscriptionUpdated.cancel_at ? /* @__PURE__ */ new Date(subscriptionUpdated.cancel_at * 1e3) : null,
|
|
276
|
+
canceledAt: subscriptionUpdated.canceled_at ? /* @__PURE__ */ new Date(subscriptionUpdated.canceled_at * 1e3) : null,
|
|
277
|
+
endedAt: subscriptionUpdated.ended_at ? /* @__PURE__ */ new Date(subscriptionUpdated.ended_at * 1e3) : null,
|
|
278
|
+
seats: subscriptionItem.quantity,
|
|
129
279
|
stripeSubscriptionId: subscriptionUpdated.id
|
|
130
280
|
},
|
|
131
281
|
where: [{
|
|
@@ -133,7 +283,7 @@ async function onSubscriptionUpdated(ctx, options, event) {
|
|
|
133
283
|
value: subscription.id
|
|
134
284
|
}]
|
|
135
285
|
});
|
|
136
|
-
if (subscriptionUpdated.status === "active" && subscriptionUpdated
|
|
286
|
+
if (subscriptionUpdated.status === "active" && isStripePendingCancel(subscriptionUpdated) && !isPendingCancel(subscription)) await options.subscription.onSubscriptionCancel?.({
|
|
137
287
|
subscription,
|
|
138
288
|
cancellationDetails: subscriptionUpdated.cancellation_details || void 0,
|
|
139
289
|
stripeSubscription: subscriptionUpdated,
|
|
@@ -148,7 +298,7 @@ async function onSubscriptionUpdated(ctx, options, event) {
|
|
|
148
298
|
if (subscriptionUpdated.status === "incomplete_expired" && subscription.status === "trialing" && plan.freeTrial?.onTrialExpired) await plan.freeTrial.onTrialExpired(subscription, ctx);
|
|
149
299
|
}
|
|
150
300
|
} catch (error) {
|
|
151
|
-
logger.error(`Stripe webhook failed. Error: ${error}`);
|
|
301
|
+
ctx.context.logger.error(`Stripe webhook failed. Error: ${error}`);
|
|
152
302
|
}
|
|
153
303
|
}
|
|
154
304
|
async function onSubscriptionDeleted(ctx, options, event) {
|
|
@@ -172,7 +322,11 @@ async function onSubscriptionDeleted(ctx, options, event) {
|
|
|
172
322
|
}],
|
|
173
323
|
update: {
|
|
174
324
|
status: "canceled",
|
|
175
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
325
|
+
updatedAt: /* @__PURE__ */ new Date(),
|
|
326
|
+
cancelAtPeriodEnd: subscriptionDeleted.cancel_at_period_end,
|
|
327
|
+
cancelAt: subscriptionDeleted.cancel_at ? /* @__PURE__ */ new Date(subscriptionDeleted.cancel_at * 1e3) : null,
|
|
328
|
+
canceledAt: subscriptionDeleted.canceled_at ? /* @__PURE__ */ new Date(subscriptionDeleted.canceled_at * 1e3) : null,
|
|
329
|
+
endedAt: subscriptionDeleted.ended_at ? /* @__PURE__ */ new Date(subscriptionDeleted.ended_at * 1e3) : null
|
|
176
330
|
}
|
|
177
331
|
});
|
|
178
332
|
await options.subscription.onSubscriptionDeleted?.({
|
|
@@ -180,41 +334,94 @@ async function onSubscriptionDeleted(ctx, options, event) {
|
|
|
180
334
|
stripeSubscription: subscriptionDeleted,
|
|
181
335
|
subscription
|
|
182
336
|
});
|
|
183
|
-
} else logger.warn(`Stripe webhook error: Subscription not found for subscriptionId: ${subscriptionId}`);
|
|
337
|
+
} else ctx.context.logger.warn(`Stripe webhook error: Subscription not found for subscriptionId: ${subscriptionId}`);
|
|
184
338
|
} catch (error) {
|
|
185
|
-
logger.error(`Stripe webhook failed. Error: ${error}`);
|
|
339
|
+
ctx.context.logger.error(`Stripe webhook failed. Error: ${error}`);
|
|
186
340
|
}
|
|
187
341
|
}
|
|
188
342
|
|
|
189
343
|
//#endregion
|
|
190
344
|
//#region src/middleware.ts
|
|
345
|
+
const stripeSessionMiddleware = createAuthMiddleware({ use: [sessionMiddleware] }, async (ctx) => {
|
|
346
|
+
return { session: ctx.context.session };
|
|
347
|
+
});
|
|
191
348
|
const referenceMiddleware = (subscriptionOptions, action) => createAuthMiddleware(async (ctx) => {
|
|
192
|
-
const
|
|
193
|
-
if (!
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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) {
|
|
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.`);
|
|
372
|
+
throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.REFERENCE_ID_NOT_ALLOWED);
|
|
198
373
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (!(ctx.body?.referenceId || ctx.query?.referenceId ? await subscriptionOptions.authorizeReference?.({
|
|
204
|
-
user: session.user,
|
|
205
|
-
session: session.session,
|
|
206
|
-
referenceId,
|
|
374
|
+
if (!await subscriptionOptions.authorizeReference({
|
|
375
|
+
user: ctxSession.user,
|
|
376
|
+
session: ctxSession.session,
|
|
377
|
+
referenceId: explicitReferenceId,
|
|
207
378
|
action
|
|
208
|
-
}, ctx)
|
|
379
|
+
}, ctx)) throw APIError$1.from("UNAUTHORIZED", STRIPE_ERROR_CODES.UNAUTHORIZED);
|
|
209
380
|
});
|
|
210
381
|
|
|
211
382
|
//#endregion
|
|
212
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
|
+
}
|
|
213
419
|
const upgradeSubscriptionBodySchema = z.object({
|
|
214
420
|
plan: z.string().meta({ description: "The name of the plan to upgrade to. Eg: \"pro\"" }),
|
|
215
421
|
annual: z.boolean().meta({ description: "Whether to upgrade to an annual plan. Eg: true" }).optional(),
|
|
216
|
-
referenceId: z.string().meta({ description: "Reference
|
|
422
|
+
referenceId: z.string().meta({ description: "Reference ID for the subscription. Eg: \"org_123\"" }).optional(),
|
|
217
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(),
|
|
218
425
|
metadata: z.record(z.string(), z.any()).optional(),
|
|
219
426
|
seats: z.number().meta({ description: "Number of seats to upgrade to (if applicable). Eg: 1" }).optional(),
|
|
220
427
|
successUrl: z.string().meta({ description: "Callback URL to redirect back after successful subscription. Eg: \"https://example.com/success\"" }).default("/"),
|
|
@@ -245,18 +452,19 @@ const upgradeSubscription = (options) => {
|
|
|
245
452
|
body: upgradeSubscriptionBodySchema,
|
|
246
453
|
metadata: { openapi: { operationId: "upgradeSubscription" } },
|
|
247
454
|
use: [
|
|
248
|
-
|
|
455
|
+
stripeSessionMiddleware,
|
|
456
|
+
referenceMiddleware(subscriptionOptions, "upgrade-subscription"),
|
|
249
457
|
originCheck((c) => {
|
|
250
458
|
return [c.body.successUrl, c.body.cancelUrl];
|
|
251
|
-
})
|
|
252
|
-
referenceMiddleware(subscriptionOptions, "upgrade-subscription")
|
|
459
|
+
})
|
|
253
460
|
]
|
|
254
461
|
}, async (ctx) => {
|
|
255
462
|
const { user: user$1, session } = ctx.context.session;
|
|
256
|
-
|
|
257
|
-
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);
|
|
258
466
|
const plan = await getPlanByName(options, ctx.body.plan);
|
|
259
|
-
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);
|
|
260
468
|
let subscriptionToUpdate = ctx.body.subscriptionId ? await ctx.context.adapter.findOne({
|
|
261
469
|
model: "subscription",
|
|
262
470
|
where: [{
|
|
@@ -271,49 +479,104 @@ const upgradeSubscription = (options) => {
|
|
|
271
479
|
}]
|
|
272
480
|
}) : null;
|
|
273
481
|
if (ctx.body.subscriptionId && subscriptionToUpdate && subscriptionToUpdate.referenceId !== referenceId) subscriptionToUpdate = null;
|
|
274
|
-
if (ctx.body.subscriptionId && !subscriptionToUpdate) throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES
|
|
275
|
-
let customerId
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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);
|
|
287
533
|
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}]
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
+
}
|
|
301
564
|
}
|
|
302
565
|
const subscriptions$1 = subscriptionToUpdate ? [subscriptionToUpdate] : await ctx.context.adapter.findMany({
|
|
303
566
|
model: "subscription",
|
|
304
567
|
where: [{
|
|
305
568
|
field: "referenceId",
|
|
306
|
-
value:
|
|
569
|
+
value: referenceId
|
|
307
570
|
}]
|
|
308
571
|
});
|
|
309
|
-
const activeOrTrialingSubscription = subscriptions$1.find((sub) => sub
|
|
310
|
-
const activeSubscription = (await client.subscriptions.list({ customer: customerId }).then((res) => res.data.filter((sub) => sub
|
|
572
|
+
const activeOrTrialingSubscription = subscriptions$1.find((sub) => isActiveOrTrialing(sub));
|
|
573
|
+
const activeSubscription = (await client.subscriptions.list({ customer: customerId }).then((res) => res.data.filter((sub) => isActiveOrTrialing(sub)))).find((sub) => {
|
|
311
574
|
if (subscriptionToUpdate?.stripeSubscriptionId || ctx.body.subscriptionId) return sub.id === subscriptionToUpdate?.stripeSubscriptionId || sub.id === ctx.body.subscriptionId;
|
|
312
575
|
if (activeOrTrialingSubscription?.stripeSubscriptionId) return sub.id === activeOrTrialingSubscription.stripeSubscriptionId;
|
|
313
576
|
return false;
|
|
314
577
|
});
|
|
315
578
|
const incompleteSubscription = subscriptions$1.find((sub) => sub.status === "incomplete");
|
|
316
|
-
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);
|
|
317
580
|
if (activeSubscription && customerId) {
|
|
318
581
|
let dbSubscription = await ctx.context.adapter.findOne({
|
|
319
582
|
model: "subscription",
|
|
@@ -371,7 +634,7 @@ const upgradeSubscription = (options) => {
|
|
|
371
634
|
});
|
|
372
635
|
return ctx.json({
|
|
373
636
|
url,
|
|
374
|
-
redirect:
|
|
637
|
+
redirect: !ctx.body.disableRedirect
|
|
375
638
|
});
|
|
376
639
|
}
|
|
377
640
|
let subscription = activeOrTrialingSubscription || incompleteSubscription;
|
|
@@ -399,7 +662,7 @@ const upgradeSubscription = (options) => {
|
|
|
399
662
|
});
|
|
400
663
|
if (!subscription) {
|
|
401
664
|
ctx.context.logger.error("Subscription ID not found");
|
|
402
|
-
throw
|
|
665
|
+
throw APIError$1.from("NOT_FOUND", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
|
|
403
666
|
}
|
|
404
667
|
const params = await subscriptionOptions.getCheckoutSessionParams?.({
|
|
405
668
|
user: user$1,
|
|
@@ -407,7 +670,13 @@ const upgradeSubscription = (options) => {
|
|
|
407
670
|
plan,
|
|
408
671
|
subscription
|
|
409
672
|
}, ctx.request, ctx);
|
|
410
|
-
const freeTrial = !
|
|
673
|
+
const freeTrial = !(await ctx.context.adapter.findMany({
|
|
674
|
+
model: "subscription",
|
|
675
|
+
where: [{
|
|
676
|
+
field: "referenceId",
|
|
677
|
+
value: referenceId
|
|
678
|
+
}]
|
|
679
|
+
})).some((s) => {
|
|
411
680
|
return !!(s.trialStart || s.trialEnd) || s.status === "trialing";
|
|
412
681
|
}) && plan.freeTrial ? { trial_period_days: plan.freeTrial.days } : void 0;
|
|
413
682
|
let priceIdToUse = void 0;
|
|
@@ -421,26 +690,36 @@ const upgradeSubscription = (options) => {
|
|
|
421
690
|
const checkoutSession = await client.checkout.sessions.create({
|
|
422
691
|
...customerId ? {
|
|
423
692
|
customer: customerId,
|
|
424
|
-
customer_update: {
|
|
693
|
+
customer_update: customerType !== "user" ? { address: "auto" } : {
|
|
425
694
|
name: "auto",
|
|
426
695
|
address: "auto"
|
|
427
696
|
}
|
|
428
|
-
} : { customer_email:
|
|
697
|
+
} : { customer_email: user$1.email },
|
|
429
698
|
success_url: getUrl(ctx, `${ctx.context.baseURL}/subscription/success?callbackURL=${encodeURIComponent(ctx.body.successUrl)}&subscriptionId=${encodeURIComponent(subscription.id)}`),
|
|
430
699
|
cancel_url: getUrl(ctx, ctx.body.cancelUrl),
|
|
431
700
|
line_items: [{
|
|
432
701
|
price: priceIdToUse,
|
|
433
702
|
quantity: ctx.body.seats || 1
|
|
434
703
|
}],
|
|
435
|
-
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
|
+
},
|
|
436
714
|
mode: "subscription",
|
|
437
715
|
client_reference_id: referenceId,
|
|
438
716
|
...params?.params,
|
|
439
717
|
metadata: {
|
|
718
|
+
...ctx.body.metadata,
|
|
719
|
+
...params?.params?.metadata,
|
|
440
720
|
userId: user$1.id,
|
|
441
721
|
subscriptionId: subscription.id,
|
|
442
|
-
referenceId
|
|
443
|
-
...params?.params?.metadata
|
|
722
|
+
referenceId
|
|
444
723
|
}
|
|
445
724
|
}, params?.options).catch(async (e) => {
|
|
446
725
|
throw ctx.error("BAD_REQUEST", {
|
|
@@ -477,17 +756,19 @@ const cancelSubscriptionCallback = (options) => {
|
|
|
477
756
|
value: subscriptionId
|
|
478
757
|
}]
|
|
479
758
|
});
|
|
480
|
-
if (!subscription || subscription.
|
|
759
|
+
if (!subscription || subscription.status === "canceled" || isPendingCancel(subscription)) throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
481
760
|
const currentSubscription = (await client.subscriptions.list({
|
|
482
761
|
customer: user$1.stripeCustomerId,
|
|
483
762
|
status: "active"
|
|
484
763
|
})).data.find((sub) => sub.id === subscription.stripeSubscriptionId);
|
|
485
|
-
if (currentSubscription
|
|
764
|
+
if (currentSubscription && isStripePendingCancel(currentSubscription) && !isPendingCancel(subscription)) {
|
|
486
765
|
await ctx.context.adapter.update({
|
|
487
766
|
model: "subscription",
|
|
488
767
|
update: {
|
|
489
768
|
status: currentSubscription?.status,
|
|
490
|
-
cancelAtPeriodEnd:
|
|
769
|
+
cancelAtPeriodEnd: currentSubscription?.cancel_at_period_end || false,
|
|
770
|
+
cancelAt: currentSubscription?.cancel_at ? /* @__PURE__ */ new Date(currentSubscription.cancel_at * 1e3) : null,
|
|
771
|
+
canceledAt: currentSubscription?.canceled_at ? /* @__PURE__ */ new Date(currentSubscription.canceled_at * 1e3) : null
|
|
491
772
|
},
|
|
492
773
|
where: [{
|
|
493
774
|
field: "id",
|
|
@@ -510,7 +791,9 @@ const cancelSubscriptionCallback = (options) => {
|
|
|
510
791
|
const cancelSubscriptionBodySchema = z.object({
|
|
511
792
|
referenceId: z.string().meta({ description: "Reference id of the subscription to cancel. Eg: '123'" }).optional(),
|
|
512
793
|
subscriptionId: z.string().meta({ description: "The Stripe subscription ID to cancel. Eg: 'sub_1ABC2DEF3GHI4JKL'" }).optional(),
|
|
513
|
-
|
|
794
|
+
customerType: z.enum(["user", "organization"]).meta({ description: "Customer type for the subscription. Eg: \"user\" or \"organization\"" }).optional(),
|
|
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\"" }),
|
|
796
|
+
disableRedirect: z.boolean().meta({ description: "Disable redirect after successful subscription cancellation. Eg: true" }).default(false)
|
|
514
797
|
});
|
|
515
798
|
/**
|
|
516
799
|
* ### Endpoint
|
|
@@ -535,12 +818,13 @@ const cancelSubscription = (options) => {
|
|
|
535
818
|
body: cancelSubscriptionBodySchema,
|
|
536
819
|
metadata: { openapi: { operationId: "cancelSubscription" } },
|
|
537
820
|
use: [
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
821
|
+
stripeSessionMiddleware,
|
|
822
|
+
referenceMiddleware(subscriptionOptions, "cancel-subscription"),
|
|
823
|
+
originCheck((ctx) => ctx.body.returnUrl)
|
|
541
824
|
]
|
|
542
825
|
}, async (ctx) => {
|
|
543
|
-
const
|
|
826
|
+
const customerType = ctx.body.customerType || "user";
|
|
827
|
+
const referenceId = ctx.body.referenceId || getReferenceId(ctx.context.session, customerType, options);
|
|
544
828
|
let subscription = ctx.body.subscriptionId ? await ctx.context.adapter.findOne({
|
|
545
829
|
model: "subscription",
|
|
546
830
|
where: [{
|
|
@@ -553,10 +837,10 @@ const cancelSubscription = (options) => {
|
|
|
553
837
|
field: "referenceId",
|
|
554
838
|
value: referenceId
|
|
555
839
|
}]
|
|
556
|
-
}).then((subs) => subs.find((sub) => sub
|
|
840
|
+
}).then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
|
|
557
841
|
if (ctx.body.subscriptionId && subscription && subscription.referenceId !== referenceId) subscription = void 0;
|
|
558
|
-
if (!subscription || !subscription.stripeCustomerId) throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES
|
|
559
|
-
const activeSubscriptions = await client.subscriptions.list({ customer: subscription.stripeCustomerId }).then((res) => res.data.filter((sub) => sub
|
|
842
|
+
if (!subscription || !subscription.stripeCustomerId) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
|
|
843
|
+
const activeSubscriptions = await client.subscriptions.list({ customer: subscription.stripeCustomerId }).then((res) => res.data.filter((sub) => isActiveOrTrialing(sub)));
|
|
560
844
|
if (!activeSubscriptions.length) {
|
|
561
845
|
/**
|
|
562
846
|
* If the subscription is not found, we need to delete the subscription
|
|
@@ -569,10 +853,10 @@ const cancelSubscription = (options) => {
|
|
|
569
853
|
value: referenceId
|
|
570
854
|
}]
|
|
571
855
|
});
|
|
572
|
-
throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES
|
|
856
|
+
throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
|
|
573
857
|
}
|
|
574
858
|
const activeSubscription = activeSubscriptions.find((sub) => sub.id === subscription.stripeSubscriptionId);
|
|
575
|
-
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);
|
|
576
860
|
const { url } = await client.billingPortal.sessions.create({
|
|
577
861
|
customer: subscription.stripeCustomerId,
|
|
578
862
|
return_url: getUrl(ctx, `${ctx.context.baseURL}/subscription/cancel/callback?callbackURL=${encodeURIComponent(ctx.body?.returnUrl || "/")}&subscriptionId=${encodeURIComponent(subscription.id)}`),
|
|
@@ -581,34 +865,42 @@ const cancelSubscription = (options) => {
|
|
|
581
865
|
subscription_cancel: { subscription: activeSubscription.id }
|
|
582
866
|
}
|
|
583
867
|
}).catch(async (e) => {
|
|
584
|
-
if (e.message
|
|
868
|
+
if (e.message?.includes("already set to be canceled")) {
|
|
585
869
|
/**
|
|
586
|
-
* in-case we missed the event from stripe, we
|
|
870
|
+
* in-case we missed the event from stripe, we sync the actual state
|
|
587
871
|
* this is a rare case and should not happen
|
|
588
872
|
*/
|
|
589
|
-
if (!subscription
|
|
590
|
-
|
|
591
|
-
update
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
873
|
+
if (!isPendingCancel(subscription)) {
|
|
874
|
+
const stripeSub = await client.subscriptions.retrieve(activeSubscription.id);
|
|
875
|
+
await ctx.context.adapter.update({
|
|
876
|
+
model: "subscription",
|
|
877
|
+
update: {
|
|
878
|
+
cancelAtPeriodEnd: stripeSub.cancel_at_period_end,
|
|
879
|
+
cancelAt: stripeSub.cancel_at ? /* @__PURE__ */ new Date(stripeSub.cancel_at * 1e3) : null,
|
|
880
|
+
canceledAt: stripeSub.canceled_at ? /* @__PURE__ */ new Date(stripeSub.canceled_at * 1e3) : null
|
|
881
|
+
},
|
|
882
|
+
where: [{
|
|
883
|
+
field: "id",
|
|
884
|
+
value: subscription.id
|
|
885
|
+
}]
|
|
886
|
+
});
|
|
887
|
+
}
|
|
597
888
|
}
|
|
598
889
|
throw ctx.error("BAD_REQUEST", {
|
|
599
890
|
message: e.message,
|
|
600
891
|
code: e.code
|
|
601
892
|
});
|
|
602
893
|
});
|
|
603
|
-
return {
|
|
894
|
+
return ctx.json({
|
|
604
895
|
url,
|
|
605
|
-
redirect:
|
|
606
|
-
};
|
|
896
|
+
redirect: !ctx.body.disableRedirect
|
|
897
|
+
});
|
|
607
898
|
});
|
|
608
899
|
};
|
|
609
900
|
const restoreSubscriptionBodySchema = z.object({
|
|
610
901
|
referenceId: z.string().meta({ description: "Reference id of the subscription to restore. Eg: '123'" }).optional(),
|
|
611
|
-
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()
|
|
612
904
|
});
|
|
613
905
|
const restoreSubscription = (options) => {
|
|
614
906
|
const client = options.stripeClient;
|
|
@@ -617,9 +909,10 @@ const restoreSubscription = (options) => {
|
|
|
617
909
|
method: "POST",
|
|
618
910
|
body: restoreSubscriptionBodySchema,
|
|
619
911
|
metadata: { openapi: { operationId: "restoreSubscription" } },
|
|
620
|
-
use: [
|
|
912
|
+
use: [stripeSessionMiddleware, referenceMiddleware(subscriptionOptions, "restore-subscription")]
|
|
621
913
|
}, async (ctx) => {
|
|
622
|
-
const
|
|
914
|
+
const customerType = ctx.body.customerType || "user";
|
|
915
|
+
const referenceId = ctx.body.referenceId || getReferenceId(ctx.context.session, customerType, options);
|
|
623
916
|
let subscription = ctx.body.subscriptionId ? await ctx.context.adapter.findOne({
|
|
624
917
|
model: "subscription",
|
|
625
918
|
where: [{
|
|
@@ -632,34 +925,42 @@ const restoreSubscription = (options) => {
|
|
|
632
925
|
field: "referenceId",
|
|
633
926
|
value: referenceId
|
|
634
927
|
}]
|
|
635
|
-
}).then((subs) => subs.find((sub) => sub
|
|
928
|
+
}).then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
|
|
636
929
|
if (ctx.body.subscriptionId && subscription && subscription.referenceId !== referenceId) subscription = void 0;
|
|
637
|
-
if (!subscription || !subscription.stripeCustomerId) throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES
|
|
638
|
-
if (subscription
|
|
639
|
-
if (!subscription
|
|
640
|
-
const activeSubscription = await client.subscriptions.list({ customer: subscription.stripeCustomerId }).then((res) => res.data.filter((sub) => sub
|
|
641
|
-
if (!activeSubscription) throw APIError.from("BAD_REQUEST", STRIPE_ERROR_CODES
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
},
|
|
650
|
-
where: [{
|
|
651
|
-
field: "id",
|
|
652
|
-
value: subscription.id
|
|
653
|
-
}]
|
|
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);
|
|
933
|
+
const activeSubscription = await client.subscriptions.list({ customer: subscription.stripeCustomerId }).then((res) => res.data.filter((sub) => isActiveOrTrialing(sub))[0]);
|
|
934
|
+
if (!activeSubscription) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND);
|
|
935
|
+
const updateParams = {};
|
|
936
|
+
if (activeSubscription.cancel_at) updateParams.cancel_at = "";
|
|
937
|
+
else if (activeSubscription.cancel_at_period_end) updateParams.cancel_at_period_end = false;
|
|
938
|
+
const newSub = await client.subscriptions.update(activeSubscription.id, updateParams).catch((e) => {
|
|
939
|
+
throw ctx.error("BAD_REQUEST", {
|
|
940
|
+
message: e.message,
|
|
941
|
+
code: e.code
|
|
654
942
|
});
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
943
|
+
});
|
|
944
|
+
await ctx.context.adapter.update({
|
|
945
|
+
model: "subscription",
|
|
946
|
+
update: {
|
|
947
|
+
cancelAtPeriodEnd: false,
|
|
948
|
+
cancelAt: null,
|
|
949
|
+
canceledAt: null,
|
|
950
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
951
|
+
},
|
|
952
|
+
where: [{
|
|
953
|
+
field: "id",
|
|
954
|
+
value: subscription.id
|
|
955
|
+
}]
|
|
956
|
+
});
|
|
957
|
+
return ctx.json(newSub);
|
|
660
958
|
});
|
|
661
959
|
};
|
|
662
|
-
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
|
+
}));
|
|
663
964
|
/**
|
|
664
965
|
* ### Endpoint
|
|
665
966
|
*
|
|
@@ -681,13 +982,15 @@ const listActiveSubscriptions = (options) => {
|
|
|
681
982
|
method: "GET",
|
|
682
983
|
query: listActiveSubscriptionsQuerySchema,
|
|
683
984
|
metadata: { openapi: { operationId: "listActiveSubscriptions" } },
|
|
684
|
-
use: [
|
|
985
|
+
use: [stripeSessionMiddleware, referenceMiddleware(subscriptionOptions, "list-subscription")]
|
|
685
986
|
}, async (ctx) => {
|
|
987
|
+
const customerType = ctx.query?.customerType || "user";
|
|
988
|
+
const referenceId = ctx.query?.referenceId || getReferenceId(ctx.context.session, customerType, options);
|
|
686
989
|
const subscriptions$1 = await ctx.context.adapter.findMany({
|
|
687
990
|
model: "subscription",
|
|
688
991
|
where: [{
|
|
689
992
|
field: "referenceId",
|
|
690
|
-
value:
|
|
993
|
+
value: referenceId
|
|
691
994
|
}]
|
|
692
995
|
});
|
|
693
996
|
if (!subscriptions$1.length) return [];
|
|
@@ -700,9 +1003,7 @@ const listActiveSubscriptions = (options) => {
|
|
|
700
1003
|
limits: plan?.limits,
|
|
701
1004
|
priceId: plan?.priceId
|
|
702
1005
|
};
|
|
703
|
-
}).filter((sub) =>
|
|
704
|
-
return sub.status === "active" || sub.status === "trialing";
|
|
705
|
-
});
|
|
1006
|
+
}).filter((sub) => isActiveOrTrialing(sub));
|
|
706
1007
|
return ctx.json(subs);
|
|
707
1008
|
});
|
|
708
1009
|
};
|
|
@@ -716,10 +1017,9 @@ const subscriptionSuccess = (options) => {
|
|
|
716
1017
|
use: [originCheck((ctx) => ctx.query.callbackURL)]
|
|
717
1018
|
}, async (ctx) => {
|
|
718
1019
|
if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
|
|
1020
|
+
const { callbackURL, subscriptionId } = ctx.query;
|
|
719
1021
|
const session = await getSessionFromCtx(ctx);
|
|
720
1022
|
if (!session) throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
|
|
721
|
-
const { user: user$1 } = session;
|
|
722
|
-
const { callbackURL, subscriptionId } = ctx.query;
|
|
723
1023
|
const subscription = await ctx.context.adapter.findOne({
|
|
724
1024
|
model: "subscription",
|
|
725
1025
|
where: [{
|
|
@@ -727,38 +1027,53 @@ const subscriptionSuccess = (options) => {
|
|
|
727
1027
|
value: subscriptionId
|
|
728
1028
|
}]
|
|
729
1029
|
});
|
|
730
|
-
if (subscription
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
update: {
|
|
742
|
-
status: stripeSubscription.status,
|
|
743
|
-
seats: stripeSubscription.items.data[0]?.quantity || 1,
|
|
744
|
-
plan: plan.name.toLowerCase(),
|
|
745
|
-
periodEnd: /* @__PURE__ */ new Date(stripeSubscription.items.data[0]?.current_period_end * 1e3),
|
|
746
|
-
periodStart: /* @__PURE__ */ new Date(stripeSubscription.items.data[0]?.current_period_start * 1e3),
|
|
747
|
-
stripeSubscriptionId: stripeSubscription.id,
|
|
748
|
-
...stripeSubscription.trial_start && stripeSubscription.trial_end ? {
|
|
749
|
-
trialStart: /* @__PURE__ */ new Date(stripeSubscription.trial_start * 1e3),
|
|
750
|
-
trialEnd: /* @__PURE__ */ new Date(stripeSubscription.trial_end * 1e3)
|
|
751
|
-
} : {}
|
|
752
|
-
},
|
|
753
|
-
where: [{
|
|
754
|
-
field: "id",
|
|
755
|
-
value: subscription.id
|
|
756
|
-
}]
|
|
757
|
-
});
|
|
758
|
-
}
|
|
759
|
-
} 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) => {
|
|
760
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));
|
|
761
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
|
+
});
|
|
762
1077
|
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
763
1078
|
});
|
|
764
1079
|
};
|
|
@@ -767,7 +1082,9 @@ const createBillingPortalBodySchema = z.object({
|
|
|
767
1082
|
return typeof localization === "string";
|
|
768
1083
|
}).optional(),
|
|
769
1084
|
referenceId: z.string().optional(),
|
|
770
|
-
|
|
1085
|
+
customerType: z.enum(["user", "organization"]).meta({ description: "Customer type for the subscription. Eg: \"user\" or \"organization\"" }).optional(),
|
|
1086
|
+
returnUrl: z.string().default("/"),
|
|
1087
|
+
disableRedirect: z.boolean().meta({ description: "Disable redirect after creating billing portal session. Eg: true" }).default(false)
|
|
771
1088
|
});
|
|
772
1089
|
const createBillingPortal = (options) => {
|
|
773
1090
|
const client = options.stripeClient;
|
|
@@ -777,22 +1094,41 @@ const createBillingPortal = (options) => {
|
|
|
777
1094
|
body: createBillingPortalBodySchema,
|
|
778
1095
|
metadata: { openapi: { operationId: "createBillingPortal" } },
|
|
779
1096
|
use: [
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
1097
|
+
stripeSessionMiddleware,
|
|
1098
|
+
referenceMiddleware(subscriptionOptions, "billing-portal"),
|
|
1099
|
+
originCheck((ctx) => ctx.body.returnUrl)
|
|
783
1100
|
]
|
|
784
1101
|
}, async (ctx) => {
|
|
785
1102
|
const { user: user$1 } = ctx.context.session;
|
|
786
|
-
const
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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);
|
|
796
1132
|
try {
|
|
797
1133
|
const { url } = await client.billingPortal.sessions.create({
|
|
798
1134
|
locale: ctx.body.locale,
|
|
@@ -801,11 +1137,11 @@ const createBillingPortal = (options) => {
|
|
|
801
1137
|
});
|
|
802
1138
|
return ctx.json({
|
|
803
1139
|
url,
|
|
804
|
-
redirect:
|
|
1140
|
+
redirect: !ctx.body.disableRedirect
|
|
805
1141
|
});
|
|
806
1142
|
} catch (error) {
|
|
807
1143
|
ctx.context.logger.error("Error creating billing portal session", error);
|
|
808
|
-
throw
|
|
1144
|
+
throw APIError$1.from("INTERNAL_SERVER_ERROR", STRIPE_ERROR_CODES.UNABLE_TO_CREATE_BILLING_PORTAL);
|
|
809
1145
|
}
|
|
810
1146
|
});
|
|
811
1147
|
};
|
|
@@ -820,26 +1156,31 @@ const stripeWebhook = (options) => {
|
|
|
820
1156
|
cloneRequest: true,
|
|
821
1157
|
disableBody: true
|
|
822
1158
|
}, async (ctx) => {
|
|
823
|
-
if (!ctx.request?.body) throw
|
|
824
|
-
const buf = await ctx.request.text();
|
|
1159
|
+
if (!ctx.request?.body) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.INVALID_REQUEST_BODY);
|
|
825
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);
|
|
826
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();
|
|
827
1165
|
let event;
|
|
828
1166
|
try {
|
|
829
|
-
if (
|
|
830
|
-
|
|
831
|
-
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);
|
|
832
1169
|
} catch (err) {
|
|
833
1170
|
ctx.context.logger.error(`${err.message}`);
|
|
834
|
-
throw
|
|
1171
|
+
throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.FAILED_TO_CONSTRUCT_STRIPE_EVENT);
|
|
835
1172
|
}
|
|
836
|
-
if (!event) throw
|
|
1173
|
+
if (!event) throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.FAILED_TO_CONSTRUCT_STRIPE_EVENT);
|
|
837
1174
|
try {
|
|
838
1175
|
switch (event.type) {
|
|
839
1176
|
case "checkout.session.completed":
|
|
840
1177
|
await onCheckoutSessionCompleted(ctx, options, event);
|
|
841
1178
|
await options.onEvent?.(event);
|
|
842
1179
|
break;
|
|
1180
|
+
case "customer.subscription.created":
|
|
1181
|
+
await onSubscriptionCreated(ctx, options, event);
|
|
1182
|
+
await options.onEvent?.(event);
|
|
1183
|
+
break;
|
|
843
1184
|
case "customer.subscription.updated":
|
|
844
1185
|
await onSubscriptionUpdated(ctx, options, event);
|
|
845
1186
|
await options.onEvent?.(event);
|
|
@@ -854,23 +1195,11 @@ const stripeWebhook = (options) => {
|
|
|
854
1195
|
}
|
|
855
1196
|
} catch (e) {
|
|
856
1197
|
ctx.context.logger.error(`Stripe webhook failed. Error: ${e.message}`);
|
|
857
|
-
throw
|
|
1198
|
+
throw APIError$1.from("BAD_REQUEST", STRIPE_ERROR_CODES.STRIPE_WEBHOOK_ERROR);
|
|
858
1199
|
}
|
|
859
1200
|
return ctx.json({ success: true });
|
|
860
1201
|
});
|
|
861
1202
|
};
|
|
862
|
-
const getUrl = (ctx, url) => {
|
|
863
|
-
if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(url)) return url;
|
|
864
|
-
return `${ctx.context.options.baseURL}${url.startsWith("/") ? url : `/${url}`}`;
|
|
865
|
-
};
|
|
866
|
-
async function resolvePriceIdFromLookupKey(stripeClient, lookupKey) {
|
|
867
|
-
if (!lookupKey) return void 0;
|
|
868
|
-
return (await stripeClient.prices.list({
|
|
869
|
-
lookup_keys: [lookupKey],
|
|
870
|
-
active: true,
|
|
871
|
-
limit: 1
|
|
872
|
-
})).data[0]?.id;
|
|
873
|
-
}
|
|
874
1203
|
|
|
875
1204
|
//#endregion
|
|
876
1205
|
//#region src/schema.ts
|
|
@@ -916,6 +1245,18 @@ const subscriptions = { subscription: { fields: {
|
|
|
916
1245
|
required: false,
|
|
917
1246
|
defaultValue: false
|
|
918
1247
|
},
|
|
1248
|
+
cancelAt: {
|
|
1249
|
+
type: "date",
|
|
1250
|
+
required: false
|
|
1251
|
+
},
|
|
1252
|
+
canceledAt: {
|
|
1253
|
+
type: "date",
|
|
1254
|
+
required: false
|
|
1255
|
+
},
|
|
1256
|
+
endedAt: {
|
|
1257
|
+
type: "date",
|
|
1258
|
+
required: false
|
|
1259
|
+
},
|
|
919
1260
|
seats: {
|
|
920
1261
|
type: "number",
|
|
921
1262
|
required: false
|
|
@@ -925,6 +1266,10 @@ const user = { user: { fields: { stripeCustomerId: {
|
|
|
925
1266
|
type: "string",
|
|
926
1267
|
required: false
|
|
927
1268
|
} } } };
|
|
1269
|
+
const organization = { organization: { fields: { stripeCustomerId: {
|
|
1270
|
+
type: "string",
|
|
1271
|
+
required: false
|
|
1272
|
+
} } } };
|
|
928
1273
|
const getSchema = (options) => {
|
|
929
1274
|
let baseSchema = {};
|
|
930
1275
|
if (options.subscription?.enabled) baseSchema = {
|
|
@@ -932,6 +1277,10 @@ const getSchema = (options) => {
|
|
|
932
1277
|
...user
|
|
933
1278
|
};
|
|
934
1279
|
else baseSchema = { ...user };
|
|
1280
|
+
if (options.organization?.enabled) baseSchema = {
|
|
1281
|
+
...baseSchema,
|
|
1282
|
+
...organization
|
|
1283
|
+
};
|
|
935
1284
|
if (options.schema && !options.subscription?.enabled && "subscription" in options.schema) {
|
|
936
1285
|
const { subscription: _subscription, ...restSchema } = options.schema;
|
|
937
1286
|
return mergeSchema(baseSchema, restSchema);
|
|
@@ -941,16 +1290,6 @@ const getSchema = (options) => {
|
|
|
941
1290
|
|
|
942
1291
|
//#endregion
|
|
943
1292
|
//#region src/index.ts
|
|
944
|
-
const STRIPE_ERROR_CODES = defineErrorCodes({
|
|
945
|
-
SUBSCRIPTION_NOT_FOUND: "Subscription not found",
|
|
946
|
-
SUBSCRIPTION_PLAN_NOT_FOUND: "Subscription plan not found",
|
|
947
|
-
ALREADY_SUBSCRIBED_PLAN: "You're already subscribed to this plan",
|
|
948
|
-
UNABLE_TO_CREATE_CUSTOMER: "Unable to create customer",
|
|
949
|
-
FAILED_TO_FETCH_PLANS: "Failed to fetch plans",
|
|
950
|
-
EMAIL_VERIFICATION_REQUIRED: "Email verification is required before you can subscribe to a plan",
|
|
951
|
-
SUBSCRIPTION_NOT_ACTIVE: "Subscription is not active",
|
|
952
|
-
SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION: "Subscription is not scheduled for cancellation"
|
|
953
|
-
});
|
|
954
1293
|
const stripe = (options) => {
|
|
955
1294
|
const client = options.stripeClient;
|
|
956
1295
|
const subscriptionEndpoints = {
|
|
@@ -969,13 +1308,70 @@ const stripe = (options) => {
|
|
|
969
1308
|
...options.subscription?.enabled ? subscriptionEndpoints : {}
|
|
970
1309
|
},
|
|
971
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
|
+
}
|
|
972
1369
|
return { options: { databaseHooks: { user: {
|
|
973
1370
|
create: { async after(user$1, ctx$1) {
|
|
974
|
-
if (!ctx$1 || !options.createCustomerOnSignUp) return;
|
|
1371
|
+
if (!ctx$1 || !options.createCustomerOnSignUp || user$1.stripeCustomerId) return;
|
|
975
1372
|
try {
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
email: user$1.email,
|
|
1373
|
+
let stripeCustomer = (await client.customers.search({
|
|
1374
|
+
query: `email:"${escapeStripeSearchValue(user$1.email)}" AND -metadata["customerType"]:"organization"`,
|
|
979
1375
|
limit: 1
|
|
980
1376
|
})).data[0];
|
|
981
1377
|
if (stripeCustomer) {
|
|
@@ -995,7 +1391,10 @@ const stripe = (options) => {
|
|
|
995
1391
|
const params = defu({
|
|
996
1392
|
email: user$1.email,
|
|
997
1393
|
name: user$1.name,
|
|
998
|
-
metadata: {
|
|
1394
|
+
metadata: {
|
|
1395
|
+
userId: user$1.id,
|
|
1396
|
+
customerType: "user"
|
|
1397
|
+
}
|
|
999
1398
|
}, extraCreateParams);
|
|
1000
1399
|
stripeCustomer = await client.customers.create(params);
|
|
1001
1400
|
await ctx$1.context.internalAdapter.updateUser(user$1.id, { stripeCustomerId: stripeCustomer.id });
|
|
@@ -1012,17 +1411,15 @@ const stripe = (options) => {
|
|
|
1012
1411
|
}
|
|
1013
1412
|
} },
|
|
1014
1413
|
update: { async after(user$1, ctx$1) {
|
|
1015
|
-
if (!ctx$1) return;
|
|
1414
|
+
if (!ctx$1 || !user$1.stripeCustomerId) return;
|
|
1016
1415
|
try {
|
|
1017
|
-
const
|
|
1018
|
-
if (!userWithStripe.stripeCustomerId) return;
|
|
1019
|
-
const stripeCustomer = await client.customers.retrieve(userWithStripe.stripeCustomerId);
|
|
1416
|
+
const stripeCustomer = await client.customers.retrieve(user$1.stripeCustomerId);
|
|
1020
1417
|
if (stripeCustomer.deleted) {
|
|
1021
|
-
ctx$1.context.logger.warn(`Stripe customer ${
|
|
1418
|
+
ctx$1.context.logger.warn(`Stripe customer ${user$1.stripeCustomerId} was deleted, cannot update email`);
|
|
1022
1419
|
return;
|
|
1023
1420
|
}
|
|
1024
1421
|
if (stripeCustomer.email !== user$1.email) {
|
|
1025
|
-
await client.customers.update(
|
|
1422
|
+
await client.customers.update(user$1.stripeCustomerId, { email: user$1.email });
|
|
1026
1423
|
ctx$1.context.logger.info(`Updated Stripe customer email from ${stripeCustomer.email} to ${user$1.email}`);
|
|
1027
1424
|
}
|
|
1028
1425
|
} catch (e) {
|