# Checkout — Design Notes **Status: design finalized, not yet built.** This is the design spec for `Modules\Core\Checkout\Services\CheckoutService`, plus the three-stage lifecycle model it's part of. Nothing in this document is implemented yet. --- ## Three-stage lifecycle: Cart → Checkout → Order Each stage is its own concern, not a phase inside a shared one — matching the pattern already established this session (`Recovery` was split out from `Cart` specifically because abandonment detection is a different lifecycle stage than line-item mutation, even though it reads `Cart` state). - **`Cart`** — line items, coupons, save-for-later (`docs/cart.md`). Ends the moment `Cart::createOrder()` is called. - **`Checkout`** — the placement moment itself: setting addresses, selecting a shipping option, placing the order. Starts where Cart ends, ends the instant an `Order` exists. This document. - **`Order`** — everything after an order exists: status transitions (`Order::status`, changed via the Filament admin `EditOrder` page — always staff-driven, never part of checkout itself), fulfillment/shipment tracking. **Named and scoped here, not yet built** — same status as `Recovery` before it existed as real code. `Modules\Core\Checkout\Events\OrderPlaced` (see below) is the handoff point: `Checkout` dispatches it the moment an order exists; `Order`'s own listeners (not built yet) would be what reacts to it — e.g. sending a confirmation email, initializing whatever `Order` needs to initialize. `Checkout` itself has no opinion about what happens after `OrderPlaced` fires. ### Where `Order` would likely absorb work that currently lives under `Shipping` `Modules\Core\Shipping`'s `Shipment`/`ShipmentInfo` models are already order-scoped (`Shipment::order(): BelongsTo`), and `PollShipmentTrackingJob`/ `ShipmentStatusUpdatedByCarrier` are fulfillment/tracking concerns that happen entirely after an order exists — conceptually closer to `Order` than to `Shipping`'s actual job (carrier rate quoting, `ShippingRateInterface` drivers, `ShippingManifest`). Not decided whether/when this gets moved; noted here so the boundary is visible when `Order` is actually scoped. --- ## `CheckoutService` Mirrors `Modules\Core\Cart\Services\CartService`'s shape (see `docs/cart.md`) — one boboko-owned API a storefront calls, keeping Lunar's own `Cart`/`ShippingManifest` primitives an implementation detail. | Method | Wraps | Dispatches | |---|---|---| | `setShippingAddress(array\|Addressable $address)` | `Cart::setShippingAddress()` | `ShippingAddressSet($cart, $address)` | | `setBillingAddress(array\|Addressable $address)` | `Cart::setBillingAddress()` | `BillingAddressSet($cart, $address)` | | `getShippingOptions()` | `ShippingManifest::getOptions($cart)` | — (read-only) | | `selectShippingOption(string $identifier)` | `Cart::setShippingOption()` | `ShippingOptionSelected($cart, $option)` — throws `InvalidShippingOptionException` if `$identifier` doesn't resolve | | `placeOrder(string $fingerprint)` | `Cart::checkFingerprint()` then `Cart::createOrder()` | `OrderPlaced($order)` | ### `getShippingOptions()` — already fully backed by the merged Shipping-Carriers work `ShippingManifest::getOptions($cart)` runs every registered `ShippingRateInterface` driver through a pipeline — this already includes ACS/Box Now live-rate quoting (`Modules\Core\Shipping\Carriers\Acs\AcsRateDriver`/`BoxNowRateDriver`, merged from the `Shipping-Carriers` branch) alongside `table-rate-shipping`'s own flat-rate/free-shipping/ collection drivers. `CheckoutService` doesn't need to build any rate-resolution logic — it's a thin pass-through to what already exists and works. ### `placeOrder()` — fingerprint check is mandatory, not optional `placeOrder(string $fingerprint): Order` requires the fingerprint the shopper's last-seen cart total was built from (`Cart::fingerprint()`) as a parameter — not an optional after-the-fact check a caller might forget. `Cart::checkFingerprint()` throws Lunar's own `FingerprintMismatchException` if the cart's contents/total changed since that fingerprint was generated (a line's price changed, stock adjusted the total, another tab modified the cart), forcing re-confirmation instead of silently placing an order at a different total than what the shopper approved. ### No exception wrapping — same reasoning as `CartService` Confirmed from source: `Lunar\Validation\Cart\ValidateCartForOrderCreation` (the validator `Cart::createOrder()` runs via `config('lunar.cart.validators.order_create')`) already throws `Lunar\Exceptions\Carts\CartException` with a field-keyed `MessageBag` (`$exception->errors()`) — billing/shipping address completeness, missing shipping option, duplicate-order guard. This is already the right shape for a storefront to catch and render as form errors directly; wrapping it in a boboko-owned exception type would add indirection with identical semantics, the same call made for `CartService`'s cart-line exceptions. `FingerprintMismatchException` (from the mandatory fingerprint check above) propagates as-is for the same reason. **One genuine exception to this rule**: `selectShippingOption()` throws `Modules\Core\Checkout\Exceptions\InvalidShippingOptionException` when `$identifier` doesn't resolve to a real option (`ShippingManifest::getOption()` just returns `null` — Lunar has no matching exception type here to propagate, unlike `CartException`/`FingerprintMismatchException` above). Same reasoning as `Modules\Core\Cart\Exceptions\InvalidCouponException` for `Discounts::validateCoupon()`, which also just returns a bool with nothing to reuse. Confirmed live: an invalid identifier previously returned the cart unchanged with no signal at all — fixed to throw instead, verified via a real container test. ### Validated from source: the real precondition chain `ValidateCartForOrderCreation::validate()`, read directly from `vendor/lunarphp/core`: 1. No completed order already exists on this cart (duplicate-order guard). 2. A billing address is set and passes `country_id`/`first_name`/`line_one`/`city`/`postcode` required-field validation. 3. If the cart `isShippable()` (has at least one non-digital line): - A shipping option must already be selected (`Cart::getShippingOption()` — which only resolves anything once `shippingAddress->shipping_option` has been persisted via `selectShippingOption()`, confirmed from `Lunar\Base\ShippingManifest::getShippingOption()`). - Unless that option is collect/pickup (`$shippingOption->collect`), a shipping address is also required and validated the same way as billing. This is why `CheckoutService`'s methods exist in the order they're listed above — a storefront checkout flow has to drive them roughly in that sequence for `placeOrder()` to ever succeed. --- ## Events — richer payload than `CartService`'s, deliberately `Modules\Core\Checkout\Events`: `ShippingAddressSet`, `BillingAddressSet`, `ShippingOptionSelected`, `OrderPlaced`. Unlike `CartService`'s events (which carry a plain `Cart`/`CartLine` model reference — see `docs/cart.md`), these carry richer, already-resolved payload — e.g. `ShippingOptionSelected` includes the resolved `ShippingOption` (name, price, carrier identifier), not just the string identifier a listener would have to re-resolve. Deliberate divergence from `CartService`'s convention: a live-priced shipping quote or a submitted address is meaningfully more expensive/awkward for a listener to re-derive later than a `CartLine` model reference is. **Why this matters beyond `Checkout` itself:** the Analytics survey (`docs/scratch/ analytics-feature-survey.html`) found conversion-funnel tracking (product view → add to cart → checkout → purchase) entirely missing, with zero underlying data captured anywhere. The Checkout survey separately flagged "abandoned-checkout stage tracking (email captured vs. shipping selected vs. payment started)" as missing. One event per real state transition here — not just a single `OrderPlaced` at the end — is what gives a future analytics/reporting listener (not built) the funnel-stage data neither gap currently has anything to build on. **None of these have a listener yet.** Same status as `CartService`'s events — dispatched, unconsumed, built so something downstream has a hook to attach to. --- ## Explicitly out of scope for `CheckoutService` - **Order-status-changed events** — post-placement, staff-driven (`Order::status` changes via the Filament admin `EditOrder` page, never through checkout). Belongs to `Order` (see above), not `Checkout`. - **Order confirmation email** — needs `OrderPlaced` as a trigger, but actual sending is separate infrastructure, same "detection/signal only, sending is a later concern" deferral already applied to `Recovery` (`docs/recovery-strategies.md`). - **Guest order tracking/lookup** — a separate storefront feature, not part of the placement flow itself. - **Payment** — authorizing/capturing a transaction against the placed order. Genuinely separate from `Checkout` as scoped here; `CheckoutService::placeOrder()` produces an `Order`, what happens to pay for it is out of this document's scope.