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 momentCart::createOrder()is called.Checkout— the placement moment itself: setting addresses, selecting a shipping option, placing the order. Starts where Cart ends, ends the instant anOrderexists. This document.Order— everything after an order exists: status transitions (Order::status, changed via the Filament adminEditOrderpage — always staff-driven, never part of checkout itself), fulfillment/shipment tracking. Named and scoped here, not yet built — same status asRecoverybefore 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:
- No completed order already exists on this cart (duplicate-order guard).
- A billing address is set and passes
country_id/first_name/line_one/city/postcoderequired-field validation. - If the cart
isShippable()(has at least one non-digital line):- A shipping option must already be selected (
Cart::getShippingOption()— which only resolves anything onceshippingAddress->shipping_optionhas been persisted viaselectShippingOption(), confirmed fromLunar\Base\ShippingManifest::getShippingOption()). - Unless that option is collect/pickup (
$shippingOption->collect), a shipping address is also required and validated the same way as billing.
- A shipping option must already be selected (
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::statuschanges via the Filament adminEditOrderpage, never through checkout). Belongs toOrder(see above), notCheckout. - Order confirmation email — needs
OrderPlacedas a trigger, but actual sending is separate infrastructure, same "detection/signal only, sending is a later concern" deferral already applied toRecovery(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
Checkoutas scoped here;CheckoutService::placeOrder()produces anOrder, what happens to pay for it is out of this document's scope.