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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user