From ec5da6d67855e9aa2b89e7182d7fae3ba47e0010 Mon Sep 17 00:00:00 2001 From: Vadym Raksha Date: Sat, 7 Mar 2026 14:11:28 +0100 Subject: [PATCH] Handle stale Stripe subscription IDs in routes --- packages/stripe/src/routes.ts | 54 ++++++++++++-- packages/stripe/test/stripe.test.ts | 108 ++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 5 deletions(-) diff --git a/packages/stripe/src/routes.ts b/packages/stripe/src/routes.ts index 2780407958..439b67a801 100644 --- a/packages/stripe/src/routes.ts +++ b/packages/stripe/src/routes.ts @@ -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, diff --git a/packages/stripe/test/stripe.test.ts b/packages/stripe/test/stripe.test.ts index 63254e1468..c798b50c8f 100644 --- a/packages/stripe/test/stripe.test.ts +++ b/packages/stripe/test/stripe.test.ts @@ -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({ + 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({ + model: "subscription", + where: [{ field: "id", value: staleSubscription.id }], + }); + expect(stillPresent).not.toBeNull(); + }); }); describe("cancel subscription fallback (missed webhook)", () => {