# 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).