From eb45d1e5a9d0b48c77ef5ca406adf0ce3ad4fd36 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 15 Jun 2026 15:58:40 +1000 Subject: [PATCH] fix: reconcile billing when stripe subscription is missing (#2988) Catch resource_missing in the subscription route, return null so the billing page still loads, and fire a sync to converge the stale row. --- .../enterprise-router/get-subscription.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/trpc/server/enterprise-router/get-subscription.ts b/packages/trpc/server/enterprise-router/get-subscription.ts index 9c7ffb4ea..f3368d792 100644 --- a/packages/trpc/server/enterprise-router/get-subscription.ts +++ b/packages/trpc/server/enterprise-router/get-subscription.ts @@ -1,7 +1,14 @@ import { getInternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans'; import { getSubscription } from '@documenso/ee/server-only/stripe/get-subscription'; +import { syncStripeCustomerSubscription } from '@documenso/ee/server-only/stripe/sync-stripe-customer-subscription'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { Stripe } from '@documenso/lib/server-only/stripe'; +import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; +import { prisma } from '@documenso/prisma'; + +import type { Logger } from 'pino'; import { authenticatedProcedure } from '../trpc'; import { ZGetSubscriptionRequestSchema } from './get-subscription.types'; @@ -26,9 +33,17 @@ export const getSubscriptionRoute = authenticatedProcedure } const [subscription, plans] = await Promise.all([ + // If the subscription is not found or there's an error, we return null to + // avoid failing the entire request. getSubscription({ organisationId, userId, + }).catch(async (e) => { + ctx.logger.error(`Failed to get subscription for organisation ${organisationId}`, e); + + await reconcileMissingStripeSubscription({ logger: ctx.logger, organisationId, userId, error: e }); + + return null; }), getInternalClaimPlans(), ]); @@ -38,3 +53,51 @@ export const getSubscriptionRoute = authenticatedProcedure plans, }; }); + +type ReconcileMissingStripeSubscriptionOptions = { + logger: Logger; + organisationId: string; + userId: number; + error: unknown; +}; + +/** + * When the Stripe subscription no longer exists (e.g. deleted by Stripe's + * test-mode retention policy, or removed manually), fire-and-forget a reconcile + * so the stale local subscription row and any billing banner converge on the + * next load. Reconcile failures must never break the read path that calls this. + */ +const reconcileMissingStripeSubscription = async ({ + logger, + organisationId, + userId, + error, +}: ReconcileMissingStripeSubscriptionOptions) => { + if (!(error instanceof Stripe.errors.StripeInvalidRequestError) || error.code !== 'resource_missing') { + return; + } + + const organisation = await prisma.organisation.findFirst({ + where: buildOrganisationWhereQuery({ + organisationId, + userId, + roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], + }), + select: { + customerId: true, + }, + }); + + if (!organisation?.customerId) { + return; + } + + void syncStripeCustomerSubscription({ + customerId: organisation.customerId, + }).catch((syncError) => { + logger.error( + `Failed to reconcile subscription after resource_missing for organisation ${organisationId}`, + syncError, + ); + }); +};