mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-25 00:22:43 -05:00
feat(stripe): flexible subscription cancellation and termination management (#6961)
Co-authored-by: GautamBytes <manchandanigautam@gmail.com>
This commit is contained in:
@@ -383,8 +383,24 @@ type cancelSubscription = {
|
||||
|
||||
This will redirect the user to the Stripe Billing Portal where they can cancel their subscription.
|
||||
|
||||
<Callout type="info">
|
||||
**Understanding Cancellation States**
|
||||
|
||||
Stripe supports different types of cancellation, and the plugin tracks all of them:
|
||||
|
||||
| Field | Description |
|
||||
|---------------------|----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `cancelAtPeriodEnd` | Whether this subscription will (if status=active) or did (if status=canceled) cancel at the end of the current billing period. |
|
||||
| `cancelAt` | If the subscription is scheduled to be canceled, this is the time at which the cancellation will take effect. |
|
||||
| `canceledAt` | If the subscription has been canceled, this is the time when it was canceled. |
|
||||
| `endedAt` | If the subscription has ended, the date the subscription ended. |
|
||||
| `status` | Changes to "canceled" only after the subscription has actually ended. |
|
||||
</Callout>
|
||||
|
||||
#### Restoring a Canceled Subscription
|
||||
|
||||
> <small className='font-normal'>**Note:** This only works for subscriptions that are still active but scheduled to cancel. It cannot restore subscriptions that have already ended (`status: "canceled"` with `endedAt` set).</small>
|
||||
|
||||
If a user changes their mind after canceling a subscription (but before the subscription period ends), you can restore the subscription:
|
||||
|
||||
|
||||
@@ -408,9 +424,15 @@ type restoreSubscription = {
|
||||
</APIMethod>
|
||||
|
||||
|
||||
This will reactivate a subscription that was previously set to cancel at the end of the billing period (`cancelAtPeriodEnd: true`). The subscription will continue to renew automatically.
|
||||
This will reactivate a subscription that was previously scheduled to cancel. The subscription will continue to renew automatically.
|
||||
|
||||
<Callout type="info">
|
||||
When a subscription is restored:
|
||||
- `cancelAtPeriodEnd` is set to `false`
|
||||
- `cancelAt` is cleared to `null`
|
||||
- `canceledAt` is cleared to `null`
|
||||
</Callout>
|
||||
|
||||
> **Note:** This only works for subscriptions that are still active but marked to cancel at the end of the period. It cannot restore subscriptions that have already ended.
|
||||
|
||||
#### Creating Billing Portal Sessions
|
||||
|
||||
@@ -662,6 +684,24 @@ Table Name: `subscription`
|
||||
defaultValue: false,
|
||||
isOptional: true
|
||||
},
|
||||
{
|
||||
name: "cancelAt",
|
||||
type: "Date",
|
||||
description: "If the subscription is scheduled to be canceled, this is the time at which the cancellation will take effect",
|
||||
isOptional: true
|
||||
},
|
||||
{
|
||||
name: "canceledAt",
|
||||
type: "Date",
|
||||
description: "If the subscription has been canceled, this is the time when the cancellation was requested. Note: If the subscription was canceled with cancelAtPeriodEnd, this reflects the cancellation request time, not when the subscription actually ends",
|
||||
isOptional: true
|
||||
},
|
||||
{
|
||||
name: "endedAt",
|
||||
type: "Date",
|
||||
description: "If the subscription has ended, this is the date the subscription ended",
|
||||
isOptional: true
|
||||
},
|
||||
{
|
||||
name: "seats",
|
||||
type: "number",
|
||||
|
||||
@@ -2,7 +2,12 @@ import type { GenericEndpointContext } from "better-auth";
|
||||
import { logger } from "better-auth";
|
||||
import type Stripe from "stripe";
|
||||
import type { InputSubscription, StripeOptions, Subscription } from "./types";
|
||||
import { getPlanByPriceInfo } from "./utils";
|
||||
import {
|
||||
getPlanByPriceInfo,
|
||||
isActiveOrTrialing,
|
||||
isPendingCancel,
|
||||
isStripePendingCancel,
|
||||
} from "./utils";
|
||||
|
||||
export async function onCheckoutSessionCompleted(
|
||||
ctx: GenericEndpointContext,
|
||||
@@ -54,7 +59,17 @@ export async function onCheckoutSessionCompleted(
|
||||
subscription.items.data[0]!.current_period_end * 1000,
|
||||
),
|
||||
stripeSubscriptionId: checkoutSession.subscription as string,
|
||||
seats,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
cancelAt: subscription.cancel_at
|
||||
? new Date(subscription.cancel_at * 1000)
|
||||
: null,
|
||||
canceledAt: subscription.canceled_at
|
||||
? new Date(subscription.canceled_at * 1000)
|
||||
: null,
|
||||
endedAt: subscription.ended_at
|
||||
? new Date(subscription.ended_at * 1000)
|
||||
: null,
|
||||
seats: seats,
|
||||
...trial,
|
||||
},
|
||||
where: [
|
||||
@@ -126,9 +141,8 @@ export async function onSubscriptionUpdated(
|
||||
where: [{ field: "stripeCustomerId", value: customerId }],
|
||||
});
|
||||
if (subs.length > 1) {
|
||||
const activeSub = subs.find(
|
||||
(sub: Subscription) =>
|
||||
sub.status === "active" || sub.status === "trialing",
|
||||
const activeSub = subs.find((sub: Subscription) =>
|
||||
isActiveOrTrialing(sub),
|
||||
);
|
||||
if (!activeSub) {
|
||||
logger.warn(
|
||||
@@ -161,7 +175,16 @@ export async function onSubscriptionUpdated(
|
||||
subscriptionUpdated.items.data[0]!.current_period_end * 1000,
|
||||
),
|
||||
cancelAtPeriodEnd: subscriptionUpdated.cancel_at_period_end,
|
||||
seats,
|
||||
cancelAt: subscriptionUpdated.cancel_at
|
||||
? new Date(subscriptionUpdated.cancel_at * 1000)
|
||||
: null,
|
||||
canceledAt: subscriptionUpdated.canceled_at
|
||||
? new Date(subscriptionUpdated.canceled_at * 1000)
|
||||
: null,
|
||||
endedAt: subscriptionUpdated.ended_at
|
||||
? new Date(subscriptionUpdated.ended_at * 1000)
|
||||
: null,
|
||||
seats: seats,
|
||||
stripeSubscriptionId: subscriptionUpdated.id,
|
||||
},
|
||||
where: [
|
||||
@@ -171,11 +194,11 @@ export async function onSubscriptionUpdated(
|
||||
},
|
||||
],
|
||||
});
|
||||
const subscriptionCanceled =
|
||||
const isNewCancellation =
|
||||
subscriptionUpdated.status === "active" &&
|
||||
subscriptionUpdated.cancel_at_period_end &&
|
||||
!subscription.cancelAtPeriodEnd; //if this is true, it means the subscription was canceled before the event was triggered
|
||||
if (subscriptionCanceled) {
|
||||
isStripePendingCancel(subscriptionUpdated) &&
|
||||
!isPendingCancel(subscription);
|
||||
if (isNewCancellation) {
|
||||
await options.subscription.onSubscriptionCancel?.({
|
||||
subscription,
|
||||
cancellationDetails:
|
||||
@@ -241,6 +264,16 @@ export async function onSubscriptionDeleted(
|
||||
update: {
|
||||
status: "canceled",
|
||||
updatedAt: new Date(),
|
||||
cancelAtPeriodEnd: subscriptionDeleted.cancel_at_period_end,
|
||||
cancelAt: subscriptionDeleted.cancel_at
|
||||
? new Date(subscriptionDeleted.cancel_at * 1000)
|
||||
: null,
|
||||
canceledAt: subscriptionDeleted.canceled_at
|
||||
? new Date(subscriptionDeleted.canceled_at * 1000)
|
||||
: null,
|
||||
endedAt: subscriptionDeleted.ended_at
|
||||
? new Date(subscriptionDeleted.ended_at * 1000)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
await options.subscription.onSubscriptionDeleted?.({
|
||||
|
||||
@@ -23,7 +23,14 @@ import type {
|
||||
Subscription,
|
||||
SubscriptionOptions,
|
||||
} from "./types";
|
||||
import { getPlanByName, getPlanByPriceInfo, getPlans } from "./utils";
|
||||
import {
|
||||
getPlanByName,
|
||||
getPlanByPriceInfo,
|
||||
getPlans,
|
||||
isActiveOrTrialing,
|
||||
isPendingCancel,
|
||||
isStripePendingCancel,
|
||||
} from "./utils";
|
||||
|
||||
const upgradeSubscriptionBodySchema = z.object({
|
||||
/**
|
||||
@@ -263,19 +270,15 @@ export const upgradeSubscription = (options: StripeOptions) => {
|
||||
],
|
||||
});
|
||||
|
||||
const activeOrTrialingSubscription = subscriptions.find(
|
||||
(sub) => sub.status === "active" || sub.status === "trialing",
|
||||
const activeOrTrialingSubscription = subscriptions.find((sub) =>
|
||||
isActiveOrTrialing(sub),
|
||||
);
|
||||
|
||||
const activeSubscriptions = await client.subscriptions
|
||||
.list({
|
||||
customer: customerId,
|
||||
})
|
||||
.then((res) =>
|
||||
res.data.filter(
|
||||
(sub) => sub.status === "active" || sub.status === "trialing",
|
||||
),
|
||||
);
|
||||
.then((res) => res.data.filter((sub) => isActiveOrTrialing(sub)));
|
||||
|
||||
const activeSubscription = activeSubscriptions.find((sub) => {
|
||||
// If we have a specific subscription to update, match by ID
|
||||
@@ -591,8 +594,8 @@ export const cancelSubscriptionCallback = (options: StripeOptions) => {
|
||||
});
|
||||
if (
|
||||
!subscription ||
|
||||
subscription.cancelAtPeriodEnd ||
|
||||
subscription.status === "canceled"
|
||||
subscription.status === "canceled" ||
|
||||
isPendingCancel(subscription)
|
||||
) {
|
||||
throw ctx.redirect(getUrl(ctx, callbackURL));
|
||||
}
|
||||
@@ -604,12 +607,24 @@ export const cancelSubscriptionCallback = (options: StripeOptions) => {
|
||||
const currentSubscription = stripeSubscription.data.find(
|
||||
(sub) => sub.id === subscription.stripeSubscriptionId,
|
||||
);
|
||||
if (currentSubscription?.cancel_at_period_end === true) {
|
||||
|
||||
const isNewCancellation =
|
||||
currentSubscription &&
|
||||
isStripePendingCancel(currentSubscription) &&
|
||||
!isPendingCancel(subscription);
|
||||
if (isNewCancellation) {
|
||||
await ctx.context.adapter.update({
|
||||
model: "subscription",
|
||||
update: {
|
||||
status: currentSubscription?.status,
|
||||
cancelAtPeriodEnd: true,
|
||||
cancelAtPeriodEnd:
|
||||
currentSubscription?.cancel_at_period_end || false,
|
||||
cancelAt: currentSubscription?.cancel_at
|
||||
? new Date(currentSubscription.cancel_at * 1000)
|
||||
: null,
|
||||
canceledAt: currentSubscription?.canceled_at
|
||||
? new Date(currentSubscription.canceled_at * 1000)
|
||||
: null,
|
||||
},
|
||||
where: [
|
||||
{
|
||||
@@ -708,13 +723,7 @@ export const cancelSubscription = (options: StripeOptions) => {
|
||||
model: "subscription",
|
||||
where: [{ field: "referenceId", value: referenceId }],
|
||||
})
|
||||
.then((subs) =>
|
||||
subs.find(
|
||||
(sub) => sub.status === "active" || sub.status === "trialing",
|
||||
),
|
||||
);
|
||||
|
||||
// Ensure the specified subscription belongs to the (validated) referenceId.
|
||||
.then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
|
||||
if (
|
||||
ctx.body.subscriptionId &&
|
||||
subscription &&
|
||||
@@ -733,11 +742,7 @@ export const cancelSubscription = (options: StripeOptions) => {
|
||||
.list({
|
||||
customer: subscription.stripeCustomerId,
|
||||
})
|
||||
.then((res) =>
|
||||
res.data.filter(
|
||||
(sub) => sub.status === "active" || sub.status === "trialing",
|
||||
),
|
||||
);
|
||||
.then((res) => res.data.filter((sub) => isActiveOrTrialing(sub)));
|
||||
if (!activeSubscriptions.length) {
|
||||
/**
|
||||
* If the subscription is not found, we need to delete the subscription
|
||||
@@ -785,21 +790,30 @@ export const cancelSubscription = (options: StripeOptions) => {
|
||||
},
|
||||
})
|
||||
.catch(async (e) => {
|
||||
if (e.message.includes("already set to be cancel")) {
|
||||
if (e.message?.includes("already set to be canceled")) {
|
||||
/**
|
||||
* in-case we missed the event from stripe, we set it manually
|
||||
* in-case we missed the event from stripe, we sync the actual state
|
||||
* this is a rare case and should not happen
|
||||
*/
|
||||
if (!subscription.cancelAtPeriodEnd) {
|
||||
await ctx.context.adapter.updateMany({
|
||||
if (!isPendingCancel(subscription)) {
|
||||
const stripeSub = await client.subscriptions.retrieve(
|
||||
activeSubscription.id,
|
||||
);
|
||||
await ctx.context.adapter.update({
|
||||
model: "subscription",
|
||||
update: {
|
||||
cancelAtPeriodEnd: true,
|
||||
cancelAtPeriodEnd: stripeSub.cancel_at_period_end,
|
||||
cancelAt: stripeSub.cancel_at
|
||||
? new Date(stripeSub.cancel_at * 1000)
|
||||
: null,
|
||||
canceledAt: stripeSub.canceled_at
|
||||
? new Date(stripeSub.canceled_at * 1000)
|
||||
: null,
|
||||
},
|
||||
where: [
|
||||
{
|
||||
field: "referenceId",
|
||||
value: referenceId,
|
||||
field: "id",
|
||||
value: subscription.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -875,11 +889,7 @@ export const restoreSubscription = (options: StripeOptions) => {
|
||||
},
|
||||
],
|
||||
})
|
||||
.then((subs) =>
|
||||
subs.find(
|
||||
(sub) => sub.status === "active" || sub.status === "trialing",
|
||||
),
|
||||
);
|
||||
.then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
|
||||
if (
|
||||
ctx.body.subscriptionId &&
|
||||
subscription &&
|
||||
@@ -902,7 +912,7 @@ export const restoreSubscription = (options: StripeOptions) => {
|
||||
STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_ACTIVE,
|
||||
);
|
||||
}
|
||||
if (!subscription.cancelAtPeriodEnd) {
|
||||
if (!isPendingCancel(subscription)) {
|
||||
throw APIError.from(
|
||||
"BAD_REQUEST",
|
||||
STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION,
|
||||
@@ -913,12 +923,7 @@ export const restoreSubscription = (options: StripeOptions) => {
|
||||
.list({
|
||||
customer: subscription.stripeCustomerId,
|
||||
})
|
||||
.then(
|
||||
(res) =>
|
||||
res.data.filter(
|
||||
(sub) => sub.status === "active" || sub.status === "trialing",
|
||||
)[0],
|
||||
);
|
||||
.then((res) => res.data.filter((sub) => isActiveOrTrialing(sub))[0]);
|
||||
if (!activeSubscription) {
|
||||
throw APIError.from(
|
||||
"BAD_REQUEST",
|
||||
@@ -926,36 +931,41 @@ export const restoreSubscription = (options: StripeOptions) => {
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const newSub = await client.subscriptions.update(
|
||||
activeSubscription.id,
|
||||
{
|
||||
cancel_at_period_end: false,
|
||||
},
|
||||
);
|
||||
// Clear scheduled cancellation based on Stripe subscription state
|
||||
// Note: Stripe doesn't accept both `cancel_at` and `cancel_at_period_end` simultaneously
|
||||
const updateParams: Stripe.SubscriptionUpdateParams = {};
|
||||
if (activeSubscription.cancel_at) {
|
||||
updateParams.cancel_at = "";
|
||||
} else if (activeSubscription.cancel_at_period_end) {
|
||||
updateParams.cancel_at_period_end = false;
|
||||
}
|
||||
|
||||
await ctx.context.adapter.update({
|
||||
model: "subscription",
|
||||
update: {
|
||||
cancelAtPeriodEnd: false,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
where: [
|
||||
{
|
||||
field: "id",
|
||||
value: subscription.id,
|
||||
},
|
||||
],
|
||||
const newSub = await client.subscriptions
|
||||
.update(activeSubscription.id, updateParams)
|
||||
.catch((e) => {
|
||||
throw ctx.error("BAD_REQUEST", {
|
||||
message: e.message,
|
||||
code: e.code,
|
||||
});
|
||||
});
|
||||
|
||||
return ctx.json(newSub);
|
||||
} catch (error) {
|
||||
ctx.context.logger.error("Error restoring subscription", error);
|
||||
throw APIError.from(
|
||||
"BAD_REQUEST",
|
||||
STRIPE_ERROR_CODES.UNABLE_TO_CREATE_CUSTOMER,
|
||||
);
|
||||
}
|
||||
await ctx.context.adapter.update({
|
||||
model: "subscription",
|
||||
update: {
|
||||
cancelAtPeriodEnd: false,
|
||||
cancelAt: null,
|
||||
canceledAt: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
where: [
|
||||
{
|
||||
field: "id",
|
||||
value: subscription.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return ctx.json(newSub);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -1030,9 +1040,7 @@ export const listActiveSubscriptions = (options: StripeOptions) => {
|
||||
priceId: plan?.priceId,
|
||||
};
|
||||
})
|
||||
.filter((sub) => {
|
||||
return sub.status === "active" || sub.status === "trialing";
|
||||
});
|
||||
.filter((sub) => isActiveOrTrialing(sub));
|
||||
return ctx.json(subs);
|
||||
},
|
||||
);
|
||||
@@ -1118,6 +1126,13 @@ export const subscriptionSuccess = (options: StripeOptions) => {
|
||||
1000,
|
||||
),
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
|
||||
cancelAt: stripeSubscription.cancel_at
|
||||
? new Date(stripeSubscription.cancel_at * 1000)
|
||||
: null,
|
||||
canceledAt: stripeSubscription.canceled_at
|
||||
? new Date(stripeSubscription.canceled_at * 1000)
|
||||
: null,
|
||||
...(stripeSubscription.trial_start &&
|
||||
stripeSubscription.trial_end
|
||||
? {
|
||||
@@ -1195,11 +1210,7 @@ export const createBillingPortal = (options: StripeOptions) => {
|
||||
},
|
||||
],
|
||||
})
|
||||
.then((subs) =>
|
||||
subs.find(
|
||||
(sub) => sub.status === "active" || sub.status === "trialing",
|
||||
),
|
||||
);
|
||||
.then((subs) => subs.find((sub) => isActiveOrTrialing(sub)));
|
||||
|
||||
customerId = subscription?.stripeCustomerId;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,18 @@ export const subscriptions = {
|
||||
required: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
cancelAt: {
|
||||
type: "date",
|
||||
required: false,
|
||||
},
|
||||
canceledAt: {
|
||||
type: "date",
|
||||
required: false,
|
||||
},
|
||||
endedAt: {
|
||||
type: "date",
|
||||
required: false,
|
||||
},
|
||||
seats: {
|
||||
type: "number",
|
||||
required: false,
|
||||
|
||||
@@ -2819,4 +2819,684 @@ describe("stripe", () => {
|
||||
expect(user?.stripeCustomerId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("webhook: cancel_at_period_end cancellation", () => {
|
||||
it("should sync cancelAtPeriodEnd and canceledAt when user cancels via Billing Portal (at_period_end mode)", async () => {
|
||||
const { auth } = await getTestInstance(
|
||||
{
|
||||
database: memory,
|
||||
plugins: [stripe(stripeOptions)],
|
||||
},
|
||||
{ disableTestUser: true },
|
||||
);
|
||||
const ctx = await auth.$context;
|
||||
|
||||
// Setup: Create user and active subscription
|
||||
const { id: userId } = await ctx.adapter.create({
|
||||
model: "user",
|
||||
data: { email: "cancel-period-end@test.com" },
|
||||
});
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const periodEnd = now + 30 * 24 * 60 * 60;
|
||||
const canceledAt = now;
|
||||
|
||||
const { id: subscriptionId } = await ctx.adapter.create({
|
||||
model: "subscription",
|
||||
data: {
|
||||
referenceId: userId,
|
||||
stripeCustomerId: "cus_cancel_test",
|
||||
stripeSubscriptionId: "sub_cancel_period_end",
|
||||
status: "active",
|
||||
plan: "starter",
|
||||
cancelAtPeriodEnd: false,
|
||||
cancelAt: null,
|
||||
canceledAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Simulate: Stripe webhook for cancel_at_period_end
|
||||
const webhookEvent = {
|
||||
type: "customer.subscription.updated",
|
||||
data: {
|
||||
object: {
|
||||
id: "sub_cancel_period_end",
|
||||
customer: "cus_cancel_test",
|
||||
status: "active",
|
||||
cancel_at_period_end: true,
|
||||
cancel_at: null,
|
||||
canceled_at: canceledAt,
|
||||
ended_at: null,
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: { id: "price_starter_123", lookup_key: null },
|
||||
quantity: 1,
|
||||
current_period_start: now,
|
||||
current_period_end: periodEnd,
|
||||
},
|
||||
],
|
||||
},
|
||||
cancellation_details: {
|
||||
reason: "cancellation_requested",
|
||||
comment: "User requested cancellation",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const stripeForTest = {
|
||||
...stripeOptions.stripeClient,
|
||||
webhooks: {
|
||||
constructEventAsync: vi.fn().mockResolvedValue(webhookEvent),
|
||||
},
|
||||
};
|
||||
|
||||
const testOptions = {
|
||||
...stripeOptions,
|
||||
stripeClient: stripeForTest as unknown as Stripe,
|
||||
stripeWebhookSecret: "test_secret",
|
||||
};
|
||||
|
||||
const { auth: webhookAuth } = await getTestInstance(
|
||||
{
|
||||
database: memory,
|
||||
plugins: [stripe(testOptions)],
|
||||
},
|
||||
{ disableTestUser: true },
|
||||
);
|
||||
const webhookCtx = await webhookAuth.$context;
|
||||
|
||||
const response = await webhookAuth.handler(
|
||||
new Request("http://localhost:3000/api/auth/stripe/webhook", {
|
||||
method: "POST",
|
||||
headers: { "stripe-signature": "test_signature" },
|
||||
body: JSON.stringify(webhookEvent),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const updatedSub = await webhookCtx.adapter.findOne<Subscription>({
|
||||
model: "subscription",
|
||||
where: [{ field: "id", value: subscriptionId }],
|
||||
});
|
||||
|
||||
expect(updatedSub).toMatchObject({
|
||||
status: "active",
|
||||
cancelAtPeriodEnd: true,
|
||||
cancelAt: null,
|
||||
canceledAt: expect.any(Date),
|
||||
endedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should sync cancelAt when subscription is scheduled to cancel at a specific date", async () => {
|
||||
const { auth } = await getTestInstance(
|
||||
{
|
||||
database: memory,
|
||||
plugins: [stripe(stripeOptions)],
|
||||
},
|
||||
{ disableTestUser: true },
|
||||
);
|
||||
const ctx = await auth.$context;
|
||||
|
||||
const { id: userId } = await ctx.adapter.create({
|
||||
model: "user",
|
||||
data: { email: "cancel-at-date@test.com" },
|
||||
});
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const cancelAt = now + 15 * 24 * 60 * 60; // Cancel in 15 days
|
||||
const canceledAt = now;
|
||||
|
||||
const { id: subscriptionId } = await ctx.adapter.create({
|
||||
model: "subscription",
|
||||
data: {
|
||||
referenceId: userId,
|
||||
stripeCustomerId: "cus_cancel_at_test",
|
||||
stripeSubscriptionId: "sub_cancel_at_date",
|
||||
status: "active",
|
||||
plan: "starter",
|
||||
cancelAtPeriodEnd: false,
|
||||
cancelAt: null,
|
||||
canceledAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Simulate: Dashboard/API cancel with specific date (cancel_at)
|
||||
const webhookEvent = {
|
||||
type: "customer.subscription.updated",
|
||||
data: {
|
||||
object: {
|
||||
id: "sub_cancel_at_date",
|
||||
customer: "cus_cancel_at_test",
|
||||
status: "active",
|
||||
cancel_at_period_end: false,
|
||||
cancel_at: cancelAt,
|
||||
canceled_at: canceledAt,
|
||||
ended_at: null,
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
price: { id: "price_starter_123", lookup_key: null },
|
||||
quantity: 1,
|
||||
current_period_start: now,
|
||||
current_period_end: now + 30 * 24 * 60 * 60,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const stripeForTest = {
|
||||
...stripeOptions.stripeClient,
|
||||
webhooks: {
|
||||
constructEventAsync: vi.fn().mockResolvedValue(webhookEvent),
|
||||
},
|
||||
};
|
||||
|
||||
const testOptions = {
|
||||
...stripeOptions,
|
||||
stripeClient: stripeForTest as unknown as Stripe,
|
||||
stripeWebhookSecret: "test_secret",
|
||||
};
|
||||
|
||||
const { auth: webhookAuth } = await getTestInstance(
|
||||
{
|
||||
database: memory,
|
||||
plugins: [stripe(testOptions)],
|
||||
},
|
||||
{ disableTestUser: true },
|
||||
);
|
||||
const webhookCtx = await webhookAuth.$context;
|
||||
|
||||
const response = await webhookAuth.handler(
|
||||
new Request("http://localhost:3000/api/auth/stripe/webhook", {
|
||||
method: "POST",
|
||||
headers: { "stripe-signature": "test_signature" },
|
||||
body: JSON.stringify(webhookEvent),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const updatedSub = await webhookCtx.adapter.findOne<Subscription>({
|
||||
model: "subscription",
|
||||
where: [{ field: "id", value: subscriptionId }],
|
||||
});
|
||||
|
||||
expect(updatedSub).toMatchObject({
|
||||
status: "active",
|
||||
cancelAtPeriodEnd: false,
|
||||
cancelAt: expect.any(Date),
|
||||
canceledAt: expect.any(Date),
|
||||
endedAt: null,
|
||||
});
|
||||
|
||||
// Verify the cancelAt date is correct
|
||||
expect(updatedSub!.cancelAt!.getTime()).toBe(cancelAt * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("webhook: immediate cancellation (subscription deleted)", () => {
|
||||
it("should set status=canceled and endedAt when subscription is immediately canceled", async () => {
|
||||
const { auth } = await getTestInstance(
|
||||
{
|
||||
database: memory,
|
||||
plugins: [stripe(stripeOptions)],
|
||||
},
|
||||
{ disableTestUser: true },
|
||||
);
|
||||
const ctx = await auth.$context;
|
||||
|
||||
const { id: userId } = await ctx.adapter.create({
|
||||
model: "user",
|
||||
data: { email: "immediate-cancel@test.com" },
|
||||
});
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const { id: subscriptionId } = await ctx.adapter.create({
|
||||
model: "subscription",
|
||||
data: {
|
||||
referenceId: userId,
|
||||
stripeCustomerId: "cus_immediate_cancel",
|
||||
stripeSubscriptionId: "sub_immediate_cancel",
|
||||
status: "active",
|
||||
plan: "starter",
|
||||
},
|
||||
});
|
||||
|
||||
// Simulate: Immediate cancellation via Billing Portal (mode: immediately) or API
|
||||
const webhookEvent = {
|
||||
type: "customer.subscription.deleted",
|
||||
data: {
|
||||
object: {
|
||||
id: "sub_immediate_cancel",
|
||||
customer: "cus_immediate_cancel",
|
||||
status: "canceled",
|
||||
cancel_at_period_end: false,
|
||||
cancel_at: null,
|
||||
canceled_at: now,
|
||||
ended_at: now,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const stripeForTest = {
|
||||
...stripeOptions.stripeClient,
|
||||
webhooks: {
|
||||
constructEventAsync: vi.fn().mockResolvedValue(webhookEvent),
|
||||
},
|
||||
};
|
||||
|
||||
const testOptions = {
|
||||
...stripeOptions,
|
||||
stripeClient: stripeForTest as unknown as Stripe,
|
||||
stripeWebhookSecret: "test_secret",
|
||||
};
|
||||
|
||||
const { auth: webhookAuth } = await getTestInstance(
|
||||
{
|
||||
database: memory,
|
||||
plugins: [stripe(testOptions)],
|
||||
},
|
||||
{ disableTestUser: true },
|
||||
);
|
||||
const webhookCtx = await webhookAuth.$context;
|
||||
|
||||
const response = await webhookAuth.handler(
|
||||
new Request("http://localhost:3000/api/auth/stripe/webhook", {
|
||||
method: "POST",
|
||||
headers: { "stripe-signature": "test_signature" },
|
||||
body: JSON.stringify(webhookEvent),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const updatedSub = await webhookCtx.adapter.findOne<Subscription>({
|
||||
model: "subscription",
|
||||
where: [{ field: "id", value: subscriptionId }],
|
||||
});
|
||||
|
||||
expect(updatedSub).not.toBeNull();
|
||||
expect(updatedSub!.status).toBe("canceled");
|
||||
expect(updatedSub!.endedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should set endedAt when cancel_at_period_end subscription reaches period end", async () => {
|
||||
const { auth } = await getTestInstance(
|
||||
{
|
||||
database: memory,
|
||||
plugins: [stripe(stripeOptions)],
|
||||
},
|
||||
{ disableTestUser: true },
|
||||
);
|
||||
const ctx = await auth.$context;
|
||||
|
||||
const { id: userId } = await ctx.adapter.create({
|
||||
model: "user",
|
||||
data: { email: "period-end-reached@test.com" },
|
||||
});
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const canceledAt = now - 30 * 24 * 60 * 60; // Canceled 30 days ago
|
||||
|
||||
const { id: subscriptionId } = await ctx.adapter.create({
|
||||
model: "subscription",
|
||||
data: {
|
||||
referenceId: userId,
|
||||
stripeCustomerId: "cus_period_end_reached",
|
||||
stripeSubscriptionId: "sub_period_end_reached",
|
||||
status: "active",
|
||||
plan: "starter",
|
||||
cancelAtPeriodEnd: true,
|
||||
canceledAt: new Date(canceledAt * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
// Simulate: Period ended, subscription is now deleted
|
||||
const webhookEvent = {
|
||||
type: "customer.subscription.deleted",
|
||||
data: {
|
||||
object: {
|
||||
id: "sub_period_end_reached",
|
||||
customer: "cus_period_end_reached",
|
||||
status: "canceled",
|
||||
cancel_at_period_end: true,
|
||||
cancel_at: null,
|
||||
canceled_at: canceledAt,
|
||||
ended_at: now,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const stripeForTest = {
|
||||
...stripeOptions.stripeClient,
|
||||
webhooks: {
|
||||
constructEventAsync: vi.fn().mockResolvedValue(webhookEvent),
|
||||
},
|
||||
};
|
||||
|
||||
const testOptions = {
|
||||
...stripeOptions,
|
||||
stripeClient: stripeForTest as unknown as Stripe,
|
||||
stripeWebhookSecret: "test_secret",
|
||||
};
|
||||
|
||||
const { auth: webhookAuth } = await getTestInstance(
|
||||
{
|
||||
database: memory,
|
||||
plugins: [stripe(testOptions)],
|
||||
},
|
||||
{ disableTestUser: true },
|
||||
);
|
||||
const webhookCtx = await webhookAuth.$context;
|
||||
|
||||
const response = await webhookAuth.handler(
|
||||
new Request("http://localhost:3000/api/auth/stripe/webhook", {
|
||||
method: "POST",
|
||||
headers: { "stripe-signature": "test_signature" },
|
||||
body: JSON.stringify(webhookEvent),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const updatedSub = await webhookCtx.adapter.findOne<Subscription>({
|
||||
model: "subscription",
|
||||
where: [{ field: "id", value: subscriptionId }],
|
||||
});
|
||||
|
||||
expect(updatedSub).not.toBeNull();
|
||||
expect(updatedSub!.status).toBe("canceled");
|
||||
expect(updatedSub!.cancelAtPeriodEnd).toBe(true);
|
||||
expect(updatedSub!.endedAt).not.toBeNull();
|
||||
|
||||
// endedAt should be the actual termination time (now), not the cancellation request time
|
||||
expect(updatedSub!.endedAt!.getTime()).toBe(now * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restore subscription", () => {
|
||||
it("should clear cancelAtPeriodEnd when restoring a cancel_at_period_end subscription", async () => {
|
||||
const { client, auth, sessionSetter } = await getTestInstance(
|
||||
{
|
||||
database: memory,
|
||||
plugins: [stripe(stripeOptions)],
|
||||
},
|
||||
{
|
||||
disableTestUser: true,
|
||||
clientOptions: {
|
||||
plugins: [stripeClient({ subscription: true })],
|
||||
},
|
||||
},
|
||||
);
|
||||
const ctx = await auth.$context;
|
||||
|
||||
const userRes = await client.signUp.email(
|
||||
{
|
||||
email: "restore-period-end@test.com",
|
||||
password: "password",
|
||||
name: "Test",
|
||||
},
|
||||
{ throw: true },
|
||||
);
|
||||
|
||||
const headers = new Headers();
|
||||
await client.signIn.email(
|
||||
{ email: "restore-period-end@test.com", password: "password" },
|
||||
{ throw: true, onSuccess: sessionSetter(headers) },
|
||||
);
|
||||
|
||||
// Create subscription scheduled to cancel at period end
|
||||
await ctx.adapter.create({
|
||||
model: "subscription",
|
||||
data: {
|
||||
referenceId: userRes.user.id,
|
||||
stripeCustomerId: "cus_restore_test",
|
||||
stripeSubscriptionId: "sub_restore_period_end",
|
||||
status: "active",
|
||||
plan: "starter",
|
||||
cancelAtPeriodEnd: true,
|
||||
cancelAt: null,
|
||||
canceledAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
mockStripe.subscriptions.list.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: "sub_restore_period_end",
|
||||
status: "active",
|
||||
cancel_at_period_end: true,
|
||||
cancel_at: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mockStripe.subscriptions.update.mockResolvedValueOnce({
|
||||
id: "sub_restore_period_end",
|
||||
status: "active",
|
||||
cancel_at_period_end: false,
|
||||
cancel_at: null,
|
||||
});
|
||||
|
||||
const restoreRes = await client.subscription.restore({
|
||||
fetchOptions: { headers },
|
||||
});
|
||||
|
||||
expect(restoreRes.data).toBeDefined();
|
||||
|
||||
// Verify Stripe was called with correct params (cancel_at_period_end: false)
|
||||
expect(mockStripe.subscriptions.update).toHaveBeenCalledWith(
|
||||
"sub_restore_period_end",
|
||||
{ cancel_at_period_end: false },
|
||||
);
|
||||
|
||||
const updatedSub = await ctx.adapter.findOne<Subscription>({
|
||||
model: "subscription",
|
||||
where: [{ field: "referenceId", value: userRes.user.id }],
|
||||
});
|
||||
|
||||
expect(updatedSub).toMatchObject({
|
||||
cancelAtPeriodEnd: false,
|
||||
cancelAt: null,
|
||||
canceledAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should clear cancelAt when restoring a cancel_at (specific date) subscription", async () => {
|
||||
const { client, auth, sessionSetter } = await getTestInstance(
|
||||
{
|
||||
database: memory,
|
||||
plugins: [stripe(stripeOptions)],
|
||||
},
|
||||
{
|
||||
disableTestUser: true,
|
||||
clientOptions: {
|
||||
plugins: [stripeClient({ subscription: true })],
|
||||
},
|
||||
},
|
||||
);
|
||||
const ctx = await auth.$context;
|
||||
|
||||
const userRes = await client.signUp.email(
|
||||
{
|
||||
email: "restore-cancel-at@test.com",
|
||||
password: "password",
|
||||
name: "Test",
|
||||
},
|
||||
{ throw: true },
|
||||
);
|
||||
|
||||
const headers = new Headers();
|
||||
await client.signIn.email(
|
||||
{ email: "restore-cancel-at@test.com", password: "password" },
|
||||
{ throw: true, onSuccess: sessionSetter(headers) },
|
||||
);
|
||||
|
||||
const cancelAt = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Create subscription scheduled to cancel at specific date
|
||||
await ctx.adapter.create({
|
||||
model: "subscription",
|
||||
data: {
|
||||
referenceId: userRes.user.id,
|
||||
stripeCustomerId: "cus_restore_cancel_at",
|
||||
stripeSubscriptionId: "sub_restore_cancel_at",
|
||||
status: "active",
|
||||
plan: "starter",
|
||||
cancelAtPeriodEnd: false,
|
||||
cancelAt: cancelAt,
|
||||
canceledAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
mockStripe.subscriptions.list.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: "sub_restore_cancel_at",
|
||||
status: "active",
|
||||
cancel_at_period_end: false,
|
||||
cancel_at: Math.floor(cancelAt.getTime() / 1000),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mockStripe.subscriptions.update.mockResolvedValueOnce({
|
||||
id: "sub_restore_cancel_at",
|
||||
status: "active",
|
||||
cancel_at_period_end: false,
|
||||
cancel_at: null,
|
||||
});
|
||||
|
||||
const restoreRes = await client.subscription.restore({
|
||||
fetchOptions: { headers },
|
||||
});
|
||||
|
||||
expect(restoreRes.data).toBeDefined();
|
||||
|
||||
// Verify Stripe was called with correct params (cancel_at: "" to clear)
|
||||
expect(mockStripe.subscriptions.update).toHaveBeenCalledWith(
|
||||
"sub_restore_cancel_at",
|
||||
{ cancel_at: "" },
|
||||
);
|
||||
|
||||
const updatedSub = await ctx.adapter.findOne<Subscription>({
|
||||
model: "subscription",
|
||||
where: [{ field: "referenceId", value: userRes.user.id }],
|
||||
});
|
||||
|
||||
expect(updatedSub).toMatchObject({
|
||||
cancelAtPeriodEnd: false,
|
||||
cancelAt: null,
|
||||
canceledAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel subscription fallback (missed webhook)", () => {
|
||||
it("should sync from Stripe when cancel request fails because subscription is already canceled", async () => {
|
||||
const { client, auth, sessionSetter } = await getTestInstance(
|
||||
{
|
||||
database: memory,
|
||||
plugins: [stripe(stripeOptions)],
|
||||
},
|
||||
{
|
||||
disableTestUser: true,
|
||||
clientOptions: {
|
||||
plugins: [stripeClient({ subscription: true })],
|
||||
},
|
||||
},
|
||||
);
|
||||
const ctx = await auth.$context;
|
||||
|
||||
const userRes = await client.signUp.email(
|
||||
{
|
||||
email: "missed-webhook@test.com",
|
||||
password: "password",
|
||||
name: "Test",
|
||||
},
|
||||
{ throw: true },
|
||||
);
|
||||
|
||||
const headers = new Headers();
|
||||
await client.signIn.email(
|
||||
{ email: "missed-webhook@test.com", password: "password" },
|
||||
{ throw: true, onSuccess: sessionSetter(headers) },
|
||||
);
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const cancelAt = now + 15 * 24 * 60 * 60;
|
||||
|
||||
// Create subscription in DB (not synced - missed webhook)
|
||||
const { id: subscriptionId } = await ctx.adapter.create({
|
||||
model: "subscription",
|
||||
data: {
|
||||
referenceId: userRes.user.id,
|
||||
stripeCustomerId: "cus_missed_webhook",
|
||||
stripeSubscriptionId: "sub_missed_webhook",
|
||||
status: "active",
|
||||
plan: "starter",
|
||||
cancelAtPeriodEnd: false, // DB thinks it's not canceling
|
||||
cancelAt: null,
|
||||
canceledAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Stripe has the subscription already scheduled to cancel with cancel_at
|
||||
mockStripe.subscriptions.list.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: "sub_missed_webhook",
|
||||
status: "active",
|
||||
cancel_at_period_end: false,
|
||||
cancel_at: cancelAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Billing portal returns error because subscription is already set to cancel
|
||||
mockStripe.billingPortal.sessions.create.mockRejectedValueOnce(
|
||||
new Error("This subscription is already set to be canceled"),
|
||||
);
|
||||
|
||||
// When fallback kicks in, it retrieves from Stripe
|
||||
mockStripe.subscriptions.retrieve.mockResolvedValueOnce({
|
||||
id: "sub_missed_webhook",
|
||||
status: "active",
|
||||
cancel_at_period_end: false,
|
||||
cancel_at: cancelAt,
|
||||
canceled_at: now,
|
||||
});
|
||||
|
||||
// Try to cancel - should fail but trigger sync
|
||||
const cancelRes = await client.subscription.cancel({
|
||||
returnUrl: "/account",
|
||||
fetchOptions: { headers },
|
||||
});
|
||||
|
||||
// Should have error because portal creation failed
|
||||
expect(cancelRes.error).toBeDefined();
|
||||
|
||||
// But DB should now be synced with Stripe's actual state
|
||||
const updatedSub = await ctx.adapter.findOne<Subscription>({
|
||||
model: "subscription",
|
||||
where: [{ field: "id", value: subscriptionId }],
|
||||
});
|
||||
|
||||
expect(updatedSub).toMatchObject({
|
||||
cancelAtPeriodEnd: false,
|
||||
cancelAt: expect.any(Date),
|
||||
canceledAt: expect.any(Date),
|
||||
});
|
||||
|
||||
// Verify it's the correct cancel_at date from Stripe
|
||||
expect(updatedSub!.cancelAt!.getTime()).toBe(cancelAt * 1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,9 +154,26 @@ export interface Subscription {
|
||||
*/
|
||||
periodEnd?: Date | undefined;
|
||||
/**
|
||||
* Cancel at period end
|
||||
* Whether this subscription will (if status=active)
|
||||
* or did (if status=canceled) cancel at the end of the current billing period.
|
||||
*/
|
||||
cancelAtPeriodEnd?: boolean | undefined;
|
||||
/**
|
||||
* If the subscription is scheduled to be canceled,
|
||||
* this is the time at which the cancellation will take effect.
|
||||
*/
|
||||
cancelAt?: Date | undefined;
|
||||
/**
|
||||
* If the subscription has been canceled, this is the time when it was canceled.
|
||||
*
|
||||
* Note: If the subscription was canceled with `cancel_at_period_end`,
|
||||
* this reflects the cancellation request time, not when the subscription actually ends.
|
||||
*/
|
||||
canceledAt?: Date | undefined;
|
||||
/**
|
||||
* If the subscription has ended, the date the subscription ended.
|
||||
*/
|
||||
endedAt?: Date | undefined;
|
||||
/**
|
||||
* A field to group subscriptions so you can have multiple subscriptions
|
||||
* for one reference id
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { StripeOptions } from "./types";
|
||||
import type Stripe from "stripe";
|
||||
import type { StripeOptions, Subscription } from "./types";
|
||||
|
||||
export async function getPlans(
|
||||
subscriptionOptions: StripeOptions["subscription"],
|
||||
@@ -33,3 +34,26 @@ export async function getPlanByName(options: StripeOptions, name: string) {
|
||||
res?.find((plan) => plan.name.toLowerCase() === name.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a subscription is in an available state (active or trialing)
|
||||
*/
|
||||
export function isActiveOrTrialing(
|
||||
sub: Subscription | Stripe.Subscription,
|
||||
): boolean {
|
||||
return sub.status === "active" || sub.status === "trialing";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a subscription is scheduled to be canceled (DB subscription object)
|
||||
*/
|
||||
export function isPendingCancel(sub: Subscription): boolean {
|
||||
return !!(sub.cancelAtPeriodEnd || sub.cancelAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Stripe subscription is scheduled to be canceled (Stripe API response)
|
||||
*/
|
||||
export function isStripePendingCancel(stripeSub: Stripe.Subscription): boolean {
|
||||
return !!(stripeSub.cancel_at_period_end || stripeSub.cancel_at);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user