Files
core/docs/checkout.md
T

9.1 KiB

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.