Conversation
64bd409 to
3b29fcc
Compare
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green HOLD — the dispute state can become stale or cross organization boundaries, and the new retry policy is untested.
|
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green HOLD — the new dispute state can become a permanent false refund block when webhook workers race or Stripe is temporarily unavailable.
|
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green HOLD — the retry and rollout paths can leave refunds incorrectly failed or disputes untracked.
|
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green HOLD — transient Stripe server errors can still drop the new recovery paths.
|
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green HOLD — dispute unblocking is not order-safe.
|
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green HOLD — dispute webhooks without a payment intent can mutate an unrelated payment.
|
|
Superseded by a four-PR stack, same code re-cut into reviewable steps (the tip reproduces this branch's tree exactly):
The review history here is worth reading alongside #6444, which is where most of it applies. |
Fixes ING-600
Context
CreditNotes::Refunds::StripeCreateJobsent refunds Stripe can never accept, and sinceApplicationJobsetsretry: 0the job died straight into the dead queue. Two production incidents: a charge with an open dispute (charge_disputed, US) and a charge already refunded outside Lago (charge_already_refunded, EU).Our only guard was
invoice.payment_dispute_lost_at?, written solely bycharge.dispute.closed, and nothing checked how much of the charge was still refundable.Description
Two independent layers:
Stripe::Refund.create, andcharge_disputed/charge_already_refundednow return without raising. This half needs no Stripe-side change and fixes both incidents on deploy.charge.dispute.created/charge.dispute.updatedand record dispute state on the invoice (newinvoices.payment_refund_blocked_at), so the common case costs no API call.The column is named for its effect rather than the event, because it is not a dispute record: an open inquiry leaves it
nil(Stripe still allows refunds there), and a lost dispute leaves it set (refunds stay blocked). It holds current state only: history of past disputes is not modelled, and a charge carrying two concurrent disputes is not ref-counted (the widened rescue is the backstop for both). Because nothing else can clear the flag, the dispute handler reads the dispute's current state from Stripe rather than trusting a payload that may have been replayed out of order, falling back to the payload if Stripe cannot be reached.No new outgoing Lago webhook is added:
config/webhook_event_types.ymlis untouched and there is noinvoice.payment_dispute_openedevent. The column is internal state, and is deliberately not exposed in the v1 serializer, GraphQL,InvoicesQuery, the CSV export or theexports_invoicesview.Every skip path stays visible: the credit note is marked
failed,credit_note.provider_refund_failurefires, and the activity log is produced, exactly as when Stripe rejects the refund today.Two points worth a look in review:
is_charge_refundable, not the event name.charge.dispute.createdalso fires for inquiries, where Stripe still accepts refunds and refunding is the normal way to resolve one. Gating on the event alone would silently block legitimate refunds. This is also why the flag is deliberately not cleared on a lost dispute.Also adds
retry_onfor rate-limit/connection errors to the refund job, which the invoice payment job already has.Deploy
A migration enqueues
PaymentProviders::Stripe::RefreshWebhookJobfor every provider with a secret key, mirroring20250626175249_refresh_stripe_webhooks, so existing Stripe connections pick up the new events on upgrade without anyone running a rake task (rake stripe:refresh_registered_webhooksremains available to re-run it). The pre-check keeps the guard correct until then, and for self-hosted installs that never run it.