Catalisa.
Building blocks/FinanceiroBeta

Billing

Assinaturas, medição de uso e faturas sem pagar percentual sobre o que você fatura

46
Endpoints
12
Entidades
0
Provedores
Tenant
Escopo
3010
Porta

Todo mundo que vende faturamento por assinatura cobra um percentual do seu faturamento. O Billing da Catalisa mede o uso, monta a fatura e controla a assinatura sem ficar com um pedaço da sua receita.

Para quem é
  • Fintechs e financeiras que cobram por operação processada, não por licença de usuário
  • Plataformas B2B que vendem plano com franquia incluída e excedente medido
  • Times de produto que já usam gateway de pagamento e só precisam da camada de faturamento
Substitui
  • Assinatura de plataforma de subscription billing cobrada como percentual do faturamento (Stripe Billing, Chargebee, Recurly)
  • Planilha de controle de franquia e excedente mantida pelo time financeiro
  • Rotina caseira de "somar o uso do mês e mandar a fatura" espalhada pelo backend
O que não é
  • Um gateway de pagamento — quem captura cartão, Pix e boleto é o building block Payments
  • Um emissor de nota fiscal (NF-e/NFS-e) ou motor de cálculo de tributos
  • Um ERP financeiro com contas a pagar, conciliação bancária e razão contábil

01Resumo executivo

O Billing responde a uma pergunta que toda empresa que cobra recorrente precisa responder todo mês: quanto este cliente deve, por quê, e o que já foi pago. Ele guarda o plano, mede o consumo, monta a fatura linha a linha, registra o pagamento e mantém o ciclo da assinatura — trial, ativa, pausada, cancelada.

Na prática, uma financeira que cobra R$ 2.000 de mensalidade mais R$ 0,80 por consulta de crédito acima de 5.000 consultas para de manter isso em planilha. Ela cria um plano com franquia de 5.000 unidades, manda um evento de uso a cada consulta e, no fim do ciclo, uma chamada gera a fatura já com a assinatura e o excedente separados em linhas distintas.

Está em staging e roda em modo standalone na porta 3010. O status é beta, e isso é deliberado: a API de faturamento está completa e coberta por testes de unidade e integração, mas o agendador que fecharia o ciclo sozinho ainda não existe — hoje quem dispara o fechamento é o integrador. A seção 15 é explícita sobre isso e você deve lê-la antes de vender o módulo.

AtributoValor
Identificadorbilling
CategoriaFinanceiro
EscopoTenant (exige organizationId no token em todas as 46 rotas)
Porta (standalone)3010
Path alias@billing
Prefixo HTTP/billing
Schema no bancobilling
StatusBeta desde 2025-11
Depende dePostgreSQL, Redis, IAM

02O problemanegócio

O cenário. Uma empresa vende software ou serviço com cobrança recorrente. No começo são dez clientes com o mesmo plano, e a cobrança cabe numa planilha. Depois vem o cliente que negociou franquia maior, o que pediu cobrança trimestral, o que quer pagar por consumo. Aí a planilha vira o sistema financeiro da empresa — e ninguém percebeu quando isso aconteceu.

O que trava hoje.

  • Medir consumo é a parte difícil, e é a parte que ninguém faz direito. Somar quantas chamadas de API o cliente fez parece trivial até o dia em que o mesmo evento é contado duas vezes porque a integração fez retry. Cobrança duplicada não é bug de software, é ligação do cliente para o diretor.

  • A franquia mora na cabeça de alguém. "Esse cliente tem 5 mil consultas inclusas" é informação que existe no contrato e no Slack, não no sistema. Quem fecha a fatura confere na mão.

  • A plataforma de billing cobra percentual do seu faturamento. Stripe Billing cobra 0,7% do volume faturado, Chargebee 0,80% no pay-as-you-go, Metronome 0,8% mais os eventos ingeridos, Recurly US$ 249/mês mais 0,9% (páginas públicas consultadas em 2026-08-16). Isso significa que o fornecedor cresce exatamente na mesma curva que você — e a linha de custo que mais dói é a que sobe quando o negócio vai bem. A página da Stripe é explícita ao dizer que o percentual "inclui transações do Billing processadas dentro e fora da Stripe": o percentual incide até sobre receita que não passou por eles.

  • Cobrar por consumo virou a norma, e a maioria das ferramentas cobra caro por isso. A adoção de precificação por uso em SaaS saiu de 34% em 2020 para 45% em 2021 e 61% em 2022, segundo as pesquisas State of Usage-Based Pricing da OpenView com cerca de 600 empresas (press release, 2021; TechCrunch sobre a 2ª edição, 2023). A mesma pesquisa de 2021 mediu retenção líquida de receita de 120% nas empresas com cobrança por uso, contra 110% nas de assinatura tradicional.

  • A base de assinaturas sai do seu banco. Quando o plano, o cliente e a fatura vivem no SaaS do fornecedor, toda pergunta cruzada ("quais clientes do segmento X estouraram franquia?") vira integração, não consulta.

  • Cada serviço novo recomeça a cobrança do zero. O módulo de crédito cobra de um jeito, o de assinatura eletrônica de outro, e a conta consolidada do cliente não existe em lugar nenhum.

O custo de não resolver. O custo direto é o percentual: numa operação que fatura R$ 5 milhões por mês, 0,7% são R$ 35 mil mensais — R$ 420 mil por ano, pagos para emitir faturas que você mesmo calculou. O custo indireto é pior e menos visível: fatura errada é a forma mais rápida de perder um cliente B2B que estava satisfeito com o produto, e a correção sempre custa mais que a diferença cobrada.


03Proposta de valornegócio

AntesDepois
A franquia do cliente está no contrato e na memória de alguémincludedUnits é campo do item de plano, aplicado no cálculo e na ingestão
Retry da integração vira cobrança em duplicidadeidempotencyKey é obrigatório em todo evento de uso; repetição retorna o evento original
Fechar o mês é conferência manual linha a linhaUma chamada gera a fatura com assinatura e excedente separados por chargeType
O custo do faturamento é percentual da sua receitaO custo não acompanha o seu faturamento
Assinatura, cliente e permissão em três bases diferentesTudo no mesmo banco, sob o mesmo organizationId do token

A medição é idempotente por contrato, não por disciplina. O campo idempotencyKey não é opcional no schema Zod. O par (organizationId, idempotencyKey) é único no banco. Reenviar o mesmo evento devolve isDuplicate: true e o evento original — não um segundo lançamento.

Franquia e excedente são do modelo, não do relatório. SubscriptionPlanItem.includedUnits define quantas unidades entram no plano. Na geração da fatura, a quantidade cobrada é max(0, consumo − franquia). Na ingestão, quando o item tem franquia e não tem preço de excedente, o evento que estouraria o limite é recusado com 422 — o limite vira barreira, não surpresa no fim do mês.

A fatura é auditável linha a linha. Cada InvoiceLineItem carrega chargeType, quantidade, preço unitário, período e metadados. Quando o cliente pergunta "de onde saiu esse valor", a resposta está na própria fatura.

A fatura finalizada é imutável. Só rascunho aceita alteração e novos itens. Depois de finalize, o documento congela: o caminho para corrigir é anular e emitir outra, que é como faturamento precisa se comportar para sobreviver a auditoria.

O custo não é percentual da sua receita. É a diferença estrutural em relação a Stripe Billing, Chargebee, Recurly e Metronome, e é o argumento que mais cresce de valor conforme o cliente cresce.


04Casos de uso reaisnegócio

Caso 1 — Uma financeira cobra por consulta de crédito sem planilha no fim do mês Cenário ilustrativo

Contexto. Financeira de crédito que vende análise de risco para 40 correspondentes. O contrato padrão é R$ 2.000 de mensalidade com 5.000 consultas inclusas e R$ 0,80 por consulta excedente.

A dor. A esteira de crédito registrava as consultas num log. No dia 1º de cada mês, alguém do financeiro exportava o log, cruzava com a lista de contratos numa planilha e digitava as faturas. Levava dois dias, e cada divergência com o cliente virava uma investigação de horas — porque a fonte de verdade era um CSV que já tinha sido editado.

A solução com o BB. Um BillingProduct "Consulta de crédito" com pricingModel: PER_UNIT e unitPrice: 0.80. Um UsageMeter com eventName: "credito.consulta" apontando para esse produto. Um SubscriptionPlan de R$ 2.000/mês com um item que liga o produto e define includedUnits: 5000. A esteira passa a chamar POST /billing/api/v1/usage a cada consulta, usando o id da consulta como idempotencyKey. No fechamento, POST /billing/api/v1/invoices/generate monta a fatura com a mensalidade como linha SUBSCRIPTION e o excedente como linha USAGE.

O resultado. O fechamento deixa de ser um projeto de dois dias e vira uma chamada por assinatura. A pergunta "por que R$ 3.240 neste mês?" é respondida pelas linhas da própria fatura, e o retry da esteira não gera consulta contada duas vezes.

Caso 2 — Um SaaS B2B trava o excedente em vez de mandar a conta surpresa Cenário ilustrativo

Contexto. Plataforma de assinatura eletrônica que vende pacotes de documentos. O plano Starter inclui 200 documentos por mês.

A dor. O modelo anterior deixava o cliente assinar à vontade e cobrava o excedente na fatura seguinte. Funcionava até chegar o cliente que assinou 3.000 documentos num mês, recebeu uma fatura quinze vezes maior que a esperada e abriu disputa. O time comercial passou a temer o próprio produto.

A solução com o BB. O item de plano recebe includedUnits: 200 e não recebe priceOverride. Com essa combinação, UsageService.checkUsageLimit agrega o consumo do período atual antes de gravar e recusa o evento que ultrapassaria a franquia, devolvendo 422 com currentUsage, limit e attempted no corpo. A aplicação usa esses campos para mostrar "você usou 200 de 200 documentos" e oferecer o upgrade. Quando o cliente aceita excedente, o mesmo item de plano ganha priceOverride e o bloqueio deixa de ser aplicado — o consumo passa a ser cobrado.

O resultado. A franquia vira um limite que o cliente enxerga durante o uso, e não uma surpresa na fatura. O mesmo campo de dados controla os dois comportamentos: franquia dura ou franquia com excedente cobrado.

Caso 3 — Uma operação com 20 mil assinantes deixa de pagar percentual sobre a própria receita Cenário ilustrativo

Contexto. Plataforma que fatura R$ 4 milhões por mês em assinaturas B2B, hoje operando em uma plataforma de subscription billing de mercado.

A dor. A linha de custo do faturamento é percentual do faturamento. Com as tabelas públicas consultadas em 2026-08-16, R$ 4 milhões por mês custariam cerca de R$ 28 mil no Stripe Billing (0,7%), R$ 32 mil no Chargebee pay-as-you-go (0,80%) ou R$ 32 mil mais mensalidade no Metronome (0,8% mais eventos). E essa conta cresce sozinha: dobrar o faturamento dobra o custo de emitir as faturas, ainda que o número de faturas e a complexidade do cálculo sejam os mesmos.

A solução com o BB. O catálogo, os planos e as assinaturas passam a viver no schema billing do próprio banco. As faturas são geradas por chamada de API, e o pagamento efetivo continua no gateway de sempre — o Billing registra o fato do pagamento via POST /billing/api/v1/payments, com externalRef apontando para a transação do gateway.

O resultado. A conta de faturamento deixa de ser função da receita. O custo do gateway continua existindo, porque alguém precisa capturar o dinheiro; o que sai da conta é o percentual pago para calcular e emitir a cobrança.

Caso 4 — O mercado inteiro precifica billing como percentual da sua receita Referência de mercado

Contexto. As páginas públicas de preço dos principais fornecedores de subscription e usage-based billing, consultadas em 2026-08-16, convergem no mesmo modelo: Stripe Billing cobra 0,7% do volume faturado; Chargebee cobra 0,80% do faturamento mensal no pay-as-you-go ou US$ 99/mês mais 0,65%; Recurly cobra US$ 249/mês mais 0,9% acima dos primeiros US$ 40 mil mensais; Metronome — hoje um produto da Stripe — cobra 0,8% do volume faturado mais US$ 0,04 por mil eventos ingeridos, ou seja, mais caro que o próprio Stripe Billing. Paddle leva o modelo ao limite ao operar como merchant of record por 5% mais US$ 0,50 por transação, taxa que já embute processamento e responsabilidade fiscal.

