cart->current(); $lines = $cart ? $this->cart->activeLines($cart) : collect(); $storeCountry = $this->storeCountry(); $shippingOptions = collect(); if ($cart?->shippingAddress) { $shippingOptions = $this->syncShipping($cart); $cart->refresh(); } return view('checkout::page', [ 'cart' => $cart, 'lines' => $lines, 'billingAddress' => $cart?->billingAddress, 'shippingAddress' => $cart?->shippingAddress, 'shippingOptions' => $shippingOptions, 'paymentMethods' => $this->checkout->getPaymentMethods(), 'shipToBilling' => (bool) data_get($cart, 'meta.ship_to_billing', true), '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. $rateKeyBefore = $this->cart->current()?->shippingAddress?->only(['postcode', 'state', 'country_id']); $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_line_two' => ['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_company_name' => ['nullable', 'string', 'max:255'], 'shipping_line_one' => ['nullable', 'string', 'max:255'], 'shipping_line_two' => ['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(); $billingCountryId = $storeCountry?->id ?? ($data['billing_country_id'] ?? null); $shippingCountryId = $storeCountry?->id ?? ($data['shipping_country_id'] ?? $billingCountryId); $billing = [ 'first_name' => $data['billing_first_name'] ?? null, 'last_name' => $data['billing_last_name'] ?? null, 'company_name' => $data['billing_company_name'] ?? null, 'tax_identifier' => $data['billing_tax_identifier'] ?? null, 'line_one' => $data['billing_line_one'] ?? null, 'line_two' => $data['billing_line_two'] ?? 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 ? [...$billing, 'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null] : [ 'first_name' => $data['shipping_first_name'] ?? null, 'last_name' => $data['shipping_last_name'] ?? null, 'company_name' => $data['shipping_company_name'] ?? null, 'line_one' => $data['shipping_line_one'] ?? null, 'line_two' => $data['shipping_line_two'] ?? 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]; $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')); $rateKeyAfter = $cart->shippingAddress?->only(['postcode', 'state', 'country_id']); // Nothing rate-relevant moved — persist and acknowledge, no re-render. if ($rateKeyAfter == $rateKeyBefore) { return $this->fragments($cart, null, $errors); } return $this->fragments($cart, $this->syncShipping($cart), $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); } $fingerprint = (string) ($this->cart->current()?->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); } return match ($result->status) { PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale), PaymentResultStatus::Pending => response()->json([ 'status' => 'pending', 'clientSecret' => $result->continuation?->value, ]), PaymentResultStatus::Failed => 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', 'shippingAddress', 'billingAddress'])->find($orderId) : null; if (! $order) { return redirect()->route('products', $locale); } return view('checkout::confirmation', ['order' => $order]); } 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 { return $this->cart->current() ?->orders() ->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, and drop a * previously-picked option that no longer applies (e.g. region changed). * * @return Collection */ private function syncShipping(Cart $cart): Collection { $address = $cart->shippingAddress; if (! $address) { return collect(); } $options = $this->checkout->getShippingOptions(); $current = $address->shipping_option; if ($options->count() === 1) { $only = $options->first(); if ($current !== $only->identifier) { try { $this->checkout->selectShippingOption($only->identifier); } catch (InvalidShippingOptionException) { // nothing to select against — leave as is } } } elseif ($current !== null && ! $options->contains(fn ($option) => $option->identifier === $current)) { $address->update(['shipping_option' => null]); $cart->calculate(); } 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(), ]); } private function storeCountry(): ?Country { if (self::STORE_COUNTRY_ISO3 === null) { return null; } return Country::where('iso3', self::STORE_COUNTRY_ISO3)->first(); } }