diff --git a/docs/content/docs/plugins/stripe.mdx b/docs/content/docs/plugins/stripe.mdx
index c06340b464..28dd854424 100644
--- a/docs/content/docs/plugins/stripe.mdx
+++ b/docs/content/docs/plugins/stripe.mdx
@@ -383,8 +383,24 @@ type cancelSubscription = {
This will redirect the user to the Stripe Billing Portal where they can cancel their subscription.
+
+**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. |
+
+
#### Restoring a Canceled Subscription
+> **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).
+
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 = {
-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.
+
+
+When a subscription is restored:
+- `cancelAtPeriodEnd` is set to `false`
+- `cancelAt` is cleared to `null`
+- `canceledAt` is cleared to `null`
+
-> **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",
diff --git a/packages/stripe/src/hooks.ts b/packages/stripe/src/hooks.ts
index 2a648d7a43..58fc68c2c8 100644
--- a/packages/stripe/src/hooks.ts
+++ b/packages/stripe/src/hooks.ts
@@ -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?.({
diff --git a/packages/stripe/src/routes.ts b/packages/stripe/src/routes.ts
index edee45b59a..8e07a0b890 100644
--- a/packages/stripe/src/routes.ts
+++ b/packages/stripe/src/routes.ts
@@ -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;
}
diff --git a/packages/stripe/src/schema.ts b/packages/stripe/src/schema.ts
index 977459d18b..52fe8fff76 100644
--- a/packages/stripe/src/schema.ts
+++ b/packages/stripe/src/schema.ts
@@ -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,
diff --git a/packages/stripe/src/stripe.test.ts b/packages/stripe/src/stripe.test.ts
index 24af1ebc0b..4b47db8bf7 100644
--- a/packages/stripe/src/stripe.test.ts
+++ b/packages/stripe/src/stripe.test.ts
@@ -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({
+ 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({
+ 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({
+ 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({
+ 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({
+ 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({
+ 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({
+ 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);
+ });
+ });
});
diff --git a/packages/stripe/src/types.ts b/packages/stripe/src/types.ts
index 0415a4f37e..5f0ddebc7f 100644
--- a/packages/stripe/src/types.ts
+++ b/packages/stripe/src/types.ts
@@ -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
diff --git a/packages/stripe/src/utils.ts b/packages/stripe/src/utils.ts
index acae6231bd..85adf9c97e 100644
--- a/packages/stripe/src/utils.ts
+++ b/packages/stripe/src/utils.ts
@@ -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);
+}