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

164 lines
8.9 KiB
Markdown

# 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 `RatedUsageItem`s, 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
`RatedUsageItem`s 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 `RatedUsageItem`s 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 `BillingStatement`s 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).