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
73 changes: 73 additions & 0 deletions src/Http/Controllers/CheckoutController.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
use Lunar\Checkout\DataObjects\CheckoutTheme;
use Lunar\Checkout\Events\CheckoutElementStored;
use Lunar\Checkout\Exceptions\AddressLookupException;
use Lunar\Checkout\Exceptions\CheckoutSessionConflictException;
use Lunar\Checkout\Exceptions\CheckoutSessionNotOperableException;
use Lunar\Checkout\Exceptions\PaymentConfirmationException;
use Lunar\Checkout\Exceptions\RollbackQuote;
use Lunar\Checkout\Express;
Expand Down Expand Up @@ -56,6 +58,12 @@

class CheckoutController extends Controller
{
/** Browser-session key listing the checkout sessions this browser pinned for payment. */
private const PINNED_SESSIONS_KEY = 'lunar.checkout.pinned';

/** Seconds a pinned-session ownership grant stays valid. */
private const PINNED_SESSION_TTL = 3600;

/**
* Collaborators are constructor-injected per the service-layer DI rule
* (spec 0016): the element registry and the address lookup are bound to
Expand Down Expand Up @@ -353,6 +361,23 @@ private function ensureOperable(CheckoutSessionModel $session): void
*/
private function ensureOwnership(CheckoutSessionModel $session): void
{
/*
* The browser that pinned this session for payment owns it through
* the gateway round-trip. Checked before CartSession::current(): once
* a webhook has placed the order, current() mints the browser a fresh
* cart, so the cart id no longer proves anything and the guest's
* processing poll would 403 instead of reaching the confirmation.
* The grant lapses after PINNED_SESSION_TTL so a shared browser
* does not keep an earlier guest's completed order reachable.
*/
$pinnedAt = ((array) session()->get(self::PINNED_SESSIONS_KEY, []))[$session->uuid] ?? null;

if (($session->status instanceof PaymentProcessing || $session->status instanceof Completed)
&& is_int($pinnedAt)
&& now()->timestamp - $pinnedAt <= self::PINNED_SESSION_TTL) {
return;
}

$cart = CartSession::current();

if ($cart !== null && (string) $cart->id === $session->cart_reference) {
Expand All @@ -370,6 +395,38 @@ private function ensureOwnership(CheckoutSessionModel $session): void
);
}

/**
* Record in the browser session that this browser pinned the checkout
* session for payment (or holds an intent it can confirm with the
* gateway), so ensureOwnership() still recognises it after a webhook
* completes the order. Keyed by UUID with the time it was recorded;
* only the most recent few are kept.
*/
private function rememberPinnedSession(CheckoutSessionModel $session): void
{
$pinned = (array) session()->get(self::PINNED_SESSIONS_KEY, []);
unset($pinned[$session->uuid]);
$pinned[$session->uuid] = now()->timestamp;

session()->put(self::PINNED_SESSIONS_KEY, array_slice($pinned, -5, preserve_keys: true));
}

/**
* The write gate shared by the routes that write session state directly,
* matching the driver's operableCart(): a session pinned for payment is
* frozen (409); a terminal or expired one is gone (410).
*/
private function ensureWritable(CheckoutSessionModel $session): void
{
if ($session->status instanceof PaymentProcessing) {
throw new CheckoutSessionConflictException('frozen');
}

if (! $session->status instanceof Open || $session->isExpired()) {
throw new CheckoutSessionNotOperableException('The checkout session is expired or terminal.');
}
}

/**
* Project the live checkout session into the prop shape the Vue app
* consumes. Cart figures are read live (read verbs never persist); money
Expand Down Expand Up @@ -656,6 +713,10 @@ public function storeContact(Request $request, CheckoutSessionModel $session, Ch
if ($customerId !== null) {
$checkoutDriver->associateCustomer($session, (string) $customerId, $data['email']);
} else {
// Once the session is pinned for payment the contact email is
// part of what completion stamps on the order.
$this->ensureWritable($session);

$session->customer_email = $data['email'];
$session->save();
}
Expand Down Expand Up @@ -918,6 +979,10 @@ public function storePaymentIntent(Request $request, CheckoutSessionModel $sessi
{
$this->ensureOwnership($session);

// A pinned session's intent is the one being charged; replacing it
// would leave reconciliation polling an unconfirmed intent.
$this->ensureWritable($session);

$data = $request->validate([
'payment_method' => ['required', 'string'],
'mode' => ['sometimes', 'string', 'in:hold'],
Expand Down Expand Up @@ -1040,6 +1105,13 @@ public function storePaymentIntent(Request $request, CheckoutSessionModel $sessi
]);
}

// This browser now holds an intent it can confirm with the gateway
// directly; if the webhook completes the order first, its processing
// poll must still reach the confirmation. The grant's clock starts
// here, so a customer who sits on the card form past the TTL and
// then confirms directly falls back to the cart ownership check.
$this->rememberPinnedSession($session);

return response()->json([
'intent' => $descriptor->reference,
'clientSecret' => $descriptor->clientSecret,
Expand Down Expand Up @@ -1163,6 +1235,7 @@ public function pay(Request $request, CheckoutSessionModel $session, CheckoutDri
}

$checkoutDriver->assertReadyForPayment($session, $data['fingerprint']);
$this->rememberPinnedSession($session);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Webhook-first customers never get the pinned-session ownership grant
Medium · Full review

rememberPinnedSession() is only called from pay(). In the webhook-first flow this PR adds, the customer confirms with Stripe.js and never calls pay(), so their browser session never gets the UUID.

If the webhook arrives before the Stripe.js return_url redirect or the first processing poll, the listener completes the session and CartSession::current() mints a fresh cart. ensureOwnership() then falls through to the cart check and returns 403. That is the same race the PR fixes for the pay() path, and the PR description lists "no confirmation handover" as one of the symptoms being fixed.

Fix: also call rememberPinnedSession($session) in storePaymentIntent() after the intent is created, since the browser holding the intent is the one that can confirm it. Add a test where the listener completes an Open session and the same browser then polls processing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, fixed in 99b8225. storePaymentIntent now records the grant once the intent is created, since that browser can confirm with the gateway directly. New test: lets the browser that created the intent reach the confirmation after a webhook-first completion. It creates an intent through the route, has the gateway place the order, checks the listener completes the session, and then the same browser's poll redirects to success with the handover. It fails without the change.

} catch (PaymentConfirmationException $e) {
// A stale fingerprint or a Pay pressed before the contact step is
// routine customer behaviour; every other reason (unorderable
Expand Down
89 changes: 87 additions & 2 deletions src/Listeners/CompleteSessionOnPaymentSuccess.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

namespace Lunar\Checkout\Listeners;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Lunar\Checkout\Contracts\CheckoutDriver;
use Lunar\Checkout\Models\CheckoutSession;
use Lunar\Checkout\States\CheckoutSession\Open;
use Lunar\Checkout\States\CheckoutSession\PaymentProcessing;
use Lunar\Core\Events\PaymentAttemptEvent;
use Lunar\Core\Models\Order;
Expand All @@ -12,11 +15,17 @@
* The gateway-agnostic success bridge (spec 0002 §D): any gateway driver that
* authorises a payment dispatches PaymentAttemptEvent; when the paid order's
* cart has a session pinned at the pay boundary, the checkout driver completes
* it — session → Completed, order adopted as its order_reference.
* it — session → Completed, order adopted as its order_reference. A session
* still Open (the customer confirmed with the gateway without the pay step)
* is pinned here first; see pinForGatewayPayment().
*
* complete() is idempotent (terminal short-circuit + guarded transition), so
* webhook replays, the reconciliation sweep, and this listener can all race
* safely — exactly one completion wins.
*
* An OrderPlacing listener must not authorise a payment itself: during a
* synchronous complete() the session is still Open, so this listener would
* pin and complete it re-entrantly.
*/
class CompleteSessionOnPaymentSuccess
{
Expand All @@ -40,13 +49,89 @@ public function handle(PaymentAttemptEvent $event): void

$session = CheckoutSession::query()
->where('cart_reference', (string) $cartId)
->where('status', PaymentProcessing::$name)
// At most one: active_cart_reference is unique across live
// (Open / PaymentProcessing) sessions.
->whereIn('status', [PaymentProcessing::$name, Open::$name])
->first();

if ($session === null) {
return;
}

/*
* A hold-mode session stays Open on purpose until the customer
* finishes on the confirm page, which captures at reconcile; it is
* never adopted from here.
*/
if ($session->status instanceof Open && ($session->isHoldMode() || ! $this->pinForGatewayPayment($session))) {
return;
}

$this->driver->complete($session);
}

/**
* The customer confirmed the payment with the gateway directly (e.g.
* Stripe.js) without going through the pay boundary, so the session is
* still Open. The money is already taken, so refusing the order would
* strand it; instead pin the session the way the pay boundary does,
* against the live cart the gateway just charged for, and let complete()
* run its listeners (OrderPlacing, contact email, payment method) like
* any other completion. Expiry is not checked: the payment happened.
*
* Returns false when another process moved the session first.
*/
private function pinForGatewayPayment(CheckoutSession $session): bool
{
/*
* Re-read under a row lock and write the merged meta, so a
* concurrent meta write (intent renewal, payment handle) is not
* lost. Not a JSON-path update: MySQL's JSON_SET on a NULL column
* returns NULL and would drop the flag.
*
* The status moves with the same guarded raw update the pay
* boundary uses (LunarCheckoutDriver::assertReadyForPayment()):
* session states change through these one-statement guards, not
* model transitions. The snapshot is taken under the lock; complete()
* re-verifies it against the cart under the cart-row lock anyway.
*/
$pinned = DB::transaction(function () use ($session): int {
$locked = CheckoutSession::query()
->whereKey($session->getKey())
->lockForUpdate()
->first();

if ($locked === null || ! $locked->status instanceof Open) {
return 0;
}

$live = $this->driver->snapshot($locked);

return CheckoutSession::query()
->whereKey($session->getKey())
->where('status', Open::$name)
->update([
'status' => PaymentProcessing::$name,
'amount_subtotal' => $live->amountSubtotal,
'amount_total' => $live->amountTotal,
'cart_fingerprint' => $live->fingerprint,
'payment_processing_at' => now(),
'meta' => json_encode(array_merge((array) $locked->meta, ['completed_without_pay_step' => true])),
'updated_at' => now(),
]);
});

$session->refresh();

if ($pinned !== 1) {
return $session->status instanceof PaymentProcessing;
}

Log::warning('Checkout session completed from a gateway payment without the pay step.', [
'checkout_session' => $session->uuid,
'cart' => $session->cart_reference,
]);

return true;
}
}
39 changes: 39 additions & 0 deletions tests/Feature/ContactInformationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
use Lunar\Checkout\Contracts\ElementRegistry;
use Lunar\Checkout\Elements\ContactInformation;
use Lunar\Checkout\Session\SessionElementStore;
use Lunar\Checkout\States\CheckoutSession\Completed;
use Lunar\Checkout\States\CheckoutSession\PaymentProcessing;
use Lunar\Core\Facades\CartSession;
use Lunar\Core\Models\Customer;
use Lunar\Tests\Checkout\Stubs\User;
Expand Down Expand Up @@ -114,6 +116,43 @@ function makeUserWithCustomer(): array
->and($session->fresh()->customer_reference)->toBeNull();
});

