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_urlse 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 > 0ediscount_validation_urlestá 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, de0até menos que ototaldo 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
totalde umcheckout_payment_typefor menor quediscount_amount, a base cai a 0 e o gateway rejeita o pagamento. A validaçãodiscount_amount < totalcobre ocheckout.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
- ../../specs/20260706173904_checkout_member_discount.md — a spec original (percentual, superseded)
- ../../learnings/checkout_fixed_discount_must_be_applied_on_base.md — por que o desconto é abatido da base