Fluxo de pagamento de invoices ao terapeuta

TLDR: Terapeuta PRO com conta financeira aprovada acumula sessões invoiceáveis; quando o repasse líquido atinge R$ 100, uma invoice é gerada automaticamente no início do mês, o sistema tenta criar uma transfer no gateway e retenta diariamente por até 10 dias — depois disso a invoice fica sinalizada como período expirado e exige intervenção manual.

Visão geral

O ciclo tem três atores: o job mensal que cria as invoices, o job diário que retenta as que falharam, e as ações do Avo para intervenção manual.

mermaid graph TD C["ProfessionalPaymentInvoiceCreationJob<br/>mensal, dia 1 às 00:01"] --> I["Invoice pending<br/>+ TransferCreationJob agendado"] I --> T{"Transfer no gateway"} T -->|sucesso| D["Invoice paid"] T -->|falha| F["Transfer failed<br/>invoice segue pending"] F --> DJ["DailyProcessingJob"] DJ -->|created_at >= 10 dias| R["retryable → novo TransferCreationJob"] DJ -->|created_at < 10 dias| E["expired_retry_period<br/>log de alerta + intervenção manual"] R --> T style C fill:#1f2937,color:#fff style D fill:#374151,color:#fff style E fill:#7f1d1d,color:#fff

Elegibilidade do terapeuta

