From 99e55902ac3c55bb683f1b9dc46be83c37d95a42 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 10 Sep 2026 01:34:30 +0300 Subject: [PATCH] Feat: Updating OrderPlaced Listeners to Decrement Stock, Creating Notifications --- .../order/notifications/placed.blade.php | 11 ++++ src/Catalog/Services/ProductIndexer.php | 8 ++- .../Listeners/DecrementStockOnOrderPlaced.php | 64 +++++++++++++++++++ .../Notifications/OrderPlacedNotification.php | 62 ++++++++++++++++++ src/Providers/OrderServiceProvider.php | 5 ++ 5 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 resources/views/order/notifications/placed.blade.php create mode 100644 src/Order/Listeners/DecrementStockOnOrderPlaced.php create mode 100644 src/Order/Notifications/OrderPlacedNotification.php diff --git a/resources/views/order/notifications/placed.blade.php b/resources/views/order/notifications/placed.blade.php new file mode 100644 index 0000000..0e1b7aa --- /dev/null +++ b/resources/views/order/notifications/placed.blade.php @@ -0,0 +1,11 @@ +

Hi,

+ +

Thanks for your order! Your order {{ $reference }} is confirmed.

+ + + +

Total: {{ $total }}

diff --git a/src/Catalog/Services/ProductIndexer.php b/src/Catalog/Services/ProductIndexer.php index bf97f38..0ed4516 100644 --- a/src/Catalog/Services/ProductIndexer.php +++ b/src/Catalog/Services/ProductIndexer.php @@ -47,8 +47,12 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media; * quantity 1, via ProductVariant::canBeFulfilledAtQuantity() (Lunar's own * purchasability rule: `purchasable === 'always'` is always true regardless of * stock, `in_stock` checks stock alone, anything else checks stock+backorder). - * Reflects stock as of the last reindex only — nothing currently reindexes a - * product when an order decrements its stock (see docs/product-listing.md). + * Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced reindexes a product + * the moment an order placed against it decrements its stock — see that + * class's own docblock for why only `purchasable === 'in_stock'` + * variants are ever touched. Any other stock edit (a manual admin + * change, a future inventory-sync integration) still only reflects here + * as of the next reindex (see docs/product-listing.md). * * - recommendations (recommendations.id filterable): [{id, name, price, image}, ...] * up to 4 other products to show alongside this one (a "related products" diff --git a/src/Order/Listeners/DecrementStockOnOrderPlaced.php b/src/Order/Listeners/DecrementStockOnOrderPlaced.php new file mode 100644 index 0000000..67e497f --- /dev/null +++ b/src/Order/Listeners/DecrementStockOnOrderPlaced.php @@ -0,0 +1,64 @@ +decrement()`), not a + * read-then-write on the Eloquent model — avoids a lost-update race between + * two orders decrementing the same variant concurrently, and skips + * Modules\Core\Catalog\Services\ProductIndexer::stock's staleness gap for + * the DB value itself even though the search index still only refreshes on + * the next reindex event/nightly job (see that class's own docblock). + * + * Never lets stock go negative (`GREATEST(stock - qty, 0)` via a raw + * expression) — an order can still be placed against a variant whose stock + * was already fully consumed by another concurrent order (Lunar has no + * stock-reservation step at cart/checkout time), so this is a best-effort + * count, not a hard inventory guarantee. + */ +class DecrementStockOnOrderPlaced +{ + public function handle(OrderPlaced $event): void + { + $lines = $event->order->lines() + ->where('type', 'physical') + ->where('purchasable_type', ProductVariant::morphName()) + ->get(['purchasable_id', 'quantity']); + + foreach ($lines as $line) { + DB::table((new ProductVariant())->getTable()) + ->where('id', $line->purchasable_id) + ->where('purchasable', 'in_stock') + ->update([ + 'stock' => DB::raw('GREATEST(stock - '.(int) $line->quantity.', 0)'), + ]); + } + + $productIds = ProductVariant::whereIn('id', $lines->pluck('purchasable_id')) + ->pluck('product_id') + ->unique(); + + Product::whereIn('id', $productIds)->get()->each->searchable(); + } +} diff --git a/src/Order/Notifications/OrderPlacedNotification.php b/src/Order/Notifications/OrderPlacedNotification.php new file mode 100644 index 0000000..b2138c3 --- /dev/null +++ b/src/Order/Notifications/OrderPlacedNotification.php @@ -0,0 +1,62 @@ +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 is confirmed', ['reference' => $order->reference])) + ->view('core::order.notifications.placed', [ + 'reference' => $order->reference, + 'total' => $order->total->formatted, + 'lines' => $order->lines, + ]); + } +} diff --git a/src/Providers/OrderServiceProvider.php b/src/Providers/OrderServiceProvider.php index 764847c..7566e8c 100644 --- a/src/Providers/OrderServiceProvider.php +++ b/src/Providers/OrderServiceProvider.php @@ -6,12 +6,15 @@ 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\Listeners\ApplyResolvedPaymentStatus; +use Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced; use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment; use Modules\Core\Order\Listeners\RecordPaymentTransaction; use Modules\Core\Order\Notifications\OrderCapturedNotification; use Modules\Core\Order\Notifications\OrderDeliveredNotification; +use Modules\Core\Order\Notifications\OrderPlacedNotification; use Modules\Core\Order\Notifications\OrderRefundedNotification; use Modules\Core\Order\Notifications\OrderStatusUpdatedNotification; use Modules\Core\Order\Observers\OrderObserver; @@ -41,12 +44,14 @@ class OrderServiceProvider extends ServiceProvider Event::listen(PaymentAuthorized::class, RecordPaymentTransaction::class); Event::listen(PaymentVoided::class, RecordPaymentTransaction::class); Event::listen(PaymentRefunded::class, RecordPaymentTransaction::class); + Event::listen(OrderPlaced::class, DecrementStockOnOrderPlaced::class); NotificationRegistry::get()->register([ OrderDeliveredNotification::class, OrderStatusUpdatedNotification::class, OrderRefundedNotification::class, OrderCapturedNotification::class, + OrderPlacedNotification::class, ]); // Lets the consuming app override copy/markup without forking core