Feature: Adding Checkout Services and Events

This commit is contained in:
2026-08-29 01:14:58 +03:00
parent ca36c31cab
commit 6671c5e9d1
7 changed files with 387 additions and 0 deletions
+20
View File
@@ -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,
) {}
}
+21
View File
@@ -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.");
}
}
+124
View File
@@ -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;
}
}