Pricing Engine
BetaTaxa por faixa de risco, com tarifas, comissões e seguros no mesmo cálculo
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.
- 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
- 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
- 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
13 endpoints em 4 recursos.
Resumo 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.
flowchart LR E["Proposta<br/>produto · score · valor · prazo"] --> C["POST /pricing/api/v1/calculate"] C --> R["Taxa mensal<br/>tarifas · comissões · seguros"] C --> J["A faixa e a regra<br/>que decidiram o preço"] style C fill:#1f6feb,stroke:#1f6feb,color:#fff
| Atributo | Valor |
|---|---|
| Identificador | pricing-engine |
| Categoria | Financeiro |
| Escopo | Tenant (exige organizationId no token em todas as 11 rotas) |
| Porta (standalone) | 3012 |
| Path alias | @pricing-engine |
| Prefixo HTTP | /pricing — não /pricing-engine |
| Schema PostgreSQL | pricing |
| Status | Beta desde 2025-11 |
| Depende de | PostgreSQL, IAM |
O problema
negócioO 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 caminho que esse preço percorre hoje, na operação típica, passa por três traduções antes de chegar ao cliente — e cada seta é um lugar onde o número muda de forma involuntária:
flowchart LR R["Comitê de risco<br/>decide a curva"] --> X["Planilha .xlsx<br/>compartilhada"] X --> T["TI transcreve para<br/>constante no código"] T --> D["Fila de release<br/>PR, revisão, janela"] D --> P["Preço no ar"] X -. "erro de célula" .-> F1["Taxa errada em produção"] T -. "erro de transcrição" .-> F1 D -. "10 a 15 dias" .-> F2["Vende com a taxa velha"] style F1 fill:#7d1a1a,stroke:#7d1a1a,color:#fff style F2 fill:#7d1a1a,stroke:#7d1a1a,color:#fff
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
iffoi 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.
Proposta de valor
negócio| Antes | Depois |
|---|---|
| A tabela de taxas é uma planilha transcrita para o código | Faixas e regras são registros consultáveis por API |
| Mudar a taxa de uma faixa é ticket, sprint e deploy | PATCH na faixa, vale na próxima chamada |
| "Por que essa taxa?" é uma investigação | A resposta traz riskBand e pricingRule que decidiram |
| Taxa, tarifa, comissão e seguro vêm de três sistemas | Uma chamada devolve os quatro, com o detalhe item a item |
| Cliente sem faixa aplicável gera erro na esteira | Devolve 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.
flowchart TD C["Resposta de POST /calculate"] --> A["approved"] C --> N["O número<br/>interestRate · totalFees<br/>totalCommissions · totalInsurance"] C --> Q["A justificativa<br/>riskBand.id e name<br/>pricingRule.id e name"] C --> B["O detalhe item a item<br/>fees · commissions · insurances"] Q --> AUD["Auditoria dois anos depois:<br/>duas consultas por identificador"] style Q fill:#1f6feb,stroke:#1f6feb,color:#fff style AUD fill:#1a7d4a,stroke:#1a7d4a,color:#fff
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.
Casos de uso reais
negócioCaso 1 — Uma financeira ajusta a curva de preço em uma tarde Cenário ilustrativo
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.
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.
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 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.
flowchart LR
subgraph ANTES["Antes — 10 a 15 dias"]
A1["Comitê decide"] --> A2["Ticket"] --> A3["PR e revisão"] --> A4["Janela de quinta"] --> A5["Taxa no ar"]
end
subgraph DEPOIS["Depois — minutos"]
D1["Comitê decide"] --> D2["PATCH em cada faixa<br/>baseRate"] --> D3["Próxima chamada<br/>de calculate já usa"]
end
D2 --> EV["Evento pricing.risk_band.updated<br/>com o diff → Audit Trail"]
style A4 fill:#7d1a1a,stroke:#7d1a1a,color:#fff
style D3 fill:#1a7d4a,stroke:#1a7d4a,color:#fffCaso 2 — Um correspondente descobre por que a proposta saiu a 2,49% Cenário ilustrativo
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%.
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 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.
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.
sequenceDiagram autonumber participant G as Gerente do correspondente participant O as Esteira de originação participant P as Pricing Engine G->>O: "Por que 2,49% e não 1,99%?" O->>O: Recupera riskBand.id e pricingRule.id<br/>gravados junto com a proposta O->>P: GET /risk-bands/:id P-->>O: Score alto · 700 a 799 · baseRate e rateSpread O->>P: GET /pricing-rules/:id P-->>O: Regra do canal · tarifas · vigência O-->>G: A faixa em que o cliente caiu<br/>e a regra comercial aplicada
Caso 3 — Um seguro prestamista para de ser calculado em planilha Cenário ilustrativo
Fintech de crédito com prestamista obrigatório acima de determinado valor, cotado por uma seguradora parceira a um percentual mensal do valor financiado.
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.
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.
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.
flowchart LR
R["Regra de precificação"] --> I["Entrada em insurances<br/>insuranceType LIFE · monthlyRate · isMandatory"]
I --> M["monthlyPremium =<br/>valor solicitado × monthlyRate ÷ 100"]
M --> T["totalPremium =<br/>monthlyPremium × nº de parcelas"]
T --> K{"maxCoverage definido<br/>e estourado?"}
K -- "sim" --> L["totalPremium = maxCoverage"]
K -- "não" --> S["totalInsurance na resposta,<br/>ao lado da taxa e das tarifas"]
L --> S
T -. "base é o valor solicitado,<br/>não o saldo devedor (§15)" .-> W["Apólice sobre saldo devedor<br/>fica superestimada"]
style W fill:#7d1a1a,stroke:#7d1a1a,color:#fffCaso 4 — O mercado chama isso de risk-based pricing e cobra caro por ele Referência de mercado
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 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.
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 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.
flowchart LR
PROB["Quero parar de manter<br/>tabela de taxa em planilha"] --> ESC{"Que porte de solução<br/>o mercado oferece?"}
ESC --> A["Plataforma de decisão inteira<br/>FICO · Provenir · Earnix · Zest AI<br/>contrato negociado, preço não publicado"]
ESC --> B["Planilha compartilhada<br/>custo zero, erro de célula<br/>e nenhuma rastreabilidade"]
ESC --> C["Pricing Engine<br/>2 tabelas · 11 rotas<br/>faixa, regra e resposta rastreável"]
style C fill:#1f6feb,stroke:#1f6feb,color:#fffMercado e diferenciais
negócioPanorama. 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.
flowchart LR D["Dados do cliente<br/>bureau, cadastro, comportamento"] --> M["MODELAR RISCO<br/>probabilístico, estatístico<br/>FICO · Zest AI · Neurotech"] M --> S["creditScore"] S --> AP["APLICAR PREÇO<br/>determinístico, auditável<br/>Catalisa Pricing Engine"] AP --> PR["Taxa, tarifa, comissão, seguro"] style AP fill:#1f6feb,stroke:#1f6feb,color:#fff
| Critério | Catalisa Pricing | FICO Platform | Provenir | Earnix | Zest AI |
|---|---|---|---|---|---|
| Escopo | Só a aplicação do preço | Decisão de crédito completa | Orquestração de decisão | Precificação analítica | Modelagem de underwriting |
| Modelagem de score | Não faz — consome o seu | Sim, é o núcleo | Via integrações | Sim | Sim, é o núcleo |
| Faixa de score → taxa | Sim, por API | Sim | Sim | Sim | Indireto |
| Tarifa, comissão e seguro no mesmo cálculo | Sim | Depende da implantação | Depende do fluxo | Sim | Não |
| Otimização de preço (elasticidade) | Não | Parcial | Não | Sim, é o núcleo | Não |
| Editor visual de regra | Não | Sim | Sim | Sim | Parcial |
| Rastreabilidade da decisão de preço | Faixa e regra na resposta | Sim, extensa | Sim | Sim | Explicabilidade de modelo |
| Preço | Precificação em definição | Não publicado | Não publicado | Não publicado | Não publicado |
| Porte de entrada | Building block avulso | Projeto corporativo | Projeto de plataforma | Projeto corporativo | Contrato 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
- A justificativa do preço vem junto com o preço.
riskBandepricingRuleestã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. - 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.
- É 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.
- Recusa é
200, não4xx. Cliente fora de faixa devolveapproved: falsecom 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
flowchart TD
Q{"Qual é o seu problema<br/>de verdade?"}
Q -- "Construir e governar<br/>o modelo de score" --> F["FICO ou Zest AI"]
Q -- "Otimizar preço por<br/>elasticidade de demanda" --> E["Earnix"]
Q -- "Orquestrar a decisão inteira<br/>com editor visual" --> P["Provenir · ou Decision Platform<br/>+ Decision Engine na Catalisa"]
Q -- "Regra de preço que não cabe em<br/>faixa de score, valor e prazo" --> M["Motor de regras de verdade<br/>o tipo DECISION daqui não está implementado"]
Q -- "Aplicar uma política declarada,<br/>de forma rastreável" --> C["Catalisa Pricing Engine"]
style C fill:#1f6feb,stroke:#1f6feb,color:#fff
style M fill:#7d1a1a,stroke:#7d1a1a,color:#fffQuando 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.
Modelo de cobrança e ROI
negócioUnidade 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:
| Driver | Por que é justo |
|---|---|
| Chamadas de cálculo de preço | Escala com o volume de propostas, que é o valor entregue |
| Faixas de risco ativas | Mede a granularidade da política — quem segmenta mais aproveita mais |
| Regras de precificação ativas | Mede 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 Pricing | Plataforma de decisão corporativa | |
|---|---|---|
| Custo de licença | Precificação em definição | Contrato negociado, não publicado |
| Projeto de implantação | Integração de API | Projeto de meses, com consultoria |
| O que vem junto | Só a aplicação do preço | Score, política, orquestração, simulação |
| Mudar uma taxa | PATCH numa faixa | Depende 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.
flowchart LR
subgraph L1["Linha 1 — ciclo de alteração de preço"]
A["Comitê decide<br/>a taxa nova"] --> B["Ticket, sprint,<br/>janela de release"] --> C["Dias a semanas<br/>vendendo com a taxa velha"]
A2["Comitê decide<br/>a taxa nova"] --> B2["PATCH por faixa"] --> C2["Minutos"]
end
subgraph L2["Linha 2 — reconstruir o preço de um contrato"]
D["Sem faixa e regra<br/>registradas"] --> E["Arqueologia de<br/>planilha e commit"]
D2["Com riskBand.id e<br/>pricingRule.id gravados"] --> E2["Consulta por identificador"]
end
style C fill:#7d1a1a,stroke:#7d1a1a,color:#fff
style C2 fill:#1a7d4a,stroke:#1a7d4a,color:#fff
style E fill:#7d1a1a,stroke:#7d1a1a,color:#fff
style E2 fill:#1a7d4a,stroke:#1a7d4a,color:#fffA 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.
Arquitetura
As camadas
flowchart TD
H["HTTP"] --> APP["Hono app · basePath /pricing<br/>applyCommonMiddleware + errorHandler"]
APP --> R1["/api/v1/risk-bands<br/>riskBandsRouter · 5 rotas"]
APP --> R2["/api/v1/pricing-rules<br/>pricingRulesRouter · 5 rotas"]
APP --> R3["/api/v1/calculate<br/>pricingRouter · 1 rota"]
APP --> R4["/health<br/>identificação e versão do build"]
R1 --> MW["authMiddleware → requirePermission(P) → requireOrganization<br/>Zod parse → ResultAsync"]
R2 --> MW
R3 --> MW
MW --> S1["PricingService<br/>o cálculo. Sem I/O além dos dois repositórios"]
MW --> S2["RiskBandService<br/>CRUD + validação de faixa + eventos"]
MW --> S3["PricingRuleService<br/>CRUD + validação de faixa, prazo e data + eventos"]
S1 --> REP["repositories/ (Prisma)<br/>toda query de leitura filtra<br/>organizationId E deletedAt null"]
S2 --> REP
S3 --> REP
REP --> PG[("PostgreSQL<br/>schema pricing")]
S2 --> EV["EventPublisher<br/>6 tipos de evento"]
S3 --> EV
style S1 fill:#1f6feb,stroke:#1f6feb,color:#fffO applyCommonMiddleware cobre limite de corpo de 1 MB, CORS, cabeçalhos de segurança e rate limit; o errorHandler traduz AppError em resposta HTTP. Os seis eventos publicados são pricing.risk_band.created, .updated e .deleted, mais pricing.pricing_rule.created, .updated e .deleted.
O caminho de uma chamada de cálculo
flowchart TD
IN["POST /pricing/api/v1/calculate<br/>productId · creditScore<br/>requestedAmount · numberOfInstallments"] --> P1
P1{"1. Encontra a faixa de risco<br/>findFirst · ORDER BY priority ASC"}
P1 -- "não achou" --> X1["200 approved false<br/>No risk band found for credit score N"]
P1 -- "achou" --> P2
P2{"2. Encontra a regra de precificação<br/>findFirst · ORDER BY priority ASC"}
P2 -- "não achou" --> X2["200 approved false<br/>No applicable pricing rule found for amount..."]
P2 -- "achou" --> P3
P3{"3. interestRate = baseRate + rateSpread<br/>da FAIXA"}
P3 -- "maxRate definido e<br/>interestRate maior que ele" --> X3["200 approved false<br/>Interest rate exceeds maximum..."]
P3 -- "dentro do teto" --> P4
P4["Calcula fees, commissions e insurances<br/>da REGRA<br/>effectiveAnnualRate = (1 + interestRate)^12 − 1"]
P4 --> OK["200 approved true<br/>interestRate · fees · totalFees · commissions<br/>insurances · riskBand · pricingRule"]
style OK fill:#1a7d4a,stroke:#1a7d4a,color:#fff
style X1 fill:#7d1a1a,stroke:#7d1a1a,color:#fff
style X2 fill:#7d1a1a,stroke:#7d1a1a,color:#fff
style X3 fill:#7d1a1a,stroke:#7d1a1a,color:#fffAs duas consultas do desenho acima são literais no código. Vale ler as cláusulas com atenção, porque é nelas que mora a maioria das dúvidas de integração:
| Passo | Consulta, exatamente como o repositório monta |
|---|---|
| 1. Faixa de risco | RiskBand WHERE productId, organizationId, deletedAt IS NULL, isActive = true, minScore <= creditScore <= maxScore · ORDER BY priority ASC → pega a primeira |
| 2. 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 |
Decisões não óbvias
Recusa é
200, não4xx.createRejectionResultmonta um resultado de sucesso comapproved: falsee 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 comriskBand.idepricingRule.idvazios (""), porque não houve faixa nem regra. Sempre chequeapprovedantes de ler qualquer outro campo.A seleção é "a primeira por
priority", não "a melhor". Tanto a faixa quanto a regra usamfindFirstcomORDER 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 menorpriority, e nada no serviço avisa que há sobreposição. Não existe validação de sobreposição de faixas. Useprioritydeliberadamente e trate a checagem de sobreposição como responsabilidade sua.productIdnão tem chave estrangeira. É umuuidlivre 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: umproductIddigitado 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,numberna aplicação. O Postgres guarda as taxas como decimal de 8 casas para não perder centésimo de ponto-base. A conversão paranumberacontece na fronteira do serviço, comNumber(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
JOINpara 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 umINSERTfeito direto no banco passa por cima dela.Evento publicado dentro da cadeia de
Result. Cada mutação encadeia a publicação do evento comResultAsync.fromPromise. Falha ao publicar viraINTERNALdepois 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.
O desempate por priority, desenhado
A regra de seleção é a mesma nos dois passos e cabe em uma frase: a primeira linha ativa que casa, ordenada por priority crescente. Duas faixas sobrepostas não são erro para o serviço — são duas linhas que casam, e vence a de menor priority.
flowchart TD S["creditScore = 780"] --> Q["Faixas ativas do produto que casam"] Q --> B1["Score alto · 700 a 799<br/>priority 20"] Q --> B2["Score premium · 750 a 850<br/>priority 10"] B1 --> ORD["ORDER BY priority ASC<br/>findFirst"] B2 --> ORD ORD --> W["Vence Score premium<br/>priority 10 é menor"] W --> N["Nada no serviço avisa<br/>que havia sobreposição"] style W fill:#1f6feb,stroke:#1f6feb,color:#fff style N fill:#7d1a1a,stroke:#7d1a1a,color:#fff
Atenção. Se as duas faixas tiverem a mesma priority, a ordem que o Postgres devolve decide, e ela não é determinística. Declare priority explícita em todas as faixas.
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.
Conceitos e modelo de dados
Glossário
| Termo | Significa |
|---|---|
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. |
Versão da regra (PricingRuleVersion) | A fotografia da regra a cada criação ou atualização: version, snapshot, changedBy, reason. É o que responde "qual tarifa valia naquele contrato". |
baseRate | Taxa mensal base da faixa, em decimal (0.0189 = 1,89% ao mês). Aceita de 0 a 1. |
rateSpread | Ajuste somado à baseRate. Aceita de −1 a 1 — pode ser negativo, para desconto. Padrão 0. |
maxRate | Teto da faixa. Se baseRate + rateSpread ultrapassá-lo, o cálculo recusa. Opcional. |
priority | Desempate. 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. |
effectiveAnnualRate | Anualização composta da taxa mensal. Não é CET — não inclui tarifa, comissão, seguro nem IOF. |
approved | true 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 Prisma | Tabela | Propósito | Campos-chave |
|---|---|---|---|
RiskBand | pricing.risk_bands | Faixa de score com a taxa | minScore, maxScore (Int), baseRate, maxRate, rateSpread (Decimal(10,8)), priority, isActive, único (organizationId, productId, name) |
PricingRule | pricing.pricing_rules | Pacote comercial de tarifas, comissões e seguros | ruleType, status, decisionProjectId, minAmount/maxAmount (Decimal(15,2)), minTerm/maxTerm, fees/commissions/insurances (JSONB), effectiveFrom/effectiveTo, priority, único (organizationId, productId, name) |
erDiagram
RISK_BAND {
uuid id PK
uuid organization_id "do token, nunca do corpo"
uuid product_id "sem chave estrangeira"
string name "único por organização e produto"
int min_score
int max_score
decimal base_rate "Decimal(10,8)"
decimal max_rate "opcional, é o freio"
decimal rate_spread "pode ser negativo"
int priority "menor vence"
boolean is_active
datetime deleted_at "exclusão lógica"
}
PRICING_RULE {
uuid id PK
uuid organization_id "do token, nunca do corpo"
uuid product_id "sem chave estrangeira"
string name "único por organização e produto"
enum rule_type "DB_RULE ou DECISION"
enum status "só ACTIVE entra no cálculo"
decimal min_amount "Decimal(15,2)"
decimal max_amount "Decimal(15,2)"
int min_term
int max_term
jsonb fees
jsonb commissions
jsonb insurances
datetime effective_from
datetime effective_to
int priority "menor vence"
datetime deleted_at "exclusão lógica"
}
PRODUTO_EXTERNO {
uuid product_id "mora no Portfolio, no Products ou no core do cliente"
}
PRODUTO_EXTERNO ||..o{ RISK_BAND : "product_id, sem FK"
PRODUTO_EXTERNO ||..o{ PRICING_RULE : "product_id, sem FK"Atenção. As duas tabelas não têm relacionamento entre si no banco. O que as une é o productId, e ele não tem chave estrangeira em lugar nenhum — o produto pode morar fora deste building block. O PRODUTO_EXTERNO no diagrama é conceitual, não é uma tabela do schema pricing.
Enumerações
| Enum | Valores | Observação |
|---|---|---|
PricingRuleStatus | ACTIVE · INACTIVE · DRAFT | Só ACTIVE entra no cálculo. Padrão na criação: DRAFT |
PricingRuleType | DB_RULE · DECISION | DECISION não está implementado (§15) |
FeeType | REGISTRATION · ADMINISTRATION · ANALYSIS · DOCUMENTATION · OTHER | Rótulo; não altera o cálculo |
FeeCalculationMethod | FIXED · PERCENTAGE_OF_PRINCIPAL · PERCENTAGE_OF_TOTAL | PERCENTAGE_OF_TOTAL hoje se comporta igual a PERCENTAGE_OF_PRINCIPAL (§15) |
CommissionType | SALES · PARTNER · CHANNEL | Rótulo; não altera o cálculo |
InsuranceType | LIFE · DISABILITY · UNEMPLOYMENT · PROPERTY · COMBINED | Rótulo; não altera o cálculo |
As fórmulas, exatamente como estão no código (PricingService)
As quatro famílias de cálculo são independentes entre si e rodam na mesma passada. A taxa vem da faixa; tarifa, comissão e seguro vêm da regra. Cada uma tem sua ordem de aplicação, e a ordem importa — o arredondamento é sempre o último passo de cada item.
flowchart LR F["FAIXA de risco"] --> T1["TAXA"] R["REGRA de precificação"] --> T2["TARIFA"] R --> T3["COMISSÃO"] R --> T4["SEGURO"] T1 --> AN["ANUALIZAÇÃO<br/>effectiveAnnualRate"] T1 --> RES["Resposta"] T2 --> RES T3 --> RES T4 --> RES AN --> RES style T1 fill:#1f6feb,stroke:#1f6feb,color:#fff
Fórmula — taxa
Fórmula — tarifa
A sequência de limites e arredondamento da tarifa é a que mais surpreende quem lê só a fórmula do método:
flowchart LR
M{"calculationMethod"} -- "FIXED" --> A["amount = value"]
M -- "PERCENTAGE_OF_PRINCIPAL" --> B["amount = valor solicitado × value ÷ 100"]
M -- "PERCENTAGE_OF_TOTAL" --> B
A --> P["piso: se abaixo de minAmount,<br/>amount = minAmount"]
B --> P
P --> T["teto: se acima de maxAmount,<br/>amount = maxAmount"]
T --> RD["arredonda a 2 casas"]
RD --> SU["totalFees = soma dos amount"]Fórmula — comissão
Fórmula — seguro
Fórmula — anualização
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
stateDiagram-v2
[*] --> DRAFT: POST /pricing-rules sem status no corpo
DRAFT --> ACTIVE: PATCH status
ACTIVE --> INACTIVE: PATCH status
INACTIVE --> DRAFT: PATCH status
ACTIVE --> DRAFT: PATCH status
DRAFT --> INACTIVE: PATCH status
INACTIVE --> ACTIVE: PATCH status
note right of ACTIVE
Só ACTIVE participa do cálculo.
end noteNã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.
stateDiagram-v2 direction LR state "Faixa ou regra viva — deletedAt null" as VIVA state "Excluída logicamente — deletedAt preenchido" as MORTA [*] --> VIVA: POST VIVA --> MORTA: DELETE MORTA --> [*]: nenhuma leitura volta a enxergá-la
Referê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étodo | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /pricing/api/v1/calculate | Calcula taxa, tarifas, comissões e seguros de uma proposta | PRICING_CALCULATE |
Faixas de risco — /pricing/api/v1/risk-bands
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /pricing/api/v1/risk-bands | Cria faixa. 201 | PRICING_RISK_BANDS_CREATE |
GET | /pricing/api/v1/risk-bands | Lista faixas, paginado | PRICING_RISK_BANDS_READ |
GET | /pricing/api/v1/risk-bands/:id | Busca faixa | PRICING_RISK_BANDS_READ |
PATCH | /pricing/api/v1/risk-bands/:id | Atualiza faixa | PRICING_RISK_BANDS_UPDATE |
DELETE | /pricing/api/v1/risk-bands/:id | Exclusão lógica. 204 sem corpo | PRICING_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étodo | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /pricing/api/v1/pricing-rules | Cria regra. 201 | PRICING_RULES_CREATE |
GET | /pricing/api/v1/pricing-rules | Lista regras, paginado | PRICING_RULES_READ |
GET | /pricing/api/v1/pricing-rules/:id | Busca regra | PRICING_RULES_READ |
GET | /pricing/api/v1/pricing-rules/:id/versions | Histórico da regra, da versão mais nova para a mais antiga: version, snapshot (a regra inteira naquele momento), changedBy, reason, createdAt | PRICING_RULES_READ |
PATCH | /pricing/api/v1/pricing-rules/:id | Atualiza regra; attributes.reason (opcional) vai para a versão gravada; o evento pricing.pricing_rule.updated leva changes.before/changes.after e version | PRICING_RULES_UPDATE |
DELETE | /pricing/api/v1/pricing-rules/:id | Exclusão lógica. 204 sem corpo | PRICING_RULES_DELETE |
Filtros da listagem: productId, status, ruleType, name (trecho do nome, sem distinguir maiúsculas) e effectiveAt (ISO-8601: só regras cuja vigência cobre o instante; 400 se a data for inválida). Ordenação fixa: productId, depois priority, depois createdAt decrescente.
Saúde
| Método | Rota | Descrição |
|---|---|---|
GET | /pricing/health | Identificaçã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.
sequenceDiagram
autonumber
participant E as Esteira de originação
participant A as Hono · /pricing
participant S as PricingService
participant B as RiskBandRepository
participant R as PricingRuleRepository
E->>A: POST /api/v1/calculate + Bearer JWT
A->>A: authMiddleware · requirePermission(PRICING_CALCULATE)<br/>requireOrganization · Zod parse
A->>S: calculatePricing(input, organizationId)
S->>B: findByProductIdAndScore(productId, org, creditScore)
B-->>S: faixa de menor priority, ou nulo
alt Nenhuma faixa
S-->>E: 200 approved false · No risk band found...
else Faixa encontrada
S->>R: findActiveByProduct(productId, org, amount, term)
R-->>S: regra ACTIVE de menor priority, ou nulo
alt Nenhuma regra
S-->>E: 200 approved false · No applicable pricing rule...
else Regra encontrada
S->>S: taxa, tarifas, comissões, seguros e anualização
S-->>E: 200 approved true + riskBand + pricingRule
end
endO
PricingServicenão faz nenhuma chamada de rede: os dois repositórios são as únicas dependências dele. Não há bureau, não há Decision Engine, não há cache.
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" }
}
}
}{
"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" }
}
}
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
productId | string (UUID) | Sim | Identificador do produto. Sem chave estrangeira — nada valida a existência |
creditScore | integer 0–1000 | Sim | O score que a sua política de risco produziu. Este building block não calcula score |
requestedAmount | number > 0 | Sim | Valor solicitado. É a base de tarifa percentual, comissão e seguro |
numberOfInstallments | integer 1–360 | Sim | Prazo em meses. Multiplica o prêmio mensal do seguro |
customerId | string (UUID) | Não | Aceito e não usado no cálculo. Serve para correlação nos seus logs |
context | object | Não | Aceito 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
}
}
}{
"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 de89.90, análise de2%do principal e prestamista de0.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"
}
}
}{
"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:
rejectionReason | Quando |
|---|---|
No risk band found for credit score N | Nenhuma faixa ativa do produto cobre o score |
No applicable pricing rule found for amount X and term Y | Nenhuma regra ACTIVE do produto cobre valor, prazo e vigência |
Interest rate exceeds maximum allowed rate for this risk band | baseRate + rateSpread ficou acima do maxRate da faixa |
No terceiro caso — e só nele —
riskBandepricingRulevêm preenchidos, cominterestRate,baseRateerateSpreadreais. Nos dois primeiros, vêm vazios.
Erros
| Status | Quando |
|---|---|
400 | Corpo reprovado no Zod: score fora de 0–1000, valor não positivo, prazo fora de 1–360, productId não é UUID |
401 | Token ausente, inválido ou expirado |
403 | Falta 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
}
}
}{
"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
}
}
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
productId | UUID | Sim | Produto ao qual a faixa pertence |
name | string 1–50 | Sim | Único por (organização, produto). Colisão devolve 409 |
displayLabel | string ≤100 | Não | Rótulo para interface |
minScore / maxScore | integer 0–1000 | Sim | Intervalo inclusivo. minScore > maxScore devolve 400 |
baseRate | number 0–1 | Sim | Taxa mensal em decimal |
maxRate | number 0–1 | Não | Teto. Menor que baseRate devolve 400 |
rateSpread | number −1 a 1 | Não | Padrão 0. Pode ser negativo |
priority | integer ≥0 | Não | Padrão 0. Menor vence no desempate |
isActive | boolean | Não | Padrã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
| Status | Quando |
|---|---|
400 | minScore > maxScore; baseRate ou maxRate fora de 0–1; maxRate < baseRate |
409 | Já 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"
}
}
}{
"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"
}
}
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
productId | UUID | Sim | Produto |
name | string 1–100 | Sim | Único por (organização, produto). Colisão devolve 409 |
ruleType | DB_RULE | DECISION | Sim | DECISION exige decisionProjectId, mas não é executado (§15) |
status | ACTIVE | INACTIVE | DRAFT | Não | Padrão DRAFT — regra recém-criada não entra no cálculo |
decisionProjectId | UUID | Só em DECISION | Gravado e ignorado no cálculo |
priority | integer ≥0 | Não | Padrão 0. Menor vence |
minAmount / maxAmount | number ≥0 | Não | Faixa de valor. min > max devolve 400. Nulo significa sem limite |
minTerm / maxTerm | integer ≥1 | Não | Faixa de prazo. min > max devolve 400 |
fees[] | array | Não | feeType, name, calculationMethod, value ≥0, isFinanced, isRefundable, minAmount, maxAmount, payer (BORROWER · HOUSE; ausente = tomador), includeInCet (entra no custo efetivo total; ausente = sim para o tomador, sempre não para a casa) |
commissions[] | array | Não | commissionType, name, percentage 0–100, fixedAmount, maxAmount, paymentTiming |
insurances[] | array | Não | insuranceType, name, monthlyRate 0–100, isMandatory, isFinanced, maxCoverage |
effectiveFrom / effectiveTo | ISO 8601 | Não | Vigência. from > to devolve 400. Nulo significa sem limite |
Erros
| Status | Quando |
|---|---|
400 | minAmount > maxAmount; minTerm > maxTerm; effectiveFrom > effectiveTo; ruleType: "DECISION" sem decisionProjectId |
409 | Já existe regra com esse name para o mesmo produto e organização |
Armadilha frequente: sem
statusno corpo, a regra nasceDRAFTe oPOST /calculatecontinua devolvendo "No applicable pricing rule found". Passe"status": "ACTIVE"ou faça umPATCHdepois.
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 }
}
}{
"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.
Início rápido
Do zero a um preço calculado. Quatro chamadas.
flowchart LR P1["1 · Autenticar no IAM<br/>TOKEN"] --> P2["2 · Criar a faixa de risco<br/>700 a 799 · 1,89% + 0,25%"] P2 --> P3["3 · Criar a regra<br/>já ACTIVE"] P3 --> P4["4 · Calcular o preço<br/>score 742 · R$ 15.000 · 36x"] P4 --> P5["5 · Ver a recusa acontecer<br/>score 420"] style P4 fill:#1a7d4a,stroke:#1a7d4a,color:#fff style P5 fill:#7d1a1a,stroke:#7d1a1a,color:#fff
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/*.tse 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)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'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'Resposta esperada — o identificador da faixa e a taxa base que ela guarda:
"b1a2c3d4-0000-0000-0000-000000000001"
0.0189"b1a2c3d4-0000-0000-0000-000000000001"
0.01893. 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'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'Resposta esperada — o identificador da regra:
"e5f6a7b8-0000-0000-0000-000000000002""e5f6a7b8-0000-0000-0000-000000000002"Atenção. O "status":"ACTIVE" no corpo não é opcional para este passo funcionar. Sem ele a regra nasce DRAFT e o passo 4 devolve approved: false com "No applicable pricing rule found".
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}'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"
}{
"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}'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"
}{
"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.
Receitas
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.
flowchart LR S["creditScore"] --> F1["800 a 1000<br/>1,59% · priority 10"] S --> F2["700 a 799<br/>1,89% · priority 20"] S --> F3["600 a 699<br/>2,49% · priority 30"] S --> F4["500 a 599<br/>3,29% · priority 40"] S --> F5["abaixo de 500<br/>nenhuma faixa"] F5 --> REJ["approved false<br/>No risk band found"] style REJ fill:#7d1a1a,stroke:#7d1a1a,color:#fff
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'
donefor 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'
doneResposta esperada — quatro identificadores, um por faixa criada:
"b1a2c3d4-0000-0000-0000-000000000001"
"b1a2c3d4-0000-0000-0000-000000000002"
"b1a2c3d4-0000-0000-0000-000000000003"
"b1a2c3d4-0000-0000-0000-000000000004""b1a2c3d4-0000-0000-0000-000000000001"
"b1a2c3d4-0000-0000-0000-000000000002"
"b1a2c3d4-0000-0000-0000-000000000003"
"b1a2c3d4-0000-0000-0000-000000000004"Armadilhas.
- Nada valida sobreposição. Se você escrever
700:799e750:850, as duas ficam ativas e o desempate épriority. Confira comGET /risk-bands?productId=...ordenado — a listagem já vem porprioritye depoisminScore. - 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 devolve409na segunda.maxRateé o freio. CommaxRate: 0.0399e uma faixa debaseRate: 0.0329maisrateSpread: 0.008, o resultado é0.0409e 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.
A vigência é filtro de consulta, não estado: a regra entra e sai do cálculo sozinha, sem ninguém publicar nem despublicar nada.
flowchart LR A["Antes de 01/09<br/>effectiveFrom no futuro"] --> B["Durante setembro<br/>a regra de campanha vence<br/>priority 1"] B --> C["Depois de 30/09<br/>effectiveTo no passado"] A -.-> R1["Regra padrão responde"] C -.-> R2["Regra padrão volta a responder<br/>campanha continua ACTIVE na listagem"] style B fill:#1f6feb,stroke:#1f6feb,color:#fff
# 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'# 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
rateSpreaddas faixas — e aí o desconto vale para todo mundo, não só para a campanha. Essa é uma limitação real do modelo (§15). prioritymenor vence. A regra de campanha precisa deprioritymenor que a regra padrão. Se as duas tiverem0, o desempate fica indefinido na prática.- Depois de
effectiveTo, a regra some do cálculo sozinha — mas continuaACTIVEna listagem. Isso é intencional: a vigência é filtro de consulta, não mudança de estado.
Descobrir por que o cálculo recusou
Três consultas, sempre na mesma ordem: o que a recusa disse, quais faixas existem, quais regras estão ACTIVE.
flowchart TD
R["rejectionReason"] --> A{"Qual texto veio?"}
A -- "No risk band found" --> B["Passo 2: as faixas ativas do produto<br/>o score cabe em alguma?"]
A -- "No applicable pricing rule found" --> C["Passo 3: as regras ACTIVE do produto<br/>valor, prazo e vigência cobrem a proposta?"]
A -- "Interest rate exceeds maximum" --> D["Faixa e regra foram achadas.<br/>baseRate + rateSpread passou do maxRate da faixa"]
style D fill:#1f6feb,stroke:#1f6feb,color:#fff# 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}'# 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:
- A regra está
DRAFT. Criada semstatus, ela nasceDRAFTe nunca entra no cálculo. É de longe a causa mais frequente de "No applicable pricing rule found". - O
productIdestá errado. Não há chave estrangeira: um UUID inexistente não dá erro, dá "nenhuma faixa encontrada". Confira que oproductIddo cálculo é literalmente o mesmo da faixa. - A faixa não cobre o score. Os limites são inclusivos nos dois lados. Score 800 numa faixa
700–799não entra. - A vigência da regra já passou ou ainda não começou. Compare
effectiveFromeeffectiveTocom o horário do servidor, em UTC. - A taxa estourou o
maxRate. SerejectionReasonmencionaexceeds maximum, a faixa e a regra foram encontradas — o problema ébaseRate + rateSpreadacima do teto da própria faixa. - A organização está errada. O
organizationIdvem 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# 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'
doneArmadilhas.
- Não há operação em lote. É um
PATCHpor 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 umGETno fim. - O
PATCHvale 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. maxRatenão é reajustado junto. SubirbaseRatesem subirmaxRatepode fazer faixas passarem a recusar. Reajuste os dois.- Guarde o antes. O evento
pricing.risk_band.updatedcarrega só as mudanças, não o valor anterior. Se você precisa do histórico, faça oGETda 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).
sequenceDiagram autonumber participant O as Seu orquestrador participant P as Pricing Engine participant C as Calculations Engine O->>P: POST /pricing/api/v1/calculate P-->>O: interestRate 0.0214 · fees · isFinanced Note over O: O orquestrador compõe o presentValue.<br/>Tarifa financiada muda o principal. O->>C: POST /loan-payment-calculator/calculations<br/>interestRate · numberOfPayments · presentValue C-->>O: payment 601.81
TAXA=$(curl -s -X POST $API/calculate -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d "$PAYLOAD" \
| jq -r '.data.attributes.interestRate')
CALC=https://calculations.bb.stg.catalisa.app/calculations-engine/api/v1/calculations
curl -s -X POST "$CALC/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'TAXA=$(curl -s -X POST $API/calculate -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d "$PAYLOAD" \
| jq -r '.data.attributes.interestRate')
CALC=https://calculations.bb.stg.catalisa.app/calculations-engine/api/v1/calculations
curl -s -X POST "$CALC/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. effectiveAnnualRatenã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 opresentValueque você manda para o cálculo da parcela não é mais orequestedAmount. O Pricing Engine devolve a flag; a composição é sua.
Integração com outros building blocks
| Building block | Como se relaciona | Obrigatório |
|---|---|---|
| IAM | Emite o token; as 9 permissões PRICING_* e o organizationId vêm dele | Sim |
| Calculations Engine | Recebe a taxa e calcula parcela, IOF e CET. Integração por contrato, não por código | Não |
| Banking Product Portfolio | Define o produto e o teto minInterestRate–maxInterestRate que a taxa daqui deve respeitar. Integração por contrato, não por código | Não |
| Decision Platform | Orquestra a esteira e chama este building block na etapa de preço | Não |
| Decision Engine | Destino pretendido das regras ruleType: "DECISION". Não implementado (§15) | Não |
| Audit Trail | Consome os 6 eventos pricing.* para trilha de compliance | Não |
| Webhooks Engine | Entrega os eventos de mudança de faixa e de regra a sistemas externos | Nã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 —PricingServicedepende exclusivamente deRiskBandRepositoryePricingRuleRepository. E nenhum outro building block chama este: o Banking Product Portfolio documenta explicitamente que oPOST /simulatedele 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
flowchart TD PROP["PROPOSTA<br/>cliente pede R$ 15.000 em 36x"] PROP --> BPP["BANKING PRODUCT PORTFOLIO — o mandato<br/>POST /simulate<br/>devolve os LIMITES: faixa de valor, faixa de TAXA,<br/>prazo, iofDailyRate. Não devolve preço."] BPP --> DEC["DECISION PLATFORM + DECISION ENGINE — a decisão<br/>bureau, política, DMN<br/>aprovado? qual limite? qual SCORE?"] DEC -- "score em mãos" --> PE["PRICING ENGINE — você está aqui<br/>POST /pricing/api/v1/calculate<br/>entrada: productId, creditScore, requestedAmount, prazo<br/>saída: interestRate, fees, commissions, insurances<br/>+ riskBand e pricingRule que decidiram"] PE --> CHK["O orquestrador confere:<br/>interestRate cabe no teto do Portfolio?"] CHK --> CE["CALCULATIONS ENGINE — a matemática<br/>parcela PRICE ou SAC · IOF · CET sobre o líquido liberado<br/>entradas: valor, prazo, interestRate daqui,<br/>tarifas daqui, iofDailyRate do Portfolio"] CE -- "parcela e CET" --> CT["CONTRATO<br/>guarda riskBand.id, pricingRule.id e versionId. Sempre."] CT --> ES["E-SIGNATURE assina"] CT --> AT["AUDIT TRAIL registra"] CT --> BI["BILLING cobra"] style PE fill:#1f6feb,stroke:#1f6feb,color:#fff style CT fill:#1a7d4a,stroke:#1a7d4a,color:#fff
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.
Configuraçã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ável | Descrição | Obrigatória | Padrão |
|---|---|---|---|
DATABASE_URL | PostgreSQL. O schema pricing precisa existir (criado pelas migrações) | Sim | — |
JWT_SECRET | Segredo HS256, mínimo 44 caracteres. Verifica o token emitido pelo IAM | Sim | — |
PORT | Porta no modo standalone | Não | 3000 (a convenção do projeto para este módulo é 3012) |
DEPLOYMENT_MODE | monolith ou standalone | Não | standalone (forçado em main.ts) |
MODULE_SELF | Identifica o serviço no /health | Não | pricing-engine |
REDIS_URL | Redis compartilhado. Usado pelo rate limit global de applyCommonMiddleware, não pelo módulo | Não | — |
Dependências de infraestrutura
| Dependência | Para quê |
|---|---|
| PostgreSQL | Schema pricing, duas tabelas |
| IAM | Emissã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
| Limite | Valor |
|---|---|
| Tamanho do corpo da requisição | 1 MB (applyCommonMiddleware) |
pageSize padrão na listagem | 20 |
creditScore | Inteiro de 0 a 1000 |
numberOfInstallments | Inteiro de 1 a 360 |
baseRate, maxRate | 0 a 1 (decimal) |
rateSpread | −1 a 1 |
percentage da comissão, monthlyRate do seguro | 0 a 100 (percentual) |
Tamanho do name da faixa | 50 caracteres |
Tamanho do name da regra | 100 caracteres |
Tamanho da description da regra | 5.000 caracteres |
| Precisão das taxas no banco | Decimal(10,8) |
| Precisão dos valores no banco | Decimal(15,2) |
| Quantidade de tarifas, comissões ou seguros por regra | Sem limite na aplicação |
Catálogo de erros
| Status | Código | Significa | O que fazer |
|---|---|---|---|
400 | VALIDATION | Corpo reprovado no Zod | A resposta traz details. Confira tipos e faixas contra a §9 |
400 | VALIDATION | minScore must be less than or equal to maxScore | Inverteu o intervalo da faixa |
400 | VALIDATION | baseRate must be between 0 and 1 | Passou percentual em vez de decimal. 1.89 não é 1,89% |
400 | VALIDATION | maxRate must be greater than or equal to baseRate | O teto ficou abaixo da base |
400 | VALIDATION | minAmount must be less than or equal to maxAmount | Inverteu a faixa de valor da regra |
400 | VALIDATION | minTerm must be less than or equal to maxTerm | Inverteu a faixa de prazo |
400 | VALIDATION | effectiveFrom must be before effectiveTo | Inverteu a vigência |
400 | VALIDATION | decisionProjectId is required for DECISION rule type | Informe o projeto — mas veja a §15 antes de usar DECISION |
401 | UNAUTHORIZED | Token ausente, inválido ou expirado | Renove no IAM |
403 | FORBIDDEN | Falta a permissão PRICING_* exigida pela rota | Confira a §9 e as permissões contratadas pela organização |
403 | — | Organization context required | O token não tem organizationId. Autentique informando a organização |
404 | NOT_FOUND | Faixa ou regra inexistente, excluída logicamente, ou de outra organização | O 404 para recurso de outra organização é intencional |
409 | CONFLICT | Risk band with this name already exists for this product | Escolha outro name |
409 | CONFLICT | Pricing rule with this name already exists for this product | Escolha outro name |
429 | — | Rate limit global estourado | Aplique recuo exponencial |
500 | INTERNAL | Failed to publish event — a gravação aconteceu, o evento não saiu | Reconcilie pelo estado do banco, não pelo evento |
500 | INTERNAL | Failed to compute pricing | Falha inesperada dentro do cálculo. Abra chamado com o corpo da requisição |
Recusa de crédito não é erro.
approved: falsevem com status200. Não trate como4xx.
Observabilidade
GET /pricing/healthdevolve 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,.deletedepricing.pricing_rule.created,.updated,.deleted. Todos carregammetadata.organizationIdemetadata.timestamp, e os de atualização carregam o objeto de mudanças recebido noPATCH. - 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).
Seguranç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.
flowchart TD
T["JWT assinado pelo IAM<br/>claim organizationId"] --> M["requireOrganization<br/>403 se o claim não existe"]
M --> C["c.get('user').organizationId"]
C --> S["Serviço"]
B["Corpo da requisição"] -. "organizationId NUNCA<br/>é lido daqui" .-> X["Ignorado"]
S --> F["findById(id, organizationId)"]
F -- "outra organização" --> N404["404 — não confirma<br/>a existência do recurso"]
F -- "mesma organização" --> W["update ou softDelete<br/>recebem só o id"]
style X fill:#7d1a1a,stroke:#7d1a1a,color:#fff
style N404 fill:#1f6feb,stroke:#1f6feb,color:#fffAs 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ão | Concede |
|---|---|
PRICING_CALCULATE | Calcular preço |
PRICING_RISK_BANDS_CREATE / _READ / _UPDATE / _DELETE | Gerir faixas de risco |
PRICING_RULES_CREATE / _READ / _UPDATE / _DELETE | Gerir 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
O 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
maxRatena 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.
Limitações conhecidas
As catorze limitações abaixo se dividem em quatro situações, e a diferença entre elas importa na hora de decidir se dá para vender:
flowchart TD L["Limitações conhecidas"] --> A["Especificado, não implementado<br/>o campo existe e não faz nada<br/>ruleType DECISION · customerId · context"] L --> B["Conhecido<br/>simplificação assumida, documentada no código<br/>PERCENTAGE_OF_TOTAL · seguro sobre valor solicitado<br/>evento que falha após a gravação"] L --> C["Roadmap<br/>sobreposição de faixas · versionamento da política<br/>simulação em lote · health que sonda o banco · métricas"] L --> D["Por design<br/>effectiveAnnualRate não é CET · productId sem FK<br/>cálculo sem persistência · sem Row-Level Security"] style A fill:#7d1a1a,stroke:#7d1a1a,color:#fff
Atenção. A primeira linha da tabela é a que mais precisa de cuidado comercial: ruleType: "DECISION" não só deixa de chamar o Decision Engine como é processada silenciosamente como se fosse DB_RULE.
| Limitação | Impacto | Situação |
|---|---|---|
ruleType: "DECISION" não é executado (e desde 10/09/2026 não é mais escolhido pelo cálculo) | O enum existe, decisionProjectId é exigido e gravado, e o campo decisionTraceId existe no tipo de resposta — mas o PricingService nunca chama o Decision Engine. Desde 10/09/2026 findActiveByProduct filtra ruleType: DB_RULE, então uma regra DECISION ativa não é mais escolhida nem 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_PRINCIPAL | O 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 principal | Conhecido, documentado no código |
| O seguro é calculado sobre o valor solicitado, não sobre o saldo devedor | monthlyPremium = requestedAmount × monthlyRate / 100, multiplicado pelo número de parcelas. Apólices de prestamista sobre saldo devedor decrescente ficam superestimadas — o erro cresce com o prazo | Conhecido, simplificação assumida |
| Não há validação de sobreposição de faixas | Duas faixas cobrindo o mesmo score convivem sem aviso. O desempate é priority, e a ORDER BY não é determinística entre faixas de mesma priority | Roadmap |
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/2020 | Por design — o CET é do Calculations Engine |
customerId e context são aceitos e ignorados | Os 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 existe | Especificado, não implementado |
productId não tem chave estrangeira | UUID inexistente não gera erro — gera "nenhuma faixa encontrada". Erro de digitação vira recusa silenciosa | Por design — o produto pode morar em três lugares diferentes |
| Versionamento da política de preço (desde 10/09/2026) | Toda criação e atualização de regra grava uma fotografia em pricing_rule_versions (version, snapshot, changedBy, reason), na mesma transação; GET /pricing-rules/:id/versions lista; o cálculo devolve pricingRule.version. As faixas de risco (RiskBand) continuam sem versão | Parcial — regra sim, faixa não |
| Sem simulação em lote | Uma chamada, uma proposta. Repricing de carteira exige um laço no chamador | Roadmap |
| Sem histórico ou trilha de cálculo | O cálculo não é persistido. Se você não guardar riskBand.id e pricingRule.id na sua proposta, a rastreabilidade se perde Desde 10/09/2026 o resultado traz pricingRule.version: guarde riskBand.id, pricingRule.id e pricingRule.version com a proposta e a fotografia em /versions reconstrói a tarifa | Por design — o building block é stateless no cálculo |
/health não sonda o banco | Devolve identificação e versão do build. Um Postgres fora do ar não faz o health falhar | Roadmap |
Falha de publicação de evento vira 500 após a gravação | A 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 exposta | Não há contador de recusas por motivo nem histograma de taxa aplicada | Roadmap |
| Sem Row-Level Security | O isolamento é garantido na camada de serviço. Acesso direto ao Postgres passa por cima dele | Por design neste módulo |
Perguntas 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 minInterestRate–maxInterestRate 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