From 3497553b41936f6839b8f98a611eba59ad8dca9b Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Tue, 1 Sep 2026 14:04:12 +0300 Subject: [PATCH] Feature: Order Service Provider, Order Events Observers --- composer.json | 3 +- .../order/notifications/captured.blade.php | 3 + .../order/notifications/delivered.blade.php | 3 + .../order/notifications/refunded.blade.php | 3 + .../notifications/status-updated.blade.php | 3 + src/Order/Enums/FulfillmentStatus.php | 15 +++ src/Order/Enums/PaymentStatus.php | 17 ++++ src/Order/Events/OrderCaptured.php | 24 +++++ src/Order/Events/OrderDelivered.php | 24 +++++ src/Order/Events/OrderRefunded.php | 25 +++++ src/Order/Events/OrderStatusUpdated.php | 25 +++++ .../DeriveOrderDeliveredFromShipment.php | 31 +++++++ .../OrderCapturedNotification.php | 50 ++++++++++ .../OrderDeliveredNotification.php | 49 ++++++++++ .../OrderRefundedNotification.php | 50 ++++++++++ .../OrderStatusUpdatedNotification.php | 50 ++++++++++ src/Order/Observers/OrderObserver.php | 22 +++++ src/Order/Observers/TransactionObserver.php | 27 ++++++ src/Order/Support/OrderStatus.php | 93 +++++++++++++++++++ src/Providers/OrderServiceProvider.php | 47 ++++++++++ 20 files changed, 563 insertions(+), 1 deletion(-) create mode 100644 resources/views/order/notifications/captured.blade.php create mode 100644 resources/views/order/notifications/delivered.blade.php create mode 100644 resources/views/order/notifications/refunded.blade.php create mode 100644 resources/views/order/notifications/status-updated.blade.php create mode 100644 src/Order/Enums/FulfillmentStatus.php create mode 100644 src/Order/Enums/PaymentStatus.php create mode 100644 src/Order/Events/OrderCaptured.php create mode 100644 src/Order/Events/OrderDelivered.php create mode 100644 src/Order/Events/OrderRefunded.php create mode 100644 src/Order/Events/OrderStatusUpdated.php create mode 100644 src/Order/Listeners/DeriveOrderDeliveredFromShipment.php create mode 100644 src/Order/Notifications/OrderCapturedNotification.php create mode 100644 src/Order/Notifications/OrderDeliveredNotification.php create mode 100644 src/Order/Notifications/OrderRefundedNotification.php create mode 100644 src/Order/Notifications/OrderStatusUpdatedNotification.php create mode 100644 src/Order/Observers/OrderObserver.php create mode 100644 src/Order/Observers/TransactionObserver.php create mode 100644 src/Order/Support/OrderStatus.php create mode 100644 src/Providers/OrderServiceProvider.php diff --git a/composer.json b/composer.json index 8d71681..4e06f6b 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,8 @@ "Modules\\Core\\Providers\\CatalogServiceProvider", "Modules\\Core\\Providers\\CartServiceProvider", "Modules\\Core\\Providers\\ReviewServiceProvider", - "Modules\\Core\\Providers\\ShippingServiceProvider" + "Modules\\Core\\Providers\\ShippingServiceProvider", + "Modules\\Core\\Providers\\OrderServiceProvider" ] } }, diff --git a/resources/views/order/notifications/captured.blade.php b/resources/views/order/notifications/captured.blade.php new file mode 100644 index 0000000..06b97e2 --- /dev/null +++ b/resources/views/order/notifications/captured.blade.php @@ -0,0 +1,3 @@ +

Hi,

+ +

Payment of {{ $amount }} for your order {{ $reference }} has been captured.

diff --git a/resources/views/order/notifications/delivered.blade.php b/resources/views/order/notifications/delivered.blade.php new file mode 100644 index 0000000..5e3f1d4 --- /dev/null +++ b/resources/views/order/notifications/delivered.blade.php @@ -0,0 +1,3 @@ +

Hi,

+ +

Good news — your order {{ $reference }} has been delivered.

diff --git a/resources/views/order/notifications/refunded.blade.php b/resources/views/order/notifications/refunded.blade.php new file mode 100644 index 0000000..59228a2 --- /dev/null +++ b/resources/views/order/notifications/refunded.blade.php @@ -0,0 +1,3 @@ +

Hi,

+ +

A refund of {{ $amount }} has been issued for your order {{ $reference }}.

