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/0004-checkout-session.md b/spec/0004-checkout-session.md index 631df06..c48d67a 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,21 @@ 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, 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 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 +364,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 44e8461..a06bac9 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,34 @@ 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. +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 +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 address to billing, then pins) is handed the post-write fingerprint so its pin @@ -299,14 +327,16 @@ 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 + 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 @@ -317,7 +347,18 @@ 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.** 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 @@ -373,6 +414,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 @@ -601,8 +649,14 @@ 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. Any @@ -610,7 +664,9 @@ states (`Expired`, etc.); the table does not duplicate those. captured) path under the session row lock, except a deadlock or lost connection, which retries; a session checkout's own concurrent completion placed is never refunded, and an order the gateway placed since the pin - stalls to the operator instead of being refunded. + stalls to the operator instead of being refunded. 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/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/src/Drivers/LunarCheckoutDriver.php b/src/Drivers/LunarCheckoutDriver.php index b382bc8..c7a2877 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,18 @@ 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 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. */ - $order = $cart->completedOrder ?: $cart->draftOrder ?: $cart->createOrder(); + $draft = $cart->draftOrder; + + $order = $cart->completedOrder + ?? ($draft !== null && ($draft->meta['checkout_fingerprint'] ?? null) === $live->fingerprint ? $draft : null) + ?? $cart->createOrder(); $this->stampContactEmail($session, $cart, $order); @@ -306,55 +314,78 @@ 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). 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 new file mode 100644 index 0000000..e278aca --- /dev/null +++ b/tests/Feature/DraftOrderAtPayGateTest.php @@ -0,0 +1,406 @@ + */ + 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 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(); + $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); +}); + +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); +}); + +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); +}); 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 031ebc6..6ed8dce 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; @@ -99,7 +101,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 @@ -138,7 +144,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; @@ -509,6 +515,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; @@ -627,6 +651,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();