Skip to content

Refund when completion throws anything after capture - #7

Merged
alecritson merged 4 commits into
mainfrom
edw2-refund-on-any-completion-failure
Nov 4, 2026
Merged

alecritson merged 4 commits into
mainfrom
edw2-refund-on-any-completion-failure

Conversation

@alecritson

@alecritson alecritson commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Why

Spec 0010 says no charge survives without an order. ReconcileCheckoutSession::completeOrRefund() only kept that promise for PaymentConfirmationException. Any other exception from complete() after the money was captured skipped the refund. Examples: core's order build failing, an OrderPlacing listener throwing, a database fault. This gap came up while working on #6.

What went wrong on main

Once the throw escapes completeOrRefund():

  • The processing page returns a 500 to the customer.
  • The reconcile sweep (lunar:checkout:reconcile) aborts inside its each() loop, so no later session is reconciled on that run. No attempt is recorded, so the failing session never stalls and blocks every later sweep.
  • Hold mode: the hold was captured before the throw. The next pass tries to capture it again, fails, and reopens the session with the money kept.

Change

Spec first: spec/0010-cart-session-reconciliation.md section E.2 point 3 and the acceptance checks describe all of the following.

completeOrRefund() catches any \Throwable from complete() and reports it first.

  • A deadlock or lost connection (Laravel's own detectors) records an attempt and retries on a later pass. A hold captured on this pass survives the retry, because captureHold() is idempotent per reference.
  • Everything else goes to moneyBack().

moneyBack() returns the money under the session row lock (lockForUpdate on the session model's own connection). The lock is held until the session is back in Open. Under the lock:

  • Completed: a concurrent completion committed first, so it returns completed and returns nothing.
  • No longer PaymentProcessing, or re-pinned against a different intent: returns not-applicable.
  • The gateway's own authorize() placed an order for the cart since the pin (the failure came after placement): records an attempt so the session stalls to the operator, and returns nothing. An order an earlier checkout of the same cart placed does not count.
  • Otherwise it voids an authorisation that was never captured (a hold, or a policy-mode requires_capture intent), or refunds captured money, then reopens.

CheckoutCompletionFailed fires only after the reopen commits. Attempts are counted before any listener runs, and only while the session is still PaymentProcessing.

LunarCheckoutDriver::complete()'s guarded write now accepts only PaymentProcessing on the async path and only Open on the sync path. A webhook completion that read the session as PaymentProcessing before reconcile reopened it can no longer complete it. This also stops two tabs paying with different methods from completing one session twice.

Small surface changes:

  • New outcome and event reason voided, for a policy-mode authorisation that was voided rather than refunded.
  • releasePayment reports released: true for it.
  • The docblocks list it.

Review history

This had three rounds of fresh-agent review.

  • Round 1 (high): the refund was decided on an unlocked re-read, so it could race a webhook still mid-transaction and refund an order that then got placed. Fixed with the lock and the narrowed guarded write.
  • Round 2 (high): a listener throwing inside the transaction rolled the reopen back after the refund, so a later pass could place the order against refunded money. Fixed by dispatching after commit.
  • Round 2 (high): a refund could hit an order the gateway had already placed. Fixed with the placed-order guard.
  • Smaller fixes from the same rounds: the intent-ref check, and using the model's own connection.
  • Round 3: nothing blocking. Fixed in 3965316:
    • the placed-order guard is scoped to orders placed since the pin;
    • attempts are counted before listeners run, and only on PaymentProcessing;
    • deadlock retry now also covers a captured hold.

Known residuals

  • Stripe's authorize() places the order in its own transaction without touching the session row. So a placement it commits while the refund is in flight is not caught: the order is placed and its money refunded. Timing-dependent. main has the same outcome. The spec now says the guarantee holds against checkout's own completion, not against every gateway's.

  • A session stalled by the placed-order guard has no clean operator exit:

    • --resolve=complete re-runs complete() and throws again if the cause is deterministic;
    • --resolve=refund refunds a placed order;
    • --resolve=cancel works but leaves no order_reference to the paid order.

    A dedicated "adopt placed order" resolution would fix this. Follow-up.

  • The async guarded write checks status but not payment_intent_ref. A stale webhook completion could complete a session that was refunded, reopened and re-pinned against a new intent while it ran. The window is narrow.

  • A hold captured on this pass while the customer's release() moved the session to Open returns not-applicable without a refund. Not a regression; main has the same gap.

  • If the database commit fails after the gateway refund, the session stays in PaymentProcessing with refunded money. main has the equivalent window between its refund and its reopen.

  • CompleteSessionOnPaymentSuccess (the webhook listener) still lets a throw escape. The gateway retries the webhook, and the sweep is the backstop.

  • SQLite ignores lockForUpdate, so the tests prove the guards and the ordering, not real lock contention. The locking argument was checked by hand against Postgres and MySQL (READ COMMITTED and REPEATABLE READ).

Tests

tests/Feature/CompletionFailureRefundTest.php covers:

  • a failed order build refunds and reopens;
  • the sweep survives a failing session;
  • a concurrent completion that already committed is not refunded;
  • a deadlock retries;
  • the async guarded write refuses a reopened session;
  • a throwing listener after the refund leaves the session reopened;
  • a gateway-placed order is never refunded;
  • a re-pinned session is left alone;
  • an older placed order from an earlier checkout still refunds;
  • a listener throwing on a failed refund still counts the attempt.

PaymentHoldTest covers:

  • a captured hold refunds when completion throws;
  • a policy-mode authorisation voids.

Mutation checks (disabling the placed-order guard, unscoping it, dispatching before counting the attempt) each fail their test.

Checks

composer pint applied. composer test: 327 passed, 1 skipped, PHPStan no errors.

completeOrRefund() only caught PaymentConfirmationException. Any other
throw from complete() (an order build failure, a placement listener, a
database fault) left captured money with no order. The exception also
escaped the reconcile sweep, aborting it for every later session. It
never consumed an attempt, so the session never stalled. A captured
hold was re-captured on the next pass and reopened with the money kept.

Every failure now reports and takes the refund (or hold void) path.
A session a concurrent completion already moved to Completed is
re-read first and never refunded. Spec 0010 section E.2 amended first.
…aults

Review of the first cut: a refund decided on an unlocked re-read could
race a webhook completion still mid-transaction and refund a placed
order. Money now goes back under the session row lock, held through the
reopen, and the async completion's guarded write accepts only
PaymentProcessing (sync only Open), so a reopened session is never
completed.

Deadlocks and lost connections retry via recordAttempt unless this pass
captured a hold. An uncaptured policy-mode authorisation is voided, not
refunded. The exception is reported before any early return.
… refund a placed order

Round two of review: a listener throwing inside the money-back
transaction rolled back the reopen after the refund, so the next pass
could place the order against refunded money. Events now dispatch after
commit. Under the lock, money also stays put when the gateway already
placed the cart's order or the session was re-pinned against a new
intent; both record an attempt for the operator. The lock runs on the
session model's own connection.
…tempts first

Round three of review: the placed-order guard matched an order an
earlier checkout of the same cart placed, stalling a refund that should
happen. It now only counts orders placed since the pin. The attempt is
recorded before CompletionFailed listeners run and only while the
session is still PaymentProcessing. Deadlock retry now covers captured
holds too, since captureHold() is idempotent per reference.
@alecritson
alecritson merged commit 5dd2cae into main Nov 4, 2026
5 checks passed
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