398 lines
18 KiB
PHP
398 lines
18 KiB
PHP
<?php
|
|
|
|
namespace Modules\Core\Checkout\Services;
|
|
|
|
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 Modules\Core\Cart\Services\CartService;
|
|
use Modules\Core\Checkout\Events\BillingAddressSet;
|
|
use Modules\Core\Checkout\Events\PaymentMethodSelected;
|
|
use Modules\Core\Checkout\Events\RecoveryConsentSet;
|
|
use Modules\Core\Checkout\Events\ShippingAddressSet;
|
|
use Modules\Core\Checkout\Events\ShippingOptionSelected;
|
|
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
|
|
use Modules\Core\Checkout\Exceptions\NoShippingAddressException;
|
|
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
|
|
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
|
use Modules\Core\Payment\Models\PaymentMethod;
|
|
use Modules\Core\Payment\Services\PaymentDriverRegistry;
|
|
use Modules\Core\Payment\Services\PaymentMethodCache;
|
|
|
|
/**
|
|
* 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,
|
|
* ensuring a draft Order exists) and hands off to Payment the instant that
|
|
* draft exists — see initiatePayment(). What happens to that Order
|
|
* afterward (status transitions, fulfillment) is deliberately out of
|
|
* scope here — see docs/payments.md and Checkout\Events\OrderPlaced's
|
|
* docblock for where that now lives.
|
|
*
|
|
* 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,
|
|
private readonly PaymentDriverRegistry $paymentDrivers,
|
|
private readonly PaymentMethodCache $paymentMethods,
|
|
) {}
|
|
|
|
/**
|
|
* Lunar\Actions\Carts\AddAddress (behind Cart::setShippingAddress())
|
|
* always deletes the cart's existing shipping address row and inserts
|
|
* a brand new one — it has no notion of "update in place." Every field
|
|
* on the new row therefore starts blank, including `meta`, which is
|
|
* where selectBoxNowLocker() stores the shopper's chosen locker. Since
|
|
* the checkout page autosaves the address form on every field change
|
|
* (not just once), any edit made after picking a locker — even an
|
|
* unrelated one, like delivery instructions — silently wiped the
|
|
* locker choice by recreating the row out from under it.
|
|
*
|
|
* Carries the previous row's box_now_locker forward onto the new one
|
|
* so the two features don't stomp on each other, without needing
|
|
* Lunar's own AddAddress action to change. The old row's meta is read
|
|
* BEFORE Lunar deletes it, since afterward there's nothing left to
|
|
* read.
|
|
*/
|
|
public function setShippingAddress(array|Addressable $address): Cart
|
|
{
|
|
$cartBefore = $this->cart->currentOrCreate();
|
|
$boxNowLocker = $cartBefore->shippingAddress?->meta['box_now_locker'] ?? null;
|
|
|
|
$cart = $cartBefore->setShippingAddress($address);
|
|
|
|
if ($boxNowLocker !== null) {
|
|
$newAddress = $cart->shippingAddress;
|
|
$newAddress->meta = [...($newAddress->meta?->toArray() ?? []), 'box_now_locker' => $boxNowLocker];
|
|
$newAddress->save();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* The shopper's promotional/abandoned-cart-recovery opt-in — a
|
|
* cart-level decision, deliberately independent of setShippingAddress()/
|
|
* setBillingAddress(): consent is given once, and must NOT be reset or
|
|
* re-asked just because the shopper later changes which address is on
|
|
* the cart (a different Addressable being set is not a withdrawal of
|
|
* consent). Only an explicit call to THIS method — the checkbox itself
|
|
* being submitted, checked or unchecked — ever changes it; calling it
|
|
* again with false is exactly how a later opt-out is recorded.
|
|
*
|
|
* Stored on Cart::meta (interim, per the legal design this implements —
|
|
* a real column/consent record is the eventual target) as
|
|
* recovery_consent (bool), recovery_consent_at (ISO 8601 timestamp,
|
|
* null when $consent is false), and recovery_consent_policy_version
|
|
* (config('legal.privacy_policy_version') at the moment of consent —
|
|
* so a later dispute is answered from what was actually agreed to,
|
|
* not whatever the policy says today). Separate from any future
|
|
* newsletter opt-in — recovery consent is its own scope, never merged
|
|
* with marketing-newsletter consent.
|
|
*
|
|
* Deliberately does not merge with the meta-writing pattern
|
|
* selectPaymentMethod() uses (read-merge-save in two separate
|
|
* statements) — this writes both meta keys in one save, since there's
|
|
* no dependency between recovery_consent and anything else needing to
|
|
* be persisted first.
|
|
*/
|
|
public function setRecoveryConsent(bool $consent): Cart
|
|
{
|
|
$cart = $this->cart->currentOrCreate();
|
|
|
|
$cart->meta = [
|
|
...($cart->meta?->toArray() ?? []),
|
|
'recovery_consent' => $consent,
|
|
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
|
|
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
|
|
];
|
|
$cart->save();
|
|
|
|
Event::dispatch(new RecoveryConsentSet($cart, $consent));
|
|
|
|
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);
|
|
|
|
// Switching away from Box Now leaves a stale box_now_locker on the
|
|
// address's meta (see setShippingAddress()'s own docblock for why
|
|
// it survives address-row recreation) — irrelevant while a
|
|
// different method is selected, but wrong if the shopper later
|
|
// switches BACK to Box Now and it resurfaces as if still chosen,
|
|
// possibly for a locker that no longer exists/fits. Cleared here,
|
|
// the one place that knows the method just changed.
|
|
if ($identifier !== 'box-now') {
|
|
$address = $cart->shippingAddress;
|
|
|
|
if ($address && isset($address->meta['box_now_locker'])) {
|
|
$meta = $address->meta->toArray();
|
|
unset($meta['box_now_locker']);
|
|
$address->meta = $meta;
|
|
$address->save();
|
|
}
|
|
}
|
|
|
|
Event::dispatch(new ShippingOptionSelected($cart, $option));
|
|
|
|
return $cart;
|
|
}
|
|
|
|
/**
|
|
* Records the shopper's chosen Box Now locker on the cart's shipping
|
|
* address (Cart\Addresses::shippingAddress()->meta['box_now_locker']),
|
|
* not on the cart itself — Lunar\Pipelines\Order\Creation\
|
|
* CreateOrderAddresses copies every cart address's full attributes
|
|
* (meta included) onto the new order address when the order is placed,
|
|
* so this is what Modules\Core\Shipping\Carriers\BoxNow\
|
|
* BoxNowFulfillmentService and Modules\Core\Shipping\Extensions\
|
|
* OrderViewExtension already expect to find at
|
|
* $order->shippingAddress->meta['box_now_locker']['locationId'].
|
|
*
|
|
* No validation against Box Now's own /destinations list here — this
|
|
* mirrors setShippingAddress()'s leniency (see its own docblock/the
|
|
* class-level note on required-field enforcement happening at the
|
|
* payment gate, not mid-checkout). An invalid/stale locationId still
|
|
* surfaces later, at BoxNowFulfillmentService::createShipment() time.
|
|
*
|
|
* @throws NoShippingAddressException if the cart has no shipping
|
|
* address yet
|
|
*/
|
|
public function selectBoxNowLocker(array $locker): Cart
|
|
{
|
|
$cart = $this->cart->currentOrCreate();
|
|
$address = $cart->shippingAddress;
|
|
|
|
if (! $address) {
|
|
throw new NoShippingAddressException();
|
|
}
|
|
|
|
$address->meta = [
|
|
...($address->meta?->toArray() ?? []),
|
|
'box_now_locker' => $locker,
|
|
];
|
|
$address->save();
|
|
|
|
return $cart;
|
|
}
|
|
|
|
/**
|
|
* Every payment method currently offered to the storefront, ordered by
|
|
* Modules\Core\Payment\Models\PaymentMethod::position — a row is
|
|
* offered only when ALL three checks pass, each meaning something
|
|
* different to an admin diagnosing why a method isn't showing up (see
|
|
* docs/payments.md):
|
|
* 1. `enabled` — an admin turned it on.
|
|
* 2. its `driver` still resolves via PaymentDriverRegistry — the
|
|
* driver class hasn't been removed (see the `payment:sync-drivers`
|
|
* command, which sets `driver_missing_at` when this fails; a row
|
|
* with that set is excluded here regardless of `enabled`, so a
|
|
* vanished driver can never silently look "available").
|
|
* 3. the resolved driver reports Configurable::isConfigured() — its
|
|
* own runtime requirements (e.g. an API key) are met.
|
|
*
|
|
* @return Collection<int, PaymentMethod>
|
|
*/
|
|
public function getPaymentMethods(): Collection
|
|
{
|
|
return $this->paymentMethods->all()
|
|
->filter(fn (PaymentMethod $method) => $method->enabled && $method->driver_missing_at === null)
|
|
->filter(fn (PaymentMethod $method) => $this->paymentDrivers->resolve($method->driver)?->isConfigured() ?? false)
|
|
->values();
|
|
}
|
|
|
|
/**
|
|
* Records which payment type the shopper picked (Cart::meta
|
|
* ['payment_method']) — read by Modules\Core\Payment\Pipelines\
|
|
* Cart\ApplyPaymentMethodFee to add that method's own `data.fee` (if
|
|
* any) before recalculation.
|
|
*
|
|
* Also snapshots Cart::fingerprint() into meta, *after* saving the
|
|
* chosen type — the fingerprint has to reflect the final total
|
|
* including any payment-method-specific fee, 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 initiatePayment() that the shopper's reviewed total is
|
|
* known, and initiatePayment() reads it back internally instead of
|
|
* taking a fingerprint parameter — a storefront should never need to
|
|
* know Cart::fingerprint() exists.
|
|
*
|
|
* Does not itself call a payment driver — selecting a method and
|
|
* initiating payment against it are deliberately separate steps, same
|
|
* as selecting a shipping option happens before placing the order.
|
|
*
|
|
* @throws UnknownPaymentTypeException if $type isn't currently offered
|
|
* — see getPaymentMethods() for what that means
|
|
*/
|
|
public function selectPaymentMethod(string $type): Cart
|
|
{
|
|
if (! $this->getPaymentMethods()->contains('type', $type)) {
|
|
throw new UnknownPaymentTypeException($type);
|
|
}
|
|
|
|
$cart = $this->cart->currentOrCreate();
|
|
$cart->meta = [...($cart->meta?->toArray() ?? []), 'payment_method' => $type];
|
|
$cart->save();
|
|
|
|
// Cart::calculate() no-ops if this cart instance was already
|
|
// calculated earlier in the request (Cart::isCalculated()) — which
|
|
// it will have been if the shopper switches payment method after
|
|
// the checkout page's first render already calculated it. Without
|
|
// recalculate() forcing a fresh run, the just-saved payment_method
|
|
// (and any fee tied to it, see ApplyPaymentMethodFee) would never
|
|
// be reflected — the summary would keep showing whichever method
|
|
// was calculated first.
|
|
$cart = $cart->recalculate();
|
|
$cart->meta = [...($cart->meta?->toArray() ?? []), 'checkout_fingerprint' => $cart->fingerprint()];
|
|
$cart->save();
|
|
|
|
Event::dispatch(new PaymentMethodSelected($cart, $type));
|
|
|
|
return $cart;
|
|
}
|
|
|
|
/**
|
|
* The one storefront-facing "place this order and pay for it" call —
|
|
* the point where Checkout hands off to Payment. Ensures a draft
|
|
* Order exists (Cart::createOrder() — confirmed idempotent against a
|
|
* cart's own pre-existing, not-yet-placed-at draft; see
|
|
* vendor/lunarphp/core/src/Actions/Carts/CreateOrder.php), then
|
|
* resolves the payment method selected by selectPaymentMethod() and
|
|
* calls pay() or authorize() on its driver, per that method's own
|
|
* `capture_mode` column.
|
|
*
|
|
* Returns the driver's own PaymentResult UNCHANGED — this method does
|
|
* not wait for or resolve anything past what pay()/authorize() itself
|
|
* returns synchronously. A Pending result (an async gateway like
|
|
* Stripe requiring 3-D Secure/a redirect) is a normal, expected
|
|
* outcome, not an error — the caller (a storefront controller) is
|
|
* responsible for whatever the gateway needs next.
|
|
*
|
|
* The draft order's own $order->total (not the Cart's) is what gets
|
|
* passed as $amount — Order::$total is Lunar's own Price-cast
|
|
* attribute, already resolving the correct Currency via the order's
|
|
* own currency_code, and is the authoritative total once the draft
|
|
* row exists.
|
|
*
|
|
* $context passed to the driver is {cart_id, order_id} — the exact
|
|
* keys Modules\Core\Payment\Drivers\StripePaymentDriver::
|
|
* rememberIntent() already reads.
|
|
*
|
|
* Same fingerprint precondition the old placeOrder() had: mandatory,
|
|
* not optional, checked before the draft is created.
|
|
*
|
|
* $termsAccepted is likewise mandatory, not optional data a caller
|
|
* might omit — an Order is a consumer contract, and its acceptance
|
|
* must be refused (TermsNotAcceptedException, before createOrder() is
|
|
* ever called — the order is never created-then-flagged) rather than
|
|
* assumed. $policyVersion is recorded alongside it on the created
|
|
* Order's own meta (terms_accepted, terms_accepted_at,
|
|
* terms_accepted_policy_version) — the order-level equivalent of
|
|
* setRecoveryConsent()'s cart-level record, and the durable audit
|
|
* trail for a later "what did the shopper actually agree to"
|
|
* dispute. Written directly here (not via a separate event/listener)
|
|
* since the Order row this attaches to doesn't exist before
|
|
* createOrder() runs, and nothing else needs to react to this
|
|
* specific write independently of the order simply existing.
|
|
*
|
|
* @param array<string, mixed> $data passed through untouched to
|
|
* the driver's pay()/authorize() — e.g. Stripe's payment_method
|
|
* token.
|
|
*
|
|
* @throws UnknownPaymentTypeException if the cart's selected
|
|
* payment_method (from selectPaymentMethod()) is no longer offered
|
|
* — re-checked here, not just at selection time, since a method
|
|
* could be disabled (or its driver removed) in between
|
|
* @throws TermsNotAcceptedException if $termsAccepted is false
|
|
* @throws FingerprintMismatchException
|
|
* @throws CartException
|
|
*/
|
|
public function initiatePayment(string $fingerprint, bool $termsAccepted, string $policyVersion, array $data = []): PaymentResult
|
|
{
|
|
if (! $termsAccepted) {
|
|
throw new TermsNotAcceptedException;
|
|
}
|
|
|
|
$cart = $this->cart->currentOrCreate();
|
|
$cart->checkFingerprint($fingerprint);
|
|
|
|
$type = $cart->meta['payment_method'] ?? null;
|
|
$method = $type !== null ? $this->getPaymentMethods()->firstWhere('type', $type) : null;
|
|
|
|
if ($method === null) {
|
|
throw new UnknownPaymentTypeException((string) $type);
|
|
}
|
|
|
|
$order = $cart->createOrder();
|
|
|
|
$order->meta = [
|
|
...($order->meta?->toArray() ?? []),
|
|
'payment_method' => $type,
|
|
'terms_accepted' => true,
|
|
'terms_accepted_at' => now()->toIso8601String(),
|
|
'terms_accepted_policy_version' => $policyVersion,
|
|
];
|
|
$order->save();
|
|
|
|
$driver = $this->paymentDrivers->resolve($method->driver);
|
|
$context = ['cart_id' => $cart->id, 'order_id' => $order->id];
|
|
|
|
return $method->capture_mode === 'authorize'
|
|
? $driver->authorize($type, $order->total, $data, $context)
|
|
: $driver->pay($type, $order->total, $data, $context);
|
|
}
|
|
}
|