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
130 lines
5.3 KiB
TypeScript
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;
|
|
}
|