it('refuses a guest email change once the session is pinned for payment', function () {
$cart = routeTestCart();
$session = app(CheckoutDriver::class)->createSession($cart);
$session->forceFill(['status' => PaymentProcessing::$name, 'customer_email' => 'paying@example.test'])->save();
CartSession::use($cart);

$this->postJson(route('lunar.checkout.contact.store', $session->uuid), ['email' => 'swapped@example.test'])
->assertStatus(409)
->assertJsonPath('reason', 'frozen');

expect($session->fresh()->customer_email)->toBe('paying@example.test');
});

it('refuses a guest email change on a completed session', function () {
$cart = routeTestCart();
$session = app(CheckoutDriver::class)->createSession($cart);
$session->forceFill(['status' => Completed::$name, 'customer_email' => 'paid@example.test'])->save();
CartSession::use($cart);

$this->postJson(route('lunar.checkout.contact.store', $session->uuid), ['email' => 'swapped@example.test'])
->assertStatus(410);

expect($session->fresh()->customer_email)->toBe('paid@example.test');
});

it('refuses a guest email change on an expired session', function () {
$cart = routeTestCart();
$session = app(CheckoutDriver::class)->createSession($cart);
$session->forceFill(['expires_at' => now()->subMinute()])->save();
CartSession::use($cart);

$this->postJson(route('lunar.checkout.contact.store', $session->uuid), ['email' => 'late@example.test'])
->assertStatus(410);

expect($session->fresh()->customer_email)->toBeNull();
});

