fix(stripe): use correct stripeCustomerId on /subscription/cancel/callback endpoint (#8032)

(cherry picked from commit 76d9ba140c)
This commit is contained in:
Taesu
2026-02-18 22:31:37 +08:00
committed by Alex Yang
parent 16b11058be
commit 155194992e
2 changed files with 191 additions and 58 deletions
+61 -57
View File
@@ -772,74 +772,78 @@ export const cancelSubscriptionCallback = (options: StripeOptions) => {
if (!session) {
throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
}
const { user } = session;
const { callbackURL, subscriptionId } = ctx.query;
if (user?.stripeCustomerId) {
try {
const subscription = await ctx.context.adapter.findOne<Subscription>({
try {
const subscription = await ctx.context.adapter.findOne<Subscription>({
model: "subscription",
where: [
{
field: "id",
value: subscriptionId,
},
],
});
if (
!subscription ||
subscription.status === "canceled" ||
isPendingCancel(subscription)
) {
throw ctx.redirect(getUrl(ctx, callbackURL));
}
// Use the subscription's own stripeCustomerId so this works
// for both user and organization subscriptions.
const customerId = subscription.stripeCustomerId;
if (!customerId) {
throw ctx.redirect(getUrl(ctx, callbackURL));
}
const stripeSubscription = await client.subscriptions.list({
customer: customerId,
status: "active",
});
const currentSubscription = stripeSubscription.data.find(
(sub) => sub.id === subscription.stripeSubscriptionId,
);
const isNewCancellation =
currentSubscription &&
isStripePendingCancel(currentSubscription) &&
!isPendingCancel(subscription);
if (isNewCancellation) {
await ctx.context.adapter.update({
model: "subscription",
update: {
status: currentSubscription?.status,
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: [
{
field: "id",
value: subscriptionId,
value: subscription.id,
},
],
});
if (
!subscription ||
subscription.status === "canceled" ||
isPendingCancel(subscription)
) {
throw ctx.redirect(getUrl(ctx, callbackURL));
}
const stripeSubscription = await client.subscriptions.list({
customer: user.stripeCustomerId,
status: "active",
await subscriptionOptions.onSubscriptionCancel?.({
subscription,
cancellationDetails: currentSubscription.cancellation_details,
stripeSubscription: currentSubscription,
event: undefined,
});
const currentSubscription = stripeSubscription.data.find(
(sub) => sub.id === subscription.stripeSubscriptionId,
);
const isNewCancellation =
currentSubscription &&
isStripePendingCancel(currentSubscription) &&
!isPendingCancel(subscription);
if (isNewCancellation) {
await ctx.context.adapter.update({
model: "subscription",
update: {
status: currentSubscription?.status,
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: [
{
field: "id",
value: subscription.id,
},
],
});
await subscriptionOptions.onSubscriptionCancel?.({
subscription,
cancellationDetails: currentSubscription.cancellation_details,
stripeSubscription: currentSubscription,
event: undefined,
});
}
} catch (error) {
ctx.context.logger.error(
"Error checking subscription status from Stripe",
error,
);
}
} catch (error) {
ctx.context.logger.error(
"Error checking subscription status from Stripe",
error,
);
}
throw ctx.redirect(getUrl(ctx, callbackURL));
},
@@ -2,7 +2,7 @@ import { organizationClient } from "better-auth/client/plugins";
import { organization } from "better-auth/plugins/organization";
import { getTestInstance } from "better-auth/test";
import type Stripe from "stripe";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, onTestFinished, vi } from "vitest";
import { stripe } from "../src";
import { stripeClient } from "../src/client";
import type { StripeOptions, Subscription } from "../src/types";
@@ -461,6 +461,135 @@ describe("stripe - organization customer", () => {
);
});
it("should update subscription on cancel callback using subscription's stripeCustomerId", async () => {
const cancelAt = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60;
const canceledAt = Math.floor(Date.now() / 1000);
// Mock Stripe to return the subscription as pending cancel
mockStripeOrg.subscriptions.list.mockResolvedValueOnce({
data: [
{
id: "sub_org_cb_123",
status: "active",
cancel_at_period_end: true,
cancel_at: cancelAt,
canceled_at: canceledAt,
cancellation_details: {
reason: "cancellation_requested",
},
},
],
});
// Clean up mocks even if assertions fail to prevent leaking into subsequent tests
onTestFinished(() => {
mockStripeOrg.subscriptions.list.mockReset();
mockStripeOrg.subscriptions.list.mockResolvedValue({ data: [] });
mockStripeOrg.subscriptions.update.mockReset();
});
const onSubscriptionCancel = vi.fn();
const { client, auth, sessionSetter } = await getTestInstance(
{
plugins: [
organization(),
stripe({
...baseOrgStripeOptions,
subscription: {
...baseOrgStripeOptions.subscription,
onSubscriptionCancel,
},
}),
],
},
{
disableTestUser: true,
clientOptions: {
plugins: [organizationClient(), stripeClient({ subscription: true })],
},
},
);
const ctx = await auth.$context;
await client.signUp.email(
{ ...testUser, email: "org-cancel-cb-test@email.com" },
{ throw: true },
);
const headers = new Headers();
await client.signIn.email(
{ ...testUser, email: "org-cancel-cb-test@email.com" },
{ throw: true, onSuccess: sessionSetter(headers) },
);
const org = await client.organization.create({
name: "Cancel Callback Org",
slug: "cancel-cb-org",
fetchOptions: { headers },
});
const orgId = org.data?.id as string;
// Organization has stripeCustomerId, user does NOT
await ctx.adapter.update({
model: "organization",
update: { stripeCustomerId: "cus_cb_org_123" },
where: [{ field: "id", value: orgId }],
});
const sub = await ctx.adapter.create<Subscription>({
model: "subscription",
data: {
referenceId: orgId,
stripeCustomerId: "cus_cb_org_123",
stripeSubscriptionId: "sub_org_cb_123",
status: "active",
plan: "starter",
},
});
// Call the cancel callback endpoint directly (simulating redirect from Stripe Portal)
const callbackUrl = new URL(
"http://localhost:3000/api/auth/subscription/cancel/callback",
);
callbackUrl.searchParams.set("callbackURL", "/dashboard");
callbackUrl.searchParams.set("subscriptionId", sub.id);
const response = await auth.handler(
new Request(callbackUrl.toString(), {
method: "GET",
headers,
}),
);
// Should redirect to the callbackURL
expect(response.status).toBe(302);
// Verify DB was updated with cancellation info
const updatedSub = await ctx.adapter.findOne<Subscription>({
model: "subscription",
where: [{ field: "id", value: sub.id }],
});
expect(updatedSub?.cancelAtPeriodEnd).toBe(true);
expect(updatedSub?.cancelAt).toBeDefined();
expect(updatedSub?.canceledAt).toBeDefined();
// Verify the Stripe API was called with the subscription's customer ID, not the user's
expect(mockStripeOrg.subscriptions.list).toHaveBeenCalledWith(
expect.objectContaining({
customer: "cus_cb_org_123",
status: "active",
}),
);
// Verify onSubscriptionCancel callback was invoked
expect(onSubscriptionCancel).toHaveBeenCalledWith(
expect.objectContaining({
subscription: expect.objectContaining({ id: sub.id }),
event: undefined,
}),
);
});
it("should restore subscription for organization", async () => {
mockStripeOrg.subscriptions.list.mockResolvedValueOnce({
data: [