Calculations Engine
BetaParcela, amortização, IOF e CET como API testada, não como planilha
A matemática do seu crédito para de existir em cinco versões — uma na planilha do produto, uma no app, uma no backoffice, uma no contrato e uma no relatório. Passa a existir em um endpoint, com teste automatizado, e todo mundo lê o mesmo número.
- Financeiras e fintechs de crédito que precisam do mesmo número na simulação, no contrato e no relatório
- Bancos digitais e SCDs que calculam amortização, IOF e CET em código espalhado por vários serviços
- Times de produto que hoje mantêm a matemática do crédito em planilha compartilhada
- Planilha de simulação de crédito compartilhada entre produto, comercial e TI
- Função de cálculo de parcela copiada e colada entre o app, o backoffice e o motor de contrato
- Módulo de cálculo embutido no core banking, quando só o cálculo é necessário
- Um motor de precificação — quem decide a taxa por risco é o Pricing Engine
- Um core banking — não escritura contrato, não movimenta saldo, não guarda nada
- Uma garantia de conformidade regulatória — ele aplica as alíquotas que você informa
- Uma biblioteca de finanças quantitativas — não faz curvas, derivativos nem marcação a mercado
12 endpoints em 4 recursos.
Resumo executivo
O Calculations Engine faz a matemática financeira do crédito: quanto é a parcela, como fica a tabela de amortização mês a mês, quanto de IOF incide, qual o Custo Efetivo Total e se vale a pena portar um contrato de uma instituição para outra. Ele recebe números, devolve números, e não guarda nada.
Na prática, ele resolve um problema que quase toda operação de crédito tem e quase nenhuma admite: a mesma conta existe em vários lugares e dá resultados diferentes. A parcela que o aplicativo mostra vem de um JavaScript; a que o backoffice mostra vem de uma planilha; a que entra no contrato vem de uma função no serviço de contratos. Quando os três divergem em oito centavos, quem descobre é o cliente. Aqui há um endpoint, com 168 testes unitários no repositório, e os três leem o mesmo número.
Está em beta desde novembro de 2025, com host publicado em staging. Os onze cálculos estão implementados e cobertos por testes que passam. O que não está resolvido: quatro dos oito sistemas de amortização devolvem remainingBalance: null na última parcela, e o campo gracePeriodMonths é aceito e silenciosamente ignorado. Leia a §15 antes de colocar isso na frente de um contrato assinado.
| Atributo | Valor |
|---|---|
| Identificador | calculations-engine |
| Categoria | Financeiro |
| Escopo | Global — exige token válido, não exige organizationId |
| Porta (standalone) | 3009 |
| Path alias | @calculations-engine |
| Prefixo HTTP | /calculations-engine |
| Schema PostgreSQL | Nenhum — o building block não tem banco de dados |
| Status | Beta desde 2025-11 |
| Depende de | IAM (só para verificar o token) |
O problema
negócioO cenário. Uma financeira precisa responder a mesma pergunta em cinco lugares diferentes: quanto o cliente paga por mês. O aplicativo responde na simulação, o site responde na landing, o correspondente responde no atendimento, o contrato materializa no papel e o relatório regulatório consolida no fim do mês. São cinco sistemas, cinco times e, quase sempre, cinco implementações da mesma fórmula.
flowchart LR P(["Quanto o cliente paga por mês?"]) P --> A["Aplicativo<br/>JavaScript próprio"] P --> B["Site<br/>outra cópia da fórmula"] P --> C["Backoffice<br/>planilha .xlsx"] P --> D["Serviço de contrato<br/>função copiada"] P --> E["Relatório regulatório<br/>consolidação mensal"] A --> R["R$ 974,79"] B --> R2["R$ 974,87"] C --> R3["R$ 974,92"] D --> R4["R$ 974,87"] E --> R5["R$ 975,00"] R --- X(["Divergência de centavos<br/>quem descobre é o cliente"]) R2 --- X R3 --- X R4 --- X R5 --- X
Os valores do diagrama são ilustrativos e servem só para mostrar a forma do problema: cinco implementações da mesma fórmula produzem cinco respostas próximas, e nenhuma delas é auditável contra as outras.
O que trava hoje.
- A conta vive em planilha. O time de produto mantém um
.xlsxcom a tabela Price, o IOF e o CET. A planilha é a fonte de verdade porque foi a primeira a existir, e ninguém consegue testá-la. A literatura de auditoria de planilhas de Raymond Panko documenta taxas de erro de célula altas e persistentes em planilhas corporativas operacionais (Panko, *What We Know About Spreadsheet Errors*). - A função é copiada, e a cópia envelhece. Alguém escreveu
calcularParcela()no serviço de originação, outro copiou para o app, um terceiro para o backoffice. Quando a regra de arredondamento muda em um, os outros dois continuam iguais — e a divergência só aparece na reclamação do cliente. - IOF é fácil de errar. A alíquota diária incide sobre cada parcela de principal, pelo prazo de cada uma, com teto. Quem implementa "IOF = valor × alíquota × prazo total" erra, e erra para mais.
- CET não é uma fórmula fechada. É a taxa que iguala o valor líquido liberado ao fluxo de pagamentos — ou seja, uma raiz que precisa ser encontrada numericamente. Implementar isso na planilha significa usar a função
TAXAdo Excel e torcer. - Não dá para auditar o que não tem teste. Quando o Procon pergunta como a parcela foi calculada, "está na planilha" não é resposta. E "está no código, mas em quatro lugares" é pior.
O custo de não resolver. O custo direto aparece na divergência: cada centavo de diferença entre a simulação e o contrato é matéria-prima de reclamação, e reclamação de crédito escala para revisional. O custo regulatório é maior. O Custo Efetivo Total é obrigatório desde a Resolução CMN 4.881/2020 — em vigor desde 1º de fevereiro de 2021, substituindo a revogada Resolução 3.517/2007 —, 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). Cálculo financeiro que não se reproduz não é um bug de engenharia — é passivo.
Proposta de valor
negócio| Antes | Depois |
|---|---|
| A parcela é calculada em cinco lugares, com cinco resultados | Um endpoint, um resultado, cinco consumidores |
| A fórmula mora numa planilha que ninguém testa | 168 testes unitários no repositório, executáveis em 12 segundos |
| Mudar o sistema de amortização exige código novo | Trocar o campo method de PRICE para SAC |
| IOF é implementado errado por aproximação | Incidência parcela a parcela, com teto de 365 dias, e a versão financiada calculada junto |
CET é a função TAXA do Excel | Endpoint próprio, com a taxa mensal e a anual, em alta precisão e arredondada |
| Comparar portabilidade é montar duas planilhas lado a lado | Uma chamada devolve os dois cronogramas, os dois CETs e o ponto de equilíbrio |
Oito sistemas de amortização, um endpoint. PRICE, SAC, BALLOON, BULLET, INTEREST_ONLY, STEP_UP, STEP_DOWN e CUSTOM. Trocar de sistema é trocar um campo — não é integrar outro fornecedor nem escrever outra função.
O IOF é calculado como o IOF funciona. A alíquota diária incide sobre cada parcela de principal, pelo número de dias entre a data do contrato e a data daquela parcela, com teto de 365 dias por parcela. A alíquota adicional incide uma vez sobre o principal. E a resposta traz também a versão financiada — o valor bruto do IOF quando ele é embutido no próprio empréstimo.
O CET é apurado numericamente, não estimado. O endpoint resolve a taxa que iguala o valor escolhido ao fluxo de pagamentos, e devolve a mensal e a anual em duas precisões: alta, para encadear em outros cálculos, e arredondada a quatro casas, para exibir.
flowchart LR A["Valor solicitado<br/>R$ 10.000"] --> B["− IOF<br/>R$ 208,33"] B --> C["− tarifas descontadas<br/>R$ 50,00"] C --> D["Valor líquido liberado<br/>R$ 9.741,67"] D --> E["CET 2,93% a.m."] A -.->|"taxa contratada"| F["2,50% a.m."] F -.-> E
A distância entre 2,50% e 2,93% ao mês é exatamente o efeito de o cliente receber menos do que assinou. Números conferidos executando os calculadores (§9 e §10).
Nada é persistido, e isso é uma vantagem operacional. O building block não tem banco, não tem migração, não tem schema. Ele não guarda score, não guarda CPF, não guarda proposta. Subir uma instância nova é subir um processo.
Portabilidade sai pronta. Um endpoint recebe o contrato atual e o proposto, e devolve os dois cronogramas, os dois CETs, a diferença de parcela, a economia líquida, o ponto de equilíbrio em meses e um veredito booleano.
Casos de uso reais
negócioCaso 1 — A parcela do app passa a bater com a do contrato Cenário ilustrativo
Fintech de crédito pessoal com simulador no aplicativo, atendimento humano no backoffice e geração de contrato num terceiro serviço.
Cada um dos três calculava a parcela por conta própria. O app arredondava a cada período; o backoffice arredondava só no fim; o gerador de contrato usava a taxa anual convertida para mensal com uma fórmula levemente diferente. A divergência ficava entre cinco e vinte centavos na parcela — pouco para o balanço, o suficiente para o cliente fotografar a tela do app ao lado do contrato e abrir reclamação.
Os três passam a chamar POST /calculations-engine/api/v1/calculations/loan-payment-calculator/calculations com os mesmos três parâmetros: taxa mensal em decimal, número de parcelas e valor presente. A resposta é um número só, arredondado a duas casas no serviço.
Divergência estrutural eliminada — não porque os times combinaram, mas porque não existe mais uma segunda implementação para divergir. E a regra de arredondamento passa a ser uma decisão versionada num repositório, com teste.
flowchart LR
subgraph antes["Antes — três implementações"]
A1["App<br/>arredonda por período"]
A2["Backoffice<br/>arredonda no fim"]
A3["Gerador de contrato<br/>converte taxa anual"]
end
subgraph depois["Depois — um endpoint"]
D1["App"] --> E
D2["Backoffice"] --> E
D3["Gerador de contrato"] --> E
E["POST .../loan-payment-calculator/calculations"]
E --> N["payment: 974.87"]
end
antes -->|"divergência de 5 a 20 centavos"| depoisCaso 2 — O CET deixa de ser calculado na planilha antes de ir para o contrato Cenário ilustrativo
Financeira que precisa informar o Custo Efetivo Total antes da contratação, conforme exige a Resolução CMN 4.881/2020.
O CET era calculado por um analista, numa planilha, com a função TAXA. Quando a operação passou a ter TAC financiada e IOF embutido, o valor líquido liberado deixou de ser óbvio e a planilha começou a divergir do que o auditor esperava. Ninguém sabia dizer se o erro estava na fórmula, na entrada, ou na interpretação de qual valor era o "liberado".
A esteira encadeia três chamadas: monta o cronograma com loan-amortization-schedule-calculator, apura o IOF com loan-iof-calculator sobre esse cronograma, e chama loan-cet-rate-calculator passando como chosenAmount o valor efetivamente liberado — o solicitado menos o IOF e menos as tarifas não financiadas. O CET sai mensal e anual, nas duas precisões.
O número que vai para o contrato tem procedência: três chamadas de API, com entradas e saídas registráveis. A §11 traz a sequência completa com números conferidos.
flowchart LR I["Valor solicitado, prazo e taxa"] --> S["1 · loan-amortization-schedule-calculator<br/>cronograma e parcela"] S --> O["2 · loan-iof-calculator<br/>IOF sobre o cronograma"] O --> L["3 · valor líquido liberado<br/>solicitado − IOF − tarifas não financiadas"] L --> C["4 · loan-cet-rate-calculator<br/>chosenAmount = líquido liberado"] C --> R["CET mensal e anual<br/>highPrecision e rounded"] R --> K["Contrato informa o CET<br/>antes da contratação"]
Caso 3 — Uma proposta de portabilidade vira uma chamada Cenário ilustrativo
Financeira que capta clientes oferecendo portabilidade de crédito consignado — modalidade prevista na regulação de portabilidade do CMN e amplamente usada no mercado brasileiro.
Para cada proposta, um analista montava duas abas de planilha: o contrato atual, com saldo devedor e parcelas restantes, e o proposto. Comparava totais à mão, estimava o custo da operação e decidia se valia a pena. Levava de dez a vinte minutos por proposta, não escalava, e a resposta ao cliente era "vale a pena" sem número que sustentasse.
POST .../credit-portability-calculator/calculations recebe o contrato atual — saldo devedor, parcelas restantes, taxa mensal, parcela atual — e o proposto — taxa, prazo, sistema —, mais os custos da portabilidade. Devolve os dois cronogramas completos, os dois CETs anuais, a diferença de parcela, a economia líquida, o ponto de equilíbrio em meses e isPortabilityAdvantage.
A análise vira uma chamada. E a proposta ao cliente passa a mostrar o número: "sua parcela cai R$ 108,06, você economiza R$ 2.191,80 no total e recupera o custo da operação em 2 meses" — todos conferidos rodando o calculador (§11).
flowchart LR A["Contrato atual<br/>saldo, parcelas restantes,<br/>taxa, parcela atual"] --> P["POST .../credit-portability-calculator/calculations"] B["Contrato proposto<br/>taxa, prazo, sistema"] --> P C["Custos da portabilidade"] --> P P --> D["Dois cronogramas completos"] P --> E["Dois CETs anuais"] P --> F["Diferença de parcela<br/>108,06"] P --> G["Economia líquida<br/>2.191,80"] P --> H["Ponto de equilíbrio<br/>2 meses"] P --> I["isPortabilityAdvantage"]
Caso 4 — Cálculo financeiro como serviço não é um mercado, e isso é o insight Referência de mercado
Procure por um fornecedor que venda "cálculo financeiro auditável como API" e você não encontra uma categoria. Encontra dois grupos. De um lado, bibliotecas: numpy-financial implementa as funções clássicas — pmt, rate, irr, npv, nper — sob licença BSD (numpy-financial); QuantLib cobre o instrumental quantitativo de mesa de operações sob licença BSD modificada (QuantLib). De outro, core bankings que embutem o cálculo no ciclo de vida do empréstimo: Mambu, Temenos, Thought Machine.
Biblioteca não resolve o problema de divergência, porque cada aplicação carrega a própria cópia e as cópias divergem em versão e em uso. E nenhuma biblioteca estrangeira tem IOF diário brasileiro, CET nos termos da Resolução CMN 4.881/2020 ou comparação de portabilidade — isso o time acaba escrevendo, uma vez por sistema. Core banking resolve, mas cobra a adoção da plataforma inteira: para ter a matemática você troca o ledger, a conta e a escrituração.
Este building block é o meio-termo que o mercado não empacotou: as funções clássicas (via @formulajs/formulajs, que replica a semântica do Excel) mais os cálculos brasileiros, expostos como serviço HTTP, com teste automatizado e sem estado. Você chama de qualquer linguagem, e não há segunda cópia para divergir.
A ausência de concorrente direto é o argumento e o alerta ao mesmo tempo: não há benchmark público contra o qual comparar, e a validação dos números é responsabilidade compartilhada. A §11 traz exemplos numéricos completos, conferidos executando o código, justamente para que você possa reproduzir e conferir contra a sua planilha antes de confiar.
flowchart LR Q(["Onde comprar a matemática do crédito?"]) Q --> L["Bibliotecas<br/>numpy-financial · QuantLib"] Q --> M["Meio-termo<br/>Catalisa Calculations Engine"] Q --> N["Core bankings<br/>Mambu · Temenos · Thought Machine"] L --> L1["Funções clássicas prontas"] L --> L2["Uma cópia por aplicação<br/>as cópias divergem"] L --> L3["Sem IOF, CET nem portabilidade"] M --> M1["Serviço HTTP, uma versão só"] M --> M2["Cálculos brasileiros inclusos"] M --> M3["Sem ledger e sem escrituração"] N --> N1["Cálculo integrado ao ledger"] N --> N2["Para ter a matemática<br/>você adota a plataforma inteira"]
Mercado e diferenciais
negócioPanorama. Cálculo financeiro nunca virou uma categoria de produto, e há um motivo: as fórmulas são públicas e antigas. PMT está no Excel desde os anos 1980, a tabela Price é do século XVIII. Ninguém vende fórmula. O que se vende é o contexto em torno dela — o core banking que a executa dentro do ciclo de vida do contrato, ou a biblioteca que a entrega para o time implementar o contexto.
O resultado prático é que quase toda financeira brasileira acaba escrevendo a mesma coisa: uma camada com Price, SAC, IOF e CET, feita sob medida, sem teste, e depois copiada. Empacotar essa camada como serviço testado é uma escolha de engenharia mais do que uma inovação de mercado — e é exatamente por isso que ela funciona: o valor não está na fórmula, está em ela existir uma vez.
| Critério | Catalisa Calculations | numpy-financial | QuantLib | Mambu | Temenos |
|---|---|---|---|---|---|
| Forma de consumo | Serviço HTTP | Biblioteca Python | Biblioteca C++/Python | Plataforma SaaS | Plataforma licenciada |
| Funções clássicas (PMT, RATE) | Sim | Sim, é o escopo | Sim, entre muitas | Interno | Interno |
| Sistemas de amortização | 8 | Você implementa | Você compõe | Configuráveis no produto | Configuráveis no produto |
| IOF brasileiro | Sim, diário com teto | Não existe | Não existe | Via configuração local | Via configuração local |
| CET | Sim, endpoint próprio | Você implementa | Você implementa | Depende da implantação | Depende da implantação |
| Portabilidade de crédito | Sim, endpoint próprio | Não | Não | Não pronto | Não pronto |
| Curvas, calendários, day count | Não | Não | Sim, é o escopo | Parcial | Sim |
| Precisão numérica | Ponto flutuante IEEE 754 | Ponto flutuante | Alta, configurável | Decimal no ledger | Decimal no ledger |
| Ledger e escrituração | Não tem | Não tem | Não tem | Sim | Sim |
| Preço | Precificação em definição | Grátis (BSD) | Grátis (BSD modificada) | Não publicado | Não publicado |
| Custo real | Integração de API | Você constrói o contexto | Você constrói o contexto | Projeto de core | Projeto de banco |
Mambu e Temenos não publicam tabela de preço. Consulta feita em 2026-08.
numpy-financiale QuantLib são open source e não têm custo de licença — o custo deles é o contexto que você constrói em volta.
Nossos diferenciais
- Os cálculos brasileiros vêm prontos. IOF diário com incidência parcela a parcela e teto de 365 dias, CET, coeficiente com carência e comissão, e comparação de portabilidade. Nenhuma biblioteca estrangeira tem isso, e cada time que precisa escreve de novo — errando o IOF com frequência, porque a aproximação intuitiva ("valor × alíquota × prazo") superestima.
- É serviço, não biblioteca — e essa distinção resolve o problema real. O problema não é não saber calcular. É ter cinco cópias do cálculo. Biblioteca não resolve isso; endpoint resolve, porque não existe uma segunda instalação para envelhecer em versão diferente.
- Oito sistemas de amortização atrás de um campo. Balão, bullet, carência com pós-carência configurável, escalonamento crescente e decrescente e cronograma totalmente customizado. Trocar é trocar
method. - Stateless de verdade. Sem banco, sem migração, sem
organizationId, sem dado pessoal em repouso. Escalar é subir processo, e a superfície de risco de vazamento entre clientes é estruturalmente zero.
Quando escolher o concorrente. Se você trabalha em Python e precisa apenas de pmt e rate dentro de um script analítico, numpy-financial é grátis, é a implementação de referência e não faz sentido chamar uma API por rede para isso. Se o seu problema é quantitativo de verdade — curva de juros, convenção de contagem de dias, calendário de feriados por praça, marcação a mercado, derivativos —, o QuantLib é o padrão da indústria e nós não estamos nem perto: este building block não tem curva, não tem calendário de feriados e usa incremento simples de mês. Se você vai trocar o core banking, o cálculo vem junto com Mambu ou Temenos, integrado ao ledger, com aritmética decimal na escrituração — e ter o cálculo fora do ledger passa a ser uma peça a mais para conciliar. E se a sua operação exige precisão decimal auditável centavo a centavo em ledger, saiba que aqui a aritmética é ponto flutuante IEEE 754 com arredondamento explícito, o que é adequado para simulação e proposta, mas não substitui a contabilidade. Este bloco ganha quando o problema é ter uma única versão confiável da matemática do crédito brasileiro, disponível para vários sistemas.
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. Dois drivers em consideração:
| Driver | Por que é justo |
|---|---|
| Chamadas de cálculo | Escala com o volume de simulações e propostas, que é o valor entregue |
| Complexidade do cálculo | Uma parcela avulsa custa microssegundos; um cronograma de 360 parcelas com IOF e CET custa mais. Cobrar igual pelos dois penalizaria o uso simples |
Comparação de custo. A comparação honesta aqui é diferente da dos outros building blocks, porque duas das quatro alternativas são gratuitas:
| Catalisa Calculations | numpy-financial / QuantLib | Core banking (Mambu, Temenos) | |
|---|---|---|---|
| Custo de licença | Precificação em definição | R$ 0 (BSD) | Contrato negociado, não publicado |
| Custo de integração | Chamada HTTP | Você escreve IOF, CET, portabilidade e os oito sistemas | Projeto de implantação |
| Custo de manutenção | Nosso | Seu, em cada aplicação que tiver uma cópia | Do fornecedor |
| Onde a divergência aparece | Não aparece — há uma instância | Em cada cópia que envelhece | Não aparece — há um core |
Estimativa de forma, não de valor, consultada em 2026-08. Nenhum número de fornecedor foi extrapolado porque Mambu e Temenos não publicam preço.
ROI. A conta de guardanapo tem duas linhas, e a primeira é a que costuma surpreender.
A primeira linha é o custo de escrever do zero. Os onze cálculos deste building block somam cerca de 1.100 linhas de calculadores mais 560 de roteamento, e 168 testes unitários. Escrever isso com a mesma cobertura — incluindo entender que o IOF diário incide parcela a parcela com teto, e que o CET precisa de busca numérica — é trabalho de semanas de um desenvolvedor que entenda de matemática financeira, não de dias. E é trabalho que se repete a cada sistema que precisar do cálculo.
A segunda linha é a que ninguém orça: o custo da divergência. Uma diferença de centavos entre a simulação e o contrato não aparece no balanço, mas aparece na reclamação, no atendimento que a trata e, no limite, na revisional. O valor de ter uma implementação só não é o tempo economizado — é a categoria inteira de incidente que deixa de existir.
Arquitetura
As camadas, do HTTP até a função pura
flowchart TD
H(["HTTP"]) --> APP
subgraph APP["Hono app · basePath('/calculations-engine')"]
MW["applyCommonMiddleware<br/>body limit 1MB · CORS · security headers · rate limit"]
EH["errorHandler"]
R1["/api/v1/calculations<br/>calculationsRouter · 11 rotas, todas POST"]
R2["/health<br/>identificação e versão do build"]
MW --> EH --> R1
MW --> R2
end
R1 --> GUARD["authMiddleware → requirePermission(CALCULATIONS_EXECUTE)<br/>SEM requireOrganization — o BB é global<br/>Zod parse → ResultAsync<T, AppError>"]
GUARD --> SVC
subgraph SVC["services/CalculationsService"]
S1["Valida invariantes de negócio<br/>positivo, não negativo, método válido"]
S2["Delega ao calculador<br/>sem estado, sem I/O, sem repositório"]
S1 --> S2
end
SVC --> CALC
subgraph CALC["calculators/ — funções puras, uma por arquivo"]
C1["loan-payment · PMT"]
C2["loan-interest-rate · RATE"]
C3["loan-cet-rate · RATE sobre o valor líquido"]
C4["loan-coefficient · coeficiente com carência e comissão"]
C5["loan-costs · soma de custos"]
C6["loan-payment-dates · datas com regra de fim de mês"]
C7["loan-iof · IOF diário + adicional, e a versão financiada"]
C8["loan-amortization-schedule · despacha para os 8 métodos"]
C9["loan-amortization-tir-overpayment"]
C10["loan-amortization-tir-principal-sum"]
C11["credit-portability · compõe schedule + costs + IOF + CET"]
end
C8 --> M1["PRICE / SAC<br/>no próprio arquivo"]
C8 --> M2["loan-amortization-balloon · BALLOON"]
C8 --> M3["loan-amortization-bullet · BULLET"]
C8 --> M4["loan-amortization-interest-only · INTEREST_ONLY"]
C8 --> M5["loan-amortization-step · STEP_UP / STEP_DOWN"]
C8 --> M6["loan-amortization-custom · CUSTOM"]
CALC --> LIB["@formulajs/formulajs<br/>PMT, RATE, ROUND, ROUNDUP, MIN"]
CALC --> LUX["luxon<br/>aritmética de datas em UTC"]Atenção. Sem banco. Sem Redis. Sem fila. Sem chamada a outro building block. O que a requisição traz é tudo o que o cálculo conhece.
O caminho de uma chamada
sequenceDiagram
autonumber
participant Cli as Aplicação do cliente
participant Hono as Hono · calculationsRouter
participant Svc as CalculationsService
participant Calc as calculators/ (funções puras)
participant Lib as formulajs + luxon
Cli->>Hono: POST /calculations-engine/api/v1/calculations/...
Hono->>Hono: authMiddleware verifica o JWT localmente
Hono->>Hono: requirePermission(CALCULATIONS_EXECUTE)
Note over Hono: Sem requireOrganization — o BB é global
Hono->>Hono: Zod valida a FORMA do corpo
Hono->>Svc: atributos já tipados
Svc->>Svc: valida INVARIANTE de negócio
alt invariante violada
Svc-->>Hono: Error com "must" na mensagem
Hono-->>Cli: 400 VALIDATION
else entrada válida
Svc->>Calc: chama a função pura
Calc->>Lib: PMT, RATE, ROUND, datas em UTC
Lib-->>Calc: número
Calc-->>Svc: resultado
Svc-->>Hono: ResultAsync ok
Hono-->>Cli: 200 com envelope JSON:API
end
Note over Cli,Lib: Nada é gravado. Guardar a requisição e a resposta é do chamador.Decisões não óbvias. Cada uma delas tem consequência direta em número que vai para contrato, então valem uma leitura antes da primeira integração.
@formulajs/formulajs em vez de fórmula escrita à mão
A biblioteca replica a semântica das funções financeiras do Excel, e isso é uma escolha deliberada: o time de produto de uma financeira valida o resultado contra a planilha dele. Usar a mesma semântica do Excel faz a conferência bater. O custo dessa escolha aparece nos detalhes: RATE é Newton-Raphson com chute inicial de 0,1, tolerância de 1e-10 e no máximo 100 iterações — e, se não convergir, devolve o último valor sem sinalizar. Não há bandeira de convergência na resposta. Para as faixas de taxa usuais de crédito ao consumidor a convergência é rápida e estável, mas entradas patológicas podem devolver um número silenciosamente errado.
flowchart LR
E["Chute inicial 0,1"] --> I["Iteração Newton-Raphson"]
I --> T{"Diferença < 1e-10?"}
T -->|sim| OK["Devolve a taxa encontrada"]
T -->|não| N{"Chegou a 100 iterações?"}
N -->|não| I
N -->|sim| SIL["Devolve o último valor<br/>SEM sinalizar falta de convergência"]Precisão de 5 casas nas linhas, 2 no total
Cada linha do cronograma arredonda saldo, juros e principal a 5 casas decimais (PRECISION = 5), e o saldo remanescente a 4. Os totais (totalAmount, totalInterest) arredondam a 2. A escolha existe para que a soma dos juros linha a linha não acumule erro visível no total. O efeito colateral é que o saldo final pode ficar com resíduo — no cenário verificado da §11, −0,0001 na 36ª parcela — e, em quatro dos oito métodos, esse resíduo vira NaN e chega como null no JSON (§15).
| Onde | Casas decimais |
|---|---|
| Saldo, juros e principal de cada linha | 5 (PRECISION = 5) |
remainingBalance | 4 |
totalAmount e totalInterest | 2 |
A geração de datas é feita em dois lugares, com regras diferentes
O endpoint loan-payment-dates-calculator usa luxon e trata fim de mês corretamente: contrato e primeira parcela no último dia do mês fazem todas as parcelas caírem no último dia; dia 31 num mês de 30 cai para o dia 30. Já o endpoint de cronograma de amortização gera as datas com date.setMonth(date.getMonth() + i) do JavaScript, que transborda: 31 de janeiro mais um mês vira 3 de março. Os dois comportamentos convivem hoje. Isso está na §15 e importa muito para o IOF, que depende do número de dias.
flowchart TD D["Primeira parcela em 31/01"] --> A["loan-payment-dates-calculator<br/>luxon, regra de fim de mês"] D --> B["loan-amortization-schedule-calculator<br/>date.setMonth(date.getMonth() + i)"] A --> A1["31/01 · 28/02 · 31/03 · 30/04"] B --> B1["31/01 · 03/03 · 31/03 · 01/05<br/>fevereiro é pulado"] A1 --> IOF["O IOF conta dias corridos —<br/>as duas regras dão IOF diferente"] B1 --> IOF
Não persiste, não exige organização
É o único building block financeiro da plataforma com escopo: global. Nenhuma rota chama requireOrganization, porque não há dado do tenant para isolar — o serviço recebe números e devolve números. A consequência: qualquer token válido da plataforma que tenha CALCULATIONS_EXECUTE pode calcular. Não há como um cliente ver o cálculo de outro, porque nenhum cálculo é guardado.
Validação em duas camadas, com propósitos diferentes
O Zod no router valida forma (tipo, obrigatoriedade, faixa sintática). O CalculationsService valida invariante de negócio (valor presente positivo, taxa não negativa, método PRICE ou SAC na portabilidade) lançando Error com mensagem contendo must, que a camada de Result converte em VALIDATION (400). Erro sem must na mensagem vira INTERNAL (500). É uma convenção frágil, mas é a que está no código.
flowchart LR B["Corpo da requisição"] --> Z["Zod no router<br/>valida a FORMA"] Z -->|reprovado| E400["400 VALIDATION"] Z -->|aprovado| S["CalculationsService<br/>valida o INVARIANTE"] S -->|"Error com 'must'"| E400 S -->|"Error sem 'must'"| E500["500 INTERNAL"] S -->|ok| C["calculators/"]
Portabilidade compõe os outros calculadores em vez de reimplementar
calculateCreditPortability chama calculateLoanAmortizationSchedule, calculateLoanCosts, calculateLoanIOF e calculateLoanCETRate. Consequência: qualquer característica dos calculadores individuais — inclusive as limitações — aparece na portabilidade.
flowchart LR P["credit-portability"] --> A["loan-amortization-schedule"] P --> B["loan-costs"] P --> C["loan-iof"] P --> D["loan-cet-rate"] A -.->|"herda também as limitações"| P
Monolito vs. standalone. Em monolito, src/app.ts monta o app e registerCalculationsEngine registra um único serviço sem dependência. Em standalone, main.ts sobe o Bun na porta configurada (3009 por convenção do projeto) e o processo precisa apenas do JWT_SECRET — nem PostgreSQL. É o building block mais leve da plataforma para operar: sem banco, sem migração, sem estado, e escalável horizontalmente sem coordenação.
Conceitos e modelo de dados
Glossário
| Termo | Significa |
|---|---|
interestRate | Taxa mensal em decimal. 0.025 é 2,5% ao mês. Nunca em percentual, nunca anual. |
presentValue / principal | Valor financiado, na data zero. |
payment | Valor da parcela. Positivo. |
Sistema de amortização (method) | Como o principal se distribui ao longo das parcelas. Oito valores possíveis. |
| PRICE | Parcela constante. O clássico "tabela Price". Juros altos no começo, principal alto no fim. |
| SAC | Principal constante, parcela decrescente. Sistema de Amortização Constante. |
| BALLOON | Parcelas regulares menores, com um percentual do principal concentrado na última. |
| BULLET | Só juros durante o prazo, o principal inteiro na última parcela. |
| INTEREST_ONLY | Carência: N parcelas só de juros, depois amortiza por PRICE ou SAC. |
| STEP_UP / STEP_DOWN | Parcela que cresce (ou decresce) um percentual fixo a cada intervalo de parcelas. |
| CUSTOM | Você fornece o vetor de parcelas; o cálculo deriva juros e principal a partir dele. |
| IOF adicional | Alíquota que incide uma vez sobre o principal. |
| IOF diário | Alíquota que incide sobre cada parcela de principal, multiplicada pelo número de dias entre a data do contrato e a data daquela parcela, com teto de 365 dias por parcela. |
| IOF financiado | O IOF bruto necessário quando ele é embutido no próprio empréstimo — um gross-up. |
| CET | Custo Efetivo Total. A taxa que iguala o valor líquido liberado ao fluxo de parcelas. |
chosenAmount | A entrada do CET: o valor efetivamente liberado ao cliente. Solicitado menos IOF menos tarifas não financiadas. |
| Coeficiente | Fator que, multiplicado pelo valor financiado, dá a parcela. Usado em tabela de correspondente. |
| TIR overpayment / principal sum | Dois métodos de decompor uma parcela conhecida em principal e juros usando dias corridos, não meses inteiros. |
highPrecision / rounded | Toda taxa retornada vem nas duas formas: alta precisão para encadear em outro cálculo, arredondada a 4 casas para exibir. |
Modelo de dados. Não há. Este building block não tem tabela Prisma, não tem schema PostgreSQL e não persiste nada. metricas.entidades: 0 no frontmatter é literal. Todo estado da requisição vive na memória do processo pela duração da chamada.
Isso não é uma lacuna, é o desenho: o cálculo é uma função pura. Guardar o resultado é responsabilidade de quem chama — e é o que permite ao chamador decidir se o resultado vai para o contrato, para o log ou para lugar nenhum.
Os oito sistemas de amortização, lado a lado
A escolha do sistema não muda a matemática do juro — muda quando o principal é devolvido, e é isso que decide o perfil da parcela e o total de juros.
flowchart TD
Q(["Como o cliente quer pagar?"])
Q --> A{"Amortiza desde a<br/>primeira parcela?"}
A -->|não, só juros o tempo todo| BUL["BULLET<br/>principal integral na última"]
A -->|"não, nas N primeiras"| IO["INTEREST_ONLY<br/>carência de juros, depois PRICE ou SAC"]
A -->|sim| B{"Parcela constante?"}
B -->|sim| PR["PRICE<br/>parcela fixa, juros altos no começo"]
B -->|"não, decrescente"| SA["SAC<br/>principal constante, parcela cai"]
B -->|"não, com degrau"| ST{"O degrau sobe ou desce?"}
ST -->|sobe| SU["STEP_UP<br/>parcela cresce a cada intervalo"]
ST -->|desce| SD["STEP_DOWN<br/>parcela decresce a cada intervalo"]
A -->|"sim, com uma parcela final grande"| BA["BALLOON<br/>percentual do principal na última"]
A -->|"sim, num vetor que eu forneço"| CU["CUSTOM<br/>paymentSchedule informado"]| Sistema | Perfil da parcela | Onde o principal se concentra | Configuração exigida |
|---|---|---|---|
PRICE | Constante | Distribuído, crescente ao longo do prazo | — |
SAC | Decrescente | Constante em todas as parcelas | — |
BALLOON | Menor até a última | Um percentual concentrado na última | balloonConfig |
BULLET | Só juros até a última | Integralmente na última | — |
INTEREST_ONLY | Só juros na carência, depois PRICE ou SAC | Após a carência | interestOnlyConfig |
STEP_UP | Cresce a cada intervalo | Mais no fim | stepConfig |
STEP_DOWN | Decresce a cada intervalo | Mais no começo | stepConfig |
CUSTOM | O que você informar | Derivado do vetor de parcelas | customConfig.paymentSchedule |
Os números que materializam esta tabela — primeira parcela, total pago e total de juros de cada sistema no mesmo cenário — estão na §11, conferidos executando os calculadores.
As fórmulas, exatamente como estão no código
Cada bloco abaixo é a fórmula como o calculador correspondente a implementa, com o arredondamento no ponto exato em que ele acontece. Leia junto com a tabela de unidades que fecha esta seção — quase todo resultado absurdo vem de unidade, não de fórmula.
Parcela — loan-payment
Taxa a partir da parcela, e o CET — loan-interest-rate e loan-cet-rate
Os dois endpoints resolvem a mesma equação; o que muda é qual valor entra como PV. No cálculo de taxa, é o valor financiado. No CET, é o líquido liberado — e é aí que mora o erro mais comum da §16.
TAXA A PARTIR DA PARCELA (loan-interest-rate · via RATE)
CET (loan-cet-rate · via RATE)
resolve i em: PV = PMT · [1 − (1 + i)^(−n)] / i
método: Newton-Raphson, chute 0.1, tolerância 1e-10, máx. 100 iterações
NÃO é bissecção. NÃO sinaliza falta de convergência.
taxa anual = (1 + i_mensal)^12 − 1
No CET, PV é o `chosenAmount` — o valor LÍQUIDO liberado. TAXA A PARTIR DA PARCELA (loan-interest-rate · via RATE)
CET (loan-cet-rate · via RATE)
resolve i em: PV = PMT · [1 − (1 + i)^(−n)] / i
método: Newton-Raphson, chute 0.1, tolerância 1e-10, máx. 100 iterações
NÃO é bissecção. NÃO sinaliza falta de convergência.
taxa anual = (1 + i_mensal)^12 − 1
No CET, PV é o `chosenAmount` — o valor LÍQUIDO liberado.Cronograma — PRICE
A parcela é constante; o que muda a cada mês é a proporção entre juros e principal.
CRONOGRAMA — PRICE
pagamento = PMT(i, n, −P) constante
para k = 1..n:
saldoInicial_k = arred(saldo, 5)
juros_k = arred(saldoInicial_k · i, 5)
principal_k = arred(pagamento − juros_k, 5)
saldo −= principal_k CRONOGRAMA — PRICE
pagamento = PMT(i, n, −P) constante
para k = 1..n:
saldoInicial_k = arred(saldo, 5)
juros_k = arred(saldoInicial_k · i, 5)
principal_k = arred(pagamento − juros_k, 5)
saldo −= principal_kCronograma — SAC
O principal é constante e os juros caem com o saldo, então a parcela decresce.
CRONOGRAMA — SAC
principalConstante = arred(P / n, 5)
para k = 1..n:
juros_k = arred(saldoInicial_k · i, 5)
principal_k = principalConstante (na última: saldoInicial_k)
pagamento_k = arred(principal_k + juros_k, 5) CRONOGRAMA — SAC
principalConstante = arred(P / n, 5)
para k = 1..n:
juros_k = arred(saldoInicial_k · i, 5)
principal_k = principalConstante (na última: saldoInicial_k)
pagamento_k = arred(principal_k + juros_k, 5)Cronograma — BULLET
O saldo nunca cai durante o prazo, e é por isso que este é o sistema mais caro em juros.
CRONOGRAMA — BULLET
juros_k = arred(P · i, 5) em todas as parcelas
principal_k = 0 exceto na última, onde principal = P CRONOGRAMA — BULLET
juros_k = arred(P · i, 5) em todas as parcelas
principal_k = 0 exceto na última, onde principal = PCronograma — BALLOON
O balão é separado do principal antes de calcular a parcela regular, e a última parcela o absorve inteiro.
CRONOGRAMA — BALLOON (percentual b, entre 0.01 e 0.99)
balão = arred(P · b, 5)
amortizável = P − balão
pagamentoReg = PMT(i, n, −amortizável)
para k = 1..n−1:
juros_k = arred(saldoInicial_k · i, 5)
principal_k = máx(0, mín(arred(pagamentoReg − juros_k, 5),
saldoInicial_k − balão))
na última: principal = saldoInicial (absorve o balão) CRONOGRAMA — BALLOON (percentual b, entre 0.01 e 0.99)
balão = arred(P · b, 5)
amortizável = P − balão
pagamentoReg = PMT(i, n, −amortizável)
para k = 1..n−1:
juros_k = arred(saldoInicial_k · i, 5)
principal_k = máx(0, mín(arred(pagamentoReg − juros_k, 5),
saldoInicial_k − balão))
na última: principal = saldoInicial (absorve o balão)Cronograma — INTEREST_ONLY
Durante a carência o saldo não se move; depois dela, o cronograma vira PRICE ou SAC sobre o que restou.
Cronograma — STEP_UP e STEP_DOWN
A parcela inicial é a que faz o valor presente do fluxo escalonado fechar com o principal. O piso protege contra parcela menor que os juros do período.
Cronograma — CUSTOM
Você entrega o vetor de parcelas e o calculador deriva juros e principal a partir dele — inclusive quando a parcela não cobre os juros do período.
IOF — loan-iof
O IOF diário não incide sobre o valor financiado inteiro pelo prazo inteiro: ele incide sobre cada parcela de principal, pelos dias daquela parcela, com teto de 365 dias. É a diferença entre o cálculo correto e a aproximação que superestima.
flowchart TD
S["Cronograma informado"] --> P["P = saldo inicial da 1ª parcela"]
P --> AD["IOF adicional = P × alíquota adicional<br/>incide UMA vez"]
S --> LOOP["Para cada parcela k"]
LOOP --> DIAS["dias_k = dias corridos entre<br/>a data do contrato e a parcela k"]
DIAS --> TETO{"dias_k > 365?"}
TETO -->|sim| C365["usa 365"]
TETO -->|não| CK["usa dias_k"]
C365 --> INC["principal_k × dias × alíquota diária"]
CK --> INC
INC --> SOMA["IOF diário = soma de todas as parcelas"]
AD --> TOT["IOF total = diário + adicional"]
SOMA --> TOT
TOT --> FIN["Versão financiada (gross-up)<br/>iofTotal × P ÷ (P − iofTotal)"]Coeficiente — loan-coefficient
É o fator que a tabela do correspondente usa: multiplicado pelo valor financiado, dá a parcela. A carência padrão de 30 dias é o caso neutro — o expoente zera e não altera nada.
TIR — overpayment
Aqui a parcela já é conhecida e o que se quer é decompô-la em principal e juros usando dias corridos, não meses inteiros. Serve para conferir contrato de terceiro e para achar saldo devedor em data que não é aniversário de parcela.
TIR — principal sum
A variante do anterior: em vez de descontar cada parcela desde a data-base, acumula juros sobre o saldo entre parcelas consecutivas.
TIR — PRINCIPAL SUM (variante: juros sobre saldo, dias entre parcelas)
principal inicial = Σ principal_k do método OVERPAYMENT
para k = 1..n:
dias_k = dias entre a parcela k−1 (ou baseDate, se k=1) e a k
juros_k = [(1 + i)^(dias_k / 30) − 1] · saldo
principal_k = pagamento − juros_k
saldo −= principal_k TIR — PRINCIPAL SUM (variante: juros sobre saldo, dias entre parcelas)
principal inicial = Σ principal_k do método OVERPAYMENT
para k = 1..n:
dias_k = dias entre a parcela k−1 (ou baseDate, se k=1) e a k
juros_k = [(1 + i)^(dias_k / 30) − 1] · saldo
principal_k = pagamento − juros_k
saldo −= principal_kConvenção de unidades — a fonte de erro mais comum
| Campo | Unidade | Exemplo para 2,5% ao mês |
|---|---|---|
interestRate, monthlyInterestRate | Decimal mensal | 0.025 |
additionalIofRate | Decimal | 0.0038 para 0,38% |
dailyIofRate | Decimal por dia | 0.000082 para 0,0082% ao dia |
commissionRate (coeficiente) | Decimal | 0.02 para 2% |
balloonPercentage | Decimal, 0,01 a 0,99 | 0.30 para balão de 30% |
stepPercentage | Decimal, 0,01 a 0,50 | 0.10 para degrau de 10% |
Passar 2.5 onde se espera 0.025 não gera erro de validação — gera um cronograma com 250% de juros ao mês. Nenhum endpoint tem teto superior de taxa.
Referência da API
Prefixo completo: /calculations-engine/api/v1/calculations. Em staging a base é https://calculations.bb.stg.catalisa.app; em desenvolvimento local no modo monolito, http://localhost:3000.
Todas as 11 rotas são POST, exigem authMiddleware (Bearer JWT) e a mesma permissão, CALCULATIONS_EXECUTE. Nenhuma exige requireOrganization — o building block é global e não isola nada por tenant, porque não guarda nada. Todas usam o envelope JSON:API ({"data":{"type":"...","attributes":{...}}}) na entrada e na saída.
Cálculos básicos
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /calculations-engine/api/v1/calculations/loan-payment-calculator/calculations | Parcela pela tabela Price (PMT) | CALCULATIONS_EXECUTE |
POST | /calculations-engine/api/v1/calculations/loan-interest-rate-calculator/calculations | Taxa mensal e anual a partir de valor, parcela e prazo | CALCULATIONS_EXECUTE |
POST | /calculations-engine/api/v1/calculations/loan-coefficient-calculator/calculations | Coeficiente de parcela, com carência e comissão | CALCULATIONS_EXECUTE |
POST | /calculations-engine/api/v1/calculations/loan-costs-calculator/calculations | Soma de uma lista de custos | CALCULATIONS_EXECUTE |
POST | /calculations-engine/api/v1/calculations/loan-payment-dates-calculator/calculations | Datas de vencimento com regra de fim de mês | CALCULATIONS_EXECUTE |
Cronograma de amortização
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /calculations-engine/api/v1/calculations/loan-amortization-schedule-calculator/calculations | Tabela completa, nos 8 sistemas | CALCULATIONS_EXECUTE |
POST | /calculations-engine/api/v1/calculations/loan-amortization-tir-overpayment-calculator/calculations | Decomposição de parcela conhecida, por dias corridos desde a data-base | CALCULATIONS_EXECUTE |
POST | /calculations-engine/api/v1/calculations/loan-amortization-tir-principal-sum-calculator/calculations | Variante: juros sobre saldo, dias entre parcelas consecutivas | CALCULATIONS_EXECUTE |
Encargos e comparação
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /calculations-engine/api/v1/calculations/loan-iof-calculator/calculations | IOF diário e adicional, mais a versão financiada | CALCULATIONS_EXECUTE |
POST | /calculations-engine/api/v1/calculations/loan-cet-rate-calculator/calculations | Custo Efetivo Total mensal e anual | CALCULATIONS_EXECUTE |
POST | /calculations-engine/api/v1/calculations/credit-portability-calculator/calculations | Compara contrato atual e proposto, com economia e ponto de equilíbrio | CALCULATIONS_EXECUTE |
Saúde
| Método | Rota | Descrição |
|---|---|---|
GET | /calculations-engine/health | Identificação e versão do build. Sem autenticação. Não conta como endpoint de negócio. |
POST .../loan-payment-calculator/calculations
Request
{
"data": {
"type": "loan-payment-calculation",
"attributes": {
"interestRate": 0.025,
"numberOfPayments": 12,
"presentValue": 10000
}
}
}{
"data": {
"type": "loan-payment-calculation",
"attributes": {
"interestRate": 0.025,
"numberOfPayments": 12,
"presentValue": 10000
}
}
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
interestRate | number | Sim | Taxa mensal decimal. Zod aceita negativo; o serviço recusa com 400 |
numberOfPayments | integer > 0 | Sim | Prazo em meses |
presentValue | number > 0 | Sim | Valor financiado |
Resposta 200
{ "data": { "type": "loan-payment-calculation", "attributes": { "payment": 974.87 } } }{ "data": { "type": "loan-payment-calculation", "attributes": { "payment": 974.87 } } }Valor conferido executando o calculador.
POST .../loan-amortization-schedule-calculator/calculations
O endpoint mais usado e o que mais tem detalhe.
Request
{
"data": {
"type": "loan-amortization-schedule",
"attributes": {
"principal": 10000,
"interestRate": 0.025,
"numberOfPayments": 12,
"firstPaymentDate": "2026-04-15T00:00:00.000Z",
"method": "PRICE"
}
}
}{
"data": {
"type": "loan-amortization-schedule",
"attributes": {
"principal": 10000,
"interestRate": 0.025,
"numberOfPayments": 12,
"firstPaymentDate": "2026-04-15T00:00:00.000Z",
"method": "PRICE"
}
}
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
principal | number > 0 | Sim | Valor financiado |
interestRate | number | Sim | Taxa mensal decimal |
numberOfPayments | integer > 0 | Sim | Prazo |
firstPaymentDate | ISO 8601 | Sim | Data da primeira parcela. As demais são geradas somando meses (§15) |
method | enum | Não | Padrão PRICE. Ver tabela abaixo |
gracePeriodMonths | integer ≥ 0 | Não | Aceito e ignorado. Não altera o resultado (§15). Para carência, use INTEREST_ONLY |
balloonConfig | objeto | Só em BALLOON | { "balloonPercentage": 0.01–0.99 } |
interestOnlyConfig | objeto | Só em INTEREST_ONLY | { "interestOnlyPeriod": ≥1, "postGraceMethod": "PRICE" | "SAC" } |
stepConfig | objeto | Só em STEP_UP/STEP_DOWN | { "stepPercentage": 0.01–0.50, "stepInterval": ≥1 } |
customConfig | objeto | Só em CUSTOM | { "paymentSchedule": number[] }. formula existe no schema e sempre falha |
method | Configuração exigida | Comportamento |
|---|---|---|
PRICE | — | Parcela constante |
SAC | — | Principal constante, parcela decrescente |
BALLOON | balloonConfig | Parcelas menores, balão na última |
BULLET | — | Só juros, principal integral na última |
INTEREST_ONLY | interestOnlyConfig | Carência de juros, depois PRICE ou SAC |
STEP_UP | stepConfig | Parcela cresce a cada intervalo |
STEP_DOWN | stepConfig | Parcela decresce a cada intervalo |
CUSTOM | customConfig.paymentSchedule | Você define as parcelas |
Resposta 200 — primeiras linhas do cenário acima, valores conferidos executando o calculador:
{
"data": {
"type": "loan-amortization-schedule",
"attributes": {
"amortizationSchedule": [
{ "paymentNumber": 1, "paymentDate": "2026-04-15T00:00:00.000Z",
"beginningBalance": 10000, "payment": 974.87127,
"principalPayment": 724.87127, "interestPayment": 250,
"remainingBalance": 9275.1287 },
{ "paymentNumber": 2, "paymentDate": "2026-05-15T00:00:00.000Z",
"beginningBalance": 9275.12873, "payment": 974.87127,
"principalPayment": 742.99305, "interestPayment": 231.87822,
"remainingBalance": 8532.1357 }
],
"totalAmount": 11698.46,
"totalInterest": 1698.46
}
}
}{
"data": {
"type": "loan-amortization-schedule",
"attributes": {
"amortizationSchedule": [
{ "paymentNumber": 1, "paymentDate": "2026-04-15T00:00:00.000Z",
"beginningBalance": 10000, "payment": 974.87127,
"principalPayment": 724.87127, "interestPayment": 250,
"remainingBalance": 9275.1287 },
{ "paymentNumber": 2, "paymentDate": "2026-05-15T00:00:00.000Z",
"beginningBalance": 9275.12873, "payment": 974.87127,
"principalPayment": 742.99305, "interestPayment": 231.87822,
"remainingBalance": 8532.1357 }
],
"totalAmount": 11698.46,
"totalInterest": 1698.46
}
}
}As linhas vêm com 5 casas decimais;
totalAmountetotalInterest, com 2. Arredonde para exibição no seu lado — não confunda a precisão interna com o valor a cobrar.
Erros
| Status | Quando |
|---|---|
400 | principal ≤ 0, interestRate < 0, numberOfPayments ≤ 0 |
400 | method fora do enum, ou firstPaymentDate fora do formato ISO 8601 |
500 | Configuração exigida ausente (BALLOON sem balloonConfig, etc.) — a mensagem não contém must, então cai em INTERNAL (§15) |
500 | INTEREST_ONLY com interestOnlyPeriod ≥ numberOfPayments |
500 | CUSTOM com formula, ou com paymentSchedule de tamanho diferente de numberOfPayments |
POST .../loan-iof-calculator/calculations
Recebe o cronograma já calculado — o IOF depende do principal de cada parcela e da data de cada uma.
Request
{
"data": {
"type": "loan-iof-calculation",
"attributes": {
"additionalIofRate": 0.0038,
"dailyIofRate": 0.000082,
"contractDate": "2026-03-15T00:00:00.000Z",
"amortizationSchedule": [
{ "paymentNumber": 1, "paymentDate": "2026-04-15T00:00:00.000Z",
"beginningBalance": 10000, "payment": 974.87127,
"principalPayment": 724.87127, "interestPayment": 250,
"remainingBalance": 9275.1287 }
]
}
}
}{
"data": {
"type": "loan-iof-calculation",
"attributes": {
"additionalIofRate": 0.0038,
"dailyIofRate": 0.000082,
"contractDate": "2026-03-15T00:00:00.000Z",
"amortizationSchedule": [
{ "paymentNumber": 1, "paymentDate": "2026-04-15T00:00:00.000Z",
"beginningBalance": 10000, "payment": 974.87127,
"principalPayment": 724.87127, "interestPayment": 250,
"remainingBalance": 9275.1287 }
]
}
}
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
additionalIofRate | number ≥ 0 | Sim | Alíquota adicional, em decimal. Você informa — o building block não tem alíquota embutida |
dailyIofRate | number ≥ 0 | Sim | Alíquota diária, em decimal |
contractDate | ISO 8601 | Sim | Data base para contar os dias |
amortizationSchedule | array | Sim | Cronograma completo. Vazio devolve 400 |
Resposta 200 — cronograma PRICE de R$ 10.000, 2,5% ao mês, 12 parcelas, contrato em 15/03/2026, alíquotas de 0,38% e 0,0082% ao dia. Valores conferidos executando o calculador:
{
"data": {
"type": "loan-iof-calculation",
"attributes": {
"iof": {
"additionalTotal": 38,
"dailyTotal": 170.32616589966,
"total": 208.32616589966
},
"iofFinanced": {
"additionalTotal": 38.80848223075176,
"dailyTotal": 173.94999954602713,
"total": 212.7584817767789
}
}
}
}{
"data": {
"type": "loan-iof-calculation",
"attributes": {
"iof": {
"additionalTotal": 38,
"dailyTotal": 170.32616589966,
"total": 208.32616589966
},
"iofFinanced": {
"additionalTotal": 38.80848223075176,
"dailyTotal": 173.94999954602713,
"total": 212.7584817767789
}
}
}
}Leitura: 38 é 10000 × 0,0038. 170,33 é a soma, parcela a parcela, de principal × dias × 0,000082. 212,76 é o gross-up: se o IOF for embutido no financiamento, é esse o valor que incide.
As alíquotas são suas. Este building block não conhece o Decreto 6.306/2007 nem as alterações dos Decretos 12.466/2025 e 12.467/2025. Ele aplica o que você informar, e vale lembrar que as alíquotas de pessoa física e de pessoa jurídica são diferentes. Manter a alíquota vigente é responsabilidade do chamador — o Banking Product Portfolio guarda
iofDailyRateeiofAdditionalRateversionados por produto justamente para isso.
POST .../loan-cet-rate-calculator/calculations
Request
{
"data": {
"type": "loan-cet-calculation",
"attributes": {
"payment": 974.87,
"numberOfPayments": 12,
"chosenAmount": 9741.67
}
}
}{
"data": {
"type": "loan-cet-calculation",
"attributes": {
"payment": 974.87,
"numberOfPayments": 12,
"chosenAmount": 9741.67
}
}
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
payment | number > 0 | Sim | Valor da parcela |
numberOfPayments | integer > 0 | Sim | Prazo |
chosenAmount | number > 0 | Sim | Valor líquido liberado — solicitado menos IOF menos tarifas não financiadas. É aqui que se erra o CET |
Resposta 200 — com chosenAmount de 9741.67383410034 (R$ 10.000 menos IOF de R$ 208,33 menos tarifa de R$ 50), conferido executando o calculador:
{
"data": {
"type": "loan-cet-calculation",
"attributes": {
"cetMonthlyRate": { "highPrecision": 0.029349077372541, "rounded": 0.0293 },
"cetYearlyRate": { "highPrecision": 0.4149860395836358, "rounded": 0.415 }
}
}
}{
"data": {
"type": "loan-cet-calculation",
"attributes": {
"cetMonthlyRate": { "highPrecision": 0.029349077372541, "rounded": 0.0293 },
"cetYearlyRate": { "highPrecision": 0.4149860395836358, "rounded": 0.415 }
}
}
}Ou seja: taxa nominal de 2,5% ao mês, CET de 2,93% ao mês e 41,50% ao ano. A diferença é exatamente o efeito de o cliente receber menos do que assinou.
Para comparação, com chosenAmount igual ao valor cheio de R$ 10.000, o mesmo endpoint devolve cetMonthlyRate.rounded: 0.025 — o CET colapsa na taxa nominal, o que só acontece quando não há nenhum custo. Se o seu CET está saindo igual à taxa contratada, você provavelmente passou o valor errado em chosenAmount.
Erros
| Status | Quando |
|---|---|
400 | payment ≤ 0, numberOfPayments ≤ 0 ou chosenAmount ≤ 0 |
500 | A busca numérica não produziu número utilizável — Unable to calculate CET rate for given parameters |
POST .../loan-coefficient-calculator/calculations
Request
{
"data": {
"type": "loan-coefficient-calculation",
"attributes": {
"interestRate": 0.025,
"numberOfPayments": 12,
"gracePeriodDays": 45,
"commissionRate": 0.02
}
}
}{
"data": {
"type": "loan-coefficient-calculation",
"attributes": {
"interestRate": 0.025,
"numberOfPayments": 12,
"gracePeriodDays": 45,
"commissionRate": 0.02
}
}
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
interestRate | number ≥ 0 | Sim | Taxa mensal decimal |
numberOfPayments | integer > 0 | Sim | Prazo |
gracePeriodDays | integer | Não | Padrão 30, que é o caso neutro (expoente zero). Valores acima de 30 aumentam o coeficiente |
commissionRate | number | Não | Padrão 0. Decimal |
Respostas 200, conferidas executando o calculador:
| Entrada | coefficient |
|---|---|
0.025, 12 parcelas, padrões | 0.097487127 |
0.025, 12 parcelas, carência 45 dias, comissão 2% | 0.10067215751891312 |
O primeiro resultado multiplicado por R$ 10.000 dá R$ 974,87 — a mesma parcela do endpoint de PMT, como esperado.
POST .../credit-portability-calculator/calculations
Request
{
"data": {
"type": "credit-portability-calculation",
"attributes": {
"currentLoan": {
"remainingBalance": 20000,
"remainingPayments": 24,
"monthlyInterestRate": 0.029,
"currentMonthlyPayment": 1177.50,
"firstPaymentDate": "2026-04-10T00:00:00.000Z",
"method": "PRICE"
},
"proposedLoan": {
"monthlyInterestRate": 0.021,
"numberOfPayments": 24,
"firstPaymentDate": "2026-04-10T00:00:00.000Z",
"method": "PRICE"
},
"portabilityCosts": [
{ "description": "Custas de portabilidade", "amount": 180 }
]
}
}
}{
"data": {
"type": "credit-portability-calculation",
"attributes": {
"currentLoan": {
"remainingBalance": 20000,
"remainingPayments": 24,
"monthlyInterestRate": 0.029,
"currentMonthlyPayment": 1177.50,
"firstPaymentDate": "2026-04-10T00:00:00.000Z",
"method": "PRICE"
},
"proposedLoan": {
"monthlyInterestRate": 0.021,
"numberOfPayments": 24,
"firstPaymentDate": "2026-04-10T00:00:00.000Z",
"method": "PRICE"
},
"portabilityCosts": [
{ "description": "Custas de portabilidade", "amount": 180 }
]
}
}
}currentLoan.method e proposedLoan.method aceitam apenas PRICE ou SAC — os outros seis sistemas não estão disponíveis aqui. costs (por contrato) e iofConfig (só para o proposto) são opcionais.
Resposta 200 — bloco comparison, conferido executando o calculador com o corpo acima:
{
"comparison": {
"monthlyPaymentDifference": 108.0616,
"totalCostDifference": 2371.7999999999993,
"totalInterestDifference": 2371.7999999999993,
"cetYearlyRateDifference": 0.13801833178170386,
"totalPortabilityCosts": 180,
"breakEvenMonths": 2,
"netSavings": 2191.7999999999993,
"isPortabilityAdvantage": true
}
}{
"comparison": {
"monthlyPaymentDifference": 108.0616,
"totalCostDifference": 2371.7999999999993,
"totalInterestDifference": 2371.7999999999993,
"cetYearlyRateDifference": 0.13801833178170386,
"totalPortabilityCosts": 180,
"breakEvenMonths": 2,
"netSavings": 2191.7999999999993,
"isPortabilityAdvantage": true
}
}Nos mesmos dados, o CET anual do contrato atual sai 0.42126133507760044 e o do proposto 0.2832430032958966.
| Campo | Significa |
|---|---|
monthlyPaymentDifference | Parcela atual menos proposta. Positivo é economia mensal |
totalCostDifference | Custo total atual menos proposto |
cetYearlyRateDifference | CET anual atual menos proposto |
breakEvenMonths | ceil(custos de portabilidade / economia mensal). null quando a economia mensal é ≤ 0 |
netSavings | totalCostDifference − totalPortabilityCosts |
isPortabilityAdvantage | netSavings > 0 |
A resposta traz também currentLoan e proposedLoan completos, cada um com amortizationSchedule, totalAmount, totalInterest, costsTotal, cetMonthlyRate e cetYearlyRate.
Duas ressalvas importantes. Primeira: o cronograma do contrato atual é reconstruído a partir do saldo devedor e das parcelas restantes — ele não é o cronograma original, é uma reconstrução do que resta. Segunda:
iofConfigsó se aplica ao contrato proposto; oiofdo contrato atual nunca é calculado e, por consequência,totalCostDifferencenão inclui IOF do lado atual.
POST .../loan-payment-dates-calculator/calculations
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
contractDate | ISO 8601 | Sim | Data do contrato |
firstPaymentDate | ISO 8601 | Sim | Primeiro vencimento |
numberOfPayments | integer > 0 | Sim | Quantidade de datas a gerar |
Resposta: { "paymentDates": ["2026-04-15T00:00:00.000Z", ...] }.
A regra de fim de mês: se contrato e primeira parcela caem no último dia do mês, todas as parcelas caem no último dia do mês. Caso contrário, mantém o dia do mês da primeira parcela e, quando o mês não tem esse dia (dia 31 em abril), cai para o último dia do mês.
Este é o gerador correto de datas — e o endpoint de cronograma de amortização não o usa (§15). Se as datas importam para você, gere aqui e trate o resultado como a fonte de verdade.
Demais endpoints
.../loan-interest-rate-calculator/calculations — { presentValue, payment, numberOfPayments } → { monthlyRate, annualRate }, cada um com highPrecision e rounded. Com presentValue: 10000, payment: 974.87 e 12 parcelas, devolve monthlyRate.rounded: 0.025 — o inverso do PMT, conferido.
.../loan-costs-calculator/calculations — { costs: [{ description, amount }] } → { costsTotal }. Soma pura, sem arredondamento e sem validação de sinal: valores negativos são somados como estão.
.../loan-amortization-tir-overpayment-calculator/calculations e .../loan-amortization-tir-principal-sum-calculator/calculations — ambos recebem { payment, interestRate, numberOfPayments, firstPaymentDate, baseDate } e devolvem { amortizationSchedule }. Servem para decompor uma parcela já conhecida em principal e juros usando dias corridos, não meses inteiros — útil para conferir um contrato de terceiro ou para calcular saldo devedor em data que não é aniversário de parcela. Diferença entre os dois: overpayment desconta cada parcela desde a data-base; principal-sum acumula juros sobre o saldo entre parcelas consecutivas. Estes dois são os únicos que geram as próprias datas com o calculador correto de fim de mês.
Início rápido
Do zero a uma tabela de amortização com IOF e CET. Cinco chamadas.
flowchart LR T["1 · IAM<br/>login e token"] --> P["2 · loan-payment<br/>a parcela"] P --> S["3 · loan-amortization-schedule<br/>o cronograma"] S --> I["4 · loan-iof<br/>o IOF sobre o cronograma"] I --> L["valor líquido liberado<br/>10.000 − 208,33 − 50"] L --> C["5 · loan-cet-rate<br/>o CET sobre o líquido"] C --> R["CET 2,93% a.m. · 41,50% a.a."]
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/calculations.router.tse dos schemas Zod. Todos os números das respostas foram verificados executando os calculadores diretamente combun, e a suíte de 168 testes unitários passa integralmente.
1. Autenticar no IAM
export API=https://calculations.bb.stg.catalisa.app/calculations-engine/api/v1/calculations
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://calculations.bb.stg.catalisa.app/calculations-engine/api/v1/calculations
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. A parcela
curl -s -X POST $API/loan-payment-calculator/calculations \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"data":{"type":"loan-payment-calculation","attributes":{
"interestRate":0.025,"numberOfPayments":12,"presentValue":10000}}}' \
| jq '.data.attributes'curl -s -X POST $API/loan-payment-calculator/calculations \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"data":{"type":"loan-payment-calculation","attributes":{
"interestRate":0.025,"numberOfPayments":12,"presentValue":10000}}}' \
| jq '.data.attributes'{ "payment": 974.87 }{ "payment": 974.87 }3. O cronograma
curl -s -X POST $API/loan-amortization-schedule-calculator/calculations \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"data":{"type":"loan-amortization-schedule","attributes":{
"principal":10000,"interestRate":0.025,"numberOfPayments":12,
"firstPaymentDate":"2026-04-15T00:00:00.000Z","method":"PRICE"}}}' \
> cronograma.json
jq '.data.attributes | {totalAmount, totalInterest,
primeira: .amortizationSchedule[0]}' cronograma.jsoncurl -s -X POST $API/loan-amortization-schedule-calculator/calculations \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"data":{"type":"loan-amortization-schedule","attributes":{
"principal":10000,"interestRate":0.025,"numberOfPayments":12,
"firstPaymentDate":"2026-04-15T00:00:00.000Z","method":"PRICE"}}}' \
> cronograma.json
jq '.data.attributes | {totalAmount, totalInterest,
primeira: .amortizationSchedule[0]}' cronograma.json{
"totalAmount": 11698.46,
"totalInterest": 1698.46,
"primeira": {
"paymentNumber": 1,
"paymentDate": "2026-04-15T00:00:00.000Z",
"beginningBalance": 10000,
"payment": 974.87127,
"principalPayment": 724.87127,
"interestPayment": 250,
"remainingBalance": 9275.1287
}
}{
"totalAmount": 11698.46,
"totalInterest": 1698.46,
"primeira": {
"paymentNumber": 1,
"paymentDate": "2026-04-15T00:00:00.000Z",
"beginningBalance": 10000,
"payment": 974.87127,
"principalPayment": 724.87127,
"interestPayment": 250,
"remainingBalance": 9275.1287
}
}Confira a linha 1 à mão: juros de 10000 × 0,025 = 250, principal de 974,87127 − 250 = 724,87127, saldo de 10000 − 724,87127 = 9275,12873. Bate.
4. O IOF sobre esse cronograma
SCHEDULE=$(jq -c '.data.attributes.amortizationSchedule' cronograma.json)
curl -s -X POST $API/loan-iof-calculator/calculations \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"data\":{\"type\":\"loan-iof-calculation\",\"attributes\":{
\"additionalIofRate\":0.0038,
\"dailyIofRate\":0.000082,
\"contractDate\":\"2026-03-15T00:00:00.000Z\",
\"amortizationSchedule\":$SCHEDULE}}}" | jq '.data.attributes.iof'SCHEDULE=$(jq -c '.data.attributes.amortizationSchedule' cronograma.json)
curl -s -X POST $API/loan-iof-calculator/calculations \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"data\":{\"type\":\"loan-iof-calculation\",\"attributes\":{
\"additionalIofRate\":0.0038,
\"dailyIofRate\":0.000082,
\"contractDate\":\"2026-03-15T00:00:00.000Z\",
\"amortizationSchedule\":$SCHEDULE}}}" | jq '.data.attributes.iof'{
"additionalTotal": 38,
"dailyTotal": 170.32616589966,
"total": 208.32616589966
}{
"additionalTotal": 38,
"dailyTotal": 170.32616589966,
"total": 208.32616589966
}5. O CET sobre o valor líquido
O cliente pediu R$ 10.000, mas recebe R$ 10.000 menos o IOF de R$ 208,33 menos uma tarifa de R$ 50 — ou seja, R$ 9.741,67. É esse o valor que entra no CET.
curl -s -X POST $API/loan-cet-rate-calculator/calculations \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"data":{"type":"loan-cet-calculation","attributes":{
"payment":974.87,"numberOfPayments":12,
"chosenAmount":9741.67383410034}}}' | jq '.data.attributes'curl -s -X POST $API/loan-cet-rate-calculator/calculations \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"data":{"type":"loan-cet-calculation","attributes":{
"payment":974.87,"numberOfPayments":12,
"chosenAmount":9741.67383410034}}}' | jq '.data.attributes'{
"cetMonthlyRate": { "highPrecision": 0.029349077372541, "rounded": 0.0293 },
"cetYearlyRate": { "highPrecision": 0.4149860395836358, "rounded": 0.415 }
}{
"cetMonthlyRate": { "highPrecision": 0.029349077372541, "rounded": 0.0293 },
"cetYearlyRate": { "highPrecision": 0.4149860395836358, "rounded": 0.415 }
}O resultado da sequência inteira, em uma frase: taxa contratada de 2,50% ao mês, CET de 2,93% ao mês e 41,50% ao ano. Todos os números desta seção foram produzidos executando os calculadores.
Credenciais de staging, documentadas em AMBIENTES.md. Nunca use credencial de produção em documentação ou script de exemplo.
Receitas
Comparar os oito sistemas de amortização com os mesmos parâmetros
Todos os números abaixo saíram de uma execução dos calculadores com R$ 10.000, 2,5% ao mês, 12 parcelas, primeira em 15/04/2026.
for M in PRICE SAC BULLET; do
curl -s -X POST $API/loan-amortization-schedule-calculator/calculations \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"data\":{\"type\":\"loan-amortization-schedule\",\"attributes\":{
\"principal\":10000,\"interestRate\":0.025,\"numberOfPayments\":12,
\"firstPaymentDate\":\"2026-04-15T00:00:00.000Z\",
\"method\":\"$M\"}}}" \
| jq -r --arg m "$M" '.data.attributes |
"\($m)\t1a \(.amortizationSchedule[0].payment)"
+ "\ttotal \(.totalAmount)\tjuros \(.totalInterest)"'
donefor M in PRICE SAC BULLET; do
curl -s -X POST $API/loan-amortization-schedule-calculator/calculations \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"data\":{\"type\":\"loan-amortization-schedule\",\"attributes\":{
\"principal\":10000,\"interestRate\":0.025,\"numberOfPayments\":12,
\"firstPaymentDate\":\"2026-04-15T00:00:00.000Z\",
\"method\":\"$M\"}}}" \
| jq -r --arg m "$M" '.data.attributes |
"\($m)\t1a \(.amortizationSchedule[0].payment)"
+ "\ttotal \(.totalAmount)\tjuros \(.totalInterest)"'
donemethod | Configuração | 1ª parcela | Total pago | Total de juros |
|---|---|---|---|---|
PRICE | — | 974,87127 | 11.698,46 | 1.698,46 |
SAC | — | 1.083,33333 | 11.625,00 | 1.625,00 |
BULLET | — | 250,00 | 13.000,00 | 3.000,00 |
BALLOON | balloonPercentage: 0.30 | 682,40989 | 12.223,59 | 2.223,59 |
INTEREST_ONLY | 3 parcelas, pós PRICE | 250,00 | 12.041,12 | 2.041,12 |
INTEREST_ONLY | 3 parcelas, pós SAC | 250,00 | 12.000,00 | 2.000,00 |
STEP_UP | 10% a cada 6 parcelas | 931,72952 | 11.739,79 | 1.739,79 |
STEP_DOWN | 10% a cada 6 parcelas | 1.022,20216 | 11.653,10 | 1.653,10 |
CUSTOM | 12 × 974,87 | 974,87 | 11.698,46 | 1.698,46 |
Leituras que valem para a conversa comercial: BULLET é o mais caro em juros (o saldo nunca cai), SAC é o mais barato entre os que amortizam desde o início, e a diferença entre PRICE e SAC no total de juros — R$ 73,46 neste cenário — é pequena, porque o efeito grande está no perfil da parcela, não no total.
Armadilhas.
BALLOON,STEP_UP,CUSTOMeINTEREST_ONLYcom pós-SACdevolvemremainingBalance: nullna última parcela. É um defeito confirmado (§15). Tratenullno saldo final como zero, ou o seu somatório quebra.gracePeriodMonthsnão faz nada. Para carência, useINTEREST_ONLYcominterestOnlyConfig.STEP_DOWNcom passo alto pode fazer a parcela ficar abaixo dos juros. O cálculo tem piso — a parcela nunca cai abaixo dos juros do período —, mas isso distorce o degrau declarado nas parcelas finais.
Calcular o CET corretamente, do começo ao fim
O erro clássico é usar o valor solicitado em vez do liberado. A sequência correta encadeia três endpoints. Números conferidos com R$ 15.000, 2,14% ao mês, 36 parcelas, contrato em 20/08/2026 e primeira parcela em 20/09/2026:
flowchart TD E["Entrada: 15.000 · 2,14% a.m. · 36 parcelas<br/>contrato 20/08/2026 · 1ª parcela 20/09/2026"] E --> P1["1 · loan-amortization-schedule-calculator"] P1 --> P1R["parcela 601,80528"] P1R --> P2["2 · loan-iof-calculator<br/>sobre o cronograma do passo 1"] P2 --> P2R["IOF total 456,29388216024"] P2R --> P3["3 · valor líquido liberado<br/>15.000 − IOF − tarifas de 389,90"] P3 --> P3R["14.153,80611783976"] P3R --> P4["4 · loan-cet-rate-calculator<br/>chosenAmount = líquido liberado"] P4 --> P4R["CET 2,51% a.m. · 34,65% a.a."]
Passo 1 — o cronograma. loan-amortization-schedule-calculator com principal 15.000, taxa 0.0214 e 36 parcelas.
Passo 2 — o IOF sobre esse cronograma. loan-iof-calculator, com adicional 0.0038, diária 0.000082 e contrato em 20/08/2026.
Passo 3 — o valor líquido liberado. É a subtração que quase todo mundo erra:
15.000 − 456,29388216024 − 389,90 (tarifas) = 14.153,80611783976 15.000 − 456,29388216024 − 389,90 (tarifas) = 14.153,80611783976Passo 4 — o CET. loan-cet-rate-calculator com parcela 601,80528, 36 parcelas e chosenAmount de 14.153,80611783976.
Confira o sentido: taxa contratada 2,14% a.m. → CET 2,51% a.m.
Atenção. Se o CET sair igual à taxa contratada, você usou o valor cheio em chosenAmount. Passando 15.000, o mesmo endpoint devolve CET mensal 0,021400000324007927 — exatamente a taxa nominal.
Armadilhas.
- Tarifa financiada não sai do líquido. Se a TAC é embutida no valor financiado, o principal do cronograma aumenta e o líquido liberado ao cliente não muda por causa dela. Decida antes o que é financiado e o que é descontado na liberação.
- Use o IOF financiado quando o IOF for embutido. Se você embute o IOF no valor financiado, o principal do cronograma é o solicitado mais
iofFinanced.total, e o IOF precisa ser recalculado sobre esse cronograma novo. É iterativo por natureza. - Use
highPrecisionpara encadear,roundedpara exibir. Arredondar no meio da cadeia propaga erro. - A busca do CET não avisa se não convergiu. O
RATEdo@formulajs/formulajsé Newton-Raphson com no máximo 100 iterações e devolve o último valor sem sinalizar (§7). Faça uma verificação de sanidade: o CET tem de ser maior ou igual à taxa contratada. Menor é sinal de entrada errada ou de não convergência.
Analisar uma proposta de portabilidade
curl -s -X POST $API/credit-portability-calculator/calculations \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"data":{"type":"credit-portability-calculation","attributes":{
"currentLoan":{"remainingBalance":20000,"remainingPayments":24,
"monthlyInterestRate":0.029,"currentMonthlyPayment":1177.50,
"firstPaymentDate":"2026-04-10T00:00:00.000Z","method":"PRICE"},
"proposedLoan":{"monthlyInterestRate":0.021,"numberOfPayments":24,
"firstPaymentDate":"2026-04-10T00:00:00.000Z","method":"PRICE"},
"portabilityCosts":[{"description":"Custas","amount":180}]}}}' \
| jq '.data.attributes.comparison'curl -s -X POST $API/credit-portability-calculator/calculations \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"data":{"type":"credit-portability-calculation","attributes":{
"currentLoan":{"remainingBalance":20000,"remainingPayments":24,
"monthlyInterestRate":0.029,"currentMonthlyPayment":1177.50,
"firstPaymentDate":"2026-04-10T00:00:00.000Z","method":"PRICE"},
"proposedLoan":{"monthlyInterestRate":0.021,"numberOfPayments":24,
"firstPaymentDate":"2026-04-10T00:00:00.000Z","method":"PRICE"},
"portabilityCosts":[{"description":"Custas","amount":180}]}}}' \
| jq '.data.attributes.comparison'Resposta conferida:
{
"monthlyPaymentDifference": 108.0616,
"totalCostDifference": 2371.7999999999993,
"totalInterestDifference": 2371.7999999999993,
"cetYearlyRateDifference": 0.13801833178170386,
"totalPortabilityCosts": 180,
"breakEvenMonths": 2,
"netSavings": 2191.7999999999993,
"isPortabilityAdvantage": true
}{
"monthlyPaymentDifference": 108.0616,
"totalCostDifference": 2371.7999999999993,
"totalInterestDifference": 2371.7999999999993,
"cetYearlyRateDifference": 0.13801833178170386,
"totalPortabilityCosts": 180,
"breakEvenMonths": 2,
"netSavings": 2191.7999999999993,
"isPortabilityAdvantage": true
}Como isso vira uma frase para o cliente: "a parcela cai de R$ 1.177,50 para R$ 1.069,44 — R$ 108,06 por mês. No total, você paga R$ 2.371,80 a menos. Descontando as custas de R$ 180, a economia líquida é de R$ 2.191,80, e você recupera o custo da operação em 2 meses." O CET anual cai de 42,13% para 28,32%.
Armadilhas.
- Só
PRICEeSAC. Os outros seis sistemas devolvem400neste endpoint. - O IOF do contrato atual nunca é calculado.
iofConfigse aplica só ao proposto, entãototalCostDifferencetem IOF de um lado e não do outro. Se o contrato atual já pagou IOF, ele é custo afundado e essa assimetria é defensável — mas saiba que ela existe. breakEvenMonthsvemnullquando a parcela não cai. Não é erro. Significa que não há economia mensal para amortizar as custas, e a vantagem, se houver, vem do prazo.currentMonthlyPaymenté informado por você, não calculado. Se você informar um valor inconsistente com o saldo, as parcelas restantes e a taxa, o CET do contrato atual sai distorcido — e nada avisa.
Descobrir por que um cálculo devolveu 400 ou 500
A regra de tradução de erro deste building block é uma convenção do código: o serviço lança Error, e se a mensagem contiver a palavra must, vira VALIDATION (400); caso contrário, vira INTERNAL (500).
| Sintoma | Causa provável |
|---|---|
400 com must be positive | Valor, parcela ou prazo zerado ou negativo |
400 com must be non-negative | Taxa negativa |
400 com must be PRICE or SAC | Sistema não suportado na portabilidade |
500 com BALLOON amortization requires balloonConfig | Faltou a configuração do método — é erro de entrada, mas cai em 500 |
500 com Payment dates mismatch | Inconsistência interna entre prazo e datas geradas |
500 com Custom formula is not yet supported | Você usou customConfig.formula. Use paymentSchedule |
500 com Unable to calculate CET rate | A busca numérica falhou. Confira se chosenAmount e payment são coerentes |
| Resultado absurdo, sem erro | Quase sempre unidade: 2.5 em vez de 0.025. Não há teto de taxa |
Conferir os números contra a sua planilha antes de confiar
Antes de colocar isso na frente de um contrato, rode a suíte e compare com a sua fonte atual.
bun run vitest run tests/unit/calculations-engine/bun run vitest run tests/unit/calculations-engine/São 168 testes em 16 arquivos, e a execução leva cerca de 12 segundos. Depois, pegue três contratos reais já assinados, jogue os parâmetros nos endpoints e compare parcela, total de juros, IOF e CET com o que está no contrato. Divergência de centavos é arredondamento e precisa ser decidida; divergência de reais é diferença de método, e você precisa saber qual.
Armadilhas.
- A precisão interna é de 5 casas, a sua provavelmente é de 2. Compare os totais e a primeira parcela, não os saldos intermediários.
- Confira as datas primeiro. O IOF depende do número de dias. Se o seu sistema gera vencimentos com regra de fim de mês e o endpoint de cronograma gera com incremento simples (§15), o IOF vai divergir por motivo de calendário, não de fórmula.
- Feriado e dia útil não existem aqui. Nenhum endpoint conhece calendário bancário. Se os seus vencimentos são ajustados para o próximo dia útil, esse ajuste é seu.
Integração com outros building blocks
| Building block | Como se relaciona | Obrigatório |
|---|---|---|
| IAM | Emite o token; a permissão CALCULATIONS_EXECUTE vem dele. Não exige organizationId | Sim |
| Pricing Engine | Produz a interestRate e as tarifas que entram aqui. Integração por contrato, não por código | Não |
| Banking Product Portfolio | Guarda iofDailyRate e iofAdditionalRate versionados por produto. Integração por contrato, não por código | Não |
| Decision Platform | Orquestra a esteira e chama este building block na etapa de cálculo | Não |
Seja honesto na venda: o Calculations Engine não chama nenhum outro building block e não é chamado por nenhum. Não há facade em
src/shared/facades/, não háModuleClient, não há import cruzado — oCalculationsServiceé registrado no container sem dependência alguma. O Banking Product Portfolio documenta explicitamente que oPOST /simulatedele devolve parâmetros resolvidos, sem parcela, sem IOF e sem CET. A composição da esteira é feita pelo orquestrador do cliente ou pelo Decision Platform, que chama cada peça em sequência. 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 — cliente pede R$ 15.000 em 36x"])
PROP --> BPP
BPP["BANKING PRODUCT PORTFOLIO — o mandato<br/>faixa de valor, faixa de taxa, prazo,<br/>iofDailyRate, iofAdditional"]
BPP --> DEC["DECISION PLATFORM + DECISION ENGINE<br/>aprovado? qual score?"]
DEC --> PRI["PRICING ENGINE — quanto CUSTA<br/>interestRate, fees, commissions, insurances"]
PRI -->|"taxa e encargos definidos"| CALC
subgraph CALC["CALCULATIONS ENGINE — você está aqui — como isso se PAGA"]
K1["1 · loan-amortization-schedule → cronograma e parcela<br/>entradas: valor, prazo, interestRate do Pricing"]
K2["2 · loan-iof → IOF sobre o cronograma<br/>entradas: iofDailyRate e iofAdditionalRate do Portfolio"]
K3["3 · loan-cet-rate → CET sobre o líquido liberado<br/>entradas: parcela, prazo, valor − IOF − tarifas do Pricing"]
K1 --> K2 --> K3
end
CALC -->|"parcela, cronograma, IOF e CET"| CTR["CONTRATO → guarda o cronograma, o CET<br/>e o versionId. Para sempre."]
CTR --> ASS["E-SIGNATURE assina"]
CTR --> AUD["AUDIT TRAIL registra"]
CTR --> BIL["BILLING cobra"]Este bloco é o último elo antes do contrato. Ele não decide nada — ele materializa em números o que os anteriores decidiram.
Atenção. As setas do diagrama são a ordem lógica da esteira, não chamadas automáticas entre serviços. Quem encadeia as etapas é o orquestrador do cliente ou o Decision Platform.
A fronteira entre este bloco e o Pricing 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, tem banco de dados e escopo de tenant; o outro é aritmética, é stateless e é global. Se a pergunta é "por que essa taxa?", é lá. Se é "quanto dá a parcela?", é aqui.
Por que esse encadeamento é o argumento comercial. Cada peça sozinha é substituível. Junto, o encaixe é o produto: o iofDailyRate que o Portfolio congelou no snapshot da versão é a alíquota que este bloco aplica; a interestRate que o Pricing produziu é a taxa do cronograma; e o CET que sai daqui é o número que a Resolução CMN 4.881/2020 obriga a informar antes da contratação. Três blocos, uma linha do contrato — e todos os três rastreáveis pelos identificadores que o contrato guardou.
Configuração e operação
Variáveis de ambiente
Este building block não tem variável própria e é o mais leve da plataforma para operar.
| Variável | Descrição | Obrigatória | Padrão |
|---|---|---|---|
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 é 3009) |
DEPLOYMENT_MODE | monolith ou standalone | Não | standalone (forçado em main.ts) |
MODULE_SELF | Identifica o serviço no /health | Não | calculations-engine |
DATABASE_URL | Não usado. O building block não tem banco | Não | — |
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ê |
|---|---|
| IAM | Emissão do token. A verificação é local, sem chamada de rede |
Sem PostgreSQL, sem Redis próprio, sem S3, sem fila, sem serviço externo. provedores: 0 e entidades: 0 no frontmatter são literais. Escalar horizontalmente é subir mais réplicas — não há estado a coordenar, não há migração a rodar e não há conexão de banco a dimensionar.
Limites
| Limite | Valor |
|---|---|
| Tamanho do corpo da requisição | 1 MB (applyCommonMiddleware) — relevante no IOF, que recebe o cronograma inteiro |
numberOfPayments | Inteiro positivo. Sem teto |
interestRate | Não negativo. Sem teto — 2.5 é aceito como 250% ao mês |
balloonPercentage | 0,01 a 0,99 |
stepPercentage | 0,01 a 0,50 |
stepInterval | ≥ 1 |
interestOnlyPeriod | ≥ 1, e obrigatoriamente menor que numberOfPayments |
customConfig.paymentSchedule | Tamanho tem de ser exatamente numberOfPayments |
| IOF: teto de dias por parcela | 365 dias |
| Precisão das linhas do cronograma | 5 casas decimais; saldo remanescente, 4 |
| Precisão dos totais | 2 casas decimais |
| Precisão das taxas | highPrecision sem arredondamento no CET; rounded a 4 casas |
Um cronograma de 360 parcelas produz uma resposta de algumas dezenas de kilobytes. O limite de 1 MB no corpo é o que restringe, na prática, o tamanho do cronograma que você consegue mandar para o endpoint de IOF.
Catálogo de erros
| Status | Código | Significa | O que fazer |
|---|---|---|---|
400 | VALIDATION | Corpo reprovado no Zod: tipo errado, campo ausente, enum inválido, data fora do ISO 8601 | A resposta traz details |
400 | VALIDATION | ... must be positive | Valor, parcela ou prazo zerado ou negativo |
400 | VALIDATION | ... must be non-negative | Taxa negativa |
400 | VALIDATION | Amortization schedule must not be empty | O IOF exige o cronograma |
400 | VALIDATION | ... method must be PRICE or SAC | Portabilidade só aceita esses dois |
401 | UNAUTHORIZED | Token ausente, inválido ou expirado | Renove no IAM |
403 | FORBIDDEN | Falta CALCULATIONS_EXECUTE | Confira as permissões contratadas pela organização |
429 | — | Rate limit global estourado | Aplique recuo exponencial |
500 | INTERNAL | BALLOON amortization requires balloonConfig (e equivalentes) | É erro de entrada classificado como interno (§15). Envie a configuração do método |
500 | INTERNAL | Interest-only period must be less than total number of payments | Reduza interestOnlyPeriod |
500 | INTERNAL | Custom formula is not yet supported. Use paymentSchedule instead. | Use paymentSchedule |
500 | INTERNAL | Payment schedule length (X) must match numberOfPayments (Y) | Ajuste o vetor |
500 | INTERNAL | Unable to calculate CET rate for given parameters | Confira coerência entre payment, numberOfPayments e chosenAmount |
500 | INTERNAL | Payment calculation resulted in NaN | Entrada degenerada no PMT |
A classificação entre
400e500segue uma convenção do código: mensagem contendo a palavramustvira400; qualquer outra vira500. Algumas mensagens de erro de entrada não contêmmuste por isso aparecem como500. Não trate500deste building block como indisponibilidade sem antes ler a mensagem.
Observabilidade.
GET /calculations-engine/healthdevolve identificação e versão do build. Como não há dependência externa, é uma sonda de vida completa: se o processo responde, o serviço funciona.- Não há evento publicado. Este building block não emite nada no barramento — ele não tem mudança de estado para anunciar.
- Não há métrica própria de latência nem contador por tipo de cálculo. Se você precisa saber qual endpoint é mais usado ou quanto tempo leva um cronograma de 360 parcelas, instrumente do lado do chamador (§15).
- Como não há persistência, não há como reconstruir um cálculo depois. Se o número precisa ser auditável, guarde a requisição e a resposta do seu lado. Esta é a implicação operacional mais importante de o building block ser stateless.
Segurança e compliance
Isolamento entre tenants
Este é o único building block financeiro da plataforma que não isola nada por tenant — e isso é correto, não uma lacuna. Nenhuma rota aplica requireOrganization, porque não há dado de tenant a proteger: o serviço recebe números na requisição, calcula em memória e devolve números. Não há tabela, não há consulta, não há linha de outro cliente que pudesse vazar. O risco de vazamento entre tenants é estruturalmente zero, não mitigado.
O que existe é controle de acesso ao cálculo: authMiddleware exige Bearer JWT válido e requirePermission(CALCULATIONS_EXECUTE) exige a permissão. Um token sem essa permissão recebe 403.
flowchart LR
T["Token de qualquer organização"] --> A{"JWT válido?"}
A -->|não| E401["401 UNAUTHORIZED"]
A -->|sim| B{"Tem CALCULATIONS_EXECUTE?"}
B -->|não| E403["403 FORBIDDEN"]
B -->|sim| C["Calcula em memória"]
C --> D["Devolve números"]
D --> Z["Nada gravado · nada consultado<br/>nenhuma linha de outro cliente existe para vazar"]Dados sensíveis
O building block não recebe e não guarda dado pessoal. Nenhum schema Zod das onze rotas aceita nome, CPF, e-mail, telefone ou identificador de cliente. As entradas são valores, prazos, taxas e datas. Do ponto de vista de LGPD, ele não é controlador nem operador de dado pessoal — o que trafega é informação financeira sem titular identificado.
Consequência prática para o seu logging: se você registrar a requisição e a resposta deste building block, estará registrando valores e taxas, não dado pessoal. A correlação com um cliente específico acontece do seu lado, e é lá que a proteção precisa existir.
Autenticação e permissões
Bearer JWT verificado localmente com JWT_SECRET (HS256). Uma única permissão para as onze rotas:
| Permissão | Concede |
|---|---|
CALCULATIONS_EXECUTE | Executar qualquer um dos onze cálculos |
Não há separação entre cálculos "leves" e "pesados" nem entre leitura e escrita — porque não há escrita.
Proteções de borda
applyCommonMiddleware aplica limite de corpo de 1 MB, CORS fail-safe, 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. O limite de corpo importa aqui mais que na média: o endpoint de IOF recebe um cronograma inteiro, e é o vetor natural de abuso de memória neste building block.
Enquadramento regulatório — o que este building block faz e o que ele não faz
Esta é a seção mais importante da documentação inteira para quem vai vender, e ela precisa ser lida como está escrita.
Atenção. Ele não garante conformidade regulatória. Ele executa as fórmulas com as alíquotas que você informar.
flowchart LR
subgraph V["O que o building block faz"]
V1["Executa as fórmulas"]
V2["Aplica o teto de 365 dias<br/>na incidência diária do IOF"]
V3["Resolve o CET sobre o chosenAmount informado"]
end
subgraph N["O que continua sendo seu"]
N1["Manter a alíquota de IOF vigente"]
N2["Decidir quais encargos entram no chosenAmount"]
N3["Respeitar teto de juros"]
N4["Calendário bancário e dia útil"]
N5["Aritmética decimal do ledger"]
end
V --> NConcretamente:
- Nenhuma alíquota de IOF está codificada.
additionalIofRateedailyIofRatesão parâmetros obrigatórios de entrada. O building block não conhece o Decreto 6.306/2007 nem as alterações trazidas pelos Decretos 12.466/2025 e 12.467/2025. Ele não distingue pessoa física de pessoa jurídica, não valida a alíquota informada e não avisa se ela está desatualizada. O único elemento normativo presente no código é o teto de 365 dias por parcela na incidência diária. Manter a alíquota vigente é responsabilidade do chamador — e o Banking Product Portfolio existe em parte para versionar isso. - O CET é calculado, não certificado. A Resolução CMN 4.881/2020, em vigor desde 1º de fevereiro de 2021, substituiu a revogada Resolução 3.517/2007 e é a norma vigente sobre divulgação do Custo Efetivo Total. O endpoint
loan-cet-rate-calculatorresolve a taxa que iguala o valor informado emchosenAmountao fluxo de parcelas — o que é a mecânica do CET. Mas quais encargos entram emchosenAmounté decisão sua, e é exatamente aí que a conformidade se ganha ou se perde. Se você esquecer de descontar uma tarifa, o número sai errado e o building block não tem como saber. - Nenhum teto de juros é verificado. O consignado tem teto definido por ato do CNPS, nos termos da Lei 10.820/2003 — alterada pela MP 1.292/2025 e convertida na Lei 15.179/2025. Este building block aceita qualquer taxa não negativa, sem limite superior. Passar
2.5onde se queria0.025produz um cronograma com 250% ao mês, sem erro nem aviso. - Não há calendário bancário. Nenhum endpoint conhece feriado, dia útil ou praça. Ajuste de vencimento para o próximo dia útil é responsabilidade do chamador, e ele altera o IOF, que conta dias corridos.
- A aritmética é ponto flutuante. IEEE 754 com arredondamento explícito em cada etapa, não decimal exato. É adequado para simulação, proposta e conferência; não substitui a aritmética decimal de um ledger contábil.
O README anterior deste módulo afirmava "conformidade com regulamentação brasileira" entre as funcionalidades. Essa afirmação não se sustenta na leitura do código e foi removida deliberadamente. O que se pode afirmar com honestidade é: as fórmulas implementadas são as que a prática brasileira usa, e a responsabilidade sobre os parâmetros é de quem chama.
Limitações conhecidas
| Limitação | Impacto | Situação |
|---|---|---|
remainingBalance vem null na última parcela em 4 dos 8 métodos | Confirmado executando os calculadores: BALLOON, STEP_UP, CUSTOM e INTEREST_ONLY com postGraceMethod: "SAC" produzem NaN no saldo da última linha, que o JSON.stringify converte em null. PRICE, SAC, BULLET, STEP_DOWN e INTEREST_ONLY com pós-PRICE não têm o problema. Trate null como zero ou o seu somatório quebra | Defeito conhecido, não corrigido |
gracePeriodMonths é aceito e ignorado | O campo existe no schema Zod da rota, é repassado ao serviço e ao calculador, e nenhum calculador o lê. Confirmado: o resultado com e sem o campo é byte a byte idêntico. Para carência, use INTEREST_ONLY | Especificado, não implementado |
| O cronograma gera datas com incremento simples de mês, e transborda | O router usa date.setMonth(date.getMonth() + i): primeira parcela em 31/01 produz 31/01, 03/03, 31/03, 01/05 — pulando fevereiro. O endpoint loan-payment-dates-calculator faz certo (31/01, 28/02, 31/03, 30/04), mas o de cronograma não o usa. Isso altera o IOF, que conta dias | Defeito conhecido, não corrigido |
iofFinanced vem null quando as alíquotas são zero | Com additionalIofRate: 0 e dailyIofRate: 0, o cálculo do rateio faz 0 × (0/0) e iofFinanced.additionalTotal e .dailyTotal viram NaN → null. iofFinanced.total fica 0, correto | Defeito conhecido |
PERCENTAGE_OF_TOTAL do IOF financiado explode se o IOF ≥ principal | A fórmula divide por (principal − iofTotal). Alíquotas absurdas produzem divisão por zero ou valor negativo, sem validação | Conhecido |
RATE não sinaliza falta de convergência | Newton-Raphson com no máximo 100 iterações; se não convergir, devolve o último valor sem avisar. Afeta o CET e o cálculo de taxa. Faça verificação de sanidade no seu lado | Limitação da biblioteca @formulajs/formulajs |
customConfig.formula está no schema e sempre falha | O campo é aceito pelo Zod e o calculador lança Custom formula is not yet supported | Especificado, não implementado |
Erros de configuração de método viram 500, não 400 | BALLOON sem balloonConfig, INTEREST_ONLY com período inválido e CUSTOM com vetor de tamanho errado são erros de entrada, mas as mensagens não contêm a palavra must e caem em INTERNAL | Conhecido |
| Sem calendário bancário | Nenhum feriado, nenhum dia útil, nenhuma praça. Vencimento em sábado permanece em sábado | Por design — fora do escopo |
| Sem convenção de contagem de dias configurável | O IOF conta dias corridos; a base mensal dos métodos TIR é fixa em 30 dias. Não há 30/360, ACT/360 nem ACT/365 selecionáveis | Por design — fora do escopo |
| Aritmética em ponto flutuante, não decimal | Adequado para simulação e proposta; não substitui ledger contábil. Resíduos como −0,0001 no saldo final aparecem (verificado no cenário de 36 parcelas da §11) | Por design |
Portabilidade só aceita PRICE e SAC | Os outros seis sistemas não estão disponíveis nesse endpoint | Conhecido |
| Portabilidade não calcula IOF do contrato atual | iofConfig se aplica só ao proposto, então totalCostDifference é assimétrico | Por design, mas precisa estar claro |
| Sem cálculo em lote | Uma chamada, um cálculo. Reprocessar uma carteira exige um laço no chamador | Roadmap |
| Sem persistência e sem trilha | Nada é guardado. Se o número precisa ser auditável depois, guarde requisição e resposta do seu lado | Por design |
| Sem métrica por tipo de cálculo | Não há contador nem histograma de latência por endpoint | Roadmap |
| Sem SACRE | O Sistema de Amortização Crescente não está implementado. Os oito métodos disponíveis são os listados na §8 | Não implementado |
As quatro primeiras limitações desta tabela foram confirmadas executando o código durante a redação desta documentação, não inferidas da leitura. Elas são a razão de o status deste building block ser
betae nãoproducao.
Perguntas frequentes
A taxa que eu passo é mensal ou anual? E em percentual ou decimal?
Mensal e em decimal. 0.025 é 2,5% ao mês. Não há campo de taxa anual em nenhum endpoint de entrada — as taxas anuais aparecem só na saída, calculadas como (1 + mensal)^12 − 1. E não há teto: passar 2.5 produz um cronograma com 250% ao mês, sem erro. É de longe a causa mais comum de resultado absurdo.
Por que o meu CET saiu igual à taxa contratada?
Porque você passou o valor solicitado em chosenAmount, e não o líquido liberado. O CET só é maior que a taxa nominal quando o cliente recebe menos do que assinou — por causa do IOF, das tarifas descontadas na liberação, ou de ambos. Com chosenAmount igual ao valor cheio, o CET colapsa matematicamente na taxa nominal. A §11 traz a sequência correta com números conferidos.
Como faço carência?
Com method: "INTEREST_ONLY" e interestOnlyConfig, informando quantas parcelas são só de juros e se a amortização posterior é PRICE ou SAC. Não use gracePeriodMonths — esse campo é aceito pela API e não faz absolutamente nada (§15).
O building block me deixa em conformidade com a regulação de IOF e CET?
Não, e essa resposta precisa ser dada assim ao cliente. Nenhuma alíquota de IOF está codificada — você informa additionalIofRate e dailyIofRate a cada chamada, e o serviço não valida se estão corretas nem se são de pessoa física ou jurídica. O CET é calculado sobre o chosenAmount que você fornecer, e decidir quais encargos entram nesse valor é decisão sua. O building block executa a matemática; a conformidade é do seu processo. O elemento normativo que está no código é apenas o teto de 365 dias na incidência diária do IOF.
Por que a última parcela às vezes vem com remainingBalance: null?
Porque é NaN. Acontece em BALLOON, STEP_UP, CUSTOM e INTEREST_ONLY com pós-SAC — quatro dos oito métodos —, e é um defeito confirmado, não um comportamento intencional (§15). Trate null no saldo da última parcela como zero. PRICE, SAC e BULLET, que são os mais usados, não têm o problema.
Preciso de banco de dados para rodar este building block?
Não. Ele não tem tabela, não tem schema e não roda migração. Precisa apenas do JWT_SECRET para verificar o token. É o serviço mais simples de operar da plataforma, e escalar é subir mais réplicas.
Se ele não guarda nada, como eu audito um cálculo seis meses depois?
Guardando a requisição e a resposta do seu lado, no momento em que o cálculo entra na proposta ou no contrato. Este building block é deliberadamente sem estado, e isso significa que a responsabilidade pela trilha é de quem chama. Como as funções são puras e determinísticas, reexecutar a mesma requisição meses depois reproduz o mesmo resultado — desde que a versão do serviço não tenha mudado, o que reforça a importância de guardar a resposta e não só a entrada.
As datas de vencimento saem certas?
Depende de qual endpoint você usa. O loan-payment-dates-calculator trata fim de mês corretamente. O endpoint de cronograma de amortização não o usa — ele gera as datas com incremento simples de mês, que transborda (31 de janeiro mais um mês vira 3 de março). Se as datas importam — e elas importam para o IOF —, gere com o endpoint dedicado e não confie nas datas do cronograma (§15).
Qual a diferença entre este e o Pricing Engine?
O Pricing Engine decide quanto custa — taxa, tarifa, comissão, seguro — a partir do risco e da política comercial. Este 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, sem estado e global.
Por que não usar simplesmente numpy-financial ou uma função no meu código?
Se você tem um único sistema, provavelmente deve usar. A vantagem aqui aparece quando a mesma conta precisa existir em vários lugares — app, backoffice, contrato, relatório — e o problema real deixa de ser "como calcular" e passa a ser "como garantir que os quatro calculam igual". Some a isso que numpy-financial não tem IOF, CET nem portabilidade brasileiros, e que as cópias envelhecem em versões diferentes. O argumento não é a fórmula: é ela existir uma vez só.
Padrão: PADRAO-DOCUMENTACAO.md · Índice: INDEX.md