feat(frontend): Billing > Consumo (platform) + Sistema > Configurações — zera "em breve"

GET /billing/consumo agrega o mesmo uso bruto de /reports/consumo (tenant), só que
em loop por todos os tenants — nunca dinheiro, só quantidade (dinheiro é Billing >
Relatórios, já existia).

GET /platform/system-config: "Sistema > Configurações" nunca teve escopo definido na
especificação. Decisão desta implementação: painel somente leitura das flags de
segurança/infra que já existem como variável de ambiente (DIALER_SIMULATION/
ALLOW_REAL_OUTBOUND_CALLS, ESL configurado, storage provider, NODE_ENV) — nunca
editável por aqui, mudar exige editar o .env e reiniciar o serviço. Nunca expõe
segredo nenhum.

Com isto, todo item dos menus Platform e Tenant tem uma tela real por trás — zero
"em breve" restando em nav-data.ts nos dois lados.

Testado ponta a ponta: /billing/consumo batendo com os mesmos números já vistos em
Relatórios > Consumo/Quotas, /platform/system-config confirmado mostrando o valor
real do .env desta VM.

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 09:01:10 -03:00
parent cc80310cb5
commit fcf334c7c0
14 changed files with 347 additions and 2 deletions

View File

@@ -0,0 +1,67 @@
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";
/**
* "Billing > Consumo" (agente.md secao 169) — a mesma agregação de
* `/reports/consumo` (tenant, mês corrente), só que em TODOS os tenants
* de uma vez. Nunca calcula valor em dinheiro (isso é o RatingEngine/
* BillingStatement — Billing > Relatórios), só a quantidade bruta dos 2
* ledgers imutáveis. Diferente de Clientes > Quotas: aqui é "quanto cada
* tenant consumiu", lá é "quanto sobra até o limite do plano".
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("billing/consumo")
export class BillingConsumoController {
@RequirePermission("billing.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 consumo 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 }, orderBy: { legalName: "asc" } });
return Promise.all(
tenants.map(async (tenant) => {
const [usageByMeter, aiUsageByType] = await withTenantContext(prisma, tenant.id, (tx) =>
Promise.all([
tx.usageEvent.groupBy({ by: ["meter"], where: { tenantId: tenant.id, occurredAt: { gte: monthStart } }, _sum: { quantity: true } }),
tx.aIUsageRecord.groupBy({ by: ["type"], where: { tenantId: tenant.id, occurredAt: { gte: monthStart } }, _sum: { quantity: true } }),
]),
);
const usage: Record<string, number> = {};
for (const row of usageByMeter) usage[row.meter] = row._sum.quantity ?? 0;
const aiUsage: Record<string, number> = {};
for (const row of aiUsageByType) aiUsage[row.type] = row._sum.quantity ?? 0;
return {
tenantId: tenant.id,
legalName: tenant.legalName,
usage: {
callCount: usage["CALL_COUNT"] ?? 0,
callSeconds: usage["CALL_SECONDS"] ?? 0,
extensionActiveDays: usage["EXTENSION_ACTIVE_DAY"] ?? 0,
agentActiveDays: usage["AGENT_ACTIVE_DAY"] ?? 0,
trunkActiveDays: usage["TRUNK_ACTIVE_DAY"] ?? 0,
recordingBytes: usage["RECORDING_BYTES"] ?? 0,
},
aiUsage: {
transcriptionSeconds: aiUsage["AI_TRANSCRIPTION_SECONDS"] ?? 0,
analysisRequests: aiUsage["AI_ANALYSIS_REQUEST"] ?? 0,
inputTokens: aiUsage["AI_INPUT_TOKENS"] ?? 0,
outputTokens: aiUsage["AI_OUTPUT_TOKENS"] ?? 0,
},
};
}),
);
}
}

View File

@@ -5,6 +5,7 @@ import { PlanVersionsController } from "./plan-versions.controller";
import { SubscriptionsController } from "./subscriptions.controller";
import { BillingPeriodsController } from "./billing-periods.controller";
import { BillingStatementsController } from "./billing-statements.controller";
import { BillingConsumoController } from "./billing-consumo.controller";
@Module({
controllers: [
@@ -14,6 +15,7 @@ import { BillingStatementsController } from "./billing-statements.controller";
SubscriptionsController,
BillingPeriodsController,
BillingStatementsController,
BillingConsumoController,
],
})
export class BillingModule {}

View File

@@ -0,0 +1,45 @@
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
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";
/**
* "Sistema > Configurações" (agente.md secao 169) — nunca teve escopo
* definido na especificação. Decisão desta implementação: painel
* read-only das flags de segurança/infra que já existem como variável de
* ambiente (`.env`, compartilhado por `apps/api` e os workers via
* systemd/docker-compose — ver `infrastructure/systemd/README.md` e
* `docker-compose.yml`), nunca editável por aqui — mudar exige editar o
* `.env` e reiniciar o serviço (documentado, não um botão de UI que
* fingiria aplicar na hora). Nunca expõe segredo nenhum (senha, chave,
* connection string) — só booleans/enums que já são público conhecimento
* de quem administra a infraestrutura.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("platform/system-config")
export class PlatformSystemConfigController {
@RequirePermission("tenants.view")
@Get()
async get(@CurrentUser() user: AccessTokenClaims) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver a configuracao do sistema");
}
const dialerSimulation = (process.env.DIALER_SIMULATION ?? "true") === "true";
const allowRealOutboundCalls = (process.env.ALLOW_REAL_OUTBOUND_CALLS ?? "false") === "true";
return {
nodeEnv: process.env.NODE_ENV ?? "development",
dialerSimulation,
allowRealOutboundCalls,
// As DUAS precisam estar explicitamente ligadas (secao 186) — nunca
// basta uma pra originar PSTN de verdade.
realOutboundCallsActive: !dialerSimulation && allowRealOutboundCalls,
storageProvider: process.env.STORAGE_PROVIDER ?? "local",
eslConfigured: Boolean(process.env.ESL_HOST) && Boolean(process.env.ESL_PASSWORD),
corsOrigin: process.env.CORS_ORIGIN ?? null,
};
}
}

View File

@@ -7,6 +7,7 @@ import { PlatformRolesController } from "./platform-roles.controller";
import { PlatformQuotasController } from "./platform-quotas.controller";
import { PlatformFreeswitchController } from "./platform-freeswitch.controller";
import { PlatformAiUsageController } from "./platform-ai-usage.controller";
import { PlatformSystemConfigController } from "./platform-system-config.controller";
@Module({
controllers: [
@@ -18,6 +19,7 @@ import { PlatformAiUsageController } from "./platform-ai-usage.controller";
PlatformQuotasController,
PlatformFreeswitchController,
PlatformAiUsageController,
PlatformSystemConfigController,
],
})
export class PlatformModule {}