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
+7
View File
@@ -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()
@@ -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,
+28
View File
@@ -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,