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_percentage foi renomeado para discount_amount e passou a guardar um valor absoluto em reais, subtraído da base antes do cálculo dos juros de parcelamento. discount_factor e apply_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-citrg já 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: POST na discount_validation_url com 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_percentage e discount_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õe installments (Checkout#installments → Checkout#installment_total).
    • Compra: POST /api/v1/checkout → CheckoutService#process (app/services/checkout_service.rb). O total é calculado no servidor por payment_total_by_billing_type e vira @payment.total e o totalValue do Asaas. O cliente não controla o total — este é o ponto de enforcement.
  • 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_total e checkout_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) e discount_validation_url (string, nullable).
  • app/models/checkout.rb:
    • Validação de discount_percentage — numericality >= 0 e <= 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 com installment_amount/total escalados por discount_factor (arredondado em 2 casas) e description reconstruída.
    • discounted_installments → installments.map { |i| apply_discount(i) }

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.total e o totalValue do 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 :checkout na v1, post :validate_discount, on: :member → Api::V1::CheckoutController#validate_discount.
  • Action: recebe { email }, carrega o checkout (reusando set_checkout). Se não for discount_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_percentage e :discount_validation_url.
  • CheckoutSerializer → expor os dois atributos.
  • ActiveAdmin: adicionar os campos ao form do checkout e ao permit_params (verificar app/admin/*checkout*).
  • Expor discount_enabled (e opcionalmente discount_percentage) no #show da v1, via método do Checkout no as_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 em values.email/values.email_confirmation em src/components/CheckoutForm/CheckoutFormFields.js — validar só quando email === email_confirmation e 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 que CheckoutDetails e as opções de parcelamento usem os installments descontados. Quando valid === 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 /checkout revalida e aplica o desconto no servidor, então o preço exibido é igual ao preço cobrado.
  • Testes (Jest/RTL, greenfield): testar useDiscountValidation com api mockado, 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

  1. bundle exec rspec nas specs novas e alteradas (model, service, checkout_service, requests).
  2. Servidor de stub respondendo {valid:true}/{valid:false}/timeout; apontar a discount_validation_url de um checkout semeado para ele e chamar POST /api/v1/checkout/:id/validate_discount.
  3. POST /api/v1/checkout com email de membro — conferir que @payment.total e o totalValue do gateway estão descontados; com email de não-membro, preço cheio.
  4. 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 test no hook novo.
  5. make seed roda limpo e é idempotente — rodar duas vezes não duplica nada.

Documentação