Feature: Adding Checkout Services and Events
This commit is contained in:
@@ -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.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Checkout\Events;
|
||||||
|
|
||||||
|
use Lunar\Base\Addressable;
|
||||||
|
use Lunar\Models\Cart;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched by CheckoutService::setBillingAddress() — see
|
||||||
|
* ShippingAddressSet's docblock for the full reasoning (Lunar dispatches no
|
||||||
|
* checkout-lifecycle events; this feeds funnel-stage tracking, not built
|
||||||
|
* yet).
|
||||||
|
*/
|
||||||
|
class BillingAddressSet
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly Cart $cart,
|
||||||
|
public readonly array|Addressable $address,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Checkout\Events;
|
||||||
|
|
||||||
|
use Lunar\Models\Order;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched by CheckoutService::placeOrder() the moment an Order exists —
|
||||||
|
* the handoff point between Checkout and Order (see docs/checkout.md's
|
||||||
|
* "Three-stage lifecycle"). Checkout has no opinion about what happens
|
||||||
|
* after this fires; Order's own listeners (not built yet — Order is a
|
||||||
|
* named-but-unscoped concern, same status Recovery had before it existed)
|
||||||
|
* would be what reacts to it — e.g. a confirmation email, initializing
|
||||||
|
* order status tracking.
|
||||||
|
*/
|
||||||
|
class OrderPlaced
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly Order $order,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Checkout\Events;
|
||||||
|
|
||||||
|
use Lunar\Base\Addressable;
|
||||||
|
use Lunar\Models\Cart;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched by CheckoutService::setShippingAddress() — Lunar itself
|
||||||
|
* dispatches no checkout-lifecycle events at all (same gap CartService's
|
||||||
|
* events fill for cart mutations; see docs/cart.md). Feeds
|
||||||
|
* abandoned-checkout stage tracking / conversion-funnel analytics (neither
|
||||||
|
* built yet — see docs/checkout.md), which is why $address is carried
|
||||||
|
* directly rather than requiring a listener to re-read it off the cart.
|
||||||
|
*/
|
||||||
|
class ShippingAddressSet
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly Cart $cart,
|
||||||
|
public readonly array|Addressable $address,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Checkout\Events;
|
||||||
|
|
||||||
|
use Lunar\DataTypes\ShippingOption;
|
||||||
|
use Lunar\Models\Cart;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched by CheckoutService::selectShippingOption() — carries the fully
|
||||||
|
* resolved ShippingOption (name, price, carrier identifier), not just the
|
||||||
|
* string identifier the caller passed in. Deliberate divergence from
|
||||||
|
* CartService's events, which carry a plain Cart/CartLine model reference —
|
||||||
|
* a live-priced carrier quote (see docs/checkout.md's note on
|
||||||
|
* ShippingManifest::getOptions() already being backed by the merged
|
||||||
|
* Shipping-Carriers ACS/Box Now live-rate drivers) is meaningfully more
|
||||||
|
* expensive for a listener to re-derive later than a CartLine reference is.
|
||||||
|
*/
|
||||||
|
class ShippingOptionSelected
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly Cart $cart,
|
||||||
|
public readonly ShippingOption $option,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Checkout\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by CheckoutService::selectShippingOption() when the given
|
||||||
|
* identifier doesn't resolve to a real, currently-available ShippingOption
|
||||||
|
* for the cart — Lunar's own ShippingManifest::getOption() just returns
|
||||||
|
* null, it has no matching exception type of its own to reuse here (same
|
||||||
|
* reasoning as Modules\Core\Cart\Exceptions\InvalidCouponException for
|
||||||
|
* Discounts::validateCoupon()).
|
||||||
|
*/
|
||||||
|
class InvalidShippingOptionException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(public readonly string $identifier)
|
||||||
|
{
|
||||||
|
parent::__construct("The shipping option \"{$identifier}\" is not available for this cart.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Checkout\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Lunar\Base\Addressable;
|
||||||
|
use Lunar\DataTypes\ShippingOption;
|
||||||
|
use Lunar\Facades\ShippingManifest;
|
||||||
|
use Lunar\Models\Cart;
|
||||||
|
use Lunar\Models\Order;
|
||||||
|
use Modules\Core\Cart\Services\CartService;
|
||||||
|
use Modules\Core\Checkout\Events\BillingAddressSet;
|
||||||
|
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||||
|
use Modules\Core\Checkout\Events\ShippingAddressSet;
|
||||||
|
use Modules\Core\Checkout\Events\ShippingOptionSelected;
|
||||||
|
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storefront-facing checkout operations, mirroring
|
||||||
|
* Modules\Core\Cart\Services\CartService's shape — one boboko-owned API a
|
||||||
|
* storefront calls, keeping Lunar's own Cart/ShippingManifest primitives an
|
||||||
|
* implementation detail. See docs/checkout.md for the full design —
|
||||||
|
* Checkout is the middle of a three-stage lifecycle (Cart → Checkout →
|
||||||
|
* Order): it owns the placement moment itself (address, shipping selection,
|
||||||
|
* placeOrder()) and ends the instant an Order exists. What happens to that
|
||||||
|
* Order afterward (status transitions, fulfillment) is deliberately out of
|
||||||
|
* scope here — see OrderPlaced's docblock.
|
||||||
|
*
|
||||||
|
* Depends on CartService for cart access rather than reaching into
|
||||||
|
* Lunar\Facades\CartSession directly a second time, so Checkout stays
|
||||||
|
* layered on top of Cart's own service boundary instead of duplicating it.
|
||||||
|
*/
|
||||||
|
class CheckoutService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CartService $cart,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function setShippingAddress(array|Addressable $address): Cart
|
||||||
|
{
|
||||||
|
$cart = $this->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<int, ShippingOption>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user