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
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 porPriceItemType, versionado poreffectiveFrom/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_extensionsetc.) continuam emPlandireto, 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) — qualPlanVersiono tenant assinou ebillingCycleAnchor(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 deAIUsageRecorddesde a PHASE 20). Meters:CALL_SECONDS,EXTENSION_ACTIVE_DAY,AGENT_ACTIVE_DAY,TRUNK_ACTIVE_DAY. Os 4 meters de IA do enum (AI_TRANSCRIPTION_SECONDSetc.) 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); oRatingEnginelê os dois ledgers, nunca duplica.RatedUsageItem— 1 linha por evento tarifado (usageEventIdOUaiUsageRecordId, nunca os dois), referenciando oPriceBookItem/RateDeckEntryusado. Granularidade fina de propósito (secao 233: "immutable usage ledger") — a agregação por categoria só acontece noBillingStatementItem.BillingPeriod(secao 134, 137) —OPEN → CALCULATING → CLOSED. Fechamento imutável: fechar de novo umCLOSEDé 409, sóPOST /billing/periods/:id/reopen(audit trail com motivo) volta praREOPENED, e só a partir daí um novocloseroda de novo.BillingStatement/BillingStatementItem(secao 138-139) — o que o tenant vê (GET /billing/statements), agregado porBillingStatementCategory. 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 debillableSeconds(mesma transação do CDR) — só grava sebillableSeconds > 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 derunRetentionSweep). 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).