From 44b1ee13b4e772d41ce0c7e9ea6e213fa88d550a Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 1 Jul 2026 17:01:07 +0700 Subject: [PATCH 1/2] feat(admin): allow removing an admin-granted subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The impersonation bar could grant a free/admin subscription but not undo it. Adds a super-admin "Remove Subscription" action next to the grant selector. - New POST /billing/remove-subscription (super-admin) → removeAdminGrantedSubscription: downgrades the org to FREE, drops the local subscription row via the existing deleteSubscription path, and clears the org's fake paymentId — all with NO Stripe calls. Clearing paymentId is required: admin grants store the user id in paymentId, and leaving it would make later Stripe flows (checkout, discount checks) call Stripe with a non-existent customer and error ("No such customer"). - Guarded to admin-granted subscriptions only: those store the user id in Organization.paymentId, so any paymentId starting with `cus_` is a real Stripe customer and is rejected (use cancel-subscription instead) — a real paid subscription can never be removed here. - /self now returns `adminGrantedSubscription`, and the impersonation bar shows the remove control only when it is true, so a genuine Stripe subscription is never accidentally removed. - Since an admin-granted subscription has no Stripe subscription to cancel, the now-redundant Cancel controls are hidden when `adminGrantedSubscription` is true: the impersonation bar's "Cancel Subscription" (Remove is used instead) and the billing page's self-service "Cancel subscription" button. Their backend endpoints (in the previous commit) already reject non-`cus_` orgs with a clean 400 as defense-in-depth. Validated by impersonating an admin-granted account: the Remove control shows while both Cancel controls (impersonation bar and billing page) are hidden; impersonating a real `cus_` subscriber hides Remove and still shows the Cancel controls, which continue to work. Co-Authored-By: Claude Opus 4.8 --- .../src/api/routes/billing.controller.ts | 12 +++++ .../src/api/routes/users.controller.ts | 7 +++ .../billing/main.billing.component.tsx | 20 +++---- .../src/components/layout/impersonate.tsx | 53 ++++++++++++++++--- .../subscriptions/subscription.repository.ts | 11 ++++ .../subscriptions/subscription.service.ts | 4 ++ .../src/services/stripe.service.ts | 23 ++++++++ 7 files changed, 114 insertions(+), 16 deletions(-) diff --git a/apps/backend/src/api/routes/billing.controller.ts b/apps/backend/src/api/routes/billing.controller.ts index dd6686b158..c4ade9f654 100644 --- a/apps/backend/src/api/routes/billing.controller.ts +++ b/apps/backend/src/api/routes/billing.controller.ts @@ -171,6 +171,18 @@ export class BillingController { return this._stripeService.cancelSubscription(org.id); } + @Post('/remove-subscription') + async removeSubscription( + @GetUserFromRequest() user: User, + @GetOrgFromRequest() org: Organization + ) { + if (!user.isSuperAdmin) { + throw new HttpException('Unauthorized', 400); + } + + return this._stripeService.removeAdminGrantedSubscription(org.id); + } + @Get('/chatbase-refund/preview') chatbaseRefundPreview(@GetOrgFromRequest() org: Organization) { return this._stripeService.chatbaseRefundPreview(org.id); diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index 98e888d72c..8308de368c 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -122,6 +122,13 @@ export class UsersController { role: organization?.users[0]?.role, // @ts-ignore isLifetime: !!organization?.subscription?.isLifetime, + // An admin-granted subscription stores the user id in `paymentId` rather + // than a real Stripe `cus_...` customer; used to expose an admin-only + // remove action without risking a real paid subscription. + adminGrantedSubscription: + // @ts-ignore + !!organization?.subscription && + !organization?.paymentId?.startsWith('cus_'), admin: !!user.isSuperAdmin, impersonate: !!impersonate, isTrailing: !process.env.STRIPE_PUBLISHABLE_KEY diff --git a/apps/frontend/src/components/billing/main.billing.component.tsx b/apps/frontend/src/components/billing/main.billing.component.tsx index 1d8d05b6bd..971bd2135d 100644 --- a/apps/frontend/src/components/billing/main.billing.component.tsx +++ b/apps/frontend/src/components/billing/main.billing.component.tsx @@ -545,15 +545,17 @@ export const MainBillingComponent: FC<{ 'Update Payment Method / Invoices History' )} - {isGeneral && !subscription?.cancelAt && ( - - )} + {isGeneral && + !subscription?.cancelAt && + !(user as any)?.adminGrantedSubscription && ( + + )} )} {subscription?.cancelAt && isGeneral && ( diff --git a/apps/frontend/src/components/layout/impersonate.tsx b/apps/frontend/src/components/layout/impersonate.tsx index 6f907640d0..97ce915342 100644 --- a/apps/frontend/src/components/layout/impersonate.tsx +++ b/apps/frontend/src/components/layout/impersonate.tsx @@ -40,6 +40,7 @@ const useCharges = () => { const ChargesModal: FC<{ close: () => void }> = ({ close }) => { const fetch = useFetch(); const t = useT(); + const user = useUser(); const { data: charges, mutate } = useCharges(); const [selected, setSelected] = useState>(new Set()); const [refunding, setRefunding] = useState(false); @@ -225,13 +226,15 @@ const ChargesModal: FC<{ close: () => void }> = ({ close }) => { {t('refund_selected', 'Refund Selected')} {selected.size > 0 && ` (${selected.size})`} - + {!(user as any)?.adminGrantedSubscription && ( + + )} ); @@ -304,6 +307,39 @@ export const Subscription = () => { ); }; + +// Shown only for admin-granted subscriptions (see `adminGrantedSubscription` on +// /self). Removing downgrades the account to FREE without touching Stripe, so a +// real paid subscription can never be removed here. +const RemoveSubscription = () => { + const fetch = useFetch(); + const t = useT(); + const remove = useCallback(async () => { + if ( + !(await deleteDialog( + t( + 'remove_admin_subscription_confirm', + 'Remove this admin-granted subscription? The account will return to the FREE plan.' + ), + t('yes_remove', 'Yes, remove'), + t('remove_subscription_title', 'Remove Subscription?'), + t('no_cancel', 'No, cancel') + )) + ) { + return; + } + await fetch('/billing/remove-subscription', { method: 'POST' }); + window.location.reload(); + }, []); + return ( +
+ {t('remove_subscription', 'Remove Subscription')} +
+ ); +}; const colorOptions = [ { value: 'INFO', label: 'Info (Blue)', className: 'bg-blue-600' }, { value: 'WARNING', label: 'Warning (Amber)', className: 'bg-amber-600' }, @@ -540,6 +576,9 @@ export const Impersonate = () => { {user?.tier?.current === 'FREE' && } + {(user as any)?.adminGrantedSubscription && ( + + )} {billingEnabled && } ) : ( diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts index afaa9b09a4..6a9738147d 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts @@ -109,6 +109,17 @@ export class SubscriptionRepository { }); } + clearCustomerId(organizationId: string) { + return this._organization.model.organization.update({ + where: { + id: organizationId, + }, + data: { + paymentId: null, + }, + }); + } + async getSubscriptionByOrgId(orgId: string) { return this._subscription.model.subscription.findFirst({ where: { diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts index f607e6d134..359a841658 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts @@ -51,6 +51,10 @@ export class SubscriptionService { ); } + clearCustomerId(organizationId: string) { + return this._subscriptionRepository.clearCustomerId(organizationId); + } + async checkSubscription(organizationId: string, subscriptionId: string) { return await this._subscriptionRepository.checkSubscription( organizationId, diff --git a/libraries/nestjs-libraries/src/services/stripe.service.ts b/libraries/nestjs-libraries/src/services/stripe.service.ts index 35facc6c2c..cdcf0c122f 100644 --- a/libraries/nestjs-libraries/src/services/stripe.service.ts +++ b/libraries/nestjs-libraries/src/services/stripe.service.ts @@ -914,6 +914,29 @@ export class StripeService { return { refunded, failed }; } + // Remove an admin-granted subscription. These store the user id in + // `paymentId` (not a real `cus_...` customer), so there is nothing to cancel + // on Stripe — we only downgrade to FREE and drop the local subscription row. + // A real Stripe subscription must go through `cancelSubscription` instead. + async removeAdminGrantedSubscription(organizationId: string) { + const org = await this._organizationService.getOrgById(organizationId); + if (!org?.paymentId) { + throw new Error('No subscription found for this organization'); + } + if (org.paymentId.startsWith('cus_')) { + throw new Error( + 'This organization has a real Stripe customer; use cancel-subscription instead' + ); + } + + // deleteSubscription locates everything by paymentId, so clear the fake + // customer id only after it runs. Leaving it would make later Stripe flows + // (checkout, discount checks) call Stripe with a non-existent customer. + await this._subscriptionService.deleteSubscription(org.paymentId); + await this._subscriptionService.clearCustomerId(org.id); + return { removed: true }; + } + async cancelSubscription(organizationId: string) { const org = await this._organizationService.getOrgById(organizationId); if (!org?.paymentId) { From 2b46bcba9a1e6e750282ee14ae10b12ed5696264 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Tue, 14 Jul 2026 19:30:01 +0700 Subject: [PATCH 2/2] fix(admin): don't flag lifetime deals as admin-granted subscriptions Lifetime-deal orgs have a subscription row but a null paymentId, so !paymentId?.startsWith('cus_') was true for them and the impersonation bar showed a Remove Subscription button that always fails server-side. Require a non-null paymentId, which only the admin-grant path sets. Co-Authored-By: Claude Fable 5 --- apps/backend/src/api/routes/users.controller.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index 8308de368c..799896ba91 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -124,11 +124,14 @@ export class UsersController { isLifetime: !!organization?.subscription?.isLifetime, // An admin-granted subscription stores the user id in `paymentId` rather // than a real Stripe `cus_...` customer; used to expose an admin-only - // remove action without risking a real paid subscription. + // remove action without risking a real paid subscription. Requires a + // non-null paymentId so lifetime deals (subscription, no paymentId) + // don't match. adminGrantedSubscription: // @ts-ignore !!organization?.subscription && - !organization?.paymentId?.startsWith('cus_'), + !!organization?.paymentId && + !organization.paymentId.startsWith('cus_'), admin: !!user.isSuperAdmin, impersonate: !!impersonate, isTrailing: !process.env.STRIPE_PUBLISHABLE_KEY