Voltar ao Blog Gestão Empresarial

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

Luis Felipe Miléo

· 4 min de leitura

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:

FaixaSalário-base (2026)Alíquota
1até R$ 1.518,007,5%
2R$ 1.518,01 a R$ 2.793,889%
3R$ 2.793,89 a R$ 4.190,8312%
4R$ 4.190,84 a R$ 8.157,4114%
5acima 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.

Veja mais sobre folha de pagamento Odoo.

#folha #esocial

Compartilhar

Sobre o autor

Luis Felipe Miléo

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 LinkedIn