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 = 100existia 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 oProfessionalPaymentInvoicesDailyProcessingJobjá 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 (invoiceblockedoucanceled). - Query complexa e procedural —
InvoiceableProfessionalsQueryfazia 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 —
Userrepresenta 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_transferdo scopeinvoiceable. - Dar semântica de domínio ao profissional com um wrapper
Therapist.
Fora de escopo
- Alterar o schema do banco — o wrapper
Therapistusadelegate_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.rbcontinua existindo e é consumido porapp/avo/resources/invoiceable_professional.rbe por 7 cards emapp/avo/cards/invoiceable_professionals/. Os concerns, o wrapperTherapiste 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.