feat(frontend): Platform > Clientes > Assinaturas/Quotas + achado real no dashboard

GET /billing/subscriptions e POST /billing/plan-versions já existiam desde a fase
Billing (PHASE 22) sem tela nenhuma — Assinaturas agora deixa escolher um tenant, ver
o histórico de versões de preço assinadas, e criar uma nova assinatura reaproveitando
uma versão existente ou versionando um preço novo na mesma ação (preço nunca é
sobrescrito, sempre uma linha nova).

GET /platform/quotas (novo) agrega uso vs. limite do plano em todos os tenants de
uma vez (ramais/agentes/troncos/filas/campanhas/chamadas-mês/armazenamento), com a
tela destacando quem está em 80%+ (amarelo) ou 100%+ (vermelho) do limite.

Achado real ao revisar o dashboard "Visão Geral" antes de escrever a agregação
cross-tenant de Quotas: aiUsageThisMonth/recordingStorageBytes sempre devolviam
zero/vazio, porque a query rodava direto no Prisma sem nenhum app.current_tenant_id
setado — ai_usage_records/recordings têm FORCE RLS, então a policy nega a leitura
silenciosamente (0 linhas, sem erro), não importa quanto uso real existisse. Mesma
classe de bug já corrigida 2x antes nesta sessão; corrigido com o mesmo padrão (loop
withTenantContext por tenant). Confirmado inserindo um AIUsageRecord de teste no
Postgres, vendo o número aparecer, e removendo o teste depois.

Testado ponta a ponta: fluxo completo de criar assinatura via UI pro tenant Beta
Corp (nova versão de preço + assinatura, confirmado na tela e no banco), Quotas
mostrando os números reais dos dois tenants de teste.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-30 08:32:53 -03:00
parent de0b632fc9
commit b1a409da09
17 changed files with 626 additions and 31 deletions

View File

