Desconto de membro no checkout

TLDR: um checkout pode abater um valor fixo em reais da base quando o email do comprador é validado como membro numa URL configurada por checkout; qualquer falha na validação resulta em preço cheio.

Visão geral

O desconto é genérico e reutilizável: cada checkout tem sua própria URL de validação e seu próprio valor de desconto. Não há acoplamento com Onion ou CITRG — qualquer checkout pode apontar para qualquer URL.

Regras que governam o comportamento:

  • Checkout sem discount_validation_url se comporta exatamente como antes: nenhuma chamada HTTP, nenhum recálculo de preço, nenhuma mudança de UI.
  • O desconto só é aplicado quando discount_amount > 0 e discount_validation_url está presente (Checkout#discount_enabled?).
  • O desconto é sempre revalidado e aplicado no servidor na compra — o frontend nunca controla o total.
  • discount_amount é um valor absoluto em reais, de 0 até menos que o total do checkout.
  • O desconto é abatido da base, e os juros de parcelamento incidem sobre o valor já descontado.

Por que valor em reais e não percentual

O desconto nasceu percentual (discount_percentage), mas não era possível chegar a um valor de desconto exato por percentual: com percentual de 2 casas decimais sobre uma base de R$ 897, os descontos alcançáveis andam de ~R$ 0,09 em R$ 0,09. Para ir de 897 a 697 seriam necessários 22,2965…%; o mais próximo (22,30%) resulta em 697,09.

O racional completo está em ../../learnings/checkout_fixed_discount_must_be_applied_on_base.md.

Exemplo

``` total 897,00 | interest_rate 2% | discount_amount 200,00

1x : 697,00 12x : base 697 → juros 697 × 2% × 12 = 167,28 → total 864,28 → 12x de 72,02 ```

Como os juros incidem sobre a base já descontada, o desconto efetivo num parcelamento com juros é maior que o discount_amount nominal — R$ 248,00 no 12x acima. É o mesmo comportamento que o desconto percentual tinha.

Fluxo

```mermaid sequenceDiagram participant F as Frontend (checkout-web) participant A as Checkout API participant V as Serviço de validação

F->>A: GET /api/v1/checkout/:id
A-->>F: discount_enabled, discount_amount
Note over F: só segue se discount_enabled
F->>A: POST /api/v1/checkout/:id/validate_discount { email }
A->>V: POST discount_validation_url { email }
V-->>A: { valid: true }
A-->>F: { valid: true, discount_amount, installments,<br/>checkout_payment_types_available } já descontados
F->>A: POST /api/v1/checkout (compra)
A->>V: revalida o email no servidor
V-->>A: { valid: true }
Note over A: CheckoutService aplica o desconto em<br/>@payment.total e no totalValue do Asaas ```

O validate_discount devolve os preços prontos — nas duas formas que o #show expõe — justamente para que o frontend não precise duplicar a fórmula de juros. Ele só reprocessa o payload com o normalizador que já usa na carga inicial.

Contratos

Validação de membro

``` POST {discount_validation_url} Content-Type: application/json X-DISCOUNT-ACCESS-TOKEN: {discount_access_token}

{ “email”: “comprador@example.com” } ```

O token de acesso é por checkout, enviado no header fixo X-DISCOUNT-ACCESS-TOKEN e lido da coluna checkouts.discount_access_token. Isso permite que cada checkout aponte para serviços de validação distintos, não necessariamente o CITRG.

Resposta Resultado
200 + { "valid": true } Desconto aplicado
200 + { "valid": false } Preço cheio
Não-200, timeout (5s) ou erro Preço cheio (fallback seguro)

Implementado em app/services/member_discount_validation_service.rb.

Configuração

Colunas em checkouts:

Coluna Tipo Descrição
discount_amount decimal(10,2), default 0, not null Valor do desconto em reais
discount_validation_url string, nullable URL do serviço de validação
discount_access_token string, nullable Credencial enviada no header X-DISCOUNT-ACCESS-TOKEN

Configuráveis via ActiveAdmin (aba “Geral”) ou POST /api/v2/checkouts.

Pontos de código

Arquivo Papel
app/models/checkout.rb discount_enabled?, discounted_total, discounted_installments, discounted_checkout_payment_types_available
app/models/checkout_payment_type.rb build_installment_item(installment, apply_discount:) — desconta a base do tipo de pagamento
app/services/member_discount_validation_service.rb Chamada HTTP de validação de membro
app/services/checkout_service.rb Enforcement do desconto na compra (server-side)
app/controllers/api/v1/checkout_controller.rb #show (flag) e #validate_discount (preços descontados)
app/controllers/api/v2/checkouts_controller.rb Configuração via API (params permitidos)
app/admin/checkouts.rb Configuração via ActiveAdmin

No frontend (checkout-web): src/components/utils/buildDiscountedCheckout.js aplica o payload descontado, src/components/utils/normalizeCheckout.js é a derivação compartilhada com useCheckout, e src/hooks/useDiscountValidation.js faz a chamada.

Limitações conhecidas

  • O desconto é aplicado apenas no fluxo v1 de compra (CheckoutService). O fluxo v2 (Payments::Creation::CreateFlow) não consulta o desconto.
  • Cross-sell (cross_sell_items) não recebe desconto.
  • Se o total de um checkout_payment_type for menor que discount_amount, a base cai a 0 e o gateway rejeita o pagamento. A validação discount_amount < total cobre o checkout.total, não os totais por tipo de pagamento.
  • A validação é refeita no submit. Se ela passa na digitação e falha ou dá timeout na compra, o cliente vê o preço com desconto e é cobrado o preço cheio, sem aviso.

Dependência externa

A rota de validação do CITRG ainda não existe. O checkout-api já está pronto atrás da URL configurável; enquanto a rota não existir ou estiver inacessível, o fallback garante preço cheio. O time CITRG precisa publicar um endpoint que aceite POST { email } e retorne { valid: bool }.

Referências