Skip to content

fix(payments): cancel abandoned 3DS challenges - #6402

Open
brunomiguelpinto wants to merge 1 commit into
mainfrom
bil-670
Open

brunomiguelpinto wants to merge 1 commit into
mainfrom
bil-670

Conversation

@brunomiguelpinto

@brunomiguelpinto brunomiguelpinto commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Context

Reported by a customer whose end customer has invoices that cannot be paid through any Lago path. Details, affected accounts and measured volumes are in the Linear ticket.

When an off-session charge fails with authentication_required, the retry drops off_session and error_on_requires_action so Stripe can offer a 3DS challenge (stripe/payments/create_service.rb:184). The PaymentIntent then sits in requires_action, which is a PROCESSING_STATUS, so the invoice is saved with ready_for_payment_processing = false.

If the customer never completes the challenge, nothing in Lago ever moves it again:

  • Stripe does not expire a PaymentIntent created through the API.
  • PaymentIntents::ExpireService expires the Lago record and the Checkout Session, never the PaymentIntent, and ExpirePaymentIntentsJob only runs on a provider settings change.
  • The only reset is a payment_intent.canceled / payment_intent.payment_failed webhook, which requires the merchant to go into the Stripe Dashboard and cancel an intent that Lago created and owns.

The invoice is unreachable through every path at once

Path Result
Invoices::Payments::RetryService:22 payment_processor_is_currently_handling_payment
DunningCampaigns::ProcessCustomerService:74 silently excluded
PaymentRequests::CreateService:87 invoices_not_ready_for_payment_processing
Pay invoice button disabled

And it is worse than the flag alone suggests: index_payments_on_payable_id_and_payable_type is a unique partial index over (payable_id, payable_type) where payable_payment_status IN ('pending','processing'). The stuck payment holds the invoice's only payment slot, so no replacement payment can be created even if the flag were reset by hand. Cancelling the stuck payment is the only way back.

Description

An hourly clock job treats an authentication challenge older than 24 hours as abandoned, cancels it at the provider, and makes the invoice payable again. The first pass also recovers the invoices that are already stuck.

It reuses the existing provider-agnostic PaymentProviders::CancelPaymentService, whose only caller until now was the gated-subscription flow. After the cancellation, Stripe's own payment_intent.canceled webhook lands the definitive status: the invoice becomes failed with ready_for_payment_processing = true, which is what puts it back into dunning and retry. This PR only lifts the lock locally, so that a webhook which never arrives cannot leave the invoice stuck; it deliberately does not write failed itself, which would duplicate the webhook and invent a status we have not confirmed.

requires_action is not only 3DS, and the stored next action is stale

Two traps, and the measured data says both are real.

The same status covers payments that are waiting on money rather than on the customer: a customer_balance wire sits on display_bank_transfer_instructions for days, ACH on verify_with_microdeposits, and Boleto/OXXO/Konbini on their own display details. The repo's own integration spec proves they are indistinguishable by status alone — simple_payment_integration_spec.rb:240 shows a customer_balance payment at status = requires_action, payable_payment_status = processing, ready_for_payment_processing = false.

And provider_payment_data cannot settle it either: it is a snapshot written when the intent was created and never refreshed, so it can be months out of date. Measured against production, provider_payment_method_data is additionally empty on every stuck row, so there is no local way at all to tell a card challenge from a wire.

So the provider is asked directly. PaymentProviders::Stripe::Payments::CheckAbandonedAuthenticationService retrieves the intent with the payment method expanded, and a payment is only abandoned when the live intent is still requires_action, on a card, with an authentication next action. Cards are the only method that does 3DS, which also closes the redirect-based APMs (iDEAL, Bancontact, Link, crypto) that share redirect_to_url. The stored next action stays only as a cheap candidate filter, so the hourly scan does not call the provider for every wire in flight.

The abandon window is measured from the last update, not creation

