Compare commits

...
5 Commits
13 changed files with 223 additions and 79 deletions
+63
View File
@@ -4,6 +4,69 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.16.3] - 2026-09-10
### Fixed
- Stripe `createAndConfirm()` built its `PaymentIntent` params with
`'automatic_payment_methods' => isset($data['payment_method']) ? null : ['enabled' => true]`. The
Stripe PHP SDK does not omit `null`-valued params from `create()` — it serializes them to an empty
string (`ApiRequestor::_encodeObjects()` → `Util::utf8(null)`), and Stripe's API rejects an empty
`automatic_payment_methods`. Every Stripe charge failed before it started whenever a
`payment_method` was supplied (i.e. every real charge in this flow). Fixed by building `$params`
conditionally so the key is either omitted entirely or set to `['enabled' => true]`, never `null`.
- `Modules\Core\Payment\Filament\Resources\PaymentMethodResource`'s "Driver status" column only
flagged a payment method whose driver *class* no longer resolves (`driver_missing_at`) — it gave
no indication when a driver resolves fine but fails `Configurable::isConfigured()` (e.g. Stripe
enabled in the DB with no `services.stripe.key` set), which `CheckoutService::getPaymentMethods()`
filters out identically. An admin had no way to tell "this method is silently absent at checkout
because of missing config" from "everything's fine" at a glance. The same icon column now also
reflects `isConfigured()`, with a tooltip distinguishing "driver not found" from "missing required
configuration" from "fully configured."
- `Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee` (now `ApplyPaymentMethodFee`) had two
stacked bugs that together meant a configured payment-method fee (e.g. €5 on Cash on Delivery)
never actually reached the cart total:
- `PaymentMethod::where(...)->value('data->fee')` silently returned `null` on Postgres — Laravel's
query builder does not translate the `->` JSON-path column-selector syntax in `value()`/`pluck()`
the way it does inside `where()` clauses, so this resolved to a discarded
`stdClass::$data->fee` property access instead of the actual fee. Fixed by loading the model and
reading the cast `->data['fee']` attribute instead.
- Even with the fee correctly read, adding it directly to `$cart->shippingTotal` didn't survive:
`Lunar\Pipelines\Cart\CalculateTax`, which runs later in the same cart-calculation pipeline,
unconditionally recomputes `shippingTotal` (and shipping tax) from `$cart->shippingBreakdown`'s
item sum — silently discarding anything set only on the plain property. Fixed by adding the fee
as its own `Lunar\Base\ValueObjects\Cart\ShippingBreakdownItem` on `shippingBreakdown` instead,
so it survives `CalculateTax`'s recompute and is correctly included in shipping tax too.
- Also generalized while fixing: the pipeline was hardcoded to the literal type string
`cash-on-delivery`. Renamed to `ApplyPaymentMethodFee` and changed it to look up whichever
`PaymentMethod` row matches `Cart::meta['payment_method']` and apply its own `data.fee` if
present — works for any payment method configured with a fee, not just one specific slug.
- `Modules\Core\Checkout\Services\CheckoutService::selectPaymentMethod()` called `$cart->calculate()`
after saving the new payment method, but `Lunar\Models\Cart::calculate()` no-ops if the cart
instance was already calculated earlier in the same request (`Cart::isCalculated()`) —
`Lunar\Managers\CartSessionManager` memoizes one `Cart` instance per request, so this was true on
every request where the checkout page's initial render had already calculated the cart. The
result: after switching payment methods, the just-saved `meta['payment_method']` change was
persisted, but the cart's totals silently kept reflecting whichever method was calculated *first*
in the request — a shopper switching from Cash in Hand to Cash on Delivery would keep seeing Cash
in Hand's total, with no COD fee applied, until something else forced a fresh calculation. Fixed
by calling `$cart->recalculate()` instead, which forces the pipeline to re-run.
## [0.16.2] - 2026-09-09
### Fixed
- `Lunar\Base\ShippingManifest` is a request-lifetime singleton whose `getOptions()` re-runs the
shipping modifier pipeline without ever clearing its `$options` collection first, and whose
`addOption()` keeps the first entry per `getIdentifier()` and silently drops any later one. In
practice, an option resolved for an earlier shipping address (or cart state) shadowed the
correct one after the address/region changed within the same request — e.g. a carrier priced
differently across two zones that both match an address would keep quoting the stale zone's
price, and `ApplyShipping` would price the cart total off that same stale option. Renamed
`Modules\Core\Shipping\Listeners\FlushLivePricingCache` to
`Modules\Core\Shipping\Listeners\InvalidateShippingOptions` and had it additionally call
`ShippingManifest::clearOptions()`, merged in because both invalidations fire on the exact same
event set (`CartLineAdded`, `CartLineUpdated`, `CartLineRemoved`, `CartCleared`,
`ShippingAddressSet`) — the only inputs the shipping modifier pipeline depends on.
## [0.16.1] - 2026-09-09 ## [0.16.1] - 2026-09-09
### Fixed ### Fixed
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "boboko/core", "name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"type": "library", "type": "library",
"version": "0.16.1", "version": "0.16.3",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
+4 -4
View File
@@ -1,6 +1,6 @@
<?php <?php
use Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee; use Modules\Core\Payment\Pipelines\Cart\ApplyPaymentMethodFee;
return [ return [
/* /*
@@ -9,8 +9,8 @@ return [
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Appended to config('lunar.cart.pipelines.cart') after ApplyShipping so | Appended to config('lunar.cart.pipelines.cart') after ApplyShipping so
| the cash-on-delivery fee is added to the shipping total before the | the selected payment method's own fee (if any) is added to the
| final Calculate step sums everything up. | shipping total before the final Calculate step sums everything up.
| |
| This is the one thing left in this file — everything about WHICH | This is the one thing left in this file — everything about WHICH
| payment methods exist (driver mapping, capture_mode, statuses) moved | payment methods exist (driver mapping, capture_mode, statuses) moved
@@ -23,6 +23,6 @@ return [
| |
*/ */
'cart_pipeline' => [ 'cart_pipeline' => [
ApplyCashOnDeliveryFee::class, ApplyPaymentMethodFee::class,
], ],
]; ];
+19 -11
View File
@@ -173,19 +173,19 @@ class CheckoutService
/** /**
* Records which payment type the shopper picked (Cart::meta * Records which payment type the shopper picked (Cart::meta
* ['payment_method']) — read by e.g. Modules\Core\Payment\Pipelines\ * ['payment_method']) — read by Modules\Core\Payment\Pipelines\
* Cart\ApplyCashOnDeliveryFee to add that type's own cart-total * Cart\ApplyPaymentMethodFee to add that method's own `data.fee` (if
* adjustments before recalculation. * any) before recalculation.
* *
* Also snapshots Cart::fingerprint() into meta, *after* saving the * Also snapshots Cart::fingerprint() into meta, *after* saving the
* chosen type — the fingerprint has to reflect the final total * chosen type — the fingerprint has to reflect the final total
* including any payment-type-specific adjustment (e.g. a COD * including any payment-method-specific fee, which only exists once
* surcharge), which only exists once payment_method is set and the * payment_method is set and the cart recalculates. Captured here,
* cart recalculates. Captured here, server-side, rather than asked of * server-side, rather than asked of the storefront: this is the last
* the storefront: this is the last moment before initiatePayment() that * moment before initiatePayment() that the shopper's reviewed total is
* the shopper's reviewed total is known, and initiatePayment() reads it * known, and initiatePayment() reads it back internally instead of
* back internally instead of taking a fingerprint parameter — a * taking a fingerprint parameter — a storefront should never need to
* storefront should never need to know Cart::fingerprint() exists. * know Cart::fingerprint() exists.
* *
* Does not itself call a payment driver — selecting a method and * Does not itself call a payment driver — selecting a method and
* initiating payment against it are deliberately separate steps, same * initiating payment against it are deliberately separate steps, same
@@ -204,7 +204,15 @@ class CheckoutService
$cart->meta = [...($cart->meta?->toArray() ?? []), 'payment_method' => $type]; $cart->meta = [...($cart->meta?->toArray() ?? []), 'payment_method' => $type];
$cart->save(); $cart->save();
$cart = $cart->calculate(); // Cart::calculate() no-ops if this cart instance was already
// calculated earlier in the request (Cart::isCalculated()) — which
// it will have been if the shopper switches payment method after
// the checkout page's first render already calculated it. Without
// recalculate() forcing a fresh run, the just-saved payment_method
// (and any fee tied to it, see ApplyPaymentMethodFee) would never
// be reflected — the summary would keep showing whichever method
// was calculated first.
$cart = $cart->recalculate();
$cart->meta = [...($cart->meta?->toArray() ?? []), 'checkout_fingerprint' => $cart->fingerprint()]; $cart->meta = [...($cart->meta?->toArray() ?? []), 'checkout_fingerprint' => $cart->fingerprint()];
$cart->save(); $cart->save();
@@ -24,6 +24,7 @@ class StorefrontLabels
'nav.account' => ['en' => 'Account', 'el' => 'Λογαριασμός'], 'nav.account' => ['en' => 'Account', 'el' => 'Λογαριασμός'],
'nav.back' => ['en' => 'Back', 'el' => 'Πίσω'], 'nav.back' => ['en' => 'Back', 'el' => 'Πίσω'],
'nav.contact' => ['en' => 'Contact', 'el' => 'Επικοινωνία'], 'nav.contact' => ['en' => 'Contact', 'el' => 'Επικοινωνία'],
'nav.close' => ['en' => 'Close', 'el' => 'Κλείσιμο'],
'cart.empty' => ['en' => 'Your cart is empty', 'el' => 'Το καλάθι σας είναι άδειο'], 'cart.empty' => ['en' => 'Your cart is empty', 'el' => 'Το καλάθι σας είναι άδειο'],
'cart.checkout' => ['en' => 'Checkout', 'el' => 'Ολοκλήρωση Παραγγελίας'], 'cart.checkout' => ['en' => 'Checkout', 'el' => 'Ολοκλήρωση Παραγγελίας'],
'cart.total' => ['en' => 'Total', 'el' => 'Σύνολο'], 'cart.total' => ['en' => 'Total', 'el' => 'Σύνολο'],
@@ -67,6 +68,7 @@ class StorefrontLabels
'en' => '{0} No products found|{1} Showing :first–:last of :total result|[2,*] Showing :first–:last of :total results', 'en' => '{0} No products found|{1} Showing :first–:last of :total result|[2,*] Showing :first–:last of :total results',
'el' => '{0} Δεν βρέθηκαν προϊόντα|{1} Εμφάνιση :first–:last από :total αποτέλεσμα|[2,*] Εμφάνιση :first–:last από :total αποτελέσματα', 'el' => '{0} Δεν βρέθηκαν προϊόντα|{1} Εμφάνιση :first–:last από :total αποτέλεσμα|[2,*] Εμφάνιση :first–:last από :total αποτελέσματα',
], ],
'shop.all_products' => ['en' => 'All Products', 'el' => 'Όλα τα Προϊόντα'],
'shop.sort_label' => ['en' => 'Sort products', 'el' => 'Ταξινόμηση προϊόντων'], 'shop.sort_label' => ['en' => 'Sort products', 'el' => 'Ταξινόμηση προϊόντων'],
'shop.sort_default' => ['en' => 'Default sorting', 'el' => 'Προεπιλεγμένη ταξινόμηση'], 'shop.sort_default' => ['en' => 'Default sorting', 'el' => 'Προεπιλεγμένη ταξινόμηση'],
'shop.sort_popularity' => ['en' => 'Popularity', 'el' => 'Δημοφιλή'], 'shop.sort_popularity' => ['en' => 'Popularity', 'el' => 'Δημοφιλή'],
+14 -10
View File
@@ -95,17 +95,21 @@ class StripePaymentDriver implements
private function createAndConfirm(string $type, Price $amount, array $data, array $context, string $captureMethod): PaymentResult private function createAndConfirm(string $type, Price $amount, array $data, array $context, string $captureMethod): PaymentResult
{ {
$params = [
'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency),
'currency' => $amount->currency->code,
'capture_method' => $captureMethod,
'confirm' => true,
];
if (isset($data['payment_method'])) {
$params['payment_method'] = $data['payment_method'];
} else {
$params['automatic_payment_methods'] = ['enabled' => true];
}
try { try {
$paymentIntent = Stripe::getClient()->paymentIntents->create([ $paymentIntent = Stripe::getClient()->paymentIntents->create($params);
'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency),
'currency' => $amount->currency->code,
'capture_method' => $captureMethod,
'confirm' => true,
'payment_method' => $data['payment_method'] ?? null,
'automatic_payment_methods' => isset($data['payment_method'])
? null
: ['enabled' => true],
]);
} catch (ApiErrorException $e) { } catch (ApiErrorException $e) {
return $this->declined($type, $amount, $e, $context, authorizing: $captureMethod === 'manual'); return $this->declined($type, $amount, $e, $context, authorizing: $captureMethod === 'manual');
} }
@@ -13,6 +13,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn; use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Events\PaymentMethodsReordered; use Modules\Core\Payment\Events\PaymentMethodsReordered;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
use Modules\Core\Payment\Models\PaymentMethod; use Modules\Core\Payment\Models\PaymentMethod;
@@ -44,10 +45,15 @@ use Modules\Core\Payment\Services\PaymentMethodService;
* already correct in the database by the time it fires. * already correct in the database by the time it fires.
* *
* `driver_missing_at` (set by the `boboko:payment:sync-drivers` command * `driver_missing_at` (set by the `boboko:payment:sync-drivers` command
* when a row's driver no longer resolves) is surfaced as its own table * when a row's driver no longer resolves) drives the "Driver status"
* column, deliberately distinct from `enabled` — an admin needs to tell * column, deliberately distinct from `enabled` — an admin needs to tell
* "I turned this off" apart from "the driver code was removed" at a * "I turned this off" apart from "this driver isn't usable right now" at
* glance, not have both look like the same disabled state. * a glance, not have both look like the same disabled state. That column
* also folds in Configurable::isConfigured() (e.g. Stripe with no API key
* set) — a class-resolves-but-isn't-usable state that CheckoutService::
* getPaymentMethods() filters out identically to a missing driver, so an
* admin needs the same at-a-glance warning for it, not just a silently
* absent checkout option.
* *
* `authorized_status` only appears in the form when `capture_mode` is * `authorized_status` only appears in the form when `capture_mode` is
* "Hold now, charge later" — it's simply unreachable for a "Charge * "Hold now, charge later" — it's simply unreachable for a "Charge
@@ -85,13 +91,12 @@ class PaymentMethodResource extends Resource
IconColumn::make('driver_missing_at') IconColumn::make('driver_missing_at')
->label('Driver status') ->label('Driver status')
->boolean() ->boolean()
->trueIcon('heroicon-o-exclamation-triangle') ->state(fn (PaymentMethod $record) => ! $record->driver_missing_at && static::driverIsConfigured($record->driver))
->falseIcon('heroicon-o-check-circle') ->trueIcon('heroicon-o-check-circle')
->trueColor('danger') ->falseIcon('heroicon-o-exclamation-triangle')
->falseColor('success') ->trueColor('success')
->tooltip(fn (PaymentMethod $record) => $record->driver_missing_at ->falseColor('danger')
? 'Driver not found as of '.$record->driver_missing_at->diffForHumans() ->tooltip(fn (PaymentMethod $record) => static::driverStatusTooltip($record)),
: 'Driver resolves correctly'),
ToggleColumn::make('enabled') ToggleColumn::make('enabled')
->label('Enabled') ->label('Enabled')
->updateStateUsing(fn (PaymentMethod $record, $state) => app(PaymentMethodService::class) ->updateStateUsing(fn (PaymentMethod $record, $state) => app(PaymentMethodService::class)
@@ -260,4 +265,33 @@ class PaymentMethodResource extends Resource
return app(PaymentDriverRegistry::class)->label($key) ?? $key; return app(PaymentDriverRegistry::class)->label($key) ?? $key;
} }
/**
* False for a missing driver too, since Configurable::isConfigured()
* has nothing to ask in that case — driverStatusTooltip() below is
* what tells the two reasons apart for the admin.
*/
private static function driverIsConfigured(?string $key): bool
{
$driver = $key ? app(PaymentDriverRegistry::class)->resolve($key) : null;
if (! $driver instanceof Configurable) {
return false;
}
return $driver->isConfigured();
}
private static function driverStatusTooltip(PaymentMethod $record): string
{
if ($record->driver_missing_at) {
return 'Driver not found as of '.$record->driver_missing_at->diffForHumans();
}
if (! static::driverIsConfigured($record->driver)) {
return 'Driver resolves, but is missing required configuration (e.g. an API key) — it will not be offered at checkout.';
}
return 'Driver resolves correctly and is fully configured.';
}
} }
+1 -1
View File
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
* creatable/deletable, same split Modules\Core\Shipping's own * creatable/deletable, same split Modules\Core\Shipping's own
* shipping_methods table already has (see docs/payments.md): * shipping_methods table already has (see docs/payments.md):
* - type: unique, machine-facing slug (Cart::meta['payment_method'], * - type: unique, machine-facing slug (Cart::meta['payment_method'],
* ApplyCashOnDeliveryFee's lookup key, every Payment event's $type). * ApplyPaymentMethodFee's lookup key, every Payment event's $type).
* - name: admin-facing label. * - name: admin-facing label.
* - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key * - driver: the Modules\Core\Payment\Services\PaymentDriverRegistry key
* — NOT the same as `type`, and not unique (two rows can share one * — NOT the same as `type`, and not unique (two rows can share one
@@ -1,31 +0,0 @@
<?php
namespace Modules\Core\Payment\Pipelines\Cart;
use Closure;
use Lunar\DataTypes\Price;
use Lunar\Models\Contracts\Cart as CartContract;
use Modules\Core\Payment\Models\PaymentMethod;
final class ApplyCashOnDeliveryFee
{
/**
* Called just before cart totals are calculated.
*
* @param Closure(CartContract): mixed $next
*/
public function handle(CartContract $cart, Closure $next): mixed
{
if (($cart->meta['payment_method'] ?? null) === 'cash-on-delivery') {
$fee = (int) (PaymentMethod::where('type', 'cash-on-delivery')->value('data->fee') ?? 0);
$cart->shippingTotal = new Price(
($cart->shippingTotal?->value ?? 0) + $fee,
$cart->currency,
1
);
}
return $next($cart);
}
}
@@ -0,0 +1,49 @@
<?php
namespace Modules\Core\Payment\Pipelines\Cart;
use Closure;
use Lunar\Base\ValueObjects\Cart\ShippingBreakdownItem;
use Lunar\DataTypes\Price;
use Lunar\Models\Contracts\Cart as CartContract;
use Modules\Core\Payment\Models\PaymentMethod;
final class ApplyPaymentMethodFee
{
/**
* Called just before cart totals are calculated, right after
* Lunar\Pipelines\Cart\ApplyShipping. Generic across every
* Modules\Core\Payment\Models\PaymentMethod row, not just cash on
* delivery — whichever type the shopper picked (Cart::meta
* ['payment_method']), its own `data.fee` (set via the Filament "Edit
* fee" action) is applied if present, no matter its slug/name/driver.
*
* Must add the fee as its own Lunar\Base\ValueObjects\Cart\
* ShippingBreakdownItem on $cart->shippingBreakdown rather than
* bumping $cart->shippingTotal directly — the later Lunar\Pipelines\
* Cart\CalculateTax step unconditionally recomputes shippingTotal
* (and shipping tax) from shippingBreakdown's item sum, so a value
* set only on the plain property is silently discarded before the
* cart finishes calculating.
*
* @param Closure(CartContract): mixed $next
*/
public function handle(CartContract $cart, Closure $next): mixed
{
$type = $cart->meta['payment_method'] ?? null;
if ($type) {
$fee = (int) (PaymentMethod::where('type', $type)->first()?->data['fee'] ?? 0);
if ($fee > 0) {
$cart->shippingBreakdown->items->put('payment-method-fee', new ShippingBreakdownItem(
name: 'Payment method fee',
identifier: 'payment-method-fee',
price: new Price($fee, $cart->currency, 1),
));
}
}
return $next($cart);
}
}
+2 -2
View File
@@ -26,7 +26,7 @@ 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\Jobs\PollShipmentTrackingJob; use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
use Modules\Core\Shipping\Listeners\FlushLivePricingCache; use Modules\Core\Shipping\Listeners\InvalidateShippingOptions;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
class ShippingServiceProvider extends ServiceProvider class ShippingServiceProvider extends ServiceProvider
@@ -70,7 +70,7 @@ class ShippingServiceProvider extends ServiceProvider
}); });
foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) { foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) {
Event::listen($event, [FlushLivePricingCache::class, 'handle']); Event::listen($event, [InvalidateShippingOptions::class, 'handle']);
} }
// Deferred: the Shipping facade resolves a binding registered in // Deferred: the Shipping facade resolves a binding registered in
+1 -1
View File
@@ -17,7 +17,7 @@ use Lunar\Shipping\Models\ShippingRate;
* across carts: the quote depends on cart-specific weight/quantity/ * across carts: the quote depends on cart-specific weight/quantity/
* destination (see docs/checkout.md). * destination (see docs/checkout.md).
* *
* Invalidated by Modules\Core\Shipping\Listeners\FlushLivePricingCache on * Invalidated by Modules\Core\Shipping\Listeners\InvalidateShippingOptions on
* the only two things that can change what this cart's quote should be: a * 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 * cart line changing (add/update/remove/clear) or the shipping address
* changing. Deliberately NOT invalidated on order placement — the price * changing. Deliberately NOT invalidated on order placement — the price
@@ -3,6 +3,7 @@
namespace Modules\Core\Shipping\Listeners; namespace Modules\Core\Shipping\Listeners;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Lunar\Facades\ShippingManifest;
use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Models\ShippingRate; use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Cart\Events\CartCleared; use Modules\Core\Cart\Events\CartCleared;
@@ -13,17 +14,29 @@ use Modules\Core\Checkout\Events\ShippingAddressSet;
use Modules\Core\Shipping\Contracts\SupportsLivePricing; use Modules\Core\Shipping\Contracts\SupportsLivePricing;
/** /**
* Flushes Modules\Core\Shipping\Concerns\CachesLivePricing's cached quotes * Invalidates everything that caches or memoises resolved shipping options
* for a cart on the only two things that can change what they should be: a * 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 * cart line changing (weight/quantity) or the shipping address changing
* (destination). See that trait's docblock for why order placement is * (destination).
* deliberately not a trigger here.
* *
* Only rates whose method's driver implements SupportsLivePricing are ever * Two things need clearing here, both stale for the same reason:
* cached by CachesLivePricing, so only their ids need a forget() call — *
* no need to touch every ShippingRate row on every cart change. * - Modules\Core\Shipping\Concerns\CachesLivePricing's per-rate cache (see
* that trait's docblock for why order placement is deliberately not a
* trigger). Only rates whose method's driver implements
* SupportsLivePricing are ever cached by it, so only their ids need a
* forget() call — no need to touch every ShippingRate row on every cart
* change.
* - Lunar\Base\ShippingManifest's $options collection, which is a
* request-lifetime singleton: ShippingManifest::getOptions() re-runs the
* modifier pipeline on every call but never clears $options first, and
* addOption() keeps the first entry per identifier and silently drops any
* later one — so an option resolved for an earlier address/cart state
* shadows the correct one after that state changes, within the same
* request. clearOptions() forces the next getOptions() call to resolve
* fresh.
*/ */
class FlushLivePricingCache class InvalidateShippingOptions
{ {
public function handle(CartLineAdded|CartLineUpdated|CartLineRemoved|CartCleared|ShippingAddressSet $event): void public function handle(CartLineAdded|CartLineUpdated|CartLineRemoved|CartCleared|ShippingAddressSet $event): void
{ {
@@ -32,6 +45,8 @@ class FlushLivePricingCache
foreach ($this->livePricingRateIds() as $rateId) { foreach ($this->livePricingRateIds() as $rateId) {
Cache::forget("shipping.live_price.{$rateId}.{$cart->id}"); Cache::forget("shipping.live_price.{$rateId}.{$cart->id}");
} }
ShippingManifest::clearOptions();
} }
/** /**