A dor do mercado. O percentual é conveniente para quem começa — custo zero antes da primeira venda — e é exatamente onde ele deixa de fazer sentido depois. O trabalho de emitir uma fatura de R$ 100 e o de emitir uma de R$ 100 mil é o mesmo trabalho computacional, mas o preço difere em mil vezes.

Como a Catalisa endereça. O Billing é módulo da plataforma, e a precificação da Catalisa não usa percentual sobre o faturamento do cliente. Os direcionadores de custo são operacionais — assinaturas ativas, eventos de uso ingeridos, faturas emitidas —, todos com relação direta com o trabalho efetivamente feito.

O resultado. O ponto de virada é matemático: quanto maior o ticket médio e o volume faturado, maior a diferença. Para operação pequena, o percentual dos fornecedores de mercado é competitivo e a §5 diz isso com todas as letras.

Caso 5 — As três ferramentas independentes de medição de uso deixaram de existir em seis meses Referência de mercado

Contexto. Entre janeiro e julho de 2026, a categoria de metering independente foi absorvida por processadores de pagamento e por um CRM. A Stripe concluiu a compra da Metronome em janeiro de 2026 (newsroom da Stripe), cujos clientes incluíam OpenAI, Anthropic e NVIDIA. A Adyen anunciou a compra do Orb por US$ 335 milhões em junho de 2026, com fechamento previsto para julho (press release da Adyen) — o Orb atendia Vercel, Supabase, Replit e Glean. E a Salesforce assinou acordo definitivo pela m3ter também em junho de 2026 (Salesforce).

A dor do mercado. Patrick Collison, da Stripe, justificou a compra dizendo que "a virada para modelos baseados em uso será uma característica definidora da próxima década para a nossa indústria". O co-CEO da Adyen foi na mesma direção, falando de "uma mudança estrutural impulsionada por IA, à medida que empresas migram para modelos de precificação por uso que exigem infraestrutura capaz de processar milhões de eventos em tempo real". Os dois têm razão sobre a tendência — e o efeito prático para quem compra é que a medição de uso, que era produto de empresa independente, virou funcionalidade de quem cobra percentual da transação.

Como a Catalisa endereça. UsageEvent é entidade de primeira classe do módulo: unicidade em (organizationId, idempotencyKey), índice em (organizationId, billingAccountId, eventTimestamp), lote de até mil eventos por requisição e franquia aplicada já na ingestão. O evento fica no seu banco, consultável por SQL, sem passar por API de terceiro e sem que o fornecedor da medição seja o mesmo que processa o seu pagamento.

O resultado. A medição de uso é parte do módulo, não plano superior nem produto de um adquirente. As limitações reais dessa implementação — agregação apenas por soma, precificação por faixas não aplicada — estão na §15, não escondidas aqui.


05Mercado e diferenciaisnegócio

Panorama. O mercado de billing se organizou em três camadas que os compradores costumam confundir. Na primeira estão os gateways com recorrência — no Brasil, Vindi, Iugu, Asaas, Pagar.me —, que capturam o dinheiro e sabem repetir uma cobrança de valor fixo; medição de consumo com agregação e franquia não é o problema que eles resolvem, e nenhum deles anuncia ingestão de eventos de uso em página pública. Na segunda estão as plataformas de subscription billing — Stripe Billing, Chargebee, Recurly, Zuora —, que fazem gestão de assinatura, cobrança de inadimplência e reconhecimento de receita, e cobram percentual do faturamento. Na terceira estavam os especialistas em usage-based — Orb, Metronome, m3ter.

Escrevemos "estavam" de propósito: em 2026 essa terceira camada foi comprada inteira. A Metronome virou produto da Stripe em janeiro, o Orb foi para a Adyen em junho por US$ 335 milhões e a m3ter foi para a Salesforce no mesmo mês (§4, Caso 5). Sobraram como independentes os dois projetos de código aberto — Lago (self-hosted gratuito; a nuvem não publica preço) e Kill Bill (Apache 2.0, na JVM). O posicionamento público do Kill Bill é, literalmente, "billing e infraestrutura de pagamento de código aberto que você mesmo roda, sem lock-in de fornecedor e sem preço baseado em receita".

Isso desenha bem onde o Billing da Catalisa se encaixa: ele oferece o que os dois projetos abertos oferecem — faturamento sem percentual sobre a sua receita — sem que você precise operar a infraestrutura, e dentro de uma plataforma onde identidade, clientes e catálogo já existem.

CritérioCatalisa BillingStripe BillingChargebee FlowRecurly StarterMetronome (Stripe)Kill BillVindi
Modelo de preçoSem percentual sobre o faturamento0,7% do volume faturado0,80%, ou US$ 99/mês + 0,65%US$ 249/mês + 0,9%0,8% + US$ 0,04/mil eventosLicença zero, você operaR$ 299–1.119/mês + taxas por transação
Cobra percentual da sua receitaNãoSimSimSimSimNãoNão (cobra por transação)
Franquia de receita isentaNão publicadaNão publicadaUS$ 40 mil/mêsNão publicada
Medição de uso (metering)Sim, com idempotência e loteSim (via Metronome)SimParcialSim, é o focoParcialNão é o foco
Franquia por item de planoSim (includedUnits)SimSimSimSimSimNão
Bloqueio na ingestão ao estourar franquiaSimNãoNãoNãoNãoNão
Precificação por faixas aplicada no cálculoNão (§15)SimSimSimSimSim
Cálculo de tributos na faturaNão (§15)Sim (Stripe Tax)SimSimParcialVia pluginSim, no ecossistema
Emissão de NF-e / NFS-eNãoNão (Brasil)NãoNãoNãoNãoSim
Cobrança automática por agendadorNão (§15)SimSimSimSimSimSim
Portal do assinante prontoNãoSimSimSimNãoNãoSim
Dunning / recuperação de inadimplênciaNãoSimSimSim, é ponto forteNãoSimParcial
Captura de pagamentoNão — é o BB PaymentsSimVia gatewayVia gatewayVia gatewayVia pluginSim, é gateway
Operação por sua contaJá vem operadoNãoNãoNãoNãoSim, integralNão
Dado de faturamento no seu bancoSimNãoNãoNãoNãoSimNão

Preços conforme páginas públicas dos fornecedores consultadas em 2026-08-16: Stripe Billing, Chargebee, Recurly, Metronome, Kill Bill, Vindi. Tabelas mudam e variam por região, plano e negociação — confira na data da sua análise. Iugu e Pagar.me não publicam tabela de preços, então não entram na comparação.

Nossos diferenciais

  1. O custo não é função da sua receita. Stripe Billing, Chargebee, Recurly e Metronome cobram percentual do volume faturado — no caso da Stripe, inclusive sobre transações processadas fora dela. Isso é difícil de copiar não por barreira técnica, mas porque é o modelo de negócio deles: um fornecedor que vive de percentual não tem como oferecer o contrário sem destruir a própria receita. Vale dizer com todas as letras que não somos os únicos: Lago e Kill Bill também não cobram percentual. A diferença nossa em relação a eles é que você não opera a infraestrutura.
  2. O faturamento mora no mesmo banco que o cliente, o catálogo e a permissão. BillingAccount referencia Person do Customers; BillingProduct guarda externalProductId apontando para o Products; a permissão vem do token do IAM. Não existe job de sincronização entre a sua base e a base do fornecedor, porque não existem duas bases.
  3. A franquia é uma barreira na ingestão, não só uma linha no cálculo. Quando o item de plano tem includedUnits e não tem priceOverride, o evento que estouraria o limite é recusado com 422 antes de virar dívida. Nenhum dos concorrentes da tabela faz isso — todos assumem que o excedente sempre pode ser cobrado depois.
  4. Idempotência obrigatória, não opcional. idempotencyKey é campo exigido pelo schema, com unicidade garantida no banco. Não há caminho em que uma integração com retry gere cobrança dupla por descuido do integrador.

Quando escolher o concorrente. Escolha o Stripe Billing ou o Chargebee se você precisa hoje de precificação por faixas de volume aplicada no cálculo, cálculo de tributos na fatura, portal de autoatendimento do assinante ou recuperação automática de pagamento recusado — o Billing não faz nada disso, e a §15 lista cada um. Se você fatura pouco, o percentual deles também é simplesmente mais barato: 0,7% sobre R$ 50 mil por mês são R$ 350, e nenhuma economia de percentual justifica trocar de plataforma nessa faixa. Escolha o Recurly se a sua dor principal é inadimplência de cartão, porque o motor de retry deles é maduro e o nosso não existe — e note que os primeiros US$ 40 mil de faturamento mensal são isentos do percentual lá. Escolha o Paddle se você vende para o exterior e quer que alguém assuma a responsabilidade fiscal como merchant of record por 5% mais US$ 0,50 por transação; isso é decisão jurídica, não técnica, e não é algo que um módulo resolve. Escolha Vindi, Iugu ou Asaas se o que você precisa é cobrar recorrência de valor fixo no Brasil com boleto, Pix e nota fiscal saindo do mesmo lugar — nesse cenário o Billing seria uma peça a mais sem ganho claro. E escolha Lago ou Kill Bill se a exigência é código aberto sob seu controle: eles resolvem o mesmo problema de modelo de preço que nós, sem custo de licença, ao preço de você operar a infraestrutura — o Kill Bill roda na JVM e a conta de engenharia de plantão é sua.

O Billing ganha quando o problema é cobrar por consumo medido, dentro de uma plataforma que o cliente já usa, sem entregar um percentual da receita ao fornecedor de faturamento — e quando o ciclo de fechamento pode ser disparado pela sua aplicação, porque é assim que ele funciona hoje.


06Modelo de cobrança e ROInegócio

Unidade de cobrança. Precificação em definição. O compromisso de desenho já está tomado e é o argumento comercial central: a Catalisa não cobra percentual sobre o faturamento processado pelo Billing. Não invente número em proposta enquanto a tabela não sair; use a comparação de modelo, que é o que de fato decide a conversa.

O que dispara custo. Assinaturas ativas, eventos de uso ingeridos e faturas emitidas. Os três têm relação direta com trabalho computacional real: uma fatura de R$ 100 mil custa o mesmo que uma de R$ 100 para ser calculada e emitida, e o modelo de cobrança precisa refletir isso.

Por que essa unidade é justa. O percentual sobre faturamento cobra pelo valor da transação, não pelo trabalho de processá-la. É excelente para quem está começando — sem receita, sem custo — e vira penalidade ao crescimento depois. A empresa que dobra o ticket médio sem mudar nada na operação de cobrança passa a pagar o dobro pelo mesmo serviço.

Vale conhecer um detalhe do modelo da Stripe antes de qualquer comparação: a página oficial do Billing diz que os 0,7% incidem sobre "transações do Billing processadas dentro e fora da Stripe". Ou seja, o percentual pode alcançar receita que você recebeu por outro meio, só porque a fatura foi emitida ali. E ele é adicional ao processamento: no Brasil, a mesma página lista 3,99% mais R$ 0,50 por cobrança de cartão bem-sucedida. Somando os dois, uma cobrança de cartão passa de 4,6%.

Comparação de custo — cenário nomeado: operação B2B que fatura R$ 4 milhões por mês, com 800 assinaturas ativas e 6 milhões de eventos de uso medidos por mês.

Catalisa BillingStripe BillingChargebee (pay-as-you-go)Recurly (Starter)Metronome (Starter)
Base de cálculoSem percentual sobre faturamento0,7% do volume faturado0,80% do faturamento mensalUS$ 249/mês + 0,9% acima de US$ 40 mil0,8% do faturado + US$ 0,04/mil eventos
Ordem de grandeza mensalPrecificação em definição~R$ 28.000~R$ 32.000~R$ 36.000 + US$ 249~R$ 32.000 + ~US$ 240
Custo ao dobrar o faturamentoSem relação diretaDobraDobraPraticamente dobraDobra
Dado de faturamentoNo seu bancoNo fornecedorNo fornecedorNo fornecedorNo fornecedor

Estimativa. Percentuais retirados das páginas públicas dos fornecedores em 2026-08-16, aplicados diretamente sobre R$ 4 milhões, sem conversão cambial nos itens em dólar e sem considerar descontos por volume, plano anual ou negociação — que existem e reduzem os valores. Serve para comparar modelos, não para cotar. Nenhum destes números é proposta comercial da Catalisa nem do fornecedor citado.

ROI. A conta de guardanapo tem duas parcelas.

