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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 20 additions & 12 deletions spec/0004-checkout-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
70 changes: 63 additions & 7 deletions spec/0010-cart-session-reconciliation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 >
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -601,16 +649,24 @@ 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
exception from `complete()` takes the same refund (or void, when nothing was
captured) path under the session row lock, except a deadlock or lost
connection, which retries; a session checkout's own concurrent completion
placed is never refunded, and an order the gateway placed since the pin
stalls to the operator instead of being refunded.
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.
Expand Down
9 changes: 9 additions & 0 deletions src/CheckoutServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading