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,17 @@
{
"name": "@b2bcall/billing",
"version": "0.0.1",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"test": "tsx src/__tests__/rating-engine.test.ts"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^22.20.1",
"tsx": "^4.23.12",
"typescript": "^5.7.0"
}
}

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();

View File

@@ -0,0 +1,11 @@
export type { RateDeckEntryLike, PriceBookItemLike, CallRatingResult } from "./types";
export {
longestPrefixMatch,
resolvePriceBookItem,
rateCallByDestination,
rateCallFlatFallback,
rateGenericUsage,
rateActiveDaysProrated,
rateTranscriptionSeconds,
rateRecordingBytes,
} from "./rating-engine";

View File

@@ -0,0 +1,129 @@
import type { RateDeckEntryLike, PriceBookItemLike, CallRatingResult } from "./types";
function isValidAt(validFrom: Date, validUntil: Date | null, at: Date): boolean {
return validFrom <= at && (validUntil === null || at < validUntil);
}
/**
* Longest prefix matching (agente.md secao 129) — entre as entries do rate
* deck válidas em `at`, retorna a de prefixo mais longo que `calledNumber`
* começa com. Empate em tamanho de prefixo: indefinido qual vence (não
* deveria acontecer com um rate deck bem configurado — dois prefixos
* idênticos vigentes ao mesmo tempo é erro de cadastro, não algo pro
* engine resolver silenciosamente).
*/
export function longestPrefixMatch(
entries: RateDeckEntryLike[],
calledNumber: string,
at: Date,
): RateDeckEntryLike | null {
let best: RateDeckEntryLike | null = null;
for (const entry of entries) {
if (!isValidAt(entry.validFrom, entry.validUntil, at)) continue;
if (!calledNumber.startsWith(entry.prefix)) continue;
if (!best || entry.prefix.length > best.prefix.length) best = entry;
}
return best;
}
/**
* Item de price book vigente em `at` pra um `type` (agente.md secao 128).
* Se mais de um item do mesmo tipo estiver vigente ao mesmo tempo (não
* deveria, mas não é validado na escrita), pega o de `effectiveFrom` mais
* recente — o reajuste mais novo vence.
*/
export function resolvePriceBookItem(
items: PriceBookItemLike[],
type: string,
at: Date,
): PriceBookItemLike | null {
let best: PriceBookItemLike | null = null;
for (const item of items) {
if (item.type !== type) continue;
if (!isValidAt(item.effectiveFrom, item.effectiveUntil, at)) continue;
if (!best || item.effectiveFrom > best.effectiveFrom) best = item;
}
return best;
}
/**
* Chamada faturável (agente.md secao 133): aplica minimum_seconds (piso),
* arredonda PRA CIMA pro próximo múltiplo de billing_increment_seconds
* (nunca arredonda pra baixo — telecom sempre cobra o incremento cheio
* iniciado), converte pra minutos fracionários e calcula o valor
* (minutos * preço/minuto + taxa de conexão fixa).
*/
export function rateCallByDestination(
billableSeconds: number,
entry: RateDeckEntryLike,
): CallRatingResult {
const flooredSeconds = Math.max(billableSeconds, entry.minimumSeconds);
const increment = entry.billingIncrementSeconds > 0 ? entry.billingIncrementSeconds : 1;
const roundedSeconds = Math.ceil(flooredSeconds / increment) * increment;
const ratedMinutes = roundedSeconds / 60;
const ratedAmount = ratedMinutes * entry.pricePerMinute + entry.connectionFee;
return {
matchedEntry: entry,
billingIncrementSeconds: entry.billingIncrementSeconds,
ratedMinutes,
destinationRate: entry.pricePerMinute,
ratedAmount,
};
}
/**
* Fallback quando nenhum prefixo do rate deck bate (secao 129 não define
* o que fazer nesse caso — decisão desta implementação: usa o
* PriceBookItem(type=CALL_MINUTE) como tarifa plana genérica, sem
* connection fee nem mínimo/incremento próprios — só arredonda pro
* minuto cheio pra cima, a granularidade mais grosseira e mais segura
* (nunca cobra a menos por falta de config).
*/
export function rateCallFlatFallback(billableSeconds: number, callMinuteItem: PriceBookItemLike): CallRatingResult {
const ratedMinutes = Math.ceil(billableSeconds / 60);
const ratedAmount = ratedMinutes * callMinuteItem.unitPrice;
return {
matchedEntry: null,
billingIncrementSeconds: 60,
ratedMinutes,
destinationRate: callMinuteItem.unitPrice,
ratedAmount,
};
}
/** Uso genérico já na mesma unidade do price book item (tokens de IA,
* AI_ANALYSIS_CALL por request, etc.) — sempre quantidade * preço
* unitário, nunca calculado ad hoc em outro lugar do código (agente.md
* secao 130: "Nunca calcular billing no frontend", e por extensão, nunca
* fora deste módulo). */
export function rateGenericUsage(quantity: number, unitPrice: number): number {
return quantity * unitPrice;
}
/** EXTENSION_ACTIVE_DAY/AGENT_ACTIVE_DAY/TRUNK_ACTIVE_DAY → preço mensal
* (EXTENSION_MONTH/AGENT_MONTH/TRUNK_MONTH) prorateado pelos dias do
* período de billing — um recurso ativo o período inteiro paga o preço
* cheio, ativo metade do período paga metade. */
export function rateActiveDaysProrated(activeDays: number, monthlyPrice: number, daysInPeriod: number): number {
if (daysInPeriod <= 0) return 0;
return activeDays * (monthlyPrice / daysInPeriod);
}
/** AI_TRANSCRIPTION_SECONDS → AI_TRANSCRIPTION_MINUTE: arredonda PRA CIMA
* pro minuto cheio (mesma convenção de `rateCallByDestination` — nunca
* cobra a menos por fração de minuto). */
export function rateTranscriptionSeconds(seconds: number, pricePerMinute: number): number {
return Math.ceil(seconds / 60) * pricePerMinute;
}
/** RECORDING_BYTES → RECORDING_GB_MONTH. Simplificação conhecida: usa os
* bytes armazenados no momento do fechamento do período como proxy do
* consumo do mês inteiro (não faz média ponderada por dia armazenado) —
* documentado em docs/BILLING.md, aceitável nesta fase por não haver
* ainda um histórico de tamanho por dia pra fazer a média de verdade. */
export function rateRecordingBytes(bytes: number, pricePerGbMonth: number): number {
const gigabytes = bytes / 1_000_000_000;
return gigabytes * pricePerGbMonth;
}

View File

@@ -0,0 +1,34 @@
/**
* Tipos "-Like" em vez de importar os tipos gerados do Prisma
* (`@b2bcall/database`) — o RatingEngine (agente.md secao 130) é
* matemática pura, sem I/O e sem depender do ORM. Quem chama (apps/api,
* um futuro worker de billing) passa as linhas já buscadas do banco.
*/
export interface RateDeckEntryLike {
id: string;
prefix: string;
destinationName: string;
destinationType: string;
pricePerMinute: number;
billingIncrementSeconds: number;
minimumSeconds: number;
connectionFee: number;
validFrom: Date;
validUntil: Date | null;
}
export interface PriceBookItemLike {
id: string;
type: string;
unitPrice: number;
effectiveFrom: Date;
effectiveUntil: Date | null;
}
export interface CallRatingResult {
matchedEntry: RateDeckEntryLike | null;
billingIncrementSeconds: number;
ratedMinutes: number;
destinationRate: number;
ratedAmount: number;
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}