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;
|
2026-08-31 13:54:20 +03:00
|
|
|
use Modules\Core\Checkout\Contracts\PaymentDriver;
|
2026-08-29 01:14:58 +03:00
|
|
|
use Modules\Core\Checkout\Events\BillingAddressSet;
|
|
|
|
|
use Modules\Core\Checkout\Events\OrderPlaced;
|
2026-08-31 13:54:20 +03:00
|
|
|
use Modules\Core\Checkout\Events\PaymentMethodSelected;
|
2026-08-29 01:14:58 +03:00
|
|
|
use Modules\Core\Checkout\Events\ShippingAddressSet;
|
|
|
|
|
use Modules\Core\Checkout\Events\ShippingOptionSelected;
|
|
|
|
|
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
|
2026-08-31 13:54:20 +03:00
|
|
|
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
2026-08-31 14:12:21 +03:00
|
|
|
use Modules\Core\Payment\Models\PaymentMethod;
|
2026-08-29 01:14:58 +03:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*
|
2026-08-31 14:12:21 +03:00
|
|
|
* Not called directly by a storefront — see confirmPayment(), which is
|
|
|
|
|
* the only caller and supplies the fingerprint captured in
|
|
|
|
|
* selectPaymentMethod(), not one the storefront has to obtain itself.
|
|
|
|
|
*
|
2026-08-29 01:14:58 +03:00
|
|
|
* 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;
|
|
|
|
|
}
|
2026-08-31 13:54:20 +03:00
|
|
|
|
2026-08-31 14:12:21 +03:00
|
|
|
/**
|
|
|
|
|
* Every payment type currently offered to the storefront — every key
|
|
|
|
|
* in config('lunar.payments.types') that is BOTH administratively
|
|
|
|
|
* enabled (Modules\Core\Payment\Models\PaymentMethod::enabled) AND
|
|
|
|
|
* whose registered PaymentDriver reports itself usable right now
|
|
|
|
|
* (PaymentDriver::isConfigured() — e.g. Stripe with no API key set is
|
|
|
|
|
* never offered, regardless of the enabled toggle). A type with no
|
|
|
|
|
* PaymentMethod row at all (never seeded) is treated as not offered,
|
|
|
|
|
* same as disabled — nothing here creates one; see
|
|
|
|
|
* InstallLunarCommand::seedPaymentMethods().
|
|
|
|
|
*
|
|
|
|
|
* @return array<string>
|
|
|
|
|
*/
|
|
|
|
|
public function getPaymentMethods(): array
|
|
|
|
|
{
|
|
|
|
|
return PaymentMethod::where('enabled', true)
|
|
|
|
|
->pluck('type')
|
|
|
|
|
->filter(fn (string $type) => $this->resolvePaymentDriver($type)?->isConfigured() ?? false)
|
|
|
|
|
->values()
|
|
|
|
|
->all();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-31 13:54:20 +03:00
|
|
|
/**
|
|
|
|
|
* 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
|
2026-08-31 14:12:21 +03:00
|
|
|
* adjustments before recalculation.
|
|
|
|
|
*
|
|
|
|
|
* Also snapshots Cart::fingerprint() into meta, *after* saving the
|
|
|
|
|
* chosen type — the fingerprint has to reflect the final total
|
|
|
|
|
* including any payment-type-specific adjustment (e.g. a COD
|
|
|
|
|
* surcharge), which only exists once payment_method is set and the
|
|
|
|
|
* cart recalculates. Captured here, server-side, rather than asked of
|
|
|
|
|
* the storefront: this is the last moment before confirmPayment() that
|
|
|
|
|
* the shopper's reviewed total is known, and confirmPayment() reads it
|
|
|
|
|
* back internally instead of taking a fingerprint parameter — a
|
|
|
|
|
* storefront should never need to know Cart::fingerprint() exists.
|
|
|
|
|
*
|
2026-08-31 13:54:20 +03:00
|
|
|
* 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.
|
|
|
|
|
*
|
2026-08-31 14:12:21 +03:00
|
|
|
* @throws UnknownPaymentTypeException if $type isn't currently offered
|
|
|
|
|
* — see getPaymentMethods() for what that means (registered,
|
|
|
|
|
* administratively enabled, and its driver reports itself usable)
|
2026-08-31 13:54:20 +03:00
|
|
|
*/
|
|
|
|
|
public function selectPaymentMethod(string $type): Cart
|
|
|
|
|
{
|
2026-08-31 14:12:21 +03:00
|
|
|
if (! in_array($type, $this->getPaymentMethods(), true)) {
|
|
|
|
|
throw new UnknownPaymentTypeException($type);
|
|
|
|
|
}
|
2026-08-31 13:54:20 +03:00
|
|
|
|
|
|
|
|
$cart = $this->cart->currentOrCreate();
|
|
|
|
|
$cart->meta = [...$cart->meta->toArray(), 'payment_method' => $type];
|
|
|
|
|
$cart->save();
|
|
|
|
|
|
2026-08-31 14:12:21 +03:00
|
|
|
$cart = $cart->calculate();
|
|
|
|
|
$cart->meta = [...$cart->meta->toArray(), 'checkout_fingerprint' => $cart->fingerprint()];
|
|
|
|
|
$cart->save();
|
|
|
|
|
|
2026-08-31 13:54:20 +03:00
|
|
|
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).
|
|
|
|
|
*
|
2026-08-31 14:12:21 +03:00
|
|
|
* The fingerprint passed to the driver is the one captured by
|
|
|
|
|
* selectPaymentMethod(), not supplied by the caller — see that
|
|
|
|
|
* method's docblock. Throws the same FingerprintMismatchException a
|
|
|
|
|
* caller-supplied one would if the cart's total has since changed;
|
|
|
|
|
* missing entirely (selectPaymentMethod() was never called for this
|
|
|
|
|
* cart) is treated the same as a mismatch, not a different error.
|
|
|
|
|
*
|
2026-08-31 13:54:20 +03:00
|
|
|
* @param array<string, mixed> $data
|
|
|
|
|
*
|
2026-08-31 14:12:21 +03:00
|
|
|
* @throws UnknownPaymentTypeException if $type isn't currently offered
|
|
|
|
|
* (see getPaymentMethods()) — re-checked here, not just in
|
|
|
|
|
* selectPaymentMethod(), since a type could be disabled between
|
|
|
|
|
* selection and confirmation
|
2026-08-31 13:54:20 +03:00
|
|
|
* @throws \Lunar\Exceptions\FingerprintMismatchException
|
|
|
|
|
* @throws \Lunar\Exceptions\Carts\CartException
|
|
|
|
|
*/
|
2026-08-31 14:12:21 +03:00
|
|
|
public function confirmPayment(string $type, array $data = []): Order
|
2026-08-31 13:54:20 +03:00
|
|
|
{
|
2026-08-31 14:12:21 +03:00
|
|
|
if (! in_array($type, $this->getPaymentMethods(), true)) {
|
|
|
|
|
throw new UnknownPaymentTypeException($type);
|
|
|
|
|
}
|
2026-08-31 13:54:20 +03:00
|
|
|
|
2026-08-31 14:12:21 +03:00
|
|
|
$cart = $this->cart->currentOrCreate();
|
|
|
|
|
$fingerprint = $cart->meta['checkout_fingerprint'] ?? '';
|
|
|
|
|
|
|
|
|
|
return $this->resolvePaymentDriver($type)->confirm($cart, $type, $fingerprint, $data);
|
2026-08-31 13:54:20 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-31 14:12:21 +03:00
|
|
|
* Resolves $type's registered PaymentDriver, or null if $type has no
|
|
|
|
|
* 'payment_driver' registered in config('lunar.payments.types.<type>')
|
|
|
|
|
* at all — deliberately non-throwing so getPaymentMethods() can filter
|
|
|
|
|
* unresolvable types silently rather than treating "not registered"
|
|
|
|
|
* as an error condition when just checking availability.
|
2026-08-31 13:54:20 +03:00
|
|
|
*/
|
2026-08-31 14:12:21 +03:00
|
|
|
private function resolvePaymentDriver(string $type): ?PaymentDriver
|
2026-08-31 13:54:20 +03:00
|
|
|
{
|
|
|
|
|
$driverClass = config("lunar.payments.types.{$type}.payment_driver");
|
|
|
|
|
|
2026-08-31 14:12:21 +03:00
|
|
|
return $driverClass ? app($driverClass) : null;
|
2026-08-31 13:54:20 +03:00
|
|
|
}
|
2026-08-29 01:14:58 +03:00
|
|
|
}
|