Feature: Order Updates, Events, Order Flows, Shipment And COD support

This commit is contained in:
2026-09-14 00:03:06 +03:00
parent 78bbd8390a
commit 44c6b7defd
73 changed files with 2854 additions and 464 deletions
+48 -3
View File
@@ -2,20 +2,33 @@
namespace Modules\Core\Providers;
use Illuminate\Console\Scheduling\Schedule as ConsoleSchedule;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Lunar\Models\Order;
use Lunar\Models\Transaction;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Notification\NotificationRegistry;
use Modules\Core\Order\Commands\CloseExpiredReturnWindows;
use Modules\Core\Order\Events\OrderDelivered;
use Modules\Core\Order\Events\OrderPaidChanged;
use Modules\Core\Order\Events\OrderPickedUp;
use Modules\Core\Order\Events\OrderStatusChanged;
use Modules\Core\Order\Listeners\AdvanceFulfillmentOnCarrierCheckpoint;
use Modules\Core\Order\Listeners\AdvanceFulfillmentOnDelivered;
use Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus;
use Modules\Core\Order\Listeners\CompleteOrderOnDelivered;
use Modules\Core\Order\Listeners\CompleteOrderOnPickedUp;
use Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced;
use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment;
use Modules\Core\Order\Listeners\MarkDeliveryFailedOnCarrierCheckpoint;
use Modules\Core\Order\Listeners\RecordPaymentTransaction;
use Modules\Core\Order\Listeners\RecordStatusTransition;
use Modules\Core\Order\Models\OrderStatusTransition;
use Modules\Core\Order\Notifications\OrderCapturedNotification;
use Modules\Core\Order\Notifications\OrderCompletedNotification;
use Modules\Core\Order\Notifications\OrderDeliveredNotification;
use Modules\Core\Order\Notifications\OrderDispatchedNotification;
use Modules\Core\Order\Notifications\OrderPickupReadyNotification;
use Modules\Core\Order\Notifications\OrderPlacedNotification;
use Modules\Core\Order\Notifications\OrderRefundedNotification;
use Modules\Core\Order\Notifications\OrderStatusUpdatedNotification;
@@ -38,23 +51,43 @@ class OrderServiceProvider extends ServiceProvider
Order::macro('paymentStatus', fn () => OrderStatus::payment($this));
Order::macro('fulfillmentStatus', fn () => OrderStatus::fulfillment($this));
Order::resolveRelationUsing('statusTransitions', function ($order) {
return $order->hasMany(OrderStatusTransition::class);
});
Event::listen(ShipmentStatusUpdatedByCarrier::class, DeriveOrderDeliveredFromShipment::class);
Event::listen(OrderDelivered::class, CompleteOrderOnDelivered::class);
Event::listen(ShipmentStatusUpdatedByCarrier::class, AdvanceFulfillmentOnCarrierCheckpoint::class);
Event::listen(ShipmentStatusUpdatedByCarrier::class, MarkDeliveryFailedOnCarrierCheckpoint::class);
Event::listen(OrderDelivered::class, AdvanceFulfillmentOnDelivered::class);
Event::listen(OrderPickedUp::class, CompleteOrderOnPickedUp::class);
Event::listen(PaymentCaptured::class, ApplyResolvedPaymentStatus::class);
Event::listen(PaymentAuthorized::class, ApplyResolvedPaymentStatus::class);
Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class);
Event::listen(PaymentCaptured::class, RecordPaymentTransaction::class);
Event::listen(PaymentAuthorized::class, RecordPaymentTransaction::class);
Event::listen(PaymentVoided::class, RecordPaymentTransaction::class);
// RecordPaymentTransaction must run before ApplyResolvedPaymentStatus
// for PaymentRefunded specifically — the latter now reads
// $order->transactions (via OrderStatus::payment()) to resolve
// Refunded vs PartiallyRefunded, which requires the refund
// Transaction row to already exist.
Event::listen(PaymentRefunded::class, RecordPaymentTransaction::class);
Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class);
Event::listen(OrderPlaced::class, DecrementStockOnOrderPlaced::class);
Event::listen(OrderStatusChanged::class, [RecordStatusTransition::class, 'handleStatusChanged']);
Event::listen(OrderPaidChanged::class, [RecordStatusTransition::class, 'handlePaidChanged']);
NotificationRegistry::get()->register([
OrderDeliveredNotification::class,
OrderStatusUpdatedNotification::class,
OrderRefundedNotification::class,
OrderCapturedNotification::class,
OrderPlacedNotification::class,
OrderPickupReadyNotification::class,
OrderDispatchedNotification::class,
OrderCompletedNotification::class,
]);
// Lets the consuming app override copy/markup without forking core
@@ -64,5 +97,17 @@ class OrderServiceProvider extends ServiceProvider
$this->publishes([
__DIR__ . '/../../resources/views/order/notifications' => resource_path('views/vendor/core/order/notifications'),
], 'core-views');
if ($this->app->runningInConsole()) {
$this->commands([CloseExpiredReturnWindows::class]);
}
// Exact midnight per an explicit compliance requirement — not a
// loose dailyAt() offset or plain daily().
$this->app->booted(function () {
$this->app->make(ConsoleSchedule::class)
->command(CloseExpiredReturnWindows::class)
->dailyAt('00:00');
});
}
}
+2
View File
@@ -9,6 +9,7 @@ use Lunar\Models\Contracts\Transaction as TransactionContract;
use Lunar\Pipelines\Cart\ApplyShipping;
use Modules\Core\Command\SyncPaymentDriversCommand;
use Modules\Core\Payment\Drivers\BankTransferPaymentDriver;
use Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver;
use Modules\Core\Payment\Drivers\OfflinePaymentDriver;
use Modules\Core\Payment\Drivers\StripePaymentDriver;
use Modules\Core\Payment\Events\PaymentMethodCreated;
@@ -33,6 +34,7 @@ class PaymentServiceProvider extends ServiceProvider
$registry->register('offline', OfflinePaymentDriver::class, 'Offline / Manual');
$registry->register('stripe', StripePaymentDriver::class, 'Stripe');
$registry->register('bank-transfer', BankTransferPaymentDriver::class, 'Bank Transfer');
$registry->register('cash-on-delivery', CashOnDeliveryPaymentDriver::class, 'Cash on Delivery');
$cartPipeline = config('lunar.cart.pipelines.cart', []);
$insertAfter = array_search(ApplyShipping::class, $cartPipeline, true);
+27 -10
View File
@@ -65,24 +65,34 @@ class ShippingServiceProvider extends ServiceProvider
__DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'),
], 'core-config');
// Signed-URL auth only, same model as Lunar's own vendor
// lunar.pdf.download route — see Modules\Core\Shipping\Http\
// Controllers\DownloadShipmentLabelController's own docblock.
$this->loadRoutesFrom(__DIR__ . '/../Shipping/routes/web.php');
Order::resolveRelationUsing('shipments', function ($order) {
return $order->hasMany(Shipment::class);
});
// ShippingMethod.data['fulfillment_type'] — see
// ShippingMethodResourceExtension::fulfillmentTypeSelect() for
// where it's set. Defaults to 'carrier' (false here) for any row
// saved before this field existed.
ShippingMethod::macro('isStorePickup', function () {
/** @var ShippingMethod $this */
return ($this->data['fulfillment_type'] ?? 'carrier') === 'store_pickup';
});
// Order has no direct ShippingMethod relation — shippingAddress.
// shipping_option is only ever a code string (see
// Modules\Core\Shipping\Extensions\OrderViewExtension::
// resolveCarrier() for the same lookup pattern already used to
// resolve a carrier driver from it).
//
// Reads ShippingMethod.data['fulfillment_type'] directly rather
// than through a ShippingMethod::macro('isStorePickup', ...) —
// Lunar\Base\Traits\HasModelExtending::__callStatic() (used by
// Lunar\Shipping\Models\ShippingMethod via Lunar\Base\BaseModel)
// intercepts EVERY unmatched static call, including macro()
// itself, and dispatches it as (new static)->macro(...) instead
// of forwarding to Macroable — so a macro registered this way
// silently never gets stored, and hasMacro() always returns
// false. (Lunar\Models\Order is unaffected because it declares
// its own macro() method directly, bypassing __callStatic
// entirely — that's why Order::macro('isStorePickupOrder', ...)
// below still works.) Defaults to 'carrier' (false) for any row
// saved before this field existed.
Order::macro('isStorePickupOrder', function () {
/** @var Order $this */
$code = $this->shippingAddress?->shipping_option;
@@ -91,7 +101,14 @@ class ShippingServiceProvider extends ServiceProvider
return false;
}
return ShippingMethod::where('code', $code)->first()?->isStorePickup() ?? false;
// ->value('data') is deliberately avoided here — on Postgres,
// ->value()/->pluck() on a JSON column silently return the
// wrong result (works fine in ->where(), not in a column
// projection); loading the model and reading its cast
// attribute avoids that entirely.
$method = ShippingMethod::where('code', $code)->first();
return ($method?->data['fulfillment_type'] ?? 'carrier') === 'store_pickup';
});
foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) {