A primeira é o percentual que deixa de ser pago. No cenário acima, a ordem de grandeza é de R$ 28 mil a R$ 36 mil por mês — algo entre R$ 336 mil e R$ 432 mil por ano — e essa parcela cresce sozinha conforme o faturamento cresce, o que é o oposto do que se espera de um custo de infraestrutura.

A segunda é a engenharia que não é gasta. Construir medição idempotente, franquia por plano, geração de fatura com itens tipados e ciclo de assinatura com transições válidas é trabalho de meses, não de semanas — e é o tipo de código em que o bug aparece na fatura do cliente, não no teste. O Billing entrega essa base pronta e coberta por testes de unidade e integração.

Contra isso pesa o que ainda não está pronto e precisa entrar na conta honestamente: sem agendador, alguém do seu lado precisa disparar o fechamento do ciclo; sem cálculo de tributo e sem emissão fiscal, a nota continua saindo de outro sistema; sem dunning, a régua de cobrança de inadimplente é sua. Em operação que depende dessas três coisas hoje, o ROI não fecha — e é melhor descobrir isso na proposta do que na implantação.


07Arquitetura

                                  HTTP
                                    │
  ┌─────────────────────────────────┴───────────────────────────────────────┐
  │ Hono app  basePath('/billing')   + applyCommonMiddleware                 │
  │   bodyLimit 1MB · CORS · security headers · rate limit global            │
  │                                                                          │
  │  /api/v1/billing-accounts   billingAccountsRouter    5 rotas             │
  │  /api/v1/products           billingProductsRouter    5 rotas             │
  │  /api/v1/plans              plansRouter              7 rotas             │
  │  /api/v1/config             configRouter             2 rotas             │
  │  /api/v1/subscriptions      subscriptionsRouter      7 rotas             │
  │  /api/v1/meters             metersRouter             5 rotas             │
  │  /api/v1/usage              usageRouter              3 rotas             │
  │  /api/v1/invoices           invoicesRouter           8 rotas             │
  │  /api/v1/payments           paymentsRouter           4 rotas             │
  │  /health                    sonda simples (sem toque no banco)           │
  └─────────────────────────────────┬───────────────────────────────────────┘
                                    │
       authMiddleware → requirePermission(P) → requireOrganization
                                    │  (nas 46 rotas, sem exceção)
                                    │  Zod parse → ResultAsync<T, AppError>
  ┌─────────────────────────────────┴───────────────────────────────────────┐
  │ services/                                                                │
  │   BillingAccountService       conta de cobrança, saldo, exclusão lógica  │
  │   BillingProductService       catálogo de itens cobráveis                │
  │   SubscriptionPlanService     planos e itens de plano (franquia)         │
  │   SubscriptionService         ciclo de vida, transições validadas        │
  │   UsageMeterService           definição dos medidores                    │
  │   UsageService                ingestão idempotente, limite, resumo       │
  │   BillingEngineService        monta a fatura a partir da assinatura      │
  │   InvoiceService              rascunho, itens, finalizar, anular, pagar  │
  │   PaymentService              registro de pagamento e estorno            │
  │   ProrationService            rateio por dias (cálculo puro)             │
  │   OrganizationBillingConfigService   prazo, moeda, ciclo por organização │
  └─────────────────────────────────┬───────────────────────────────────────┘
                                    │
  ┌─────────────────────────────────┴───────────────────────────────────────┐
  │ repositories/ (Prisma)   →   PostgreSQL, schema "billing", 12 modelos    │
  └─────────────────────────────────┬───────────────────────────────────────┘
                                    │ EventPublisher
                                    ▼
                     Redis Stream  "iam-events"   →  webhooks-engine

O caminho do dinheiro, do consumo à fatura:

  aplicação do cliente
        │  POST /usage  (idempotencyKey obrigatório)
        ▼
  ┌───────────────┐   resolve medidor por meterId ou eventName
  │  UsageEvent   │   verifica franquia do item de plano  ──► 422 se estourar
  │  (bruto)      │   unicidade (organizationId, idempotencyKey)
  └───────┬───────┘
          │  UsageService.aggregateUsageForPeriod()
          │  ⚠ NÃO existe job que chame isto — ver §11 e §15
          ▼
  ┌───────────────┐   soma por medidor e período, upsert incremental
  │  UsageRecord  │   billed = false
  │  (agregado)   │
  └───────┬───────┘
          │  POST /invoices/generate  (includeUsage: true)
          ▼
  ┌───────────────────────────────────────────────────────────┐
  │ BillingEngineService                                       │
  │   linha SUBSCRIPTION  ← basePrice × quantity do plano      │
  │   linha SUBSCRIPTION  ← cada item de plano (produto)       │
  │   linha USAGE         ← max(0, consumo − includedUnits)    │
  │                          × (priceOverride ou unitPrice)    │
  │   marca os UsageRecord consumidos como billed = true       │
  └───────────────────────────┬───────────────────────────────┘
                              ▼
                     Invoice em DRAFT

Decisões não óbvias.

  • UsageEvent e UsageRecord são tabelas separadas, de propósito. O evento bruto é volume alto e imutável; o registro agregado é o que a fatura consome. Separar permite que a ingestão seja um INSERT barato, sem contenção com o cálculo, e que a reagregação de um período seja idempotente pelo upsert em (organizationId, billingAccountId, meterId, periodStart). O preço dessa escolha é o passo intermediário de agregação — que hoje precisa ser disparado explicitamente, e é a armadilha número um de integração deste módulo (§11).
  • A fatura só existe a partir do que já foi agregado. BillingEngineService.addUsageChargesUsageRecord, não UsageEvent. Gerar fatura sem ter agregado antes produz uma fatura sem nenhuma linha de uso, sem erro nenhum. É silencioso e é a causa mais provável de "a fatura veio sem o consumo".
  • idempotencyKey é obrigatório no schema Zod, não só único no banco. A unicidade no banco protege contra duplicata; exigir o campo protege contra o integrador que nunca pensou no assunto. A verificação acontece antes da escrita, então o retorno de duplicata é 201 com meta.isDuplicate: true, e não 409 — reenviar é seguro por construção.
  • O lote de uso é processado em sequência, não em paralelo. recordUsageEventsBatch reduz os eventos um a um justamente para que a checagem de idempotência e a de franquia enxerguem o efeito dos eventos anteriores do mesmo lote. Paralelizar quebraria as duas. O custo é latência proporcional ao tamanho do lote; o limite é 1.000 eventos por requisição.
  • O lote falha por item, não por requisição. Um evento inválido no meio do lote entra em errors[] com o índice, e os demais são gravados. Faturamento não pode perder 999 eventos válidos por causa de um billingAccountId errado.
  • Valores monetários são Decimal no Postgres, convertidos com .toNumber() na borda. Decimal(15,2) para dinheiro, Decimal(15,4) para preço unitário e quantidade — quatro casas porque preço por unidade em cobrança por consumo é frequentemente fração de centavo. A conversão para number acontece só na serialização da resposta; o cálculo e o armazenamento não passam por ponto flutuante.
  • A transição de assinatura é uma tabela, não uma cadeia de if. VALID_TRANSITIONS em subscription.service.ts declara o grafo, e toda ação consulta isValidTransition antes de agir. Isso é o que impede "retomar uma assinatura cancelada" de existir como caminho.
  • Exclusão é lógica em tudo que tem valor histórico. BillingAccount, BillingProduct, SubscriptionPlan, Subscription, Invoice, Payment e UsageMeter têm deletedAt. Fatura emitida não desaparece porque alguém apagou o cliente.
  • /health não toca o banco. Diferente do IAM, a sonda do Billing responde estático com nome do serviço e versão. Serve para o orquestrador saber que o processo está de pé, não para saber que o banco está acessível — se você monitora saúde de dependência, monitore pelo IAM ou por uma chamada real de listagem.

Monolito vs. standalone. Em monolito, o app é montado em src/app.ts junto com os demais e resolve serviços pelo container TypeDI. Em standalone — o modo usado em produção — main.ts sobe o Bun na porta de PORT (padrão da tabela: 3010) e define MODULE_SELF=billing. O applyCommonMiddleware existe justamente porque, em standalone, os middlewares globais do monolito não alcançam o módulo: sem ele, o Billing subiria sem limite de corpo, sem cabeçalhos de segurança e sem rate limit.


08Conceitos e modelo de dados

Glossário

TermoSignifica
BillingAccountA quem se cobra. Uma entidade de cobrança dentro da organização, opcionalmente ligada a uma Person do Customers. E-mail único por organização.
BillingProductUm item cobrável do catálogo interno de faturamento: "Mensalidade", "Consulta de crédito", "GB armazenado". Não é o produto comercial do BB Products — pode apontar para ele por externalProductId.
SubscriptionPlanModelo de assinatura: preço-base, intervalo, dias de teste. É um template, não uma assinatura.
SubscriptionPlanItemLiga um produto ao plano, com quantidade, preço sobrescrito (priceOverride) e franquia (includedUnits). É aqui que mora o "5.000 consultas inclusas".
SubscriptionA instância: esta conta de cobrança, neste plano, com este período corrente. Guarda unitPrice no momento da contratação, para que mudança de preço no plano não reescreva o histórico.
UsageMeterComo medir. Liga um eventName (único por organização) a um produto e diz como agregar.
UsageEventUm fato de consumo bruto, com idempotencyKey, quantity e properties livres. Alto volume, imutável.
UsageRecordO consumo já agregado por medidor e período. É o que a fatura lê. Carrega billed para não cobrar duas vezes.
InvoiceO documento de cobrança. Rascunho é editável; finalizada, não.
InvoiceLineItemUma linha da fatura, tipada por chargeType, com período próprio. É a resposta a "de onde saiu esse valor".
PaymentO registro de que o dinheiro entrou. O Billing não captura o pagamento; ele registra o fato, com externalRef para a transação do gateway.
OrganizationBillingConfigPreferências de faturamento da organização: moeda padrão, prazo de pagamento, tipo de ciclo. Criada sob demanda no primeiro acesso.
includedUnitsA franquia. Unidades que entram no plano antes de começar a cobrar.
priceOverridePreço do produto só para este plano. Sua presença também sinaliza que o excedente é cobrável — e portanto que a franquia não deve bloquear a ingestão.

Modelo de dados — schema billing no PostgreSQL, 12 modelos.

Modelo PrismaTabelaPropósitoCampos-chave
OrganizationBillingConfigbilling.organization_billing_configsPreferências por organizaçãoorganizationId (único), defaultCurrency, paymentTermDays, billingCycleType, invoicePrefix, invoiceFooter
BillingAccountbilling.billing_accountsEntidade de cobrançaÚnico (organizationId, email), personId, personOrphaned, balance, status, taxId, deletedAt
BillingProductbilling.billing_productsCatálogo cobrávelÚnico (organizationId, name), pricingModel, unitPrice Decimal(15,4), tiers (JSONB), externalProductId, deletedAt
SubscriptionPlanbilling.subscription_plansTemplate de assinaturaÚnico (organizationId, name), billingInterval, billingCycleType, basePrice, trialDays, deletedAt
SubscriptionPlanItembilling.subscription_plan_itemsProduto dentro do planoÚnico (planId, productId), quantity, priceOverride, includedUnits
Subscriptionbilling.subscriptionsAssinatura ativastatus, currentPeriodStart/End, trialStart/End, cancelAtPeriodEnd, pausedAt, unitPrice, deletedAt
UsageMeterbilling.usage_metersDefinição de mediçãoÚnico (organizationId, eventName), aggregationType, propertyKey, category, productId, deletedAt
UsageEventbilling.usage_eventsConsumo brutoÚnico (organizationId, idempotencyKey), índice (organizationId, billingAccountId, eventTimestamp), quantity Decimal(15,4), properties JSONB
UsageRecordbilling.usage_recordsConsumo agregadoÚnico (organizationId, billingAccountId, meterId, periodStart), billed, invoiceId
Invoicebilling.invoicesFaturaÚnico (organizationId, invoiceNumber), status, subtotal/taxAmount/discountAmount/total/amountPaid/amountDue, dueDate, finalizedAt, paidAt, voidedAt, deletedAt
InvoiceLineItembilling.invoice_line_itemsLinha da faturachargeType, quantity, unitPrice, amount, periodStart/End, metadata
Paymentbilling.paymentsPagamento registradostatus, amount, paymentMethod, externalRef, refundedAmount, refundedAt, deletedAt

Enumerações

