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..799896ba91 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -122,6 +122,16 @@ 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. Requires a + // non-null paymentId so lifetime deals (subscription, no paymentId) + // don't match. + adminGrantedSubscription: + // @ts-ignore + !!organization?.subscription && + !!organization?.paymentId && + !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) {