Race condition na expiração do PIX

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

O que aconteceu

O cliente pagou um 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 aconteceu mesmo assim (o terapeuta atendeu fora da plataforma). Nenhum payout foi gerado.


Como investigar um caso semelhante

```ruby # 1. Find the meeting by pid meeting = Meeting.find_by(pid: “") order = meeting.order payment = order.payment

puts payment.status # should be expired or paid puts payment.updated_at # when it changed puts meeting.cancel_reason

2. Check all orders for this patient/professional

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. Confirm payment on the gateway

result = Zeuspay::V2::Payments::Find.new( payment_id: payment.gateway_payment_id ).perform puts result.status # :paid means money was received puts result.response_body.inspect

4. Cross-check the pixQrCodeId with the client receipt

# result.response_body[:installments][0][:billing_type_payload][:payload] # should contain the same pixQrCodeId shown in the Nubank/bank receipt ```


Como corrigir manualmente

Só proceder após confirmar que o gateway mostra paid/RECEIVED.

```ruby # Step 1 — Fix payment status payment = Order.find().payment payment.update!(status: :paid)

Step 2 — Recreate the meeting (validate: false bypasses date validations)

meeting = Meeting.new( status: :finished, start_at: Time.zone.parse(“"), end_at: Time.zone.parse("<session date and time + duration>"), location_type: :online, # confirm with therapist owner_id: , order_id: , user_client_id: , paid_at: payment.updated_at, paid_total: payment.total ) meeting.save!(validate: false)

Step 3 — Find therapist fee from a recent invoice

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

Step 4 — Create the payout invoice

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..." )

Step 5 — Link meeting to invoice

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

Step 6 — Trigger the transfer job

ProfessionalPaymentInvoiceTransferCreationJob.perform_later(invoice.id) ```


Causa raiz

Dois comportamentos que interagem criam a lacuna:

  1. Payments::Expire — expira se payment.pending?, independente de quão perto do momento 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? # <- silent failure here Orders::ConfirmPayment.call(order: context.order) end


O que poderia prevenir isso

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