Catalisa.
Building blocks/FinanceiroBeta

Pricing Engine

Taxa por faixa de risco, com tarifas, comissões e seguros no mesmo cálculo

11
Endpoints
2
Entidades
0
Provedores
Tenant
Escopo
3012
Porta

A sua política de preço para de morar em planilha e em `if` dentro da esteira. Você declara as faixas de score e as regras comerciais por API, e cada proposta recebe a taxa, as tarifas, as comissões e o seguro em uma única chamada — com a faixa e a regra que decidiram o preço vindo na resposta.

Para quem é
  • Financeiras e fintechs de crédito que diferenciam taxa por score, canal e prazo
  • Bancos digitais e SCDs que precisam mudar tabela de preço sem esperar janela de release
  • Times de risco e produto que precisam explicar, contrato a contrato, por que aquela taxa
Substitui
  • Planilha de tabela de taxas compartilhada entre risco, produto e comercial
  • Bloco de `if` de score e prazo codificado dentro do serviço de originação
  • Licença de motor de decisão de terceiro usada só para resolver preço
O que não é
  • Um motor de decisão de crédito — quem aprova ou recusa é o Decision Platform
  • Uma calculadora financeira — quem calcula parcela, IOF e CET é o Calculations Engine
  • Um catálogo de produtos — quem guarda o produto e o teto de taxa é o Banking Product Portfolio
  • Um modelo de score — ele consome o score que você já tem, não o produz

01Resumo executivo

O Pricing Engine responde uma pergunta só, e responde sempre da mesma forma: quanto custa emprestar para este cliente, neste produto, neste valor e neste prazo. Você cadastra as faixas de score com a taxa de cada uma, cadastra as regras comerciais com as tarifas, comissões e seguros, e a esteira passa a perguntar o preço por API em vez de consultar uma planilha.

Na prática, é a diferença entre uma financeira que muda a taxa da faixa de score 700–799 com um PATCH e uma que abre um chamado de engenharia. E, na hora que o cliente reclamar da taxa, a resposta da API já traz o nome da faixa e o nome da regra que a produziram — não um número solto.

Está em beta desde novembro de 2025, com host publicado em staging. As duas tabelas, as onze rotas e o cálculo de taxa, tarifa, comissão e seguro estão implementados e cobertos por testes unitários e de integração. O que não está: regra do tipo DECISION, que existe no schema mas nunca chama o Decision Engine. Leia a §15 antes de prometer isso a cliente.

AtributoValor
Identificadorpricing-engine
CategoriaFinanceiro
EscopoTenant (exige organizationId no token em todas as 11 rotas)
Porta (standalone)3012
Path alias@pricing-engine
Prefixo HTTP/pricingnão /pricing-engine
Schema PostgreSQLpricing
StatusBeta desde 2025-11
Depende dePostgreSQL, IAM

02O problemanegócio

O cenário. Uma financeira não vende crédito a um preço só. Vende a 1,89% ao mês para quem tem score alto e vem pelo aplicativo, a 2,49% para quem tem score médio e vem pelo correspondente, com TAC de R$ 89,90 num canal e isenta em outro, com comissão de 1,5% para o parceiro e prestamista obrigatório acima de R$ 20 mil. Isso não é uma tabela — são dezenas de combinações que mudam quando o custo de funding muda, quando a inadimplência da safra piora, ou quando o comercial fecha um acordo novo.

O que trava hoje.

  • A tabela de preço mora em planilha. O time de risco mantém um .xlsx, alguém exporta para o time de TI, e TI transcreve para constante no código. Cada transcrição é uma chance de erro, e a taxa de erro de célula em planilhas corporativas é conhecida e alta — a literatura de auditoria de planilhas de Raymond Panko documenta erro em uma fração relevante das planilhas operacionais examinadas (Panko, *What We Know About Spreadsheet Errors*).
  • Mudar a taxa exige deploy. Ajustar a faixa de score 700–799 em 20 pontos-base vira ticket, sprint e janela de release. O comercial pede na segunda e recebe no mês seguinte, quando o concorrente já ajustou.
  • Ninguém consegue explicar a taxa depois. Seis meses após a contratação, a pergunta "por que este cliente pagou 2,49%" não tem resposta reproduzível: a planilha foi sobrescrita, o if foi refatorado e o número está sozinho no contrato.
  • Tarifa, comissão e seguro são calculados em três lugares. A taxa sai de um sistema, a TAC de outro, o prestamista de uma planilha da seguradora. Quando os três discordam, o cliente descobre na fatura.
  • Faixa de score é fácil de errar e caro de errar. Uma sobreposição de faixas mal declarada — 700–799 e 750–850 ativas ao mesmo tempo — faz clientes iguais receberem preços diferentes conforme a ordem em que o banco devolveu as linhas.

O custo de não resolver. O custo direto é o tempo de resposta comercial: enquanto a taxa não muda, cada proposta perdida por preço é receita que não volta. O custo indireto é regulatório e jurídico: o preço cobrado precisa ser reconstruível na data do contrato, e a carteira de crédito do Sistema Financeiro Nacional somava R$ 7,1 trilhões em janeiro de 2026 (BACEN, Estatísticas Monetárias e de Crédito). Precificação que não se explica é a matéria-prima de ação revisional.


03Proposta de valornegócio

AntesDepois
A tabela de taxas é uma planilha transcrita para o códigoFaixas e regras são registros consultáveis por API
Mudar a taxa de uma faixa é ticket, sprint e deployPATCH na faixa, vale na próxima chamada
"Por que essa taxa?" é uma investigaçãoA resposta traz riskBand e pricingRule que decidiram
Taxa, tarifa, comissão e seguro vêm de três sistemasUma chamada devolve os quatro, com o detalhe item a item
Cliente sem faixa aplicável gera erro na esteiraDevolve 200 com approved: false e o motivo em texto

A faixa e a regra vêm na resposta. Todo cálculo devolve riskBand.id, riskBand.name e pricingRule.id, pricingRule.name. Quem for auditar o contrato não precisa reconstruir a política — precisa ler dois campos e buscar os dois registros.

Tarifa, comissão e seguro são parte do preço, não um adendo. As três configurações vivem em JSONB dentro da regra de precificação e são calculadas na mesma passada da taxa, com o detalhe item a item na resposta. A TAC de R$ 89,90 e a comissão de 1,5% do parceiro saem do mesmo lugar que a taxa.

A recusa é um resultado, não uma exceção. Score fora de qualquer faixa, valor fora de qualquer regra, ou taxa acima do teto da faixa devolvem 200 com approved: false e rejectionReason em texto. A sua esteira trata isso como decisão de negócio, não como falha de integração.

Cada tenant vê só a própria política. Toda consulta filtra por organizationId vindo do token assinado, nunca do corpo. Um correspondente não descobre a tabela de outro.

Exclusão é lógica. Faixa e regra excluídas ficam com deletedAt preenchido e a linha preservada. A política de ontem continua consultável no banco depois de a de hoje entrar.


04Casos de uso reaisnegócio

Caso 1 — Uma financeira ajusta a curva de preço em uma tarde Cenário ilustrativo

Contexto. Financeira de crédito pessoal com quatro faixas de score por produto e três produtos ativos. O custo de funding subiu 40 pontos-base.

A dor. No desenho anterior, as taxas eram um mapa de constantes dentro do serviço de originação. Repassar o custo significava editar código, abrir PR, esperar revisão e aguardar a janela de release da quinta-feira. Entre a decisão do comitê e a taxa nova em produção passavam-se de dez a quinze dias — período em que a operação vendia crédito com margem negativa e ninguém conseguia parar isso sem derrubar a esteira.

A solução com o BB. As quatro faixas viram registros RiskBand, uma por produto, com minScore, maxScore, baseRate e rateSpread. Repassar o custo é um PATCH /pricing/api/v1/risk-bands/:id por faixa alterando baseRate. A esteira continua chamando POST /pricing/api/v1/calculate e recebe a taxa nova na chamada seguinte, sem deploy.

O resultado. O ciclo entre decisão de comitê e taxa em produção passa de dias para minutos. E a alteração deixa rastro: o PATCH publica o evento pricing.risk_band.updated com o diff, que o Audit Trail consome.

Caso 2 — Um correspondente descobre por que a proposta saiu a 2,49% Cenário ilustrativo

Contexto. Financeira de consignado que origina por correspondentes bancários. O gerente de um correspondente reclama que a mesma proposta que ele viu a 1,99% na semana passada saiu a 2,49%.

A dor. Sem faixa e regra explícitas na resposta, a única forma de responder era pedir para o time de dados reconstruir a política vigente naquele dia. A resposta chegava em três dias, sem certeza, e o correspondente já tinha perdido o cliente.

A solução com o BB. A resposta de POST /pricing/api/v1/calculate carrega riskBand: { id, name, minScore, maxScore } e pricingRule: { id, name, ruleType }. A esteira grava esses dois identificadores junto com a proposta. Responder ao correspondente vira GET /pricing/api/v1/risk-bands/:id e GET /pricing/api/v1/pricing-rules/:id — a faixa aplicada, com o intervalo de score, e a regra aplicada, com as tarifas.

