Files
B2BCall-dialer/packages/billing/src/rating-engine.ts
Matheus b27cfaab02 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
2026-08-29 00:28:35 -03:00

130 lines
5.3 KiB
TypeScript

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;
}