Só terapeuta com plano PRO pode receber invoice (Therapist.therapist? exige subscription_plan.slug == "pro"). Para ser considerado invoiceável (ProfessionalPayments::Invoiceable#invoiceable?) precisa dos três:

  1. Conta financeira aprovada — financial_accounts.approved existente
  2. Sessões invoiceáveis — pelo menos 1 meeting que atenda aos critérios abaixo
  3. Requisito de repasse — invoiceable_total >= MINIMUM_PAYMENT_AMOUNT (R$ 100, em config/initializers/invoices.rb) ou existência de sessões expiradas

Terapeuta PRO ├── tem conta financeira aprovada? → não → não invoiceável ├── tem meetings invoiceáveis? → não → não invoiceável └── total >= R$100 (ou expiradas)? → não → não invoiceável → sim → invoiceável ✓

Quando uma sessão é invoiceável

Definido pelo scope Meeting.invoiceable (ProfessionalPayments::MeetingInvoiceable). Uma meeting entra na invoice quando atende a todos os critérios:

Critério Scope Regra
Status finished meeting concluída
Pagamento with_paid_order order com payment paid e payments.total > 0
Data until_last_month end_at antes do início do mês corrente
Sem invoice without_invoice não associada a nenhuma invoice existente

Sessões do mês corrente ainda não são invoiceáveis — o cálculo só inclui meses anteriores.

Sessões expiradas (scope :expired): meetings com end_at anterior ao início do mês de 3 meses atrás. Forçam a criação da invoice mesmo com o total abaixo do mínimo, via has_expired_meetings?.

Cálculo do repasse

ProfessionalPaymentInvoices::CalculatePayout:

Componente Regra
subtotal soma de payment_unit_price das meetings
total_fee 10% do subtotal (CalculateFee::TOTAL_FEE)
transfer_fee_total R$ 3,00 fixo (DEFAULT_TRANSFER_FEE_TOTAL)
total subtotal - total_fee - transfer_fee_total

Exemplo: subtotal R$ 200,00 → taxa 10% R$ 20,00 → taxa de transferência R$ 3,00 → líquido R$ 177,00.

Além do mínimo, ProfessionalPaymentInvoices::Create valida o máximo: payout.subtotal não pode exceder professional.financial_setting.max_payout (default FinancialSetting::MAX_DEFAULT_PAYOUT, R$ 4.999,00).

Criação da invoice

O ProfessionalPaymentInvoiceCreationJob (cron, dia 1 de cada mês às 00:01, fila cron) itera Therapist.invoiceable e chama ProfessionalPaymentInvoices::Create para cada um. O use case:

  1. Valida conta financeira, presença de meetings, payout não-zero, mínimo e máximo
  2. Monta a ProfessionalPaymentInvoice com status: :pending e os professional_payment_invoice_meetings_attributes
  3. Salva e agenda o ProfessionalPaymentInvoiceTransferCreationJob com wait_until: scheduled_at (offset aleatório de 15–115 s, para espalhar a carga no gateway)

Statuses

Os statuses da invoice e os da transfer são conjuntos distintos — a confusão entre os dois é a origem de boa parte dos mal-entendidos nessa área.

ProfessionalPaymentInvoice#status:

Status Significa
pending criada, aguardando ou retentando a transferência
paid pagamento confirmado
canceled cancelada manualmente pelo admin

ProfessionalPaymentInvoiceTransfer#status: created, pending, in_bank_processing, done, cancelled, failed, blocked.

O estado de retry da invoice não é um status — é derivado de created_at, retry_count e dos scopes:

  • retryable — status: :pending e created_at >= 10.days.ago
  • expired_retry_period — status: :pending e created_at < 10.days.ago

Retry e expiração (DailyProcessingJob)

O ProfessionalPaymentInvoicesDailyProcessingJob roda diariamente na fila cron, em três etapas:

  1. Falha transfers travadas — transfers em created/pending há mais de 24 h (STALE_TRANSFER_TTL) passam a failed. Sem isso a guarda do ScheduleTransfer bloquearia qualquer nova tentativa indefinidamente.
  2. Registra invoices expiradas — invoices em expired_retry_period geram um Rails.logger.warn pedindo intervenção manual. Não há mudança de status: elas continuam pending.
  3. Retenta as elegíveis — invoices em retryable recebem um novo ProfessionalPaymentInvoiceTransferCreationJob.

Período expirado vs. limite de tentativas

  • Período expirado (expired_retry_period?): invoice ainda pending além dos 10 dias. Sai do retry automático e só é logada.
  • Limite de tentativas: retry_count >= 5. next_retry_at retorna nil e next_retry_critical? fica true.

Retry manual (admin)

Um admin pode forçar uma nova tentativa pela ação “Execute Transfer” no Avo. A condição é transferable?:

ruby def transferable? pending? && !professional_payment_invoice_transfers.exists?(status: [:done, :in_bank_processing]) end

Bloqueia o retry manual apenas se já existe uma transfer concluída ou em andamento. A restrição de 10 dias é exclusiva do job diário — o admin pode retentar depois disso.

Avo — telas e ações

Telas

Tela O que mostra
Invoices lista de todas as invoices com filtros por status, retry e período
Erros de fatura apenas invoices com erro — falhas de transfer, último erro e contagem de retry
Terapeuta à pagar profissionais PRO com status de elegibilidade e observações

Scopes disponíveis

InvoicesCurrentMonth, InvoicesLastMonth, WithStatusInBankProcessing, WithStatusPaid, WithStatusCanceled, PaidCurrentMonth, PendingOld.

Ações

Ação Quando aparece O que faz
Execute Transfer invoice transferable? enfileira nova tentativa de transferência
Mark as Paid show marca como paga com comprovante
Pull from Gateway show atualiza o status da transfer pelo gateway
Cancel & Release Meetings show cancela a invoice e libera as meetings para nova invoice
Create for Professional profissional com meetings invoiceáveis cria nova invoice manualmente

Cards

Na index: MonthlyPaidTotalCard, PendingTotalCard, TotalInvoicesCard, MonthlyPaidCountCard, PendingInvoicesCard, TotalMeetingsCard. No show: RetryStatusCard, com o estado de retry, o último erro e a próxima tentativa.

Referências

  • app/use_cases/professional_payment_invoices/create.rb
  • app/services/professional_payment_invoices/calculate_payout.rb
  • app/models/concerns/professional_payments/invoiceable.rb
  • app/models/concerns/professional_payments/meeting_invoiceable.rb
  • app/jobs/professional_payment_invoices_daily_processing_job.rb
  • Specs de origem: payment_invoiceability_refactor, simplify_invoice_creation, stuck_created_transfers