Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions spec/0010-cart-session-reconciliation.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,32 @@ for before an order is created:
**idempotent keyed on the session `uuid`** (create-or-fetch, §B).
3. **Mismatch or failure → the refund invariant.** If the live cart changed during
the gateway window, or order creation fails (e.g. stock gone under the
`ValidateCartForOrderCreation` re-check):
`ValidateCartForOrderCreation` re-check), the money goes back as below. Any
exception `complete()` throws counts as a failure, not only a confirmation
mismatch: an order build, a placement listener or a database fault that
rolls the completion back leaves money with no order, so it is reported and
resolves here. A deadlock or a lost database connection is the exception:
it records a reconciliation attempt and retries on a later pass (a captured
hold survives the retry, because `captureHold()` is idempotent per
reference). Money goes back
under the **session row lock**, held until the session is back in `Open`:
a concurrent completion either committed first (the session reads
`Completed` and nothing is returned) or waits on its guarded write and then
finds the session no longer `PaymentProcessing`. The async completion's
guarded write accepts only `PaymentProcessing`, and the synchronous one only
`Open`, so a session reopened mid-completion is never completed. Under the
same lock, money stays put when the session was re-pinned against another
intent meanwhile, or when the gateway's own `authorize()` placed an order
for the cart since the pin (the failure came after placement): that records
an attempt and stalls to the operator instead. An attempt is only counted
while the session is still `PaymentProcessing`, and before any listener
runs. Residual: a gateway that places the order in its own transaction
without touching the session row (Stripe's `authorize()` does) can still
commit that placement while the refund is in flight, so "never refunded"
holds against checkout's own completion, not against every gateway's. `CheckoutCompletionFailed`
fires only after the reopen commits. An authorisation that was never
captured (a hold, or a policy-mode `requires_capture` intent) is voided
rather than refunded.
- **Payment not captured** — abort/void the intent via the gateway driver;
only once the void is **gateway-confirmed** does the session transition
`→ Open` (re-synced, `CheckoutCompletionFailed`); the customer re-confirms
Expand Down Expand Up @@ -580,7 +605,12 @@ states (`Expired`, etc.); the table does not duplicate those.
order-creation transaction; a duplicate callback or double-submitted synchronous
completion yields exactly one order; a post-capture mismatch or order-creation
failure refunds the charge and returns the session to `Open`
(`CheckoutCompletionFailed`) — no charge survives without an order.
(`CheckoutCompletionFailed`) — no charge survives without an order. Any
exception from `complete()` takes the same refund (or void, when nothing was
captured) path under the session row lock, except a deadlock or lost
connection, which retries; a session checkout's own concurrent completion
placed is never refunded, and an order the gateway placed since the pin
stalls to the operator instead of being refunded.
- Invalidating a `PaymentProcessing` session voids the intent first; an
unabortable intent leaves the session in `PaymentProcessing` for §E.2 — never a
terminal state with an unresolved charge.
Expand Down
147 changes: 115 additions & 32 deletions src/Actions/ReconcileCheckoutSession.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace Lunar\Checkout\Actions;

use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\DetectsConcurrencyErrors;
use Illuminate\Database\DetectsLostConnections;
use Lunar\Checkout\Contracts\Actions\ReconcilesCheckoutSession;
use Lunar\Checkout\Contracts\CheckoutDriver;
use Lunar\Checkout\Events\CheckoutCompletionFailed;
Expand All @@ -11,13 +13,15 @@
use Lunar\Checkout\Exceptions\PaymentConfirmationException;
use Lunar\Checkout\Models\CheckoutSession;
use Lunar\Checkout\States\CheckoutSession\Cancelled;
use Lunar\Checkout\States\CheckoutSession\Completed;
use Lunar\Checkout\States\CheckoutSession\Open;
use Lunar\Checkout\States\CheckoutSession\PaymentProcessing;
use Lunar\Checkout\Support\PaymentIntentGateway;
use Lunar\Core\Contracts\SupportsPaymentHolds;
use Lunar\Core\Contracts\SupportsPaymentIntents;
use Lunar\Core\Enums\PaymentIntentStatus;
use Lunar\Core\Exceptions\PaymentIntentException;
use Lunar\Core\Models\Order;

/**
* Bounded resolution of a `PaymentProcessing` session (spec 0010 §F): the
Expand All @@ -29,6 +33,9 @@
*/
final class ReconcileCheckoutSession implements ReconcilesCheckoutSession
{
use DetectsConcurrencyErrors;
use DetectsLostConnections;

public function __construct(
private CheckoutDriver $driver,
private PaymentIntentGateway $gateways,
Expand Down Expand Up @@ -64,7 +71,7 @@ public function execute(CheckoutSession $session, ?string $resolve = null): stri
PaymentIntentStatus::Pending => 'pending',

PaymentIntentStatus::Captured,
PaymentIntentStatus::RequiresCapture => $this->completeOrRefund($session, $gateway, $reference),
PaymentIntentStatus::RequiresCapture => $this->completeOrRefund($session, $gateway, $reference, $status),

PaymentIntentStatus::Voided,
PaymentIntentStatus::Failed => $this->reopen($session, 'intent_'.$status->value),
Expand Down Expand Up @@ -99,7 +106,7 @@ public function release(CheckoutSession $session): string
// moved: the customer may keep editing and re-confirm, so
// reopen with the hold intact instead of forcing completion.
if (! ($session->isHoldMode() && $status === PaymentIntentStatus::RequiresCapture)) {
return $this->completeOrRefund($session, $gateway, $reference);
return $this->completeOrRefund($session, $gateway, $reference, $status);
}
}
}
Expand All @@ -120,14 +127,16 @@ public function release(CheckoutSession $session): string

/**
* Captured money resolves to exactly one of: an order, or money back.
* Money back is flavour-aware: an authorise-only hold that was never
* captured voids; anything actually captured (a standard intent, or a
* hold captured by this very call) refunds.
* Money back is flavour-aware: an authorisation that was never captured
* (a hold, or a policy-mode requires_capture intent) voids; anything
* actually captured (a standard intent, or a hold captured by this very
* call) refunds.
*/
private function completeOrRefund(
CheckoutSession $session,
SupportsPaymentIntents $gateway,
string $reference,
PaymentIntentStatus $status,
): string {
// An authorise-only hold is not money moved yet: capture it now, in
// the one idempotent place every completion path funnels through, so
Expand Down Expand Up @@ -161,43 +170,107 @@ private function completeOrRefund(
return 'completed';
} catch (PaymentConfirmationException) {
// The cart no longer matches what the customer paid for.
} catch (\Throwable $e) {
// Any other failure rolled the completion back and left money
// with no order (spec 0010 §E.2). A deadlock or a dropped
// connection is retried on a later pass instead; captureHold()
// is idempotent per reference, so a captured hold survives that.
report($e);

if ($this->causedByConcurrencyError($e) || $this->causedByLostConnection($e)) {
return $this->recordAttempt($session);
}
}

// Money back: captured funds refund; an uncaptured hold voids. A
// hold-mode session that reaches here with $holdCaptured still false
// means $gateway lacked SupportsPaymentHolds (a misconfiguration);
// voiding is still the safe answer.
if ($session->isHoldMode() && ! $holdCaptured) {
return $this->moneyBack($session, $gateway, $reference, $holdCaptured || $status === PaymentIntentStatus::Captured);
}

/**
* Under the session row lock, held until the session is reopened: a
* concurrent completion (the webhook) either committed first, and is
* seen here, or waits on its guarded write and then finds the session
* no longer PaymentProcessing. Events fire only after the reopen commits,
* so a throwing listener cannot roll back a reopen whose money has
* already gone back.
*/
private function moneyBack(
CheckoutSession $session,
SupportsPaymentIntents $gateway,
string $reference,
bool $captured,
): string {
[$outcome, $event] = $session->getConnection()->transaction(function () use ($session, $gateway, $reference, $captured): array {
$locked = CheckoutSession::query()->whereKey($session->getKey())->lockForUpdate()->firstOrFail();

if ($locked->status instanceof Completed) {
return ['completed', null];
}

// Another process reopened it, or reopened and re-pinned it
// against a new intent: this pass's money decision is stale.
if (! $locked->status instanceof PaymentProcessing || $locked->payment_intent_ref !== $reference) {
return ['not-applicable', null];
}

// The gateway's own authorize() placed an order for this cart
// since the pin, before complete() failed: returning the money
// would leave a placed order unpaid. The operator resolves it.
// An order an earlier checkout of the same cart placed does not
// count.
$placedForThisPayment = Order::query()
->where('cart_id', (int) $session->cart_reference)
->whereNotNull('placed_at')
->when($locked->payment_processing_at, fn ($query, $pinnedAt) => $query->where('placed_at', '>=', $pinnedAt))
->exists();

if ($placedForThisPayment) {
return ['order-placed', null];
}

// Nothing was captured, so there is nothing to refund: release
// the authorisation. A hold-mode session with no capture here
// means $gateway lacked SupportsPaymentHolds (a
// misconfiguration); voiding is still the safe answer.
if (! $captured) {
try {
$gateway->voidIntent($reference);
} catch (PaymentIntentException) {
return ['void-failed', new CheckoutCompletionFailed($session, 'void-failed')];
}

$holdMode = $session->isHoldMode();
$this->reopen($session, 'voided');

return ['voided', new CheckoutCompletionFailed($session, $holdMode ? 'hold-voided' : 'voided')];
}

try {
$gateway->voidIntent($reference);
$refund = $gateway->refundIntent(
$reference,
$session->amount_total,
'lunar-checkout-refund:'.$reference,
);
} catch (PaymentIntentException) {
$this->events->dispatch(new CheckoutCompletionFailed($session, 'void-failed'));

return $this->recordAttempt($session);
return ['refund-failed', new CheckoutCompletionFailed($session, 'refund-failed')];
}

$this->reopen($session, 'hold_voided');
$this->events->dispatch(new CheckoutCompletionFailed($session, 'hold-voided'));
$this->reopen($session, 'refunded');

return 'voided';
}
return ['refunded', new CheckoutCompletionFailed($session, 'refunded', $refund->reference)];
});

try {
$refund = $gateway->refundIntent(
$reference,
$session->amount_total,
'lunar-checkout-refund:'.$reference,
);
} catch (PaymentIntentException) {
$this->events->dispatch(new CheckoutCompletionFailed($session, 'refund-failed'));
// Count the attempt before any listener runs, so a throwing
// listener cannot stop the session reaching its stall.
$result = match ($outcome) {
'void-failed', 'refund-failed', 'order-placed' => $this->recordAttempt($session),
default => $outcome,
};

return $this->recordAttempt($session);
if ($event !== null) {
$this->events->dispatch($event);
}

$this->reopen($session, 'refunded');
$this->events->dispatch(new CheckoutCompletionFailed($session, 'refunded', $refund->reference));

return 'refunded';
return $result;
}

private function resolveByOperator(
Expand Down Expand Up @@ -301,9 +374,19 @@ private function recordAttempt(CheckoutSession $session): string
{
$maxAttempts = (int) config('lunar.checkout.reconciliation.max_attempts', 5);

$session->increment('reconciliation_attempts');
// Only a session still awaiting resolution counts an attempt: one a
// concurrent completion or reopen moved on must not raise a stall.
$counted = CheckoutSession::query()
->whereKey($session->getKey())
->where('status', PaymentProcessing::$name)
->increment('reconciliation_attempts');

$session->refresh();

if ($counted === 0) {
return 'not-applicable';
}

if ($session->reconciliation_attempts < $maxAttempts) {
return 'retry';
}
Expand Down
2 changes: 1 addition & 1 deletion src/Contracts/Actions/ReconcilesCheckoutSession.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public function execute(CheckoutSession $session, ?string $resolve = null): stri
* unconfirmable outcome stays frozen for the reconciliation sweep.
*
* Returns a stable outcome code: `released`, `completed`, `refunded`,
* `unconfirmed` or `not-applicable`.
* `voided`, `unconfirmed` or `not-applicable`.
*/
public function release(CheckoutSession $session): string;
}
5 changes: 4 additions & 1 deletion src/Drivers/LunarCheckoutDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,11 @@ public function complete(CheckoutSession $session, ?string $confirmationToken =
* the first. All of it sits in one transaction under the cart lock,
* so nothing observes the session completed without its order.
*/
// Each path completes only from the status it verified: a session
// that reconcile reopened (refunded) while this ran must not be
// completed by the async path that read it PaymentProcessing.
$transitioned = $session->transitionGuarded(
[Open::$name, PaymentProcessing::$name],
[$isSync ? Open::$name : PaymentProcessing::$name],
Completed::$name,
$attributes,
);
Expand Down
3 changes: 2 additions & 1 deletion src/Events/CheckoutCompletionFailed.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
* The §E.2 completion re-verify or order creation failed, or a misdirected
* gateway success event was refunded (spec 0010 §E.2). `$reason` is a stable
* machine code distinguishing `refunded` / `refund-pending` / `refund-failed`
* (plus `verify-mismatch` when nothing was captured yet).
* (plus `verify-mismatch` when nothing was captured yet, and `hold-voided` /
* `voided` / `void-failed` when an uncaptured authorisation was released).
*/
class CheckoutCompletionFailed extends CheckoutSessionEvent
{
Expand Down
2 changes: 1 addition & 1 deletion src/Http/Controllers/CheckoutController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1275,7 +1275,7 @@ public function releasePayment(CheckoutSessionModel $session, ReconcilesCheckout

return response()->json([
'outcome' => $outcome,
'released' => in_array($outcome, ['released', 'refunded'], true),
'released' => in_array($outcome, ['released', 'refunded', 'voided'], true),
// The retry pins against the live fingerprint, so hand it over.
'fingerprint' => $checkoutDriver->fingerprint($session),
]);
Expand Down
Loading
Loading