O resultado. A pergunta "por que essa taxa" tem resposta em uma chamada, com o intervalo de score que o cliente caiu e a lista de tarifas. E fica claro para o próprio correspondente o que ele precisa fazer para melhorar o preço do cliente.

Caso 3 — Um seguro prestamista para de ser calculado em planilha Cenário ilustrativo

Contexto. Fintech de crédito com prestamista obrigatório acima de determinado valor, cotado por uma seguradora parceira a um percentual mensal do valor financiado.

A dor. O percentual vivia numa planilha do time de parcerias. A esteira calculava a parcela sem o seguro, o time comercial somava o prêmio à mão na proposta, e a divergência entre os dois números aparecia quando o cliente comparava a proposta com o contrato.

A solução com o BB. O prestamista vira uma entrada em insurances da regra de precificação, com insuranceType: "LIFE", monthlyRate e isMandatory. A resposta do cálculo passa a trazer insurances[] com monthlyPremium e totalPremium, e totalInsurance consolidado, ao lado da taxa e das tarifas.

O resultado. Um único número, produzido num único lugar, que a proposta e o contrato leem da mesma resposta de API. A armadilha honesta está na §15: o prêmio é calculado sobre o valor solicitado, não sobre o saldo devedor decrescente — se a sua apólice é sobre saldo devedor, este cálculo superestima.

Caso 4 — O mercado chama isso de risk-based pricing e cobra caro por ele Referência de mercado

Contexto. Precificação diferenciada por risco é prática consolidada e regulada. Nos Estados Unidos, o Risk-Based Pricing Rule, editado em conjunto pelo Federal Reserve e pela FTC sob o FCRA, obriga o credor a avisar o consumidor quando lhe oferece condições piores por causa do relatório de crédito (FTC, Risk-Based Pricing Rule). A regra existe justamente porque a prática é universal.

A dor do mercado. A categoria de fornecedores que resolve isso — FICO, Provenir, Earnix, Zest AI — vende plataforma de decisão inteira, com contrato corporativo negociado. Nenhum dos quatro publica tabela de preço, o que por si só diz o porte do comprador que eles atendem. Uma financeira de médio porte que só quer parar de manter tabela de taxa em planilha não tem uma opção proporcional ao problema.

Como a Catalisa endereça. Este building block é a peça de preço isolada: duas tabelas, onze rotas, cálculo de taxa mais tarifa mais comissão mais seguro. Ele não tem editor visual de fluxo, não tem marketplace de dados e não treina modelo. Ele tem a faixa, a regra e a resposta rastreável, contratáveis por API ao lado do que a instituição já opera.

O resultado. O padrão de mercado sem o porte de contrato do mercado. O trade-off honesto está na §5: quando o problema é modelar risco, e não aplicar preço, eles ganham.


05Mercado e diferenciaisnegócio

Panorama. O mercado resolve precificação de crédito em dois extremos. De um lado, plataformas de decisão corporativas — FICO, Provenir, Earnix — que fazem score, política, orquestração, simulação e preço, com licença negociada e projeto de implantação. Do outro, a planilha: a esmagadora maioria das operações de médio porte mantém a tabela de taxas num arquivo compartilhado e transcreve para o código. Entre os dois não há muita coisa, e é nesse vão que este building block se coloca.

Vale separar duas coisas que costumam ser vendidas juntas. Modelar risco — decidir qual score o cliente tem e qual a probabilidade de inadimplência — é o produto da FICO e da Zest AI. Aplicar preço — dado o score, qual taxa, qual tarifa, qual comissão — é uma mecânica determinística e auditável. O Pricing Engine faz só a segunda, e não pretende fazer a primeira.

CritérioCatalisa PricingFICO PlatformProvenirEarnixZest AI
EscopoSó a aplicação do preçoDecisão de crédito completaOrquestração de decisãoPrecificação analíticaModelagem de underwriting
Modelagem de scoreNão faz — consome o seuSim, é o núcleoVia integraçõesSimSim, é o núcleo
Faixa de score → taxaSim, por APISimSimSimIndireto
Tarifa, comissão e seguro no mesmo cálculoSimDepende da implantaçãoDepende do fluxoSimNão
Otimização de preço (elasticidade)NãoParcialNãoSim, é o núcleoNão
Editor visual de regraNãoSimSimSimParcial
Rastreabilidade da decisão de preçoFaixa e regra na respostaSim, extensaSimSimExplicabilidade de modelo
PreçoPrecificação em definiçãoNão publicadoNão publicadoNão publicadoNão publicado
Porte de entradaBuilding block avulsoProjeto corporativoProjeto de plataformaProjeto corporativoContrato SaaS negociado

Nenhum dos cinco fornecedores publica tabela de preço. Consulta feita em 2026-08. As linhas sobre o comportamento deles descrevem o posicionamento público de cada um e não substituem uma avaliação técnica direta com o fornecedor.

Nossos diferenciais

  1. A justificativa do preço vem junto com o preço. riskBand e pricingRule estão na mesma resposta que a taxa, com nome e identificador. Não é uma funcionalidade de auditoria bolt-on — é o formato do retorno, e por isso não tem como o integrador esquecer de guardar.
  2. Tarifa, comissão e seguro entram no mesmo cálculo. Numa operação de crédito brasileira, a taxa nominal é a menor parte da conversa. Fornecedor que resolve só a taxa devolve o problema mais chato — consolidar TAC, comissão de parceiro e prestamista — para o time de integração.
  3. É a peça, não a plataforma. Onze rotas, duas tabelas, dependência de PostgreSQL e do IAM. Entra ao lado da esteira que existe, sem projeto de substituição e sem obrigar a trocar o motor de decisão que já roda.
  4. Recusa é 200, não 4xx. Cliente fora de faixa devolve approved: false com motivo em texto. A esteira distingue "não tem preço para este perfil" de "a integração quebrou" sem inspecionar mensagem de erro.

Quando escolher o concorrente. Se o seu problema é modelar risco — construir e governar o modelo que produz o score, monitorar drift, provar explicabilidade a um regulador — escolha FICO ou Zest AI, e use este building block depois deles, se usar. Se você precisa de otimização de preço com elasticidade de demanda, curva de aceitação e simulação de cenário de margem, o Earnix faz isso e nós não fazemos nem temos no roadmap. Se a exigência é orquestrar a decisão inteira — bureau, política, árvore, fallback, retentativa, editor visual para o time de risco mexer sem engenharia — o Provenir entrega isso pronto, e o equivalente na Catalisa é o Decision Platform somado ao Decision Engine, não este bloco. E se a sua política de preço depende de uma regra que não cabe em faixa de score mais faixa de valor e prazo, hoje você precisa de um motor de regras de verdade: o tipo DECISION existe no schema daqui, mas não está implementado (§15). Este bloco ganha quando o problema é aplicar uma política de preço declarada, de forma rastreável, sem contratar uma plataforma inteira.


06Modelo de cobrança e ROInegócio

Unidade de cobrança. Precificação em definição. Não há preço fechado para este building block e não vamos inventar um.

O que dispara custo. Três drivers em consideração:

DriverPor que é justo
Chamadas de cálculo de preçoEscala com o volume de propostas, que é o valor entregue
Faixas de risco ativasMede a granularidade da política — quem segmenta mais aproveita mais
Regras de precificação ativasMede a complexidade comercial: canais, campanhas, convênios

Comparação de custo. Não dá para fazer comparação numérica honesta: nenhum dos cinco análogos publica tabela de preço. FICO, Provenir, Earnix, Zest AI e Neurotech trabalham com contrato negociado. O que dá para comparar é a forma do custo, e a diferença é material:

Catalisa PricingPlataforma de decisão corporativa
Custo de licençaPrecificação em definiçãoContrato negociado, não publicado
Projeto de implantaçãoIntegração de APIProjeto de meses, com consultoria
O que vem juntoSó a aplicação do preçoScore, política, orquestração, simulação
Mudar uma taxaPATCH numa faixaDepende do modelo de governança contratado

Estimativa de forma, não de valor, consultada em 2026-08. Nenhum número de fornecedor foi extrapolado aqui porque nenhum é público.

ROI. A conta de guardanapo tem duas linhas.

A primeira é o ciclo de alteração de preço. Numa operação em que mudar uma taxa custa um ticket, uma sprint e uma janela de release, o intervalo entre a decisão do comitê e o preço novo em produção é de dias a semanas. Enquanto isso, ou a instituição vende com margem menor que a decidida, ou perde proposta para quem já ajustou. Aqui a alteração é um PATCH por faixa. Uma financeira que revisa a curva de preço mensalmente recupera de dez a quinze dias de defasagem por revisão.

A segunda linha é a que ninguém orça até precisar: reconstruir por que um contrato específico saiu naquela taxa. Sem faixa e regra registradas na resposta, isso é arqueologia de planilha e de commit. Com elas, é uma consulta por identificador. O valor não é o tempo economizado — é a diferença entre conseguir e não conseguir responder a um questionamento de cliente, de Procon ou de auditoria.


