mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-26 21:45:40 -05:00
Handle stale Stripe subscription IDs in routes
This commit is contained in:
@@ -50,6 +50,35 @@ function getUrl(ctx: GenericEndpointContext, url: string) {
|
||||
}`;
|
||||
}
|
||||
|
||||
function isStripeResourceMissingError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") {
|
||||
return false;
|
||||
}
|
||||
const stripeError = error as { code?: string; statusCode?: number };
|
||||
return (
|
||||
stripeError.code === "resource_missing" || stripeError.statusCode === 404
|
||||
);
|
||||
}
|
||||
|
||||
async function retrieveStripeSubscriptionOrNull(
|
||||
ctx: GenericEndpointContext,
|
||||
stripeClient: Stripe,
|
||||
subscriptionId: string,
|
||||
scope: string,
|
||||
) {
|
||||
try {
|
||||
return await stripeClient.subscriptions.retrieve(subscriptionId);
|
||||
} catch (error) {
|
||||
if (isStripeResourceMissingError(error)) {
|
||||
ctx.context.logger.warn(
|
||||
`Stripe subscription ${subscriptionId} not found during ${scope}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a Stripe price ID from a lookup key.
|
||||
* @internal
|
||||
@@ -1345,10 +1374,13 @@ export const cancelSubscription = (options: StripeOptions) => {
|
||||
STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
const activeSubscription = await client.subscriptions.retrieve(
|
||||
const activeSubscription = await retrieveStripeSubscriptionOrNull(
|
||||
ctx,
|
||||
client,
|
||||
subscription.stripeSubscriptionId,
|
||||
"cancel",
|
||||
);
|
||||
if (!isActiveOrTrialing(activeSubscription)) {
|
||||
if (!activeSubscription || !isActiveOrTrialing(activeSubscription)) {
|
||||
/**
|
||||
* If the subscription is not active, delete the stale DB record.
|
||||
* This is a rare case and should not happen.
|
||||
@@ -1551,17 +1583,29 @@ export const restoreSubscription = (options: StripeOptions) => {
|
||||
},
|
||||
],
|
||||
});
|
||||
const releasedSub = await client.subscriptions.retrieve(
|
||||
const releasedSub = await retrieveStripeSubscriptionOrNull(
|
||||
ctx,
|
||||
client,
|
||||
subscription.stripeSubscriptionId,
|
||||
"restore schedule release",
|
||||
);
|
||||
if (!releasedSub) {
|
||||
throw APIError.from(
|
||||
"BAD_REQUEST",
|
||||
STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
return ctx.json(releasedSub);
|
||||
}
|
||||
|
||||
// Handle pending cancellation
|
||||
const activeSubscription = await client.subscriptions.retrieve(
|
||||
const activeSubscription = await retrieveStripeSubscriptionOrNull(
|
||||
ctx,
|
||||
client,
|
||||
subscription.stripeSubscriptionId,
|
||||
"restore",
|
||||
);
|
||||
if (!isActiveOrTrialing(activeSubscription)) {
|
||||
if (!activeSubscription || !isActiveOrTrialing(activeSubscription)) {
|
||||
throw APIError.from(
|
||||
"BAD_REQUEST",
|
||||
STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
||||
|
||||
@@ -1099,6 +1099,48 @@ describe("stripe", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should delete stale local subscriptions when Stripe cancel lookup returns resource_missing", async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const { client, ctx, headers, userId } = await createSignedInGroupedUser(
|
||||
"grouped-stale-cancel@email.com",
|
||||
"cus_grouped_stale_cancel",
|
||||
);
|
||||
|
||||
const staleSubscription = await ctx.adapter.create({
|
||||
model: "subscription",
|
||||
data: {
|
||||
plan: "starter",
|
||||
referenceId: userId,
|
||||
stripeCustomerId: "cus_grouped_stale_cancel",
|
||||
stripeSubscriptionId: "sub_stale_cancel",
|
||||
status: "active",
|
||||
},
|
||||
});
|
||||
|
||||
mockStripe.subscriptions.retrieve.mockRejectedValueOnce(
|
||||
Object.assign(new Error("No such subscription"), {
|
||||
code: "resource_missing",
|
||||
statusCode: 404,
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await client.subscription.cancel({
|
||||
group: "default",
|
||||
returnUrl: "/account",
|
||||
fetchOptions: { headers },
|
||||
});
|
||||
|
||||
expect(res.error?.code).toBe("SUBSCRIPTION_NOT_FOUND");
|
||||
expect(mockStripe.billingPortal.sessions.create).not.toHaveBeenCalled();
|
||||
|
||||
const deleted = await ctx.adapter.findOne<Subscription>({
|
||||
model: "subscription",
|
||||
where: [{ field: "id", value: staleSubscription.id }],
|
||||
});
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow restoring the legacy "default" group explicitly', async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -6384,6 +6426,72 @@ describe("stripe", () => {
|
||||
|
||||
expect(restoreRes.error?.status).toBe(400);
|
||||
});
|
||||
|
||||
it("should map stale Stripe restore lookups to SUBSCRIPTION_NOT_FOUND", async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
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-stale@test.com",
|
||||
password: "password",
|
||||
name: "Test",
|
||||
},
|
||||
{ throw: true },
|
||||
);
|
||||
|
||||
const headers = new Headers();
|
||||
await client.signIn.email(
|
||||
{ email: "restore-stale@test.com", password: "password" },
|
||||
{ throw: true, onSuccess: sessionSetter(headers) },
|
||||
);
|
||||
|
||||
const staleSubscription = await ctx.adapter.create({
|
||||
model: "subscription",
|
||||
data: {
|
||||
referenceId: userRes.user.id,
|
||||
stripeCustomerId: "cus_restore_stale",
|
||||
stripeSubscriptionId: "sub_restore_stale",
|
||||
status: "active",
|
||||
plan: "starter",
|
||||
cancelAtPeriodEnd: true,
|
||||
canceledAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
mockStripe.subscriptions.retrieve.mockRejectedValueOnce(
|
||||
Object.assign(new Error("No such subscription"), {
|
||||
code: "resource_missing",
|
||||
statusCode: 404,
|
||||
}),
|
||||
);
|
||||
|
||||
const restoreRes = await client.subscription.restore({
|
||||
fetchOptions: { headers },
|
||||
});
|
||||
|
||||
expect(restoreRes.error?.code).toBe("SUBSCRIPTION_NOT_FOUND");
|
||||
expect(mockStripe.subscriptions.update).not.toHaveBeenCalled();
|
||||
|
||||
const stillPresent = await ctx.adapter.findOne<Subscription>({
|
||||
model: "subscription",
|
||||
where: [{ field: "id", value: staleSubscription.id }],
|
||||
});
|
||||
expect(stillPresent).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel subscription fallback (missed webhook)", () => {
|
||||
|
||||
Reference in New Issue
Block a user