From f85fb51ecd7c0a11e5025531d35f6dd05d859577 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sat, 29 Aug 2026 10:31:58 +0300 Subject: [PATCH] Feature: Adding Caches and Fallbacks to Live Pricing Requests --- src/Providers/ShippingServiceProvider.php | 11 ++++ src/Shipping/Carriers/Acs/AcsRateDriver.php | 58 +++++++++++------- src/Shipping/Concerns/CachesLivePricing.php | 38 ++++++++++++ .../Filament/Pages/ManageShippingRates.php | 58 +++++++++++------- .../Listeners/FlushLivePricingCache.php | 61 +++++++++++++++++++ 5 files changed, 182 insertions(+), 44 deletions(-) create mode 100644 src/Shipping/Concerns/CachesLivePricing.php create mode 100644 src/Shipping/Listeners/FlushLivePricingCache.php diff --git a/src/Providers/ShippingServiceProvider.php b/src/Providers/ShippingServiceProvider.php index c93b899..2b77e83 100644 --- a/src/Providers/ShippingServiceProvider.php +++ b/src/Providers/ShippingServiceProvider.php @@ -3,6 +3,7 @@ namespace Modules\Core\Providers; use Illuminate\Console\Scheduling\Schedule as ConsoleSchedule; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use Livewire\Livewire; use Livewire\Mechanisms\ComponentRegistry; @@ -10,6 +11,11 @@ use Lunar\Models\Order; use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates; use Lunar\Shipping\Models\ShippingMethod; +use Modules\Core\Cart\Events\CartCleared; +use Modules\Core\Cart\Events\CartLineAdded; +use Modules\Core\Cart\Events\CartLineRemoved; +use Modules\Core\Cart\Events\CartLineUpdated; +use Modules\Core\Checkout\Events\ShippingAddressSet; use Modules\Core\Shipping\Carriers\Acs\AcsClient; use Modules\Core\Shipping\Carriers\Acs\AcsFulfillmentService; use Modules\Core\Shipping\Carriers\Acs\AcsRateDriver; @@ -20,6 +26,7 @@ use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Filament\Pages\ManageShippingRates; use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob; +use Modules\Core\Shipping\Listeners\FlushLivePricingCache; use Modules\Core\Shipping\Models\Shipment; class ShippingServiceProvider extends ServiceProvider @@ -62,6 +69,10 @@ class ShippingServiceProvider extends ServiceProvider return $order->hasMany(Shipment::class); }); + foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) { + Event::listen($event, [FlushLivePricingCache::class, 'handle']); + } + // Deferred: the Shipping facade resolves a binding registered in // lunarphp/table-rate-shipping's own ShippingServiceProvider::boot(), // and provider boot order between packages isn't guaranteed. diff --git a/src/Shipping/Carriers/Acs/AcsRateDriver.php b/src/Shipping/Carriers/Acs/AcsRateDriver.php index 6c8af72..0daea90 100644 --- a/src/Shipping/Carriers/Acs/AcsRateDriver.php +++ b/src/Shipping/Carriers/Acs/AcsRateDriver.php @@ -8,12 +8,14 @@ use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; 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\ResolvesFixedPricing; use Modules\Core\Shipping\Contracts\SupportsLivePricing; class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing { use ResolvesFixedPricing; + use CachesLivePricing; public ShippingRate $shippingRate; @@ -51,35 +53,45 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing return $this->resolveLivePrice($shippingRate, $shippingMethod, $cart, $postcode); } + /** + * Wrapped in CachesLivePricing's cache so a live-pricing outage within + * the cache window still serves the last successful quote instead of + * immediately falling back. A cold cache during an outage falls back + * to the rate's own configured static price (resolveFixedPrice()) — + * see ManageShippingRates, which now allows a static price to be + * configured on a "live" rate specifically for this fallback. + */ private function resolveLivePrice(ShippingRate $shippingRate, $shippingMethod, $cart, string $postcode): ?ShippingOption { - try { - $destination = $this->areaResolver->resolve($postcode); + return $this->cached($shippingRate, $cart, function () use ($shippingRate, $shippingMethod, $cart, $postcode) { + try { + $destination = $this->areaResolver->resolve($postcode); - $response = $this->client->call('ACS_Price_Calculation', [ - 'Billing_Code' => config('acs.billing_code'), - 'Acs_Station_Destination' => $destination->stationId, - 'Weight' => $this->totalWeightInKg($cart), - 'Pickup_Date' => now()->toDateString(), - 'Charge_Type' => 2, - ])->throwIfError(); - } catch (AcsApiException $e) { - report($e); + $response = $this->client->call('ACS_Price_Calculation', [ + 'Billing_Code' => config('acs.billing_code'), + 'Acs_Station_Destination' => $destination->stationId, + 'Weight' => $this->totalWeightInKg($cart), + 'Pickup_Date' => now()->toDateString(), + 'Charge_Type' => 2, + ])->throwIfError(); + } catch (AcsApiException $e) { + report($e); - return null; - } + return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart); + } - $amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100); + $amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100); - return new ShippingOption( - name: $shippingMethod->name ?: $this->name(), - description: $shippingMethod->description ?: $this->description(), - identifier: $shippingRate->getIdentifier(), - price: new Price($amount, $cart->currency, 1), - taxClass: $shippingRate->getTaxClass(), - taxReference: $shippingRate->getTaxReference(), - meta: ['acs_station_destination' => $destination->stationId], - ); + return new ShippingOption( + name: $shippingMethod->name ?: $this->name(), + description: $shippingMethod->description ?: $this->description(), + identifier: $shippingRate->getIdentifier(), + price: new Price($amount, $cart->currency, 1), + taxClass: $shippingRate->getTaxClass(), + taxReference: $shippingRate->getTaxReference(), + meta: ['acs_station_destination' => $destination->stationId], + ); + }); } public function on(ShippingRate $shippingRate): self diff --git a/src/Shipping/Concerns/CachesLivePricing.php b/src/Shipping/Concerns/CachesLivePricing.php new file mode 100644 index 0000000..bd6bed2 --- /dev/null +++ b/src/Shipping/Concerns/CachesLivePricing.php @@ -0,0 +1,38 @@ +id}.{$cart->id}", + now()->addMinutes(30), + $resolve, + ); + } +} diff --git a/src/Shipping/Filament/Pages/ManageShippingRates.php b/src/Shipping/Filament/Pages/ManageShippingRates.php index bf6cdb3..672e1d1 100644 --- a/src/Shipping/Filament/Pages/ManageShippingRates.php +++ b/src/Shipping/Filament/Pages/ManageShippingRates.php @@ -2,10 +2,12 @@ namespace Modules\Core\Shipping\Filament\Pages; +use Filament\Forms\Components\TextInput; use Filament\Forms\Form; use Filament\Forms\Get; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Model; use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates; use Lunar\Shipping\Models\ShippingMethod; use Lunar\Shipping\Models\ShippingRate; @@ -17,13 +19,20 @@ use Lunar\Shipping\Models\ShippingRate; * ShippingZoneResource::getPages() — is untouched; the container simply * hands back this subclass whenever the vendor class is resolved. * - * Hides the price / price-break fields for a rate whose method has + * Relabels the price / price-break fields for a rate whose method has * charge_by = "live" (see ShippingMethodResourceExtension, which adds that - * option to methods whose driver supports live pricing) — those fields - * would otherwise be dead configuration the driver never reads. Pricing - * strategy (cart_total / weight / live) stays entirely on the Shipping - * Method, matching Lunar's own existing charge_by convention; nothing new - * is stored on the rate itself. + * option to methods whose driver supports live pricing) — they stay + * visible and editable, but as the fallback price used when the live API + * call fails (see AcsRateDriver::resolveLivePrice()), not the primary + * price. Pricing strategy (cart_total / weight / live) stays entirely on + * the Shipping Method, matching Lunar's own existing charge_by convention; + * nothing new is stored on the rate itself. + * + * Also re-binds the vendor price field's afterStateHydrated(): the vendor + * callback reads $record->basePrices->first()->price->decimal with no + * null-guard, which crashes on any rate with no basePrices row — routine + * for a live rate that has never had a fallback price configured. Same + * logic, just null-safe. */ class ManageShippingRates extends BaseManageShippingRates { @@ -32,13 +41,13 @@ class ManageShippingRates extends BaseManageShippingRates $form = parent::form($form); return $form->schema( - $this->hidePriceFieldsWhenLive($form->getComponents()) + $this->labelPriceFieldsAsFallbackWhenLive($form->getComponents()) ); } - private function hidePriceFieldsWhenLive(array $components): array + private function labelPriceFieldsAsFallbackWhenLive(array $components): array { - $isNotLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) !== 'live'; + $isLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) === 'live'; foreach ($components as $component) { if (! method_exists($component, 'getName')) { @@ -46,11 +55,25 @@ class ManageShippingRates extends BaseManageShippingRates } if ($component->getName() === 'price') { - $component->visible($isNotLive)->required($isNotLive)->dehydrated(true); + $component->required(fn (Get $get) => ! $isLive($get)) + ->helperText(fn (Get $get) => $isLive($get) + ? 'Used only if the live API call fails.' + : null) + ->afterStateHydrated(static function (TextInput $component, ?Model $record = null): void { + if (! $record) { + return; + } + + $basePrice = $record->basePrices->first(); + + $component->state($basePrice?->price->decimal); + }); } if ($component->getName() === 'prices') { - $component->visible($isNotLive)->dehydrated(true); + $component->helperText(fn (Get $get) => $isLive($get) + ? 'Used only if the live API call fails.' + : null); } } @@ -68,7 +91,9 @@ class ManageShippingRates extends BaseManageShippingRates ->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label')) ->formatStateUsing(function ($state, ShippingRate $record) { if (static::methodChargeBy($record->shipping_method_id) === 'live') { - return 'Live API pricing'; + return $state === null + ? 'Live API pricing, no fallback set' + : $state->price->formatted.' (fallback)'; } return $state?->price->formatted; @@ -80,15 +105,6 @@ class ManageShippingRates extends BaseManageShippingRates ); } - protected static function saveShippingRate(?ShippingRate $shippingRate = null, array $data = []): void - { - if (static::methodChargeBy($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id) === 'live') { - return; - } - - parent::saveShippingRate($shippingRate, $data); - } - protected static function methodChargeBy(ShippingMethod|int|string|null $method): ?string { if (blank($method)) { diff --git a/src/Shipping/Listeners/FlushLivePricingCache.php b/src/Shipping/Listeners/FlushLivePricingCache.php new file mode 100644 index 0000000..b772b07 --- /dev/null +++ b/src/Shipping/Listeners/FlushLivePricingCache.php @@ -0,0 +1,61 @@ +cart; + + foreach ($this->livePricingRateIds() as $rateId) { + Cache::forget("shipping.live_price.{$rateId}.{$cart->id}"); + } + } + + /** + * @return array + */ + private function livePricingRateIds(): array + { + return ShippingRate::query() + ->whereHas('shippingMethod', fn ($query) => $query->whereIn( + 'driver', + $this->liveDriverKeys(), + )) + ->pluck('id') + ->all(); + } + + /** + * @return array + */ + private function liveDriverKeys(): array + { + return Shipping::getSupportedDrivers() + ->filter(fn ($driver) => $driver instanceof SupportsLivePricing) + ->keys() + ->all(); + } +}