@better-auth/stripe 1.4.9 → 1.4.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +6 -6
- package/dist/client.d.mts +2 -2
- package/dist/client.mjs +2 -2
- package/dist/{index-DQa3pHPd.d.mts → index-DtwvPnmn.d.mts} +45 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +201 -59
- package/package.json +5 -5
- package/src/client.ts +1 -1
- package/src/hooks.ts +166 -17
- package/src/middleware.ts +1 -2
- package/src/routes.ts +126 -83
- package/src/schema.ts +12 -0
- package/src/stripe.test.ts +2434 -1291
- package/src/types.ts +30 -1
- package/src/utils.ts +25 -1
package/src/hooks.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import type { GenericEndpointContext } from "better-auth";
|
|
2
|
-
import {
|
|
1
|
+
import type { GenericEndpointContext } from "@better-auth/core";
|
|
2
|
+
import type { User } from "@better-auth/core/db";
|
|
3
3
|
import type Stripe from "stripe";
|
|
4
4
|
import type { InputSubscription, StripeOptions, Subscription } from "./types";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
getPlanByPriceInfo,
|
|
7
|
+
isActiveOrTrialing,
|
|
8
|
+
isPendingCancel,
|
|
9
|
+
isStripePendingCancel,
|
|
10
|
+
} from "./utils";
|
|
6
11
|
|
|
7
12
|
export async function onCheckoutSessionCompleted(
|
|
8
13
|
ctx: GenericEndpointContext,
|
|
@@ -54,7 +59,17 @@ export async function onCheckoutSessionCompleted(
|
|
|
54
59
|
subscription.items.data[0]!.current_period_end * 1000,
|
|
55
60
|
),
|
|
56
61
|
stripeSubscriptionId: checkoutSession.subscription as string,
|
|
57
|
-
|
|
62
|
+
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
63
|
+
cancelAt: subscription.cancel_at
|
|
64
|
+
? new Date(subscription.cancel_at * 1000)
|
|
65
|
+
: null,
|
|
66
|
+
canceledAt: subscription.canceled_at
|
|
67
|
+
? new Date(subscription.canceled_at * 1000)
|
|
68
|
+
: null,
|
|
69
|
+
endedAt: subscription.ended_at
|
|
70
|
+
? new Date(subscription.ended_at * 1000)
|
|
71
|
+
: null,
|
|
72
|
+
seats: seats,
|
|
58
73
|
...trial,
|
|
59
74
|
},
|
|
60
75
|
where: [
|
|
@@ -93,7 +108,123 @@ export async function onCheckoutSessionCompleted(
|
|
|
93
108
|
}
|
|
94
109
|
}
|
|
95
110
|
} catch (e: any) {
|
|
96
|
-
logger.error(`Stripe webhook failed. Error: ${e.message}`);
|
|
111
|
+
ctx.context.logger.error(`Stripe webhook failed. Error: ${e.message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function onSubscriptionCreated(
|
|
116
|
+
ctx: GenericEndpointContext,
|
|
117
|
+
options: StripeOptions,
|
|
118
|
+
event: Stripe.Event,
|
|
119
|
+
) {
|
|
120
|
+
try {
|
|
121
|
+
if (!options.subscription?.enabled) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const subscriptionCreated = event.data.object as Stripe.Subscription;
|
|
126
|
+
const stripeCustomerId = subscriptionCreated.customer?.toString();
|
|
127
|
+
if (!stripeCustomerId) {
|
|
128
|
+
ctx.context.logger.warn(
|
|
129
|
+
`Stripe webhook warning: customer.subscription.created event received without customer ID`,
|
|
130
|
+
);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Check if subscription already exists in database
|
|
135
|
+
const existingSubscription =
|
|
136
|
+
await ctx.context.adapter.findOne<Subscription>({
|
|
137
|
+
model: "subscription",
|
|
138
|
+
where: [
|
|
139
|
+
{
|
|
140
|
+
field: "stripeSubscriptionId",
|
|
141
|
+
value: subscriptionCreated.id,
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
});
|
|
145
|
+
if (existingSubscription) {
|
|
146
|
+
ctx.context.logger.info(
|
|
147
|
+
`Stripe webhook: Subscription ${subscriptionCreated.id} already exists in database, skipping creation`,
|
|
148
|
+
);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Find user by stripeCustomerId
|
|
153
|
+
const user = await ctx.context.adapter.findOne<User>({
|
|
154
|
+
model: "user",
|
|
155
|
+
where: [
|
|
156
|
+
{
|
|
157
|
+
field: "stripeCustomerId",
|
|
158
|
+
value: stripeCustomerId,
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
});
|
|
162
|
+
if (!user) {
|
|
163
|
+
ctx.context.logger.warn(
|
|
164
|
+
`Stripe webhook warning: No user found with stripeCustomerId: ${stripeCustomerId}`,
|
|
165
|
+
);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const subscriptionItem = subscriptionCreated.items.data[0];
|
|
170
|
+
if (!subscriptionItem) {
|
|
171
|
+
ctx.context.logger.warn(
|
|
172
|
+
`Stripe webhook warning: Subscription ${subscriptionCreated.id} has no items`,
|
|
173
|
+
);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const priceId = subscriptionItem.price.id;
|
|
178
|
+
const priceLookupKey = subscriptionItem.price.lookup_key || null;
|
|
179
|
+
const plan = await getPlanByPriceInfo(options, priceId, priceLookupKey);
|
|
180
|
+
if (!plan) {
|
|
181
|
+
ctx.context.logger.warn(
|
|
182
|
+
`Stripe webhook warning: No matching plan found for priceId: ${priceId}`,
|
|
183
|
+
);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const seats = subscriptionItem.quantity;
|
|
188
|
+
const periodStart = new Date(subscriptionItem.current_period_start * 1000);
|
|
189
|
+
const periodEnd = new Date(subscriptionItem.current_period_end * 1000);
|
|
190
|
+
|
|
191
|
+
const trial =
|
|
192
|
+
subscriptionCreated.trial_start && subscriptionCreated.trial_end
|
|
193
|
+
? {
|
|
194
|
+
trialStart: new Date(subscriptionCreated.trial_start * 1000),
|
|
195
|
+
trialEnd: new Date(subscriptionCreated.trial_end * 1000),
|
|
196
|
+
}
|
|
197
|
+
: {};
|
|
198
|
+
|
|
199
|
+
// Create the subscription in the database
|
|
200
|
+
const newSubscription = await ctx.context.adapter.create<Subscription>({
|
|
201
|
+
model: "subscription",
|
|
202
|
+
data: {
|
|
203
|
+
referenceId: user.id,
|
|
204
|
+
stripeCustomerId: stripeCustomerId,
|
|
205
|
+
stripeSubscriptionId: subscriptionCreated.id,
|
|
206
|
+
status: subscriptionCreated.status,
|
|
207
|
+
plan: plan.name.toLowerCase(),
|
|
208
|
+
periodStart,
|
|
209
|
+
periodEnd,
|
|
210
|
+
seats,
|
|
211
|
+
...(plan.limits ? { limits: plan.limits } : {}),
|
|
212
|
+
...trial,
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
ctx.context.logger.info(
|
|
217
|
+
`Stripe webhook: Created subscription ${subscriptionCreated.id} for user ${user.id} from dashboard`,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
await options.subscription?.onSubscriptionCreated?.({
|
|
221
|
+
event,
|
|
222
|
+
subscription: newSubscription,
|
|
223
|
+
stripeSubscription: subscriptionCreated,
|
|
224
|
+
plan,
|
|
225
|
+
});
|
|
226
|
+
} catch (error: any) {
|
|
227
|
+
ctx.context.logger.error(`Stripe webhook failed. Error: ${error}`);
|
|
97
228
|
}
|
|
98
229
|
}
|
|
99
230
|
|
|
@@ -126,12 +257,11 @@ export async function onSubscriptionUpdated(
|
|
|
126
257
|
where: [{ field: "stripeCustomerId", value: customerId }],
|
|
127
258
|
});
|
|
128
259
|
if (subs.length > 1) {
|
|
129
|
-
const activeSub = subs.find(
|
|
130
|
-
(sub
|
|
131
|
-
sub.status === "active" || sub.status === "trialing",
|
|
260
|
+
const activeSub = subs.find((sub: Subscription) =>
|
|
261
|
+
isActiveOrTrialing(sub),
|
|
132
262
|
);
|
|
133
263
|
if (!activeSub) {
|
|
134
|
-
logger.warn(
|
|
264
|
+
ctx.context.logger.warn(
|
|
135
265
|
`Stripe webhook error: Multiple subscriptions found for customerId: ${customerId} and no active subscription is found`,
|
|
136
266
|
);
|
|
137
267
|
return;
|
|
@@ -161,7 +291,16 @@ export async function onSubscriptionUpdated(
|
|
|
161
291
|
subscriptionUpdated.items.data[0]!.current_period_end * 1000,
|
|
162
292
|
),
|
|
163
293
|
cancelAtPeriodEnd: subscriptionUpdated.cancel_at_period_end,
|
|
164
|
-
|
|
294
|
+
cancelAt: subscriptionUpdated.cancel_at
|
|
295
|
+
? new Date(subscriptionUpdated.cancel_at * 1000)
|
|
296
|
+
: null,
|
|
297
|
+
canceledAt: subscriptionUpdated.canceled_at
|
|
298
|
+
? new Date(subscriptionUpdated.canceled_at * 1000)
|
|
299
|
+
: null,
|
|
300
|
+
endedAt: subscriptionUpdated.ended_at
|
|
301
|
+
? new Date(subscriptionUpdated.ended_at * 1000)
|
|
302
|
+
: null,
|
|
303
|
+
seats: seats,
|
|
165
304
|
stripeSubscriptionId: subscriptionUpdated.id,
|
|
166
305
|
},
|
|
167
306
|
where: [
|
|
@@ -171,11 +310,11 @@ export async function onSubscriptionUpdated(
|
|
|
171
310
|
},
|
|
172
311
|
],
|
|
173
312
|
});
|
|
174
|
-
const
|
|
313
|
+
const isNewCancellation =
|
|
175
314
|
subscriptionUpdated.status === "active" &&
|
|
176
|
-
subscriptionUpdated
|
|
177
|
-
!subscription
|
|
178
|
-
if (
|
|
315
|
+
isStripePendingCancel(subscriptionUpdated) &&
|
|
316
|
+
!isPendingCancel(subscription);
|
|
317
|
+
if (isNewCancellation) {
|
|
179
318
|
await options.subscription.onSubscriptionCancel?.({
|
|
180
319
|
subscription,
|
|
181
320
|
cancellationDetails:
|
|
@@ -205,7 +344,7 @@ export async function onSubscriptionUpdated(
|
|
|
205
344
|
}
|
|
206
345
|
}
|
|
207
346
|
} catch (error: any) {
|
|
208
|
-
logger.error(`Stripe webhook failed. Error: ${error}`);
|
|
347
|
+
ctx.context.logger.error(`Stripe webhook failed. Error: ${error}`);
|
|
209
348
|
}
|
|
210
349
|
}
|
|
211
350
|
|
|
@@ -241,6 +380,16 @@ export async function onSubscriptionDeleted(
|
|
|
241
380
|
update: {
|
|
242
381
|
status: "canceled",
|
|
243
382
|
updatedAt: new Date(),
|
|
383
|
+
cancelAtPeriodEnd: subscriptionDeleted.cancel_at_period_end,
|
|
384
|
+
cancelAt: subscriptionDeleted.cancel_at
|
|
385
|
+
? new Date(subscriptionDeleted.cancel_at * 1000)
|
|
386
|
+
: null,
|
|
387
|
+
canceledAt: subscriptionDeleted.canceled_at
|
|
388
|
+
? new Date(subscriptionDeleted.canceled_at * 1000)
|
|
389
|
+
: null,
|
|
390
|
+
endedAt: subscriptionDeleted.ended_at
|
|
391
|
+
? new Date(subscriptionDeleted.ended_at * 1000)
|
|
392
|
+
: null,
|
|
244
393
|
},
|
|
245
394
|
});
|
|
246
395
|
await options.subscription.onSubscriptionDeleted?.({
|
|
@@ -249,11 +398,11 @@ export async function onSubscriptionDeleted(
|
|
|
249
398
|
subscription,
|
|
250
399
|
});
|
|
251
400
|
} else {
|
|
252
|
-
logger.warn(
|
|
401
|
+
ctx.context.logger.warn(
|
|
253
402
|
`Stripe webhook error: Subscription not found for subscriptionId: ${subscriptionId}`,
|
|
254
403
|
);
|
|
255
404
|
}
|
|
256
405
|
} catch (error: any) {
|
|
257
|
-
logger.error(`Stripe webhook failed. Error: ${error}`);
|
|
406
|
+
ctx.context.logger.error(`Stripe webhook failed. Error: ${error}`);
|
|
258
407
|
}
|
|
259
408
|
}
|
package/src/middleware.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createAuthMiddleware } from "@better-auth/core/api";
|
|
2
|
-
import { logger } from "better-auth";
|
|
3
2
|
import { APIError } from "better-auth/api";
|
|
4
3
|
import type { SubscriptionOptions } from "./types";
|
|
5
4
|
|
|
@@ -24,7 +23,7 @@ export const referenceMiddleware = (
|
|
|
24
23
|
referenceId !== session.user.id &&
|
|
25
24
|
!subscriptionOptions.authorizeReference
|
|
26
25
|
) {
|
|
27
|
-
logger.error(
|
|
26
|
+
ctx.context.logger.error(
|
|
28
27
|
`Passing referenceId into a subscription action isn't allowed if subscription.authorizeReference isn't defined in your stripe plugin config.`,
|
|
29
28
|
);
|
|
30
29
|
throw new APIError("BAD_REQUEST", {
|
package/src/routes.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { Stripe as StripeType } from "stripe";
|
|
|
13
13
|
import * as z from "zod/v4";
|
|
14
14
|
import {
|
|
15
15
|
onCheckoutSessionCompleted,
|
|
16
|
+
onSubscriptionCreated,
|
|
16
17
|
onSubscriptionDeleted,
|
|
17
18
|
onSubscriptionUpdated,
|
|
18
19
|
} from "./hooks";
|
|
@@ -23,7 +24,14 @@ import type {
|
|
|
23
24
|
Subscription,
|
|
24
25
|
SubscriptionOptions,
|
|
25
26
|
} from "./types";
|
|
26
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
getPlanByName,
|
|
29
|
+
getPlanByPriceInfo,
|
|
30
|
+
getPlans,
|
|
31
|
+
isActiveOrTrialing,
|
|
32
|
+
isPendingCancel,
|
|
33
|
+
isStripePendingCancel,
|
|
34
|
+
} from "./utils";
|
|
27
35
|
|
|
28
36
|
const STRIPE_ERROR_CODES = defineErrorCodes({
|
|
29
37
|
SUBSCRIPTION_NOT_FOUND: "Subscription not found",
|
|
@@ -272,19 +280,15 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
272
280
|
],
|
|
273
281
|
});
|
|
274
282
|
|
|
275
|
-
const activeOrTrialingSubscription = subscriptions.find(
|
|
276
|
-
(sub)
|
|
283
|
+
const activeOrTrialingSubscription = subscriptions.find((sub) =>
|
|
284
|
+
isActiveOrTrialing(sub),
|
|
277
285
|
);
|
|
278
286
|
|
|
279
287
|
const activeSubscriptions = await client.subscriptions
|
|
280
288
|
.list({
|
|
281
289
|
customer: customerId,
|
|
282
290
|
})
|
|
283
|
-
.then((res) =>
|
|
284
|
-
res.data.filter(
|
|
285
|
-
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
286
|
-
),
|
|
287
|
-
);
|
|
291
|
+
.then((res) => res.data.filter((sub) => isActiveOrTrialing(sub)));
|
|
288
292
|
|
|
289
293
|
const activeSubscription = activeSubscriptions.find((sub) => {
|
|
290
294
|
// If we have a specific subscription to update, match by ID
|
|
@@ -408,7 +412,7 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
408
412
|
});
|
|
409
413
|
return ctx.json({
|
|
410
414
|
url,
|
|
411
|
-
redirect:
|
|
415
|
+
redirect: !ctx.body.disableRedirect,
|
|
412
416
|
});
|
|
413
417
|
}
|
|
414
418
|
|
|
@@ -465,7 +469,13 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
|
|
465
469
|
ctx,
|
|
466
470
|
);
|
|
467
471
|
|
|
468
|
-
const
|
|
472
|
+
const allSubscriptions = await ctx.context.adapter.findMany<Subscription>(
|
|
473
|
+
{
|
|
474
|
+
model: "subscription",
|
|
475
|
+
where: [{ field: "referenceId", value: referenceId }],
|
|
476
|
+
},
|
|
477
|
+
);
|
|
478
|
+
const hasEverTrialed = allSubscriptions.some((s) => {
|
|
469
479
|
// Check if user has ever had a trial for any plan (not just the same plan)
|
|
470
480
|
// This prevents users from getting multiple trials by switching plans
|
|
471
481
|
const hadTrial =
|
|
@@ -599,8 +609,8 @@ export const cancelSubscriptionCallback = (options: StripeOptions) => {
|
|
|
599
609
|
});
|
|
600
610
|
if (
|
|
601
611
|
!subscription ||
|
|
602
|
-
subscription.
|
|
603
|
-
subscription
|
|
612
|
+
subscription.status === "canceled" ||
|
|
613
|
+
isPendingCancel(subscription)
|
|
604
614
|
) {
|
|
605
615
|
throw ctx.redirect(getUrl(ctx, callbackURL));
|
|
606
616
|
}
|
|
@@ -612,12 +622,24 @@ export const cancelSubscriptionCallback = (options: StripeOptions) => {
|
|
|
612
622
|
const currentSubscription = stripeSubscription.data.find(
|
|
613
623
|
(sub) => sub.id === subscription.stripeSubscriptionId,
|
|
614
624
|
);
|
|
615
|
-
|
|
625
|
+
|
|
626
|
+
const isNewCancellation =
|
|
627
|
+
currentSubscription &&
|
|
628
|
+
isStripePendingCancel(currentSubscription) &&
|
|
629
|
+
!isPendingCancel(subscription);
|
|
630
|
+
if (isNewCancellation) {
|
|
616
631
|
await ctx.context.adapter.update({
|
|
617
632
|
model: "subscription",
|
|
618
633
|
update: {
|
|
619
634
|
status: currentSubscription?.status,
|
|
620
|
-
cancelAtPeriodEnd:
|
|
635
|
+
cancelAtPeriodEnd:
|
|
636
|
+
currentSubscription?.cancel_at_period_end || false,
|
|
637
|
+
cancelAt: currentSubscription?.cancel_at
|
|
638
|
+
? new Date(currentSubscription.cancel_at * 1000)
|
|
639
|
+
: null,
|
|
640
|
+
canceledAt: currentSubscription?.canceled_at
|
|
641
|
+
? new Date(currentSubscription.canceled_at * 1000)
|
|
642
|
+
: null,
|
|
621
643
|
},
|
|
622
644
|
where: [
|
|
623
645
|
{
|
|
@@ -663,6 +685,16 @@ const cancelSubscriptionBodySchema = z.object({
|
|
|
663
685
|
description:
|
|
664
686
|
'URL to take customers to when they click on the billing portal\'s link to return to your website. Eg: "/account"',
|
|
665
687
|
}),
|
|
688
|
+
/**
|
|
689
|
+
* Disable Redirect
|
|
690
|
+
*/
|
|
691
|
+
disableRedirect: z
|
|
692
|
+
.boolean()
|
|
693
|
+
.meta({
|
|
694
|
+
description:
|
|
695
|
+
"Disable redirect after successful subscription cancellation. Eg: true",
|
|
696
|
+
})
|
|
697
|
+
.default(false),
|
|
666
698
|
});
|
|
667
699
|
|
|
668
700
|
/**
|
|
@@ -716,13 +748,7 @@ export const cancelSubscription = (options: StripeOptions) => {
|
|
|
716
748
|
model: "subscription",
|
|
717
749
|
where: [{ field: "referenceId", value: referenceId }],
|
|
718
750
|
})
|
|
719
|
-
.then((subs) =>
|
|
720
|
-
subs.find(
|
|
721
|
-
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
722
|
-
),
|
|
723
|
-
);
|
|
724
|
-
|
|
725
|
-
// Ensure the specified subscription belongs to the (validated) referenceId.
|
|
751
|
+
.then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
|
|
726
752
|
if (
|
|
727
753
|
ctx.body.subscriptionId &&
|
|
728
754
|
subscription &&
|
|
@@ -740,11 +766,7 @@ export const cancelSubscription = (options: StripeOptions) => {
|
|
|
740
766
|
.list({
|
|
741
767
|
customer: subscription.stripeCustomerId,
|
|
742
768
|
})
|
|
743
|
-
.then((res) =>
|
|
744
|
-
res.data.filter(
|
|
745
|
-
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
746
|
-
),
|
|
747
|
-
);
|
|
769
|
+
.then((res) => res.data.filter((sub) => isActiveOrTrialing(sub)));
|
|
748
770
|
if (!activeSubscriptions.length) {
|
|
749
771
|
/**
|
|
750
772
|
* If the subscription is not found, we need to delete the subscription
|
|
@@ -790,21 +812,30 @@ export const cancelSubscription = (options: StripeOptions) => {
|
|
|
790
812
|
},
|
|
791
813
|
})
|
|
792
814
|
.catch(async (e) => {
|
|
793
|
-
if (e.message
|
|
815
|
+
if (e.message?.includes("already set to be canceled")) {
|
|
794
816
|
/**
|
|
795
|
-
* in-case we missed the event from stripe, we
|
|
817
|
+
* in-case we missed the event from stripe, we sync the actual state
|
|
796
818
|
* this is a rare case and should not happen
|
|
797
819
|
*/
|
|
798
|
-
if (!subscription
|
|
799
|
-
await
|
|
820
|
+
if (!isPendingCancel(subscription)) {
|
|
821
|
+
const stripeSub = await client.subscriptions.retrieve(
|
|
822
|
+
activeSubscription.id,
|
|
823
|
+
);
|
|
824
|
+
await ctx.context.adapter.update({
|
|
800
825
|
model: "subscription",
|
|
801
826
|
update: {
|
|
802
|
-
cancelAtPeriodEnd:
|
|
827
|
+
cancelAtPeriodEnd: stripeSub.cancel_at_period_end,
|
|
828
|
+
cancelAt: stripeSub.cancel_at
|
|
829
|
+
? new Date(stripeSub.cancel_at * 1000)
|
|
830
|
+
: null,
|
|
831
|
+
canceledAt: stripeSub.canceled_at
|
|
832
|
+
? new Date(stripeSub.canceled_at * 1000)
|
|
833
|
+
: null,
|
|
803
834
|
},
|
|
804
835
|
where: [
|
|
805
836
|
{
|
|
806
|
-
field: "
|
|
807
|
-
value:
|
|
837
|
+
field: "id",
|
|
838
|
+
value: subscription.id,
|
|
808
839
|
},
|
|
809
840
|
],
|
|
810
841
|
});
|
|
@@ -815,10 +846,10 @@ export const cancelSubscription = (options: StripeOptions) => {
|
|
|
815
846
|
code: e.code,
|
|
816
847
|
});
|
|
817
848
|
});
|
|
818
|
-
return {
|
|
849
|
+
return ctx.json({
|
|
819
850
|
url,
|
|
820
|
-
redirect:
|
|
821
|
-
};
|
|
851
|
+
redirect: !ctx.body.disableRedirect,
|
|
852
|
+
});
|
|
822
853
|
},
|
|
823
854
|
);
|
|
824
855
|
};
|
|
@@ -880,11 +911,7 @@ export const restoreSubscription = (options: StripeOptions) => {
|
|
|
880
911
|
},
|
|
881
912
|
],
|
|
882
913
|
})
|
|
883
|
-
.then((subs) =>
|
|
884
|
-
subs.find(
|
|
885
|
-
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
886
|
-
),
|
|
887
|
-
);
|
|
914
|
+
.then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
|
|
888
915
|
if (
|
|
889
916
|
ctx.body.subscriptionId &&
|
|
890
917
|
subscription &&
|
|
@@ -905,7 +932,7 @@ export const restoreSubscription = (options: StripeOptions) => {
|
|
|
905
932
|
message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_ACTIVE,
|
|
906
933
|
});
|
|
907
934
|
}
|
|
908
|
-
if (!subscription
|
|
935
|
+
if (!isPendingCancel(subscription)) {
|
|
909
936
|
throw ctx.error("BAD_REQUEST", {
|
|
910
937
|
message:
|
|
911
938
|
STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION,
|
|
@@ -916,47 +943,48 @@ export const restoreSubscription = (options: StripeOptions) => {
|
|
|
916
943
|
.list({
|
|
917
944
|
customer: subscription.stripeCustomerId,
|
|
918
945
|
})
|
|
919
|
-
.then(
|
|
920
|
-
(res) =>
|
|
921
|
-
res.data.filter(
|
|
922
|
-
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
923
|
-
)[0],
|
|
924
|
-
);
|
|
946
|
+
.then((res) => res.data.filter((sub) => isActiveOrTrialing(sub))[0]);
|
|
925
947
|
if (!activeSubscription) {
|
|
926
948
|
throw ctx.error("BAD_REQUEST", {
|
|
927
949
|
message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
928
950
|
});
|
|
929
951
|
}
|
|
930
952
|
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
953
|
+
// Clear scheduled cancellation based on Stripe subscription state
|
|
954
|
+
// Note: Stripe doesn't accept both `cancel_at` and `cancel_at_period_end` simultaneously
|
|
955
|
+
const updateParams: Stripe.SubscriptionUpdateParams = {};
|
|
956
|
+
if (activeSubscription.cancel_at) {
|
|
957
|
+
updateParams.cancel_at = "";
|
|
958
|
+
} else if (activeSubscription.cancel_at_period_end) {
|
|
959
|
+
updateParams.cancel_at_period_end = false;
|
|
960
|
+
}
|
|
938
961
|
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
{
|
|
947
|
-
field: "id",
|
|
948
|
-
value: subscription.id,
|
|
949
|
-
},
|
|
950
|
-
],
|
|
962
|
+
const newSub = await client.subscriptions
|
|
963
|
+
.update(activeSubscription.id, updateParams)
|
|
964
|
+
.catch((e) => {
|
|
965
|
+
throw ctx.error("BAD_REQUEST", {
|
|
966
|
+
message: e.message,
|
|
967
|
+
code: e.code,
|
|
968
|
+
});
|
|
951
969
|
});
|
|
952
970
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
971
|
+
await ctx.context.adapter.update({
|
|
972
|
+
model: "subscription",
|
|
973
|
+
update: {
|
|
974
|
+
cancelAtPeriodEnd: false,
|
|
975
|
+
cancelAt: null,
|
|
976
|
+
canceledAt: null,
|
|
977
|
+
updatedAt: new Date(),
|
|
978
|
+
},
|
|
979
|
+
where: [
|
|
980
|
+
{
|
|
981
|
+
field: "id",
|
|
982
|
+
value: subscription.id,
|
|
983
|
+
},
|
|
984
|
+
],
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
return ctx.json(newSub);
|
|
960
988
|
},
|
|
961
989
|
);
|
|
962
990
|
};
|
|
@@ -1031,9 +1059,7 @@ export const listActiveSubscriptions = (options: StripeOptions) => {
|
|
|
1031
1059
|
priceId: plan?.priceId,
|
|
1032
1060
|
};
|
|
1033
1061
|
})
|
|
1034
|
-
.filter((sub) =>
|
|
1035
|
-
return sub.status === "active" || sub.status === "trialing";
|
|
1036
|
-
});
|
|
1062
|
+
.filter((sub) => isActiveOrTrialing(sub));
|
|
1037
1063
|
return ctx.json(subs);
|
|
1038
1064
|
},
|
|
1039
1065
|
);
|
|
@@ -1119,6 +1145,13 @@ export const subscriptionSuccess = (options: StripeOptions) => {
|
|
|
1119
1145
|
1000,
|
|
1120
1146
|
),
|
|
1121
1147
|
stripeSubscriptionId: stripeSubscription.id,
|
|
1148
|
+
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
|
|
1149
|
+
cancelAt: stripeSubscription.cancel_at
|
|
1150
|
+
? new Date(stripeSubscription.cancel_at * 1000)
|
|
1151
|
+
: null,
|
|
1152
|
+
canceledAt: stripeSubscription.canceled_at
|
|
1153
|
+
? new Date(stripeSubscription.canceled_at * 1000)
|
|
1154
|
+
: null,
|
|
1122
1155
|
...(stripeSubscription.trial_start &&
|
|
1123
1156
|
stripeSubscription.trial_end
|
|
1124
1157
|
? {
|
|
@@ -1158,6 +1191,16 @@ const createBillingPortalBodySchema = z.object({
|
|
|
1158
1191
|
.optional(),
|
|
1159
1192
|
referenceId: z.string().optional(),
|
|
1160
1193
|
returnUrl: z.string().default("/"),
|
|
1194
|
+
/**
|
|
1195
|
+
* Disable Redirect
|
|
1196
|
+
*/
|
|
1197
|
+
disableRedirect: z
|
|
1198
|
+
.boolean()
|
|
1199
|
+
.meta({
|
|
1200
|
+
description:
|
|
1201
|
+
"Disable redirect after creating billing portal session. Eg: true",
|
|
1202
|
+
})
|
|
1203
|
+
.default(false),
|
|
1161
1204
|
});
|
|
1162
1205
|
|
|
1163
1206
|
export const createBillingPortal = (options: StripeOptions) => {
|
|
@@ -1196,11 +1239,7 @@ export const createBillingPortal = (options: StripeOptions) => {
|
|
|
1196
1239
|
},
|
|
1197
1240
|
],
|
|
1198
1241
|
})
|
|
1199
|
-
.then((subs) =>
|
|
1200
|
-
subs.find(
|
|
1201
|
-
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
1202
|
-
),
|
|
1203
|
-
);
|
|
1242
|
+
.then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
|
|
1204
1243
|
|
|
1205
1244
|
customerId = subscription?.stripeCustomerId;
|
|
1206
1245
|
}
|
|
@@ -1220,7 +1259,7 @@ export const createBillingPortal = (options: StripeOptions) => {
|
|
|
1220
1259
|
|
|
1221
1260
|
return ctx.json({
|
|
1222
1261
|
url,
|
|
1223
|
-
redirect:
|
|
1262
|
+
redirect: !ctx.body.disableRedirect,
|
|
1224
1263
|
});
|
|
1225
1264
|
} catch (error: any) {
|
|
1226
1265
|
ctx.context.logger.error(
|
|
@@ -1294,6 +1333,10 @@ export const stripeWebhook = (options: StripeOptions) => {
|
|
|
1294
1333
|
await onCheckoutSessionCompleted(ctx, options, event);
|
|
1295
1334
|
await options.onEvent?.(event);
|
|
1296
1335
|
break;
|
|
1336
|
+
case "customer.subscription.created":
|
|
1337
|
+
await onSubscriptionCreated(ctx, options, event);
|
|
1338
|
+
await options.onEvent?.(event);
|
|
1339
|
+
break;
|
|
1297
1340
|
case "customer.subscription.updated":
|
|
1298
1341
|
await onSubscriptionUpdated(ctx, options, event);
|
|
1299
1342
|
await options.onEvent?.(event);
|
package/src/schema.ts
CHANGED
|
@@ -46,6 +46,18 @@ export const subscriptions = {
|
|
|
46
46
|
required: false,
|
|
47
47
|
defaultValue: false,
|
|
48
48
|
},
|
|
49
|
+
cancelAt: {
|
|
50
|
+
type: "date",
|
|
51
|
+
required: false,
|
|
52
|
+
},
|
|
53
|
+
canceledAt: {
|
|
54
|
+
type: "date",
|
|
55
|
+
required: false,
|
|
56
|
+
},
|
|
57
|
+
endedAt: {
|
|
58
|
+
type: "date",
|
|
59
|
+
required: false,
|
|
60
|
+
},
|
|
49
61
|
seats: {
|
|
50
62
|
type: "number",
|
|
51
63
|
required: false,
|