diff --git a/resources/views/order/notifications/status-updated.blade.php b/resources/views/order/notifications/status-updated.blade.php new file mode 100644 index 0000000..fbcd470 --- /dev/null +++ b/resources/views/order/notifications/status-updated.blade.php @@ -0,0 +1,3 @@ +

Hi,

+ +

Your order {{ $reference }} is now: {{ $statusLabel }}

diff --git a/src/Order/Enums/FulfillmentStatus.php b/src/Order/Enums/FulfillmentStatus.php new file mode 100644 index 0000000..1f13890 --- /dev/null +++ b/src/Order/Enums/FulfillmentStatus.php @@ -0,0 +1,15 @@ + capture rather than creating a new one, so this can't + * key off `wasRecentlyCreated` the way OrderRefunded does. + */ +class OrderCaptured +{ + use Dispatchable; + + public function __construct( + public readonly Order $order, + public readonly Transaction $transaction, + ) {} +} diff --git a/src/Order/Events/OrderDelivered.php b/src/Order/Events/OrderDelivered.php new file mode 100644 index 0000000..3e011bf --- /dev/null +++ b/src/Order/Events/OrderDelivered.php @@ -0,0 +1,24 @@ +shipmentInfo->status !== TrackingStatus::Delivered) { + return; + } + + $order = $event->shipmentInfo->shipment->order; + + if (! $order) { + return; + } + + OrderDelivered::dispatch($order, $event->shipmentInfo); + } +} diff --git a/src/Order/Notifications/OrderCapturedNotification.php b/src/Order/Notifications/OrderCapturedNotification.php new file mode 100644 index 0000000..87f59ab --- /dev/null +++ b/src/Order/Notifications/OrderCapturedNotification.php @@ -0,0 +1,50 @@ +event->order; + + $email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email; + + return NotificationFacade::route('mail', $email); + } + + public function toMail(object $notifiable): MailMessage + { + $order = $this->event->order; + + return (new MailMessage) + ->subject(__('Payment captured for your order :reference', ['reference' => $order->reference])) + ->view('core::order.notifications.captured', [ + 'reference' => $order->reference, + 'amount' => $this->event->transaction->amount->formatted, + ]); + } +} diff --git a/src/Order/Notifications/OrderDeliveredNotification.php b/src/Order/Notifications/OrderDeliveredNotification.php new file mode 100644 index 0000000..8c68d47 --- /dev/null +++ b/src/Order/Notifications/OrderDeliveredNotification.php @@ -0,0 +1,49 @@ +event->order; + + $email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email; + + return NotificationFacade::route('mail', $email); + } + + public function toMail(object $notifiable): MailMessage + { + $order = $this->event->order; + + return (new MailMessage) + ->subject(__('Your order :reference has been delivered', ['reference' => $order->reference])) + ->view('core::order.notifications.delivered', [ + 'reference' => $order->reference, + ]); + } +} diff --git a/src/Order/Notifications/OrderRefundedNotification.php b/src/Order/Notifications/OrderRefundedNotification.php new file mode 100644 index 0000000..b5a4d3e --- /dev/null +++ b/src/Order/Notifications/OrderRefundedNotification.php @@ -0,0 +1,50 @@ +event->order; + + $email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email; + + return NotificationFacade::route('mail', $email); + } + + public function toMail(object $notifiable): MailMessage + { + $order = $this->event->order; + + return (new MailMessage) + ->subject(__('A refund has been issued for your order :reference', ['reference' => $order->reference])) + ->view('core::order.notifications.refunded', [ + 'reference' => $order->reference, + 'amount' => $this->event->transaction->amount->formatted, + ]); + } +} diff --git a/src/Order/Notifications/OrderStatusUpdatedNotification.php b/src/Order/Notifications/OrderStatusUpdatedNotification.php new file mode 100644 index 0000000..c2259f8 --- /dev/null +++ b/src/Order/Notifications/OrderStatusUpdatedNotification.php @@ -0,0 +1,50 @@ +event->order; + + $email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email; + + return NotificationFacade::route('mail', $email); + } + + public function toMail(object $notifiable): MailMessage + { + $order = $this->event->order; + + return (new MailMessage) + ->subject(__('Your order :reference has been updated', ['reference' => $order->reference])) + ->view('core::order.notifications.status-updated', [ + 'reference' => $order->reference, + 'statusLabel' => config("lunar.orders.statuses.{$order->status}.label", $order->status), + ]); + } +} diff --git a/src/Order/Observers/OrderObserver.php b/src/Order/Observers/OrderObserver.php new file mode 100644 index 0000000..9cfbef8 --- /dev/null +++ b/src/Order/Observers/OrderObserver.php @@ -0,0 +1,22 @@ +wasChanged('status')) { + return; + } + + OrderStatusUpdated::dispatch( + $order, + $order->getOriginal('status'), + $order->status, + ); + } +} diff --git a/src/Order/Observers/TransactionObserver.php b/src/Order/Observers/TransactionObserver.php new file mode 100644 index 0000000..d5b862f --- /dev/null +++ b/src/Order/Observers/TransactionObserver.php @@ -0,0 +1,27 @@ +success) { + return; + } + + if ($transaction->type === 'refund' && $transaction->wasRecentlyCreated) { + OrderRefunded::dispatch($transaction->order, $transaction); + + return; + } + + if ($transaction->type === 'capture' && $transaction->wasChanged('type')) { + OrderCaptured::dispatch($transaction->order, $transaction); + } + } +} diff --git a/src/Order/Support/OrderStatus.php b/src/Order/Support/OrderStatus.php new file mode 100644 index 0000000..364aa6e --- /dev/null +++ b/src/Order/Support/OrderStatus.php @@ -0,0 +1,93 @@ +transactions; + + $intentTotal = $transactions + ->filter(fn ($t) => $t->type === 'intent' && $t->success) + ->sum('amount.value'); + + $captureTotal = $transactions + ->filter(fn ($t) => $t->type === 'capture' && $t->success) + ->sum('amount.value'); + + $refundTotal = $transactions + ->filter(fn ($t) => $t->type === 'refund' && $t->success) + ->sum('amount.value'); + + $total = $intentTotal ?: $captureTotal; + + if (! $total) { + return PaymentStatus::Offline; + } + + if ( + ($refundTotal && $refundTotal < $total) || + ($captureTotal && $captureTotal < $intentTotal) + ) { + return PaymentStatus::PartialRefund; + } + + if ($refundTotal >= $total) { + return PaymentStatus::Refunded; + } + + if ($captureTotal >= $intentTotal) { + return PaymentStatus::Captured; + } + + return PaymentStatus::Uncaptured; + } + + /** + * Reads shipments.shipmentInfo if already eager-loaded (the caller's + * job — e.g. Order::with('shipments.shipmentInfo')) and picks the + * latest checkpoint in PHP, instead of Shipment::latestShipmentInfo()'s + * per-shipment query — calling this across a list of orders would + * otherwise be an extra query per shipment. + */ + public static function fulfillment(Order $order): FulfillmentStatus + { + $shipments = $order->shipments->reject(fn ($shipment) => $shipment->cancelled_at !== null); + + if ($shipments->isEmpty()) { + return FulfillmentStatus::Unfulfilled; + } + + $latestStatuses = $shipments->map(function ($shipment) { + $latest = $shipment->relationLoaded('shipmentInfo') + ? $shipment->shipmentInfo->sortByDesc('occurred_at')->first() + : $shipment->latestShipmentInfo(); + + return $latest?->status ?? TrackingStatus::Pending; + }); + + if ($latestStatuses->every(fn (TrackingStatus $status) => $status === TrackingStatus::Delivered)) { + return FulfillmentStatus::Delivered; + } + + if ($latestStatuses->contains(fn (TrackingStatus $status) => $status === TrackingStatus::Delivered)) { + return FulfillmentStatus::PartiallyShipped; + } + + return FulfillmentStatus::Shipped; + } +} diff --git a/src/Providers/OrderServiceProvider.php b/src/Providers/OrderServiceProvider.php new file mode 100644 index 0000000..02d59d2 --- /dev/null +++ b/src/Providers/OrderServiceProvider.php @@ -0,0 +1,47 @@ + OrderStatus::payment($this)); + Order::macro('fulfillmentStatus', fn () => OrderStatus::fulfillment($this)); + + Event::listen(ShipmentStatusUpdatedByCarrier::class, DeriveOrderDeliveredFromShipment::class); + + NotificationRegistry::get()->register([ + OrderDeliveredNotification::class, + OrderStatusUpdatedNotification::class, + OrderRefundedNotification::class, + OrderCapturedNotification::class, + ]); + + // Lets the consuming app override copy/markup without forking core + // — published into resources/views/vendor/core/order/notifications, + // which loadViewsFrom() (CoreServiceProvider) already resolves + // ahead of the package's own views for the `core::` namespace. + $this->publishes([ + __DIR__ . '/../../resources/views/order/notifications' => resource_path('views/vendor/core/order/notifications'), + ], 'core-views'); + } +}