A payment row is reused across attempts while it is still pendingInvoices::Payments::CreateService does a find_or_create_by! on that status. Its created_at can therefore be months older than the challenge it currently carries, and selecting on it would cancel a challenge raised minutes ago on the very next tick. Entering requires_action is the last write the row takes, so updated_at is the time of that transition; any later write only pushes the deadline out, which is the safe direction. Covered by a regression spec. Thanks @TiagoLago for catching this.

Why 24 hours

There is no provider-side timeout to copy — that absence is the bug. The two anchors are that the 3DS challenge itself expires at the issuer within minutes, and that hosted payment sessions converge on 24h (Stripe Checkout Sessions default and cap there). Lago already picked the same window for its own intents: PaymentIntent#expires_at defaults to 24.hours.from_now. It is a single constant, Payment::AUTHENTICATION_ABANDON_PERIOD.

The job runs at *:00, ahead of dunning at *:45, so a freed invoice is collectable within the same hour.

Payment-gated subscriptions are skipped

Those already cancel their own abandoned payment through Subscriptions::ActivationRules::CancelService, on the subscription clock. Skipping them also keeps this job from triggering a gated activation resolution as a side effect.

Changes

File
app/models/payment.rb AUTHENTICATION_ABANDON_PERIOD, AUTHENTICATION_NEXT_ACTIONS, abandoned_at_authentication scope
app/services/payment_providers/stripe/payments/check_abandoned_authentication_service.rb asks the provider what the intent is doing now
app/services/invoices/payments/cancel_abandoned_service.rb cancels at the provider, lifts the lock
app/jobs/invoices/payments/cancel_abandoned_job.rb payments / providers queue
app/jobs/clock/cancel_abandoned_payments_job.rb unique :until_executed
clock.rb hourly at *:00
migration partial index on updated_at, added CONCURRENTLY, so the hourly scan is not a seq scan over payments

Rollout note

The first tick cancels every invoice that is already stuck. The measured backlog is large enough that this means a burst of provider calls, the same number of webhooks coming back, and then that many invoices entering dunning and retry at once across several organizations. It stays within Stripe's rate limits, but it means real charges and real dunning emails going out at once to end customers who have heard nothing for months. This needs a deliberate rollout — a first-run limit, or organization by organization — rather than a quiet deploy. Figures are in the Linear ticket.

Out of scope

  • Payment requests. They reach the identical state through the same CreatePaymentFactory, and PaymentRequests::Payments::CreateService#update_invoices_payment_status propagates ready_for_payment_processing = false to every invoice in the request, so one abandoned challenge locks the request and all of its invoices. Measured volume is non-trivial; kept out to keep this reviewable and tracked separately.
  • RetryService cancelling and reopening online, which is what the customer explicitly asked for. It changes visible UI/API behaviour and deserves its own PR.
  • A one-off backfill. Probably unnecessary, since the first run of this job picks up the stuck invoices anyway.

Testing

98 examples, 0 failures. RuboCop clean.

  • spec/models/payment_spec.rb — scope boundaries: age, payable type, payment status, next_action type, and a reused row carrying a fresh challenge
  • spec/services/payment_providers/stripe/payments/check_abandoned_authentication_service_spec.rb — card challenge, SDK challenge, wire, non-card redirect, completed since, no payment method, unreadable intent
  • spec/services/invoices/payments/cancel_abandoned_service_spec.rb — cancel and unlock, payment status left untouched, bank transfer and ACH microdeposits left alone, provider refusing the cancellation, already paid, voided, gated activation, non-invoice payable
  • spec/jobs/clock/cancel_abandoned_payments_job_spec.rb, spec/jobs/invoices/payments/cancel_abandoned_job_spec.rb

Fixes BIL-670.

@brunomiguelpinto brunomiguelpinto self-assigned this Sep 14, 2026
@lago-claude-ai-agent

Copy link
Copy Markdown
Contributor

Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green

HOLD — the selector treats every Stripe requires_action payment as an abandoned 3DS authentication.

  • Restrict expiration to true authentication next actions: existing customer-balance invoices also create requires_action/processing payments with display_bank_transfer_instructions, so the current scope would cancel legitimate awaiting-funds intents after 24 hours and unlock the invoice for another attempt.
  • Add coverage proving non-authentication requires_action payments, at least Stripe customer-balance payments, are not selected or canceled.

