Feat: Updating OrderPlaced Listeners to Decrement Stock, Creating Notifications
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
<p>Hi,</p>
|
||||||
|
|
||||||
|
<p>Thanks for your order! Your order <strong>{{ $reference }}</strong> is confirmed.</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
@foreach ($lines as $line)
|
||||||
|
<li>{{ $line->quantity }} × {{ $line->description }} — {{ $line->total?->formatted }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>Total: <strong>{{ $total }}</strong></p>
|
||||||
@@ -47,8 +47,12 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
|||||||
* quantity 1, via ProductVariant::canBeFulfilledAtQuantity() (Lunar's own
|
* quantity 1, via ProductVariant::canBeFulfilledAtQuantity() (Lunar's own
|
||||||
* purchasability rule: `purchasable === 'always'` is always true regardless of
|
* purchasability rule: `purchasable === 'always'` is always true regardless of
|
||||||
* stock, `in_stock` checks stock alone, anything else checks stock+backorder).
|
* stock, `in_stock` checks stock alone, anything else checks stock+backorder).
|
||||||
* Reflects stock as of the last reindex only — nothing currently reindexes a
|
* Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced reindexes a product
|
||||||
* product when an order decrements its stock (see docs/product-listing.md).
|
* 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}, ...]
|
* - recommendations (recommendations.id filterable): [{id, name, price, image}, ...]
|
||||||
* up to 4 other products to show alongside this one (a "related products"
|
* up to 4 other products to show alongside this one (a "related products"
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Order\Listeners;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Lunar\Models\Product;
|
||||||
|
use Lunar\Models\ProductVariant;
|
||||||
|
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only place ProductVariant::stock is written as a result of an order —
|
||||||
|
* fires once per order regardless of capture_mode/driver, same reasoning as
|
||||||
|
* Modules\Core\Order\Notifications\OrderPlacedNotification: OrderPlaced is
|
||||||
|
* dispatched exactly once, from the one place an order's placed_at
|
||||||
|
* actually gets set (Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus),
|
||||||
|
* so this can't double-decrement across a capture/authorize/refund sequence
|
||||||
|
* the way listening to PaymentCaptured directly could.
|
||||||
|
*
|
||||||
|
* Only decrements for `purchasable === 'in_stock'` variants — 'always' and
|
||||||
|
* 'backorder' variants are deliberately allowed to sell past (or without
|
||||||
|
* regard to) their stock count already (see ProductVariant::
|
||||||
|
* canBeFulfilledAtQuantity()), so decrementing their stock would just make
|
||||||
|
* that column an inaccurate, decreasingly-negative number with no purchasing
|
||||||
|
* consequence. Only `OrderLine::type === 'physical'` lines are considered —
|
||||||
|
* a digital line has no stock to decrement (ProductVariant::getType()).
|
||||||
|
*
|
||||||
|
* A single UPDATE per variant (`DB::table(...)->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Order\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Notifications\AnonymousNotifiable;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||||
|
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||||
|
use Modules\Core\Notification\BaseNotification;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The order confirmation email — fires once, for every capture_mode and
|
||||||
|
* driver alike (Stripe, offline, bank-transfer), since OrderPlaced is
|
||||||
|
* dispatched from the one place an order's placed_at actually gets set
|
||||||
|
* (Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus), not from a
|
||||||
|
* driver-specific event like OrderCaptured. Before this existed, an
|
||||||
|
* offline/bank-transfer order got no placement email at all — only a
|
||||||
|
* Stripe (auto-captured) order did, via OrderCapturedNotification, which is
|
||||||
|
* a different concern (payment confirmation, not order confirmation) that
|
||||||
|
* happens to fire at the same moment for that one driver.
|
||||||
|
*/
|
||||||
|
class OrderPlacedNotification extends BaseNotification
|
||||||
|
{
|
||||||
|
public function __construct(private readonly OrderPlaced $event) {}
|
||||||
|
|
||||||
|
public static function getKey(): string
|
||||||
|
{
|
||||||
|
return 'order.placed.customer.mail';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function listensTo(): string
|
||||||
|
{
|
||||||
|
return OrderPlaced::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function notifiable(): AnonymousNotifiable
|
||||||
|
{
|
||||||
|
$order = $this->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,15 @@ use Illuminate\Support\Facades\Event;
|
|||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Lunar\Models\Order;
|
use Lunar\Models\Order;
|
||||||
use Lunar\Models\Transaction;
|
use Lunar\Models\Transaction;
|
||||||
|
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||||
use Modules\Core\Notification\NotificationRegistry;
|
use Modules\Core\Notification\NotificationRegistry;
|
||||||
use Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus;
|
use Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus;
|
||||||
|
use Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced;
|
||||||
use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment;
|
use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment;
|
||||||
use Modules\Core\Order\Listeners\RecordPaymentTransaction;
|
use Modules\Core\Order\Listeners\RecordPaymentTransaction;
|
||||||
use Modules\Core\Order\Notifications\OrderCapturedNotification;
|
use Modules\Core\Order\Notifications\OrderCapturedNotification;
|
||||||
use Modules\Core\Order\Notifications\OrderDeliveredNotification;
|
use Modules\Core\Order\Notifications\OrderDeliveredNotification;
|
||||||
|
use Modules\Core\Order\Notifications\OrderPlacedNotification;
|
||||||
use Modules\Core\Order\Notifications\OrderRefundedNotification;
|
use Modules\Core\Order\Notifications\OrderRefundedNotification;
|
||||||
use Modules\Core\Order\Notifications\OrderStatusUpdatedNotification;
|
use Modules\Core\Order\Notifications\OrderStatusUpdatedNotification;
|
||||||
use Modules\Core\Order\Observers\OrderObserver;
|
use Modules\Core\Order\Observers\OrderObserver;
|
||||||
@@ -41,12 +44,14 @@ class OrderServiceProvider extends ServiceProvider
|
|||||||
Event::listen(PaymentAuthorized::class, RecordPaymentTransaction::class);
|
Event::listen(PaymentAuthorized::class, RecordPaymentTransaction::class);
|
||||||
Event::listen(PaymentVoided::class, RecordPaymentTransaction::class);
|
Event::listen(PaymentVoided::class, RecordPaymentTransaction::class);
|
||||||
Event::listen(PaymentRefunded::class, RecordPaymentTransaction::class);
|
Event::listen(PaymentRefunded::class, RecordPaymentTransaction::class);
|
||||||
|
Event::listen(OrderPlaced::class, DecrementStockOnOrderPlaced::class);
|
||||||
|
|
||||||
NotificationRegistry::get()->register([
|
NotificationRegistry::get()->register([
|
||||||
OrderDeliveredNotification::class,
|
OrderDeliveredNotification::class,
|
||||||
OrderStatusUpdatedNotification::class,
|
OrderStatusUpdatedNotification::class,
|
||||||
OrderRefundedNotification::class,
|
OrderRefundedNotification::class,
|
||||||
OrderCapturedNotification::class,
|
OrderCapturedNotification::class,
|
||||||
|
OrderPlacedNotification::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Lets the consuming app override copy/markup without forking core
|
// Lets the consuming app override copy/markup without forking core
|
||||||
|
|||||||
Reference in New Issue
Block a user