INSS progressivo no Odoo: 152 linhas de testes garantindo cálculo correto
Como o módulo l10n_br_hr_payroll calcula INSS progressivo no Odoo com 5 faixas, função pura testável e 152 linhas de cobertura automatizada.
Luis Felipe Miléo
INSS no Brasil parece simples até você sentar para implementar. São cinco faixas progressivas, cada salário pode tocar várias delas, e cada centavo errado vira problema com a Receita ou com o colaborador. Quando a KMEE migrou a folha CLT do Odoo 8.0 para 16.0, fizemos questão de blindar o cálculo de INSS com 152 linhas de testes automatizados sobre uma função pura. Este post mostra como.
A regra de cálculo
Desde abril/2020 o INSS de empregados CLT é progressivo, não mais por faixa única. A Lei 14.020/2020 instituiu cinco faixas sobre o salário de contribuição:
| Faixa | Salário-base (2026) | Alíquota |
|---|---|---|
| 1 | até R$ 1.518,00 | 7,5% |
| 2 | R$ 1.518,01 a R$ 2.793,88 | 9% |
| 3 | R$ 2.793,89 a R$ 4.190,83 | 12% |
| 4 | R$ 4.190,84 a R$ 8.157,41 | 14% |
| 5 | acima do teto | (zero — teto) |
O cálculo correto é o método progressivo: cada parcela do salário é tributada na sua faixa. Um colaborador ganhando R$ 5.000,00 não paga 14% sobre R$ 5.000. Paga 7,5% sobre os primeiros R$ 1.518, 9% sobre o que excede até R$ 2.793,88, 12% sobre o que excede até R$ 4.190,83, e 14% sobre o restante até R$ 5.000.
Quem implementou INSS antigo aplicava alíquota única da faixa onde o salário caía. Esse cálculo está errado desde 2020. Ainda assim, achamos esse erro em vários sistemas em produção.
A função pura
A primeira decisão arquitetural foi separar a regra do framework. No l10n_br_hr_payroll, a função vive em tools/br.py:
from decimal import Decimal, ROUND_HALF_UP
INSS_FAIXAS_2026 = [
(Decimal("1518.00"), Decimal("0.075")),
(Decimal("2793.88"), Decimal("0.09")),
(Decimal("4190.83"), Decimal("0.12")),
(Decimal("8157.41"), Decimal("0.14")),
]
def calc_inss(base: Decimal, faixas=INSS_FAIXAS_2026) -> Decimal:
"""Calcula INSS progressivo. Retorna contribuicao em Decimal."""
contrib = Decimal("0.00")
anterior = Decimal("0.00")
for limite, aliquota in faixas:
if base <= limite:
contrib += (base - anterior) * aliquota
return contrib.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
contrib += (limite - anterior) * aliquota
anterior = limite
# Acima do teto: contribuicao maxima
return contrib.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
Sem self, sem env, sem ORM, sem mock. Função pura: entra Decimal, sai Decimal. Isso é o que torna possível ter 152 linhas de teste rodando em segundos.
Os testes
O arquivo tests/test_calc_inss.py cobre os casos que importam:
def test_inss_primeiro_centavo_da_faixa_1():
assert calc_inss(Decimal("0.01")) == Decimal("0.00")
def test_inss_limite_exato_faixa_1():
# 1518 * 0.075 = 113.85
assert calc_inss(Decimal("1518.00")) == Decimal("113.85")
def test_inss_um_centavo_acima_faixa_1():
# 1518 * 0.075 + 0.01 * 0.09 = 113.85 + 0.0009 -> 113.85
assert calc_inss(Decimal("1518.01")) == Decimal("113.85")
def test_inss_meio_da_faixa_3():
# 1518*0.075 + (2793.88-1518)*0.09 + (3500-2793.88)*0.12
# = 113.85 + 114.829 + 84.7344 = 313.41
assert calc_inss(Decimal("3500.00")) == Decimal("313.41")
def test_inss_teto():
# 1518*0.075 + 1275.88*0.09 + 1396.95*0.12 + 3966.58*0.14
# = 113.85 + 114.83 + 167.63 + 555.32 = 951.63
assert calc_inss(Decimal("8157.41")) == Decimal("951.63")
def test_inss_acima_do_teto_paga_teto():
assert calc_inss(Decimal("20000.00")) == Decimal("951.63")
E mais 140 linhas cobrindo limites entre faixas, arredondamento, valores absurdos, históricos de tabelas anteriores, e regressões de bugs reais que apareceram em produção ao longo dos anos.
Por que Decimal e não float
A folha trabalha com centavos. Em Python, 0.1 + 0.2 == 0.3 retorna False porque float é binário. Esse erro de meio centavo, multiplicado por milhares de colaboradores e doze meses, vira diferença gritante na guia da GFIP. Decimal é representação exata em base 10, e é o tipo certo para dinheiro.
O quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) arredonda para duas casas usando o método legal brasileiro: 5 sempre arredonda para cima. Python por padrão (round() da stdlib) usa banker’s rounding (ROUND_HALF_EVEN), que erra na folha. Tem post inteiro só sobre isso.
Integração com hr.salary.rule
A regra de salário do Odoo (hr.salary.rule) consome essa função:
# salary rule INSS
result = - tools.br.calc_inss(BASE_INSS).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
A base BASE_INSS é construída por outras regras (salário, horas extras, comissões, DSR, etc.) somando rubricas marcadas como base INSS. Cada rubrica tem natureza eSocial S-1.3 mapeada — assunto do post sobre S-1010 e itens_remun.
O que muda quando a tabela muda
Toda vez que a Portaria do INSS atualiza valores (geralmente em janeiro), basta editar INSS_FAIXAS_2026. Os testes batem porque os valores esperados também são recalculados. Em sistemas onde a tabela está espalhada por triggers de banco, telas e relatórios, atualizar é épico. Aqui é uma constante e um run de testes.
Conclusão
INSS progressivo é o exemplo perfeito de cálculo que tem que ser função pura, testada, auditável. A KMEE doou esse código à OCA como parte do PR #277 (kmee/kmee-odoo-addons#277). Quem quiser auditar o cálculo da própria empresa, hoje, pode rodar pytest tests/test_calc_inss.py e ver os 152 asserts passando. Folha brasileira não precisa ser caixa-preta.
Sobre o autor
Luis Felipe Miléo
Desenvolvedor Odoo · KMEE
Desenvolvedor especializado em localização fiscal e projetos open source no ecossistema Odoo/OCA, com foco em integrações para o mercado latino-americano.
Ver perfil no LinkedInArtigos relacionados
Open Finance regulado vs APIs proprietárias: qual usar para cada caso
7 de jul. de 2026
Gestão EmpresarialDDA no Odoo: contas a pagar 100% automatizadas
30 de jun. de 2026
Gestão EmpresarialTOTVS está descontinuando sua API bancária — Odoo é a alternativa neutra
9 de jun. de 2026