Feature: Adding Caches and Fallbacks to Live Pricing Requests

This commit is contained in:
2026-08-29 10:31:58 +03:00
parent 6671c5e9d1
commit f85fb51ecd
5 changed files with 182 additions and 44 deletions
+11
View File
@@ -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.
+35 -23
View File
@@ -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
@@ -0,0 +1,38 @@
<?php
namespace Modules\Core\Shipping\Concerns;
use Illuminate\Support\Facades\Cache;
use Lunar\DataTypes\ShippingOption;
use Lunar\Models\Cart;
use Lunar\Shipping\Models\ShippingRate;
/**
* Shared by any Modules\Core\Shipping\Contracts\SupportsLivePricing driver —
* a live carrier price quote is a real, billed API call, but stable from
* one fetch to the next within a single checkout attempt. Keyed by rate id
* + cart id, so two different live-priced rates (e.g. ACS and a future
* carrier) never collide — each is its own ShippingRate row. Not shared
* across carts: the quote depends on cart-specific weight/quantity/
* destination (see docs/checkout.md).
*
* Invalidated by Modules\Core\Shipping\Listeners\FlushLivePricingCache on
* the only two things that can change what this cart's quote should be: a
* cart line changing (add/update/remove/clear) or the shipping address
* changing. Deliberately NOT invalidated on order placement — the price
* the shopper was quoted must still be there if anything re-reads it after
* the order exists; Cart::createOrder() persists the resolved price onto
* the order anyway, so nothing should be re-querying the live driver for
* that cart again regardless of cache state.
*/
trait CachesLivePricing
{
private function cached(ShippingRate $shippingRate, Cart $cart, \Closure $resolve): ?ShippingOption
{
return Cache::remember(
"shipping.live_price.{$shippingRate->id}.{$cart->id}",
now()->addMinutes(30),
$resolve,
);
}
}
@@ -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)) {
@@ -0,0 +1,61 @@
<?php
namespace Modules\Core\Shipping\Listeners;
use Illuminate\Support\Facades\Cache;
use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Models\ShippingRate;
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\Contracts\SupportsLivePricing;
/**
* Flushes Modules\Core\Shipping\Concerns\CachesLivePricing's cached quotes
* for a cart on the only two things that can change what they should be: a
* cart line changing (weight/quantity) or the shipping address changing
* (destination). See that trait's docblock for why order placement is
* deliberately not a trigger here.
*
* Only rates whose method's driver implements SupportsLivePricing are ever
* cached by CachesLivePricing, so only their ids need a forget() call —
* no need to touch every ShippingRate row on every cart change.
*/
class FlushLivePricingCache
{
public function handle(CartLineAdded|CartLineUpdated|CartLineRemoved|CartCleared|ShippingAddressSet $event): void
{
$cart = $event->cart;
foreach ($this->livePricingRateIds() as $rateId) {
Cache::forget("shipping.live_price.{$rateId}.{$cart->id}");
}
}
/**
* @return array<int>
*/
private function livePricingRateIds(): array
{
return ShippingRate::query()
->whereHas('shippingMethod', fn ($query) => $query->whereIn(
'driver',
$this->liveDriverKeys(),
))
->pluck('id')
->all();
}
/**
* @return array<string>
*/
private function liveDriverKeys(): array
{
return Shipping::getSupportedDrivers()
->filter(fn ($driver) => $driver instanceof SupportsLivePricing)
->keys()
->all();
}
}