it('refuses an email with no domain', function (string $route) {
$cart = routeTestCart();
$session = app(CheckoutDriver::class)->createSession($cart);
Expand Down
73 changes: 73 additions & 0 deletions tests/Feature/PayBoundaryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -358,3 +358,76 @@ public function paymentBlocker(CheckoutSession $session, Cart $cart): ?string

expect(Order::query()->count())->toBe(1);
});

it('lets the browser that pinned a session poll it after a webhook completed it', function () {
app(PaymentMethodRegistry::class)->add(IntentTestMethod::class);

$session = CheckoutCart::session(CheckoutCart::orderable());

$this->postJson(route('lunar.checkout.pay', $session->uuid), [
'fingerprint' => CheckoutCart::fingerprint($session),
'payment_method' => 'card',
])->assertSuccessful();

// The gateway webhook wins the race: completion places the order, so the
// next CartSession::current() mints the browser a fresh cart.
app(CheckoutDriver::class)->complete($session->refresh());
$session->refresh();

$this->get(route('lunar.checkout.processing', $session->uuid))
->assertRedirect($session->success_url ?: (config('lunar.checkout.urls.success') ?: '/'))
->assertSessionHas('lunar.checkout.completed.order_reference', $session->order_reference);
});

it('lets a pinned-session ownership grant lapse after an hour', function () {
app(PaymentMethodRegistry::class)->add(IntentTestMethod::class);

$session = CheckoutCart::session(CheckoutCart::orderable());

$this->postJson(route('lunar.checkout.pay', $session->uuid), [
'fingerprint' => CheckoutCart::fingerprint($session),
'payment_method' => 'card',
])->assertSuccessful();

app(CheckoutDriver::class)->complete($session->refresh());

$this->travel(61)->minutes();

$this->get(route('lunar.checkout.processing', $session->uuid))
->assertForbidden();
});

