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:
- Conta financeira aprovada —
financial_accounts.approvedexistente - Sessões invoiceáveis — pelo menos 1 meeting que atenda aos critérios abaixo
- Requisito de repasse —
invoiceable_total >= MINIMUM_PAYMENT_AMOUNT(R$ 100, emconfig/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:
- Valida conta financeira, presença de meetings, payout não-zero, mínimo e máximo
- Monta a
ProfessionalPaymentInvoicecomstatus: :pendinge osprofessional_payment_invoice_meetings_attributes - Salva e agenda o
ProfessionalPaymentInvoiceTransferCreationJobcomwait_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: :pendingecreated_at >= 10.days.agoexpired_retry_period—status: :pendingecreated_at < 10.days.ago
Retry e expiração (DailyProcessingJob)
O ProfessionalPaymentInvoicesDailyProcessingJob roda diariamente na fila cron, em três etapas:
- Falha transfers travadas — transfers em
created/pendinghá mais de 24 h (STALE_TRANSFER_TTL) passam afailed. Sem isso a guarda doScheduleTransferbloquearia qualquer nova tentativa indefinidamente. - Registra invoices expiradas — invoices em
expired_retry_periodgeram umRails.logger.warnpedindo intervenção manual. Não há mudança de status: elas continuampending. - Retenta as elegíveis — invoices em
retryablerecebem um novoProfessionalPaymentInvoiceTransferCreationJob.
Período expirado vs. limite de tentativas
- Período expirado (
expired_retry_period?): invoice aindapendingalém dos 10 dias. Sai do retry automático e só é logada. - Limite de tentativas:
retry_count >= 5.next_retry_atretornanilenext_retry_critical?ficatrue.
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.rbapp/services/professional_payment_invoices/calculate_payout.rbapp/models/concerns/professional_payments/invoiceable.rbapp/models/concerns/professional_payments/meeting_invoiceable.rbapp/jobs/professional_payment_invoices_daily_processing_job.rb- Specs de origem: payment_invoiceability_refactor, simplify_invoice_creation, stuck_created_transfers