Desconto em valor absoluto precisa ser aplicado na base

O que aconteceu

O desconto de membro nasceu percentual (discount_percentage) e era aplicado escalando o item de parcela já calculado, pelo fator 1 - pct/100. Isso funcionava porque os juros de parcelamento são lineares sobre a base, então escalar o total final era matematicamente idêntico a descontar a base:

(base × fator) × (1 + juros) == (base × (1 + juros)) × fator

Ao trocar o desconto para valor em reais, essa equivalência caiu — e a implementação existente passou a produzir um preço diferente do prometido.

Causa raiz

Dois problemas distintos apareceram juntos.

Percentual com 2 casas não alcança valores exatos

Não é bug de arredondamento, é representabilidade. Com decimal(5,2) de percentual sobre uma base de R$ 897, os descontos alcançáveis andam de ~R$ 0,09 em R$ 0,09:

897 → 697 exige 22,2965…% 22,30% resulta em 697,09

Quando o negócio precisa cravar um preço final (“de 897 por 697”), o desconto tem que ser expresso no mesmo domínio do preço — reais — e não em percentual.

Subtração não comuta com juros

A identidade acima vale para fator multiplicativo, não para subtração:

(base − X) × (1 + juros) ≠ (base × (1 + juros)) − X

Concretamente, com base 897, juros de 2% ao mês lineares e desconto de R$ 200 em 12x:

Onde abater Total Parcela Desconto efetivo
Na base (adotado) 864,28 72,02 R$ 248,00
No total final 912,28 76,02 R$ 200,00

Correção

Adotamos abater na base: o desconto reduz o preço do produto e os juros incidem sobre o valor já descontado. Isso preserva o comportamento que o percentual tinha — ele também reduzia os juros — e faz o à vista bater exatamente no preço prometido.

  • O desconto deixou de ser uma transformação do item pronto e passou a ser a troca da base antes do cálculo de juros. Checkout#discount_factor e Checkout#apply_discount(item) foram removidos em favor de Checkout#discounted_total, consumido via o parâmetro overide_total que #installment_total já aceitava.
  • CheckoutPaymentType#build_installment_item calcula a própria base e não passa por Checkout#installment_total, então precisou do mesmo tratamento (kwarg apply_discount:) — senão os checkouts que usam checkout_payment_types cobrariam preço cheio.
  • O frontend não pode mais espelhar a conta escalando os totais: precisaria duplicar a fórmula de juros. Passou a consumir os preços já descontados que a API devolve em validate_discount, e o applyCheckoutDiscount.js foi deletado.

Como evitar

  • Quando o negócio promete um preço final exato, expresse o desconto no mesmo domínio do preço (reais), não em percentual.
  • Antes de reaproveitar uma transformação de preço, verifique se a operação comuta com o cálculo de juros. Fator multiplicativo comuta; subtração não.
  • A fórmula de juros vive só no servidor. Se o frontend precisa exibir preço descontado, a API devolve o preço pronto — não a regra para o cliente recalcular.

Comportamento atual: ../reference/checkout/member_discount.md.