EnumValoresObservação
BillingAccountStatusACTIVE · SUSPENDED · CLOSEDACTIVE aceita nova assinatura
SubscriptionStatusTRIAL · ACTIVE · PAUSED · CANCELED · EXPIREDEXPIRED existe no enum mas nenhum caminho de código o grava (§15)
InvoiceStatusDRAFT · PENDING · PAID · OVERDUE · CANCELED · VOIDOVERDUE e CANCELED existem no enum mas nenhum caminho de código os grava (§15)
PaymentStatusTypePENDING · PROCESSING · COMPLETED · FAILED · REFUNDEDPagamento registrado pela API nasce COMPLETED
BillingIntervalDAILY · WEEKLY · MONTHLY · QUARTERLY · YEARLY
BillingCycleTypeCALENDAR_ALIGNED · ANNIVERSARYArmazenado; o cálculo de período hoje é sempre aniversário (§15)
PricingModelTypeFLAT_FEE · TIERED · VOLUME · PER_UNIT · PACKAGERótulo de catálogo; o cálculo aplica sempre quantidade × preço unitário (§15)
ChargeTypeSUBSCRIPTION · USAGE · ONE_TIME · PRORATION · CREDITO motor emite SUBSCRIPTION e USAGE; os demais só por lançamento manual
UsageAggregationTypeSUM · MAX · LAST · AVERAGE · COUNT_DISTINCTArmazenado; a agregação implementada é sempre soma (§15)

Máquina de estados da fatura

                    POST /invoices
                    POST /invoices/generate
                            │
                            ▼
                    ┌───────────────┐
       PATCH ───────│     DRAFT     │──── POST /:id/line-items
       (notas,      │   (rascunho)  │     (só aqui aceita item novo)
        vencimento) └───┬───────┬───┘
                        │       │
      POST /:id/finalize│       │POST /:id/void
   BILLING_INVOICES_    │       │BILLING_INVOICES_VOID
        FINALIZE        │       │
                        ▼       │
                ┌───────────────┐
                │    PENDING    │──── POST /:id/void ────┐
                │  (finalizada, │                        │
                │   imutável)   │                        │
                └───────┬───────┘                        ▼
                        │                        ┌──────────────┐
     POST /payments com │                        │     VOID     │
     invoiceId cobrindo │                        │  (terminal)  │
     o total            │                        └──────────────┘
                        ▼                          void recusa
                ┌───────────────┐                  PAID e VOID
                │     PAID      │
                │  (terminal)   │◄── pagamento parcial mantém PENDING
                └───────────────┘    e só reduz amountDue

  ┌────────────────────────────────────────────────────────────────────────┐
  │ OVERDUE e CANCELED existem no enum e no banco, mas NENHUM caminho de   │
  │ código os grava hoje. A consulta InvoiceRepository.findOverdue está    │
  │ escrita e não tem chamador — falta o agendador. Não construa lógica    │
  │ de integração esperando receber uma fatura em OVERDUE. Ver §15.        │
  └────────────────────────────────────────────────────────────────────────┘

  Regras aplicadas em InvoiceService:
    PATCH e adicionar item        → só em DRAFT, senão 400 VALIDATION
    finalize                      → só de DRAFT, senão 400 VALIDATION
    void                          → recusa se status é PAID ou VOID
    pagamento                     → recusa se status é VOID
    finalize NÃO recalcula totais → o total é o que os itens somaram

Máquina de estados da assinatura

                POST /subscriptions
                        │
        plano tem trialDays?   e skipTrial != true
                ┌───────┴────────┐
               sim              não
                │                │
                ▼                ▼
        ┌───────────────┐  ┌───────────────┐
        │     TRIAL     │─▶│    ACTIVE     │◄──┐
        └───┬───────┬───┘  └───┬───┬───┬───┘   │ POST /:id/resume
            │       │          │   │   │       │ BILLING_SUBSCRIPTIONS_
            │       │  ┌───────┘   │   └───────┤        UPDATE
            │       │  │           │           │
            │       │  │ POST /:id/pause       │
            │       │  │           │      ┌────┴──────┐
            │       │  │           │      │  PAUSED   │
            │       │  │           │      └────┬──────┘
            │       │  │           │           │
            ▼       ▼  ▼           ▼           ▼
        ┌──────────────────────────────────────────┐
        │              CANCELED                     │  POST /:id/cancel
        │              (terminal)                   │  BILLING_SUBSCRIPTIONS_
        └──────────────────────────────────────────┘         CANCEL

        ┌──────────────────────────────────────────┐
        │              EXPIRED                      │  declarado como destino
        │              (terminal)                   │  válido de TRIAL e ACTIVE,
        └──────────────────────────────────────────┘  mas nenhum código grava

  Grafo declarado em VALID_TRANSITIONS (subscription.service.ts):
    TRIAL     → ACTIVE, CANCELED, EXPIRED
    ACTIVE    → PAUSED, CANCELED, EXPIRED
    PAUSED    → ACTIVE, CANCELED
    CANCELED  → (nada)
    EXPIRED   → (nada)

  Duas armadilhas que o grafo não mostra:

  1. TRIAL → ACTIVE não tem endpoint. A transição existe em
     SubscriptionService.activateFromTrial(), que nenhuma rota expõe e
     nenhum job chama. Assinatura em teste permanece em TRIAL.

  2. cancel com cancelAtPeriodEnd = true NÃO muda o status. Ele apenas
     grava a flag; a assinatura segue ACTIVE. E findDueForBilling exclui
     quem tem a flag ligada, então essa assinatura também para de gerar
     fatura. Fechar o ciclo dela é responsabilidade da sua aplicação.

09Referência da API

Prefixo: /billing. Em standalone (staging), a base é https://billing.bb.stg.catalisa.app.

Todas as 46 rotas aplicam, nesta ordem: authMiddlewarerequirePermission(...)requireOrganization. Não existe rota pública além de GET /billing/health, e não existe rota que aceite organizationId no corpo — o tenant vem sempre do token.

Todos os corpos de escrita seguem envelope JSON:API — { "data": { "type": "...", "attributes": { ... } } }. Paginação por page[number] e page[size] (padrão 20, máximo 100). Filtros por filter[campo].

Contas de cobrança — /billing/api/v1/billing-accounts

MétodoRotaDescriçãoPermissão
POST/billing/api/v1/billing-accountsCria conta de cobrançaBILLING_ACCOUNTS_CREATE
GET/billing/api/v1/billing-accountsLista contas. Filtros: filter[status], filter[personId]BILLING_ACCOUNTS_READ
GET/billing/api/v1/billing-accounts/:idBusca contaBILLING_ACCOUNTS_READ
PATCH/billing/api/v1/billing-accounts/:idAtualiza contaBILLING_ACCOUNTS_UPDATE
DELETE/billing/api/v1/billing-accounts/:idExclusão lógica. 204BILLING_ACCOUNTS_DELETE

Produtos de faturamento — /billing/api/v1/products

MétodoRotaDescriçãoPermissão
POST/billing/api/v1/productsCria produto cobrávelBILLING_PRODUCTS_CREATE
GET/billing/api/v1/productsLista. Filtros: filter[isActive], filter[pricingModel]BILLING_PRODUCTS_READ
GET/billing/api/v1/products/:idBusca produtoBILLING_PRODUCTS_READ
PATCH/billing/api/v1/products/:idAtualiza produtoBILLING_PRODUCTS_UPDATE
DELETE/billing/api/v1/products/:idExclusão lógica. 204BILLING_PRODUCTS_DELETE

Planos — /billing/api/v1/plans

MétodoRotaDescriçãoPermissão
POST/billing/api/v1/plansCria planoBILLING_PLANS_CREATE
GET/billing/api/v1/plansLista. Filtros: filter[isActive], filter[billingInterval]BILLING_PLANS_READ
GET/billing/api/v1/plans/:idBusca plano com itensBILLING_PLANS_READ
PATCH/billing/api/v1/plans/:idAtualiza planoBILLING_PLANS_UPDATE
DELETE/billing/api/v1/plans/:idExclusão lógica. 204BILLING_PLANS_DELETE
POST/billing/api/v1/plans/:id/itemsAdiciona produto ao plano. 201BILLING_PLANS_UPDATE
DELETE/billing/api/v1/plans/:planId/items/:itemIdRemove item do plano. 204BILLING_PLANS_UPDATE

Configuração da organização — /billing/api/v1/config

MétodoRotaDescriçãoPermissão
GET/billing/api/v1/configLê a configuração; cria com padrões se não existirBILLING_CONFIG_MANAGE
PATCH/billing/api/v1/configAtualiza moeda, ciclo, prazo, prefixo e rodapéBILLING_CONFIG_MANAGE

Assinaturas — /billing/api/v1/subscriptions

MétodoRotaDescriçãoPermissão
POST/billing/api/v1/subscriptionsCria assinatura. 201BILLING_SUBSCRIPTIONS_CREATE
GET/billing/api/v1/subscriptionsLista. Filtros: filter[billingAccountId], filter[planId], filter[status]BILLING_SUBSCRIPTIONS_READ
GET/billing/api/v1/subscriptions/:idBusca assinaturaBILLING_SUBSCRIPTIONS_READ
PATCH/billing/api/v1/subscriptions/:idAltera apenas quantityBILLING_SUBSCRIPTIONS_UPDATE
POST/billing/api/v1/subscriptions/:id/cancelCancela agora ou ao fim do períodoBILLING_SUBSCRIPTIONS_CANCEL
POST/billing/api/v1/subscriptions/:id/pausePausa (só de ACTIVE)BILLING_SUBSCRIPTIONS_UPDATE
POST/billing/api/v1/subscriptions/:id/resumeRetoma (só de PAUSED)BILLING_SUBSCRIPTIONS_UPDATE

Medidores de uso — /billing/api/v1/meters

MétodoRotaDescriçãoPermissão
POST/billing/api/v1/metersCria medidor. 201BILLING_METERS_CREATE
GET/billing/api/v1/metersLista. Filtros: filter[productId], filter[isActive]BILLING_METERS_READ
GET/billing/api/v1/meters/:idBusca medidorBILLING_METERS_READ
PATCH/billing/api/v1/meters/:idAtualiza medidorBILLING_METERS_UPDATE
DELETE/billing/api/v1/meters/:idExclusão lógica. 204BILLING_METERS_DELETE

Uso — /billing/api/v1/usage

MétodoRotaDescriçãoPermissão
POST/billing/api/v1/usageRegistra um evento de uso. 201BILLING_USAGE_CREATE
POST/billing/api/v1/usage/batchRegistra lote de 1 a 1.000 eventos. 201BILLING_USAGE_CREATE
GET/billing/api/v1/usage/summaryResumo por medidor no períodoBILLING_USAGE_READ

Faturas — /billing/api/v1/invoices

MétodoRotaDescriçãoPermissão
POST/billing/api/v1/invoicesCria fatura em branco (DRAFT). 201BILLING_INVOICES_CREATE
POST/billing/api/v1/invoices/generateGera fatura a partir de uma assinatura. 201BILLING_INVOICES_CREATE
GET/billing/api/v1/invoicesLista. Filtros: filter[billingAccountId], filter[status], filter[startDate], filter[endDate]BILLING_INVOICES_READ
GET/billing/api/v1/invoices/:idBusca fatura com itensBILLING_INVOICES_READ
PATCH/billing/api/v1/invoices/:idAltera notes e dueDate. Só em DRAFTBILLING_INVOICES_UPDATE
POST/billing/api/v1/invoices/:id/line-itemsAdiciona linha. Só em DRAFT. 201BILLING_INVOICES_UPDATE
POST/billing/api/v1/invoices/:id/finalizeDRAFTPENDING, congela a faturaBILLING_INVOICES_FINALIZE
POST/billing/api/v1/invoices/:id/voidAnula. Recusa PAID e VOIDBILLING_INVOICES_VOID

Pagamentos — /billing/api/v1/payments

MétodoRotaDescriçãoPermissão
POST/billing/api/v1/paymentsRegistra pagamento. 201BILLING_PAYMENTS_READ
GET/billing/api/v1/paymentsLista. Filtros: filter[billingAccountId], filter[invoiceId], filter[status]BILLING_PAYMENTS_READ
GET/billing/api/v1/payments/:idBusca pagamentoBILLING_PAYMENTS_READ
POST/billing/api/v1/payments/:id/refundEstorna total ou parcialBILLING_PAYMENTS_REFUND