it('still forbids polling a completed session from a browser that never pinned it', function () {
app(PaymentMethodRegistry::class)->add(IntentTestMethod::class);

$session = CheckoutCart::session(CheckoutCart::orderable());
$session->forceFill(['status' => PaymentProcessing::$name])->save();
app(CheckoutDriver::class)->complete($session->refresh());

$this->get(route('lunar.checkout.processing', $session->uuid))
->assertForbidden();
});

it('refuses to create a payment intent for a completed session', function () {
app(PaymentMethodRegistry::class)->add(IntentTestMethod::class);

$session = CheckoutCart::session(CheckoutCart::orderable());
$session->forceFill(['status' => Completed::$name])->save();

$this->postJson(route('lunar.checkout.payment-intent.store', $session->uuid), [
'payment_method' => 'card',
])->assertStatus(410);
});

it('refuses to replace the payment intent of a session pinned for payment', function () {
app(PaymentMethodRegistry::class)->add(IntentTestMethod::class);

$session = CheckoutCart::session(CheckoutCart::orderable());
$session->forceFill(['status' => PaymentProcessing::$name, 'payment_intent_ref' => 'pi_charged'])->save();

$this->postJson(route('lunar.checkout.payment-intent.store', $session->uuid), [
'payment_method' => 'card',
])->assertStatus(409)->assertJsonPath('reason', 'frozen');

expect($session->fresh()->payment_intent_ref)->toBe('pi_charged');
});
Loading
Loading