feat(billing): rating engine, fechamento de periodo, dashboard platform (fase 22)

Fecha a orquestracao de Billing (agente.md secao 120-139) sobre o schema/
RatingEngine puro ja existentes: escritores do ledger UsageEvent
(CALL_SECONDS no CDR, ACTIVE_DAY via sweep diario), closeBillingPeriod/
reopenBillingPeriod (fechamento imutavel com audit trail), e os
controllers de price books/rate decks/plan versions/subscriptions/
periods/statements. Corrige 2 bugs reais de RLS achados no teste ponta a
ponta (reopen sem tenant context, subscriptions sem withTenantContext) e
adiciona teste unitario do RatingEngine (17 casos).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY
This commit is contained in:
2026-08-29 00:28:35 -03:00
parent d4e2513764
commit b27cfaab02
35 changed files with 4705 additions and 34 deletions

View File

@@ -0,0 +1,330 @@
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
import { getPrismaClient, withTenantContext, type Prisma, type PriceItemType } from "@b2bcall/database";
import {
resolvePriceBookItem,
rateCallFlatFallback,
rateGenericUsage,
rateActiveDaysProrated,
rateTranscriptionSeconds,
rateRecordingBytes,
type PriceBookItemLike,
} from "@b2bcall/billing";
import { recordAuditEvent } from "@b2bcall/auth";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** UsageMeter -> PriceItemType, pra métricas do tipo "N dias ativo" (agente.md
* secao 131) — preço mensal do item, prorateado por `rateActiveDaysProrated`. */
const ACTIVE_DAY_METER_TO_PRICE_TYPE: Record<string, PriceItemType> = {
EXTENSION_ACTIVE_DAY: "EXTENSION_MONTH",
AGENT_ACTIVE_DAY: "AGENT_MONTH",
TRUNK_ACTIVE_DAY: "TRUNK_MONTH",
};
/** AIUsageType -> PriceItemType (secao 124/131 — nomes não batem 1:1,
* "SECONDS"/"REQUEST"/plural de token na origem viram "MINUTE"/"CALL"/
* singular no catálogo de preço). */
const AI_USAGE_TYPE_TO_PRICE_TYPE: Record<string, PriceItemType> = {
AI_TRANSCRIPTION_SECONDS: "AI_TRANSCRIPTION_MINUTE",
AI_ANALYSIS_REQUEST: "AI_ANALYSIS_CALL",
AI_INPUT_TOKENS: "AI_INPUT_TOKEN",
AI_OUTPUT_TOKENS: "AI_OUTPUT_TOKEN",
};
/** PriceItemType -> BillingStatementCategory, pra agrupar `RatedUsageItem`s
* na linha do statement (agente.md secao 136). */
const PRICE_TYPE_TO_CATEGORY: Record<PriceItemType, string> = {
BASE_SUBSCRIPTION: "PLAN_BASE",
EXTENSION_MONTH: "EXTENSIONS",
AGENT_MONTH: "AGENTS",
TRUNK_MONTH: "TRUNKS",
CALL: "CALLS",
CALL_MINUTE: "MINUTES",
FIXED_MINUTE: "MINUTES",
MOBILE_MINUTE: "MINUTES",
INTERNATIONAL_MINUTE: "MINUTES",
AI_TRANSCRIPTION_MINUTE: "AI_TRANSCRIPTION",
AI_ANALYSIS_CALL: "AI_ANALYSIS",
AI_INPUT_TOKEN: "AI_TOKENS",
AI_OUTPUT_TOKEN: "AI_TOKENS",
RECORDING_GB_MONTH: "STORAGE",
};
const CATEGORY_LABEL: Record<string, string> = {
PLAN_BASE: "Assinatura do plano",
EXTENSIONS: "Ramais ativos",
AGENTS: "Agentes ativos",
TRUNKS: "Troncos ativos",
CALLS: "Chamadas",
MINUTES: "Minutos de chamada",
AI_TRANSCRIPTION: "Transcricao (IA)",
AI_ANALYSIS: "Analise de chamada (IA)",
AI_TOKENS: "Tokens (IA)",
STORAGE: "Armazenamento de gravacoes",
ADJUSTMENT: "Ajuste",
};
/**
* RatingEngine é matemática pura (packages/billing, sem I/O — ver
* comentário em rating-engine.ts); este service faz a orquestração real
* (agente.md secao 130-137): resolve catálogos vigentes, lê os 2 ledgers
* imutáveis (`UsageEvent`+`AIUsageRecord`), grava `RatedUsageItem` (1 por
* evento — nunca agrega antes de ratear, "immutable usage ledger" secao
* 233) e fecha em `BillingStatement`/`BillingStatementItem` (agregado por
* categoria, o que o tenant efetivamente vê).
*
* **Lacuna real, conhecida**: `Call.calledNumber` ainda não é populado
* pelo CDR (ver TODO.md PHASE 17) — não dá pra fazer o longest-prefix
* match do RateDeck (secao 129) por destino real. `CALL_SECONDS` sempre
* usa `rateCallFlatFallback` (PriceBookItem `CALL_MINUTE`) por enquanto;
* `RateDeck`/`longestPrefixMatch` ficam cadastráveis e testados
* isoladamente (packages/billing tem teste unitário), só não são
* exercitados ponta a ponta até essa lacuna fechar.
*/
export async function closeBillingPeriod(opts: {
tenantId: string;
periodStart: Date;
periodEnd: Date;
userId: string;
}): Promise<{ periodId: string; statementId: string; total: number }> {
const { tenantId, periodStart, periodEnd, userId } = opts;
if (periodStart >= periodEnd) {
throw new BadRequestException("periodStart deve ser anterior a periodEnd");
}
const daysInPeriod = (periodEnd.getTime() - periodStart.getTime()) / MS_PER_DAY;
const prisma = getPrismaClient();
const result = await withTenantContext(prisma, tenantId, async (tx) => {
const existing = await tx.billingPeriod.findUnique({
where: { tenantId_periodStart_periodEnd: { tenantId, periodStart, periodEnd } },
});
if (existing?.status === "CLOSED") {
throw new ConflictException("Periodo ja fechado (secao 137) — reabra explicitamente antes de recalcular");
}
if (existing?.status === "CALCULATING") {
throw new ConflictException("Fechamento ja em andamento para este periodo");
}
const period = existing
? await tx.billingPeriod.update({ where: { id: existing.id }, data: { status: "CALCULATING" } })
: await tx.billingPeriod.create({ data: { tenantId, periodStart, periodEnd, status: "CALCULATING" } });
const tenant = await tx.tenant.findUniqueOrThrow({ where: { id: tenantId } });
const priceBook = await tx.priceBook.findFirst({
where: tenant.priceBookId ? { id: tenant.priceBookId } : { isDefault: true },
include: { items: true },
});
if (!priceBook) {
throw new ConflictException("Nenhum PriceBook configurado (nem default) para tarifar este tenant");
}
const priceItems: PriceBookItemLike[] = priceBook.items;
const callMinuteItem = resolvePriceBookItem(priceItems, "CALL_MINUTE", periodEnd);
const [usageEvents, aiUsageRecords] = await Promise.all([
tx.usageEvent.findMany({
where: { tenantId, occurredAt: { gte: periodStart, lt: periodEnd }, ratedUsageItems: { none: {} } },
}),
tx.aIUsageRecord.findMany({
where: { tenantId, occurredAt: { gte: periodStart, lt: periodEnd }, ratedUsageItems: { none: {} } },
}),
]);
const ratedItemsData: Prisma.RatedUsageItemCreateManyInput[] = [];
for (const ev of usageEvents) {
if (ev.meter === "CALL_SECONDS") {
if (!callMinuteItem) continue; // sem preco/minuto configurado — nao cobra, nao inventa preco
const rated = rateCallFlatFallback(ev.quantity, callMinuteItem);
ratedItemsData.push({
tenantId,
usageEventId: ev.id,
callId: ev.callId,
priceBookItemId: callMinuteItem.id,
quantity: rated.ratedMinutes,
unitPrice: rated.destinationRate,
amount: rated.ratedAmount,
currency: priceBook.currency,
billingPeriodId: period.id,
});
continue;
}
const priceType = ACTIVE_DAY_METER_TO_PRICE_TYPE[ev.meter];
if (!priceType) continue; // RECORDING_BYTES e os meters de IA nao viram UsageEvent (ver cdr.ts / process-*.ts)
const item = resolvePriceBookItem(priceItems, priceType, periodEnd);
if (!item) continue;
const amount = rateActiveDaysProrated(ev.quantity, item.unitPrice, daysInPeriod);
ratedItemsData.push({
tenantId,
usageEventId: ev.id,
priceBookItemId: item.id,
quantity: ev.quantity,
unitPrice: daysInPeriod > 0 ? item.unitPrice / daysInPeriod : 0,
amount,
currency: priceBook.currency,
billingPeriodId: period.id,
});
}
for (const rec of aiUsageRecords) {
const priceType = AI_USAGE_TYPE_TO_PRICE_TYPE[rec.type];
const item = resolvePriceBookItem(priceItems, priceType, periodEnd);
if (!item) continue;
const amount =
rec.type === "AI_TRANSCRIPTION_SECONDS"
? rateTranscriptionSeconds(rec.quantity, item.unitPrice)
: rateGenericUsage(rec.quantity, item.unitPrice);
ratedItemsData.push({
tenantId,
aiUsageRecordId: rec.id,
priceBookItemId: item.id,
quantity: rec.quantity,
unitPrice: item.unitPrice,
amount,
currency: priceBook.currency,
billingPeriodId: period.id,
});
}
// RECORDING_BYTES (secao 131): sem ledger de eventos próprio (ver
// comentário em rating-engine.ts) — usa os bytes armazenados AGORA como
// proxy do consumo do período inteiro, decisão documentada em
// docs/BILLING.md.
const storageItem = resolvePriceBookItem(priceItems, "RECORDING_GB_MONTH", periodEnd);
if (storageItem) {
const recordingAgg = await tx.recording.aggregate({
where: { tenantId, status: "AVAILABLE" },
_sum: { sizeBytes: true },
});
const bytes = Number(recordingAgg._sum.sizeBytes ?? 0n);
if (bytes > 0) {
const amount = rateRecordingBytes(bytes, storageItem.unitPrice);
ratedItemsData.push({
tenantId,
priceBookItemId: storageItem.id,
quantity: bytes / 1_000_000_000,
unitPrice: storageItem.unitPrice,
amount,
currency: priceBook.currency,
billingPeriodId: period.id,
});
}
}
if (ratedItemsData.length > 0) {
await tx.ratedUsageItem.createMany({ data: ratedItemsData });
}
const ratedItems = await tx.ratedUsageItem.findMany({
where: { billingPeriodId: period.id },
include: { priceBookItem: true },
});
const categoryTotals = new Map<string, number>();
for (const item of ratedItems) {
const category = item.priceBookItem ? PRICE_TYPE_TO_CATEGORY[item.priceBookItem.type] : "ADJUSTMENT";
categoryTotals.set(category, (categoryTotals.get(category) ?? 0) + item.amount);
}
const subscription = await tx.tenantSubscription.findFirst({
where: { tenantId, status: { in: ["ACTIVE", "TRIALING"] } },
include: { planVersion: true },
orderBy: { startedAt: "desc" },
});
if (subscription && subscription.planVersion.basePrice > 0) {
categoryTotals.set("PLAN_BASE", (categoryTotals.get("PLAN_BASE") ?? 0) + subscription.planVersion.basePrice);
}
const subtotal = [...categoryTotals.values()].reduce((sum, v) => sum + v, 0);
const currency = subscription?.currency ?? priceBook.currency;
const statement = await tx.billingStatement.create({
data: {
tenantId,
billingPeriodId: period.id,
currency,
subtotal,
adjustments: 0,
total: subtotal,
items: {
create: [...categoryTotals.entries()].map(([category, amount]) => ({
tenantId,
category: category as never,
description: CATEGORY_LABEL[category] ?? category,
amount,
})),
},
},
});
await tx.billingPeriod.update({
where: { id: period.id },
data: { status: "CLOSED", closedAt: new Date() },
});
return { periodId: period.id, statementId: statement.id, total: statement.total, ratedCount: ratedItems.length };
});
await recordAuditEvent(prisma, {
action: "BILLING_PERIOD_CLOSE",
tenantId,
userId,
entityType: "billing_period",
entityId: result.periodId,
after: { total: result.total, ratedItemCount: result.ratedCount },
});
return result;
}
/**
* "Reabrir" um período fechado pra corrigir e recalcular (secao 137): nunca
* volta direto pra OPEN — vira REOPENED, com audit trail (user+motivo),
* deixando visível no histórico que esse período já foi fechado antes.
* `closeBillingPeriod` aceita rodar de novo em cima de um período
* REOPENED (só bloqueia CLOSED/CALCULATING).
*
* **Bug real, achado no teste desta fase**: a primeira versão lia o
* período com `prisma.billingPeriod.findUniqueOrThrow({ where: { id } })`
* SEM tenant context pra descobrir o `tenantId` — mas `billing_periods` tem
* FORCE ROW LEVEL SECURITY (agente.md secao 30), então a leitura sem
* `app.current_tenant_id` não vê a linha, e o "not found" virava 500 (P2025
* não mapeado, nunca um 404 de verdade). `tenantId` precisa vir explícito
* no request (mesma exceção já aplicada em `closeBillingPeriod`), nunca
* descoberto lendo a própria tabela protegida por RLS.
*/
export async function reopenBillingPeriod(opts: {
periodId: string;
tenantId: string;
userId: string;
reason: string;
}): Promise<void> {
const prisma = getPrismaClient();
const period = await withTenantContext(prisma, opts.tenantId, (tx) =>
tx.billingPeriod.findUnique({ where: { id: opts.periodId } }),
);
if (!period || period.tenantId !== opts.tenantId) {
throw new NotFoundException("Periodo de billing nao encontrado para este tenant");
}
if (period.status !== "CLOSED") {
throw new ConflictException("So' e' possivel reabrir um periodo CLOSED");
}
await withTenantContext(prisma, opts.tenantId, (tx) =>
tx.billingPeriod.update({
where: { id: period.id },
data: { status: "REOPENED", reopenedAt: new Date() },
}),
);
await recordAuditEvent(prisma, {
action: "BILLING_PERIOD_REOPEN",
tenantId: period.tenantId,
userId: opts.userId,
entityType: "billing_period",
entityId: period.id,
after: { reason: opts.reason },
});
}