From d6c6bf6a1c8d68c01d0b9f5657bf157edd46874f Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Tue, 29 Sep 2026 00:35:29 +0300 Subject: [PATCH] Fix: Correct shipment resolving --- .../checkout/bbk-checkout-form-controller.js | 10 +++ .../js/checkout/bbk-payment-controller.js | 17 ++++- resources/views/checkout/page.blade.php | 2 +- .../Http/Controllers/CheckoutController.php | 12 +++ src/Checkout/Services/CheckoutService.php | 55 +++++++++----- src/Providers/ShippingServiceProvider.php | 14 ++++ src/Shipping/Carriers/Acs/AcsRateDriver.php | 14 +++- .../Carriers/BoxNow/BoxNowRateDriver.php | 6 ++ .../StorePickup/StorePickupRateDriver.php | 75 +++++++++++++++++++ .../Concerns/ExcludesRestrictedProducts.php | 35 +++++++++ .../Contracts/SupportsCashCollection.php | 25 +++++++ src/Shipping/Support/ShippingManager.php | 35 +++++++++ 12 files changed, 278 insertions(+), 22 deletions(-) create mode 100644 src/Shipping/Carriers/StorePickup/StorePickupRateDriver.php create mode 100644 src/Shipping/Concerns/ExcludesRestrictedProducts.php create mode 100644 src/Shipping/Contracts/SupportsCashCollection.php create mode 100644 src/Shipping/Support/ShippingManager.php diff --git a/resources/js/checkout/bbk-checkout-form-controller.js b/resources/js/checkout/bbk-checkout-form-controller.js index aa310ec..161b49a 100644 --- a/resources/js/checkout/bbk-checkout-form-controller.js +++ b/resources/js/checkout/bbk-checkout-form-controller.js @@ -192,6 +192,16 @@ export default class extends Controller { detail: { html: data.summaryHtml }, })) } + + // bbk-payment is a sibling controller (both sit on + // .bbk-checkout-main), not a target of this one — dispatched as an + // event rather than reached into directly, same shape as + // bbk-cart:changed above. + if (data.paymentMethodsHtml != null) { + window.dispatchEvent(new CustomEvent('bbk-checkout:payment-methods-changed', { + detail: { html: data.paymentMethodsHtml }, + })) + } } applyErrors(errors) { diff --git a/resources/js/checkout/bbk-payment-controller.js b/resources/js/checkout/bbk-payment-controller.js index 9cc59a5..055b7a8 100644 --- a/resources/js/checkout/bbk-payment-controller.js +++ b/resources/js/checkout/bbk-payment-controller.js @@ -17,7 +17,7 @@ const POLL_TIMEOUT = 30000 // poll /order-status until the webhook places it // { status:'failed'|'invalid'|'stale', message } -> show inline, re-enable export default class extends Controller { - static targets = ['element', 'terms', 'error', 'submit', 'processing', 'processingText'] + static targets = ['element', 'terms', 'error', 'submit', 'processing', 'processingText', 'methods'] static values = { selectUrl: String, @@ -56,11 +56,26 @@ export default class extends Controller { } window.addEventListener('bbk-cart:updated', this.onSummaryUpdate) + // Fired by bbk-checkout-form after a shipping-option change — + // getPaymentMethods() filters by fulfillment type, so the offered + // methods (and which one, if any, is still validly selected) can + // change without this controller's own element ever reconnecting. + this.onPaymentMethodsChanged = (event) => { + const html = event.detail?.html + if (html == null || !this.hasMethodsTarget) return + + this.methodsTarget.innerHTML = html + + if (!this.selectedIsStripe()) this.unmountStripe() + } + window.addEventListener('bbk-checkout:payment-methods-changed', this.onPaymentMethodsChanged) + if (this.selectedIsStripe()) this.mountStripe() } disconnect() { window.removeEventListener('bbk-cart:updated', this.onSummaryUpdate) + window.removeEventListener('bbk-checkout:payment-methods-changed', this.onPaymentMethodsChanged) this.unmountStripe() } diff --git a/resources/views/checkout/page.blade.php b/resources/views/checkout/page.blade.php index 68a44e6..16fff4a 100644 --- a/resources/views/checkout/page.blade.php +++ b/resources/views/checkout/page.blade.php @@ -219,7 +219,7 @@

{{ __('checkout.page.payment_heading') }}

