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