Condição de corrida na expiração do PIX

Incidente: order 144487 — Diego Benjamim / Marcos Windson Silva — 14/03/2026

O que aconteceu

O cliente pagou o PIX às 00:25. O ExpireJob rodou às 00:54 (exatamente 30 minutos após a criação da order), encontrou o pagamento ainda pending e o expirou — destruindo todas as sessões. O webhook de confirmação do gateway chegou tarde demais.

A sessão foi realizada mesmo assim (o terapeuta atendeu fora da plataforma). Nenhum repasse foi gerado.

Causa raiz

Dois comportamentos que interagem criam a janela:

  1. Payments::Expire — expira se payment.pending?, não importa quão perto do horário do pagamento. A janela de 30 minutos não considera a latência de confirmação do PIX.

  2. Payments::UpdateMeetingsByGatewayStatus — só chama Orders::ConfirmPayment quando order.meetings.pending.exists?. Se as sessões já foram destruídas, o webhook silenciosamente não faz nada.

ruby # app/use_cases/payments/update_meetings_by_gateway_status.rb if context.payment.paid? && order.meetings.pending.exists? # <- falha silenciosa aqui Orders::ConfirmPayment.call(order: context.order) end

As regras envolvidas estão em R-001 e R-002.

Correção

Não houve correção sistêmica — o caso foi resolvido manualmente. O procedimento de investigação e reparo está abaixo, porque a janela continua aberta.

Como investigar um caso semelhante

```ruby # 1. Encontrar a meeting pelo pid meeting = Meeting.find_by(pid: “") order = meeting.order payment = order.payment

puts payment.status # deve ser expired ou paid puts payment.updated_at # quando mudou puts meeting.cancel_reason

2. Verificar todas as orders desse paciente/profissional

Order.joins(:meetings).where(meetings: { user_client_id: meeting.user_client_id }) .each { |o| puts “#{o.id} | #{o.payment&.status} | #{o.payment&.updated_at}” }

3. Confirmar o pagamento no gateway

result = Zeuspay::V2::Payments::Find.new( payment_id: payment.gateway_payment_id ).perform puts result.status # :paid significa que o dinheiro entrou puts result.response_body.inspect

4. Conferir o pixQrCodeId com o comprovante do cliente

# result.response_body[:installments][0][:billing_type_payload][:payload] # deve conter o mesmo pixQrCodeId mostrado no comprovante do banco ```

Como corrigir manualmente

Só prossiga depois de confirmar que o gateway mostra paid/RECEIVED.

```ruby # Passo 1 — corrigir o status do pagamento payment = Order.find().payment payment.update!(status: :paid)

Passo 2 — recriar a meeting (validate: false ignora as validações de data)

meeting = Meeting.new( status: :finished, start_at: Time.zone.parse(“"), end_at: Time.zone.parse("<data e hora da sessão + duração>"), location_type: :online, # confirmar com o terapeuta owner_id: , order_id: , user_client_id: , paid_at: payment.updated_at, paid_total: payment.total ) meeting.save!(validate: false)

Passo 3 — buscar a taxa do terapeuta numa invoice recente

recent = ProfessionalPaymentInvoice.where(professional_id: ) .order(created_at: :desc).first puts "subtotal: #{recent.subtotal} | fee: #{recent.fee_total} | transfer_fee: #{recent.transfer_fee_total}" # Fórmula: total = subtotal - fee_total - transfer_fee_total

Passo 4 — criar a invoice de repasse

invoice = ProfessionalPaymentInvoice.create!( professional_id: , status: :pending, subtotal: , fee_total: , transfer_fee_total: , total: , admin_user_id: , payment_details: "Sessões realizadas (1):\n<DD/MM/YYYY às HHh>\n\n..." )

Passo 5 — vincular a meeting à invoice

ProfessionalPaymentInvoiceMeeting.create!( professional_payment_invoice_id: invoice.id, meeting_id: meeting.id, total: )

Passo 6 — disparar o job de transferência

ProfessionalPaymentInvoiceTransferCreationJob.perform_later(invoice.id) ```

Como evitar

  • Aumentar a janela de expiração — o PIX tem alguns minutos de latência de liquidação; 30 minutos é apertado.
  • Usar soft delete nas sessões — se as sessões forem recuperáveis, um webhook atrasado pode reativá-las em vez de falhar em silêncio.
  • Tratar o caso do webhook atrasado explicitamente — quando payment.paid? e não existem sessões pending, verificar se as sessões foram canceladas pelo sistema e recuperá-las (ou, no mínimo, disparar um alerta).
  • Adicionar alerta — quando o ExpireJob rodar e o pagamento estiver confirmado do lado do gateway, disparar alerta no Slack/Sentry para revisão manual.