Desconto de membro no checkout por validação de email
TLDR: permitir que um checkout aplique um desconto percentual quando o email do comprador é validado como membro numa URL de validação por checkout, caindo para o preço cheio quando não há URL, quando dá timeout ou quando a resposta é
valid: false.
Superseded em 2026-07-31 — o desconto deixou de ser percentual.
discount_percentagefoi renomeado paradiscount_amounte passou a guardar um valor absoluto em reais, subtraído da base antes do cálculo dos juros de parcelamento.discount_factoreapply_discount(item)não existem mais. Comportamento atual: ../reference/checkout/member_discount.md. Racional: ../learnings/checkout_fixed_discount_must_be_applied_on_base.md. Tudo abaixo descreve a implementação percentual original e fica apenas como registro histórico.
A branch
feat/checkout-discount-citrgjá existia quando esta spec foi escrita.
Contexto
É preciso aplicar um desconto percentual num checkout quando o email do comprador pertence a um membro (por exemplo, do CITRG). Se não for membro, cobra-se o preço cheio.
Decisões alinhadas:
- Sem regressão: um checkout sem
discount_validation_url— o estado de todos os checkouts hoje — se comporta exatamente como hoje: nenhuma chamada HTTP de validação, nenhum recálculo de preço, nenhuma mudança de UI. O fluxo de desconto só liga quando a URL existe. - Genérico e reutilizável: cada checkout tem sua própria URL de validação e seu próprio percentual de desconto. Não há acoplamento com Onion/CITRG — qualquer checkout pode apontar para qualquer URL.
- Contrato HTTP:
POSTnadiscount_validation_urlcom body{ "email": "..." }.200+{ "valid": true }aplica o desconto;{ "valid": false }, timeout, erro ou não-200 resultam em preço cheio. - Armazenamento: duas colunas em
Checkout—discount_percentageediscount_validation_url. - UX: quando validado, mostrar uma mensagem “desconto de membro aplicado (X%)” perto do preço.
- Escopo: checkout-api + checkout-web.
Achados de ancoragem:
- O fluxo público é o v1 (o checkout-web usa
baseURL .../api/v1/).- Preços:
GET /api/v1/checkout/:id→Api::V1::CheckoutController#show, que expõeinstallments(Checkout#installments→Checkout#installment_total). - Compra:
POST /api/v1/checkout→CheckoutService#process(app/services/checkout_service.rb). O total é calculado no servidor porpayment_total_by_billing_typee vira@payment.totale ototalValuedo Asaas. O cliente não controla o total — este é o ponto de enforcement.
- Preços:
- Nota matemática: em
Checkout#installment_total,total = base * (1 + interest_rate/100 * parcelas). Como os juros são lineares sobre a base, multiplicar o total final pelo fator de desconto equivale a descontar a base — então o desconto se aplica uniformemente aos dois ramos de preço (installment_totalecheckout_payment_types.build_installment_item) sem recalcular os juros.
Foi exatamente esta equivalência que caiu quando o desconto virou valor absoluto, e que motivou o supersede.
Objetivos
- Adicionar configuração de desconto por checkout (
discount_percentage,discount_validation_url). - Validar o email do comprador contra a URL do checkout e aplicar o desconto percentual quando
valid: true. - Fazer o enforcement do desconto no servidor, na compra — nunca confiar no frontend.
- Exibir a mensagem de desconto aplicado na UI do checkout quando validado.
- Garantir zero mudança de comportamento para checkouts sem URL configurada.
Fora de escopo
A rota de validação do CITRG ainda não existe. O checkout-api entrega pronto atrás da discount_validation_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 }.
Mudanças
checkout-api (TDD — teste antes da implementação)
Migration e model
- Migration adicionando em
checkouts:discount_percentage(decimal, precision 5, scale 2,default: 0,null: false) ediscount_validation_url(string, nullable). app/models/checkout.rb:- Validação de
discount_percentage— numericality>= 0e<= 100. discount_enabled?→discount_percentage.to_f.positive? && discount_validation_url.present?discount_factor→1 - (discount_percentage.to_f / 100.0)apply_discount(installment_hash)→ hash cominstallment_amount/totalescalados pordiscount_factor(arredondado em 2 casas) edescriptionreconstruída.discounted_installments→installments.map { |i| apply_discount(i) }
- Validação de
Service de validação (genérico)
Novo app/services/member_discount_validation_service.rb: MemberDiscountValidationService.new(checkout, email).valid? → boolean. Faz POST checkout.discount_validation_url com { email: }.to_json, header JSON e timeout curto (~5s) via HTTParty. Retorna true apenas em 200 + { valid: true }; timeout, exceção, não-200 ou valid: false retornam false (com rescue amplo, mesmo padrão de app/models/onion.rb). A URL vem do checkout, não de ENV.
Enforcement na compra (autoritativo)
app/services/checkout_service.rb:
- No
process, assim que o customer é conhecido:@discount_applies = @checkout.discount_enabled? && MemberDiscountValidationService.new(@checkout, @customer.email).valid? - Em
payment_total_by_billing_type, se@discount_applies, retornar@checkout.apply_discount(item)nos dois ramos existentes. Assim@payment.totale ototalValuedo Asaas já carregam o desconto. - Sem URL,
discount_enabled?é falso — o service de validação não é instanciado e o total é exatamente o de hoje.
Endpoint de exibição
- Rota: dentro de
resources :checkoutna v1,post :validate_discount, on: :member→Api::V1::CheckoutController#validate_discount. - Action: recebe
{ email }, carrega o checkout (reusandoset_checkout). Se não fordiscount_enabled?, retorna{ valid: false }sem nenhuma chamada externa (guard antes de instanciar o service). Caso contrário roda o service: válido →{ valid: true, discount_percentage:, installments: @checkout.discounted_installments }; senão →{ valid: false }. Endpoint público, mesmo padrão de#show/#create.
Configuração do merchant e flag no show
app/controllers/api/v2/checkouts_controller.rb#checkout_params→ permitir:discount_percentagee:discount_validation_url.CheckoutSerializer→ expor os dois atributos.- ActiveAdmin: adicionar os campos ao form do checkout e ao
permit_params(verificarapp/admin/*checkout*). - Expor
discount_enabled(e opcionalmentediscount_percentage) no#showda v1, via método doCheckoutnoas_json, para o frontend saber se ativa o fluxo. Sem URL →discount_enabled: false.
Testes (RSpec)
| Arquivo | Cobertura |
|---|---|
spec/models/checkout_spec.rb |
discount_enabled?, discount_factor, apply_discount, validação de faixa |
spec/services/member_discount_validation_service_spec.rb |
HTTParty mockado — 200 {valid:true} → true; {valid:false} → false; timeout/exceção → false; não-200 → false |
spec/services/checkout_service_spec.rb |
Total descontado quando válido; total cheio quando inválido/desabilitado; checkout sem URL não instancia o service e mantém o total de hoje |
Request spec de validate_discount |
Válido true/false; sem URL → {valid:false} sem chamada externa |
Request spec de #show |
discount_enabled reflete presença/ausência da URL |
spec/factories/checkouts.rb |
Trait :with_member_discount |
checkout-web (CRA/React 17, Formik)
- Novo hook
src/hooks/useDiscountValidation.js:api.post("checkout/{id}/validate_discount", { email })retornando{ valid, installments, discount_percentage }. - Disparar apenas quando
checkout.discount_enabled === true(vindo do#show). Caso contrário o hook não é chamado e a tela fica idêntica à de hoje. Gatilho decidido: effect com chave emvalues.email/values.email_confirmationemsrc/components/CheckoutForm/CheckoutFormFields.js— validar só quandoemail === email_confirmatione o formato for válido. Revalidar sempre que o par mudar para um novo email coincidente; resetar o estado de desconto (preço cheio, sem badge) quando os campos divergirem; pular a chamada quando o email coincidente já tiver acabado de ser validado. - Subir o estado de desconto para
src/Pages/PageDetails.js, para queCheckoutDetailse as opções de parcelamento usem osinstallmentsdescontados. Quandovalid === true, mostrar mensagem/badge “Desconto de membro aplicado (X%)” perto do preço (src/components/CheckoutDetails/CheckoutDetails.js), reusando i18n (t(...)) e os estilos existentes. Nada é exibido quando não validado, não-membro ou sem desconto configurado. - Nenhuma flag de desconto é enviada no submit: o
POST /checkoutrevalida e aplica o desconto no servidor, então o preço exibido é igual ao preço cobrado. - Testes (Jest/RTL, greenfield): testar
useDiscountValidationcomapimockado, cobrindo válido true/false/erro.
Seeds
Seed idempotente (db/seeds*) com um checkout que tenha desconto configurado (find_or_create_by!, sem IDs hardcoded), para que o ambiente de dev funcione após make seed.
Como verificar
bundle exec rspecnas specs novas e alteradas (model, service, checkout_service, requests).- Servidor de stub respondendo
{valid:true}/{valid:false}/timeout; apontar adiscount_validation_urlde um checkout semeado para ele e chamarPOST /api/v1/checkout/:id/validate_discount. POST /api/v1/checkoutcom email de membro — conferir que@payment.totale ototalValuedo gateway estão descontados; com email de não-membro, preço cheio.- Rodar o checkout-web (
yarn start, porta 3009) contra a API local: email de membro mostra preço descontado e mensagem de desconto aplicado; não-membro mantém preço cheio; checkout sem URL fica idêntico ao de hoje.yarn testno hook novo. make seedroda limpo e é idempotente — rodar duas vezes não duplica nada.
Documentação
- ../reference/checkout/member_discount.md — comportamento atual da regra de negócio
- ../learnings/checkout_fixed_discount_must_be_applied_on_base.md — a nota matemática que derrubou a abordagem percentual