diff --git a/src/Http/Controllers/CheckoutController.php b/src/Http/Controllers/CheckoutController.php index 69d88c2..92ea8dc 100644 --- a/src/Http/Controllers/CheckoutController.php +++ b/src/Http/Controllers/CheckoutController.php @@ -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; @@ -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 @@ -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) { @@ -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 @@ -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(); } @@ -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'], @@ -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, @@ -1163,6 +1235,7 @@ public function pay(Request $request, CheckoutSessionModel $session, CheckoutDri } $checkoutDriver->assertReadyForPayment($session, $data['fingerprint']); + $this->rememberPinnedSession($session); } catch (PaymentConfirmationException $e) { // A stale fingerprint or a Pay pressed before the contact step is // routine customer behaviour; every other reason (unorderable diff --git a/src/Listeners/CompleteSessionOnPaymentSuccess.php b/src/Listeners/CompleteSessionOnPaymentSuccess.php index 1f7ac51..2d0b4ef 100644 --- a/src/Listeners/CompleteSessionOnPaymentSuccess.php +++ b/src/Listeners/CompleteSessionOnPaymentSuccess.php @@ -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; @@ -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 { @@ -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; + } } diff --git a/tests/Feature/ContactInformationTest.php b/tests/Feature/ContactInformationTest.php index 2536261..ef8a1a1 100644 --- a/tests/Feature/ContactInformationTest.php +++ b/tests/Feature/ContactInformationTest.php @@ -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; @@ -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); diff --git a/tests/Feature/PayBoundaryTest.php b/tests/Feature/PayBoundaryTest.php index ff9246a..67ad35f 100644 --- a/tests/Feature/PayBoundaryTest.php +++ b/tests/Feature/PayBoundaryTest.php @@ -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'); +}); diff --git a/tests/Feature/WebhookFirstPlacementTest.php b/tests/Feature/WebhookFirstPlacementTest.php new file mode 100644 index 0000000..52c5988 --- /dev/null +++ b/tests/Feature/WebhookFirstPlacementTest.php @@ -0,0 +1,246 @@ +id, 'secret_'.$cart->id); + } +} + +class WebhookFirstIntentMethod extends AbstractPaymentMethod +{ + public function handle(): string + { + return 'webhook-first-card'; + } + + public function label(): string + { + return 'Card'; + } + + public function driver(): string + { + return 'webhook-first-intents'; + } + + public function requiresIntent(): bool + { + return true; + } + + public function component(): string + { + return 'stripe-card'; + } +} + +/** + * Swap the checkout driver for a proxy whose snapshot() first runs $race. + * The listener calls snapshot() between its locked read and its guarded + * update, so this exercises the guarded update's lost-race fallback (in + * production the row lock serialises the other writer instead). + */ +function raceTheListener(Closure $race): void +{ + $real = app(CheckoutDriver::class); + $proxy = Mockery::mock($real)->makePartial(); + $proxy->shouldReceive('snapshot')->andReturnUsing(function (CheckoutSession $session) use ($real, $race) { + $race($session); + + return $real->snapshot($session); + }); + + app()->instance(CheckoutDriver::class, $proxy); +} + +/** + * The gateway placed an order for the cart (its webhook authorised a payment + * the customer confirmed client-side) and announced it the way every gateway + * driver does. + */ +function gatewayPlacesOrder(Order $order, bool $success = true): void +{ + $order->update(['placed_at' => now()]); + + PaymentAttemptEvent::dispatch(new PaymentAuthorize( + success: $success, + orderId: $order->id, + paymentType: 'stripe', + )); +} + +it('completes a session the customer never pinned when the gateway takes payment for its cart', function () { + Event::fake([OrderPlacing::class]); + + $cart = CheckoutCart::orderable(); + CartSession::use($cart); + $session = app(CheckoutDriver::class)->resolveOrCreateSession($cart); + + expect($session->status)->toBeInstanceOf(Open::class); + + $order = $cart->createOrder(); + gatewayPlacesOrder($order); + + $session->refresh(); + + expect($session->status)->toBeInstanceOf(Completed::class) + ->and((string) $session->order_reference)->toBe((string) $order->id) + ->and($session->meta['completed_without_pay_step'] ?? null)->toBeTrue(); + + Event::assertDispatchedTimes(OrderPlacing::class, 1); + Event::assertDispatched(OrderPlacing::class, fn (OrderPlacing $event): bool => $event->order->id === $order->id); +}); + +it('leaves an open session alone when the gateway reports a failed payment', function () { + $cart = CheckoutCart::orderable(); + CartSession::use($cart); + $session = app(CheckoutDriver::class)->resolveOrCreateSession($cart); + + gatewayPlacesOrder($cart->createOrder(), success: false); + + expect($session->refresh()->status)->toBeInstanceOf(Open::class); +}); + +it('completes only once when the gateway replays the success', function () { + Event::fake([OrderPlacing::class]); + + $cart = CheckoutCart::orderable(); + CartSession::use($cart); + $session = app(CheckoutDriver::class)->resolveOrCreateSession($cart); + + $order = $cart->createOrder(); + gatewayPlacesOrder($order); + gatewayPlacesOrder($order->fresh()); + + expect($session->refresh()->status)->toBeInstanceOf(Completed::class); + + Event::assertDispatchedTimes(OrderPlacing::class, 1); +}); + +it('adopts the payment even when the open session has expired', function () { + $cart = CheckoutCart::orderable(); + CartSession::use($cart); + $session = app(CheckoutDriver::class)->resolveOrCreateSession($cart); + $session->forceFill(['expires_at' => now()->subMinute()])->save(); + + $order = $cart->createOrder(); + gatewayPlacesOrder($order); + + expect($session->refresh()->status)->toBeInstanceOf(Completed::class) + ->and((string) $session->order_reference)->toBe((string) $order->id); +}); + +it('keeps the payment method chosen before the gateway took payment', function () { + $cart = CheckoutCart::orderable(); + CartSession::use($cart); + $session = app(CheckoutDriver::class)->resolveOrCreateSession($cart); + $session->forceFill(['meta' => ['payment_method' => 'stripe']])->save(); + + gatewayPlacesOrder($cart->createOrder()); + + $session->refresh(); + + expect($session->meta['payment_method'] ?? null)->toBe('stripe') + ->and($session->meta['completed_without_pay_step'] ?? null)->toBeTrue() + ->and($session->cart_fingerprint)->not->toBeNull() + ->and($session->payment_processing_at)->not->toBeNull(); +}); + +it('never adopts an open hold-mode session', function () { + $cart = CheckoutCart::orderable(); + CartSession::use($cart); + $session = app(CheckoutDriver::class)->resolveOrCreateSession($cart); + $session->forceFill(['meta' => ['payment_intent_mode' => 'hold']])->save(); + + gatewayPlacesOrder($cart->createOrder()); + + expect($session->refresh()->status)->toBeInstanceOf(Open::class); +}); + +it('lets the browser that created the intent reach the confirmation after a webhook-first completion', function () { + Payments::extend('webhook-first-intents', fn () => app(WebhookFirstIntentGateway::class)); + app(PaymentMethodRegistry::class)->add(WebhookFirstIntentMethod::class); + + $session = CheckoutCart::session(CheckoutCart::orderable()); + + $this->postJson(route('lunar.checkout.payment-intent.store', $session->uuid), [ + 'payment_method' => 'webhook-first-card', + ])->assertSuccessful(); + + // The customer confirms with the gateway directly; the webhook places + // the order before the browser's first processing poll. + $cart = Cart::find($session->cart_reference); + gatewayPlacesOrder($cart->createOrder()); + $session->refresh(); + + expect($session->status)->toBeInstanceOf(Completed::class); + + $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('still completes when the pay step pins the session first, without flagging it', function () { + Log::spy(); + + $cart = CheckoutCart::orderable(); + CartSession::use($cart); + $session = app(CheckoutDriver::class)->resolveOrCreateSession($cart); + + raceTheListener(fn (CheckoutSession $s) => CheckoutSession::query()->whereKey($s->getKey()) + ->update(['status' => PaymentProcessing::$name, 'cart_fingerprint' => $s->cart_fingerprint])); + + gatewayPlacesOrder($cart->createOrder()); + $session->refresh(); + + expect($session->status)->toBeInstanceOf(Completed::class) + ->and($session->meta['completed_without_pay_step'] ?? null)->toBeNull(); + + Log::shouldNotHaveReceived('warning'); +}); + +it('leaves a session alone when another process ends it first', function () { + Log::spy(); + + $cart = CheckoutCart::orderable(); + CartSession::use($cart); + $session = app(CheckoutDriver::class)->resolveOrCreateSession($cart); + + raceTheListener(fn (CheckoutSession $s) => CheckoutSession::query()->whereKey($s->getKey()) + ->update(['status' => Cancelled::$name])); + + gatewayPlacesOrder($cart->createOrder()); + + expect($session->refresh()->status)->toBeInstanceOf(Cancelled::class); + + Log::shouldNotHaveReceived('warning'); +});