>(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) {