07Arquitetura

                    HTTP
                      │
  ┌───────────────────┴────────────────────────────────────────────────────┐
  │ Hono app  basePath('/pricing')                                          │
  │  applyCommonMiddleware (body limit 1MB, CORS, security headers, rate)   │
  │  errorHandler                                                           │
  │                                                                         │
  │  /api/v1/risk-bands      riskBandsRouter      5 rotas                   │
  │  /api/v1/pricing-rules   pricingRulesRouter   5 rotas                   │
  │  /api/v1                 pricingRouter        1 rota  (/calculate)      │
  │  /health                 identificação e versão do build                │
  └───────────────────┬────────────────────────────────────────────────────┘
        authMiddleware → requirePermission(P) → requireOrganization
                      │  Zod parse → ResultAsync<T, AppError>
  ┌───────────────────┴────────────────────────────────────────────────────┐
  │ services/                                                               │
  │   PricingService      o cálculo. Sem I/O além dos dois repositórios     │
  │   RiskBandService     CRUD + validação de faixa + eventos               │
  │   PricingRuleService  CRUD + validação de faixa/prazo/data + eventos    │
  └───────────────────┬────────────────────────────────────────────────────┘
                      │
  ┌───────────────────┴────────────────────────────────────────────────────┐
  │ repositories/ (Prisma)  →  PostgreSQL, schema "pricing"                 │
  │   toda query de leitura filtra organizationId E deletedAt: null         │
  └────────────────────────────────────────────────────────────────────────┘
                      │
                      ▼  EventPublisher — 6 tipos de evento
        pricing.risk_band.created/updated/deleted
        pricing.pricing_rule.created/updated/deleted

