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 {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

View File

@@ -0,0 +1,52 @@
import { Panel, PanelHeader } from "@/components/ui/panel";
import { EmptyState } from "@/components/ui/table";
import { formatBytes, formatDuration, formatInt } from "@/lib/format";
import type { TenantConsumo } from "@/lib/billing-types";
function Stat({ label, value }: { label: string; value: string | number }) {
return (
<div>
<span className="block text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="mt-1 block font-mono text-sm tabular-nums text-foreground">{value}</span>
</div>
);
}
export function ConsumoView({ consumo }: { consumo: TenantConsumo[] }) {
return (
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold text-foreground">Consumo</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Uso bruto de todos os tenants no mês corrente mesma agregação de Relatórios &gt; Consumo (tenant),
que aqui em todos de uma vez. Nunca em dinheiro (isso é Billing &gt; Relatórios).
</p>
</div>
{consumo.length === 0 ? (
<Panel>
<EmptyState title="Nenhum tenant" description="Nenhum tenant cadastrado ainda." />
</Panel>
) : (
consumo.map((c) => {
const recording = formatBytes(c.usage.recordingBytes);
return (
<Panel key={c.tenantId} className="p-5">
<PanelHeader title={c.legalName} />
<div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4 lg:grid-cols-8">
<Stat label="Chamadas" value={formatInt(c.usage.callCount)} />
<Stat label="Tempo em chamada" value={formatDuration(c.usage.callSeconds)} />
<Stat label="Ramais ativos/dia" value={c.usage.extensionActiveDays} />
<Stat label="Agentes ativos/dia" value={c.usage.agentActiveDays} />
<Stat label="Troncos ativos/dia" value={c.usage.trunkActiveDays} />
<Stat label="Gravações" value={`${recording.value}${recording.unit}`} />
<Stat label="Transcrição IA" value={formatDuration(c.aiUsage.transcriptionSeconds)} />
<Stat label="Tokens IA (in/out)" value={`${formatInt(c.aiUsage.inputTokens)} / ${formatInt(c.aiUsage.outputTokens)}`} />
</div>
</Panel>
);
})
)}
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import type { TenantConsumo } from "@/lib/billing-types";
import { ConsumoView } from "./consumo-view";
export default async function BillingConsumoPage() {
const session = await requireSession();
const consumo = await apiFetch<TenantConsumo[]>("/billing/consumo", session.accessToken);
return <ConsumoView consumo={consumo} />;
}

View File

@@ -0,0 +1,93 @@
import { CircleCheck, CircleX } from "lucide-react";
import { Panel, PanelHeader } from "@/components/ui/panel";
import { Pill } from "@/components/ui/pill";
import type { SystemConfig } from "@/lib/platform-types";
function FlagRow({ label, on, description }: { label: string; on: boolean; description: string }) {
return (
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4 last:border-0">
<div>
<p className="text-sm font-medium text-foreground">{label}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{description}</p>
</div>
{on ? (
<span className="flex shrink-0 items-center gap-1.5 text-status-green">
<CircleCheck className="h-4 w-4" aria-hidden /> Ligado
</span>
) : (
<span className="flex shrink-0 items-center gap-1.5 text-muted-foreground">
<CircleX className="h-4 w-4" aria-hidden /> Desligado
</span>
)}
</div>
);
}
export function ConfiguracoesView({ config }: { config: SystemConfig }) {
return (
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold text-foreground">Configurações do sistema</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Somente leitura estas flags vêm do <code className="font-mono text-xs">.env</code> compartilhado por
todos os serviços (agente.md secao 186). Mudar exige editar o arquivo e reiniciar o serviço, nunca um botão
aqui que fingiria aplicar na hora.
</p>
</div>
<Panel className="p-5">
<PanelHeader title="Discador — segurança de chamada real" />
<div className="mt-3 flex items-center gap-3">
<Pill tone={config.realOutboundCallsActive ? "accent" : "neutral"}>
{config.realOutboundCallsActive ? "Chamadas PSTN reais ATIVAS" : "Nenhuma chamada PSTN real é feita"}
</Pill>
</div>
<p className="mt-2 text-xs text-muted-foreground">
As duas flags abaixo precisam estar explicitamente na posição de risco ao mesmo tempo pra originar uma
chamada real nunca ativado por padrão.
</p>
</Panel>
<Panel>
<FlagRow
label="DIALER_SIMULATION"
on={config.dialerSimulation}
description="Ligado = resultados de chamada (atendida/ocupado/não atende) são sorteados em software, nenhuma chamada PSTN sai de verdade."
/>
<FlagRow
label="ALLOW_REAL_OUTBOUND_CALLS"
on={config.allowRealOutboundCalls}
description="Precisa estar ligado E DIALER_SIMULATION desligado pra originar uma chamada PSTN de verdade."
/>
</Panel>
<Panel>
<PanelHeader title="Infraestrutura" />
<FlagRow
label="Event Socket do FreeSWITCH (ESL) configurado"
on={config.eslConfigured}
description="Host e senha presentes no ambiente (não confirma alcançável — ver Infraestrutura > Saúde)."
/>
<div className="flex items-center justify-between gap-4 border-b border-border px-5 py-4 last:border-0">
<div>
<p className="text-sm font-medium text-foreground">Storage de gravações</p>
<p className="mt-0.5 text-xs text-muted-foreground">Onde os arquivos de gravação são salvos.</p>
</div>
<Pill>{config.storageProvider}</Pill>
</div>
<div className="flex items-center justify-between gap-4 border-b border-border px-5 py-4 last:border-0">
<div>
<p className="text-sm font-medium text-foreground">Ambiente (NODE_ENV)</p>
</div>
<Pill tone={config.nodeEnv === "production" ? "accent" : "neutral"}>{config.nodeEnv}</Pill>
</div>
{config.corsOrigin && (
<div className="flex items-center justify-between gap-4 px-5 py-4">
<p className="text-sm font-medium text-foreground">CORS_ORIGIN</p>
<span className="font-mono text-xs text-muted-foreground">{config.corsOrigin}</span>
</div>
)}
</Panel>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import type { SystemConfig } from "@/lib/platform-types";
import { ConfiguracoesView } from "./configuracoes-view";
export default async function SistemaConfiguracoesPage() {
const session = await requireSession();
const config = await apiFetch<SystemConfig>("/platform/system-config", session.accessToken);
return <ConfiguracoesView config={config} />;
}

View File

@@ -46,7 +46,11 @@ export const PLATFORM_NAV: NavSection[] = [
label: "Billing",
icon: CreditCard,
children: [
{ label: "Consumo" },
{
label: "Consumo",
href: "/platform/billing/consumo",
description: "Uso bruto de todos os tenants no mês corrente",
},
{
label: "Tarifas",
href: "/platform/billing/tarifas",
@@ -135,7 +139,11 @@ export const PLATFORM_NAV: NavSection[] = [
href: "/platform/sistema/auditoria",
description: "Log de eventos de todos os tenants",
},
{ label: "Configurações" },
{
label: "Configurações",
href: "/platform/sistema/configuracoes",
description: "Flags de segurança/infra do .env — somente leitura",
},
],
},
];

View File

@@ -93,6 +93,25 @@ export interface BillingStatement {
items?: BillingStatementItem[];
}
export interface TenantConsumo {
tenantId: string;
legalName: string;
usage: {
callCount: number;
callSeconds: number;
extensionActiveDays: number;
agentActiveDays: number;
trunkActiveDays: number;
recordingBytes: number;
};
aiUsage: {
transcriptionSeconds: number;
analysisRequests: number;
inputTokens: number;
outputTokens: number;
};
}
export interface PlanVersion {
id: string;
planId: string;

View File

@@ -101,6 +101,16 @@ export interface FreeswitchNodes {
gateways: unknown;
}
export interface SystemConfig {
nodeEnv: string;
dialerSimulation: boolean;
allowRealOutboundCalls: boolean;
realOutboundCallsActive: boolean;
storageProvider: string;
eslConfigured: boolean;
corsOrigin: string | null;
}
export interface TenantAiUsage {
tenantId: string;
legalName: string;