From c78a659554e713a62b4641ac0b3ff5946e6d6d00 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 1 Jul 2026 17:51:07 +0700 Subject: [PATCH 1/2] fix(admin): don't hit Stripe for admin-granted subscriptions Admin-granted subscriptions store the user id in Organization.paymentId (not a real `cus_...` customer), so every billing path that fed paymentId to Stripe threw "No such customer". Guard them all to skip Stripe when there is no real `cus_` customer: - getCharges returns no charges for any non-`cus_` paymentId. - prorate returns a zero price (it fires on billing-page load per plan card, which was the main source of error logs). - checkDiscount / applyDiscount return false (no coupon to offer/apply). - finishTrial returns early (nothing to finish). - /billing/portal, /billing/cancel and /billing/cancel-subscription reject with a clean 400 instead of a Stripe error (the UI also hides their buttons). Also on the display side: /self exposes `hasStripeCustomer` (paymentId starts with `cus_`), and the billing page only renders the "Update Payment Method / Invoices History" button (which opens the Stripe billing portal) when it is true. Admin-granted and never-subscribed orgs have no real customer, so the button is hidden and the portal is never reached. Validated by impersonating an admin-granted account (paymentId = user id): the billing page now loads with no Stripe error logs (previously flooded by the per-plan prorate call), and the Update Payment Method button is hidden. A real `cus_` subscriber still sees the button and the portal opens normally. Co-Authored-By: Claude Opus 4.8 --- .../src/api/routes/billing.controller.ts | 22 +++++++++++++++ .../src/api/routes/users.controller.ts | 5 ++++ .../billing/main.billing.component.tsx | 14 +++++----- .../src/services/stripe.service.ts | 27 ++++++++++++++++++- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/api/routes/billing.controller.ts b/apps/backend/src/api/routes/billing.controller.ts index dd6686b158..98a2b378df 100644 --- a/apps/backend/src/api/routes/billing.controller.ts +++ b/apps/backend/src/api/routes/billing.controller.ts @@ -96,6 +96,12 @@ export class BillingController { @Get('/portal') async modifyPayment(@GetOrgFromRequest() org: Organization) { + // Admin-granted/never-subscribed orgs have no real `cus_...` customer, so + // there is no Stripe billing portal to open (the UI hides the button; this + // guards the endpoint against a "No such customer" Stripe error). + if (!org.paymentId?.startsWith('cus_')) { + throw new HttpException('No Stripe customer for this organization', 400); + } const customer = await this._stripeService.getCustomerByOrganizationId( org.id ); @@ -116,6 +122,13 @@ export class BillingController { @GetUserFromRequest() user: User, @Body() body: { feedback: string } ) { + // An admin-granted subscription has no Stripe subscription to cancel (its + // paymentId is the user id, not a `cus_...` customer). The UI hides the + // self-service cancel button for these; removal is an admin-only action. + if (!org.paymentId?.startsWith('cus_')) { + throw new HttpException('No Stripe subscription to cancel', 400); + } + await this._notificationService.sendEmail( process.env.EMAIL_FROM_ADDRESS, 'Subscription Cancelled', @@ -168,6 +181,15 @@ export class BillingController { throw new HttpException('Unauthorized', 400); } + // Admin-granted subscriptions have no Stripe subscription to cancel; the + // admin panel hides this and offers "Remove Subscription" instead. + if (!org.paymentId?.startsWith('cus_')) { + throw new HttpException( + 'Admin-granted subscription; use remove-subscription instead', + 400 + ); + } + return this._stripeService.cancelSubscription(org.id); } diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index 98e888d72c..623604aaf3 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -128,6 +128,11 @@ export class UsersController { ? false : organization?.isTrailing, allowTrial: organization?.allowTrial, + // A real Stripe customer has a `cus_...` paymentId. Admin-granted + // subscriptions store the user id there instead, and never-subscribed + // orgs have none, so gate billing-portal UI on this to avoid calling + // Stripe with a non-existent customer ("No such customer"). + hasStripeCustomer: !!organization?.paymentId?.startsWith('cus_'), streakSince: organization?.streakSince || null, publicApi: // @ts-ignore diff --git a/apps/frontend/src/components/billing/main.billing.component.tsx b/apps/frontend/src/components/billing/main.billing.component.tsx index 1d8d05b6bd..d889895443 100644 --- a/apps/frontend/src/components/billing/main.billing.component.tsx +++ b/apps/frontend/src/components/billing/main.billing.component.tsx @@ -539,12 +539,14 @@ export const MainBillingComponent: FC<{ {!!subscription?.id && (
- + {!!(user as any)?.hasStripeCustomer && ( + + )} {isGeneral && !subscription?.cancelAt && (