Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/backend/src/api/routes/billing.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions apps/backend/src/api/routes/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 11 additions & 9 deletions apps/frontend/src/components/billing/main.billing.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -545,15 +545,17 @@ export const MainBillingComponent: FC<{
'Update Payment Method / Invoices History'
)}
</Button>
{isGeneral && !subscription?.cancelAt && (
<Button
className="bg-red-500"
loading={loading}
onClick={moveToCheckout('FREE')}
>
{t('cancel_subscription_1', 'Cancel subscription')}
</Button>
)}
{isGeneral &&
!subscription?.cancelAt &&
!(user as any)?.adminGrantedSubscription && (
<Button
className="bg-red-500"
loading={loading}
onClick={moveToCheckout('FREE')}
>
{t('cancel_subscription_1', 'Cancel subscription')}
</Button>
)}
</div>
)}
{subscription?.cancelAt && isGeneral && (
Expand Down
53 changes: 46 additions & 7 deletions apps/frontend/src/components/layout/impersonate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Set<string>>(new Set());
const [refunding, setRefunding] = useState(false);
Expand Down Expand Up @@ -225,13 +226,15 @@ const ChargesModal: FC<{ close: () => void }> = ({ close }) => {
{t('refund_selected', 'Refund Selected')}
{selected.size > 0 && ` (${selected.size})`}
</Button>
<Button
onClick={handleCancel}
loading={cancelling}
className="!bg-red-700 rounded-[4px]"
>
{t('cancel_subscription', 'Cancel Subscription')}
</Button>
{!(user as any)?.adminGrantedSubscription && (
<Button
onClick={handleCancel}
loading={cancelling}
className="!bg-red-700 rounded-[4px]"
>
{t('cancel_subscription', 'Cancel Subscription')}
</Button>
)}
</div>
</div>
);
Expand Down Expand Up @@ -304,6 +307,39 @@ export const Subscription = () => {
</Select>
);
};

// 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 (
<div
className="px-[10px] rounded-[4px] bg-red-700 text-white cursor-pointer whitespace-nowrap"
onClick={remove}
>
{t('remove_subscription', 'Remove Subscription')}
</div>
);
};
const colorOptions = [
{ value: 'INFO', label: 'Info (Blue)', className: 'bg-blue-600' },
{ value: 'WARNING', label: 'Warning (Amber)', className: 'bg-amber-600' },
Expand Down Expand Up @@ -540,6 +576,9 @@ export const Impersonate = () => {
</div>
</div>
{user?.tier?.current === 'FREE' && <Subscription />}
{(user as any)?.adminGrantedSubscription && (
<RemoveSubscription />
)}
{billingEnabled && <ManageBilling />}
</div>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions libraries/nestjs-libraries/src/services/stripe.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading