Files
B2BCall-dialer/docs/BILLING.md
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

8.9 KiB

Billing (agente.md secao 120-139)

"Criar billing desde o início. Não tratar cobrança como relatório calculado posteriormente de maneira improvisada" (secao 125). Fecha a PHASE 22 (Usage Metering + Billing) junto com a IA usage metering que já vinha desde a PHASE 20/21 (AIUsageRecord).

Modelo de dados

Catálogos GLOBAIS da plataforma (sem tenant_id, mesmo padrão de Plan), gerenciados só por platform admin:

  • PriceBook/PriceBookItem (secao 128) — preço unitário por PriceItemType, versionado por effectiveFrom/effectiveUntil.
  • RateDeck/RateDeckEntry (secao 129) — tarifa por prefixo de destino (longest-prefix match), pricePerMinute/billingIncrementSeconds/ minimumSeconds/connectionFee.
  • PlanVersion (secao 126) — só o preço base da assinatura é versionado; os limites (max_extensions etc.) continuam em Plan direto, sem versionamento próprio (mudam raramente nesta fase do produto — simplificação conhecida).

Tenant.priceBookId/rateDeckId (null = usa o que tiver isDefault=true, mesma convenção de Queue.aiPrivacyLevel) escolhem qual catálogo se aplica a cada tenant.

Tenant-scoped, com RLS:

  • TenantSubscription (secao 127) — qual PlanVersion o tenant assinou e billingCycleAnchor (dia do mês, 1-28). Histórico: nunca UPDATE no preço de uma assinatura ativa, sempre uma nova linha.
  • UsageEvent (secao 131) — ledger imutável de uso bruto (só INSERT, nunca UPDATE/DELETE, mesma disciplina de AIUsageRecord desde a PHASE 20). Meters: CALL_SECONDS, EXTENSION_ACTIVE_DAY, AGENT_ACTIVE_DAY, TRUNK_ACTIVE_DAY. Os 4 meters de IA do enum (AI_TRANSCRIPTION_SECONDS etc.) existem só pra bater com a especificação — quem escreve esse uso na prática é AIUsageRecord (ledger próprio, criado antes da fase Billing existir); o RatingEngine lê os dois ledgers, nunca duplica.
  • RatedUsageItem — 1 linha por evento tarifado (usageEventId OU aiUsageRecordId, nunca os dois), referenciando o PriceBookItem/ RateDeckEntry usado. Granularidade fina de propósito (secao 233: "immutable usage ledger") — a agregação por categoria só acontece no BillingStatementItem.
  • BillingPeriod (secao 134, 137) — OPEN → CALCULATING → CLOSED. Fechamento imutável: fechar de novo um CLOSED é 409, só POST /billing/periods/:id/reopen (audit trail com motivo) volta pra REOPENED, e só a partir daí um novo close roda de novo.
  • BillingStatement/BillingStatementItem (secao 138-139) — o que o tenant vê (GET /billing/statements), agregado por BillingStatementCategory. Nunca chamado de "invoice"/"nota fiscal" na UI (PRODUCT.md).

Orquestração (apps/api/src/billing/billing-engine.service.ts)

packages/billing (RatingEngine) é matemática pura, sem I/O — recebe linhas já buscadas do banco (*Like interfaces, não os tipos do Prisma) e devolve valores calculados: longestPrefixMatch, resolvePriceBookItem (vigência por effectiveFrom/effectiveUntil), rateCallByDestination, rateCallFlatFallback, rateGenericUsage, rateActiveDaysProrated, rateTranscriptionSeconds, rateRecordingBytes. Tem teste unitário próprio dessas funções.

closeBillingPeriod(tenantId, periodStart, periodEnd, userId) é quem faz I/O: resolve o PriceBook vigente do tenant (ou o default), busca UsageEvent/AIUsageRecord do período ainda sem RatedUsageItem (ratedUsageItems: { none: {} }), tarifa cada um com o RatingEngine, grava os RatedUsageItems, soma PLAN_BASE da TenantSubscription ativa, agrega por categoria em BillingStatementItem, fecha o BillingPeriod. Tudo dentro de um único withTenantContext (atômico).

RECORDING_BYTES não tem UsageEvent próprio (ver comentário no schema) — usa Recording.sizeBytes somado NO MOMENTO do fechamento como proxy do consumo do período inteiro (não faz média ponderada por dia armazenado). Documentado aqui porque é a maior liberdade tomada na implementação: correto o bastante pra fechar o período, mas superfatura um tenant que reduziu MUITO o volume de gravações no meio do período e subfatura o oposto.