@lago-claude-ai-agent

Copy link
Copy Markdown
Contributor

Automated pre-review (advisory, not a required check) — verdict: PASS · CI green

PASS — The scan is narrowly limited to stale invoice payments awaiting an interactive Stripe action, and the service rechecks lifecycle and gated-activation state before cancelling. The specs cover selection exclusions, job delegation, provider no-op behavior, and unlocking only after cancellation moves the payment out of processing.

Non-blocking: the ItemMetadata and Organization annotation-only changes are unrelated to this behavior.

@brunomiguelpinto brunomiguelpinto changed the title fix(payments): cancel abandoned 3DS challenges fix(payments): cancel abandoned authentication challenges Sep 14, 2026
Comment thread app/models/payment.rb Outdated

scope :abandoned_at_authentication, lambda {
where(payable_type: "Invoice", status: "requires_action", payable_payment_status: :processing)
.where(created_at: ..AUTHENTICATION_ABANDON_PERIOD.ago)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This uses the row's created_at as the start of the 3DS abandonment window, but payment rows can be reused across attempts while they are still in payable_payment_status = pending (Invoices::Payments::CreateService does a find_or_create_by! on that status before Stripe updates the row). If an old pending row is retried today and only then moves to requires_action, the hourly scan will treat it as already older than 24h and cancel a fresh authentication challenge almost immediately. The cutoff should be based on when the payment entered requires_action (for example the updated_at from that transition or a dedicated timestamp), or the retry path needs to create a fresh row for the challenge.

## Context

When an off-session charge fails with `authentication_required`, the retry
drops `off_session` so the provider can offer a 3D Secure challenge. The
resulting PaymentIntent sits in `requires_action` until the end customer
completes it. When they never do, nothing in Lago ever moves it: providers do
not expire a PaymentIntent created through the API, and the only reset is a
`payment_intent.canceled` webhook, which requires the merchant to cancel an
intent that Lago created and owns from the provider's own dashboard.

While that payment is stuck the invoice is unreachable through every path at
once. `ready_for_payment_processing` stays false, so retries, dunning and
payment requests all refuse it and the Pay invoice button is disabled. The
unique partial index on pending and processing provider payments also reserves
the invoice's only payment slot, so no replacement payment can be created even
if the flag were reset by hand. Cancelling the stuck payment is the only way
back, and the volume of invoices locked this way is significant.

## Description

An hourly clock job treats an authentication challenge older than 24 hours as
abandoned, cancels it at the provider and makes the invoice payable again. The
window matches the expiry Lago already applies to its own payment intents, and
the challenge expires at the issuer long before that, so nothing completable is
cut short. It runs ahead of dunning so a freed invoice is collectable within
the same hour.

`requires_action` alone is not enough to identify an abandoned challenge. The
same status covers payments that are waiting on money rather than on the
customer: an incoming wire, ACH microdeposit verification, and offline vouchers
all sit there legitimately for days. Nor is the stored next action reliable,
since it is a snapshot written when the intent was created and never refreshed.
So before cancelling anything the provider is asked what the intent is doing
now, and only a card still awaiting an authentication next action is treated as
abandoned. Everything else is left to arrive.

The abandon window is measured from the payment's last update rather than its
creation, because a payment row is reused across attempts while it is still
pending. Its creation time can be months older than the challenge it currently
carries, which would have a fresh challenge cancelled on the following tick.

Cancelling reuses the existing provider-agnostic cancellation service, which
already tolerates an intent that moved on without us. The invoice payment
status is left alone: the provider's own cancellation webhook is what lands the
definitive status, and writing it here as well would duplicate that and invent
a status we have not confirmed. Payments that gate a subscription activation
are skipped, as those already have their own cancellation on the subscription
clock.

A partial index keeps the hourly scan off a full table scan of payments.
@brunomiguelpinto brunomiguelpinto changed the title fix(payments): cancel abandoned authentication challenges fix(payments): cancel abandoned 3DS challenges Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants