Files
core/src/Checkout/Services/CheckoutService.php
T

189 lines
7.0 KiB
PHP
Raw Normal View History

<?php
namespace Modules\Core\Checkout\Services;
2026-08-31 13:16:13 +03:00
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Exceptions\Carts\CartException;
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\Contracts\PaymentDriver;
use Modules\Core\Checkout\Events\BillingAddressSet;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Checkout\Events\PaymentMethodSelected;
use Modules\Core\Checkout\Events\ShippingAddressSet;
use Modules\Core\Checkout\Events\ShippingOptionSelected;
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
/**
* 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
*/
public function placeOrder(string $fingerprint): Order
{
$cart = $this->cart->currentOrCreate();
$cart->checkFingerprint($fingerprint);
$order = $cart->createOrder();
Event::dispatch(new OrderPlaced($order));
return $order;
}
/**
* Records which payment type the shopper picked (Cart::meta
* ['payment_method']) — read by e.g. Modules\Core\Payment\Pipelines\
* Cart\ApplyCashOnDeliveryFee to add that type's own cart-total
* adjustments before the shopper reaches placeOrder()/confirmPayment().
* Does not itself call a PaymentDriver — selecting a method and
* confirming payment against it are deliberately separate steps, same
* as selecting a shipping option happens before placing the order.
*
* @throws UnknownPaymentTypeException if $type has no registered
* PaymentDriver (config('lunar.payments.types.<type>.payment_driver'))
*/
public function selectPaymentMethod(string $type): Cart
{
$this->paymentDriverFor($type);
$cart = $this->cart->currentOrCreate();
$cart->meta = [...$cart->meta->toArray(), 'payment_method' => $type];
$cart->save();
Event::dispatch(new PaymentMethodSelected($cart, $type));
return $cart;
}
/**
* Resolves $type's registered PaymentDriver and calls confirm() —
* the driver decides whether/when the order actually gets placed (see
* Modules\Core\Checkout\Contracts\PaymentDriver's docblock). $data
* carries whatever that driver needs (Stripe's payment_intent id, a
* future redirect-based provider's callback payload).
*
* @param array<string, mixed> $data
*
* @throws UnknownPaymentTypeException if $type has no registered driver
* @throws \Lunar\Exceptions\FingerprintMismatchException
* @throws \Lunar\Exceptions\Carts\CartException
*/
public function confirmPayment(string $type, string $fingerprint, array $data = []): Order
{
$driver = $this->paymentDriverFor($type);
return $driver->confirm($this->cart->currentOrCreate(), $fingerprint, $data);
}
/**
* @throws UnknownPaymentTypeException
*/
private function paymentDriverFor(string $type): PaymentDriver
{
$driverClass = config("lunar.payments.types.{$type}.payment_driver");
if (! $driverClass) {
throw new UnknownPaymentTypeException($type);
}
return app($driverClass);
}
}