cart->current(); $lines = $cart ? $this->cart->activeLines($cart) : collect(); $storeCountry = $this->storeCountry(); $shippingOptions = collect(); // Captured before prefillFromAccount(), which may recreate the address // row (dropping its shipping_option) — same reason as in saveAddress(). $previousOption = $cart?->shippingAddress?->shipping_option; if ($cart && Auth::check()) { $cart = $this->prefillFromAccount($cart); // Nothing chosen on this cart yet: carry over the account's standing // opt-in (an explicit earlier choice, recorded with its own // timestamp/policy version). Never opts anyone in by default. if (! array_key_exists('recovery_consent', $cart->meta?->toArray() ?? []) && data_get($this->account->customer(Auth::user()), 'meta.recovery_consent')) { $cart = $this->checkout->setRecoveryConsent(true); } } if ($cart?->shippingAddress) { $shippingOptions = $this->syncShipping($cart, $previousOption); // Cart's CachesProperties::refresh() explicitly nulls total/ // subTotal/shippingTotal/etc. back to their defaults — every // Lunar call site pairs it with recalculate() for exactly that // reason. Bare refresh() here was leaving $cart->total null on // reload, which fed a 0 amount straight into the Stripe Element. $cart->refresh()->recalculate(); } $paymentMethods = $this->checkout->getPaymentMethods(); // Nothing checked yet (fresh cart), or the shopper's earlier pick is // no longer offered (method disabled/removed since) — auto-select // the first one, same as a manual click would, so the payment // section (and the Stripe Element mounting under it) isn't sitting // inert behind an unchecked radio. A still-valid previous choice is // left alone. $firstMethod = $paymentMethods->first(); if ($cart && $firstMethod && ! $paymentMethods->contains('type', data_get($cart, 'meta.payment_method'))) { $cart = $this->checkout->selectPaymentMethod($firstMethod->type); } return view('checkout::page', [ 'cart' => $cart, 'lines' => $lines, 'billingAddress' => $cart?->billingAddress, 'shippingAddress' => $cart?->shippingAddress, 'shippingOptions' => $shippingOptions, 'paymentMethods' => $paymentMethods, 'shipToBilling' => (bool) data_get($cart, 'meta.ship_to_billing', true), 'wantsInvoice' => (bool) data_get($cart, 'meta.wants_invoice', false), 'storeCountry' => $storeCountry, 'countries' => $storeCountry ? collect() : Country::orderBy('name')->get(['id', 'name']), 'regions' => $storeCountry ? State::where('country_id', $storeCountry->id)->orderBy('name')->get(['id', 'name']) : collect(), ]); } public function saveAddress(string $locale, Request $request): JsonResponse { $storeCountry = $this->storeCountry(); $sameAsBilling = $request->boolean('same_as_billing'); // Only the fields shipping rates resolve against — if none of these // changed (shopper edited their name, phone, email, …) there's no point // re-quoting shipping or re-rendering the summary. $addressBefore = $this->cart->current()?->shippingAddress; $rateKeyBefore = $addressBefore?->only(['postcode', 'state', 'country_id']); // setShippingAddress() below always deletes + recreates this row (see // syncShipping()'s docblock) — capture what was selected NOW, before // it's gone, so it can be carried forward onto the fresh row. $previousOption = $addressBefore?->shipping_option; $stateRule = $storeCountry ? ['nullable', 'string', Rule::exists((new State)->getTable(), 'name')->where('country_id', $storeCountry->id)] : ['nullable', 'string', 'max:255']; $countryRule = $storeCountry ? ['nullable'] : ['nullable', 'integer', 'exists:'.(new Country)->getTable().',id']; // Lenient — only format checks. Anything that fails is simply left out // of what gets persisted, and reported back for inline display. $validator = Validator::make($request->all(), [ 'contact_email' => ['nullable', 'email'], 'billing_first_name' => ['nullable', 'string', 'max:255'], 'billing_last_name' => ['nullable', 'string', 'max:255'], 'billing_company_name' => ['nullable', 'string', 'max:255'], 'billing_tax_identifier' => ['nullable', 'string', 'max:255'], 'billing_line_one' => ['nullable', 'string', 'max:255'], 'billing_city' => ['nullable', 'string', 'max:255'], 'billing_state' => $stateRule, 'billing_postcode' => ['nullable', 'string', 'max:20'], 'billing_country_id' => $countryRule, 'billing_contact_phone' => ['nullable', 'string', 'max:50'], 'shipping_first_name' => ['nullable', 'string', 'max:255'], 'shipping_last_name' => ['nullable', 'string', 'max:255'], 'shipping_line_one' => ['nullable', 'string', 'max:255'], 'shipping_city' => ['nullable', 'string', 'max:255'], 'shipping_state' => $stateRule, 'shipping_postcode' => ['nullable', 'string', 'max:20'], 'shipping_country_id' => $countryRule, 'shipping_contact_phone' => ['nullable', 'string', 'max:50'], 'shipping_delivery_instructions' => ['nullable', 'string', 'max:1000'], ]); $errors = $validator->errors()->toArray(); $data = $validator->valid(); // Logged in: the order email is always the account's. It isn't a field // on the page then, and a submitted value isn't trusted. if ($user = Auth::user()) { $data['contact_email'] = $user->email; } $billingCountryId = $storeCountry?->id ?? ($data['billing_country_id'] ?? null); $shippingCountryId = $storeCountry?->id ?? ($data['shipping_country_id'] ?? $billingCountryId); // Company/ΑΦΜ only count when "I want an invoice" is ticked; the fields // stay in the DOM (just hidden) when it isn't, so ignore what they send. $wantsInvoice = $request->boolean('wants_invoice'); $billing = [ 'first_name' => $data['billing_first_name'] ?? null, 'last_name' => $data['billing_last_name'] ?? null, 'company_name' => $wantsInvoice ? ($data['billing_company_name'] ?? null) : null, 'tax_identifier' => $wantsInvoice ? ($data['billing_tax_identifier'] ?? null) : null, 'line_one' => $data['billing_line_one'] ?? null, 'city' => $data['billing_city'] ?? null, 'state' => $data['billing_state'] ?? null, 'postcode' => $data['billing_postcode'] ?? null, 'country_id' => $billingCountryId, 'contact_email' => $data['contact_email'] ?? null, 'contact_phone' => $data['billing_contact_phone'] ?? null, ]; $shipping = $sameAsBilling ? [ ...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]), 'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null, ] : [ 'first_name' => $data['shipping_first_name'] ?? null, 'last_name' => $data['shipping_last_name'] ?? null, 'line_one' => $data['shipping_line_one'] ?? null, 'city' => $data['shipping_city'] ?? null, 'state' => $data['shipping_state'] ?? null, 'postcode' => $data['shipping_postcode'] ?? null, 'country_id' => $shippingCountryId, 'contact_email' => $data['contact_email'] ?? null, 'contact_phone' => $data['shipping_contact_phone'] ?? null, 'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null, ]; $this->checkout->setBillingAddress($billing); $cart = $this->checkout->setShippingAddress($shipping); $cart->meta = [ ...($cart->meta?->toArray() ?? []), 'ship_to_billing' => $sameAsBilling, 'wants_invoice' => $wantsInvoice, ]; $cart->save(); // Abandoned-cart-recovery opt-in — boboko-core owns the record (bool + // timestamp + policy version on Cart::meta, RecoveryConsentSet event). // Deliberately its own scope, not merged with any future newsletter opt-in. $this->checkout->setRecoveryConsent($request->boolean('recovery_consent')); if (Auth::check()) { $this->account->setRecoveryConsent(Auth::user(), $request->boolean('recovery_consent')); } $rateKeyAfter = $cart->shippingAddress?->only(['postcode', 'state', 'country_id']); $rateChanged = $rateKeyAfter != $rateKeyBefore; // setShippingAddress() above always deletes and recreates the // CartAddress row (Lunar's AddAddress action), which drops whatever // shipping_option was previously selected — regardless of whether the // rate-determining fields actually changed. So this always has to run // to restore/re-validate it, even on a save that only touched e.g. the // phone number. Only the fragment RE-RENDER is skippable when nothing // rate-relevant moved — the re-select itself is not optional. $options = $this->syncShipping($cart, $previousOption); if (! $rateChanged) { return $this->fragments($cart, null, $errors); } return $this->fragments($cart, $options, $errors); } public function selectShippingOption(string $locale, Request $request): JsonResponse { $identifier = (string) $request->input('shipping_option'); try { $this->checkout->selectShippingOption($identifier); } catch (InvalidShippingOptionException) { // Re-render with whatever is currently valid; no hard error surfaced. } $cart = $this->cart->current(); $options = $cart?->shippingAddress ? $this->checkout->getShippingOptions() : collect(); return $this->fragments($cart, $options); } /** * Autosave-select a payment method (radio change). Persists it via * CheckoutService (which also records it on Cart::meta and re-snapshots * the fingerprint) so ApplyCashOnDeliveryFee etc. show in the summary. */ public function selectPaymentMethod(string $locale, Request $request): JsonResponse { $type = (string) $request->input('payment_type'); try { $this->checkout->selectPaymentMethod($type); } catch (UnknownPaymentTypeException) { // Radio value out of sync with what's offered — ignore, the summary // just won't reflect a method fee. place-order re-checks properly. } return response()->json([ 'summaryHtml' => view('checkout::partials.cart-body')->render(), ]); } /** * The real submit — the hard gate. Re-selects the payment method (fresh * fingerprint), then hands off to CheckoutService::initiatePayment(), which * creates the draft order, records terms acceptance, and charges the driver. * Returns JSON the bbk-payment controller routes on: * { redirect } — placed, go to confirmation * { status: 'pending', clientSecret }— 3-D Secure; client does handleNextAction then polls * { status: 'failed', message } — declined * { status: 'invalid'|'stale', ... } — cart incomplete / changed since selection */ public function placeOrder(string $locale, Request $request): JsonResponse { if (! $request->boolean('terms_accepted')) { return response()->json(['error' => __('checkout.page.terms_required')], 422); } try { $this->checkout->selectPaymentMethod((string) $request->input('payment_type')); } catch (UnknownPaymentTypeException) { return response()->json(['error' => __('checkout.page.choose_payment_method')], 422); } $cart = $this->cart->current(); // Captured now, before initiatePayment() can place the order — Lunar's // CartSessionManager::fetchOrCreate() silently swaps the session onto a // BRAND NEW empty cart the moment the current one hasCompletedOrders() // (i.e. has an order with placed_at set), which happens synchronously // for an immediately-captured payment. Any later $this->cart->current() // call in this same flow (here, or in a subsequent orderStatus() poll // once the 3-D Secure webhook sets placed_at) would then resolve to // that fresh, order-less cart instead of the one that was just placed. // Storing the real cart id ourselves, under our own session key, // sidesteps CartSession entirely for the rest of the placement flow. session(['checkout.cart_id' => $cart?->id]); // Lunar's own ValidateCartForOrderCreation (order_create validator) // never checks for this — an empty cart with a valid billing address // sails straight through it and would place a real, zero-line order. // The disabled "place order" button is only the client-side half of // this fix; this is the half that actually matters. if ($cart === null || $this->cart->activeLines($cart)->isEmpty()) { return response()->json([ 'status' => 'invalid', 'message' => __('checkout.page.cart_empty'), ], 422); } // Lenient autosave never requires these; this is the gate. if (data_get($cart, 'meta.wants_invoice') && (blank($cart->billingAddress?->company_name) || blank($cart->billingAddress?->tax_identifier))) { return response()->json([ 'status' => 'invalid', 'message' => __('checkout.page.invoice_required'), ], 422); } // Same check Lunar's own ValidateCartForOrderCreation runs inside // initiatePayment() (a product unpublished/deleted after it was // added to the cart) — checked here first so the shopper is told // which product is the problem, rather than falling into the // catch-all "complete your billing/shipping details" message below, // which is what actually happened and is generic to every // CartException reason, misleading when the real cause is a line, // not an address. $unavailableLines = $this->cart->activeLines($cart)->filter( fn ($line) => ! $line->purchasable || ! $line->purchasable->isPurchasable(), ); if ($unavailableLines->isNotEmpty()) { $names = $unavailableLines ->map(fn ($line) => $line->purchasable?->product?->translateAttribute('name') ?? $line->purchasable?->getIdentifier()) ->filter() ->implode(', '); return response()->json([ 'status' => 'invalid', 'message' => __('checkout.page.cart_line_unavailable', ['name' => $names]), ], 422); } // The one incomplete-cart case worth a specific message + pointing the // shopper at the right section: a region resolving 2+ methods needs an // explicit pick (no auto-select), easy to miss since nothing else on // the page demands it. Everything else CartException catches below. if ($cart?->shippingAddress && ! $cart->shippingAddress->shipping_option) { return response()->json([ 'status' => 'invalid', 'message' => __('checkout.page.shipping_method_required'), 'field' => 'shipping_option', ], 422); } $fingerprint = (string) ($cart?->meta['checkout_fingerprint'] ?? ''); $data = $request->filled('payment_method') ? ['payment_method' => (string) $request->input('payment_method')] : []; try { $result = $this->checkout->initiatePayment( $fingerprint, termsAccepted: true, policyVersion: (string) config('legal.terms_version'), data: $data, ); } catch (FingerprintMismatchException) { return response()->json(['status' => 'stale', 'message' => __('checkout.page.payment_cart_changed')], 409); } catch (CartException $e) { return response()->json([ 'status' => 'invalid', 'message' => __('checkout.page.payment_incomplete_details'), 'errors' => collect($e->errors()->toArray())->map(fn ($m) => is_array($m) ? ($m[0] ?? null) : $m)->all(), ], 422); } catch (TermsNotAcceptedException) { return response()->json(['error' => __('checkout.page.terms_required')], 422); } // Pending with no continuation (cash-on-delivery, or any other // deferred/offline method) means CheckoutService::initiatePayment() // already created the placed order — money just hasn't changed // hands yet. Only a Pending WITH a continuation (Stripe's client // secret) means the shopper still has something to do before the // order exists as far as the storefront is concerned. return match (true) { $result->status === PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale), $result->status === PaymentResultStatus::Pending && $result->continuation === null => $this->orderPlacedResponse($locale), $result->status === PaymentResultStatus::Pending => response()->json([ 'status' => 'pending', 'clientSecret' => $result->continuation?->value, ]), default => response()->json([ 'status' => 'failed', 'message' => $result->failureReason ?: __('checkout.page.payment_failed'), 'retriable' => $result->retriable, ], 422), }; } /** * Poll target for the 3-D Secure path: has the webhook placed the order yet? * boboko-core's StripeWebhookController -> handleCallback -> PaymentCaptured * -> ApplyResolvedPaymentStatus sets placed_at. */ public function orderStatus(string $locale): JsonResponse { $order = $this->placedOrder(); if (! $order) { return response()->json(['placed' => false]); } session(['checkout.order_id' => $order->id]); CartSession::forget(); return response()->json(['placed' => true, 'redirect' => route('checkout.confirmation', $locale)]); } public function confirmation(string $locale): View|RedirectResponse { $orderId = session('checkout.order_id'); $order = $orderId ? Order::with(['lines.purchasable.product', 'shippingAddress', 'billingAddress'])->find($orderId) : null; if (! $order) { return redirect()->route('products', $locale); } // Looked up by type rather than a stored relation — the method may since // have been disabled/deleted, but the order still needs to show what was // actually used at the time. $paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null) ->first() ?->translate('name'); return view('checkout::confirmation', [ 'order' => $order, 'paymentMethodName' => $paymentMethodName, ]); } private function orderPlacedResponse(string $locale): JsonResponse { if ($order = $this->placedOrder()) { session(['checkout.order_id' => $order->id]); } CartSession::forget(); return response()->json(['redirect' => route('checkout.confirmation', $locale)]); } private function placedOrder(): ?Order { $cartId = session('checkout.cart_id'); if ($cartId === null) { return null; } return Order::where('cart_id', $cartId) ->whereNotNull('placed_at') ->latest('placed_at') ->first(); } /** * Re-resolve shipping options for the cart's current address and keep the * selection sane: auto-select when exactly one resolves, or carry a * previous pick forward when it's still among the resolved options. * * $previousOption must be captured by the CALLER before setShippingAddress() * runs — Lunar's AddAddress action always deletes and recreates the * CartAddress row on every save (see saveAddress()), so by the time this * runs, $address->shipping_option is unconditionally null regardless of * what was selected a moment ago. There is nothing meaningful left to read * off $address itself; $previousOption is the only source of truth for * "what was chosen before this save wiped the row." show() passes the * address's own (not-just-wiped) current value, since nothing recreated * anything in that path. * * Always (re-)applies the resolved target via selectShippingOption() rather * than comparing against the (always-blank, post-recreation) current value * — the fresh row needs the write regardless of whether the decision * "which option" actually changed. * * @return Collection */ private function syncShipping(Cart $cart, ?string $previousOption): Collection { if (! $cart->shippingAddress) { return collect(); } $options = $this->checkout->getShippingOptions(); $target = match (true) { $options->count() === 1 => $options->first()->identifier, $previousOption !== null && $options->contains(fn ($option) => $option->identifier === $previousOption) => $previousOption, default => null, }; if ($target !== null) { try { $this->checkout->selectShippingOption($target); } catch (InvalidShippingOptionException) { // $target came from $options itself — shouldn't happen, stay defensive } } return $options; } /** * $options === null means "nothing money-relevant changed" — acknowledge the * save (and any field errors) without re-rendering the shipping options or * the order summary, so a plain name/phone edit is a cheap round-trip. */ private function fragments(?Cart $cart, ?Collection $options, array $errors = []): JsonResponse { return response()->json([ 'errors' => collect($errors) ->map(fn ($messages) => is_array($messages) ? ($messages[0] ?? null) : $messages) ->all(), 'shippingOptionsHtml' => $options === null ? null : view('checkout::partials.shipping-options', [ 'shippingAddress' => $cart?->shippingAddress, 'shippingOptions' => $options, ])->render(), // Composer (CheckoutModuleServiceProvider) fills $cart / $lines. 'summaryHtml' => $options === null ? null : view('checkout::partials.cart-body')->render(), ]); } /** * Logged-in shopper: fills any BLANK cart address field from the account * (name, saved default address, phone, email), on every checkout load, so * an account filled in after checkout started still shows up. Never * overwrites anything already in the cart. * * Company/ΑΦΜ (and ticking "I want an invoice") only on the first pass * (meta.account_prefilled): someone who then clears them or unticks the * box for this order shouldn't get them back on the next reload. * * Writes only when something actually changes, so a normal reload costs * nothing extra. */ private function prefillFromAccount(Cart $cart): Cart { $user = Auth::user(); $customer = $this->account->customer($user); $addresses = collect($this->account->addresses($user)); $saved = $addresses->firstWhere('shipping_default', true) ?? $addresses->first(); $firstPass = ! data_get($cart, 'meta.account_prefilled'); $fromAccount = array_filter([ 'first_name' => $customer?->first_name ?: $saved?->first_name, 'last_name' => $customer?->last_name ?: $saved?->last_name, 'line_one' => $saved?->line_one, 'city' => $saved?->city, 'state' => $saved?->state, 'postcode' => $saved?->postcode, 'country_id' => $this->storeCountry()?->id ?? $saved?->country_id, 'contact_email' => $user->email, 'contact_phone' => $saved?->contact_phone, ], 'filled'); $invoice = $firstPass ? array_filter([ 'company_name' => $customer?->company_name, 'tax_identifier' => $customer?->tax_identifier, ], 'filled') : []; $fields = ['first_name', 'last_name', 'company_name', 'tax_identifier', 'line_one', 'city', 'state', 'postcode', 'country_id', 'contact_email', 'contact_phone']; $fillBlanks = function (?array $current, array $values) { $current ??= []; foreach ($values as $key => $value) { if (blank($current[$key] ?? null)) { $current[$key] = $value; } } return $current; }; $billingBefore = $cart->billingAddress?->only($fields); $billing = $fillBlanks($billingBefore, [...$fromAccount, ...$invoice]); // Shipping has no company/ΑΦΜ (same shape saveAddress() writes). $shipToBilling = (bool) data_get($cart, 'meta.ship_to_billing', true); $shippingFields = [...array_diff($fields, ['company_name', 'tax_identifier']), 'delivery_instructions']; $shippingBefore = $cart->shippingAddress?->only($shippingFields); $shipping = $shipToBilling ? [ ...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]), 'delivery_instructions' => $shippingBefore['delivery_instructions'] ?? null, ] : $fillBlanks($shippingBefore, $fromAccount); if ($billing != ($billingBefore ?? []) || $shipping != ($shippingBefore ?? [])) { $this->checkout->setBillingAddress($billing); $cart = $this->checkout->setShippingAddress($shipping); } if ($firstPass) { $cart->meta = [ ...($cart->meta?->toArray() ?? []), 'account_prefilled' => true, 'wants_invoice' => (bool) data_get($cart, 'meta.wants_invoice') || $invoice !== [], ]; $cart->save(); } return $cart; } private function storeCountry(): ?Country { if (self::STORE_COUNTRY_ISO3 === null) { return null; } return Country::where('iso3', self::STORE_COUNTRY_ISO3)->first(); } }