Repository navigation
Refund when completion throws anything after capture - #7
Merged
Merged
Conversation
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.
TheAaronLawrence
approved these changes
Nov 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Spec 0010 says no charge survives without an order.
ReconcileCheckoutSession::completeOrRefund()only kept that promise forPaymentConfirmationException. Any other exception fromcomplete()after the money was captured skipped the refund. Examples: core's order build failing, anOrderPlacinglistener throwing, a database fault. This gap came up while working on #6.What went wrong on
mainOnce the throw escapes
completeOrRefund():lunar:checkout:reconcile) aborts inside itseach()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.Change
Spec first:
spec/0010-cart-session-reconciliation.mdsection E.2 point 3 and the acceptance checks describe all of the following.completeOrRefund()catches any\Throwablefromcomplete()and reports it first.captureHold()is idempotent per reference.moneyBack().moneyBack()returns the money under the session row lock (lockForUpdateon the session model's own connection). The lock is held until the session is back inOpen. Under the lock:Completed: a concurrent completion committed first, so it returnscompletedand returns nothing.PaymentProcessing, or re-pinned against a different intent: returnsnot-applicable.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.requires_captureintent), or refunds captured money, then reopens.CheckoutCompletionFailedfires only after the reopen commits. Attempts are counted before any listener runs, and only while the session is stillPaymentProcessing.LunarCheckoutDriver::complete()'s guarded write now accepts onlyPaymentProcessingon the async path and onlyOpenon the sync path. A webhook completion that read the session asPaymentProcessingbefore 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:
voided, for a policy-mode authorisation that was voided rather than refunded.releasePaymentreportsreleased: truefor it.Review history
This had three rounds of fresh-agent review.
3965316:PaymentProcessing;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.mainhas 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=completere-runscomplete()and throws again if the cause is deterministic;--resolve=refundrefunds a placed order;--resolve=cancelworks but leaves noorder_referenceto 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 toOpenreturnsnot-applicablewithout a refund. Not a regression;mainhas the same gap.If the database commit fails after the gateway refund, the session stays in
PaymentProcessingwith refunded money.mainhas 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.phpcovers:PaymentHoldTestcovers:Mutation checks (disabling the placed-order guard, unscoping it, dispatching before counting the attempt) each fail their test.
Checks
composer pintapplied.composer test: 327 passed, 1 skipped, PHPStan no errors.