Refatoração da elegibilidade de pagamento ao terapeuta (parte 1 de 4)

TLDR: Centraliza em concerns as regras de “sessão pode ser paga” e “terapeuta pode receber”, aplica o mínimo de R$ 100 que existia como constante mas nunca era validado, e corta a duplicação de invoices causada pelo scope with_expired_transfer.

Sequência: parte 1 de uma iniciativa de 4 specs escritas entre 27/01 e 03/02/2026 — parte 2: simplificar a criação de invoice, parte 3: melhorar a interface Avo, parte 4: métricas mensais.

Contexto

Regras vigentes antes da mudança

Validações na criação da invoice (ProfessionalPaymentInvoices::Creation::Validation):

Validação Regra Erro
has_financial_account conta bancária aprovada (financial_accounts.approved) “Precisa ter uma conta bancária aprovada.”
has_finished_paid_finished_meetings ao menos 1 meeting invoiceable via Meeting.from_professional(id).invoiceable “Não possui sessões a serem pagas.”
is_lower_than_max_payout subtotal <= max_payout (default R$ 4.999,00, FinancialSetting::MAX_DEFAULT_PAYOUT) “O valor máximo de pagamento é de {max_payout}.”

Scope Meeting.invoiceable (antes):

ruby scope :invoiceable, -> { within_payable_order # order com payment paid e total > 0 .finished # meeting status finished .where.missing(:professional_payment_invoice_meeting) # não tem invoice }

Cálculo de payout (CalculatePayout): total = subtotal - total_fee - transfer_fee_total, com subtotal = soma de payment_unit_price, transfer_fee_total = R$ 3,00 fixo e total_fee = 10% do subtotal. Exemplo: subtotal R$ 200,00 → taxa R$ 20,00 → transfer R$ 3,00 → líquido R$ 177,00.

Problemas identificados

  • Valor mínimo de R$ 100 não validado — FinancialSetting::MEETING_TOTAL_MIN = 100 existia mas não era usado; invoice podia ser criada com R$ 50, R$ 30.
  • Sem filtro de período — meetings de qualquer época podiam ser pagas; não filtrava “até o mês anterior” nem expirava meetings com mais de 3 meses.
  • Duplicação de invoices via with_expired_transfer — meetings com invoice em status retryável voltavam a ser invoiceable, enquanto o ProfessionalPaymentInvoicesDailyProcessingJob já retentava a invoice existente. Resultado: a mesma meeting aparecia em múltiplas invoices simultaneamente. A regra correta é que a meeting só volta a ser invoiceable após intervenção manual do admin (invoice blocked ou canceled).
  • Query complexa e procedural — InvoiceableProfessionalsQuery fazia 6 joins em SQL, calculava totais em SQL e revalidava em Ruby (N+1), sem reuso e difícil de testar.
  • Falta de semântica de domínio — User representa tanto paciente quanto profissional; métodos de pagamento espalhados, difícil entender “quem pode receber pagamento”.

Objetivos

  • Centralizar em concerns as regras de invoiceabilidade de sessão e de profissional.
  • Aplicar o valor mínimo de repasse de R$ 100 na decisão de elegibilidade.
  • Introduzir filtro de período (até o mês anterior) e o conceito de sessão expirada (> 3 meses).
  • Eliminar a duplicação de invoices removendo with_expired_transfer do scope invoiceable.
  • Dar semântica de domínio ao profissional com um wrapper Therapist.

Fora de escopo

  • Alterar o schema do banco — o wrapper Therapist usa delegate_missing_to, sem tabela nova.
  • Consolidar o flow de criação em um único use case — é a parte 2.

Mudanças

1. Concern ProfessionalPayments::MeetingInvoiceable

Centraliza as regras de “meeting pode ser paga”. Scopes:

Scope Regra
with_paid_order paciente pagou (renomeado de within_payable_order)
until_last_month end_at antes do início do mês corrente
recent end_at a partir do início do mês de 3 meses atrás
expired end_at anterior ao início do mês de 3 meses atrás
without_invoice sem professional_payment_invoice_meeting
invoiceable combinação de finished + with_paid_order + until_last_month + without_invoice

Importante: o with_expired_transfer foi removido para prevenir duplicação. Meetings com invoice em retry não voltam a ser invoiceable automaticamente.

2. Concern ProfessionalPayments::Invoiceable

Centraliza as regras de “profissional pode receber pagamento”. Scope invoiceable_professionals (profissionais com subscription pro que podem receber) e:

ruby def invoiceable? has_approved_financial_account? && has_invoiceable_meetings? && has_payout_requirements? end

Métodos auxiliares: has_approved_financial_account?, has_invoiceable_meetings?, has_expired_meetings?, has_payout_requirements?, invoiceable_meetings, invoiceable_amount, invoiceable_total.

has_payout_requirements? é invoiceable_total >= FinancialSetting::MIN_PAYOUT || has_expired_meetings? — sessões expiradas forçam a criação da invoice mesmo abaixo do mínimo.

3. Wrapper Therapist

Separa a semântica User vs. Therapist sem tocar no banco:

```ruby class Therapist delegate_missing_to :user

def self.find(id) # só encontra se o user tem subscription pro end

def self.invoiceable # wrapper sobre User.invoiceable_professionals end end ```

4. Constante MIN_PAYOUT

Em FinancialSetting:

ruby MEETING_TOTAL_MIN = 100 # valor mínimo por sessão (paciente paga) MEETING_TOTAL_MAX = 1_000 # valor máximo por sessão MAX_DEFAULT_PAYOUT = 4999.0 # valor máximo de payout (terapeuta recebe) MIN_PAYOUT = 100.0 # NOVO: valor mínimo de payout (terapeuta recebe)

Impacto: profissionais com menos de R$ 100 acumulam meetings para o mês seguinte até atingir o mínimo.

5. Remover InvoiceableProfessionalsQuery

ProfessionalPaymentInvoices::InvoiceableProfessionalsQuery.new(User.all).all → User.invoiceable_professionals.

Desvio verificado em 2026-08-06: este passo não foi concluído. app/queries/professional_payment_invoices/invoiceable_professionals_query.rb continua existindo e é consumido por app/avo/resources/invoiceable_professional.rb e por 7 cards em app/avo/cards/invoiceable_professionals/. Os concerns, o wrapper Therapist e as constantes foram entregues; a remoção da query não.

Arquivos

Novos: app/models/concerns/professional_payments/meeting_invoiceable.rb, app/models/concerns/professional_payments/invoiceable.rb, app/models/therapist.rb e os specs correspondentes.

Modificados: app/models/meeting.rb (include + rename de scope), app/models/user.rb (include), app/models/financial_setting.rb (MIN_PAYOUT), app/jobs/professional_payment_invoice_creation_job.rb, app/avo/resources/invoiceable_professional.rb.

Como verificar

bash make test test=spec/models/concerns/professional_payments/meeting_invoiceable_spec.rb make test test=spec/models/concerns/professional_payments/invoiceable_spec.rb make test test=spec/models/therapist_spec.rb

Regras de negócio finais a conferir:

Sessão invoiceable — status finished; paciente pagou (payment paid, total > 0); end_at até o mês anterior; end_at não mais de 3 meses atrás; sem invoice associada.

Profissional invoiceable — subscription pro ativa; conta bancária aprovada; total >= R$ 100 (ou tem sessões expiradas); ao menos 1 meeting invoiceable.

Documentação

O comportamento resultante está descrito em invoice_payment_flow.