Fix: Correct shipment resolving
This commit is contained in:
@@ -192,6 +192,16 @@ export default class extends Controller {
|
|||||||
detail: { html: data.summaryHtml },
|
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) {
|
applyErrors(errors) {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const POLL_TIMEOUT = 30000
|
|||||||
// poll /order-status until the webhook places it
|
// poll /order-status until the webhook places it
|
||||||
// { status:'failed'|'invalid'|'stale', message } -> show inline, re-enable
|
// { status:'failed'|'invalid'|'stale', message } -> show inline, re-enable
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = ['element', 'terms', 'error', 'submit', 'processing', 'processingText']
|
static targets = ['element', 'terms', 'error', 'submit', 'processing', 'processingText', 'methods']
|
||||||
|
|
||||||
static values = {
|
static values = {
|
||||||
selectUrl: String,
|
selectUrl: String,
|
||||||
@@ -56,11 +56,26 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
window.addEventListener('bbk-cart:updated', this.onSummaryUpdate)
|
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()
|
if (this.selectedIsStripe()) this.mountStripe()
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
window.removeEventListener('bbk-cart:updated', this.onSummaryUpdate)
|
window.removeEventListener('bbk-cart:updated', this.onSummaryUpdate)
|
||||||
|
window.removeEventListener('bbk-checkout:payment-methods-changed', this.onPaymentMethodsChanged)
|
||||||
this.unmountStripe()
|
this.unmountStripe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -219,7 +219,7 @@
|
|||||||
<section class="bbk-checkout-section">
|
<section class="bbk-checkout-section">
|
||||||
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.payment_heading') }}</h2>
|
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.payment_heading') }}</h2>
|
||||||
|
|
||||||
<div id="bbk-payment-methods">
|
<div id="bbk-payment-methods" data-bbk-payment-target="methods">
|
||||||
@include('checkout::partials.payment-methods', [
|
@include('checkout::partials.payment-methods', [
|
||||||
'paymentMethods' => $paymentMethods,
|
'paymentMethods' => $paymentMethods,
|
||||||
'cart' => $cart,
|
'cart' => $cart,
|
||||||
|
|||||||
@@ -646,6 +646,18 @@ class CheckoutController extends Controller
|
|||||||
])->render(),
|
])->render(),
|
||||||
// Composer (Providers\CheckoutModuleServiceProvider) fills $cart / $lines.
|
// Composer (Providers\CheckoutModuleServiceProvider) fills $cart / $lines.
|
||||||
'summaryHtml' => $options === null ? null : view('checkout::partials.cart-body')->render(),
|
'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(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,13 @@ use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
|
|||||||
use Modules\Core\Checkout\Exceptions\NoShippingAddressException;
|
use Modules\Core\Checkout\Exceptions\NoShippingAddressException;
|
||||||
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
|
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
|
||||||
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
||||||
|
use Lunar\Shipping\Facades\Shipping;
|
||||||
use Modules\Core\Payment\Contracts\RequiresFulfillmentType;
|
use Modules\Core\Payment\Contracts\RequiresFulfillmentType;
|
||||||
use Modules\Core\Payment\DTOs\PaymentResult;
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
||||||
use Modules\Core\Payment\Models\PaymentMethod;
|
use Modules\Core\Payment\Models\PaymentMethod;
|
||||||
use Modules\Core\Payment\Services\PaymentDriverRegistry;
|
use Modules\Core\Payment\Services\PaymentDriverRegistry;
|
||||||
use Modules\Core\Payment\Services\PaymentMethodCache;
|
use Modules\Core\Payment\Services\PaymentMethodCache;
|
||||||
|
use Modules\Core\Shipping\Contracts\SupportsCashCollection;
|
||||||
use Modules\Core\Shipping\Support\FulfillmentType;
|
use Modules\Core\Shipping\Support\FulfillmentType;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -254,42 +256,59 @@ class CheckoutService
|
|||||||
* a courier delivery makes no sense (no staff member present at
|
* a courier delivery makes no sense (no staff member present at
|
||||||
* handoff to take cash), and cash-on-delivery alongside store
|
* handoff to take cash), and cash-on-delivery alongside store
|
||||||
* pickup is equally meaningless (OfflinePaymentDriver already
|
* pickup is equally meaningless (OfflinePaymentDriver already
|
||||||
* covers that in-person moment). A cart with no shipping option
|
* covers that in-person moment).
|
||||||
* selected yet imposes no constraint here — every method is
|
* 5. for cash-on-delivery specifically, the cart's currently selected
|
||||||
* offered until a fulfillment type is actually known, the same
|
* shipping method's own driver implements Modules\Core\Shipping\
|
||||||
* leniency setShippingAddress()'s own docblock describes for
|
* Contracts\SupportsCashCollection — "carrier" alone
|
||||||
* required-field enforcement happening at the payment gate, not
|
* (RequiresFulfillmentType) isn't precise enough, since an
|
||||||
* mid-checkout.
|
* 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<int, PaymentMethod>
|
* @return Collection<int, PaymentMethod>
|
||||||
*/
|
*/
|
||||||
public function getPaymentMethods(): Collection
|
public function getPaymentMethods(): Collection
|
||||||
{
|
{
|
||||||
$fulfillmentType = $this->currentFulfillmentType();
|
$shippingMethod = $this->currentShippingMethod();
|
||||||
|
$fulfillmentType = $shippingMethod ? FulfillmentType::resolve($shippingMethod) : null;
|
||||||
|
|
||||||
return $this->paymentMethods->all()
|
return $this->paymentMethods->all()
|
||||||
->filter(fn (PaymentMethod $method) => $method->enabled && $method->driver_missing_at === null)
|
->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);
|
$driver = $this->paymentDrivers->resolve($method->driver);
|
||||||
|
|
||||||
if (! $driver?->isConfigured()) {
|
if (! $driver?->isConfigured()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($fulfillmentType === null || ! $driver instanceof RequiresFulfillmentType) {
|
if ($fulfillmentType !== null && $driver instanceof RequiresFulfillmentType
|
||||||
return true;
|
&& $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();
|
->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function currentShippingMethod(): ?ShippingMethod
|
||||||
* @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;
|
$identifier = $this->cart->currentOrCreate()->shippingAddress?->shipping_option;
|
||||||
|
|
||||||
@@ -297,9 +316,7 @@ class CheckoutService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$method = ShippingMethod::where('code', $identifier)->first();
|
return ShippingMethod::where('code', $identifier)->first();
|
||||||
|
|
||||||
return $method ? FulfillmentType::resolve($method) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use Livewire\Mechanisms\ComponentRegistry;
|
|||||||
use Lunar\Models\Order;
|
use Lunar\Models\Order;
|
||||||
use Lunar\Shipping\Facades\Shipping;
|
use Lunar\Shipping\Facades\Shipping;
|
||||||
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates;
|
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates;
|
||||||
|
use Lunar\Shipping\Interfaces\ShippingMethodManagerInterface;
|
||||||
use Lunar\Shipping\Models\ShippingMethod;
|
use Lunar\Shipping\Models\ShippingMethod;
|
||||||
use Modules\Core\Cart\Events\CartCleared;
|
use Modules\Core\Cart\Events\CartCleared;
|
||||||
use Modules\Core\Cart\Events\CartLineAdded;
|
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\Carriers\BoxNow\BoxNowRateDriver;
|
||||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||||
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
|
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
|
||||||
|
use Modules\Core\Shipping\Carriers\StorePickup\StorePickupRateDriver;
|
||||||
use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
|
use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
|
||||||
use Modules\Core\Shipping\Listeners\InvalidateShippingOptions;
|
use Modules\Core\Shipping\Listeners\InvalidateShippingOptions;
|
||||||
use Modules\Core\Shipping\Support\FulfillmentType;
|
use Modules\Core\Shipping\Support\FulfillmentType;
|
||||||
|
use Modules\Core\Shipping\Support\ShippingManager;
|
||||||
use Modules\Core\Shipping\Models\Shipment;
|
use Modules\Core\Shipping\Models\Shipment;
|
||||||
|
|
||||||
class ShippingServiceProvider extends ServiceProvider
|
class ShippingServiceProvider extends ServiceProvider
|
||||||
@@ -57,6 +60,16 @@ class ShippingServiceProvider extends ServiceProvider
|
|||||||
// first load, the Livewire registration covers every AJAX
|
// first load, the Livewire registration covers every AJAX
|
||||||
// round-trip (form submits, table interactions) afterwards.
|
// round-trip (form submits, table interactions) afterwards.
|
||||||
$this->app->bind(VendorManageShippingRates::class, ManageShippingRates::class);
|
$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
|
public function boot(): void
|
||||||
@@ -123,6 +136,7 @@ class ShippingServiceProvider extends ServiceProvider
|
|||||||
$this->app->booted(function () {
|
$this->app->booted(function () {
|
||||||
Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class));
|
Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class));
|
||||||
Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::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)
|
$this->app->make(ConsoleSchedule::class)
|
||||||
->job(new WarmAcsAreaCacheJob)
|
->job(new WarmAcsAreaCacheJob)
|
||||||
|
|||||||
@@ -9,16 +9,19 @@ use Lunar\Shipping\Interfaces\ShippingRateInterface;
|
|||||||
use Lunar\Shipping\Models\ShippingRate;
|
use Lunar\Shipping\Models\ShippingRate;
|
||||||
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
|
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
|
||||||
use Modules\Core\Shipping\Concerns\CachesLivePricing;
|
use Modules\Core\Shipping\Concerns\CachesLivePricing;
|
||||||
|
use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts;
|
||||||
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
||||||
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
|
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
|
||||||
|
use Modules\Core\Shipping\Contracts\SupportsCashCollection;
|
||||||
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
|
||||||
use Modules\Core\Shipping\Support\ShippingMethodName;
|
use Modules\Core\Shipping\Support\ShippingMethodName;
|
||||||
use Modules\Core\Shipping\Support\WeightCalculator;
|
use Modules\Core\Shipping\Support\WeightCalculator;
|
||||||
|
|
||||||
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, DeclaresFulfillmentType
|
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, DeclaresFulfillmentType, SupportsCashCollection
|
||||||
{
|
{
|
||||||
use ResolvesFixedPricing;
|
use ResolvesFixedPricing;
|
||||||
use CachesLivePricing;
|
use CachesLivePricing;
|
||||||
|
use ExcludesRestrictedProducts;
|
||||||
|
|
||||||
public ShippingRate $shippingRate;
|
public ShippingRate $shippingRate;
|
||||||
|
|
||||||
@@ -37,6 +40,11 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, Decla
|
|||||||
return 'carrier';
|
return 'carrier';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function collectsCash(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public function description(): string
|
public function description(): string
|
||||||
{
|
{
|
||||||
return 'Live rate quote from ACS Courier.';
|
return 'Live rate quote from ACS Courier.';
|
||||||
@@ -48,6 +56,10 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, Decla
|
|||||||
$shippingMethod = $shippingRate->shippingMethod;
|
$shippingMethod = $shippingRate->shippingMethod;
|
||||||
$cart = $shippingOptionRequest->cart;
|
$cart = $shippingOptionRequest->cart;
|
||||||
|
|
||||||
|
if ($this->cartHasExcludedProducts($shippingRate, $cart)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (($shippingMethod->data['charge_by'] ?? 'cart_total') !== 'live') {
|
if (($shippingMethod->data['charge_by'] ?? 'cart_total') !== 'live') {
|
||||||
return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart);
|
return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use Lunar\DataTypes\ShippingOption;
|
|||||||
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
|
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
|
||||||
use Lunar\Shipping\Interfaces\ShippingRateInterface;
|
use Lunar\Shipping\Interfaces\ShippingRateInterface;
|
||||||
use Lunar\Shipping\Models\ShippingRate;
|
use Lunar\Shipping\Models\ShippingRate;
|
||||||
|
use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts;
|
||||||
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
|
||||||
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
|
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
|
|||||||
class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType
|
class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType
|
||||||
{
|
{
|
||||||
use ResolvesFixedPricing;
|
use ResolvesFixedPricing;
|
||||||
|
use ExcludesRestrictedProducts;
|
||||||
|
|
||||||
public ShippingRate $shippingRate;
|
public ShippingRate $shippingRate;
|
||||||
|
|
||||||
@@ -38,6 +40,10 @@ class BoxNowRateDriver implements ShippingRateInterface, DeclaresFulfillmentType
|
|||||||
|
|
||||||
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
|
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
|
||||||
{
|
{
|
||||||
|
if ($this->cartHasExcludedProducts($shippingOptionRequest->shippingRate, $shippingOptionRequest->cart)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return $this->resolveFixedPrice(
|
return $this->resolveFixedPrice(
|
||||||
$shippingOptionRequest->shippingRate,
|
$shippingOptionRequest->shippingRate,
|
||||||
$shippingOptionRequest->shippingRate->shippingMethod,
|
$shippingOptionRequest->shippingRate->shippingMethod,
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Carriers\StorePickup;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-party replacement for lunarphp/table-rate-shipping's own Collection
|
||||||
|
* driver — same shape as AcsRateDriver/BoxNowRateDriver, unlike the vendor
|
||||||
|
* generic drivers (flat-rate/ship-by/free-shipping/collection), which this
|
||||||
|
* module no longer registers (see ShippingServiceProvider). Two reasons
|
||||||
|
* this exists rather than keeping the vendor driver:
|
||||||
|
*
|
||||||
|
* 1. Collection::resolve() reads $shippingMethod->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Concerns;
|
||||||
|
|
||||||
|
use Lunar\Models\Cart;
|
||||||
|
use Lunar\Models\Product;
|
||||||
|
use Lunar\Shipping\Models\ShippingRate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A shipping zone can list specific products that can't go through it (see
|
||||||
|
* the Filament "Shipping Exclusions" relation manager on ShippingZoneResource)
|
||||||
|
* — a merchant's way of saying "this item physically can't be handled by
|
||||||
|
* this carrier/method," independent of price. lunarphp/table-rate-shipping's
|
||||||
|
* own generic drivers (FlatRate, ShipBy, FreeShipping, Collection) each
|
||||||
|
* check this before returning an option; every first-party driver in this
|
||||||
|
* module (ACS, Box Now, store pickup) shares it from here instead, so a
|
||||||
|
* merchant-configured exclusion is honored no matter which driver a
|
||||||
|
* shipping method uses — not just the vendor's generic ones. Call this
|
||||||
|
* FIRST in resolve(), before any pricing lookup (live or fixed): an
|
||||||
|
* excluded product should make the option disappear entirely, the same
|
||||||
|
* "return null" a driver already uses for "no rate matched."
|
||||||
|
*/
|
||||||
|
trait ExcludesRestrictedProducts
|
||||||
|
{
|
||||||
|
private function cartHasExcludedProducts(ShippingRate $shippingRate, Cart $cart): bool
|
||||||
|
{
|
||||||
|
$productIds = $cart->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Contracts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A shipping rate driver implements this to say a person is physically
|
||||||
|
* present at handoff to collect cash — true for a courier like
|
||||||
|
* Modules\Core\Shipping\Carriers\Acs\AcsRateDriver, false for an
|
||||||
|
* unattended parcel locker network like Modules\Core\Shipping\Carriers\
|
||||||
|
* BoxNow\BoxNowRateDriver, where no one is there to take payment. Checked
|
||||||
|
* by Modules\Core\Checkout\Services\CheckoutService::getPaymentMethods()
|
||||||
|
* for cash-on-delivery specifically (Modules\Core\Payment\Drivers\
|
||||||
|
* CashOnDeliveryPaymentDriver) — separate from Modules\Core\Shipping\
|
||||||
|
* Contracts\DeclaresFulfillmentType, which only distinguishes carrier vs.
|
||||||
|
* store_pickup and can't tell ACS and Box Now apart (both 'carrier').
|
||||||
|
*
|
||||||
|
* Not implemented at all means "no" — a driver with no opinion here
|
||||||
|
* (any of table-rate-shipping's own generic drivers, if one were ever
|
||||||
|
* re-added) is treated as not supporting cash collection, the safer
|
||||||
|
* default for a driver this module knows nothing about.
|
||||||
|
*/
|
||||||
|
interface SupportsCashCollection
|
||||||
|
{
|
||||||
|
public function collectsCash(): bool;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Shipping\Support;
|
||||||
|
|
||||||
|
use Lunar\Shipping\Managers\ShippingManager as VendorShippingManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops lunarphp/table-rate-shipping's own generic drivers (free-shipping,
|
||||||
|
* flat-rate, ship-by, collection) from getSupportedDrivers() — every
|
||||||
|
* shipping method in this app now uses a first-party driver (ACS, Box
|
||||||
|
* Now, or Modules\Core\Shipping\Carriers\StorePickup\StorePickupRateDriver,
|
||||||
|
* registered via Shipping::extend() in ShippingServiceProvider), each of
|
||||||
|
* which declares its own fulfillment type and correctly decodes
|
||||||
|
* ShippingMethod's translatable `name` column (see StorePickupRateDriver's
|
||||||
|
* own docblock for why the vendor `collection` driver specifically had to
|
||||||
|
* go — it read $shippingMethod->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)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user