From 1f2328afe567653bae7bcb3e66fd7ffbb699aea8 Mon Sep 17 00:00:00 2001 From: Alec Ritson Date: Wed, 4 Nov 2026 11:15:09 +0000 Subject: [PATCH 1/5] feat(checkout): draft the order at the pay gate, place it at completion Lunar spec 0077 ("Placement") requires an order to exist before any money is taken. The Lunar driver's assertReadyForPayment() now verifies, pins and drafts the cart's order in one transaction over a locked read of the cart. A failed draft rolls back the pin; a lost pin drafts nothing. complete() now rebuilds the cart's draft with createOrder() instead of adopting it as-is, so a synchronous retry after a refunded card attempt cannot place a draft that predates a cart change. Specs 0010 (section E, E.2, acceptance checks) and 0004 amended first. --- spec/0004-checkout-session.md | 31 +- spec/0010-cart-session-reconciliation.md | 50 +++- src/Drivers/LunarCheckoutDriver.php | 113 +++++--- tests/Feature/DraftOrderAtPayGateTest.php | 337 ++++++++++++++++++++++ tests/Feature/PayBoundaryTest.php | 6 +- tests/Feature/PaymentHoldTest.php | 67 ++++- 6 files changed, 535 insertions(+), 69 deletions(-) create mode 100644 tests/Feature/DraftOrderAtPayGateTest.php diff --git a/spec/0004-checkout-session.md b/spec/0004-checkout-session.md index 631df06..77ad381 100644 --- a/spec/0004-checkout-session.md +++ b/spec/0004-checkout-session.md @@ -43,9 +43,10 @@ model — is backend-neutral and Lunar-owned. Lunar ships a default `LunarChecko > identity, lifecycle, pinned context, non-derivable element data); lines, > addresses and shipping options are **derived through the driver**, never stored. > The `order_type`/`order_id` morph is likewise replaced by a **driver-opaque -> `order_reference`** string, renders never write the session row, and completion -> re-verifies the pinned fingerprint before the order exists -> ([[0010-cart-session-reconciliation]] §D/§E.2). 0010 §A also adds the +> `order_reference`** string, renders never write the session row, the Lunar +> driver drafts the cart's order at the pay boundary, and completion re-verifies +> the pinned fingerprint before that order is placed +> ([[0010-cart-session-reconciliation]] §D/§E/§E.2). 0010 §A also adds the > concurrency/reconciliation columns (`active_cart_reference`, > `payment_processing_at`, `reconciliation_attempts`, `pruned_at`) and the index > set. The rows below are updated to match. @@ -115,7 +116,7 @@ the state machine, the element model — is backend-neutral and Lunar-owned. Two | Verb | Responsibility | |------|----------------| | `createSession($source): CheckoutSession` | **Ingest** an arbitrary source cart → a session. Reads whatever the backend calls a cart (a Lunar `Cart`; or a Statamic simple-product basket; a custom cart — the source type is the driver's concern), produces the **pinned context** (currency, channel, locale) + the initial synced totals (`amount_subtotal`/`amount_total` in minor units) + `cart_fingerprint`, stores the opaque `cart_reference` (no clone, no frozen-line copy — [[0010-cart-session-reconciliation]]), and returns the `uuid`. The default impl is §B | -| `complete(CheckoutSession $session): mixed` | **Finalise** a session → an order. Re-verifies the pinned fingerprint ([[0010-cart-session-reconciliation]] §E.2), creates the order in whatever system the backend owns, and links it via the driver-opaque `order_reference` (§A). MUST be **idempotent keyed on the session `uuid`** (create-or-fetch) — a non-Lunar driver cannot share a transaction with a remote order system ([[0010-cart-session-reconciliation]] §E.2). For the Lunar driver this is §E (Lunar `Order` + `AwaitingPayment → InProcess`); a non-Lunar driver defines its own order creation | +| `complete(CheckoutSession $session): mixed` | **Finalise** a session → a placed order. Re-verifies the pinned fingerprint ([[0010-cart-session-reconciliation]] §E.2), creates or adopts the order in whatever system the backend owns (the Lunar driver adopts the draft its pay boundary made, [[0010-cart-session-reconciliation]] §E), places it, and links it via the driver-opaque `order_reference` (§A). MUST be **idempotent keyed on the session `uuid`** (create-or-fetch) — a non-Lunar driver cannot share a transaction with a remote order system ([[0010-cart-session-reconciliation]] §E.2). For the Lunar driver this is §E (Lunar `Order` + `AwaitingPayment → InProcess`); a non-Lunar driver defines its own order creation | **Why driver-based.** The pinned snapshot is **backend-neutral** — minor-unit totals, a currency, a locale, a content fingerprint — so the session, element model, payment layer, and hosted page behave @@ -265,15 +266,20 @@ element ever touches a backend cart model directly. ### E. Completion -This is the **default `LunarCheckoutDriver`'s `complete`**. On confirmed payment -([[0002-payment-methods-and-driver]]): the session transitions `PaymentProcessing → Completed`, a -Lunar order is created at `AwaitingPayment` (then `→ InProcess` + `placed_at` per 0002), and -`order_reference` is written. (A non-Lunar driver reaches `Completed` the same way but creates its +This is the **default `LunarCheckoutDriver`'s `complete`**. An order exists before any money is +taken: on a gateway method the pay boundary already drafted the cart's Lunar order (`placed_at` +null) in the pin's transaction ([[0010-cart-session-reconciliation]] §E). On confirmed payment +([[0002-payment-methods-and-driver]]): the session transitions `PaymentProcessing → Completed`, +that draft is rebuilt from the verified cart and placed at `AwaitingPayment` (then `→ InProcess` + +`placed_at` per 0002), and `order_reference` is written. The synchronous path has no earlier pin, +so it creates the order (or adopts and rebuilds a draft an earlier failed attempt left) inside +`complete()`. (A non-Lunar driver reaches `Completed` the same way but creates its own order — §"The checkout driver".) -Before the order exists, `complete()` **re-verifies** the live cart against the session's pinned -fingerprint inside the order-creation transaction; a mismatch or creation failure resolves under -the **refund invariant** — no charge survives without an order +Before the order is placed, `complete()` **re-verifies** the live cart against the session's +pinned fingerprint inside the placement transaction; a mismatch or failure resolves under the +**refund invariant**: no charge survives without a placed order. A refunded or voided attempt +leaves the draft unplaced on the cart, and the next Pay reuses it ([[0010-cart-session-reconciliation]] §E.2). Completion is **idempotent and guarded**: the `→ Completed` transition is an atomic guarded write @@ -357,7 +363,8 @@ follow-up. `PaymentProcessing` rejects element stores. - Completion creates exactly one order even under duplicate callbacks **and double-submitted synchronous completions** (atomic guarded transition), re-verifies the pinned fingerprint - before the order insert, writes `order_reference`, and (Lunar driver) transitions the order + before placing the order (on the async path, the draft the pay boundary made), writes + `order_reference`, and (Lunar driver) transitions the order `AwaitingPayment → InProcess`; a post-capture mismatch or creation failure refunds and returns the session to `Open` ([[0010-cart-session-reconciliation]] §E.2). - The active driver resolves by name from `config('lunar.checkout.driver')` (default `lunar`); a diff --git a/spec/0010-cart-session-reconciliation.md b/spec/0010-cart-session-reconciliation.md index 21c47f8..37f86e2 100644 --- a/spec/0010-cart-session-reconciliation.md +++ b/spec/0010-cart-session-reconciliation.md @@ -255,7 +255,7 @@ Only when all pass does the session **pin** — `amount_total` to the live total *and* `cart_fingerprint` to the live fingerprint — and transition to `PaymentProcessing`. The pinned pair is the frozen contract: the amount sent to the gateway is the pinned total, and the pinned fingerprint is what the -completion boundary (§E.2) re-verifies before an order exists. The pin is **one +completion boundary (§E.2) re-verifies before the order is placed. The pin is **one guarded statement** — status, `amount_total`, `amount_subtotal`, `cart_fingerprint`, `payment_intent_ref`, and `payment_processing_at` together, `WHERE id = ? AND status = 'open' AND (expires_at IS NULL OR expires_at > @@ -271,6 +271,24 @@ never `Cart::checkFingerprint()`) + `ValidateCartForOrderCreation`. This is the contract point the payment layer ([[0002-payment-methods-and-driver]]) calls first; 0002 wires it into the payment entry point. +**The order is drafted inside the pin.** An order exists before any money is +taken (Lunar spec 0077, "Placement"). The Lunar driver runs steps 1 to 3 and the +pin inside one database transaction, against one locked read of the cart row +(the same `lockForUpdate()` read `complete()` takes), and once the pin affects +its one row it calls `$cart->createOrder()` in that same transaction. The result +is the cart's **draft order**: built from exactly the cart the pinned +fingerprint covers, with `placed_at` null. If drafting throws, the transaction +rolls back the pin and Pay fails with nothing pinned. If the pin affects zero +rows, the driver throws before drafting, so a lost race, an expiry or a +supersede leaves no new draft. Step 0 runs before the transaction, because its +invalidation voids the gateway intent and must not be rolled back. Drafting is +internal to the Lunar driver's `assertReadyForPayment()`: the `CheckoutDriver` +contract is unchanged, the session stores no order id while `PaymentProcessing` +(`order_reference` is still written only at `→ Completed`), and a non-Lunar +driver may create its order wherever its backend allows, provided §E.2 holds. +`createOrder()` reuses the cart's existing draft and rebuilds it in place, so a +retry never mints a second order and never consumes a discount twice. + **Store verbs on the pay path carry the token too.** A client that writes a fingerprint input moments before paying (the default flow copies the delivery address to billing, then pins) is handed the post-write fingerprint so its pin @@ -299,11 +317,13 @@ out-of-band change) re-syncing the cart only updates what the customer *sees*; nothing authorises a charge except this gate, which always re-checks the live cart against the exact amount the customer just confirmed. -### E.2 The completion boundary — re-verify, then order; no charge without an order +### E.2 The completion boundary: drafted at the pin; re-verify, then place; no charge without an order Lines, addresses, and shipping are derived from the **live** cart at completion (§A), so `complete()` MUST prove the live cart is still the one the customer paid -for before an order is created: +for before the order is placed. On the async path the Lunar driver drafted that +order at the pin (§E); completion adopts the cart's draft rather than creating a +second order, and the synchronous path creates or adopts it inside `complete()`: 1. **Re-verify, under a consistent read.** Inside the same transaction that creates the order, the driver recomputes the live fingerprint and requires it @@ -317,7 +337,10 @@ for before an order is created: checkout's own writes; the consistent read closes the out-of-band writer. Gate 2 (element re-validation + `canCreateOrder`, §E) runs again here on the async path. -2. **Match → order.** Create the order from the consistent-read set; the +2. **Match → place.** Rebuild the cart's draft order from the consistent-read + set (`createOrder()` reuses the draft the pin made, or creates one when there + is none), record the payment on it, and stamp `placed_at`. An order a + webhook-first `authorize()` already placed is adopted as it stands. The guarded `→ Completed` write sets `order_reference` **in the same statement**, after the order exists. The transition is the same atomic guarded write as §E (`WHERE status IN (open, payment_processing)`), which also covers the @@ -348,6 +371,13 @@ for before an order is created: reference when one exists. A non-Lunar driver may substitute its own remediation (a backorder, a manual-review hold) but MUST NOT silently retain a charge that produced no order. + - **The draft survives, unplaced.** Neither a void nor a refund touches the + order the pin drafted: it stays on the cart with `placed_at` null and no + session points at it. The next Pay on the reopened session reuses that same + order, rebuilt from the cart the customer confirms then, so a retry keeps + one order id. A synchronous retry (an offline method, or a total a discount + dropped to zero) rebuilds it the same way inside `complete()`, never placing + a draft that predates a cart change. **`→ Open` return-path hygiene** (normative — every return path, here and in §F): the guarded UPDATE that returns a session to `Open` **clears @@ -576,11 +606,19 @@ states (`Expired`, etc.); the table does not duplicate those. charged exactly that. An out-of-band change that bypasses the store verbs (e.g. a direct `setShippingAddress` from another tab) still changes the fingerprint — the token is a commitment to an exact amount. +- Pay on a gateway method drafts exactly one unplaced order for the cart in the + pin's transaction; a rejected pin (stale token, expired session, a concurrent + Pay that lost) drafts nothing, and a draft that fails to build leaves the + session `Open` and unpinned (§E). - `complete()` re-verifies the live fingerprint against the pinned one inside the - order-creation transaction; a duplicate callback or double-submitted synchronous + order-placement transaction and places the drafted order (same id, with the + payment's `Transaction` rows on it); a webhook-first placement adopts that same + draft; 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. A refunded + or voided completion leaves the draft unplaced, and the next Pay reuses its id; + a hold is captured only after the pin drafted the order. - 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. diff --git a/src/Drivers/LunarCheckoutDriver.php b/src/Drivers/LunarCheckoutDriver.php index 5ab131d..191ad19 100644 --- a/src/Drivers/LunarCheckoutDriver.php +++ b/src/Drivers/LunarCheckoutDriver.php @@ -106,11 +106,12 @@ public function resolveOrCreateSession(mixed $source, array $attributes = []): C } /** - * Spec 0010 §E.2 — re-verify, then order; no charge without an order. - * Synchronous path (`Open`): the pay gate runs here, in the same - * transaction as order creation, against the submitted confirmation token. - * Async path (`PaymentProcessing`): the live cart must still match the - * fingerprint pinned at the gate. Idempotent keyed on the session. + * Spec 0010 §E.2: drafted at the pin; re-verify, then place. Synchronous + * path (`Open`): the pay gate runs here, in the same transaction as order + * creation, against the submitted confirmation token. Async path + * (`PaymentProcessing`): the live cart must still match the fingerprint + * pinned at the gate, which drafted the order. Idempotent keyed on the + * session. */ public function complete(CheckoutSession $session, ?string $confirmationToken = null): mixed { @@ -169,11 +170,12 @@ public function complete(CheckoutSession $session, ?string $confirmationToken = } /* - * Adopt the order the gateway's authorize() path already created - * for this cart (webhook-first race) rather than minting a second - * one; only a cart with no order yet creates here. + * Adopt the order a webhook-first authorize() already placed. + * Otherwise createOrder() reuses the cart's draft (the one the pay + * gate made) and rebuilds it from the cart just verified: a draft + * left by an earlier failed attempt may predate a cart change. */ - $order = $cart->completedOrder ?: $cart->draftOrder ?: $cart->createOrder(); + $order = $cart->completedOrder ?: $cart->createOrder(); $this->stampContactEmail($session, $cart, $order); @@ -303,55 +305,76 @@ public function fingerprint(CheckoutSession $session): string /** * The pay-boundary gate (spec 0010 §E): step 0 (pinned currency/channel), - * token match, Gate 2, then the atomic one-statement pin. + * token match, Gate 2, the atomic one-statement pin, then the cart's + * draft order, so an order exists before any money moves. */ public function assertReadyForPayment(CheckoutSession $session, string $confirmedFingerprint): void { - $cart = $this->resolveCart($session)->calculate(); - $live = $this->buildSnapshot($cart); + // Step 0 runs outside the transaction: a diverged context voids the + // gateway intent and cancels the session, and a rollback must not + // undo that half while the void stands. + $this->assertPinnedContext($session, $this->buildSnapshot($this->resolveCart($session)->calculate())); - $this->assertPinnedContext($session, $live); + $this->db->transaction(function () use ($session, $confirmedFingerprint) { + // Verify, pin and draft from one locked read of the cart, so the + // draft holds exactly what the pinned fingerprint covers. + $cart = Cart::query() + ->whereKey((int) $session->cart_reference) + ->lockForUpdate() + ->first(); - if (! hash_equals($confirmedFingerprint, $live->fingerprint)) { - $this->events->dispatch(new CheckoutPaymentConfirmationFailed($session)); + if ($cart === null) { + throw new CheckoutSessionNotOperableException('The session\'s source cart no longer exists.'); + } - throw new PaymentConfirmationException('fingerprint_mismatch'); - } + $cart->calculate(); + $live = $this->buildSnapshot($cart); - $this->assertPickupPointChosen($cart); - $this->assertShippingOptionChosen($cart); - $this->assertContactKnown($session, $cart); + if (! hash_equals($confirmedFingerprint, $live->fingerprint)) { + $this->events->dispatch(new CheckoutPaymentConfirmationFailed($session)); - if (! $cart->canCreateOrder()) { - throw new PaymentConfirmationException('cart_not_orderable'); - } + throw new PaymentConfirmationException('fingerprint_mismatch'); + } - // The pin: one guarded statement — the expiry predicate lives here, - // not in a pre-check (spec 0010 §E). - $pinned = CheckoutSession::query() - ->whereKey($session->getKey()) - ->where('status', Open::$name) - ->where(fn ($query) => $query->whereNull('expires_at')->orWhere('expires_at', '>', now())) - ->update([ - 'status' => PaymentProcessing::$name, - 'amount_subtotal' => $live->amountSubtotal, - 'amount_total' => $live->amountTotal, - 'cart_fingerprint' => $live->fingerprint, - 'payment_processing_at' => now(), - 'updated_at' => now(), - ]); + $this->assertPickupPointChosen($cart); + $this->assertShippingOptionChosen($cart); + $this->assertContactKnown($session, $cart); - $session->refresh(); + if (! $cart->canCreateOrder()) { + throw new PaymentConfirmationException('cart_not_orderable'); + } - if ($pinned === 1) { - return; - } + // The pin: one guarded statement — the expiry predicate lives here, + // not in a pre-check (spec 0010 §E). + $pinned = CheckoutSession::query() + ->whereKey($session->getKey()) + ->where('status', Open::$name) + ->where(fn ($query) => $query->whereNull('expires_at')->orWhere('expires_at', '>', now())) + ->update([ + 'status' => PaymentProcessing::$name, + 'amount_subtotal' => $live->amountSubtotal, + 'amount_total' => $live->amountTotal, + 'cart_fingerprint' => $live->fingerprint, + 'payment_processing_at' => now(), + 'updated_at' => now(), + ]); + + if ($pinned !== 1) { + $session->refresh(); + + if ($session->isExpired() || ! ($session->status instanceof Open || $session->status instanceof PaymentProcessing)) { + throw new CheckoutSessionNotOperableException('The checkout session is expired or terminal.'); + } - if ($session->isExpired() || ! ($session->status instanceof Open || $session->status instanceof PaymentProcessing)) { - throw new CheckoutSessionNotOperableException('The checkout session is expired or terminal.'); - } + throw new CheckoutSessionConflictException('frozen'); + } - throw new CheckoutSessionConflictException('frozen'); + // Reuses the draft a failed or refunded earlier attempt left + // behind, refreshed from this cart (spec 0010 §E). + $cart->createOrder(); + }); + + $session->refresh(); } /* diff --git a/tests/Feature/DraftOrderAtPayGateTest.php b/tests/Feature/DraftOrderAtPayGateTest.php new file mode 100644 index 0000000..1f3e047 --- /dev/null +++ b/tests/Feature/DraftOrderAtPayGateTest.php @@ -0,0 +1,337 @@ + */ + public static array $refunds = []; + + public function createIntent(Cart $cart): PaymentIntentDescriptor + { + return new PaymentIntentDescriptor('pi_draft_'.$cart->id); + } + + public function fetchIntent(string $reference): PaymentIntentStatus + { + return PaymentIntentStatus::Captured; + } + + public function voidIntent(string $reference): void {} + + public function refundIntent(string $reference, int $amountMinor, string $idempotencyKey): PaymentRefund + { + static::$refunds[] = $reference; + + return new PaymentRefund(success: true, reference: 'refund_'.$reference); + } + + public function authorize(): ?PaymentAuthorize + { + if (! $this->order) { + $this->order = $this->cart->draftOrder()->first() ?: $this->cart->createOrder(); + } + + $this->order->transactions()->create([ + 'success' => true, + 'type' => 'capture', + 'driver' => 'draft-at-pin', + 'amount' => (int) $this->order->total, + 'reference' => $this->data['payment_intent'], + 'status' => 'succeeded', + ]); + + return parent::authorize(); + } +} + +class DraftAtPinCardMethod extends AbstractPaymentMethod +{ + public function handle(): string + { + return 'draft-card'; + } + + public function label(): string + { + return 'Card'; + } + + public function driver(): string + { + return 'draft-at-pin'; + } + + public function requiresIntent(): bool + { + return true; + } + + public function component(): string + { + return 'stripe-card'; + } +} + +class DraftAtPinBankTransferMethod extends DraftAtPinCardMethod +{ + public function handle(): string + { + return 'draft-bank-transfer'; + } + + public function driver(): string + { + return 'offline'; + } + + public function requiresIntent(): bool + { + return false; + } + + public function component(): string + { + return 'offline-notice'; + } +} + +beforeEach(function () { + DraftAtPinGateway::$refunds = []; + + Payments::extend('draft-at-pin', fn () => app(DraftAtPinGateway::class)); + app(PaymentMethodRegistry::class)->add(DraftAtPinCardMethod::class); + app(PaymentMethodRegistry::class)->add(DraftAtPinBankTransferMethod::class); +}); + +function draftAtPinSession(): CheckoutSession +{ + $cart = CheckoutCart::orderable(); + CartSession::use($cart); + $session = CheckoutCart::session($cart); + + test()->postJson(route('lunar.checkout.payment-intent.store', $session->uuid), [ + 'payment_method' => 'draft-card', + ])->assertSuccessful(); + + return $session->refresh(); +} + +function pressPay(CheckoutSession $session, string $method = 'draft-card'): TestResponse +{ + return test()->postJson(route('lunar.checkout.pay', $session->uuid), [ + 'fingerprint' => CheckoutCart::fingerprint($session), + 'payment_method' => $method, + ]); +} + +function sessionCart(CheckoutSession $session): Cart +{ + return Cart::query()->findOrFail((int) $session->cart_reference); +} + +/** + * Changes the cart after the pin. Rebuilding a draft whose line quantity + * changed lazy-loads the order line's order inside core's OrderLineQuantity + * validator, which this suite's strict mode rejects, so the retry tests + * allow lazy loading. + */ +function doubleTheQuantity(CheckoutSession $session): void +{ + Model::preventLazyLoading(false); + + sessionCart($session)->lines()->firstOrFail()->update(['quantity' => 2]); +} + +function orderedQuantity(Order $order): int +{ + return (int) $order->lines()->where('type', '!=', 'shipping')->sum('quantity'); +} + +it('drafts exactly one unplaced order for the cart when Pay pins a gateway method', function () { + $session = draftAtPinSession(); + + pressPay($session)->assertSuccessful()->assertJsonPath('pinned', true); + + $order = Order::query()->sole(); + + expect($session->refresh()->status)->toBeInstanceOf(PaymentProcessing::class) + ->and($order->cart_id)->toBe((int) $session->cart_reference) + ->and($order->placed_at)->toBeNull() + ->and($order->transactions()->count())->toBe(0); +}); + +it('drafts nothing when the cart changed since the customer confirmed it', function () { + $session = draftAtPinSession(); + $staleToken = CheckoutCart::fingerprint($session); + + CheckoutCart::addLine(sessionCart($session)); + + $this->postJson(route('lunar.checkout.pay', $session->uuid), [ + 'fingerprint' => $staleToken, + 'payment_method' => 'draft-card', + ])->assertUnprocessable(); + + expect($session->refresh()->status)->toBeInstanceOf(Open::class) + ->and(Order::query()->count())->toBe(0); +}); + +it('drafts nothing when the session expired before the pin', function () { + $session = draftAtPinSession(); + $session->forceFill(['expires_at' => now()->subMinute()])->save(); + + expect(fn () => app(CheckoutDriver::class)->assertReadyForPayment($session, CheckoutCart::fingerprint($session))) + ->toThrow(CheckoutSessionNotOperableException::class); + + expect(Order::query()->count())->toBe(0); +}); + +it('drafts no second order when a concurrent Pay loses the pin', function () { + $session = draftAtPinSession(); + $token = CheckoutCart::fingerprint($session); + + app(CheckoutDriver::class)->assertReadyForPayment($session, $token); + $draftId = Order::query()->sole()->id; + + // The losing submission loaded the session while it was still Open. + $loser = $session->replicate()->forceFill(['id' => $session->id, 'status' => Open::$name]); + $loser->exists = true; + + expect(fn () => app(CheckoutDriver::class)->assertReadyForPayment($loser, $token)) + ->toThrow(CheckoutSessionConflictException::class); + + expect(Order::query()->pluck('id')->all())->toBe([$draftId]); +}); + +it('leaves the session open when the draft cannot be built', function () { + $session = draftAtPinSession(); + + config()->set('lunar.orders.pipelines.creation', [ + fn () => throw new RuntimeException('order pipeline down'), + ]); + + expect(fn () => app(CheckoutDriver::class)->assertReadyForPayment($session, CheckoutCart::fingerprint($session))) + ->toThrow(RuntimeException::class, 'order pipeline down'); + + $session->refresh(); + + expect($session->status)->toBeInstanceOf(Open::class) + ->and($session->payment_processing_at)->toBeNull() + ->and(Order::query()->count())->toBe(0); +}); + +it('places the drafted order on completion and records the payment on it', function () { + $session = draftAtPinSession(); + pressPay($session)->assertSuccessful(); + $draftId = Order::query()->sole()->id; + + $this->get(route('lunar.checkout.processing', $session->uuid)); + + $session->refresh(); + $order = Order::query()->sole(); + + expect($session->status)->toBeInstanceOf(Completed::class) + ->and($order->id)->toBe($draftId) + ->and((string) $session->order_reference)->toBe((string) $draftId) + ->and($order->placed_at)->not->toBeNull() + ->and($order->transactions()->where('type', 'capture')->pluck('reference')->all())->toBe([$session->payment_intent_ref]); +}); + +it('places the drafted order when the webhook arrives before the browser', function () { + $session = draftAtPinSession(); + pressPay($session)->assertSuccessful(); + $draftId = Order::query()->sole()->id; + + Payments::driver('draft-at-pin') + ->cart(sessionCart($session)->calculate()) + ->withData(['payment_intent' => $session->refresh()->payment_intent_ref]) + ->authorize(); + + $session->refresh(); + $order = Order::query()->sole(); + + expect($session->status)->toBeInstanceOf(Completed::class) + ->and($order->id)->toBe($draftId) + ->and((string) $session->order_reference)->toBe((string) $draftId) + ->and($order->placed_at)->not->toBeNull() + ->and($order->transactions()->count())->toBe(1); +}); + +it('keeps the draft unplaced after a refund and reuses it on the next Pay', function () { + Event::fake([CheckoutCompletionFailed::class]); + + $session = draftAtPinSession(); + pressPay($session)->assertSuccessful(); + $draftId = Order::query()->sole()->id; + + // The cart changes during the gateway window: completion refunds. + doubleTheQuantity($session); + $this->get(route('lunar.checkout.processing', $session->uuid)); + + expect(DraftAtPinGateway::$refunds)->toHaveCount(1) + ->and($session->refresh()->status)->toBeInstanceOf(Open::class) + ->and(Order::query()->sole()->id)->toBe($draftId) + ->and(Order::query()->sole()->placed_at)->toBeNull(); + + pressPay($session)->assertSuccessful(); + + $order = Order::query()->sole(); + + expect($order->id)->toBe($draftId) + ->and($order->placed_at)->toBeNull() + ->and(orderedQuantity($order))->toBe(2); +}); + +it('places the refreshed draft when the retry after a refund pays synchronously', function () { + Event::fake([CheckoutCompletionFailed::class]); + + $session = draftAtPinSession(); + pressPay($session)->assertSuccessful(); + $draftId = Order::query()->sole()->id; + + doubleTheQuantity($session); + $this->get(route('lunar.checkout.processing', $session->uuid)); + + expect($session->refresh()->status)->toBeInstanceOf(Open::class); + + pressPay($session, 'draft-bank-transfer')->assertSuccessful()->assertJsonPath('completed', true); + + $order = Order::query()->sole(); + + expect($order->id)->toBe($draftId) + ->and($order->placed_at)->not->toBeNull() + ->and(orderedQuantity($order))->toBe(2) + ->and((int) $order->total)->toBe((int) sessionCart($session)->calculate()->total->value); +}); diff --git a/tests/Feature/PayBoundaryTest.php b/tests/Feature/PayBoundaryTest.php index 67ad35f..42cd038 100644 --- a/tests/Feature/PayBoundaryTest.php +++ b/tests/Feature/PayBoundaryTest.php @@ -135,7 +135,7 @@ public function driver(): string ->and($session->status)->toBeInstanceOf(PaymentProcessing::class); }); -it('still pins for confirmation when the method needs an intent', function () { +it('pins for confirmation with the order drafted but unplaced when the method needs an intent', function () { app(PaymentMethodRegistry::class)->add(IntentTestMethod::class); $session = CheckoutCart::session(CheckoutCart::orderable()); @@ -148,7 +148,9 @@ public function driver(): string $session->refresh(); expect($session->status)->toBeInstanceOf(PaymentProcessing::class) - ->and(Order::query()->count())->toBe(0); + ->and($session->order_reference)->toBeNull() + ->and(Order::query()->count())->toBe(1) + ->and(Order::query()->first()->placed_at)->toBeNull(); }); it('completes a zero-total cart synchronously even for an intent method', function () { diff --git a/tests/Feature/PaymentHoldTest.php b/tests/Feature/PaymentHoldTest.php index bc60b5c..8aec9b8 100644 --- a/tests/Feature/PaymentHoldTest.php +++ b/tests/Feature/PaymentHoldTest.php @@ -1,5 +1,6 @@ */ public static array $voidedReferences = []; - /** @var array */ + /** @var array}> */ public static array $captureCalls = []; - /** @var array */ + /** @var array}> */ public static array $refundCalls = []; public static int $holdSequence = 0; @@ -98,7 +100,11 @@ public function captureHold(string $reference, int $amountMinor): void throw new PaymentIntentException('fake gateway capture failed'); } - static::$captureCalls[] = ['reference' => $reference, 'amount' => $amountMinor]; + static::$captureCalls[] = [ + 'reference' => $reference, + 'amount' => $amountMinor, + 'unplaced_orders' => Order::query()->whereNull('placed_at')->pluck('id')->all(), + ]; } public function fetchIntent(string $reference): PaymentIntentStatus @@ -137,7 +143,7 @@ class IntentOnlyGateway extends OfflinePayment implements CreatesPaymentIntents, /** @var array */ public static array $voidedReferences = []; - /** @var array */ + /** @var array}> */ public static array $refundCalls = []; public static PaymentIntentStatus $fetchStatus = PaymentIntentStatus::RequiresCapture; @@ -508,6 +514,24 @@ function mintPinnedHoldSessionWithoutHoldSupport(): CheckoutSession ->and($session->status)->toBeInstanceOf(Completed::class); }); +it('has the order drafted before the hold is captured, then places that same order', function () { + $session = mintHoldSession(); + + $this->postJson(route('lunar.checkout.pay', $session->uuid), [ + 'payment_method' => 'fake', + 'fingerprint' => CheckoutCart::fingerprint($session), + ])->assertOk(); + + $draftId = Order::query()->sole()->id; + + $this->get(route('lunar.checkout.processing', $session->uuid)); + + expect(FakeHoldGateway::$captureCalls)->toHaveCount(1) + ->and(FakeHoldGateway::$captureCalls[0]['unplaced_orders'])->toBe([$draftId]) + ->and((string) $session->refresh()->order_reference)->toBe((string) $draftId) + ->and(Order::query()->sole()->placed_at)->not->toBeNull(); +}); + it('does not capture policy-mode requires_capture sessions (pre-existing manual policy)', function () { $session = mintPinnedStandardSession(); FakeHoldGateway::$fetchStatus = PaymentIntentStatus::RequiresCapture; @@ -592,6 +616,41 @@ function mintPinnedHoldSessionWithoutHoldSupport(): CheckoutSession expect($session->refresh()->status)->toBeInstanceOf(Open::class); }); +it('leaves the draft unplaced when completion voids a stray hold, and the retry reuses it', function () { + $session = mintOpenSessionWithIntentOnlyMethod(); + $session->forceFill([ + 'payment_intent_ref' => 'pi_intent_only_live', + 'meta' => ['payment_method' => 'intent-only', 'payment_intent_mode' => 'hold'], + ])->save(); + + $this->postJson(route('lunar.checkout.pay', $session->uuid), [ + 'payment_method' => 'intent-only', + 'fingerprint' => CheckoutCart::fingerprint($session), + ])->assertOk(); + + $draftId = Order::query()->sole()->id; + + // Rebuilding a draft whose line quantity changed lazy-loads inside core's + // OrderLineQuantity validator, which this suite's strict mode rejects. + Model::preventLazyLoading(false); + Cart::query()->findOrFail((int) $session->cart_reference)->lines()->firstOrFail()->update(['quantity' => 2]); + + $this->get(route('lunar.checkout.processing', $session->uuid)); + + expect(IntentOnlyGateway::$voidedReferences)->toBe(['pi_intent_only_live']) + ->and($session->refresh()->status)->toBeInstanceOf(Open::class) + ->and(Order::query()->sole()->id)->toBe($draftId) + ->and(Order::query()->sole()->placed_at)->toBeNull(); + + $this->postJson(route('lunar.checkout.pay', $session->uuid), [ + 'payment_method' => 'intent-only', + 'fingerprint' => CheckoutCart::fingerprint($session), + ])->assertOk(); + + expect(Order::query()->sole()->id)->toBe($draftId) + ->and(Order::query()->sole()->placed_at)->toBeNull(); +}); + it('completes synchronously and voids the hold when a discount drops a hold session to zero', function () { registerFakeHoldGateway(); From 7b8e7dc8832162b14c5c17bdd6a1ba1fcf92b9ff Mon Sep 17 00:00:00 2001 From: Alec Ritson Date: Wed, 4 Nov 2026 11:26:31 +0000 Subject: [PATCH 2/5] fix(checkout): adopt the pinned draft on async completion, rebuild only on sync A rebuild after capture could throw (core order build) outside the refund path. The async draft was built from the cart the fingerprint just matched, so adopting it is exact. Document that unpaid pins leave one unplaced draft per cart. --- spec/0010-cart-session-reconciliation.md | 16 ++++++++++++---- src/Drivers/LunarCheckoutDriver.php | 14 +++++++++----- tests/Feature/DraftOrderAtPayGateTest.php | 17 +++++++++++++++++ 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/spec/0010-cart-session-reconciliation.md b/spec/0010-cart-session-reconciliation.md index 37f86e2..b1e5898 100644 --- a/spec/0010-cart-session-reconciliation.md +++ b/spec/0010-cart-session-reconciliation.md @@ -288,6 +288,10 @@ contract is unchanged, the session stores no order id while `PaymentProcessing` driver may create its order wherever its backend allows, provided §E.2 holds. `createOrder()` reuses the cart's existing draft and rebuilds it in place, so a retry never mints a second order and never consumes a discount twice. +A cart therefore carries at most one draft. A session that pins and is never +paid leaves that draft behind with `placed_at` null; it is not cleaned up when +the session expires or is pruned, so anything listing or counting orders MUST +filter on `placed_at` (core's `Cart::draftOrder()` / `completedOrder()` split). **Store verbs on the pay path carry the token too.** A client that writes a fingerprint input moments before paying (the default flow copies the delivery @@ -337,10 +341,14 @@ second order, and the synchronous path creates or adopts it inside `complete()`: checkout's own writes; the consistent read closes the out-of-band writer. Gate 2 (element re-validation + `canCreateOrder`, §E) runs again here on the async path. -2. **Match → place.** Rebuild the cart's draft order from the consistent-read - set (`createOrder()` reuses the draft the pin made, or creates one when there - is none), record the payment on it, and stamp `placed_at`. An order a - webhook-first `authorize()` already placed is adopted as it stands. The +2. **Match → place.** On the async path, adopt the draft the pin built: the + fingerprint match proves it holds what was paid for, and adopting it runs + no order build that could fail after capture. Only a session with no draft + creates one here. The synchronous path had no pin, so `createOrder()` reuses + any draft an earlier failed attempt left and rebuilds it from the + consistent-read set. Then record the payment on the order and stamp + `placed_at`. An order a webhook-first `authorize()` already placed is + adopted as it stands. The guarded `→ Completed` write sets `order_reference` **in the same statement**, after the order exists. The transition is the same atomic guarded write as §E (`WHERE status IN (open, payment_processing)`), which also covers the diff --git a/src/Drivers/LunarCheckoutDriver.php b/src/Drivers/LunarCheckoutDriver.php index 191ad19..5605ea1 100644 --- a/src/Drivers/LunarCheckoutDriver.php +++ b/src/Drivers/LunarCheckoutDriver.php @@ -170,12 +170,16 @@ public function complete(CheckoutSession $session, ?string $confirmationToken = } /* - * Adopt the order a webhook-first authorize() already placed. - * Otherwise createOrder() reuses the cart's draft (the one the pay - * gate made) and rebuilds it from the cart just verified: a draft - * left by an earlier failed attempt may predate a cart change. + * Adopt the order a webhook-first authorize() already placed. The + * async path adopts the draft the pay gate built from the cart + * whose fingerprint just matched, with no rebuild that could throw + * after capture. The sync path had no pin, so createOrder() reuses + * any draft an earlier failed attempt left and rebuilds it from + * the cart just verified, which may have changed since. */ - $order = $cart->completedOrder ?: $cart->createOrder(); + $order = $cart->completedOrder + ?? ($isSync ? null : $cart->draftOrder) + ?? $cart->createOrder(); $this->stampContactEmail($session, $cart, $order); diff --git a/tests/Feature/DraftOrderAtPayGateTest.php b/tests/Feature/DraftOrderAtPayGateTest.php index 1f3e047..0caf160 100644 --- a/tests/Feature/DraftOrderAtPayGateTest.php +++ b/tests/Feature/DraftOrderAtPayGateTest.php @@ -269,6 +269,23 @@ function orderedQuantity(Order $order): int ->and($order->transactions()->where('type', 'capture')->pluck('reference')->all())->toBe([$session->payment_intent_ref]); }); +it('places the drafted order without rebuilding it once the payment is captured', function () { + $session = draftAtPinSession(); + pressPay($session)->assertSuccessful(); + $draftId = Order::query()->sole()->id; + + config()->set('lunar.orders.pipelines.creation', [ + fn () => throw new RuntimeException('order pipeline down'), + ]); + + $this->get(route('lunar.checkout.processing', $session->uuid)); + + expect($session->refresh()->status)->toBeInstanceOf(Completed::class) + ->and((string) $session->order_reference)->toBe((string) $draftId) + ->and(Order::query()->sole()->placed_at)->not->toBeNull() + ->and(DraftAtPinGateway::$refunds)->toBeEmpty(); +}); + it('places the drafted order when the webhook arrives before the browser', function () { $session = draftAtPinSession(); pressPay($session)->assertSuccessful(); From 838efacdec9b4fc0a7e4ee527e84dd23029ffd75 Mon Sep 17 00:00:00 2001 From: Alec Ritson Date: Wed, 4 Nov 2026 11:58:25 +0000 Subject: [PATCH 3/5] docs(spec): async completion places the pinned draft as built --- spec/0004-checkout-session.md | 3 ++- spec/0010-cart-session-reconciliation.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/spec/0004-checkout-session.md b/spec/0004-checkout-session.md index 77ad381..c48d67a 100644 --- a/spec/0004-checkout-session.md +++ b/spec/0004-checkout-session.md @@ -270,7 +270,8 @@ This is the **default `LunarCheckoutDriver`'s `complete`**. An order exists befo taken: on a gateway method the pay boundary already drafted the cart's Lunar order (`placed_at` null) in the pin's transaction ([[0010-cart-session-reconciliation]] §E). On confirmed payment ([[0002-payment-methods-and-driver]]): the session transitions `PaymentProcessing → Completed`, -that draft is rebuilt from the verified cart and placed at `AwaitingPayment` (then `→ InProcess` + +that draft, verified against the pinned fingerprint and used as built, is placed at +`AwaitingPayment` (then `→ InProcess` + `placed_at` per 0002), and `order_reference` is written. The synchronous path has no earlier pin, so it creates the order (or adopts and rebuilds a draft an earlier failed attempt left) inside `complete()`. (A non-Lunar driver reaches `Completed` the same way but creates its diff --git a/spec/0010-cart-session-reconciliation.md b/spec/0010-cart-session-reconciliation.md index b1e5898..0cd63c4 100644 --- a/spec/0010-cart-session-reconciliation.md +++ b/spec/0010-cart-session-reconciliation.md @@ -330,7 +330,7 @@ order at the pin (§E); completion adopts the cart's draft rather than creating second order, and the synchronous path creates or adopts it inside `complete()`: 1. **Re-verify, under a consistent read.** Inside the same transaction that - creates the order, the driver recomputes the live fingerprint and requires it + places the order, the driver recomputes the live fingerprint and requires it to equal the session's **pinned** fingerprint. The Lunar driver reads the cart row **and** its lines/addresses `FOR UPDATE` **once**, computes the verification fingerprint **from that loaded set**, and builds the order From bb7eb1a37d4d48d8414251ffb2a78544dbd08e0a Mon Sep 17 00:00:00 2001 From: Alec Ritson Date: Wed, 4 Nov 2026 12:38:37 +0000 Subject: [PATCH 4/5] fix(checkout): adopt a draft unrebuilt only when the pin stamped this fingerprint Review: a payment confirmed with no Pay step after a reopen and a cart change reached complete() with the first attempt's draft, which the async path placed as built. The pin now stamps the draft with the checkout fingerprint, and completion adopts it unrebuilt only when that matches the fingerprint it just verified; any other draft is rebuilt by createOrder(). --- spec/0010-cart-session-reconciliation.md | 20 +++++++------ src/Drivers/LunarCheckoutDriver.php | 22 +++++++++------ tests/Feature/DraftOrderAtPayGateTest.php | 34 +++++++++++++++++++++++ 3 files changed, 59 insertions(+), 17 deletions(-) diff --git a/spec/0010-cart-session-reconciliation.md b/spec/0010-cart-session-reconciliation.md index 0cd63c4..3e5a0f0 100644 --- a/spec/0010-cart-session-reconciliation.md +++ b/spec/0010-cart-session-reconciliation.md @@ -341,14 +341,18 @@ second order, and the synchronous path creates or adopts it inside `complete()`: checkout's own writes; the consistent read closes the out-of-band writer. Gate 2 (element re-validation + `canCreateOrder`, §E) runs again here on the async path. -2. **Match → place.** On the async path, adopt the draft the pin built: the - fingerprint match proves it holds what was paid for, and adopting it runs - no order build that could fail after capture. Only a session with no draft - creates one here. The synchronous path had no pin, so `createOrder()` reuses - any draft an earlier failed attempt left and rebuilds it from the - consistent-read set. Then record the payment on the order and stamp - `placed_at`. An order a webhook-first `authorize()` already placed is - adopted as it stands. The +2. **Match → place.** Adopt the cart's draft as built only when the pin + stamped it (`meta.checkout_fingerprint`) with exactly the fingerprint just + verified: then it holds what was paid for, and no order build runs after + capture. Any other draft (one an earlier failed attempt left, or one a + session pinned without the gate never refreshed, such as a payment the + gateway confirmed with no Pay step) is rebuilt by `createOrder()` from the + consistent-read set, which also creates the order when there is none. Then + record the payment on the order and stamp `placed_at`. An order a + webhook-first `authorize()` already placed is adopted as it stands. A + gateway's own `authorize()` that reads `$cart->draftOrder` directly (core + Stripe does) is outside this check: a payment confirmed with no Pay after a + cart change meets the stale draft there, and fixing that belongs in core. The guarded `→ Completed` write sets `order_reference` **in the same statement**, after the order exists. The transition is the same atomic guarded write as §E (`WHERE status IN (open, payment_processing)`), which also covers the diff --git a/src/Drivers/LunarCheckoutDriver.php b/src/Drivers/LunarCheckoutDriver.php index 5605ea1..5cbabfa 100644 --- a/src/Drivers/LunarCheckoutDriver.php +++ b/src/Drivers/LunarCheckoutDriver.php @@ -170,15 +170,17 @@ public function complete(CheckoutSession $session, ?string $confirmationToken = } /* - * Adopt the order a webhook-first authorize() already placed. The - * async path adopts the draft the pay gate built from the cart - * whose fingerprint just matched, with no rebuild that could throw - * after capture. The sync path had no pin, so createOrder() reuses - * any draft an earlier failed attempt left and rebuilds it from - * the cart just verified, which may have changed since. + * Adopt the order a webhook-first authorize() already placed. + * Otherwise adopt the draft as built only when the pay gate built + * it from exactly the cart just verified, so no rebuild runs after + * capture. Any other draft (one an earlier failed attempt left, or + * one a session pinned without the gate never refreshed) predates + * a cart change: createOrder() reuses it and rebuilds it here. */ + $draft = $cart->draftOrder; + $order = $cart->completedOrder - ?? ($isSync ? null : $cart->draftOrder) + ?? ($draft !== null && ($draft->meta['checkout_fingerprint'] ?? null) === $live->fingerprint ? $draft : null) ?? $cart->createOrder(); $this->stampContactEmail($session, $cart, $order); @@ -374,8 +376,10 @@ public function assertReadyForPayment(CheckoutSession $session, string $confirme } // Reuses the draft a failed or refunded earlier attempt left - // behind, refreshed from this cart (spec 0010 §E). - $cart->createOrder(); + // behind, refreshed from this cart (spec 0010 §E). The stamp is + // what completion checks before adopting the draft unrebuilt. + $draft = $cart->createOrder(); + $draft->update(['meta' => array_merge((array) $draft->meta, ['checkout_fingerprint' => $live->fingerprint])]); }); $session->refresh(); diff --git a/tests/Feature/DraftOrderAtPayGateTest.php b/tests/Feature/DraftOrderAtPayGateTest.php index 0caf160..883a0d1 100644 --- a/tests/Feature/DraftOrderAtPayGateTest.php +++ b/tests/Feature/DraftOrderAtPayGateTest.php @@ -352,3 +352,37 @@ function orderedQuantity(Order $order): int ->and(orderedQuantity($order))->toBe(2) ->and((int) $order->total)->toBe((int) sessionCart($session)->calculate()->total->value); }); + +it('rebuilds a stale draft when a payment arrives on a session pinned without the gate', function () { + Event::fake([CheckoutCompletionFailed::class]); + + $session = draftAtPinSession(); + pressPay($session)->assertSuccessful(); + $draftId = Order::query()->sole()->id; + + doubleTheQuantity($session); + $this->get(route('lunar.checkout.processing', $session->uuid)); + + expect($session->refresh()->status)->toBeInstanceOf(Open::class); + + // The gateway took payment with no Pay step: the session is pinned + // against the live cart the way the webhook listener pins it, and the + // draft still holds the cart from the first attempt. + $session->forceFill([ + 'status' => PaymentProcessing::$name, + 'payment_intent_ref' => 'pi_without_pay', + 'payment_processing_at' => now(), + 'cart_fingerprint' => CheckoutCart::fingerprint($session), + 'amount_total' => sessionCart($session)->calculate()->total->value, + ])->save(); + + $this->get(route('lunar.checkout.processing', $session->uuid)); + + $order = Order::query()->sole(); + + expect($session->refresh()->status)->toBeInstanceOf(Completed::class) + ->and($order->id)->toBe($draftId) + ->and($order->placed_at)->not->toBeNull() + ->and(orderedQuantity($order))->toBe(2) + ->and((int) $order->total)->toBe((int) $session->amount_total); +}); From 9270a4956e51389f7205ecabdaede7334313c16d Mon Sep 17 00:00:00 2001 From: Alec Ritson Date: Wed, 4 Nov 2026 12:43:46 +0000 Subject: [PATCH 5/5] feat(checkout): throttle Pay so abandoned drafts cannot exhaust limited coupons Review: drafting at Pay consumes a use of each discount on the cart and nothing gives it back, so pressing Pay and abandoning across fresh carts could exhaust a promotion. The Pay route now uses a checkout-pay limiter (10 a minute, 30 an hour, per IP). Counting the use at placement is a core follow-up. --- routes/web.php | 1 + spec/0010-cart-session-reconciliation.md | 6 ++++++ src/CheckoutServiceProvider.php | 9 +++++++++ tests/Feature/DraftOrderAtPayGateTest.php | 18 ++++++++++++++++++ 4 files changed, 34 insertions(+) diff --git a/routes/web.php b/routes/web.php index cffe011..8f4c879 100644 --- a/routes/web.php +++ b/routes/web.php @@ -88,6 +88,7 @@ // fingerprint of the state the customer confirmed. The gateway's // client-side confirmation happens after this returns. Route::post($path.'/{session}/pay', [CheckoutController::class, 'pay']) + ->middleware('throttle:checkout-pay') ->name('lunar.checkout.pay'); // Customer-initiated unpin after a failed/abandoned gateway diff --git a/spec/0010-cart-session-reconciliation.md b/spec/0010-cart-session-reconciliation.md index 3e5a0f0..fe15e7b 100644 --- a/spec/0010-cart-session-reconciliation.md +++ b/spec/0010-cart-session-reconciliation.md @@ -288,6 +288,12 @@ contract is unchanged, the session stores no order id while `PaymentProcessing` driver may create its order wherever its backend allows, provided §E.2 holds. `createOrder()` reuses the cart's existing draft and rebuilds it in place, so a retry never mints a second order and never consumes a discount twice. +Drafting does consume a use of each discount on the cart (core's +`markAsUsed()`), so an abandoned Pay spends a limited coupon's use and nothing +gives it back. That is an accepted trade-off; the Pay route is throttled +(`checkout-pay`: 10 a minute and 30 an hour per IP) so it cannot be exhausted +cheaply on purpose, and counting the use at placement instead is a core +follow-up. A cart therefore carries at most one draft. A session that pins and is never paid leaves that draft behind with `placed_at` null; it is not cleaned up when the session expires or is pruned, so anything listing or counting orders MUST diff --git a/src/CheckoutServiceProvider.php b/src/CheckoutServiceProvider.php index 7e55130..7b2de3c 100644 --- a/src/CheckoutServiceProvider.php +++ b/src/CheckoutServiceProvider.php @@ -150,6 +150,15 @@ public function boot(): void // other lookups rather than left open to abuse. RateLimiter::for('checkout-shipping-quote', fn (Request $request): Limit => Limit::perMinute(10)->by($request->ip())); + // Pay drafts the order, and drafting consumes a use of any limited + // coupon on the cart (spec 0010 section E). Throttled so pressing Pay + // and abandoning across fresh carts cannot cheaply exhaust a + // promotion; a real customer retries a handful of times at most. + RateLimiter::for('checkout-pay', fn (Request $request): array => [ + Limit::perMinute(10)->by('minute:'.$request->ip()), + Limit::perHour(30)->by('hour:'.$request->ip()), + ]); + $this->mergeConfigFrom(__DIR__.'/../config/checkout.php', 'lunar.checkout'); // Spec 0013 §C: the chosen pickup point rides on the collect diff --git a/tests/Feature/DraftOrderAtPayGateTest.php b/tests/Feature/DraftOrderAtPayGateTest.php index 883a0d1..e278aca 100644 --- a/tests/Feature/DraftOrderAtPayGateTest.php +++ b/tests/Feature/DraftOrderAtPayGateTest.php @@ -386,3 +386,21 @@ function orderedQuantity(Order $order): int ->and(orderedQuantity($order))->toBe(2) ->and((int) $order->total)->toBe((int) $session->amount_total); }); + +it('throttles Pay so abandoned attempts cannot cheaply exhaust a limited coupon', function () { + $session = draftAtPinSession(); + + foreach (range(1, 10) as $attempt) { + $this->postJson(route('lunar.checkout.pay', $session->uuid), [ + 'fingerprint' => 'stale', + 'payment_method' => 'draft-card', + ])->assertUnprocessable(); + } + + $this->postJson(route('lunar.checkout.pay', $session->uuid), [ + 'fingerprint' => CheckoutCart::fingerprint($session), + 'payment_method' => 'draft-card', + ])->assertTooManyRequests(); + + expect(Order::query()->count())->toBe(0); +});