From acb5d51f24c09291048f356423e673dc41e336c3 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Mon, 2 Nov 2026 18:33:51 +0330 Subject: [PATCH] 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 --- .../migration.sql | 1 + apps/api/prisma/schema.prisma | 2 + apps/api/src/billing/billing.service.ts | 62 +++++++++++++ apps/api/src/platform/dto.ts | 7 ++ apps/api/src/platform/platform.controller.ts | 11 +++ apps/api/src/platform/platform.service.ts | 28 ++++++ apps/api/src/users/users.service.ts | 7 ++ apps/web/src/views/adm/AdmBillingPanel.vue | 90 ++++++++++++++++++- packages/shared/src/index.ts | 14 +++ 9 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 apps/api/prisma/migrations/20261102183000_owner_user_cap/migration.sql diff --git a/apps/api/prisma/migrations/20261102183000_owner_user_cap/migration.sql b/apps/api/prisma/migrations/20261102183000_owner_user_cap/migration.sql new file mode 100644 index 0000000..c0def89 --- /dev/null +++ b/apps/api/prisma/migrations/20261102183000_owner_user_cap/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "BillingAccount" ADD COLUMN "maxUsers" INTEGER; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 6caf0bb..328d16d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -465,6 +465,8 @@ model BillingAccount { maxOrganizations Int @default(1) /// سقف حجم فایل همهٔ محیط‌های این مالک، به بایت. null = بدون سقف storageQuotaBytes BigInt? + /// سقف کاربر فعال همهٔ محیط‌های این مالک. null = هنوز تعیین نشده + maxUsers Int? /// رزرو برای درگاه/لندینگ بعدی externalPaymentRef String? notes String? diff --git a/apps/api/src/billing/billing.service.ts b/apps/api/src/billing/billing.service.ts index 61a8659..762cb69 100644 --- a/apps/api/src/billing/billing.service.ts +++ b/apps/api/src/billing/billing.service.ts @@ -11,6 +11,7 @@ import type { BillingAccountStatus, BillingPlanCode, OrgModuleKey, + OwnerSeatUsageDto, SubscriptionSummaryDto, } from '@mento/shared'; import { @@ -64,6 +65,7 @@ export class BillingService { startsAt: Date; expiresAt: Date; maxOrganizations: number; + maxUsers?: number | null; externalPaymentRef: string | null; notes: string | null; }): BillingAccountDto { @@ -72,11 +74,71 @@ export class BillingService { id: row.id, ownerUserId: row.ownerUserId, ...summary, + maxUsers: row.maxUsers ?? null, externalPaymentRef: row.externalPaymentRef, notes: row.notes, }; } + async seatUsage(ownerUserId: string): Promise { + const account = await this.getByOwnerUserId(ownerUserId); + const orgIds = await this.ownedOrganizationIds(ownerUserId); + const usedUsers = await this.countActiveUsers(orgIds); + return { usedUsers, maxUsers: account?.maxUsers ?? null }; + } + + async setMaxUsers(ownerUserId: string, maxUsers: number): Promise { + await this.prisma.billingAccount.update({ + where: { ownerUserId }, + data: { maxUsers }, + }); + return this.seatUsage(ownerUserId); + } + + /** کاربر جدید فقط وقتی سقف اشتراک جا دارد. کسی که از قبل در محیط‌های همین مالک فعال است صندلی تازه نمی‌گیرد. */ + async assertSeatAvailable(organizationId: string, userId: string | null): Promise { + const owner = await this.prisma.membership.findFirst({ + where: { organizationId, role: 'owner', isActive: true }, + orderBy: { createdAt: 'asc' }, + select: { userId: true }, + }); + if (!owner) return; + const account = await this.getByOwnerUserId(owner.userId); + if (!account || account.maxUsers == null) return; + const orgIds = await this.ownedOrganizationIds(owner.userId); + if (userId) { + const already = await this.prisma.membership.findFirst({ + where: { userId, organizationId: { in: orgIds }, isActive: true }, + select: { id: true }, + }); + if (already) return; + } + const used = await this.countActiveUsers(orgIds); + if (used >= account.maxUsers) { + throw new ForbiddenException( + `سقف کاربر این اشتراک ${account.maxUsers} نفر است. برای افزودن کاربر باید سقف اشتراک بیشتر شود.`, + ); + } + } + + private async ownedOrganizationIds(ownerUserId: string): Promise { + const rows = await this.prisma.membership.findMany({ + where: { userId: ownerUserId, role: 'owner', isActive: true }, + select: { organizationId: true }, + }); + return rows.map((row) => row.organizationId); + } + + private async countActiveUsers(organizationIds: string[]): Promise { + if (!organizationIds.length) return 0; + const rows = await this.prisma.membership.findMany({ + where: { organizationId: { in: organizationIds }, isActive: true }, + select: { userId: true }, + distinct: ['userId'], + }); + return rows.length; + } + async getByOwnerUserId(ownerUserId: string) { return this.prisma.billingAccount.findUnique({ where: { ownerUserId } }); } diff --git a/apps/api/src/platform/dto.ts b/apps/api/src/platform/dto.ts index 01e1bfc..9353191 100644 --- a/apps/api/src/platform/dto.ts +++ b/apps/api/src/platform/dto.ts @@ -148,6 +148,13 @@ export class ApplyBillingPlanDto { status?: 'active' | 'expired' | 'suspended'; } +export class SetUserCapDto { + @IsInt() + @Min(1) + @Max(100_000) + maxUsers!: number; +} + export class SetStorageQuotaDto { /** گیگابایت؛ null = بدون سقف */ @IsNumber() diff --git a/apps/api/src/platform/platform.controller.ts b/apps/api/src/platform/platform.controller.ts index 58a7af5..64f5baf 100644 --- a/apps/api/src/platform/platform.controller.ts +++ b/apps/api/src/platform/platform.controller.ts @@ -12,6 +12,7 @@ import { CurrentUser } from '../auth/current-user.decorator'; import { ApplyBillingPlanDto, SetStorageQuotaDto, + SetUserCapDto, CreatePlatformAdminDto, CreatePlatformOrganizationDto, ResetPlatformAdminPasswordDto, @@ -133,6 +134,16 @@ export class PlatformController { return this.platform.setStorageQuota(id, dto.quotaGb); } + @Get('users/:id/seats') + seatUsage(@Param('id') id: string) { + return this.platform.seatUsage(id); + } + + @Patch('users/:id/billing/users') + setUserCap(@Param('id') id: string, @Body() dto: SetUserCapDto) { + return this.platform.setUserCap(id, dto.maxUsers); + } + @Patch('organizations/:id/modules') updateOrganizationModules( @Param('id') id: string, diff --git a/apps/api/src/platform/platform.service.ts b/apps/api/src/platform/platform.service.ts index ca86c0f..4c92dd6 100644 --- a/apps/api/src/platform/platform.service.ts +++ b/apps/api/src/platform/platform.service.ts @@ -425,6 +425,30 @@ export class PlatformService { return this.storage.usageForOwner(userId, refresh); } + async seatUsage(userId: string) { + await this.requireBillingOwner(userId); + return this.billing.seatUsage(userId); + } + + async setUserCap(userId: string, maxUsers: number) { + await this.requireBillingOwner(userId); + return this.billing.setMaxUsers(userId, maxUsers); + } + + private async requireBillingOwner(userId: string) { + const owner = await this.prisma.membership.findFirst({ + where: { userId, role: 'owner', isActive: true }, + select: { id: true }, + }); + if (!owner) { + throw new BadRequestException('سقف کاربر فقط برای مالک محیط معنی دارد'); + } + const account = await this.billing.getByOwnerUserId(userId); + if (!account) { + throw new BadRequestException('اول برای این مالک یک پلن اعمال کنید'); + } + } + async setStorageQuota(userId: string, quotaGb: number | null) { await this.storageUsage(userId, false); return this.storage.setQuotaGb(userId, quotaGb); @@ -495,9 +519,13 @@ export class PlatformService { userId: true, organizationId: true, role: true, + isActive: true, }, }); if (!row) throw new NotFoundException('عضویت پیدا نشد'); + if (isActive && !row.isActive) { + await this.billing.assertSeatAvailable(row.organizationId, row.userId); + } if (!isActive && row.role === 'owner') { const activeOwners = await this.memberships.countActiveOwners( row.organizationId, diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 84a37cd..c453f2a 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -23,6 +23,7 @@ import { normalizeMobile } from '../auth/auth.service'; import { PrismaService } from '../prisma/prisma.service'; import { OrgRolesService } from '../org-roles/org-roles.service'; import { MembershipsService } from '../memberships/memberships.service'; +import { BillingService } from '../billing/billing.service'; import { LocalFileStorage } from '../storage/local-file.storage'; import * as path from 'path'; @@ -70,6 +71,7 @@ export class UsersService { private readonly orgRoles: OrgRolesService, private readonly memberships: MembershipsService, private readonly files: LocalFileStorage, + private readonly billing: BillingService, ) {} async list(organizationId: string): Promise { @@ -105,6 +107,7 @@ export class UsersService { return this.addExistingUser(organizationId, existingUser.id, resolved); } + await this.billing.assertSeatAvailable(organizationId, null); const password = dto.password?.trim() ?? ''; if (password.length < 8) { throw new BadRequestException( @@ -506,6 +509,9 @@ export class UsersService { throw new BadRequestException('حداقل یک مالک فعال لازم است'); } } + if (isActive && !target.isActive) { + await this.billing.assertSeatAvailable(organizationId, targetId); + } await this.prisma.membership.update({ where: { id: target.id }, data: { isActive }, @@ -533,6 +539,7 @@ export class UsersService { userId: string, resolved: { role: UserRole; orgRoleId: string }, ): Promise { + await this.billing.assertSeatAvailable(organizationId, userId); const existing = await this.prisma.membership.findFirst({ where: { userId, organizationId }, }); diff --git a/apps/web/src/views/adm/AdmBillingPanel.vue b/apps/web/src/views/adm/AdmBillingPanel.vue index db52c8d..e94add1 100644 --- a/apps/web/src/views/adm/AdmBillingPanel.vue +++ b/apps/web/src/views/adm/AdmBillingPanel.vue @@ -1,10 +1,11 @@ @@ -127,6 +178,41 @@ onMounted(() => { {{ BILLING_PLAN_LABELS[code] }} +
+ تعداد کاربر +

+ فعال {{ toFaDigits(seats.usedUsers) }} + + +

+

در حال خواندن کاربران…

+

+ تعداد فعلی از سقف بیشتر است. تا کم شدن کاربران، افزودن کاربر جدید بسته است. +

+
+ +
+ +
+ +
+

{{ seatError }}

+
حجم فایل

diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9938afd..14ca4a8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -351,6 +351,18 @@ export interface SetStorageQuotaRequest { quotaGb: number | null; } +/** سقف‌های آمادهٔ تعداد کاربر. بالاتر از ۲۰ توافقی و با عدد جدا ذخیره می‌شود. */ +export const BILLING_USER_CAPS = [3, 10, 20] as const; + +export interface OwnerSeatUsageDto { + usedUsers: number; + maxUsers: number | null; +} + +export interface SetUserCapRequest { + maxUsers: number; +} + export interface SubscriptionSummaryDto { planCode: BillingPlanCode; status: BillingAccountStatus; @@ -369,6 +381,8 @@ export interface BillingAccountDto { startsAt: string; expiresAt: string; maxOrganizations: number; + /** null یعنی سقف کاربر هنوز تعیین نشده */ + maxUsers?: number | null; externalPaymentRef?: string | null; notes?: string | null; daysRemaining: number;