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 */ 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 four 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. * 4. its driver's RequiresFulfillmentType (if it declares one) * agrees with the cart's currently selected shipping method's own * fulfillment type (Modules\Core\Shipping\Support\ * FulfillmentType::resolve()) — "Pay in store" offered alongside * a courier delivery makes no sense (no staff member present at * handoff to take cash), and cash-on-delivery alongside store * pickup is equally meaningless (OfflinePaymentDriver already * covers that in-person moment). A cart with no shipping option * selected yet imposes no constraint here — every method is * offered until a fulfillment type is actually known, the same * leniency setShippingAddress()'s own docblock describes for * required-field enforcement happening at the payment gate, not * mid-checkout. * * @return Collection */ public function getPaymentMethods(): Collection { $fulfillmentType = $this->currentFulfillmentType(); return $this->paymentMethods->all() ->filter(fn (PaymentMethod $method) => $method->enabled && $method->driver_missing_at === null) ->filter(function (PaymentMethod $method) use ($fulfillmentType) { $driver = $this->paymentDrivers->resolve($method->driver); if (! $driver?->isConfigured()) { return false; } if ($fulfillmentType === null || ! $driver instanceof RequiresFulfillmentType) { return true; } return $driver->requiredFulfillmentType() === $fulfillmentType; }) ->values(); } /** * @return 'carrier'|'store_pickup'|null null when the cart has no * shipping option selected yet */ private function currentFulfillmentType(): ?string { $identifier = $this->cart->currentOrCreate()->shippingAddress?->shipping_option; if ($identifier === null) { return null; } $method = ShippingMethod::where('code', $identifier)->first(); return $method ? FulfillmentType::resolve($method) : null; } /** * 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 $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); } }