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:
143
packages/billing/src/__tests__/rating-engine.test.ts
Normal file
143
packages/billing/src/__tests__/rating-engine.test.ts
Normal 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();
|
||||
Reference in New Issue
Block a user