payment fix

This commit is contained in:
elvira
2026-09-15 19:20:59 +03:00
parent 1598f053ee
commit ba6d68c5b9
5 changed files with 155 additions and 96 deletions
@@ -61,7 +61,9 @@ public function show(string $locale): View
$shippingOptions = collect();
if ($cart?->shippingAddress) {
$shippingOptions = $this->syncShipping($cart);
// Nothing recreates the address row in this path — its own current
// value is the correct "previous" to carry forward if still valid.
$shippingOptions = $this->syncShipping($cart, $cart->shippingAddress->shipping_option);
// Cart's CachesProperties::refresh() explicitly nulls total/
// subTotal/shippingTotal/etc. back to their defaults — every
@@ -97,7 +99,13 @@ public function saveAddress(string $locale, Request $request): JsonResponse
// 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']);
$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)]
@@ -195,7 +203,7 @@ public function saveAddress(string $locale, Request $request): JsonResponse
// 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);
$options = $this->syncShipping($cart, $previousOption);
if (! $rateChanged) {
return $this->fragments($cart, null, $errors);
@@ -265,7 +273,21 @@ public function placeOrder(string $locale, Request $request): JsonResponse
return response()->json(['error' => __('checkout.page.choose_payment_method')], 422);
}
$fingerprint = (string) ($this->cart->current()?->meta['checkout_fingerprint'] ?? '');
$cart = $this->cart->current();
// 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')]
@@ -360,35 +382,46 @@ private function placedOrder(): ?Order
/**
* 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).
* 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<int, \Lunar\DataTypes\ShippingOption>
*/
private function syncShipping(Cart $cart): Collection
private function syncShipping(Cart $cart, ?string $previousOption): Collection
{
$address = $cart->shippingAddress;
if (! $address) {
if (! $cart->shippingAddress) {
return collect();
}
$options = $this->checkout->getShippingOptions();
$current = $address->shipping_option;
if ($options->count() === 1) {
$only = $options->first();
$target = match (true) {
$options->count() === 1 => $options->first()->identifier,
$previousOption !== null && $options->contains(fn ($option) => $option->identifier === $previousOption) => $previousOption,
default => null,
};
if ($current !== $only->identifier) {
try {
$this->checkout->selectShippingOption($only->identifier);
} catch (InvalidShippingOptionException) {
// nothing to select against — leave as is
}
if ($target !== null) {
try {
$this->checkout->selectShippingOption($target);
} catch (InvalidShippingOptionException) {
// $target came from $options itself — shouldn't happen, stay defensive
}
} elseif ($current !== null && ! $options->contains(fn ($option) => $option->identifier === $current)) {
$address->update(['shipping_option' => null]);
$cart->calculate();
}
return $options;
Generated
+69 -69
View File
@@ -515,11 +515,11 @@
},
{
"name": "boboko/core",
"version": "0.17.0",
"version": "0.17.1",
"source": {
"type": "git",
"url": "https://code.radical-elements.com/boboko/core.git",
"reference": "57fc28ca0680841fddc222be4a5a936b43247883"
"reference": "d9fb3bbde661bc8f49b3032462af029c5983b03a"
},
"require": {
"laravel/framework": "^12.0",
@@ -568,7 +568,7 @@
}
},
"description": "Core module — authentication and shared panel behaviour",
"time": "2026-09-14T17:18:26+00:00"
"time": "2026-09-15T13:12:03+00:00"
},
{
"name": "brick/math",
@@ -4302,16 +4302,16 @@
},
{
"name": "livewire/livewire",
"version": "v3.8.8",
"version": "v3.8.9",
"source": {
"type": "git",
"url": "https://github.com/livewire/livewire.git",
"reference": "ec19e5fb1e0b60df22ad15b901c2f7740267ba11"
"reference": "993a3f5051774ca12768067ab8644ea97c417417"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/livewire/livewire/zipball/ec19e5fb1e0b60df22ad15b901c2f7740267ba11",
"reference": "ec19e5fb1e0b60df22ad15b901c2f7740267ba11",
"url": "https://api.github.com/repos/livewire/livewire/zipball/993a3f5051774ca12768067ab8644ea97c417417",
"reference": "993a3f5051774ca12768067ab8644ea97c417417",
"shasum": ""
},
"require": {
@@ -4366,7 +4366,7 @@
"description": "A front-end framework for Laravel.",
"support": {
"issues": "https://github.com/livewire/livewire/issues",
"source": "https://github.com/livewire/livewire/tree/v3.8.8"
"source": "https://github.com/livewire/livewire/tree/v3.8.9"
},
"funding": [
{
@@ -4374,7 +4374,7 @@
"type": "github"
}
],
"time": "2026-09-07T16:02:29+00:00"
"time": "2026-09-14T19:27:17+00:00"
},
{
"name": "lukascivil/treewalker",
@@ -9740,16 +9740,16 @@
},
{
"name": "symfony/console",
"version": "v7.4.18",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0"
"reference": "3a1973458b153f566a8d7499bc06ece323d60063"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0",
"reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0",
"url": "https://api.github.com/repos/symfony/console/zipball/3a1973458b153f566a8d7499bc06ece323d60063",
"reference": "3a1973458b153f566a8d7499bc06ece323d60063",
"shasum": ""
},
"require": {
@@ -9814,7 +9814,7 @@
"terminal"
],
"support": {
"source": "https://github.com/symfony/console/tree/v7.4.18"
"source": "https://github.com/symfony/console/tree/v7.4.19"
},
"funding": [
{
@@ -9834,7 +9834,7 @@
"type": "tidelift"
}
],
"time": "2026-08-25T14:18:37+00:00"
"time": "2026-09-13T10:38:41+00:00"
},
{
"name": "symfony/css-selector",
@@ -10296,16 +10296,16 @@
},
{
"name": "symfony/finder",
"version": "v7.4.17",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
"reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6"
"reference": "1b900bc6ae5ba60c5eee69c11ba44566190576fe"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/finder/zipball/5ce28827081f6d1f0c32eaf3882750f19cb5bbe6",
"reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6",
"url": "https://api.github.com/repos/symfony/finder/zipball/1b900bc6ae5ba60c5eee69c11ba44566190576fe",
"reference": "1b900bc6ae5ba60c5eee69c11ba44566190576fe",
"shasum": ""
},
"require": {
@@ -10340,7 +10340,7 @@
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/finder/tree/v7.4.17"
"source": "https://github.com/symfony/finder/tree/v7.4.19"
},
"funding": [
{
@@ -10360,20 +10360,20 @@
"type": "tidelift"
}
],
"time": "2026-08-21T12:09:28+00:00"
"time": "2026-09-10T19:14:10+00:00"
},
{
"name": "symfony/html-sanitizer",
"version": "v8.1.6",
"version": "v8.1.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/html-sanitizer.git",
"reference": "f8bbdb0704e6b6e9a3412481c1a87886a0633199"
"reference": "3d79bdecd01f71bccc45ddfa1d33c024df347a1f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/f8bbdb0704e6b6e9a3412481c1a87886a0633199",
"reference": "f8bbdb0704e6b6e9a3412481c1a87886a0633199",
"url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/3d79bdecd01f71bccc45ddfa1d33c024df347a1f",
"reference": "3d79bdecd01f71bccc45ddfa1d33c024df347a1f",
"shasum": ""
},
"require": {
@@ -10412,7 +10412,7 @@
"sanitizer"
],
"support": {
"source": "https://github.com/symfony/html-sanitizer/tree/v8.1.6"
"source": "https://github.com/symfony/html-sanitizer/tree/v8.1.7"
},
"funding": [
{
@@ -10432,20 +10432,20 @@
"type": "tidelift"
}
],
"time": "2026-08-30T01:03:44+00:00"
"time": "2026-09-04T10:14:04+00:00"
},
{
"name": "symfony/http-foundation",
"version": "v7.4.18",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
"reference": "d070b716a32fbe3bf04204db0f58ace73b86d133"
"reference": "f7b953be7801974a5a0685e1228bbc3b12b35936"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/d070b716a32fbe3bf04204db0f58ace73b86d133",
"reference": "d070b716a32fbe3bf04204db0f58ace73b86d133",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/f7b953be7801974a5a0685e1228bbc3b12b35936",
"reference": "f7b953be7801974a5a0685e1228bbc3b12b35936",
"shasum": ""
},
"require": {
@@ -10494,7 +10494,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-foundation/tree/v7.4.18"
"source": "https://github.com/symfony/http-foundation/tree/v7.4.19"
},
"funding": [
{
@@ -10514,20 +10514,20 @@
"type": "tidelift"
}
],
"time": "2026-08-30T20:10:52+00:00"
"time": "2026-09-14T17:41:17+00:00"
},
{
"name": "symfony/http-kernel",
"version": "v7.4.18",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
"reference": "275d2d2d24530f2a0eaf17704a3a93860a036351"
"reference": "683507424cc84ca85ac1551adb609b6b98216667"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/275d2d2d24530f2a0eaf17704a3a93860a036351",
"reference": "275d2d2d24530f2a0eaf17704a3a93860a036351",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/683507424cc84ca85ac1551adb609b6b98216667",
"reference": "683507424cc84ca85ac1551adb609b6b98216667",
"shasum": ""
},
"require": {
@@ -10613,7 +10613,7 @@
"description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-kernel/tree/v7.4.18"
"source": "https://github.com/symfony/http-kernel/tree/v7.4.19"
},
"funding": [
{
@@ -10633,20 +10633,20 @@
"type": "tidelift"
}
],
"time": "2026-08-30T21:24:29+00:00"
"time": "2026-09-15T07:09:35+00:00"
},
{
"name": "symfony/mailer",
"version": "v7.4.17",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/mailer.git",
"reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584"
"reference": "6f6f2441bef07a42d2617b9b3aadf04a321aa514"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mailer/zipball/b17c9bf3a551d5f635638a3b6c05f06c4dc87584",
"reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584",
"url": "https://api.github.com/repos/symfony/mailer/zipball/6f6f2441bef07a42d2617b9b3aadf04a321aa514",
"reference": "6f6f2441bef07a42d2617b9b3aadf04a321aa514",
"shasum": ""
},
"require": {
@@ -10697,7 +10697,7 @@
"description": "Helps sending emails",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/mailer/tree/v7.4.17"
"source": "https://github.com/symfony/mailer/tree/v7.4.19"
},
"funding": [
{
@@ -10717,20 +10717,20 @@
"type": "tidelift"
}
],
"time": "2026-08-21T17:40:08+00:00"
"time": "2026-09-15T06:01:15+00:00"
},
{
"name": "symfony/mime",
"version": "v7.4.18",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
"reference": "bf328d82105831db3e409195db0540ff57f27c80"
"reference": "f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mime/zipball/bf328d82105831db3e409195db0540ff57f27c80",
"reference": "bf328d82105831db3e409195db0540ff57f27c80",
"url": "https://api.github.com/repos/symfony/mime/zipball/f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693",
"reference": "f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693",
"shasum": ""
},
"require": {
@@ -10786,7 +10786,7 @@
"mime-type"
],
"support": {
"source": "https://github.com/symfony/mime/tree/v7.4.18"
"source": "https://github.com/symfony/mime/tree/v7.4.19"
},
"funding": [
{
@@ -10806,7 +10806,7 @@
"type": "tidelift"
}
],
"time": "2026-08-22T09:04:42+00:00"
"time": "2026-09-04T10:45:44+00:00"
},
{
"name": "symfony/options-resolver",
@@ -11870,16 +11870,16 @@
},
{
"name": "symfony/process",
"version": "v7.4.18",
"version": "v7.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
"reference": "058d17fc284cce14efb2385783b55014a461b176"
"reference": "ed0ae095b86994d370d5791612e55984f15aa30e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/process/zipball/058d17fc284cce14efb2385783b55014a461b176",
"reference": "058d17fc284cce14efb2385783b55014a461b176",
"url": "https://api.github.com/repos/symfony/process/zipball/ed0ae095b86994d370d5791612e55984f15aa30e",
"reference": "ed0ae095b86994d370d5791612e55984f15aa30e",
"shasum": ""
},
"require": {
@@ -11911,7 +11911,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/process/tree/v7.4.18"
"source": "https://github.com/symfony/process/tree/v7.4.19"
},
"funding": [
{
@@ -11931,7 +11931,7 @@
"type": "tidelift"
}
],
"time": "2026-08-21T17:40:08+00:00"
"time": "2026-09-02T12:38:34+00:00"
},
{
"name": "symfony/routing",
@@ -12107,16 +12107,16 @@
},
{
"name": "symfony/string",
"version": "v8.1.2",
"version": "v8.1.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
"reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc"
"reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc",
"reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc",
"url": "https://api.github.com/repos/symfony/string/zipball/d950140b5f56f31901e5b7a0c04ffc3a3deb943c",
"reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c",
"shasum": ""
},
"require": {
@@ -12173,7 +12173,7 @@
"utf8"
],
"support": {
"source": "https://github.com/symfony/string/tree/v8.1.2"
"source": "https://github.com/symfony/string/tree/v8.1.7"
},
"funding": [
{
@@ -12193,7 +12193,7 @@
"type": "tidelift"
}
],
"time": "2026-07-28T07:35:25+00:00"
"time": "2026-09-11T14:51:16+00:00"
},
{
"name": "symfony/translation",
@@ -12665,16 +12665,16 @@
},
{
"name": "technikermathe/blade-lucide-icons",
"version": "v3.179.0",
"version": "v3.180.0",
"source": {
"type": "git",
"url": "https://github.com/PascaleBeier/blade-lucide-icons.git",
"reference": "bfae6d39e35bd78f72b7372a26a30b500687c923"
"reference": "adac9da3a65910fedc271129eea5340093910203"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PascaleBeier/blade-lucide-icons/zipball/bfae6d39e35bd78f72b7372a26a30b500687c923",
"reference": "bfae6d39e35bd78f72b7372a26a30b500687c923",
"url": "https://api.github.com/repos/PascaleBeier/blade-lucide-icons/zipball/adac9da3a65910fedc271129eea5340093910203",
"reference": "adac9da3a65910fedc271129eea5340093910203",
"shasum": ""
},
"require": {
@@ -12724,9 +12724,9 @@
],
"support": {
"issues": "https://github.com/PascaleBeier/blade-lucide-icons/issues",
"source": "https://github.com/PascaleBeier/blade-lucide-icons/tree/v3.179.0"
"source": "https://github.com/PascaleBeier/blade-lucide-icons/tree/v3.180.0"
},
"time": "2026-09-12T02:06:59+00:00"
"time": "2026-09-15T02:25:56+00:00"
},
{
"name": "thecodingmachine/safe",
@@ -145,6 +145,10 @@ private function lines(): array
],
'page.place_order' => ['Place order — payment obligation', 'Παραγγελία με υποχρέωση πληρωμής'],
'page.choose_payment_method' => ['Choose a payment method.', 'Επίλεξε τρόπο πληρωμής.'],
'page.shipping_method_required' => [
'Choose a shipping method below to continue.',
'Επίλεξε τρόπο αποστολής παρακάτω για να συνεχίσεις.',
],
'page.payment_failed' => ['Payment failed. Please try again.', 'Η πληρωμή απέτυχε. Δοκίμασε ξανά.'],
'page.payment_incomplete_details' => [
'Complete your billing and shipping details above.',
@@ -30,6 +30,7 @@ export default class extends Controller {
this.saveTimer = null
this.saveController = null
this.statusTimer = null
this.shippingPromise = null
if (this.hasSameAsBillingTarget) this.applySameAsBilling()
}
@@ -108,6 +109,9 @@ export default class extends Controller {
async flush() {
clearTimeout(this.saveTimer)
await this.save()
// A shipping-method radio click fires its own (undebounced) request —
// still async, still racy against an immediate "place order" click.
if (this.shippingPromise) await this.shippingPromise
}
async save() {
@@ -137,11 +141,18 @@ export default class extends Controller {
}
async selectShipping(event) {
// Tracked so flush() can await it — nothing else stops "place order"
// (a separate, unrelated click) from racing ahead of this request.
this.shippingPromise = this.doSelectShipping(event.target.value)
await this.shippingPromise
}
async doSelectShipping(value) {
this.saveController?.abort()
this.setStatus('saving')
const body = new FormData()
body.append('shipping_option', event.target.value)
body.append('shipping_option', value)
try {
const response = await fetch(this.selectShippingUrlValue, {
@@ -160,6 +171,8 @@ export default class extends Controller {
this.setStatus('saved')
} catch {
this.setStatus('error')
} finally {
this.shippingPromise = null
}
}
@@ -215,6 +215,15 @@ export default class extends Controller {
return
}
// Points at the section that actually needs attention, rather than
// leaving a generic error and making the shopper hunt for it — e.g. a
// region with 2+ shipping methods needs an explicit pick, easy to miss.
if (data.field === 'shipping_option') {
document.getElementById('bbk-shipping-options')?.scrollIntoView({ block: 'center', behavior: 'smooth' })
this.fail(data.message || data.error || this.genericErrorValue, { scroll: false })
return
}
this.fail(data.message || data.error || this.genericErrorValue)
}
@@ -252,15 +261,15 @@ export default class extends Controller {
// ── helpers ──────────────────────────────────────────────────────
fail(message) {
this.showError(message)
fail(message, { scroll = true } = {}) {
this.showError(message, { scroll })
this.submitTarget.disabled = false
}
showError(message) {
showError(message, { scroll = true } = {}) {
this.errorTarget.textContent = message
this.errorTarget.hidden = false
this.errorTarget.scrollIntoView({ block: 'center', behavior: 'smooth' })
if (scroll) this.errorTarget.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
clearError() {