2026-08-29 01:14:58 +03:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace Modules\Core\Checkout\Services;
|
|
|
|
|
|
2026-08-31 13:16:13 +03:00
|
|
|
use Lunar\Exceptions\FingerprintMismatchException;
|
|
|
|
|
use Lunar\Exceptions\Carts\CartException;
|
2026-08-29 01:14:58 +03:00
|
|
|
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.
|
|
|
|
|
*
|
2026-08-31 13:16:13 +03:00
|
|
|
* @throws FingerprintMismatchException
|
|
|
|
|
* @throws CartException
|
2026-08-29 01:14:58 +03:00
|
|
|
*/
|
|
|
|
|
public function placeOrder(string $fingerprint): Order
|
|
|
|
|
{
|
|
|
|
|
$cart = $this->cart->currentOrCreate();
|
|
|
|
|
$cart->checkFingerprint($fingerprint);
|
|
|
|
|
|
|
|
|
|
$order = $cart->createOrder();
|
|
|
|
|
|
|
|
|
|
Event::dispatch(new OrderPlaced($order));
|
|
|
|
|
|
|
|
|
|
return $order;
|
|
|
|
|
}
|
|
|
|
|
}
|