O caminho de uma chamada de cálculo

  POST /pricing/api/v1/calculate
   { productId, creditScore, requestedAmount, numberOfInstallments }
             │
             ▼
  ┌───────────────────────────────────────────────────────────────────────┐
  │ 1. Encontra a faixa de risco                                          │
  │    RiskBand WHERE productId, organizationId, deletedAt IS NULL,       │
  │      isActive = true, minScore <= creditScore <= maxScore             │
  │    ORDER BY priority ASC  →  pega a PRIMEIRA                          │
  └───────────────┬───────────────────────────────────────────────────────┘
        não achou │ → 200 { approved: false, rejectionReason: "No risk
                  │        band found for credit score N" }
           achou  ▼
  ┌───────────────────────────────────────────────────────────────────────┐
  │ 2. Encontra a regra de precificação                                   │
  │    PricingRule WHERE productId, organizationId, deletedAt IS NULL,    │
  │      status = ACTIVE,                                                 │
  │      (minAmount IS NULL OR minAmount <= requestedAmount),             │
  │      (maxAmount IS NULL OR maxAmount >= requestedAmount),             │
  │      (minTerm   IS NULL OR minTerm   <= numberOfInstallments),        │
  │      (maxTerm   IS NULL OR maxTerm   >= numberOfInstallments),        │
  │      (effectiveFrom IS NULL OR effectiveFrom <= agora),               │
  │      (effectiveTo   IS NULL OR effectiveTo   >= agora)                │
  │    ORDER BY priority ASC  →  pega a PRIMEIRA                          │
  └───────────────┬───────────────────────────────────────────────────────┘
        não achou │ → 200 { approved: false, rejectionReason: "No
                  │        applicable pricing rule found for amount..." }
           achou  ▼
  ┌───────────────────────────────────────────────────────────────────────┐
  │ 3. Calcula                                                            │
  │    interestRate = baseRate + rateSpread            (da FAIXA)         │
  │    se maxRate definido e interestRate > maxRate                       │
  │        → 200 { approved: false, "Interest rate exceeds maximum..." }  │
  │    fees[], commissions[], insurances[]             (da REGRA)         │
  │    effectiveAnnualRate = (1 + interestRate)^12 − 1                    │
  └───────────────┬───────────────────────────────────────────────────────┘
                  ▼
       200 { approved: true, interestRate, fees, totalFees,
             commissions, insurances, riskBand, pricingRule, ... }

Decisões não óbvias.

  • Recusa é 200, não 4xx. createRejectionResult monta um resultado de sucesso com approved: false e o motivo em texto. Não ter preço para um perfil é uma decisão de negócio, não uma falha de integração — e tratar como erro HTTP faria a esteira do cliente confundir "score fora de faixa" com "o serviço caiu". O custo dessa escolha: o objeto de recusa vem com riskBand.id e pricingRule.id vazios (""), porque não houve faixa nem regra. Sempre cheque approved antes de ler qualquer outro campo.

  • A seleção é "a primeira por priority", não "a melhor". Tanto a faixa quanto a regra usam findFirst com ORDER BY priority ASC. Se você declarar duas faixas que se sobrepõem — 700–799 e 750–850, ambas ativas — o cliente com score 780 recebe a de menor priority, e nada no serviço avisa que há sobreposição. Não existe validação de sobreposição de faixas. Use priority deliberadamente e trate a checagem de sobreposição como responsabilidade sua.

  • productId não tem chave estrangeira. É um uuid livre nas duas tabelas. O Pricing Engine não verifica se o produto existe em lugar nenhum, porque o produto pode morar no Banking Product Portfolio, no Products ou no core do cliente. O preço da flexibilidade: um productId digitado errado não dá erro — dá "nenhuma faixa encontrada".

  • effectiveAnnualRate é composição pura de juros, e não é CET. O campo vale (1 + interestRate)^12 − 1. Ele não inclui tarifa, comissão, seguro nem IOF. Não use este número como Custo Efetivo Total em nenhum documento entregue ao cliente — CET é apurado no Calculations Engine, a partir do valor líquido liberado.

  • Taxas em Decimal(10,8) no banco, number na aplicação. O Postgres guarda as taxas como decimal de 8 casas para não perder centésimo de ponto-base. A conversão para number acontece na fronteira do serviço, com Number(riskBand.baseRate). Isso é adequado para taxa (magnitude pequena, 8 casas), mas significa que a aritmética de tarifa e comissão é ponto flutuante binário, com arredondamento explícito a duas casas em cada item.

  • Fee, comissão e seguro em JSONB, não em tabela. Uma regra de precificação pode ter zero ou muitas tarifas, de tipos diferentes. Modelar em tabelas exigiria três tabelas filhas e três JOIN para um dado que só é lido junto com a regra-mãe. O trade-off: o Postgres não valida a estrutura — a garantia vem do Zod no router, e um INSERT feito direto no banco passa por cima dela.

  • Evento publicado dentro da cadeia de Result. Cada mutação encadeia a publicação do evento com ResultAsync.fromPromise. Falha ao publicar vira INTERNAL depois de a gravação já ter acontecido. Ou seja: a linha está no banco e o evento não saiu. Trate os eventos como notificação melhor-esforço, não como fonte de verdade transacional.

Monolito vs. standalone. Em monolito, src/app.ts monta o app e os serviços vêm do container TypeDI (registerPricingEngine). Em standalone — o modo usado em produção —, main.ts sobe o Bun na porta configurada (3012 por convenção do projeto) e o serviço precisa apenas de PostgreSQL e do JWT_SECRET; a verificação de token é local, sem chamada de rede ao IAM. Não há diferença de comportamento entre os modos: este building block não consome nenhum outro por HTTP.


08Conceitos e modelo de dados

Glossário

TermoSignifica
Faixa de risco (RiskBand)Intervalo de score com a taxa associada. É o que responde "quanto custa emprestar para quem tem este score". Vive por produto e por organização.
Regra de precificação (PricingRule)O pacote comercial: quais tarifas, comissões e seguros se aplicam, para que faixa de valor, prazo e período de vigência.
baseRateTaxa mensal base da faixa, em decimal (0.0189 = 1,89% ao mês). Aceita de 0 a 1.
rateSpreadAjuste somado à baseRate. Aceita de −1 a 1 — pode ser negativo, para desconto. Padrão 0.
maxRateTeto da faixa. Se baseRate + rateSpread ultrapassá-lo, o cálculo recusa. Opcional.
priorityDesempate. Menor valor vence, tanto na faixa quanto na regra. Padrão 0.
Tarifa (FeeConfiguration)Cobrança avulsa: TAC, tarifa de cadastro, taxa de análise. Fixa ou percentual.
Comissão (CommissionConfiguration)Remuneração de canal, parceiro ou vendedor. Percentual do valor solicitado, com fixo opcional.
Seguro (InsuranceConfiguration)Prestamista e coberturas afins. Percentual mensal sobre o valor solicitado.
effectiveAnnualRateAnualização composta da taxa mensal. Não é CET — não inclui tarifa, comissão, seguro nem IOF.
approvedtrue quando houve faixa e regra aplicáveis e a taxa ficou dentro do teto. Sempre cheque antes de ler o resto.

Modelo de dados — schema pricing no PostgreSQL. Dois modelos, ambos com organizationId, productId, createdBy, updatedBy e deletedAt.

Modelo PrismaTabelaPropósitoCampos-chave
RiskBandpricing.risk_bandsFaixa de score com a taxaminScore, maxScore (Int), baseRate, maxRate, rateSpread (Decimal(10,8)), priority, isActive, único (organizationId, productId, name)
PricingRulepricing.pricing_rulesPacote comercial de tarifas, comissões e segurosruleType, status, decisionProjectId, minAmount/maxAmount (Decimal(15,2)), minTerm/maxTerm, fees/commissions/insurances (JSONB), effectiveFrom/effectiveTo, priority, único (organizationId, productId, name)

Enumerações

EnumValoresObservação
PricingRuleStatusACTIVE · INACTIVE · DRAFTACTIVE entra no cálculo. Padrão na criação: DRAFT
PricingRuleTypeDB_RULE · DECISIONDECISION não está implementado (§15)
FeeTypeREGISTRATION · ADMINISTRATION · ANALYSIS · DOCUMENTATION · OTHERRótulo; não altera o cálculo
FeeCalculationMethodFIXED · PERCENTAGE_OF_PRINCIPAL · PERCENTAGE_OF_TOTALPERCENTAGE_OF_TOTAL hoje se comporta igual a PERCENTAGE_OF_PRINCIPAL (§15)
CommissionTypeSALES · PARTNER · CHANNELRótulo; não altera o cálculo
InsuranceTypeLIFE · DISABILITY · UNEMPLOYMENT · PROPERTY · COMBINEDRótulo; não altera o cálculo

As fórmulas, exatamente como estão no código (PricingService)

  TAXA
    interestRate = baseRate + rateSpread
    se maxRate definido e interestRate > maxRate → recusa

  TARIFA  (por item de fees[])
    FIXED                    → amount = value
    PERCENTAGE_OF_PRINCIPAL  → amount = requestedAmount × (value / 100)
    PERCENTAGE_OF_TOTAL      → amount = requestedAmount × (value / 100)   ← igual
    depois:  se amount < minAmount → amount = minAmount
             se amount > maxAmount → amount = maxAmount
             amount = arredonda(amount, 2 casas)
    totalFees = soma dos amount

  COMISSÃO  (por item de commissions[])
    amount = requestedAmount × (percentage / 100)
    se fixedAmount definido  → amount += fixedAmount
    se amount > maxAmount    → amount = maxAmount
    amount = arredonda(amount, 2 casas)
    totalCommissions = soma dos amount

  SEGURO  (por item de insurances[])
    monthlyPremium = requestedAmount × (monthlyRate / 100)
    totalPremium   = monthlyPremium × numberOfInstallments
    se totalPremium > maxCoverage → totalPremium = maxCoverage
    ambos arredondados a 2 casas
    totalInsurance = soma dos totalPremium

  ANUALIZAÇÃO
    effectiveAnnualRate = (1 + interestRate)^12 − 1      ← NÃO é CET

Atenção a duas escolhas do código: percentage da comissão e monthlyRate do seguro são lidos como percentual (o schema Zod aceita 0 a 100 e o cálculo divide por 100), enquanto baseRate e rateSpread são lidos como decimal (0 a 1). São convenções diferentes no mesmo módulo — declare baseRate: 0.0189 para 1,89% ao mês e monthlyRate: 0.035 para 0,035% ao mês.

Estados da regra de precificação

        POST /pricing-rules
        (sem "status" no corpo)
                 │
                 ▼
           ┌──────────┐    PATCH status         ┌──────────┐
           │  DRAFT   │ ──────────────────────▶ │  ACTIVE  │
           └──────────┘                          └────┬─────┘
                 ▲                                    │ PATCH status
                 │ PATCH status                       ▼
                 │                              ┌──────────┐
                 └───────────────────────────── │ INACTIVE │
                                                └──────────┘

  Não há máquina de estados no serviço: qualquer transição é aceita
  por PATCH. Só ACTIVE participa do cálculo. A faixa de risco não tem
  status — tem o booleano isActive.

  DELETE em qualquer um dos dois é lógico: grava deletedAt e a linha
  fica. Nenhuma leitura volta a enxergá-la.

09Referência da API

Prefixo do app: /pricing — atenção, não /pricing-engine. Em staging a base é https://pricing.bb.stg.catalisa.app; em desenvolvimento local no modo monolito, http://localhost:3000.

Todas as 11 rotas exigem authMiddleware (Bearer JWT), requirePermission(...) e requireOrganization. Token sem organizationId recebe 403 antes de a regra de negócio rodar.

Cálculo de preço

MétodoRotaDescriçãoPermissão
POST/pricing/api/v1/calculateCalcula taxa, tarifas, comissões e seguros de uma propostaPRICING_CALCULATE

Faixas de risco — /pricing/api/v1/risk-bands

MétodoRotaDescriçãoPermissão
POST/pricing/api/v1/risk-bandsCria faixa. 201PRICING_RISK_BANDS_CREATE
GET/pricing/api/v1/risk-bandsLista faixas, paginadoPRICING_RISK_BANDS_READ
GET/pricing/api/v1/risk-bands/:idBusca faixaPRICING_RISK_BANDS_READ
PATCH/pricing/api/v1/risk-bands/:idAtualiza faixaPRICING_RISK_BANDS_UPDATE
DELETE/pricing/api/v1/risk-bands/:idExclusão lógica. 204 sem corpoPRICING_RISK_BANDS_DELETE

Filtros da listagem: productId, isActive (true/false). Paginação: pageNumber, pageSize (padrão 20). Ordenação fixa: productId, depois priority, depois minScore.

Regras de precificação — /pricing/api/v1/pricing-rules

MétodoRotaDescriçãoPermissão
POST/pricing/api/v1/pricing-rulesCria regra. 201PRICING_RULES_CREATE
GET/pricing/api/v1/pricing-rulesLista regras, paginadoPRICING_RULES_READ
GET/pricing/api/v1/pricing-rules/:idBusca regraPRICING_RULES_READ
PATCH/pricing/api/v1/pricing-rules/:idAtualiza regraPRICING_RULES_UPDATE
DELETE/pricing/api/v1/pricing-rules/:idExclusão lógica. 204 sem corpoPRICING_RULES_DELETE

Filtros da listagem: productId, status, ruleType. Ordenação fixa: productId, depois priority, depois createdAt decrescente.

Saúde

MétodoRotaDescrição
GET/pricing/healthIdentificação e versão do build. Sem autenticação. Não conta como endpoint de negócio.

POST /pricing/api/v1/calculate

O endpoint que a esteira chama. Todo o corpo vem dentro do envelope JSON:API.

Request

{
  "data": {
    "type": "pricing-calculations",
    "attributes": {
      "productId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "creditScore": 742,
      "requestedAmount": 15000,
      "numberOfInstallments": 36,
      "customerId": "9c858901-8a57-4791-81fe-4c455b099bc9",
      "context": { "canal": "app", "convenio": "inss" }
    }
  }
}
CampoTipoObrigatórioDescrição
productIdstring (UUID)SimIdentificador do produto. Sem chave estrangeira — nada valida a existência
creditScoreinteger 0–1000SimO score que a sua política de risco produziu. Este building block não calcula score
requestedAmountnumber > 0SimValor solicitado. É a base de tarifa percentual, comissão e seguro
numberOfInstallmentsinteger 1–360SimPrazo em meses. Multiplica o prêmio mensal do seguro
customerIdstring (UUID)NãoAceito e não usado no cálculo. Serve para correlação nos seus logs
contextobjectNãoAceito e não usado no cálculo. Reservado para a regra DECISION (§15)

Resposta 200 — aprovado

{
  "data": {
    "type": "pricing-calculations",
    "attributes": {
      "riskBand": {
        "id": "b1a2c3d4-0000-0000-0000-000000000001",
        "name": "Score alto",
        "minScore": 700,
        "maxScore": 799
      },
      "pricingRule": {
        "id": "e5f6a7b8-0000-0000-0000-000000000002",
        "name": "Crédito pessoal — app",
        "ruleType": "DB_RULE"
      },
      "interestRate": 0.0214,
      "baseRate": 0.0189,
      "rateSpread": 0.0025,
      "fees": [
        { "feeType": "REGISTRATION", "name": "TAC", "amount": 89.9,
          "calculationMethod": "FIXED", "isFinanced": true },
        { "feeType": "ANALYSIS", "name": "Análise de crédito", "amount": 300,
          "calculationMethod": "PERCENTAGE_OF_PRINCIPAL", "isFinanced": false }
      ],
      "totalFees": 389.9,
      "commissions": [
        { "commissionType": "PARTNER", "name": "Comissão do parceiro", "amount": 225 }
      ],
      "totalCommissions": 225,
      "insurances": [
        { "insuranceType": "LIFE", "name": "Prestamista",
          "monthlyPremium": 5.25, "totalPremium": 189 }
      ],
      "totalInsurance": 189,
      "effectiveAnnualRate": 0.28928889574814853,
      "approved": true
    }
  }
}

Os valores acima foram calculados com as fórmulas da §8 para requestedAmount: 15000, numberOfInstallments: 36, baseRate: 0.0189, rateSpread: 0.0025, TAC fixa de 89.90, análise de 2% do principal e prestamista de 0.035% ao mês. effectiveAnnualRate é (1.0214)^12 − 1.

Resposta 200 — recusado

{
  "data": {
    "type": "pricing-calculations",
    "attributes": {
      "riskBand": { "id": "", "name": "", "minScore": 0, "maxScore": 0 },
      "pricingRule": { "id": "", "name": "", "ruleType": "DB_RULE" },
      "interestRate": 0,
      "baseRate": 0,
      "rateSpread": 0,
      "fees": [],
      "totalFees": 0,
      "approved": false,
      "rejectionReason": "No risk band found for credit score 420"
    }
  }
}

Os três motivos de recusa que o código produz, em texto literal:

rejectionReasonQuando
No risk band found for credit score NNenhuma faixa ativa do produto cobre o score
No applicable pricing rule found for amount X and term YNenhuma regra ACTIVE do produto cobre valor, prazo e vigência
Interest rate exceeds maximum allowed rate for this risk bandbaseRate + rateSpread ficou acima do maxRate da faixa

No terceiro caso — e nele — riskBand e pricingRule vêm preenchidos, com interestRate, baseRate e rateSpread reais. Nos dois primeiros, vêm vazios.

Erros

StatusQuando
400Corpo reprovado no Zod: score fora de 0–1000, valor não positivo, prazo fora de 1–360, productId não é UUID
401Token ausente, inválido ou expirado
403Falta PRICING_CALCULATE, ou token sem organizationId

POST /pricing/api/v1/risk-bands

Request

{
  "data": {
    "type": "risk-bands",
    "attributes": {
      "productId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "name": "Score alto",
      "displayLabel": "AA — 700 a 799",
      "minScore": 700,
      "maxScore": 799,
      "baseRate": 0.0189,
      "maxRate": 0.0299,
      "rateSpread": 0.0025,
      "priority": 10,
      "isActive": true
    }
  }
}
CampoTipoObrigatórioDescrição
productIdUUIDSimProduto ao qual a faixa pertence
namestring 1–50SimÚnico por (organização, produto). Colisão devolve 409
displayLabelstring ≤100NãoRótulo para interface
minScore / maxScoreinteger 0–1000SimIntervalo inclusivo. minScore > maxScore devolve 400
baseRatenumber 0–1SimTaxa mensal em decimal
maxRatenumber 0–1NãoTeto. Menor que baseRate devolve 400
rateSpreadnumber −1 a 1NãoPadrão 0. Pode ser negativo
priorityinteger ≥0NãoPadrão 0. Menor vence no desempate
isActivebooleanNãoPadrão true. Faixa inativa nunca é escolhida no cálculo

Resposta 201 — o recurso, com links.self apontando para /pricing/api/v1/risk-bands/:id.

Erros

StatusQuando
400minScore > maxScore; baseRate ou maxRate fora de 0–1; maxRate < baseRate
409Já existe faixa com esse name para o mesmo produto e organização

A validação não checa sobreposição com outras faixas. Duas faixas 700–799 e 750–850 convivem sem aviso, e o desempate é priority.


POST /pricing/api/v1/pricing-rules

Request

{
  "data": {
    "type": "pricing-rules",
    "attributes": {
      "productId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "name": "Crédito pessoal — app",
      "description": "Tabela do canal digital, vigente a partir de setembro",
      "ruleType": "DB_RULE",
      "status": "ACTIVE",
      "priority": 10,
      "minAmount": 1000,
      "maxAmount": 50000,
      "minTerm": 6,
      "maxTerm": 48,
      "fees": [
        { "feeType": "REGISTRATION", "name": "TAC",
          "calculationMethod": "FIXED", "value": 89.90, "isFinanced": true },
        { "feeType": "ANALYSIS", "name": "Análise de crédito",
          "calculationMethod": "PERCENTAGE_OF_PRINCIPAL", "value": 2,
          "maxAmount": 400 }
      ],
      "commissions": [
        { "commissionType": "PARTNER", "name": "Comissão do parceiro",
          "percentage": 1.5, "maxAmount": 900 }
      ],
      "insurances": [
        { "insuranceType": "LIFE", "name": "Prestamista",
          "monthlyRate": 0.035, "isMandatory": true, "isFinanced": true }
      ],
      "effectiveFrom": "2026-09-01T00:00:00.000Z",
      "effectiveTo": "2026-12-31T23:59:59.000Z"
    }
  }
}
CampoTipoObrigatórioDescrição
productIdUUIDSimProduto
namestring 1–100SimÚnico por (organização, produto). Colisão devolve 409
ruleTypeDB_RULE | DECISIONSimDECISION exige decisionProjectId, mas não é executado (§15)
statusACTIVE | INACTIVE | DRAFTNãoPadrão DRAFT — regra recém-criada não entra no cálculo
decisionProjectIdUUIDSó em DECISIONGravado e ignorado no cálculo
priorityinteger ≥0NãoPadrão 0. Menor vence
minAmount / maxAmountnumber ≥0NãoFaixa de valor. min > max devolve 400. Nulo significa sem limite
minTerm / maxTerminteger ≥1NãoFaixa de prazo. min > max devolve 400
fees[]arrayNãofeeType, name, calculationMethod, value ≥0, isFinanced, isRefundable, minAmount, maxAmount
commissions[]arrayNãocommissionType, name, percentage 0–100, fixedAmount, maxAmount, paymentTiming
insurances[]arrayNãoinsuranceType, name, monthlyRate 0–100, isMandatory, isFinanced, maxCoverage
effectiveFrom / effectiveToISO 8601NãoVigência. from > to devolve 400. Nulo significa sem limite

Erros

StatusQuando
400minAmount > maxAmount; minTerm > maxTerm; effectiveFrom > effectiveTo; ruleType: "DECISION" sem decisionProjectId
409Já existe regra com esse name para o mesmo produto e organização

Armadilha frequente: sem status no corpo, a regra nasce DRAFT e o POST /calculate continua devolvendo "No applicable pricing rule found". Passe "status": "ACTIVE" ou faça um PATCH depois.


PATCH /pricing/api/v1/risk-bands/:id e PATCH /pricing/api/v1/pricing-rules/:id

O corpo exige o envelope completo, com o id dentro de data:

{
  "data": {
    "type": "risk-bands",
    "id": "b1a2c3d4-0000-0000-0000-000000000001",
    "attributes": { "baseRate": 0.0209 }
  }
}

O id que vale é o da URL; o de data.id é exigido pelo schema Zod mas não é usado para localizar o recurso. Todos os campos de attributes são opcionais — só o que você mandar é alterado.


10Início rápido

Do zero a um preço calculado. Quatro chamadas.

Os comandos abaixo não foram executados contra staging nesta sessão — o ambiente não estava acessível a partir da máquina de documentação. As rotas, permissões e formatos vieram da leitura de routes/*.ts e dos schemas Zod, e os números da resposta de exemplo foram calculados com as fórmulas da §8. Confirme na primeira execução.

1. Autenticar no IAM

export API=https://pricing.bb.stg.catalisa.app/pricing/api/v1

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)

2. Criar a faixa de risco

PRODUCT_ID=$(uuidgen | tr 'A-Z' 'a-z')

BAND=$(curl -s -X POST $API/risk-bands \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"data\":{\"type\":\"risk-bands\",\"attributes\":{
        \"productId\":\"$PRODUCT_ID\",
        \"name\":\"Score alto\",
        \"minScore\":700,\"maxScore\":799,
        \"baseRate\":0.0189,\"rateSpread\":0.0025,\"maxRate\":0.0299,
        \"priority\":10}}}")

echo "$BAND" | jq '.data.id, .data.attributes.baseRate'

3. Criar a regra de precificação — já ACTIVE

curl -s -X POST $API/pricing-rules \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"data\":{\"type\":\"pricing-rules\",\"attributes\":{
        \"productId\":\"$PRODUCT_ID\",
        \"name\":\"Crédito pessoal — app\",
        \"ruleType\":\"DB_RULE\",
        \"status\":\"ACTIVE\",
        \"minAmount\":1000,\"maxAmount\":50000,
        \"minTerm\":6,\"maxTerm\":48,
        \"fees\":[{\"feeType\":\"REGISTRATION\",\"name\":\"TAC\",
                   \"calculationMethod\":\"FIXED\",\"value\":89.90}],
        \"insurances\":[{\"insuranceType\":\"LIFE\",\"name\":\"Prestamista\",
                         \"monthlyRate\":0.035}]}}}" | jq '.data.id'

4. Calcular o preço

curl -s -X POST $API/calculate \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"data\":{\"type\":\"pricing-calculations\",\"attributes\":{
        \"productId\":\"$PRODUCT_ID\",
        \"creditScore\":742,
        \"requestedAmount\":15000,
        \"numberOfInstallments\":36}}}" \
  | jq '.data.attributes | {approved, interestRate, totalFees, totalInsurance,
                            faixa: .riskBand.name, regra: .pricingRule.name}'

Resposta esperada, com as fórmulas da §8:

{
  "approved": true,
  "interestRate": 0.0214,
  "totalFees": 89.9,
  "totalInsurance": 189,
  "faixa": "Score alto",
  "regra": "Crédito pessoal — app"
}

interestRate é 0.0189 + 0.0025. totalInsurance é 15000 × 0.035 / 100 × 36, ou seja 5.25 × 36.

5. Ver a recusa acontecer

curl -s -X POST $API/calculate \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"data\":{\"type\":\"pricing-calculations\",\"attributes\":{
        \"productId\":\"$PRODUCT_ID\",
        \"creditScore\":420,
        \"requestedAmount\":15000,
        \"numberOfInstallments\":36}}}" \
  | jq '.data.attributes | {approved, rejectionReason}'
{
  "approved": false,
  "rejectionReason": "No risk band found for credit score 420"
}

Status HTTP 200. Isso é proposital — veja §7.

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


11Receitas

Montar uma curva de preço completa por produto

Uma curva típica tem quatro faixas. Declare todas com priority explícita e sem sobreposição.

for f in "Score muito alto:800:1000:0.0159:10" \
         "Score alto:700:799:0.0189:20" \
         "Score médio:600:699:0.0249:30" \
         "Score baixo:500:599:0.0329:40"; do
  IFS=':' read -r NOME MIN MAX RATE PRIO <<< "$f"
  curl -s -X POST $API/risk-bands \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
    -d "{\"data\":{\"type\":\"risk-bands\",\"attributes\":{
          \"productId\":\"$PRODUCT_ID\",\"name\":\"$NOME\",
          \"minScore\":$MIN,\"maxScore\":$MAX,
          \"baseRate\":$RATE,\"maxRate\":0.0399,\"priority\":$PRIO}}}" \
    | jq -r '.data.id // .message'
done

Armadilhas.

  • Nada valida sobreposição. Se você escrever 700:799 e 750:850, as duas ficam ativas e o desempate é priority. Confira com GET /risk-bands?productId=... ordenado — a listagem já vem por priority e depois minScore.
  • Score abaixo de 500 fica sem faixa neste exemplo, e toda proposta desse perfil recebe approved: false. Se a sua política é recusar mesmo, isso está certo; se não é, falta uma faixa.
  • name é único por produto. Rodar o laço duas vezes devolve 409 na segunda.
  • maxRate é o freio. Com maxRate: 0.0399 e uma faixa de baseRate: 0.0329 mais rateSpread: 0.008, o resultado é 0.0409 e o cálculo recusa. Isso é o comportamento desejado, mas surpreende quem esperava que a taxa fosse apenas limitada ao teto.

Rodar uma campanha promocional com data de fim

O rateSpread negativo e a vigência da regra resolvem campanha sem duplicar a curva inteira.

# Regra de campanha, prioridade menor que a regra padrão → vence
curl -s -X POST $API/pricing-rules \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"data\":{\"type\":\"pricing-rules\",\"attributes\":{
        \"productId\":\"$PRODUCT_ID\",
        \"name\":\"Campanha de setembro — TAC zero\",
        \"ruleType\":\"DB_RULE\",\"status\":\"ACTIVE\",\"priority\":1,
        \"fees\":[],
        \"effectiveFrom\":\"2026-09-01T00:00:00.000Z\",
        \"effectiveTo\":\"2026-09-30T23:59:59.000Z\"}}}" | jq '.data.id'

Armadilhas.

  • A campanha zera a tarifa, não a taxa. A taxa vem da faixa, não da regra. Para descontar taxa na campanha, você precisa mexer em rateSpread das faixas — e aí o desconto vale para todo mundo, não só para a campanha. Essa é uma limitação real do modelo (§15).
  • priority menor vence. A regra de campanha precisa de priority menor que a regra padrão. Se as duas tiverem 0, o desempate fica indefinido na prática.
  • Depois de effectiveTo, a regra some do cálculo sozinha — mas continua ACTIVE na listagem. Isso é intencional: a vigência é filtro de consulta, não mudança de estado.

Descobrir por que o cálculo recusou

# 1. O que veio na recusa
curl -s -X POST $API/calculate -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -d "$PAYLOAD" \
  | jq '.data.attributes | {approved, rejectionReason, riskBand, pricingRule}'

# 2. As faixas ativas do produto
curl -s "$API/risk-bands?productId=$PRODUCT_ID&isActive=true" \
  -H "Authorization: Bearer $TOKEN" \
  | jq '.data[] | {nome: .attributes.name, min: .attributes.minScore,
                   max: .attributes.maxScore, prio: .attributes.priority}'

# 3. As regras ACTIVE do produto
curl -s "$API/pricing-rules?productId=$PRODUCT_ID&status=ACTIVE" \
  -H "Authorization: Bearer $TOKEN" \
  | jq '.data[] | {nome: .attributes.name, minA: .attributes.minAmount,
                   maxA: .attributes.maxAmount, minT: .attributes.minTerm,
                   maxT: .attributes.maxTerm, de: .attributes.effectiveFrom,
                   ate: .attributes.effectiveTo}'

Ordem de diagnóstico, do erro mais comum ao menos comum:

  1. A regra está DRAFT. Criada sem status, ela nasce DRAFT e nunca entra no cálculo. É de longe a causa mais frequente de "No applicable pricing rule found".
  2. O productId está errado. Não há chave estrangeira: um UUID inexistente não dá erro, dá "nenhuma faixa encontrada". Confira que o productId do cálculo é literalmente o mesmo da faixa.
  3. A faixa não cobre o score. Os limites são inclusivos nos dois lados. Score 800 numa faixa 700–799 não entra.
  4. A vigência da regra já passou ou ainda não começou. Compare effectiveFrom e effectiveTo com o horário do servidor, em UTC.
  5. A taxa estourou o maxRate. Se rejectionReason menciona exceeds maximum, a faixa e a regra foram encontradas — o problema é baseRate + rateSpread acima do teto da própria faixa.
  6. A organização está errada. O organizationId vem do token. Faixa criada com um token e consultada com outro simplesmente não existe do outro lado.

Reajustar a curva inteira sem downtime

# Sobe todas as faixas do produto em 20 pontos-base
curl -s "$API/risk-bands?productId=$PRODUCT_ID&pageSize=100" \
  -H "Authorization: Bearer $TOKEN" \
  | jq -c '.data[] | {id: .id, novo: (.attributes.baseRate + 0.002)}' \
  | while read -r linha; do
      ID=$(echo "$linha" | jq -r .id)
      NOVO=$(echo "$linha" | jq -r .novo)
      curl -s -X PATCH "$API/risk-bands/$ID" \
        -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
        -d "{\"data\":{\"type\":\"risk-bands\",\"id\":\"$ID\",
              \"attributes\":{\"baseRate\":$NOVO}}}" | jq -r '.data.id'
    done

Armadilhas.

  • Não há operação em lote. É um PATCH por faixa, e não há transação envolvendo todas. Se o laço parar no meio, metade da curva ficou reajustada. Rode fora do horário de pico e confira o resultado com um GET no fim.
  • O PATCH vale na chamada seguinte. Não há cache neste building block. Propostas em voo no instante do reajuste podem ter pegado a taxa antiga — se isso importa, congele a taxa no seu lado no momento da proposta.
  • maxRate não é reajustado junto. Subir baseRate sem subir maxRate pode fazer faixas passarem a recusar. Reajuste os dois.
  • Guarde o antes. O evento pricing.risk_band.updated carrega só as mudanças, não o valor anterior. Se você precisa do histórico, faça o GET da curva antes do laço e guarde o retorno.

Entregar a taxa para o Calculations Engine

O Pricing Engine devolve a taxa; a parcela é do Calculations Engine. O encadeamento é do seu orquestrador — não há chamada automática entre os dois (§12).

TAXA=$(curl -s -X POST $API/calculate -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -d "$PAYLOAD" \
  | jq -r '.data.attributes.interestRate')

curl -s -X POST \
  https://calculations.bb.stg.catalisa.app/calculations-engine/api/v1/calculations/loan-payment-calculator/calculations \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"data\":{\"type\":\"loan-payment-calculation\",\"attributes\":{
        \"interestRate\":$TAXA,
        \"numberOfPayments\":36,
        \"presentValue\":15000}}}" | jq '.data.attributes.payment'

Com interestRate: 0.0214, numberOfPayments: 36 e presentValue: 15000, a resposta é 601.81 — número conferido rodando o calculador diretamente.

Armadilhas.

  • A convenção de taxa é a mesma nos dois. interestRate é decimal mensal (0.0214) nos dois building blocks. Não multiplique por 100 no caminho.
  • effectiveAnnualRate não é CET. Para o CET você precisa do valor líquido liberado — valor solicitado menos IOF menos tarifas não financiadas — e do endpoint de CET do Calculations Engine. §12 mostra a sequência.
  • Tarifa financiada muda o principal. Se isFinanced: true, a tarifa entra no valor financiado, e o presentValue que você manda para o cálculo da parcela não é mais o requestedAmount. O Pricing Engine devolve a flag; a composição é sua.

12Integração com outros building blocks

Building blockComo se relacionaObrigatório
IAMEmite o token; as 9 permissões PRICING_* e o organizationId vêm deleSim
Calculations EngineRecebe a taxa e calcula parcela, IOF e CET. Integração por contrato, não por códigoNão
Banking Product PortfolioDefine o produto e o teto minInterestRatemaxInterestRate que a taxa daqui deve respeitar. Integração por contrato, não por códigoNão
Decision PlatformOrquestra a esteira e chama este building block na etapa de preçoNão
Decision EngineDestino pretendido das regras ruleType: "DECISION". Não implementado (§15)Não
Audit TrailConsome os 6 eventos pricing.* para trilha de complianceNão
Webhooks EngineEntrega os eventos de mudança de faixa e de regra a sistemas externosNão

Seja honesto na venda: o Pricing Engine não chama nenhum outro building block. Não há facade, não há ModuleClient, não há import cruzado — PricingService depende exclusivamente de RiskBandRepository e PricingRuleRepository. E nenhum outro building block chama este: o Banking Product Portfolio documenta explicitamente que o POST /simulate dele devolve parâmetros resolvidos, sem taxa, sem parcela e sem CET. A composição da esteira é feita pelo orquestrador do cliente ou pelo Decision Platform. O diagrama abaixo é o desenho da esteira, não uma cadeia de chamadas automáticas.

Onde este bloco entra na esteira de crédito

   ┌──────────┐
   │ Proposta │  cliente pede R$ 15.000 em 36x
   └────┬─────┘
        ▼
  ┌───────────────────────────────────────────────────────────────────────┐
  │ BANKING PRODUCT PORTFOLIO — o mandato                                 │
  │   POST /simulate → faixa de valor, faixa de TAXA, prazo, iofDailyRate │
  │   devolve os LIMITES. Não devolve preço.                              │
  └────┬──────────────────────────────────────────────────────────────────┘
       ▼
  ┌───────────────────────────────────────────────────────────────────────┐
  │ DECISION PLATFORM + DECISION ENGINE — a decisão                       │
  │   bureau, política, DMN → aprovado? qual limite? qual SCORE?          │
  └────┬──────────────────────────────────────────────────────────────────┘
       │  score em mãos ↓
  ┌───────────────────────────────────────────────────────────────────────┐
  │ PRICING ENGINE  ◀── você está aqui                                    │
  │   POST /pricing/api/v1/calculate                                      │
  │   entrada: productId, creditScore, requestedAmount, prazo             │
  │   saída:   interestRate, fees[], commissions[], insurances[]          │
  │            + riskBand e pricingRule que decidiram                     │
  │   o orquestrador confere: interestRate cabe no teto do Portfolio?     │
  └────┬──────────────────────────────────────────────────────────────────┘
       │  taxa e custos definidos ↓
  ┌───────────────────────────────────────────────────────────────────────┐
  │ CALCULATIONS ENGINE — a matemática                                    │
  │   parcela (PRICE/SAC) · IOF · CET sobre o líquido liberado            │
  │   entradas: valor, prazo, interestRate daqui, tarifas daqui,          │
  │             iofDailyRate do Portfolio                                 │
  └────┬──────────────────────────────────────────────────────────────────┘
       │  parcela e CET ↓
  ┌───────────────────────────────────────────────────────────────────────┐
  │ CONTRATO → guarda riskBand.id, pricingRule.id e versionId. Sempre.    │
  │   E-SIGNATURE assina · AUDIT TRAIL registra · BILLING cobra           │
  └───────────────────────────────────────────────────────────────────────┘

A fronteira entre este bloco e o Calculations Engine, em uma frase. O Pricing Engine decide quanto custa — a taxa e os encargos, a partir do risco e da política comercial. O Calculations Engine calcula como isso se paga — a parcela, o cronograma, o IOF e o CET, a partir de números que alguém já decidiu. Um é política; o outro é aritmética. Por isso um tem banco de dados e escopo de tenant, e o outro é stateless e sem persistência.

Por que esse encadeamento é o argumento comercial. Cada peça sozinha é substituível. Junto, o encaixe é o produto: o teto de taxa que o Portfolio congelou no snapshot é o limite que a taxa daqui precisa respeitar; a interestRate daqui é a entrada da parcela lá no Calculations; e o par riskBand.id mais pricingRule.id que o contrato guarda é o que permite, dois anos depois, reconstruir a política que produziu aquele preço.


13Configuração e operação

Variáveis de ambiente

Este building block não tem variável própria. Usa só a configuração compartilhada.

VariávelDescriçãoObrigatóriaPadrão
DATABASE_URLPostgreSQL. O schema pricing precisa existir (criado pelas migrações)Sim
JWT_SECRETSegredo HS256, mínimo 44 caracteres. Verifica o token emitido pelo IAMSim
PORTPorta no modo standaloneNão3000 (a convenção do projeto para este módulo é 3012)
DEPLOYMENT_MODEmonolith ou standaloneNãostandalone (forçado em main.ts)
MODULE_SELFIdentifica o serviço no /healthNãopricing-engine
REDIS_URLRedis compartilhado. Usado pelo rate limit global de applyCommonMiddleware, não pelo móduloNão

Dependências de infraestrutura

DependênciaPara quê
PostgreSQLSchema pricing, duas tabelas
IAMEmissão do token. A verificação é local, sem chamada de rede

Sem S3, sem fila, sem serviço externo. provedores: 0 no frontmatter é literal.

Limites

LimiteValor
Tamanho do corpo da requisição1 MB (applyCommonMiddleware)
pageSize padrão na listagem20
creditScoreInteiro de 0 a 1000
numberOfInstallmentsInteiro de 1 a 360
baseRate, maxRate0 a 1 (decimal)
rateSpread−1 a 1
percentage da comissão, monthlyRate do seguro0 a 100 (percentual)
Tamanho do name da faixa50 caracteres
Tamanho do name da regra100 caracteres
Tamanho da description da regra5.000 caracteres
Precisão das taxas no bancoDecimal(10,8)
Precisão dos valores no bancoDecimal(15,2)
Quantidade de tarifas, comissões ou seguros por regraSem limite na aplicação

Catálogo de erros

StatusCódigoSignificaO que fazer
400VALIDATIONCorpo reprovado no ZodA resposta traz details. Confira tipos e faixas contra a §9
400VALIDATIONminScore must be less than or equal to maxScoreInverteu o intervalo da faixa
400VALIDATIONbaseRate must be between 0 and 1Passou percentual em vez de decimal. 1.89 não é 1,89%
400VALIDATIONmaxRate must be greater than or equal to baseRateO teto ficou abaixo da base
400VALIDATIONminAmount must be less than or equal to maxAmountInverteu a faixa de valor da regra
400VALIDATIONminTerm must be less than or equal to maxTermInverteu a faixa de prazo
400VALIDATIONeffectiveFrom must be before effectiveToInverteu a vigência
400VALIDATIONdecisionProjectId is required for DECISION rule typeInforme o projeto — mas veja a §15 antes de usar DECISION
401UNAUTHORIZEDToken ausente, inválido ou expiradoRenove no IAM
403FORBIDDENFalta a permissão PRICING_* exigida pela rotaConfira a §9 e as permissões contratadas pela organização
403Organization context requiredO token não tem organizationId. Autentique informando a organização
404NOT_FOUNDFaixa ou regra inexistente, excluída logicamente, ou de outra organizaçãoO 404 para recurso de outra organização é intencional
409CONFLICTRisk band with this name already exists for this productEscolha outro name
409CONFLICTPricing rule with this name already exists for this productEscolha outro name
429Rate limit global estouradoAplique recuo exponencial
500INTERNALFailed to publish event — a gravação aconteceu, o evento não saiuReconcilie pelo estado do banco, não pelo evento
500INTERNALFailed to compute pricingFalha inesperada dentro do cálculo. Abra chamado com o corpo da requisição

Recusa de crédito não é erro. approved: false vem com status 200. Não trate como 4xx.

Observabilidade.

  • GET /pricing/health devolve identificação e versão do build. Não sonda o banco — não é uma verificação de dependência, é um sinal de vida do processo.
  • Seis eventos são publicados: pricing.risk_band.created, .updated, .deleted e pricing.pricing_rule.created, .updated, .deleted. Todos carregam metadata.organizationId e metadata.timestamp, e os de atualização carregam o objeto de mudanças recebido no PATCH.
  • Não há métrica própria de latência de cálculo nem contador de recusas. Se a taxa de approved: false é um indicador do seu negócio, instrumente do lado do chamador — o building block não expõe isso hoje (§15).

14Segurança e compliance

Isolamento entre tenants. O organizationId é claim assinado no JWT. As onze rotas aplicam o middleware local requireOrganization, que devolve 403 quando o claim está ausente, antes de qualquer regra de negócio. Nenhuma rota lê organizationId do corpo da requisição — o valor sempre vem de c.get('user').organizationId. Nos repositórios, toda leitura filtra organizationId e deletedAt: null na mesma cláusula; buscar por ID uma faixa de outra organização devolve 404, não 403, para não confirmar a existência do recurso.

As operações de escrita (update, softDelete) recebem apenas o id, mas os serviços só as chamam depois de um findById(id, organizationId) que já falhou com 404 caso o recurso não pertença ao tenant. O isolamento é garantido na camada de serviço; não há Row-Level Security no schema pricing.

Dados sensíveis. Este building block não guarda dado pessoal. Não há nome, CPF, e-mail nem telefone nas duas tabelas. O creditScore e o customerId chegam na requisição de cálculo e não são persistidos — o cálculo é feito em memória e a resposta é devolvida. customerId sequer é usado no cálculo.

Do ponto de vista de LGPD, isso significa que o Pricing Engine não é controlador nem operador de dado pessoal em repouso. O que ele guarda é política comercial da organização: faixas, taxas, tarifas e comissões — informação sensível do ponto de vista concorrencial, não pessoal. É por isso que o isolamento entre tenants é a preocupação central desta seção, e não a anonimização.

Autenticação e permissões. Bearer JWT verificado localmente com JWT_SECRET (HS256). Nove permissões, todas do vocabulário compartilhado do IAM:

PermissãoConcede
PRICING_CALCULATECalcular preço
PRICING_RISK_BANDS_CREATE / _READ / _UPDATE / _DELETEGerir faixas de risco
PRICING_RULES_CREATE / _READ / _UPDATE / _DELETEGerir regras de precificação

A separação entre PRICING_CALCULATE e as permissões de gestão é deliberada: a esteira de originação precisa apenas de PRICING_CALCULATE. Um token de integração que só calcula não consegue ler nem alterar a curva de preço da instituição.

Proteções de borda. 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 (HSTS, CSP, nosniff, X-Frame-Options) e rate limit global. Em DEPLOYMENT_MODE=standalone — o modo de produção — esse conjunto é aplicado pelo próprio app.ts do módulo, não herdado do monolito.

Exclusão lógica. Faixas e regras usam deletedAt. A linha sobrevive à remoção, que é o que auditoria e obrigação de retenção exigem. Não há expurgo automatizado.

Enquadramento regulatório — o que este building block faz e o que ele não faz. Ele não contém nenhuma regra regulatória codificada: não há teto de juros, não há alíquota, não há validação de conformidade. As taxas que você declara são as que ele aplica. Consequências práticas que precisam estar claras na venda:

  • O teto de juros do consignado, definido pelo CNPS e pelo Conselho de Recursos da Previdência Social nos termos da Lei 10.820/2003 — alterada pela MP 1.292/2025 e convertida na Lei 15.179/2025 —, não é verificado aqui. Use maxRate na faixa para materializar o teto que a sua área jurídica determinar, e trate isso como controle seu, não como conformidade nossa.
  • O Custo Efetivo Total, exigido pela Resolução CMN 4.881/2020, em vigor desde 1º de fevereiro de 2021 e que substituiu a revogada Resolução 3.517/2007, não é produzido por este building block. O campo effectiveAnnualRate é composição de juros e nada mais. O CET é apurado no Calculations Engine, a partir do valor líquido liberado.
  • O IOF, regido pelo Decreto 6.306/2007, também não aparece aqui.

Em resumo: o Pricing Engine é a ferramenta com que você implementa a sua política, inclusive a parte dela que é imposta por norma. Ele não a garante.


15Limitações conhecidas

LimitaçãoImpactoSituação
ruleType: "DECISION" não é executadoO enum existe, decisionProjectId é exigido e gravado, e o campo decisionTraceId existe no tipo de resposta — mas o PricingService nunca chama o Decision Engine. Pior: findActiveByProduct não filtra por ruleType, então uma regra DECISION com status: ACTIVE é escolhida e processada como se fosse DB_RULE, usando as tarifas dela e ignorando o projeto de decisão. Não use DECISION em produção.Especificado, não implementado
PERCENTAGE_OF_TOTAL é igual a PERCENTAGE_OF_PRINCIPALO switch do cálculo de tarifa trata os dois casos com a mesma expressão, com um comentário no código dizendo que "total exigiria o cálculo da parcela". Uma tarifa declarada como percentual do total é cobrada como percentual do principalConhecido, documentado no código
O seguro é calculado sobre o valor solicitado, não sobre o saldo devedormonthlyPremium = requestedAmount × monthlyRate / 100, multiplicado pelo número de parcelas. Apólices de prestamista sobre saldo devedor decrescente ficam superestimadas — o erro cresce com o prazoConhecido, simplificação assumida
Não há validação de sobreposição de faixasDuas faixas cobrindo o mesmo score convivem sem aviso. O desempate é priority, e a ORDER BY não é determinística entre faixas de mesma priorityRoadmap
effectiveAnnualRate não é CET e o nome sugere que éO campo é (1 + interestRate)^12 − 1. Não inclui tarifa, comissão, seguro nem IOF. Publicá-lo como CET seria descumprir a Resolução CMN 4.881/2020Por design — o CET é do Calculations Engine
customerId e context são aceitos e ignoradosOs dois campos passam pelo schema Zod e não são lidos pelo cálculo. context está reservado para a regra DECISION que não existeEspecificado, não implementado
productId não tem chave estrangeiraUUID inexistente não gera erro — gera "nenhuma faixa encontrada". Erro de digitação vira recusa silenciosaPor design — o produto pode morar em três lugares diferentes
Sem versionamento da política de preçoAlterar baseRate sobrescreve o valor. Não há snapshot como o do Banking Product Portfolio. Para reconstruir a taxa de um contrato antigo, você precisa ter guardado o resultado do cálculo do seu ladoRoadmap
Sem simulação em loteUma chamada, uma proposta. Repricing de carteira exige um laço no chamadorRoadmap
Sem histórico ou trilha de cálculoO cálculo não é persistido. Se você não guardar riskBand.id e pricingRule.id na sua proposta, a rastreabilidade se perdePor design — o building block é stateless no cálculo
/health não sonda o bancoDevolve identificação e versão do build. Um Postgres fora do ar não faz o health falharRoadmap
Falha de publicação de evento vira 500 após a gravaçãoA linha está no banco e o chamador recebe 500. Retentativa cega cria registro duplicado (ou 409, se o name colidir)Conhecido
Sem métrica de negócio expostaNão há contador de recusas por motivo nem histograma de taxa aplicadaRoadmap
Sem Row-Level SecurityO isolamento é garantido na camada de serviço. Acesso direto ao Postgres passa por cima delePor design neste módulo

16Perguntas frequentes

O Pricing Engine calcula o score do cliente?

Não. Ele consome o score que você já tem. Quem produz score é o seu modelo, o bureau, ou o Decision Platform somado ao Decision Engine. Este building block responde "dado este score, qual o preço", e essa separação é deliberada: modelar risco e aplicar preço são problemas diferentes, com ciclos de mudança diferentes e donos diferentes dentro da instituição.

Por que a recusa vem com status 200?

Porque não ter preço para um perfil é uma decisão de negócio, não uma falha de integração. Se a recusa fosse 4xx, a sua esteira precisaria inspecionar o corpo do erro para distinguir "score fora de faixa" de "o serviço caiu" — e alguém acabaria tratando as duas coisas do mesmo jeito num catch. Cheque approved antes de ler qualquer outro campo da resposta.

Posso usar o effectiveAnnualRate como CET na proposta ao cliente?

Não. effectiveAnnualRate é só a anualização composta da taxa mensal: (1 + interestRate)^12 − 1. Ele não inclui tarifa, comissão, seguro nem IOF. O Custo Efetivo Total é exigido pela Resolução CMN 4.881/2020, precisa considerar todos os encargos e o valor efetivamente liberado, e é apurado pelo Calculations Engine.

Criei a faixa e a regra, mas o cálculo diz que não achou regra. Por quê?

Quase certamente a regra nasceu DRAFT. Sem "status": "ACTIVE" no corpo, o padrão do schema é DRAFT, e só regra ACTIVE entra no cálculo. É o chamado de suporte mais comum deste building block. A segunda causa mais comum é productId diferente entre a faixa, a regra e o cálculo — não há chave estrangeira que avise.

O que acontece se duas faixas cobrirem o mesmo score?

O cálculo pega a de menor priority, e ninguém avisa que há sobreposição. Se as duas tiverem a mesma priority, a escolha depende da ordem que o Postgres devolver, o que na prática é imprevisível. Declare priority explícita em todas as faixas e trate a checagem de sobreposição como responsabilidade sua, hoje.

Como eu reconstruo, dois anos depois, por que aquele contrato saiu a 2,49%?

Guardando riskBand.id e pricingRule.id na sua proposta no momento do cálculo. Este building block não versiona a política nem persiste o resultado do cálculo — se você alterar a baseRate da faixa, o valor antigo se perde. Guardar os dois identificadores permite pelo menos identificar qual faixa e qual regra decidiram; para guardar os valores da época, grave a resposta inteira do cálculo do seu lado. É a limitação mais relevante da §15.

Dá para dar desconto de taxa numa campanha sem mexer na curva inteira?

Hoje, não de forma limpa. A taxa vem da faixa, e a campanha é modelada como regra — que controla tarifa, comissão, seguro e vigência, mas não a taxa. Na prática, campanha de taxa exige criar faixas paralelas com rateSpread negativo e um productId próprio para o canal da campanha. É contornável, mas é contorno.

Preciso do Banking Product Portfolio para usar o Pricing Engine?

Não. Os dois são independentes e não se chamam. O que o Banking Product Portfolio acrescenta é o mandato: a faixa minInterestRatemaxInterestRate congelada numa versão de produto, contra a qual o seu orquestrador confere se a taxa daqui é vendável. Sem ele, o teto que existe é o maxRate da faixa, que você mesmo declara.

Este building block me deixa em conformidade com a regulação de crédito?

Não, e essa é uma resposta que precisa ser dada assim. Ele não codifica teto de juros, alíquota nem regra de divulgação. Ele é a ferramenta com que você implementa a política que a sua área jurídica determinou, inclusive a parte imposta por norma. Quem garante conformidade é o seu processo; o que ele oferece é a mecânica de aplicar e a rastreabilidade de ter aplicado.

Qual a diferença entre este e o Calculations Engine?

Este decide quanto custa — taxa, tarifa, comissão, seguro — a partir do risco e da política comercial. O Calculations Engine calcula como isso se paga — parcela, cronograma, IOF, CET — a partir de números que alguém já decidiu. Um é política, com banco de dados e escopo de tenant; o outro é aritmética, stateless e sem persistência.


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

Building blocks relacionados