feat(stripe): flexible subscription cancellation and termination management (#6961)

Co-authored-by: GautamBytes <manchandanigautam@gmail.com>
This commit is contained in:
Taesu
2025-12-26 16:01:01 +09:00
committed by GitHub
parent e8458c47df
commit b8d5f71b99
7 changed files with 909 additions and 92 deletions

View File

@@ -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",

View File

@@ -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?.({

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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);
});
});
});

View File

@@ -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

View File

@@ -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);
}