Atenção ao registrar pagamento. A rota de criação exige BILLING_PAYMENTS_READ, não BILLING_PAYMENTS_RECORD. A permissão BILLING_PAYMENTS_RECORD existe no vocabulário do IAM, mas o router ainda não a usa — há um comentário no código dizendo que não haveria permissão de criação, o que deixou de ser verdade. Documentado aqui porque é o que o código faz hoje; consta como pendência na §15. Ao montar papéis, considere que quem consegue ler pagamentos também consegue registrar pagamento.

Saúde

MétodoRotaDescrição
GET/billing/healthResponde { status, service, version, timestamp }. Não toca banco nem Redis.

POST /billing/api/v1/usage

Registra um evento de consumo. É o endpoint de maior volume do módulo.

Request

{
  "data": {
    "type": "usage-events",
    "attributes": {
      "billingAccountId": "0f1c1d3a-1111-4a2b-9c3d-000000000001",
      "eventName": "credito.consulta",
      "idempotencyKey": "consulta-8f3a2b19",
      "quantity": 1,
      "eventTimestamp": "2026-08-16T13:45:00Z",
      "properties": { "cpf": "***", "produto": "consignado" }
    }
  }
}
CampoTipoObrigatórioDescrição
billingAccountIduuidSimConta de cobrança que consumiu
meterIduuidUm dos doisMedidor por id
eventNamestringUm dos doisMedidor pelo nome do evento. Resolve só medidor ativo
idempotencyKeystring (1–255)SimChave de deduplicação. Use o id natural da operação
quantitynumber > 0SimQuantidade consumida
eventTimestampdateNãoPadrão: agora. É a data usada na agregação por período
propertiesobjectNãoMetadados livres, guardados em JSONB

Resposta 201

{
  "data": {
    "type": "usage-events",
    "id": "9a2f...",
    "links": { "self": "/billing/api/v1/usage/9a2f..." },
    "attributes": {
      "billingAccountId": "0f1c1d3a-1111-4a2b-9c3d-000000000001",
      "meterId": "3c7e...",
      "idempotencyKey": "consulta-8f3a2b19",
      "eventTimestamp": "2026-08-16T13:45:00.000Z",
      "quantity": 1
    }
  },
  "meta": { "isDuplicate": false }
}

Reenviar a mesma idempotencyKey devolve 201 com meta.isDuplicate: true e o evento original — não um erro e não um segundo lançamento.

Erros

StatusCódigoQuando
400VALIDATIONCorpo reprovado no Zod, ou nem meterId nem eventName informados
403Token sem organizationId, ou sem BILLING_USAGE_CREATE
404NOT_FOUNDConta de cobrança ou medidor inexistente na organização
422VALIDATIONFranquia estourada. details traz currentUsage, limit, attempted, meter, billingAccountId

POST /billing/api/v1/usage/batch

Mesmo formato, em lote de 1 a 1.000 eventos.

{
  "data": [
    { "type": "usage-events", "attributes": { "billingAccountId": "...", "eventName": "credito.consulta", "idempotencyKey": "c-1", "quantity": 1 } },
    { "type": "usage-events", "attributes": { "billingAccountId": "...", "eventName": "credito.consulta", "idempotencyKey": "c-2", "quantity": 1 } }
  ]
}

Resposta 201 — sucesso parcial é o comportamento normal:

{
  "data": [ { "type": "usage-events", "id": "...", "attributes": { } } ],
  "meta": { "recorded": 1, "duplicates": 1, "errors": 0 }
}

Quando há falhas, o campo errors aparece com { index, error } por item. Sempre leia meta: um lote pode retornar 201 com metade dos eventos rejeitados.


POST /billing/api/v1/invoices/generate

Monta a fatura de um ciclo a partir da assinatura. É o endpoint central do fechamento.

Request

{
  "data": {
    "attributes": {
      "subscriptionId": "5b8d...",
      "includeUsage": true
    }
  }
}
CampoTipoObrigatórioDescrição
subscriptionIduuidSimAssinatura a faturar
includeUsagebooleanNãoPadrão true. Inclui UsageRecord não faturados com periodEnd até o fim do período corrente

O que o motor faz, nesta ordem: gera o número da fatura, cria a fatura em DRAFT, adiciona a linha SUBSCRIPTION do preço-base × quantidade, adiciona uma linha SUBSCRIPTION por item de plano, adiciona linhas USAGE a partir dos registros agregados descontando includedUnits, marca esses registros como billed e recalcula os totais.

Resposta 201 — fatura em DRAFT com os itens. Chame finalize depois para congelar.

Erros

StatusCódigoQuando
400VALIDATIONsubscriptionId ausente ou não é UUID
404NOT_FOUNDAssinatura inexistente na organização

A armadilha. includeUsage: trueUsageRecord, não UsageEvent. Se ninguém agregou o período antes, a fatura sai sem nenhuma linha de uso e sem erro. Veja a receita "Fechar um ciclo de faturamento" na §11.


POST /billing/api/v1/invoices/:id/finalize

DRAFTPENDING. Grava finalizedAt e finalizedBy (o sub do token) e publica billing.invoice.finalized.

Depois disso a fatura não aceita PATCH nem novos itens. finalize não recalcula os totais — ele congela o que os itens já somaram. Confira o total antes de finalizar.

Erros

StatusCódigoQuando
400VALIDATIONFatura não está em DRAFT
404NOT_FOUNDFatura inexistente na organização

POST /billing/api/v1/payments

Registra que o dinheiro entrou. O Billing não captura pagamento — quem captura é o gateway, ou o building block Payments.

Request

{
  "data": {
    "type": "payments",
    "attributes": {
      "billingAccountId": "0f1c1d3a-1111-4a2b-9c3d-000000000001",
      "invoiceId": "7d2a...",
      "amount": 3240.00,
      "paymentMethod": "pix",
      "externalRef": "E00038166202608161345s0123456789",
      "metadata": { "gateway": "payments-bb" }
    }
  }
}

Com invoiceId, o serviço soma ao amountPaid da fatura e recalcula amountDue; quando o saldo chega a zero ou menos, a fatura vira PAID e o evento billing.invoice.paid é publicado. Pagamento parcial mantém PENDING. Em ambos os casos, o balance da conta de cobrança é incrementado pelo valor.

Erros

StatusCódigoQuando
400VALIDATIONFatura está VOID, ou já está PAID
404NOT_FOUNDConta de cobrança ou fatura inexistente

POST /billing/api/v1/subscriptions/:id/cancel

{ "data": { "attributes": { "cancelAtPeriodEnd": true } } }

Corpo opcional; ausente, equivale a cancelAtPeriodEnd: false.

cancelAtPeriodEndO que acontece
false (padrão)status vira CANCELED e canceledAt é gravado. Terminal.
trueO status não muda. Só a flag é gravada. A assinatura segue ACTIVE e sai da lista de findDueForBilling — ou seja, para de gerar fatura, mas ninguém a cancela no fim do período. Fechar o ciclo é sua responsabilidade (§15).

Erros

StatusCódigoQuando
400VALIDATIONTransição inválida — assinatura já CANCELED ou EXPIRED
404NOT_FOUNDAssinatura inexistente na organização

10Início rápido

Do zero à primeira fatura com assinatura e uso medido. As chamadas seguem o ambiente de staging descrito em AMBIENTES.md.