@@ -1,5 +1,5 @@
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
import { getPrismaClient } from "@b2bcall/database";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
@@ -28,32 +28,46 @@ export class PlatformOverviewController {
todayStart.setUTCHours(0, 0, 0, 0);
const monthStart = new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1));
const [
tenantsActive,
tenantsTotal,
extensionsTotal,
agentsTotal,
callsCurrent,
callsToday,
cpsCapacity,
aiUsageThisMonth,
recordingBytesAgg,
] = await Promise.all([
prisma.tenant.count({ where: { status: "ACTIVE", deletedAt: null } }),
prisma.tenant.count({ where: { deletedAt: null } }),
prisma.extension.count({ where: { deletedAt: null } }),
prisma.agent.count({ where: { deletedAt: null } }),
prisma.call.count({ where: { endAt: null } }),
prisma.call.count({ where: { createdAt: { gte: todayStart } } }),
prisma.plan.aggregate({ _sum: { maxCps: true } }),
prisma.aIUsageRecord.groupBy({
by: ["type"],
where: { occurredAt: { gte: monthStart } },
_sum: { quantity: true },
}),
prisma.recording.aggregate({ _sum: { sizeBytes: true } }),
const [tenantsActive, tenantsTotal, extensionsTotal, agentsTotal, callsCurrent, callsToday, cpsCapacity] =
await Promise.all([
prisma.tenant.count({ where: { status: "ACTIVE", deletedAt: null } }),
prisma.tenant.count({ where: { deletedAt: null } }),
prisma.extension.count({ where: { deletedAt: null } }),
prisma.agent.count({ where: { deletedAt: null } }),
prisma.call.count({ where: { endAt: null } }),
prisma.call.count({ where: { createdAt: { gte: todayStart } } }),
prisma.plan.aggregate({ _sum: { maxCps: true } }),
]);
// `ai_usage_records`/`recordings` têm FORCE RLS (secao 32) — as duas
// agregações acima (achado real, corrigido aqui) rodavam direto no
// Prisma sem `app.current_tenant_id` nenhum setado, então SEMPRE
// devolviam 0 linhas (zero silencioso, sem erro nenhum), nunca o
// número real, não importa quanto uso existisse nos tenants. Mesma
// classe de bug já corrigida em `TenantsController.list` — só um
// loop `withTenantContext` por tenant enxerga as linhas de verdade.
const activeTenantIds = await prisma.tenant.findMany({ where: { deletedAt: null }, select: { id: true } });
const [aiUsagePerTenant, recordingBytesPerTenant] = await Promise.all([
Promise.all(
activeTenantIds.map((t) =>
withTenantContext(prisma, t.id, (tx) =>
tx.aIUsageRecord.groupBy({ by: ["type"], where: { tenantId: t.id, occurredAt: { gte: monthStart } }, _sum: { quantity: true } }),
),
),
),
Promise.all(
activeTenantIds.map((t) =>
withTenantContext(prisma, t.id, (tx) => tx.recording.aggregate({ where: { tenantId: t.id }, _sum: { sizeBytes: true } })),
),
),
]);
const aiUsageThisMonth: Record<string, number> = {};
for (const rows of aiUsagePerTenant) {
for (const row of rows) aiUsageThisMonth[row.type] = (aiUsageThisMonth[row.type] ?? 0) + (row._sum.quantity ?? 0);
}
const recordingStorageBytes = recordingBytesPerTenant.reduce((sum, agg) => sum + Number(agg._sum.sizeBytes ?? 0n), 0);
return {
tenantsActive,
tenantsTotal,
@@ -70,10 +84,8 @@ export class PlatformOverviewController {
// token bucket do Redis do predictive-dialer, apps/api nao le de
// la ainda).
cpsCapacityConfigured: cpsCapacity._sum.maxCps ?? null,
aiUsageThisMonth: Object.fromEntries(
aiUsageThisMonth.map((row) => [row.type, row._sum.quantity ?? 0]),
),
recordingStorageBytes: Number(recordingBytesAgg._sum.sizeBytes ?? 0n),
aiUsageThisMonth,
recordingStorageBytes,
// Precisa de BillingPeriod/BillingStatement fechados de verdade
// (fase Billing, em construcao) — nenhum periodo foi fechado ainda
// nesta lab, entao nao ha numero real pra mostrar. null e' honesto,

View File

@@ -0,0 +1,73 @@
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
/**
* "Clientes > Quotas" (agente.md secao 169) — uso vs. limite do plano em
* TODOS os tenants, pra platform admin achar quem está perto de estourar
* sem precisar abrir um por um. Mesmo cálculo de `/reports/consumo`
* (tenant), só que looping por tenant (RLS, secao 32) em vez de um só.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("platform/quotas")
export class PlatformQuotasController {
@RequirePermission("tenants.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims): Promise<Record<string, unknown>[]> {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver quotas de todos os tenants");
}
const prisma = getPrismaClient();
const now = new Date();
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const tenants = await prisma.tenant.findMany({
where: { deletedAt: null },
include: { plan: true },
orderBy: { legalName: "asc" },
});
return Promise.all(
tenants.map(async (tenant) => {
const [callCountAgg, recordingAgg, extensionCount, agentCount, trunkCount, queueCount, campaignCount] =
await withTenantContext(prisma, tenant.id, (tx) =>
Promise.all([
tx.usageEvent.aggregate({ where: { tenantId: tenant.id, meter: "CALL_COUNT", occurredAt: { gte: monthStart } }, _sum: { quantity: true } }),
tx.recording.aggregate({ where: { tenantId: tenant.id }, _sum: { sizeBytes: true } }),
tx.extension.count({ where: { tenantId: tenant.id, deletedAt: null } }),
tx.agent.count({ where: { tenantId: tenant.id, deletedAt: null } }),
tx.trunk.count({ where: { tenantId: tenant.id, deletedAt: null } }),
tx.queue.count({ where: { tenantId: tenant.id, deletedAt: null } }),
tx.campaign.count({ where: { tenantId: tenant.id, deletedAt: null } }),
]),
);
const callCount = callCountAgg._sum.quantity ?? 0;
const recordingBytes = Number(recordingAgg._sum.sizeBytes ?? 0n);
const recordingGb = recordingBytes / 1024 ** 3;
const ratio = (used: number, max: number | null) => (max ? used / max : null);
return {
tenantId: tenant.id,
legalName: tenant.legalName,
planName: tenant.plan.name,
status: tenant.status,
items: [
{ key: "extensions", label: "Ramais", used: extensionCount, max: tenant.plan.maxExtensions, ratio: ratio(extensionCount, tenant.plan.maxExtensions) },
{ key: "agents", label: "Agentes", used: agentCount, max: tenant.plan.maxAgents, ratio: ratio(agentCount, tenant.plan.maxAgents) },
{ key: "trunks", label: "Troncos", used: trunkCount, max: tenant.plan.maxTrunks, ratio: ratio(trunkCount, tenant.plan.maxTrunks) },
{ key: "queues", label: "Filas", used: queueCount, max: tenant.plan.maxQueues, ratio: ratio(queueCount, tenant.plan.maxQueues) },
{ key: "campaigns", label: "Campanhas", used: campaignCount, max: tenant.plan.maxCampaigns, ratio: ratio(campaignCount, tenant.plan.maxCampaigns) },
{ key: "monthlyCalls", label: "Chamadas/mês", used: callCount, max: tenant.plan.maxMonthlyCalls, ratio: ratio(callCount, tenant.plan.maxMonthlyCalls) },
{ key: "recordingGb", label: "Armazenamento (GB)", used: Number(recordingGb.toFixed(3)), max: tenant.plan.maxRecordingStorageGb, ratio: ratio(recordingGb, tenant.plan.maxRecordingStorageGb) },
],
};
}),
);
}
}

View File

@@ -4,6 +4,7 @@ import { PlatformUsersController } from "./platform-users.controller";
import { PlatformAuditController } from "./platform-audit.controller";
import { PlatformHealthController } from "./platform-health.controller";
import { PlatformRolesController } from "./platform-roles.controller";
import { PlatformQuotasController } from "./platform-quotas.controller";
@Module({
controllers: [
@@ -12,6 +13,7 @@ import { PlatformRolesController } from "./platform-roles.controller";
PlatformAuditController,
PlatformHealthController,
PlatformRolesController,
PlatformQuotasController,
],
})
export class PlatformModule {}