-
+
@include('checkout::partials.payment-methods', [ 'paymentMethods' => $paymentMethods, 'cart' => $cart, diff --git a/src/Checkout/Http/Controllers/CheckoutController.php b/src/Checkout/Http/Controllers/CheckoutController.php index c686531..f7da5ec 100644 --- a/src/Checkout/Http/Controllers/CheckoutController.php +++ b/src/Checkout/Http/Controllers/CheckoutController.php @@ -646,6 +646,18 @@ class CheckoutController extends Controller ])->render(), // Composer (Providers\CheckoutModuleServiceProvider) fills $cart / $lines. 'summaryHtml' => $options === null ? null : view('checkout::partials.cart-body')->render(), + // getPaymentMethods() filters by the cart's CURRENT fulfillment + // type (Modules\Core\Checkout\Services\CheckoutService's own + // docblock) — a shipping-option change can change that filter's + // result (e.g. switching to store pickup should drop + // cash-on-delivery and offer "pay in store" instead), so this + // needs to be re-rendered every time shipping options are, + // otherwise the payment section silently goes stale until a + // full page reload. + 'paymentMethodsHtml' => $options === null ? null : view('checkout::partials.payment-methods', [ + 'paymentMethods' => $this->checkout->getPaymentMethods(), + 'cart' => $cart, + ])->render(), ]); } diff --git a/src/Checkout/Services/CheckoutService.php b/src/Checkout/Services/CheckoutService.php index 843a2b1..48fa764 100644 --- a/src/Checkout/Services/CheckoutService.php +++ b/src/Checkout/Services/CheckoutService.php @@ -22,11 +22,13 @@ 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 Lunar\Shipping\Facades\Shipping; use Modules\Core\Payment\Contracts\RequiresFulfillmentType; use Modules\Core\Payment\DTOs\PaymentResult; use Modules\Core\Payment\Models\PaymentMethod; use Modules\Core\Payment\Services\PaymentDriverRegistry; use Modules\Core\Payment\Services\PaymentMethodCache; +use Modules\Core\Shipping\Contracts\SupportsCashCollection; use Modules\Core\Shipping\Support\FulfillmentType; /** @@ -254,42 +256,59 @@ class CheckoutService * 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. + * covers that in-person moment). + * 5. for cash-on-delivery specifically, the cart's currently selected + * shipping method's own driver implements Modules\Core\Shipping\ + * Contracts\SupportsCashCollection — "carrier" alone + * (RequiresFulfillmentType) isn't precise enough, since an + * unattended parcel locker network (Modules\Core\Shipping\ + * Carriers\BoxNow\BoxNowRateDriver) is a carrier delivery with + * nobody there to collect cash, unlike an actual courier + * (Modules\Core\Shipping\Carriers\Acs\AcsRateDriver). + * Checks 4 and 5 both impose no constraint when the cart has no + * shipping option selected yet — every method is offered until a + * shipping method 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(); + $shippingMethod = $this->currentShippingMethod(); + $fulfillmentType = $shippingMethod ? FulfillmentType::resolve($shippingMethod) : null; return $this->paymentMethods->all() ->filter(fn (PaymentMethod $method) => $method->enabled && $method->driver_missing_at === null) - ->filter(function (PaymentMethod $method) use ($fulfillmentType) { + ->filter(function (PaymentMethod $method) use ($fulfillmentType, $shippingMethod) { $driver = $this->paymentDrivers->resolve($method->driver); if (! $driver?->isConfigured()) { return false; } - if ($fulfillmentType === null || ! $driver instanceof RequiresFulfillmentType) { - return true; + if ($fulfillmentType !== null && $driver instanceof RequiresFulfillmentType + && $driver->requiredFulfillmentType() !== $fulfillmentType) { + return false; } - return $driver->requiredFulfillmentType() === $fulfillmentType; + // collect(...)->get() rather than Shipping::driver(), which + // throws for an unregistered key — a stale ShippingMethod + // row (e.g. still pointing at a removed generic driver) + // must fail this check quietly, the same as any other + // driver this module doesn't recognize. + if ($shippingMethod !== null && $method->driver === 'cash-on-delivery') { + $shippingDriver = collect(Shipping::getSupportedDrivers())->get($shippingMethod->driver); + + return $shippingDriver instanceof SupportsCashCollection && $shippingDriver->collectsCash(); + } + + return true; }) ->values(); } - /** - * @return 'carrier'|'store_pickup'|null null when the cart has no - * shipping option selected yet - */ - private function currentFulfillmentType(): ?string + private function currentShippingMethod(): ?ShippingMethod { $identifier = $this->cart->currentOrCreate()->shippingAddress?->shipping_option; @@ -297,9 +316,7 @@ class CheckoutService return null; } - $method = ShippingMethod::where('code', $identifier)->first(); - - return $method ? FulfillmentType::resolve($method) : null; + return ShippingMethod::where('code', $identifier)->first(); } /** diff --git a/src/Providers/ShippingServiceProvider.php b/src/Providers/ShippingServiceProvider.php index 55e8121..91656f7 100644 --- a/src/Providers/ShippingServiceProvider.php +++ b/src/Providers/ShippingServiceProvider.php @@ -10,6 +10,7 @@ use Livewire\Mechanisms\ComponentRegistry; use Lunar\Models\Order; use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates; +use Lunar\Shipping\Interfaces\ShippingMethodManagerInterface; use Lunar\Shipping\Models\ShippingMethod; use Modules\Core\Cart\Events\CartCleared; use Modules\Core\Cart\Events\CartLineAdded; @@ -25,9 +26,11 @@ use Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService; use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Filament\Pages\ManageShippingRates; +use Modules\Core\Shipping\Carriers\StorePickup\StorePickupRateDriver; use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob; use Modules\Core\Shipping\Listeners\InvalidateShippingOptions; use Modules\Core\Shipping\Support\FulfillmentType; +use Modules\Core\Shipping\Support\ShippingManager; use Modules\Core\Shipping\Models\Shipment; class ShippingServiceProvider extends ServiceProvider @@ -57,6 +60,16 @@ class ShippingServiceProvider extends ServiceProvider // first load, the Livewire registration covers every AJAX // round-trip (form submits, table interactions) afterwards. $this->app->bind(VendorManageShippingRates::class, ManageShippingRates::class); + + // Binds AFTER the vendor's own ShippingServiceProvider — the + // last-registered bind() for a given abstract wins in Laravel's + // container, and vendor providers register before this one lists + // theirs as a dependency implicitly via composer.json's package + // discovery order — see Modules\Core\Shipping\Support\ + // ShippingManager's own docblock for why this override exists at + // all (dropping the vendor's generic drivers from + // getSupportedDrivers()). + $this->app->bind(ShippingMethodManagerInterface::class, fn ($app) => $app->make(ShippingManager::class)); } public function boot(): void @@ -123,6 +136,7 @@ class ShippingServiceProvider extends ServiceProvider $this->app->booted(function () { Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class)); Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class)); + Shipping::extend('store-pickup', fn ($app) => $app->make(StorePickupRateDriver::class)); $this->app->make(ConsoleSchedule::class) ->job(new WarmAcsAreaCacheJob) diff --git a/src/Shipping/Carriers/Acs/AcsRateDriver.php b/src/Shipping/Carriers/Acs/AcsRateDriver.php index 5a780a6..e290e14 100644 --- a/src/Shipping/Carriers/Acs/AcsRateDriver.php +++ b/src/Shipping/Carriers/Acs/AcsRateDriver.php @@ -9,16 +9,19 @@ use Lunar\Shipping\Interfaces\ShippingRateInterface; use Lunar\Shipping\Models\ShippingRate; use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; use Modules\Core\Shipping\Concerns\CachesLivePricing; +use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType; +use Modules\Core\Shipping\Contracts\SupportsCashCollection; use Modules\Core\Shipping\Contracts\SupportsLivePricing; use Modules\Core\Shipping\Support\ShippingMethodName; use Modules\Core\Shipping\Support\WeightCalculator; -class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, DeclaresFulfillmentType +class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, DeclaresFulfillmentType, SupportsCashCollection { use ResolvesFixedPricing; use CachesLivePricing; + use ExcludesRestrictedProducts; public ShippingRate $shippingRate; @@ -37,6 +40,11 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, Decla return 'carrier'; } + public function collectsCash(): bool + { + return true; + } + public function description(): string { return 'Live rate quote from ACS Courier.'; @@ -48,6 +56,10 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, Decla $shippingMethod = $shippingRate->shippingMethod; $cart = $shippingOptionRequest->cart; + if ($this->cartHasExcludedProducts($shippingRate, $cart)) { + return null; + } + if (($shippingMethod->data['charge_by'] ?? 'cart_total') !== 'live') { return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart); } diff --git a/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php b/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php index cd9c392..4218e6a 100644 --- a/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php +++ b/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php @@ -6,6 +6,7 @@ use Lunar\DataTypes\ShippingOption; use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; use Lunar\Shipping\Interfaces\ShippingRateInterface; use Lunar\Shipping\Models\ShippingRate; +use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType; @@ -18,6 +19,7 @@ use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType; class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType { use ResolvesFixedPricing; + use ExcludesRestrictedProducts; public ShippingRate $shippingRate; @@ -38,6 +40,10 @@ class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption { + if ($this->cartHasExcludedProducts($shippingOptionRequest->shippingRate, $shippingOptionRequest->cart)) { + return null; + } + return $this->resolveFixedPrice( $shippingOptionRequest->shippingRate, $shippingOptionRequest->shippingRate->shippingMethod, diff --git a/src/Shipping/Carriers/StorePickup/StorePickupRateDriver.php b/src/Shipping/Carriers/StorePickup/StorePickupRateDriver.php new file mode 100644 index 0000000..2655bd5 --- /dev/null +++ b/src/Shipping/Carriers/StorePickup/StorePickupRateDriver.php @@ -0,0 +1,75 @@ +name directly instead of + * through Modules\Core\Shipping\Support\ShippingMethodName::resolve() — + * ShippingMethod has no cast for that locale-keyed JSON column (see + * ShippingMethodName's own docblock), so the storefront showed the raw + * JSON blob as the option's name instead of the translated string. + * 2. Unambiguously store pickup, like ACS/Box Now are unambiguously + * carrier — implementing DeclaresFulfillmentType means a merchant never + * has to separately pick "Collect in store" from the generic + * data['fulfillment_type'] field (see that contract's own docblock). + * + * No live pricing — there's no API for in-person pickup, just the method's + * own charge_by + price-break configuration (usually free), the same + * mechanism Box Now uses. + */ +class StorePickupRateDriver implements ShippingRateInterface, DeclaresFulfillmentType +{ + use ResolvesFixedPricing; + use ExcludesRestrictedProducts; + + public ShippingRate $shippingRate; + + public function name(): string + { + return 'Store Pickup'; + } + + public function fulfillmentType(): string + { + return 'store_pickup'; + } + + public function description(): string + { + return 'Collect your order in store.'; + } + + public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption + { + if ($this->cartHasExcludedProducts($shippingOptionRequest->shippingRate, $shippingOptionRequest->cart)) { + return null; + } + + return $this->resolveFixedPrice( + $shippingOptionRequest->shippingRate, + $shippingOptionRequest->shippingRate->shippingMethod, + $shippingOptionRequest->cart, + ); + } + + public function on(ShippingRate $shippingRate): self + { + $this->shippingRate = $shippingRate; + + return $this; + } +} diff --git a/src/Shipping/Concerns/ExcludesRestrictedProducts.php b/src/Shipping/Concerns/ExcludesRestrictedProducts.php new file mode 100644 index 0000000..18093fa --- /dev/null +++ b/src/Shipping/Concerns/ExcludesRestrictedProducts.php @@ -0,0 +1,35 @@ +lines->load('purchasable')->pluck('purchasable.product_id'); + + return $shippingRate->shippingZone->shippingExclusions() + ->whereHas('exclusions', function ($query) use ($productIds) { + $query->wherePurchasableType(Product::morphName()) + ->whereIn('purchasable_id', $productIds); + })->exists(); + } +} diff --git a/src/Shipping/Contracts/SupportsCashCollection.php b/src/Shipping/Contracts/SupportsCashCollection.php new file mode 100644 index 0000000..a3c961c --- /dev/null +++ b/src/Shipping/Contracts/SupportsCashCollection.php @@ -0,0 +1,25 @@ +name raw, showing the storefront the raw + * JSON blob instead of a translated string). The generic drivers' + * create{X}Driver() methods still exist on the parent (harmless — nothing + * calls them once their key is gone from getSupportedDrivers()), so this + * only needs to override the one method that lists what's offered. + * + * Bound over the vendor's own ShippingMethodManagerInterface binding in + * ShippingServiceProvider — see that class for why (registration order: + * this module's provider binds after the vendor's own). + */ +class ShippingManager extends VendorShippingManager +{ + public function getSupportedDrivers(): \Illuminate\Support\Collection + { + return collect($this->customCreators) + ->mapWithKeys(function ($creator, $key) { + return [$key => $this->callCustomCreator($key)]; + }); + } +}