Verificação. A sonda GET /billing/health foi executada contra staging durante a redação deste documento (respondeu 200). As demais chamadas foram conferidas linha a linha contra routes/*.ts e os schemas Zod, mas não foram executadas nesta redação — elas criam dados. Rode em staging, nunca em produção.

1. Autenticar no IAM

TOKEN=$(curl -s -X POST https://iam.bb.stg.catalisa.app/iam/api/v1/users/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "admin@catalisa.app",
    "password": "root123456",
    "organizationId": "b0000000-0000-0000-0000-000000000001"
  }' | jq -r .accessToken)

BASE=https://billing.bb.stg.catalisa.app/billing/api/v1
AUTH="Authorization: Bearer $TOKEN"; JSON="Content-Type: application/json"

2. Conferir que o serviço responde

curl -s https://billing.bb.stg.catalisa.app/billing/health | jq
{ "status": "ok", "service": "billing", "version": "5a7bc56", "timestamp": "2026-08-16T16:29:46.031Z" }

3. Criar a conta de cobrança

ACC=$(curl -s -X POST "$BASE/billing-accounts" -H "$AUTH" -H "$JSON" -d '{
  "data": { "type": "billing-accounts", "attributes": {
    "name": "Correspondente Exemplo LTDA",
    "email": "financeiro@correspondente-exemplo.com.br",
    "currency": "BRL", "taxId": "00000000000191", "taxIdType": "CNPJ" }}}' \
  | jq -r '.data.id')
echo "conta: $ACC"

4. Criar o produto cobrável e o medidor

PROD=$(curl -s -X POST "$BASE/products" -H "$AUTH" -H "$JSON" -d '{
  "data": { "type": "billing-products", "attributes": {
    "name": "Consulta de credito", "pricingModel": "PER_UNIT",
    "unitPrice": 0.80, "unitLabel": "consulta" }}}' | jq -r '.data.id')

METER=$(curl -s -X POST "$BASE/meters" -H "$AUTH" -H "$JSON" -d "{
  \"data\": { \"type\": \"usage-meters\", \"attributes\": {
    \"productId\": \"$PROD\", \"name\": \"Consultas de credito\",
    \"eventName\": \"credito.consulta\", \"aggregationType\": \"SUM\" }}}" \
  | jq -r '.data.id')

5. Criar o plano e amarrar a franquia

PLAN=$(curl -s -X POST "$BASE/plans" -H "$AUTH" -H "$JSON" -d '{
  "data": { "type": "subscription-plans", "attributes": {
    "name": "Correspondente Padrao", "billingInterval": "MONTHLY",
    "billingCycleType": "ANNIVERSARY", "basePrice": 2000.00, "trialDays": 0 }}}' \
  | jq -r '.data.id')

# includedUnits = franquia; priceOverride presente => excedente cobrável
curl -s -X POST "$BASE/plans/$PLAN/items" -H "$AUTH" -H "$JSON" -d "{
  \"data\": { \"type\": \"subscription-plan-items\", \"attributes\": {
    \"productId\": \"$PROD\", \"quantity\": 1,
    \"includedUnits\": 5000, \"priceOverride\": 0.80 }}}" | jq '.data.attributes.items'

6. Assinar

SUB=$(curl -s -X POST "$BASE/subscriptions" -H "$AUTH" -H "$JSON" -d "{
  \"data\": { \"type\": \"subscriptions\", \"attributes\": {
    \"billingAccountId\": \"$ACC\", \"planId\": \"$PLAN\", \"quantity\": 1 }}}" \
  | jq -r '.data.id')

7. Medir consumo

curl -s -X POST "$BASE/usage" -H "$AUTH" -H "$JSON" -d "{
  \"data\": { \"type\": \"usage-events\", \"attributes\": {
    \"billingAccountId\": \"$ACC\", \"eventName\": \"credito.consulta\",
    \"idempotencyKey\": \"consulta-0001\", \"quantity\": 1 }}}" | jq '.meta'
{ "isDuplicate": false }

Reenvie o mesmo comando: a resposta continua 201, mas meta.isDuplicate vira true e nenhum evento novo é criado. É a garantia de que retry não vira cobrança dupla.

8. Conferir o consumo do período

curl -s -G "$BASE/usage/summary" -H "$AUTH" \
  --data-urlencode "billingAccountId=$ACC" \
  --data-urlencode "startDate=2026-08-01T00:00:00Z" \
  --data-urlencode "endDate=2026-08-31T23:59:59Z" | jq '.data.attributes.items'

9. Gerar e finalizar a fatura

INV=$(curl -s -X POST "$BASE/invoices/generate" -H "$AUTH" -H "$JSON" -d "{
  \"data\": { \"attributes\": { \"subscriptionId\": \"$SUB\", \"includeUsage\": true }}}" \
  | jq -r '.data.id')

curl -s "$BASE/invoices/$INV" -H "$AUTH" \
  | jq '{numero: .data.attributes.invoiceNumber, status: .data.attributes.status,
         total: .data.attributes.total, itens: .data.attributes.lineItems}'

curl -s -X POST "$BASE/invoices/$INV/finalize" -H "$AUTH" \
  | jq '.data.attributes.status'
"PENDING"

A fatura deste passo virá sem a linha de uso, porque o consumo do passo 7 ainda não foi agregado em UsageRecord. Isso é esperado e é a armadilha mais importante do módulo — a receita seguinte resolve.

10. Registrar o pagamento

curl -s -X POST "$BASE/payments" -H "$AUTH" -H "$JSON" -d "{
  \"data\": { \"type\": \"payments\", \"attributes\": {
    \"billingAccountId\": \"$ACC\", \"invoiceId\": \"$INV\",
    \"amount\": 2000.00, \"paymentMethod\": \"pix\",
    \"externalRef\": \"E0003816620260816...\" }}}" | jq '.data.attributes.status'

curl -s "$BASE/invoices/$INV" -H "$AUTH" | jq '.data.attributes.status'
"PAID"

Credenciais de staging. Nunca use credencial de produção em documentação ou script de exemplo.


11Receitas

Fechar um ciclo de faturamento com uso medido

Objetivo. Emitir a fatura do ciclo com a assinatura e o consumo excedente nas linhas corretas.

O ponto crítico: existe um passo de agregação entre o evento e a fatura, e ele não roda sozinho.

  UsageEvent (bruto)  ──agregação──▶  UsageRecord  ──generate──▶  Invoice
                          ▲
                          └── UsageService.aggregateUsageForPeriod()
                              não tem endpoint e não tem job

Enquanto o agendador não existir (§15), o fechamento tem duas formas:

Forma A — lançar o consumo direto na fatura. É o caminho que funciona só com a API pública hoje.

# 1. Consultar o consumo agregado do período
USO=$(curl -s -G "$BASE/usage/summary" -H "$AUTH" \
  --data-urlencode "billingAccountId=$ACC" \
  --data-urlencode "meterId=$METER" \
  --data-urlencode "startDate=2026-08-01T00:00:00Z" \
  --data-urlencode "endDate=2026-08-31T23:59:59Z")

QTD=$(echo "$USO" | jq -r '.data.attributes.items[0].quantity')
FRANQUIA=$(echo "$USO" | jq -r '.data.attributes.items[0].includedUnits // 0')
EXCEDENTE=$(( QTD > FRANQUIA ? QTD - FRANQUIA : 0 ))

# 2. Gerar a fatura só com a assinatura
INV=$(curl -s -X POST "$BASE/invoices/generate" -H "$AUTH" -H "$JSON" -d "{
  \"data\": { \"attributes\": { \"subscriptionId\": \"$SUB\", \"includeUsage\": false }}}" \
  | jq -r '.data.id')

# 3. Lançar o excedente como linha USAGE, enquanto ainda é DRAFT
if [ "$EXCEDENTE" -gt 0 ]; then
  curl -s -X POST "$BASE/invoices/$INV/line-items" -H "$AUTH" -H "$JSON" -d "{
    \"data\": { \"type\": \"invoice-line-items\", \"attributes\": {
      \"subscriptionId\": \"$SUB\", \"chargeType\": \"USAGE\",
      \"description\": \"Consultas excedentes (agosto/2026)\",
      \"quantity\": $EXCEDENTE, \"unitPrice\": 0.80,
      \"periodStart\": \"2026-08-01T00:00:00Z\",
      \"periodEnd\": \"2026-08-31T23:59:59Z\" }}}" > /dev/null
fi

# 4. Conferir e finalizar
curl -s "$BASE/invoices/$INV" -H "$AUTH" | jq '.data.attributes | {total, lineItems}'
curl -s -X POST "$BASE/invoices/$INV/finalize" -H "$AUTH" | jq '.data.attributes.status'

Forma B — agregar antes, em processo interno. Se você roda o Billing em monolito ou tem acesso ao container, chame UsageService.aggregateUsageForPeriod(organizationId, billingAccountId, periodStart, periodEnd) no seu agendador e depois use includeUsage: true. É o caminho que o motor foi desenhado para percorrer.

Armadilhas.

  • includeUsage: true sem agregação prévia produz fatura sem uso e sem erro. É silencioso. Se a sua fatura veio só com a mensalidade, é isto.
  • finalize não recalcula o total. Confira total e lineItems antes; depois de finalizada, o caminho é void e refazer.
  • generate não é idempotente. Chamar duas vezes cria duas faturas em DRAFT, cada uma com um número. Só que a segunda não terá as linhas de uso, porque a primeira já marcou os UsageRecord como billed. Anule a duplicada.
  • O número da fatura vem de COUNT(*) + 1 por organização, no formato INV-{ano}-{000001}. Duas gerações concorrentes podem colidir na unicidade (organizationId, invoiceNumber). Serialize o fechamento por organização.
  • invoicePrefix da configuração não é aplicado ao número gerado — o prefixo é sempre INV- (§15).
  • taxAmount e discountAmount são sempre zero. total é igual a subtotal. Imposto e desconto precisam entrar como linha explícita, ou vir de outro sistema.

Vender com franquia dura (bloqueio) ou com excedente cobrado

Objetivo. Escolher, por item de plano, se estourar a franquia trava o uso ou vira cobrança.

A decisão é feita por dois campos em SubscriptionPlanItem, e a lógica está em UsageService.checkUsageLimit:

includedUnitspriceOverrideComportamento na ingestão
definidoausenteBloqueia. Evento que ultrapassaria a franquia recebe 422 com currentUsage, limit, attempted
definidodefinidoNão bloqueia. Excedente é cobrado no fechamento
ausentequalquerNão bloqueia. Tudo é cobrado
# Franquia dura: 200 documentos, sem excedente
curl -s -X POST "$BASE/plans/$PLAN/items" -H "$AUTH" -H "$JSON" -d "{
  \"data\": { \"attributes\": { \"productId\": \"$PROD\", \"includedUnits\": 200 }}}"

# Franquia com excedente cobrado a R$ 1,20
curl -s -X POST "$BASE/plans/$PLAN/items" -H "$AUTH" -H "$JSON" -d "{
  \"data\": { \"attributes\": { \"productId\": \"$PROD\",
    \"includedUnits\": 200, \"priceOverride\": 1.20 }}}"

Armadilhas.

  • O bloqueio só vale se houver assinatura ACTIVE ou TRIAL na conta de cobrança. Sem assinatura, nada é bloqueado — o evento é aceito.
  • A franquia é contada contra o período corrente da assinatura, não contra o mês calendário. Se a assinatura começou dia 12, a janela vai do dia 12 ao dia 12.
  • Um item por produto por plano. Repetir o productId retorna 409 CONFLICT.
  • A troca de bloqueio para excedente é um PATCH no plano? Não — não há rota de atualização de item de plano. Remova o item (DELETE /plans/:planId/items/:itemId) e adicione de novo com priceOverride.
  • Em lote, o bloqueio enxerga os eventos anteriores do mesmo lote, porque o processamento é sequencial. O evento que estoura entra em errors[]; os anteriores ficam gravados.

Registrar pagamento vindo de um gateway externo

Objetivo. Fechar a fatura quando o dinheiro entrou por Pix, boleto ou cartão em outro sistema.

# webhook do gateway confirmou a liquidação
curl -s -X POST "$BASE/payments" -H "$AUTH" -H "$JSON" -d "{
  \"data\": { \"type\": \"payments\", \"attributes\": {
    \"billingAccountId\": \"$ACC\", \"invoiceId\": \"$INV\",
    \"amount\": 3240.00, \"paymentMethod\": \"pix\",
    \"externalRef\": \"E00038166202608161345s0123456789\",
    \"metadata\": { \"origem\": \"payments-bb\", \"txid\": \"...\" } }}}"

# conferir se a fatura fechou
curl -s "$BASE/invoices/$INV" -H "$AUTH" \
  | jq '.data.attributes | {status, total, amountPaid, amountDue}'

Armadilhas.

  • Não há idempotência em pagamento. Reprocessar o mesmo webhook cria um segundo Payment e credita o valor de novo. Guarde o externalRef do seu lado e consulte GET /payments?filter[invoiceId]=... antes de registrar, ou deduplique no seu consumidor de webhook.
  • Pagamento sem invoiceId não abate fatura nenhuma. Ele só incrementa o balance da conta. Use para adiantamento e crédito, sabendo que não existe hoje rotina de aplicar esse saldo numa fatura futura.
  • Pagar mais que o total leva a fatura a PAID e zera amountDue — o excedente não vira crédito automático.
  • Fatura VOID recusa pagamento com 400; fatura já PAID, também.
  • O estorno (POST /payments/:id/refund) muda o Payment para REFUNDED no estorno total, mas não reabre a fatura: ela permanece PAID. Reverter a fatura é decisão sua, e hoje exige void mais reemissão.

Migrar um cliente de plano

Objetivo. Trocar o plano de uma assinatura existente.

Não existe endpoint de troca de plano. PATCH /subscriptions/:id altera apenas quantity. O caminho é cancelar e recriar:

# 1. Cancelar a assinatura atual (imediato)
curl -s -X POST "$BASE/subscriptions/$SUB/cancel" -H "$AUTH" -H "$JSON" \
  -d '{"data":{"attributes":{"cancelAtPeriodEnd":false}}}' | jq '.data.attributes.status'

# 2. Criar a nova
NOVA=$(curl -s -X POST "$BASE/subscriptions" -H "$AUTH" -H "$JSON" -d "{
  \"data\": { \"attributes\": { \"billingAccountId\": \"$ACC\",
    \"planId\": \"$NOVO_PLANO\", \"quantity\": 1, \"skipTrial\": true }}}" | jq -r '.data.id')

# 3. Lançar o rateio manualmente na próxima fatura, se houver

Armadilhas.

  • O rateio não é aplicado automaticamente. O ProrationService calcula rateio proporcional por dias, mas nenhum fluxo o chama — nenhuma linha PRORATION é criada por conta própria. Se você cobra pró-rata, calcule e lance como item da fatura em DRAFT.
  • Uma conta não pode ter duas assinaturas ativas no mesmo plano. A segunda retorna 409 CONFLICT. Planos diferentes, sem restrição.
  • Assinatura em conta de cobrança que não está ACTIVE é recusada com 400.
  • skipTrial: true evita que a migração conceda um novo período de teste — quase sempre é o que você quer numa troca de plano.
  • Alterar basePrice do plano não altera assinaturas existentes: o unitPrice foi congelado na contratação. Isso é proposital e é o que preserva o histórico.

Descobrir por que uma fatura veio errada

Objetivo. Diagnóstico na ordem que resolve mais rápido.

# 1. O que a fatura efetivamente cobrou
curl -s "$BASE/invoices/$INV" -H "$AUTH" \
  | jq '.data.attributes | {status, subtotal, total, amountPaid, amountDue,
                            itens: [.lineItems[] | {chargeType, description, quantity, unitPrice, amount}]}'

# 2. Quanto o cliente consumiu de fato no período
curl -s -G "$BASE/usage/summary" -H "$AUTH" \
  --data-urlencode "billingAccountId=$ACC" \
  --data-urlencode "startDate=2026-08-01T00:00:00Z" \
  --data-urlencode "endDate=2026-08-31T23:59:59Z" | jq '.data.attributes.items'

# 3. O plano tem a franquia e o preço que o contrato diz?
curl -s "$BASE/plans/$PLAN" -H "$AUTH" | jq '.data.attributes.items'

# 4. A assinatura está no período que você acha que está?
curl -s "$BASE/subscriptions/$SUB" -H "$AUTH" \
  | jq '.data.attributes | {status, currentPeriodStart, currentPeriodEnd, quantity, unitPrice}'

Ordem das causas prováveis, da mais para a menos comum:

  1. Faltou linha de uso → o período não foi agregado antes do generate. É a causa número um.
  2. Valor de uso menor que o esperadoincludedUnits descontou; confira o item de plano.
  3. Total não bate com a soma das linhas → alguém finalizou depois de adicionar item por outro caminho; finalize não recalcula.
  4. Consumo aparece no summary mas não na fatura → os UsageRecord correspondentes já estavam billed, cobrados numa fatura anterior.
  5. Período errado → a janela é o período da assinatura, não o mês calendário, e billingCycleType não altera esse cálculo hoje (§15).

billedQuantity e unbilledQuantity do summary não são confiáveis. Hoje o serviço devolve sempre billedQuantity: 0 e unbilledQuantity igual ao total. Use quantity e includedUnits; ignore os outros dois (§15).


12Integração com outros building blocks

Building blockComo se relacionaObrigatório
IAMEmite o token com organizationId e as permissões BILLING_*. As 46 rotas exigem os doisSim
CustomersBillingAccount.personId aponta para Person. Excluir a pessoa deixa personId nulo e a conta marcada como órfãNão
ProductsBillingProduct.externalProductId liga o item cobrável ao produto comercialNão
PaymentsCaptura o dinheiro (Pix, cartão, boleto). O Billing registra o fato com externalRef apontando para a transaçãoNão
Webhooks EngineConsome os eventos publicados no stream Redis e entrega ao sistema do clienteNão
Audit TrailRegistra quem finalizou, anulou e estornou. A fatura já guarda createdBy e finalizedByNão
Pricing EnginePrecifica por risco antes da contratação; o Billing cobra o preço já definidoNão
CommerceVenda e catálogo comercial; o Billing cuida da cobrança recorrente do que foi vendidoNão
   ┌──────────────┐   define quem é o cliente   ┌──────────────────────────┐
   │  Customers   │──────── Person ────────────▶│                          │
   └──────────────┘                             │        BILLING           │
   ┌──────────────┐   define o que se vende     │                          │
   │   Products   │─── externalProductId ──────▶│  BillingAccount          │
   └──────────────┘                             │  Plan + PlanItem         │
   ┌──────────────┐   preço por risco           │  Subscription            │
   │Pricing Engine│──── basePrice ─────────────▶│  UsageMeter/Event/Record │
   └──────────────┘                             │  Invoice + LineItem      │
   ┌──────────────┐   quem pode o quê           │  Payment                 │
   │     IAM      │──── token + BILLING_* ─────▶│                          │
   └──────────────┘                             └───────────┬──────────────┘
                                                            │
                          eventos billing.* no Redis Stream │
                                                            ▼
   ┌──────────────┐   captura o dinheiro        ┌──────────────────────────┐
   │   Payments   │◀─── externalRef ────────────│   Webhooks Engine        │
   │ (Pix/cartão) │                             │  entrega ao ERP/CRM      │
   └──────────────┘                             └──────────────────────────┘
                                                            │
   ┌──────────────┐                                         ▼
   │ Audit Trail  │◀──── quem finalizou/anulou ───────  sistemas do cliente
   └──────────────┘

A divisão de responsabilidade que mais gera dúvida na venda: o Billing decide quanto cobrar e emite o documento; o Payments recebe o dinheiro. São problemas diferentes e vendidos separadamente. Um gateway de mercado — Vindi, Iugu, Asaas, Stripe — resolve o segundo e apenas arranha o primeiro quando a cobrança é de valor fixo. Quando a cobrança depende de consumo medido com franquia, o gateway não tem como saber o valor: alguém precisa calcular, e é isso que o Billing faz.

Eventos publicados — no stream Redis iam-events, consumidos pelo Webhooks Engine.

EventoQuando
billing.account.created · .updated · .deletedCiclo da conta de cobrança
billing.subscription.createdAssinatura criada (TRIAL ou ACTIVE)
billing.subscription.activatedSaída do teste para ativa
billing.subscription.paused · .resumed · .canceledAções de ciclo
billing.subscription.quantity_changedPATCH que alterou a quantidade
billing.invoice.created · .finalized · .paid · .voidedCiclo da fatura
billing.payment.completed · .refundedPagamento e estorno
billing.usage.event_recordedCada evento de uso novo (não os duplicados)
billing.config.updatedConfiguração da organização alterada

billing.invoice.overdue, billing.subscription.expired, billing.payment.created e billing.payment.failed estão declarados no enum BillingEventType, mas nenhum caminho de código os publica hoje. Não construa automação esperando recebê-los (§15).

O evento billing.usage.event_recorded é publicado por evento. Em ingestão de alto volume, filtre a assinatura de webhook por tipo, ou o consumidor vai receber tudo.


13Configuração e operação

Variáveis de ambiente — o Billing não tem variável própria; usa as compartilhadas.

VariávelDescriçãoObrigatóriaPadrão
DATABASE_URLPostgreSQL. O módulo usa o schema billingSim
REDIS_URLRedis. Publicação de eventos e contadores de rate limitSim
JWT_SECRETSegredo HS256 do IAM, para validar o token. Mínimo 44 caracteresSim
PORTPorta em standaloneNão3000 (a porta registrada do Billing é 3010)
DEPLOYMENT_MODEmonolith ou standaloneNãostandalone em main.ts
MODULE_BILLING_URLURL do Billing, usada por outros módulos em standaloneNão''
RATE_LIMIT_ENABLEDLiga/desliga o rate limit globalNãoligado
RATE_LIMIT_GLOBAL_MAXRequisições por janelaNão10000
RATE_LIMIT_GLOBAL_WINDOWJanela em msNão60000

Configuração por organização — em GET/PATCH /billing/api/v1/config, não em variável de ambiente.

CampoDescriçãoPadrãoEfeito real hoje
defaultCurrencyMoeda padrão (3 letras)BRLNão aplicado na criação de fatura, que grava BRL fixo (§15)
paymentTermDaysPrazo de vencimento, 0 a 36530Aplicado em POST /invoices sem dueDate
billingCycleTypeCALENDAR_ALIGNED ou ANNIVERSARYCALENDAR_ALIGNEDNão aplicado ao cálculo de período (§15)
invoicePrefixPrefixo do número da fatura, até 20 caracteresNão aplicado; o prefixo é sempre INV- (§15)
invoiceFooterRodapé, até 1.000 caracteresApenas armazenado

Dependências de infraestrutura

DependênciaPara quê
PostgreSQLSchema billing, 12 tabelas. Valores em Decimal
RedisStream iam-events para publicação; contadores de rate limit
IAMOrigem do token. Validação da assinatura é local, sem chamada de rede

Limites e quotas

LimiteValorOnde
Eventos por lote de uso1 a 1.000Schema Zod e verificação no serviço
Tamanho do corpo1 MBapplyCommonMiddleware. Lote grande pode estourar antes do limite de 1.000 eventos
Página20 por padrão, 100 no máximogetPaginationParams
Rate limit global10.000 req / 60 s (padrão)rateLimitMiddleware
idempotencyKey1 a 255 caracteresSchema Zod
Nome de conta, produto, plano, medidor200 caracteresSchema Zod
eventName do medidor100 caracteres, único por organizaçãoSchema Zod + banco
trialDays, paymentTermDays0 a 365Schema Zod
Precisão monetáriaDecimal(15,2) para valores, Decimal(15,4) para preço unitário e quantidadePrisma

Catálogo de erros — envelope { "error": "<código>", "message": "...", "details": { ... } }.

StatusCódigoSignificaO que fazer
400VALIDATIONCorpo reprovado no Zod, ou regra de estado violadaA mensagem nomeia a regra. Ver os casos abaixo
400VALIDATION"Only DRAFT invoices can be updated / can have line items added / can be finalized"A fatura já foi finalizada. Anule e refaça
400VALIDATION"Cannot void paid or already voided invoices"Fatura paga não se anula. Use estorno do pagamento
400VALIDATION"Cannot pay voided invoice" / "Invoice is already paid"Confira o status antes de registrar pagamento
400VALIDATION"Cannot cancel/pause/resume subscription in X status"Transição inválida. Ver o grafo na §8
400VALIDATION"Billing account is not active" / "Subscription plan is not active"Ative a conta ou o plano antes de assinar
400VALIDATION"Refund amount exceeds available amount"Já houve estorno parcial. Consulte refundedAmount
401UNAUTHORIZEDToken ausente, inválido ou expiradoRenove pelo refresh token do IAM
403Permissão BILLING_* faltando no tokenConfira o papel e o que a organização contratou
403Token sem organizationIdAutentique informando a organização. Vale para as 46 rotas
404NOT_FOUNDRecurso inexistente ou de outra organizaçãoConfira o id. Recurso de outro tenant é indistinguível de inexistente, de propósito
409CONFLICTE-mail de conta, nome de produto/plano, eventName ou produto já no planoEscolha outro valor
409CONFLICT"Active subscription with this plan already exists"A conta já assina esse plano
422VALIDATIONFranquia de uso estouradadetails traz currentUsage, limit, attempted. Ofereça upgrade ou libere excedente
429Rate limit globalRecuo exponencial. Em ingestão de uso, prefira o lote
500INTERNALFalha de banco ou de publicação de eventoVerifique PostgreSQL e Redis

Observabilidade.

  • GET /billing/health responde { status, service, version, timestamp } e não toca banco nem Redis — serve de liveness, não de readiness.
  • Todo evento publicado passa pelo EventPublisher, que injeta contexto de trace (traceId, spanId) nos metadados e conta a métrica de evento publicado. Uma fatura pode ser rastreada da ingestão do uso até a entrega do webhook.
  • Bloqueio de franquia gera log de aviso [USAGE BLOCKED] account=... meter=... current=... limit=... attempted=.... É o sinal para acompanhar cliente prestes a estourar o plano — e a base natural de um alerta comercial.
  • Estouro de rate limit vai para logSecurityEvent como RATE_LIMIT_EXCEEDED.
  • Não há métrica de negócio exposta (faturamento emitido, faturas vencidas). Monte pelo banco ou pelos eventos.

14Segurança e compliance

Isolamento entre tenants. O organizationId é claim assinado no JWT e nunca é lido do corpo da requisição. As 46 rotas do módulo declaram requireOrganization — foi conferido rota a rota, sem exceção. No repositório, organizationId entra na cláusula WHERE de toda consulta por id: findById(id, organizationId) usa findFirst({ where: { id, organizationId } }), e não findUnique({ where: { id } }). Na prática, pedir o id de uma fatura de outra organização devolve 404, não 403 — indistinguível de "não existe", o que evita confirmar a existência de recursos alheios.

Dados pessoais. BillingAccount guarda nome, e-mail, endereço de cobrança e identificação fiscal (taxId, tipicamente CPF ou CNPJ). UsageEvent.properties é JSONB livre e pode receber dado pessoal se você colocar lá — o módulo não filtra o conteúdo. A recomendação é gravar identificador opaco, não CPF nem nome, porque esse é o dado de maior volume e menor controle de retenção do módulo.

Enquadramento LGPD. taxId e e-mail de cobrança são dados pessoais quando a conta é de pessoa física. A exclusão é lógica (deletedAt), e não há expurgo automatizado — atender pedido de eliminação exige processo explícito. Há um detalhe relevante: BillingAccount.personId referencia Person com onDelete: SetNull, e existe personOrphaned para marcar a conta que perdeu o vínculo. O campo existe e é exposto na API, mas nenhum código o preenche hoje (§15) — não construa relatório de conformidade em cima dele.

Retenção fiscal versus direito ao esquecimento. Fatura emitida é documento com obrigação de guarda, e por isso a exclusão do módulo é lógica em tudo que tem valor histórico. Isso é intencional e é o comportamento correto do ponto de vista fiscal; significa que "apagar o cliente" não apaga o que foi cobrado dele.

Integridade do documento de cobrança. A fatura finalizada não aceita alteração — PATCH e novos itens são recusados fora de DRAFT. finalizedBy guarda o id do usuário que finalizou, e createdBy o de quem criou. Anular exige BILLING_INVOICES_VOID, permissão separada de UPDATE exatamente para poder ser concedida a menos gente.

Segregação de funções pelas permissões. O vocabulário do IAM separa as ações de risco: BILLING_INVOICES_FINALIZE (emitir), BILLING_INVOICES_VOID (anular), BILLING_PAYMENTS_REFUND (estornar) e BILLING_SUBSCRIPTIONS_CANCEL (cancelar) são permissões próprias, não derivadas de UPDATE. Isso permite montar papéis em que o atendimento consulta, o financeiro emite e só a gerência anula e estorna.

Uma ressalva que o desenho de papéis precisa conhecer: POST /payments exige BILLING_PAYMENTS_READ, e não BILLING_PAYMENTS_RECORD. Quem tem leitura de pagamentos consegue registrar pagamento e, com isso, marcar fatura como paga. Ao montar um papel de consulta, considere isso até que a permissão correta seja aplicada (§15).

Dados sensíveis de pagamento. O módulo não guarda número de cartão, CVV nem dado de instrumento de pagamento. Payment guarda valor, método como texto livre ("pix", "cartao") e externalRef — a referência da transação no gateway. Isso mantém o Billing fora do escopo de PCI-DSS, e é uma razão de peso para não começar a gravar dado de cartão em metadata.

Superfície de rede. Em standalone, applyCommonMiddleware aplica limite de corpo de 1 MB, CORS fail-safe (sem origens configuradas em produção, bloqueia cross-origin), cabeçalhos de segurança e rate limit global. Sem ele, o módulo subiria sem nenhuma dessas proteções, porque os middlewares globais do monolito não alcançam o processo standalone.

Autenticação. Não há rota pública além de /billing/health. Todo o resto exige Bearer JWT válido emitido pelo IAM, com permissão específica e organização no token.


15Limitações conhecidas

O Billing tem uma API de faturamento completa e uma automação ausente. Esta seção é a leitura obrigatória antes de qualquer proposta comercial.

Automação — o bloco mais importante

LimitaçãoImpactoSituação
Não há agendador nem consumidorsrc/billing/jobs/ e src/billing/consumers/ existem, mas todos os export estão comentados. Nada acontece sozinho no módulo. Fechamento de ciclo, agregação de uso, marcação de vencido e ativação de teste dependem de chamada externaRoadmap. É o que separa o status beta de producao
aggregateUsageForPeriod não tem endpoint nem jobO passo que transforma UsageEvent em UsageRecord — pré-requisito para generate com includeUsage: true — só é alcançável de dentro do processo. Pela API pública, o caminho é lançar o uso como item de fatura (§11)Roadmap
billSubscriptionsDue não tem endpoint nem jobO método que fatura todas as assinaturas vencidas de uma organização e avança o período existe e é testado, mas nada o chama. Faturar em massa exige um generate por assinaturaRoadmap
activateFromTrial não tem endpoint nem jobAssinatura em TRIAL não sai do teste sozinha e não vira ACTIVE por nenhuma rota. Ela permanece em TRIAL indefinidamenteRoadmap. Evite trialDays até que exista
OVERDUE nunca é gravadoO status existe no enum e a consulta findOverdue está escrita, mas sem chamador. Fatura vencida continua PENDING. Régua de inadimplência é suaRoadmap
EXPIRED nunca é gravadoDeclarado como transição válida de TRIAL e ACTIVE, mas nenhum código o gravaRoadmap
cancelAtPeriodEnd: true não conclui o cancelamentoGrava a flag, mantém ACTIVE e tira a assinatura da fila de faturamento. Ninguém a cancela no fim do períodoRoadmap
CANCELED de fatura nunca é gravadoO enum tem CANCELED e VOID; só VOID é usado. Trate CANCELED como inexistentePor design na prática

Precificação e cálculo

LimitaçãoImpactoSituação
Precificação por faixas não é aplicadaBillingProduct.tiers é gravado, retornado pela API e ignorado no cálculo. pricingModel (TIERED, VOLUME, PACKAGE, FLAT_FEE) é rótulo de catálogo: a fatura sempre calcula quantidade × preço unitário. Não venda preço por faixaRoadmap. É a lacuna funcional mais visível contra Stripe Billing e Chargebee
A agregação é sempre somaUsageMeter.aggregationType aceita SUM, MAX, LAST, AVERAGE e COUNT_DISTINCT, mas aggregateByMeter usa _sum em todos os casos. propertyKey é gravado e não usado. Medição de pico ou de valor final entrega o total somado — silenciosamente erradoRoadmap. Até lá, use apenas SUM
Rateio calculado, nunca aplicadoProrationService calcula rateio, crédito de downgrade e cobrança de upgrade, com testes. Nenhum fluxo o chama e nenhuma linha PRORATION é criada automaticamenteRoadmap
Sem tributos e sem descontotaxAmount e discountAmount são sempre zero; total é igual a subtotal. Nenhuma rota os altera. Imposto e desconto entram como linha explícitaRoadmap
Sem emissão fiscalNão emite NF-e nem NFS-e e não integra com emissor. É diferença relevante contra Vindi, Iugu e Asaas no BrasilFora do escopo atual
Sem troca de planoPATCH /subscriptions/:id altera só quantity. Migrar de plano é cancelar e recriar (§11)Roadmap
billingCycleType não afeta o cálculoCALENDAR_ALIGNED e ANNIVERSARY são gravados no plano e na configuração, mas calculateNextPeriodEnd sempre soma o intervalo à data de início — ou seja, sempre aniversárioRoadmap
Fatura sempre em BRLInvoiceService.createInvoice grava currency: 'BRL' fixo, ignorando defaultCurrency da configuração e a moeda da conta. Fatura vinda de generate usa a moeda da assinatura, então os dois caminhos podem divergirRoadmap
invoicePrefix não é aplicadoO número é sempre INV-{ano}-{sequencial}. O prefixo configurado é só armazenadoRoadmap
Número de fatura por COUNT(*) + 1Duas gerações concorrentes na mesma organização podem colidir na unicidade. Serialize o fechamento por organizaçãoRoadmap

Operação e dados

LimitaçãoImpactoSituação
summary devolve billedQuantity e unbilledQuantity fixosSempre 0 e o total, respectivamente, independentemente do que já foi faturado. Use quantity e includedUnitsRoadmap
Pagamento sem idempotênciaReprocessar webhook cria segundo Payment e credita de novo. Deduplique do seu lado (§11)Roadmap
Estorno não reabre a faturaEstorno total marca o Payment como REFUNDED, mas a fatura permanece PAIDRoadmap
Saldo da conta não é aplicado em faturabalance é incrementado por pagamento sem invoiceId, e não existe rotina que use esse crédito numa fatura futuraRoadmap
personOrphaned nunca é preenchidomarkPersonOrphaned existe no repositório e não tem chamador. O campo é sempre falseRoadmap
Exclusão de plano não verifica assinaturas ativasDELETE /plans/:id faz exclusão lógica sem checar se há assinatura viva usando o plano. Mesma coisa para produto e medidorRoadmap
POST /payments exige a permissão erradaUsa BILLING_PAYMENTS_READ enquanto BILLING_PAYMENTS_RECORD existe no vocabulário do IAM e não é usada. Quem lê pagamentos consegue registrar pagamentoPendência conhecida — considere no desenho de papéis (§14)
/health não verifica dependênciasResponde estático. Banco fora do ar não derruba a sondaPor design; use como liveness
Sem dunning e sem portal do assinanteSem retry de cobrança recusada, régua de e-mail ou tela de autoatendimentoFora do escopo atual
Eventos declarados e não publicadosbilling.invoice.overdue, billing.subscription.expired, billing.payment.created e billing.payment.failed estão no enum e nenhum código os emiteConsequência da ausência de agendador
Lote grande pode estourar o limite de corpoO schema aceita 1.000 eventos, mas bodyLimit corta em 1 MB. Com properties volumoso, o teto real é bem menorPor design; divida o lote

16Perguntas frequentes

Qual a diferença entre o Billing e o Payments? Preciso dos dois?

O Billing decide quanto cobrar e emite o documento; o Payments recebe o dinheiro. Se você cobra valor fixo e já tem gateway, talvez precise só do gateway. Se o valor depende de consumo medido com franquia, alguém precisa calcular — e o gateway não sabe fazer isso. Nesse caso, os dois: o Billing calcula e emite, o Payments captura, e você registra o pagamento no Billing com externalRef apontando para a transação.

O Billing substitui a Vindi, a Iugu ou o Asaas?

Não diretamente. Essas empresas são gateways com recorrência acoplada: elas capturam o dinheiro e emitem nota fiscal, coisas que o Billing não faz. O que o Billing substitui é a camada de cálculo — medição de uso, franquia, excedente, montagem da fatura —, que é justamente onde esses produtos são rasos porque foram desenhados para cobrança de valor fixo. A combinação comum é Billing calculando e um gateway brasileiro recebendo.

Por que a minha fatura veio sem a linha de uso?

Porque o consumo não foi agregado antes. POST /invoices/generate com includeUsage: trueUsageRecord, não UsageEvent, e a agregação não roda sozinha hoje. Sem o passo intermediário, a fatura sai só com a assinatura — e sem erro nenhum. A receita "Fechar um ciclo de faturamento" na §11 mostra os dois caminhos.

O que acontece se a minha integração reenviar o mesmo evento de uso?

Nada é cobrado duas vezes. idempotencyKey é obrigatório e único por organização; o reenvio devolve 201 com meta.isDuplicate: true e o evento original. Use um identificador natural da operação — o id da consulta, do documento, da mensagem —, nunca um UUID gerado na hora da chamada, que mudaria a cada retry.

Dá para bloquear o cliente quando ele estoura a franquia?

Dá, e é o comportamento padrão quando o item de plano tem includedUnits sem priceOverride: o evento que ultrapassaria o limite recebe 422 com currentUsage, limit e attempted, e a sua aplicação usa esses campos para oferecer upgrade. Adicionando priceOverride ao mesmo item, o bloqueio some e o excedente passa a ser cobrado. Só vale se houver assinatura ACTIVE ou TRIAL na conta.

Posso cobrar por faixas — mais barato acima de dez mil unidades?

Hoje não. pricingModel aceita TIERED e VOLUME, e tiers é gravado e devolvido pela API, mas o cálculo da fatura sempre usa quantidade × preço unitário. Não venda preço por faixa (§15). O contorno é criar um produto por faixa e lançar as linhas de fatura conforme a regra, o que funciona mas é trabalho manual.

Como fica a assinatura em período de teste?

Ela nasce em TRIAL quando o plano tem trialDays e você não passou skipTrial: true — e fica em TRIAL. A transição para ACTIVE existe no serviço, mas nenhuma rota a expõe e nenhum job a executa. Enquanto isso não muda, a recomendação prática é criar a assinatura com skipTrial: true e controlar o período de teste na sua aplicação.

Consigo cancelar ao fim do período, como no Stripe?

Consegue marcar, mas não consegue concluir. cancelAtPeriodEnd: true grava a flag e a assinatura continua ACTIVE, saindo da fila de faturamento. Ninguém a cancela quando o período acaba, porque não há agendador. Se o seu produto depende disso, controle a data do seu lado e chame o cancelamento imediato quando chegar a hora.

A fatura finalizada pode ser corrigida?

Não. Depois de finalize, ela não aceita PATCH nem novos itens. O caminho é POST /invoices/:id/void e emitir outra — que é o comportamento correto para um documento de cobrança e o que permite explicar a um auditor o que foi cobrado e quando. Confira o total antes de finalizar: finalize congela o que os itens somaram e não recalcula nada.

O Billing calcula imposto?

Não. taxAmount e discountAmount são sempre zero e total é igual a subtotal. Se você precisa de ISS, retenção ou desconto na fatura, lance como linha explícita enquanto a fatura está em DRAFT, ou calcule no sistema fiscal. Emissão de NF-e e NFS-e também está fora do escopo.

Quanto custa? Vocês cobram percentual do meu faturamento?

A precificação está em definição, e não há tabela para citar. O compromisso de desenho, esse sim, está tomado: a Catalisa não cobra percentual sobre o faturamento processado pelo Billing — que é como Stripe Billing (0,7%), Chargebee (0,80%), Metronome (0,8% mais eventos) e Recurly (0,9% mais mensalidade) precificam, conforme as páginas públicas consultadas em 2026-08-16. Os direcionadores do nosso custo são operacionais: assinaturas ativas, eventos ingeridos, faturas emitidas.

Honestidade obriga a completar: não somos os únicos a fazer isso. Lago e Kill Bill também não cobram percentual da sua receita, e o Kill Bill anuncia exatamente esse posicionamento na página inicial. A diferença é que os dois são projetos de código aberto que você hospeda e opera; o Billing vem operado e dentro de uma plataforma onde identidade, clientes e catálogo já estão resolvidos.

Vale a pena construir isso internamente se as ferramentas de mercado existem?

A pergunta mudou de forma em 2026. As três ferramentas independentes de medição de uso — Metronome, Orb e m3ter — foram compradas em seis meses por Stripe, Adyen e Salesforce (§4, Caso 5). Quem hoje quer cobrança por consumo compra de um processador de pagamento, que cobra percentual da receita, ou opera um projeto de código aberto. O Billing existe para dar uma terceira opção dentro da plataforma que o cliente já usa. Dito isso, a §15 é longa por um motivo: se você precisa hoje de faixas de preço, tributo na fatura e fechamento automático, a ferramenta de mercado entrega e o Billing ainda não.

Por que o status é beta se ele já está em staging respondendo?

Porque a API está pronta e a automação não. As 46 rotas funcionam, estão cobertas por testes de unidade e integração e o isolamento por tenant foi conferido rota a rota. O que falta é o agendador que fecharia o ciclo, agregaria o uso, marcaria o vencido e ativaria o teste. Chamar isso de produção seria pedir para o cliente descobrir na implantação — a §15 lista cada item.


Padrão: PADRAO-DOCUMENTACAO.md · Índice: INDEX.md

Building blocks relacionados