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:
163
docs/BILLING.md
Normal file
163
docs/BILLING.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user