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:
17
packages/billing/package.json
Normal file
17
packages/billing/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
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();
|
||||
11
packages/billing/src/index.ts
Normal file
11
packages/billing/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type { RateDeckEntryLike, PriceBookItemLike, CallRatingResult } from "./types";
|
||||
export {
|
||||
longestPrefixMatch,
|
||||
resolvePriceBookItem,
|
||||
rateCallByDestination,
|
||||
rateCallFlatFallback,
|
||||
rateGenericUsage,
|
||||
rateActiveDaysProrated,
|
||||
rateTranscriptionSeconds,
|
||||
rateRecordingBytes,
|
||||
} from "./rating-engine";
|
||||
129
packages/billing/src/rating-engine.ts
Normal file
129
packages/billing/src/rating-engine.ts
Normal 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;
|
||||
}
|
||||
34
packages/billing/src/types.ts
Normal file
34
packages/billing/src/types.ts
Normal 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;
|
||||
}
|
||||
8
packages/billing/tsconfig.json
Normal file
8
packages/billing/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user