Escritores do ledger UsageEvent

  • CALL_SECONDS: apps/freeswitch-events/src/cdr.ts::finalizeCall, junto com o cálculo de billableSeconds (mesma transação do CDR) — só grava se billableSeconds > 0 (chamada que nunca bridgeou não gera evento).
  • EXTENSION_ACTIVE_DAY/AGENT_ACTIVE_DAY/TRUNK_ACTIVE_DAY: apps/api/src/billing/active-day-sweep.ts::runActiveDaySweep, boot + de hora em hora (mesmo padrão de runRetentionSweep). 1 evento por recurso ativo por dia — idempotente dentro do mesmo dia (checa existência antes de inserir; sem constraint única no banco pra isso, limitação conhecida documentada no próprio arquivo).

Lacuna real, conhecida: CALL_SECONDS sempre usa o fallback plano

Call.calledNumber ainda não é populado pelo CDR (PHASE 17, TODO.md) — não dá pra fazer o longest-prefix match do RateDeck (secao 129) por destino real. Por isso closeBillingPeriod sempre chama rateCallFlatFallback (contra PriceBookItem tipo CALL_MINUTE), nunca rateCallByDestination/longestPrefixMatch contra um RateDeck. RateDeck/RateDeckEntry ficam cadastráveis via API e testados isoladamente (unit test do RatingEngine), mas não exercitados ponta a ponta em closeBillingPeriod até essa lacuna do CDR fechar.

Permissions

pricing.manage (PriceBook/RateDeck/PlanVersion) e billing.manage (TenantSubscription, fechar/reabrir período) são ações de platform admin sobre um tenant arbitrário — tenantId vem explícito no body (mesma exceção já usada em GLOBAL de AIProvider/AIPromptTemplate), e isPlatformUser é checado explicitamente na camada de serviço, nunca só confiado na permission (o seed de RBAC dá billing.manage/billing.view também pro tenant_admin, mas essas rotas continuam platform-only via isPlatformUser — revisar se um dia existir uma ação de billing que o próprio tenant deva poder fazer). billing.view é do tenant, sempre escopado ao próprio JWT.

O que foi testado de verdade

Ponta a ponta contra a API real (apps/api no host) e Postgres real com RLS: tenant + PriceBook (9 items) + PlanVersion (basePrice=99) + TenantSubscription criados; 3 UsageEvent(EXTENSION_ACTIVE_DAY), 1 UsageEvent(CALL_SECONDS, 185s) e 4 AIUsageRecord (transcrição 42s, 1 análise, 1500 tokens de entrada, 600 de saída) semeados manualmente (mesmo padrão de "transcrição semeada" já usado na PHASE 20/21, já que gerar uma chamada real ponta a ponta não testa nada a mais do lado do billing). POST /billing/periods/close fechou o período com 8 RatedUsageItems e total R$ 101,1043129032258 — conferido a mão (0,40 chamada + 1,451612903225806 ramal + 99 plano + 0,05 transcrição + 0,20 análise + 0,0027 tokens) e batendo exatamente.

Confirmado também: fechar 2x o mesmo período dá 409; reabrir um período CLOSED funciona e grava audit log; reabrir um período que não está CLOSED dá 409; reabrir com tenantId de outro tenant dá 404 (RLS isolando de verdade, não só a checagem de permission); fechar de novo depois de reabrir reusa os RatedUsageItems já existentes (não duplica) e gera um 2º BillingStatement com o mesmo total; tenant admin (sem role de plataforma) recebe 403 tentando fechar um período mas lista as próprias BillingStatements normalmente; PriceBooksController, RateDecksController, PlanVersionsController (incremento de version automático) e SubscriptionsController testados via HTTP com token real. runActiveDaySweep chamado 2x seguidas no mesmo dia — confirmado não duplicar o evento do dia (idempotência).

Bug real, achado no teste desta fase: reopenBillingPeriod lia prisma.billingPeriod.findUniqueOrThrow({ where: { id } }) SEM tenant context pra descobrir o tenantId do período — mas billing_periods tem FORCE ROW LEVEL SECURITY, então a leitura sem app.current_tenant_id nunca via a linha, e "not found" (P2025) virava 500, não um 404 de verdade. Corrigido exigindo tenantId explícito no body do reopen (igual ao close) e lendo dentro de withTenantContext.

Bug real, achado no teste desta fase: SubscriptionsController criava/lia TenantSubscription direto por prisma.tenantSubscription sem withTenantContext — a tabela tem RLS, então o create batia direto em "new row violates row-level security policy" (create) e o list sempre voltava vazio (select). Corrigido envolvendo os dois em withTenantContext(prisma, tenantId, ...).

Nunca exercitado: rateCallByDestination/longestPrefixMatch contra um RateDeck real dentro de closeBillingPeriod (ver lacuna do calledNumber acima — testado só isoladamente como função pura); runActiveDaySweep rodando via setInterval de verdade por várias horas (só chamado diretamente na mesma execução do teste); reajuste de preço no meio de um período já aberto (PriceBookItem/RateDeckEntry com 2 vigências sobrepostas).