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
164 lines
8.9 KiB
Markdown
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).
|