diff --git a/docs/checkout.md b/docs/checkout.md new file mode 100644 index 0000000..9f486b8 --- /dev/null +++ b/docs/checkout.md @@ -0,0 +1,155 @@ +# 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. diff --git a/src/Checkout/Events/BillingAddressSet.php b/src/Checkout/Events/BillingAddressSet.php new file mode 100644 index 0000000..9dd48b4 --- /dev/null +++ b/src/Checkout/Events/BillingAddressSet.php @@ -0,0 +1,20 @@ +cart->currentOrCreate()->setShippingAddress($address); + + Event::dispatch(new ShippingAddressSet($cart, $address)); + + return $cart; + } + + public function setBillingAddress(array|Addressable $address): Cart + { + $cart = $this->cart->currentOrCreate()->setBillingAddress($address); + + Event::dispatch(new BillingAddressSet($cart, $address)); + + return $cart; + } + + /** + * Every shipping option currently available for the cart — already + * fully backed by the merged Shipping-Carriers work: this runs every + * registered Lunar\Shipping\Interfaces\ShippingRateInterface driver + * (ACS/Box Now live-rate quoting alongside table-rate-shipping's own + * flat-rate/free-shipping/collection drivers) through + * ShippingManifest's pipeline. No rate-resolution logic lives here — + * this is a thin pass-through. + * + * @return Collection + */ + public function getShippingOptions(): Collection + { + return ShippingManifest::getOptions($this->cart->currentOrCreate()); + } + + /** + * @throws InvalidShippingOptionException if $identifier doesn't resolve + * to a real, currently-available option for the cart + */ + public function selectShippingOption(string $identifier): Cart + { + $cartBefore = $this->cart->currentOrCreate(); + $option = ShippingManifest::getOption($cartBefore, $identifier); + + if ($option === null) { + throw new InvalidShippingOptionException($identifier); + } + + $cart = $cartBefore->setShippingOption($option); + + Event::dispatch(new ShippingOptionSelected($cart, $option)); + + return $cart; + } + + /** + * $fingerprint is mandatory, not optional — the caller must prove the + * cart total the shopper last saw (Cart::fingerprint()) still matches + * before an order is placed. Cart::checkFingerprint() throws Lunar's own + * FingerprintMismatchException on a mismatch (a line's price changed, + * stock adjusted the total, another tab modified the cart) rather than + * silently placing an order at a different total than what was shown. + * + * No exception wrapping: Lunar\Validation\Cart\ValidateCartForOrderCreation + * (run inside Cart::createOrder()) already throws + * Lunar\Exceptions\Carts\CartException with a field-keyed MessageBag + * ($exception->errors()) for address/shipping-option validation and the + * duplicate-order guard — already the right shape for a storefront to + * render as form errors directly. FingerprintMismatchException + * propagates the same way, for the same reason. + * + * @throws \Lunar\Exceptions\FingerprintMismatchException + * @throws \Lunar\Exceptions\Carts\CartException + */ + public function placeOrder(string $fingerprint): Order + { + $cart = $this->cart->currentOrCreate(); + $cart->checkFingerprint($fingerprint); + + $order = $cart->createOrder(); + + Event::dispatch(new OrderPlaced($order)); + + return $order; + } +}