Handle stale Stripe subscription IDs in routes

This commit is contained in:
Vadym Raksha
2026-03-07 14:11:28 +01:00
parent e6d185f7fe
commit ec5da6d678
2 changed files with 157 additions and 5 deletions
+49 -5
View File
@@ -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,
+108
View File
@@ -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)", () => {