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:
330
apps/api/src/billing/billing-engine.service.ts
Normal file
330
apps/api/src/billing/billing-engine.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user