feat(billing): cap active users on an owner subscription

Super admin can set a seat limit of 3, 10, 20, or a negotiated number so an owner cannot add users past that cap.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-02 18:33:51 +03:30
co-authored by Cursor
parent 06977d27ed
commit acb5d51f24
9 changed files with 220 additions and 2 deletions
+88 -2
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import type { BillingAccountDto, BillingPlanCode, StorageUsageDto } from '@mento/shared';
import type { BillingAccountDto, BillingPlanCode, OwnerSeatUsageDto, StorageUsageDto } from '@mento/shared';
import {
BILLING_PLAN_CODES,
BILLING_PLAN_LABELS,
BILLING_USER_CAPS,
isPermanentBillingPlan,
} from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
@@ -21,6 +22,10 @@ const emit = defineEmits<{
}>();
const usage = ref<StorageUsageDto | null>(null);
const seats = ref<OwnerSeatUsageDto | null>(null);
const customUsers = ref('');
const seatBusy = ref(false);
const seatError = ref('');
const quotaGb = ref('');
const storageBusy = ref(false);
const storageError = ref('');
@@ -100,8 +105,54 @@ function saveTypedQuota() {
void saveQuota(value);
}
async function loadSeats() {
seatError.value = '';
try {
const { data } = await platformApi.get<OwnerSeatUsageDto>(`/platform/users/${props.userId}/seats`);
seats.value = data;
customUsers.value =
data.maxUsers != null && !(BILLING_USER_CAPS as readonly number[]).includes(data.maxUsers)
? String(data.maxUsers)
: '';
} catch (err) {
seatError.value = errorText(err, 'تعداد کاربر خوانده نشد');
}
}
async function saveUserCap(maxUsers: number) {
seatBusy.value = true;
seatError.value = '';
try {
const { data } = await platformApi.patch<OwnerSeatUsageDto>(
`/platform/users/${props.userId}/billing/users`,
{ maxUsers },
);
seats.value = data;
customUsers.value =
data.maxUsers != null && !(BILLING_USER_CAPS as readonly number[]).includes(data.maxUsers)
? String(data.maxUsers)
: '';
} catch (err) {
seatError.value = errorText(err, 'سقف کاربر ذخیره نشد');
} finally {
seatBusy.value = false;
}
}
function saveCustomUsers() {
const value = Number(customUsers.value.trim());
if (!Number.isInteger(value) || value < 21) {
seatError.value = 'برای بالای ۲۰ کاربر، عدد توافقی را از ۲۱ به بالا وارد کنید';
return;
}
void saveUserCap(value);
}
onMounted(() => {
if (props.billing) void loadStorage();
if (props.billing) {
void loadStorage();
void loadSeats();
}
});
</script>
@@ -127,6 +178,41 @@ onMounted(() => {
{{ BILLING_PLAN_LABELS[code] }}
</button>
</div>
<div v-if="billing" class="storage">
<strong>تعداد کاربر</strong>
<p v-if="seats" class="muted">
فعال {{ toFaDigits(seats.usedUsers) }}
<template v-if="seats.maxUsers == null"> · سقف تعیین نشده</template>
<template v-else> از {{ toFaDigits(seats.maxUsers) }}</template>
</p>
<p v-else-if="!seatError" class="muted">در حال خواندن کاربران…</p>
<p v-if="seats?.maxUsers != null && seats.usedUsers > seats.maxUsers" class="error">
تعداد فعلی از سقف بیشتر است. تا کم شدن کاربران، افزودن کاربر جدید بسته است.
</p>
<div class="plan-row">
<button
v-for="cap in BILLING_USER_CAPS"
:key="cap"
class="btn"
:class="{ 'btn-ghost': seats?.maxUsers !== cap }"
type="button"
:disabled="seatBusy"
@click="saveUserCap(cap)"
>
تا {{ toFaDigits(cap) }} کاربر
</button>
</div>
<label class="quota">
بالای ۲۰، توافقی
<input v-model="customUsers" type="number" min="21" step="1" inputmode="numeric" />
</label>
<div class="plan-row">
<button class="btn" type="button" :disabled="seatBusy" @click="saveCustomUsers">
ذخیره سقف توافقی
</button>
</div>
<p v-if="seatError" class="error">{{ seatError }}</p>
</div>
<div v-if="billing" class="storage">
<strong>حجم فایل</strong>
<p v-if="usage" class="muted">