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,143 @@
/**
* Teste unitário do RatingEngine (agente.md secao 128-133) — matemática
* pura, sem I/O, sem banco. Roda com:
* pnpm --filter @b2bcall/billing run test
*/
import {
longestPrefixMatch,
resolvePriceBookItem,
rateCallByDestination,
rateCallFlatFallback,
rateGenericUsage,
rateActiveDaysProrated,
rateTranscriptionSeconds,
rateRecordingBytes,
} from "../rating-engine";
import type { RateDeckEntryLike, PriceBookItemLike } from "../types";
function assert(condition: boolean, message: string): void {
if (!condition) {
throw new Error(`FALHOU: ${message}`);
}
console.log(`OK: ${message}`);
}
function closeEnough(a: number, b: number, epsilon = 1e-9): boolean {
return Math.abs(a - b) < epsilon;
}
function entry(overrides: Partial<RateDeckEntryLike> = {}): RateDeckEntryLike {
return {
id: "entry-1",
prefix: "5511",
destinationName: "SP",
destinationType: "FIXED",
pricePerMinute: 0.1,
billingIncrementSeconds: 60,
minimumSeconds: 0,
connectionFee: 0,
validFrom: new Date("2020-01-01"),
validUntil: null,
...overrides,
};
}
function item(overrides: Partial<PriceBookItemLike> = {}): PriceBookItemLike {
return {
id: "item-1",
type: "CALL_MINUTE",
unitPrice: 0.1,
effectiveFrom: new Date("2020-01-01"),
effectiveUntil: null,
...overrides,
};
}
function main(): void {
// longestPrefixMatch: prefixo mais especifico vence.
{
const entries = [
entry({ id: "generic", prefix: "55" }),
entry({ id: "sp", prefix: "5511" }),
entry({ id: "sp-mobile", prefix: "551199" }),
];
const match = longestPrefixMatch(entries, "5511999998888", new Date("2025-01-01"));
assert(match?.id === "sp-mobile", "longestPrefixMatch escolhe o prefixo mais longo que bate");
}
// longestPrefixMatch: ignora entry fora de vigencia.
{
const entries = [
entry({ id: "expired", prefix: "5511", validFrom: new Date("2020-01-01"), validUntil: new Date("2024-01-01") }),
entry({ id: "current", prefix: "55", validFrom: new Date("2024-01-01"), validUntil: null }),
];
const match = longestPrefixMatch(entries, "5511999998888", new Date("2025-01-01"));
assert(match?.id === "current", "longestPrefixMatch ignora entry expirada mesmo com prefixo mais longo");
}
// longestPrefixMatch: sem match nenhum.
{
const match = longestPrefixMatch([entry({ prefix: "44" })], "5511999998888", new Date("2025-01-01"));
assert(match === null, "longestPrefixMatch retorna null sem nenhum prefixo batendo");
}
// resolvePriceBookItem: pega o effectiveFrom mais recente vigente.
{
const items = [
item({ id: "v1", unitPrice: 0.1, effectiveFrom: new Date("2020-01-01") }),
item({ id: "v2", unitPrice: 0.2, effectiveFrom: new Date("2024-01-01") }),
];
const resolved = resolvePriceBookItem(items, "CALL_MINUTE", new Date("2025-01-01"));
assert(resolved?.id === "v2" && resolved.unitPrice === 0.2, "resolvePriceBookItem pega o reajuste mais novo vigente");
}
// rateCallByDestination: piso + arredondamento pra cima + connection fee.
{
const result = rateCallByDestination(65, entry({ pricePerMinute: 0.5, billingIncrementSeconds: 60, minimumSeconds: 30, connectionFee: 0.1 }));
// 65s >= minimo 30, arredonda pra 120 (2 incrementos de 60), 2min * 0.5 + 0.1 = 1.1
assert(result.ratedMinutes === 2, "rateCallByDestination arredonda 65s pra 2 incrementos de 60s");
assert(closeEnough(result.ratedAmount, 1.1), "rateCallByDestination soma connection fee ao valor por minuto");
}
// rateCallByDestination: minimo aplicado quando a chamada e mais curta.
{
const result = rateCallByDestination(5, entry({ pricePerMinute: 0.6, billingIncrementSeconds: 30, minimumSeconds: 30, connectionFee: 0 }));
// piso de 30s mesmo com 5s reais, arredonda pra 30 (ja multiplo de 30)
assert(closeEnough(result.ratedMinutes, 0.5), "rateCallByDestination aplica o minimo mesmo pra chamada curtissima");
}
// rateCallFlatFallback: sempre arredonda pro minuto cheio, sem connection fee.
{
const result = rateCallFlatFallback(185, item({ unitPrice: 0.1 }));
assert(result.ratedMinutes === 4, "rateCallFlatFallback arredonda 185s pra 4 minutos cheios");
assert(closeEnough(result.ratedAmount, 0.4), "rateCallFlatFallback nao tem connection fee");
assert(result.matchedEntry === null, "rateCallFlatFallback nunca referencia um RateDeckEntry");
}
// rateGenericUsage: multiplicacao simples.
{
assert(closeEnough(rateGenericUsage(1500, 0.000001), 0.0015), "rateGenericUsage calcula tokens de entrada");
}
// rateActiveDaysProrated: recurso ativo o periodo inteiro paga o preco cheio.
{
assert(closeEnough(rateActiveDaysProrated(30, 15, 30), 15), "rateActiveDaysProrated: ativo o mes inteiro paga o preco cheio");
assert(closeEnough(rateActiveDaysProrated(15, 15, 30), 7.5), "rateActiveDaysProrated: ativo metade do periodo paga metade");
assert(rateActiveDaysProrated(10, 15, 0) === 0, "rateActiveDaysProrated: periodo de 0 dias nao divide por zero");
}
// rateTranscriptionSeconds: sempre arredonda pra cima.
{
assert(closeEnough(rateTranscriptionSeconds(42, 0.05), 0.05), "rateTranscriptionSeconds arredonda 42s pro minuto cheio");
assert(closeEnough(rateTranscriptionSeconds(61, 0.05), 0.1), "rateTranscriptionSeconds cobra 2 minutos por 61s");
}
// rateRecordingBytes: conversao simples bytes -> GB.
{
assert(closeEnough(rateRecordingBytes(2_000_000_000, 2), 4), "rateRecordingBytes converte 2GB corretamente");
}
console.log("\nTodos os testes do RatingEngine passaram.");
}
main();