Feature: Updating Listreners, Separating Logic from listeners, Queuing Policies

This commit is contained in:
2026-09-16 23:24:02 +03:00
parent a411e6bbc1
commit a55697ce82
25 changed files with 445 additions and 163 deletions
+10 -2
View File
@@ -49,7 +49,8 @@ boboko-test/
app/ app/
Models/ Models/
Customer.php ← app-level model, extends Modules\Core\Customer\Models\Customer Customer.php ← app-level model, extends Modules\Core\Customer\Models\Customer
User.php ← app-level model, dispatches Modules\Core\Auth\Events\UserCreated User.php ← app-level model, no $dispatchesEvents needed — core dispatches
UserCreated itself (Modules\Core\Auth\Services\UserOtpService)
Staff.php ← app-level model, extends Modules\Core\Auth\Models\Staff Staff.php ← app-level model, extends Modules\Core\Auth\Models\Staff
Lunar/ Lunar/
Extensions/ ← app's own Filament resource extensions (source of truth, wired in PanelServiceProvider) Extensions/ ← app's own Filament resource extensions (source of truth, wired in PanelServiceProvider)
@@ -264,7 +265,14 @@ php artisan vendor:publish --tag=core-config
'auto_create_customer_for_user' => false, 'auto_create_customer_for_user' => false,
``` ```
Both listeners guard against the other direction re-triggering: they call `User::withoutEvents(...)` around `firstOrCreate`/save, so pairing a `Customer` never spuriously fires `UserCreated` (and vice versa) even if both directions are somehow active at once. A guard against the other direction re-triggering is only needed where a real risk exists:
`App\Listeners\CreateUserForCustomerListener` (`boboko-test`, app-level) wraps its
`firstOrCreate` in `User::withoutEvents(...)`, since finding-or-creating a `User` there could
itself fire `UserCreated` and loop back into `CreateCustomerForUser`. `Modules\Core\Customer\
Listeners\CreateCustomerForUser` (core) needs no such guard — it calls a plain
`$model::create([])` on `Customer`, which has no `$dispatchesEvents`/model hooks of its own in
core that could re-trigger anything; the guard belongs only on the side that actually creates a
`User`.
--- ---
+13
View File
@@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Modules\Core\Auth\Events\UserAuthenticated; use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Auth\Events\UserCreated;
use Modules\Core\Auth\Exceptions\OtpThrottledException; use Modules\Core\Auth\Exceptions\OtpThrottledException;
use Modules\Core\Auth\Mail\UserOtpMail; use Modules\Core\Auth\Mail\UserOtpMail;
@@ -75,6 +76,18 @@ class UserOtpService
$model = config('auth.providers.users.model'); $model = config('auth.providers.users.model');
$user = $model::firstOrCreate(['email' => $email]); $user = $model::firstOrCreate(['email' => $email]);
// wasRecentlyCreated is Eloquent's own "did firstOrCreate() just
// INSERT, or did it find an existing row" flag — the only reliable
// way to tell them apart from firstOrCreate()'s return value alone.
// Without this check, a genuinely new signup never fired
// UserCreated at all (this class's own docblock claimed the
// Customer/User pairing cascade "already triggers" here, which was
// false as written — see Modules\Core\Customer\Listeners\
// CreateCustomerForUser, which depends entirely on this event).
if ($user->wasRecentlyCreated) {
Event::dispatch(new UserCreated($user));
}
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT); $code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
$user->otp_code = $code; $user->otp_code = $code;
@@ -2,11 +2,17 @@
namespace Modules\Core\Catalog\Listeners; namespace Modules\Core\Catalog\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Lunar\Models\Product; use Lunar\Models\Product;
use Modules\Core\Catalog\Events\ProductDeleted; use Modules\Core\Catalog\Events\ProductDeleted;
use Modules\Core\Catalog\Events\ProductSaved; use Modules\Core\Catalog\Events\ProductSaved;
/** /**
* Queued — a Meilisearch filter query plus N reindex calls with no
* same-request reader; a few seconds of stale `recommendations` on a
* referencing product's storefront page is a cosmetic, not correctness,
* concern (see the class's own docblock below).
*
* Keeps every product's embedded `recommendations` field (see * Keeps every product's embedded `recommendations` field (see
* ProductIndexer) in sync when a product they recommend changes or is * ProductIndexer) in sync when a product they recommend changes or is
* removed. Unlike Modules\Core\Catalog\Observers\ProductOptionReindexObserver's * removed. Unlike Modules\Core\Catalog\Observers\ProductOptionReindexObserver's
@@ -27,7 +33,7 @@ use Modules\Core\Catalog\Events\ProductSaved;
* SCOUT_QUEUE is configured) reindex job per matched product — this * SCOUT_QUEUE is configured) reindex job per matched product — this
* listener itself does no synchronous Meilisearch writing. * listener itself does no synchronous Meilisearch writing.
*/ */
class ReindexProductsRecommendingProduct class ReindexProductsRecommendingProduct implements ShouldQueue
{ {
public function handleSaved(ProductSaved $event): void public function handleSaved(ProductSaved $event): void
{ {
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace Modules\Core\Catalog\Services;
use Illuminate\Support\Facades\DB;
use Lunar\Models\Order;
use Lunar\Models\Product;
use Lunar\Models\ProductVariant;
/**
* The one place ProductVariant::stock is written as a result of an order —
* previously this lived entirely inside Modules\Core\Order\Listeners\
* DecrementStockOnOrderPlaced, a listener with no Service behind it at
* all, even though stock (the column, its invariants — "never negative",
* "only in_stock variants") is fundamentally a Catalog concern, not an
* Order one. That listener is now a thin caller of this class, matching
* how every other module's event reaction delegates its actual write to
* a Service (e.g. Modules\Core\Order\Listeners\RecordPaymentTransaction
* -> Modules\Core\Order\Services\TransactionRecorder).
*
* 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(...)->update()` with a raw
* expression), 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 StockService
{
public function decrementForOrder(Order $order): void
{
$lines = $order->lines()
->where('type', 'physical')
->where('purchasable_type', ProductVariant::morphName())
->get(['purchasable_id', 'quantity']);
if ($lines->isEmpty()) {
return;
}
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();
}
}
@@ -6,6 +6,19 @@ use Lunar\Facades\ModelManifest;
use Lunar\Models\Contracts\Customer as CustomerContract; use Lunar\Models\Contracts\Customer as CustomerContract;
use Modules\Core\Auth\Events\UserCreated; use Modules\Core\Auth\Events\UserCreated;
/**
* Deliberately NOT queued, even though UserCreated (requesting an OTP
* code) and the login that follows it (submitting the code) are normally
* separate requests with a real time gap between them — that gap is not
* a guarantee this code controls. A busy/backed-up queue (a deploy in
* progress, a crashed worker, a traffic spike) could make this job run
* AFTER the shopper has already logged in and something has read
* $user->latestCustomer() (Modules\Core\Customer\Services\
* CustomerAccountService), silently returning null for a legitimately
* paired user with no retry anywhere to catch it. Kept synchronous so the
* Customer always exists by the time UserCreated's dispatch call returns,
* regardless of queue health.
*/
class CreateCustomerForUser class CreateCustomerForUser
{ {
public function handle(UserCreated $event): void public function handle(UserCreated $event): void
@@ -2,6 +2,7 @@
namespace Modules\Core\Customer\Listeners; namespace Modules\Core\Customer\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Lunar\Models\Address; use Lunar\Models\Address;
use Modules\Core\Customer\Events\CustomerAddressCreated; use Modules\Core\Customer\Events\CustomerAddressCreated;
use Modules\Core\Customer\Events\CustomerAddressDeleted; use Modules\Core\Customer\Events\CustomerAddressDeleted;
@@ -18,8 +19,11 @@ use Modules\Core\Logging\ActivityLogService;
* passed through explicitly on every call, since these events are * passed through explicitly on every call, since these events are
* `web`-guard-caused, not `staff`-guard — see ActivityLogService's own * `web`-guard-caused, not `staff`-guard — see ActivityLogService's own
* docblock for why that parameter exists. * docblock for why that parameter exists.
*
* Queued — a pure audit-log write with no same-request reader; the
* shopper's own request doesn't need this to complete before responding.
*/ */
class LogCustomerAccountActivity class LogCustomerAccountActivity implements ShouldQueue
{ {
public function __construct( public function __construct(
private readonly ActivityLogService $activityLog, private readonly ActivityLogService $activityLog,
@@ -2,12 +2,21 @@
namespace Modules\Core\Localization\Listeners; namespace Modules\Core\Localization\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Localization\Events\LanguageCreated; use Modules\Core\Localization\Events\LanguageCreated;
use Modules\Core\Localization\Events\LanguageDeleted; use Modules\Core\Localization\Events\LanguageDeleted;
use Modules\Core\Localization\Events\LanguageUpdated; use Modules\Core\Localization\Events\LanguageUpdated;
use Modules\Core\Localization\Services\LanguageCache; use Modules\Core\Localization\Services\LanguageCache;
class FlushLanguageCache /**
* Queued — the only reader of this cache is Modules\Core\Localization\
* Middleware\LocaleMiddleware on a LATER storefront request, never the
* same admin request that edited/created/deleted the Language row (that
* request redirects to a fresh page read straight from the DB, not this
* cache). A few seconds of eventual consistency before the queue worker
* picks this up is an acceptable trade for not blocking the admin save.
*/
class FlushLanguageCache implements ShouldQueue
{ {
public function __construct(private readonly LanguageCache $languages) {} public function __construct(private readonly LanguageCache $languages) {}
@@ -2,6 +2,7 @@
namespace Modules\Core\Localization\Listeners; namespace Modules\Core\Localization\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Modules\Core\Localization\Events\TranslationCreated; use Modules\Core\Localization\Events\TranslationCreated;
use Modules\Core\Localization\Events\TranslationDeleted; use Modules\Core\Localization\Events\TranslationDeleted;
@@ -15,8 +16,13 @@ use Spatie\TranslationLoader\LanguageLine;
* `group`/`key` (the old group's cached array never gets told a row left it). * `group`/`key` (the old group's cached array never gets told a row left it).
* This listener flushes every group+locale combination touched by either the * This listener flushes every group+locale combination touched by either the
* old or new state so nothing can remain stale. * old or new state so nothing can remain stale.
*
* Queued — this cache backs `__('storefront.*')` lookups on a LATER
* storefront request, never the same admin request that just edited the
* translation (Filament redirects to a fresh index read straight from the
* DB, not this cache). Safe to let a queue worker pick up.
*/ */
class FlushTranslationCache class FlushTranslationCache implements ShouldQueue
{ {
public function handle(TranslationCreated|TranslationUpdated|TranslationDeleted $event): void public function handle(TranslationCreated|TranslationUpdated|TranslationDeleted $event): void
{ {
@@ -2,6 +2,7 @@
namespace Modules\Core\Localization\Listeners; namespace Modules\Core\Localization\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Modules\Core\Localization\Events\TranslationCreated; use Modules\Core\Localization\Events\TranslationCreated;
use Modules\Core\Localization\Events\TranslationDeleted; use Modules\Core\Localization\Events\TranslationDeleted;
@@ -9,7 +10,12 @@ use Modules\Core\Localization\Events\TranslationUpdated;
use Modules\Core\Logging\ActivityLogService; use Modules\Core\Logging\ActivityLogService;
use Spatie\TranslationLoader\LanguageLine; use Spatie\TranslationLoader\LanguageLine;
class LogTranslationActivity /**
* Queued — a pure audit-log write with no same-request reader (Filament
* redirects to a fresh index page after save, which doesn't read the
* activity log at all).
*/
class LogTranslationActivity implements ShouldQueue
{ {
public function __construct( public function __construct(
private readonly ActivityLogService $activityLog, private readonly ActivityLogService $activityLog,
@@ -2,6 +2,7 @@
namespace Modules\Core\Localization\Listeners; namespace Modules\Core\Localization\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Modules\Core\Localization\Events\LanguageUpdated; use Modules\Core\Localization\Events\LanguageUpdated;
use Spatie\TranslationLoader\LanguageLine; use Spatie\TranslationLoader\LanguageLine;
@@ -12,8 +13,15 @@ use Spatie\TranslationLoader\LanguageLine;
* getTranslationsForGroup($newCode, ...) would silently return nothing for * getTranslationsForGroup($newCode, ...) would silently return nothing for
* that locale even though the translated content still exists. Move the * that locale even though the translated content still exists. Move the
* text.{oldCode} key to text.{newCode} on every affected row instead. * text.{oldCode} key to text.{newCode} on every affected row instead.
*
* Queued — this walks every LanguageLine row containing the old locale key
* with no upper bound, and nothing in the same request needs the migration
* to have completed before responding (a rename is a rare admin action;
* the affected storefront locale is briefly unavailable until the queue
* worker finishes, the same window that already exists before this
* listener runs at all).
*/ */
class MigrateTranslationsForRenamedLanguage class MigrateTranslationsForRenamedLanguage implements ShouldQueue
{ {
public function handle(LanguageUpdated $event): void public function handle(LanguageUpdated $event): void
{ {
@@ -2,12 +2,19 @@
namespace Modules\Core\Order\Listeners; namespace Modules\Core\Order\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Order\Events\OrderDispatched; use Modules\Core\Order\Events\OrderDispatched;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Services\OrderStatusWriter; use Modules\Core\Order\Services\OrderStatusWriter;
use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier; use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
/** /**
* Queued — see Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment's
* own docblock: ShipmentStatusUpdatedByCarrier comes from a scheduled
* polling job, not a webhook, so nothing needs this to complete before a
* request returns.
*
* The automatic half of "Dispatched" — the manual fallback is the staff * The automatic half of "Dispatched" — the manual fallback is the staff
* "Update Status" action (Modules\Core\Shipping\Extensions\ * "Update Status" action (Modules\Core\Shipping\Extensions\
* OrderViewExtension). Listens to ShipmentStatusUpdatedByCarrier directly, * OrderViewExtension). Listens to ShipmentStatusUpdatedByCarrier directly,
@@ -19,14 +26,17 @@ use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
* carrier that skips straight there without a distinct collection * carrier that skips straight there without a distinct collection
* checkpoint. * checkpoint.
* *
* Guarded to only fire from 'ready_for_dispatch' — a late/duplicate * Guarded by OrderStatusFlow::isValidTransition() rather than a hardcoded
* checkpoint, or an order the manual action already advanced, is a * "only fire from 'ready_for_dispatch'" comparison — the single source of
* silent no-op. * truth for the status graph lives there, not duplicated here. A
* late/duplicate checkpoint, or an order the manual action already
* advanced, is a silent no-op either way.
*/ */
class AdvanceFulfillmentOnCarrierCheckpoint class AdvanceFulfillmentOnCarrierCheckpoint implements ShouldQueue
{ {
public function __construct( public function __construct(
private readonly OrderStatusWriter $writer, private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {} ) {}
public function handle(ShipmentStatusUpdatedByCarrier $event): void public function handle(ShipmentStatusUpdatedByCarrier $event): void
@@ -38,7 +48,7 @@ class AdvanceFulfillmentOnCarrierCheckpoint
$order = $event->shipmentInfo->shipment->order; $order = $event->shipmentInfo->shipment->order;
if (! $order || $order->status !== 'ready_for_dispatch') { if (! $order || ! $this->flow->isValidTransition($order, 'dispatched')) {
return; return;
} }
@@ -2,10 +2,18 @@
namespace Modules\Core\Order\Listeners; namespace Modules\Core\Order\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Order\Events\OrderDelivered; use Modules\Core\Order\Events\OrderDelivered;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Services\OrderStatusWriter; use Modules\Core\Order\Services\OrderStatusWriter;
/** /**
* Queued — OrderDelivered is only ever dispatched from Modules\Core\Order\
* Listeners\DeriveOrderDeliveredFromShipment, itself queued (see that
* class's own docblock: the triggering ShipmentStatusUpdatedByCarrier
* comes from a scheduled polling job, not a request with a page waiting
* on the result).
*
* Writes `status` to 'delivered' once a carrier confirms delivery, rather * Writes `status` to 'delivered' once a carrier confirms delivery, rather
* than jumping straight to 'completed'. Carrier orders get a return * than jumping straight to 'completed'. Carrier orders get a return
* window between delivery and completion (see Modules\Core\Order\ * window between delivery and completion (see Modules\Core\Order\
@@ -20,21 +28,23 @@ use Modules\Core\Order\Services\OrderStatusWriter;
* OrderDelivered — deriving "was this delivered" and acting on it by * OrderDelivered — deriving "was this delivered" and acting on it by
* writing `status` are deliberately two different listeners. * writing `status` are deliberately two different listeners.
* *
* Guarded to only fire from 'dispatched' — a duplicate/late Delivered * Guarded by OrderStatusFlow::isValidTransition() rather than a hardcoded
* "only fire from 'dispatched'" comparison. A duplicate/late Delivered
* checkpoint, or an order a manual action already moved past, is a * checkpoint, or an order a manual action already moved past, is a
* silent no-op. * silent no-op either way.
*/ */
class AdvanceFulfillmentOnDelivered class AdvanceFulfillmentOnDelivered implements ShouldQueue
{ {
public function __construct( public function __construct(
private readonly OrderStatusWriter $writer, private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {} ) {}
public function handle(OrderDelivered $event): void public function handle(OrderDelivered $event): void
{ {
$order = $event->order; $order = $event->order;
if ($order->status !== 'dispatched') { if (! $this->flow->isValidTransition($order, 'delivered')) {
return; return;
} }
@@ -2,13 +2,8 @@
namespace Modules\Core\Order\Listeners; namespace Modules\Core\Order\Listeners;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced; use Modules\Core\Order\Services\OrderPaymentResolutionService;
use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Services\OrderStatusWriter;
use Modules\Core\Order\Support\OrderStatus;
use Modules\Core\Payment\Events\PaymentAuthorized; use Modules\Core\Payment\Events\PaymentAuthorized;
use Modules\Core\Payment\Events\PaymentCaptured; use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentRefunded; use Modules\Core\Payment\Events\PaymentRefunded;
@@ -17,38 +12,24 @@ use Modules\Core\Payment\Events\PaymentRefunded;
* Registered against PaymentCaptured, PaymentAuthorized, AND * Registered against PaymentCaptured, PaymentAuthorized, AND
* PaymentRefunded (see OrderServiceProvider). * PaymentRefunded (see OrderServiceProvider).
* *
* PaymentCaptured writes both Order::paid/paid_at (via * A thin reactor — resolves which Order this outcome belongs to (Payment
* OrderStatusWriter::markPaid()) AND advances `status` out of * has no concept of an Order, so this reads $event->context['order_id'])
* 'awaiting_payment' to the next step in the order's flow (see * and hands off to Modules\Core\Order\Services\
* OrderStatusFlow::nextOptions()) — re-confirmed with the user: a * OrderPaymentResolutionService for the actual decisions: whether to mark
* captured payment, manual or via Stripe's webhook, should never leave an * the order paid, whether/how far to advance `status`, and what a refund
* order sitting at 'awaiting_payment'. Only fires when status is still * does to it. See that service's own docblock, and its methods' own
* exactly 'awaiting_payment', so a duplicate/delayed capture event never * docblocks, for the full business reasoning (re-confirmed with the
* regresses an order staff already advanced further. PaymentAuthorized * user): a captured payment, manual or via Stripe's webhook, should
* only marks paid — an authorization is not yet captured funds, so * never leave an order sitting at 'awaiting_payment'; an authorization
* status stays put until the actual capture. * only marks paid, since it isn't yet captured funds; a refund is a
* * normal step in the order's own status sequence, unlike a capture.
* A refund still moves `status` (returned -> refunded/partially_refunded)
* — refunds are a normal step in Modules\Core\Order\Services\
* OrderStatusFlow's own sequence, unlike captures. Derives
* Refunded/PartialRefund from Modules\Core\Order\Support\OrderStatus::
* payment() — the existing, unchanged derived-enum logic, reused rather
* than reimplemented.
*
* Reads $event->context['order_id'] to find which Order this outcome
* belongs to — Payment has no concept of an Order.
*
* Dispatches Checkout\Events\OrderPlaced itself, once placed_at is set.
* Never fires from the PaymentRefunded path — a refund can only ever
* happen after an order was already placed.
* *
* Deliberately does NOT react to PaymentVoided. * Deliberately does NOT react to PaymentVoided.
*/ */
class ApplyResolvedPaymentStatus class ApplyResolvedPaymentStatus
{ {
public function __construct( public function __construct(
private readonly OrderStatusWriter $writer, private readonly OrderPaymentResolutionService $resolution,
private readonly OrderStatusFlow $flow,
) {} ) {}
public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void
@@ -62,58 +43,11 @@ class ApplyResolvedPaymentStatus
$order = Order::findOrFail($orderId); $order = Order::findOrFail($orderId);
if ($event instanceof PaymentRefunded) { if ($event instanceof PaymentRefunded) {
$this->applyRefund($order, $event); $this->resolution->resolveRefund($order, $event::class);
return; return;
} }
$wasPlaced = ! blank($order->placed_at); $this->resolution->resolveCaptureOrAuthorization($order, $event::class, isCapture: $event instanceof PaymentCaptured);
$this->writer->markPaid($order, $event::class);
if ($event instanceof PaymentCaptured) {
$this->advancePastAwaitingPayment($order, $event);
}
if (! $wasPlaced) {
$order->update(['placed_at' => $order->placed_at ?? now()]);
Event::dispatch(new OrderPlaced($order));
}
}
private function advancePastAwaitingPayment(Order $order, PaymentCaptured $event): void
{
if ($order->status !== 'awaiting_payment') {
return;
}
$next = $this->flow->nextOptions($order);
$target = array_key_first($next);
if ($target !== null) {
$this->writer->write($order, $target, $event::class);
}
}
/**
* Requires the refund Transaction row to already exist (Modules\Core\
* Order\Listeners\RecordPaymentTransaction must run first — see
* OrderServiceProvider's listener registration order for
* PaymentRefunded), so the relation is refreshed here rather than
* trusted from a possibly-stale $order instance.
*/
private function applyRefund(Order $order, PaymentRefunded $event): void
{
$order->load('transactions');
$target = match (OrderStatus::payment($order)) {
PaymentStatus::Refunded => 'refunded',
PaymentStatus::PartialRefund => 'partially_refunded',
default => null,
};
if ($target !== null && $order->status !== $target) {
$this->writer->write($order, $target, $event::class);
}
} }
} }
@@ -4,9 +4,19 @@ namespace Modules\Core\Order\Listeners;
use Modules\Core\Order\Events\OrderCompleted; use Modules\Core\Order\Events\OrderCompleted;
use Modules\Core\Order\Events\OrderPickedUp; use Modules\Core\Order\Events\OrderPickedUp;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Services\OrderStatusWriter; use Modules\Core\Order\Services\OrderStatusWriter;
/** /**
* Deliberately NOT queued — OrderPickedUp is dispatched from a staff
* Filament action (see OrderFulfillmentService::markPickedUp()), and the
* page staff are looking at needs to show `status` as 'completed'
* immediately after they click, not still 'picked_up' until a queue
* worker catches up. Unlike ShipmentStatusUpdatedByCarrier's listeners
* (queued — dispatched from a scheduled polling job with no page waiting
* on the result), this one has a real same-request/same-page-load
* dependency.
*
* The store-pickup mirror of AdvanceFulfillmentOnDelivered — reacts to * The store-pickup mirror of AdvanceFulfillmentOnDelivered — reacts to
* OrderPickedUp (dispatched by Modules\Core\Order\Services\ * OrderPickedUp (dispatched by Modules\Core\Order\Services\
* OrderFulfillmentService::markPickedUp() the moment staff confirm the * OrderFulfillmentService::markPickedUp() the moment staff confirm the
@@ -15,20 +25,22 @@ use Modules\Core\Order\Services\OrderStatusWriter;
* business design — unlike the carrier branch, there is no 'delivered' * business design — unlike the carrier branch, there is no 'delivered'
* intermediate value on this path. * intermediate value on this path.
* *
* Guarded to only fire from 'picked_up' — a duplicate dispatch (e.g. a * Guarded by OrderStatusFlow::isValidTransition() rather than a hardcoded
* stale page re-submitting the action) is a silent no-op. * "only fire from 'picked_up'" comparison. A duplicate dispatch (e.g. a
* stale page re-submitting the action) is a silent no-op either way.
*/ */
class CompleteOrderOnPickedUp class CompleteOrderOnPickedUp
{ {
public function __construct( public function __construct(
private readonly OrderStatusWriter $writer, private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {} ) {}
public function handle(OrderPickedUp $event): void public function handle(OrderPickedUp $event): void
{ {
$order = $event->order; $order = $event->order;
if ($order->status !== 'picked_up') { if (! $this->flow->isValidTransition($order, 'completed')) {
return; return;
} }
@@ -2,63 +2,38 @@
namespace Modules\Core\Order\Listeners; namespace Modules\Core\Order\Listeners;
use Illuminate\Support\Facades\DB; use Modules\Core\Catalog\Services\StockService;
use Lunar\Models\Product;
use Lunar\Models\ProductVariant;
use Modules\Core\Checkout\Events\OrderPlaced; use Modules\Core\Checkout\Events\OrderPlaced;
/** /**
* The only place ProductVariant::stock is written as a result of an order — * Deliberately NOT queued — unlike this codebase's other queued side
* fires once per order regardless of capture_mode/driver, same reasoning as * effects (cache flushes, audit logs, search reindexes), a stalled queue
* Modules\Core\Order\Notifications\OrderPlacedNotification: OrderPlaced is * here isn't just cosmetic staleness: it widens the window in which
* dispatched exactly once, from the one place an order's placed_at * another order can be accepted against stock this order already
* actually gets set (Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus), * committed (Lunar has no stock-reservation step at checkout time to
* so this can't double-decrement across a capture/authorize/refund sequence * begin with — see StockService's own "Never lets stock go negative"
* the way listening to PaymentCaptured directly could. * note — so some oversell race already exists, but a queue stall of
* minutes/hours extends that window far past the sub-millisecond one a
* synchronous write leaves open). StockService's atomic `GREATEST(stock -
* qty, 0)` SQL still protects against a LOST update between two orders
* decrementing the same variant concurrently; running it synchronously
* keeps the exposure window as small as possible on top of that.
* *
* Only decrements for `purchasable === 'in_stock'` variants — 'always' and * The actual decrement logic lives in Modules\Core\Catalog\Services\
* 'backorder' variants are deliberately allowed to sell past (or without * StockService — stock (the column, its invariants) is a Catalog concern,
* regard to) their stock count already (see ProductVariant:: * not an Order one; this listener is just the "an order was placed"
* canBeFulfilledAtQuantity()), so decrementing their stock would just make * trigger. Fires once per order regardless of capture_mode/driver, same
* that column an inaccurate, decreasingly-negative number with no purchasing * reasoning as Modules\Core\Order\Notifications\OrderPlacedNotification:
* consequence. Only `OrderLine::type === 'physical'` lines are considered — * OrderPlaced is dispatched exactly once, from the one place an order's
* a digital line has no stock to decrement (ProductVariant::getType()). * placed_at actually gets set (Modules\Core\Order\Listeners\
* * ApplyResolvedPaymentStatus), so this can't double-decrement across a
* A single UPDATE per variant (`DB::table(...)->decrement()`), not a * capture/authorize/refund sequence the way listening to PaymentCaptured
* read-then-write on the Eloquent model — avoids a lost-update race between * directly could.
* 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 class DecrementStockOnOrderPlaced
{ {
public function handle(OrderPlaced $event): void public function handle(OrderPlaced $event): void
{ {
$lines = $event->order->lines() app(StockService::class)->decrementForOrder($event->order);
->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();
} }
} }
@@ -2,17 +2,23 @@
namespace Modules\Core\Order\Listeners; namespace Modules\Core\Order\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Order\Events\OrderDelivered; use Modules\Core\Order\Events\OrderDelivered;
use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier; use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
/** /**
* Queued — ShipmentStatusUpdatedByCarrier is dispatched from
* Modules\Core\Shipping\Jobs\PollShipmentTrackingJob, a scheduled job with
* no HTTP request waiting on a response, so there is no same-request
* timing pressure for any of this event's listeners (unlike a webhook).
*
* Translates a carrier tracking checkpoint into OrderDelivered — the event * Translates a carrier tracking checkpoint into OrderDelivered — the event
* OrderDeliveredNotification (via NotificationRegistry) actually listens * OrderDeliveredNotification (via NotificationRegistry) actually listens
* to. Kept separate from the notification itself so the "is this checkpoint * to. Kept separate from the notification itself so the "is this checkpoint
* a delivery" filtering doesn't leak into notification code. * a delivery" filtering doesn't leak into notification code.
*/ */
class DeriveOrderDeliveredFromShipment class DeriveOrderDeliveredFromShipment implements ShouldQueue
{ {
public function handle(ShipmentStatusUpdatedByCarrier $event): void public function handle(ShipmentStatusUpdatedByCarrier $event): void
{ {
@@ -2,20 +2,28 @@
namespace Modules\Core\Order\Listeners; namespace Modules\Core\Order\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Services\OrderStatusWriter; use Modules\Core\Order\Services\OrderStatusWriter;
use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier; use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
/** /**
* Queued — see Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment's
* own docblock: ShipmentStatusUpdatedByCarrier comes from a scheduled
* polling job, not a webhook.
*
* Wires TrackingStatus::Failed to the 'delivery_failed' status for the * Wires TrackingStatus::Failed to the 'delivery_failed' status for the
* first time — previously an unused enum case. Guarded to only fire from * first time — previously an unused enum case. Guarded by
* 'dispatched': a stale/duplicate checkpoint, or an order a manual action * OrderStatusFlow::isValidTransition() rather than a hardcoded "only fire
* already moved past, is a silent no-op. * from 'dispatched'" comparison. A stale/duplicate checkpoint, or an
* order a manual action already moved past, is a silent no-op either way.
*/ */
class MarkDeliveryFailedOnCarrierCheckpoint class MarkDeliveryFailedOnCarrierCheckpoint implements ShouldQueue
{ {
public function __construct( public function __construct(
private readonly OrderStatusWriter $writer, private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {} ) {}
public function handle(ShipmentStatusUpdatedByCarrier $event): void public function handle(ShipmentStatusUpdatedByCarrier $event): void
@@ -26,7 +34,7 @@ class MarkDeliveryFailedOnCarrierCheckpoint
$order = $event->shipmentInfo->shipment->order; $order = $event->shipmentInfo->shipment->order;
if (! $order || $order->status !== 'dispatched') { if (! $order || ! $this->flow->isValidTransition($order, 'delivery_failed')) {
return; return;
} }
@@ -10,6 +10,17 @@ use Modules\Core\Payment\Events\PaymentRefunded;
use Modules\Core\Payment\Events\PaymentVoided; use Modules\Core\Payment\Events\PaymentVoided;
/** /**
* Deliberately NOT queued, despite looking like a pure audit-trail write
* with no same-request reader — Modules\Core\Providers\
* OrderServiceProvider registers this to run BEFORE
* Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus for
* PaymentRefunded specifically, because that listener's refund-status
* resolution reads the Transaction row this listener just wrote. Queueing
* this would run it asynchronously while ApplyResolvedPaymentStatus (sync)
* proceeds immediately, almost certainly executing before the queued job
* and silently breaking that read. See OrderServiceProvider's own
* registration-order comment.
*
* Writes the Transaction row for a successful payment outcome — the * Writes the Transaction row for a successful payment outcome — the
* "record what happened" half of reacting to Payment's events, separate * "record what happened" half of reacting to Payment's events, separate
* from Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus's "update * from Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus's "update
@@ -2,11 +2,16 @@
namespace Modules\Core\Order\Listeners; namespace Modules\Core\Order\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Order\Events\OrderPaidChanged; use Modules\Core\Order\Events\OrderPaidChanged;
use Modules\Core\Order\Events\OrderStatusChanged; use Modules\Core\Order\Events\OrderStatusChanged;
use Modules\Core\Order\Services\OrderStatusTransitionRecorder; use Modules\Core\Order\Services\OrderStatusTransitionRecorder;
/** /**
* Queued — a pure history-log write with no same-request reader anywhere
* in the codebase (no Filament page renders order_status_transitions
* immediately after a status change; it's browsed later, if at all).
*
* The one place order_status_transitions rows actually get written — * The one place order_status_transitions rows actually get written —
* listens to OrderStatusChanged (every write of the single `status` * listens to OrderStatusChanged (every write of the single `status`
* column, via Modules\Core\Order\Services\OrderStatusWriter::write()) and * column, via Modules\Core\Order\Services\OrderStatusWriter::write()) and
@@ -15,7 +20,7 @@ use Modules\Core\Order\Services\OrderStatusTransitionRecorder;
* one consistent audit trail entry ('paid', with a null from_status) * one consistent audit trail entry ('paid', with a null from_status)
* rather than a second, separate table. * rather than a second, separate table.
*/ */
class RecordStatusTransition class RecordStatusTransition implements ShouldQueue
{ {
public function __construct( public function __construct(
private readonly OrderStatusTransitionRecorder $recorder, private readonly OrderStatusTransitionRecorder $recorder,
@@ -0,0 +1,90 @@
<?php
namespace Modules\Core\Order\Services;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Order\Support\OrderStatus;
/**
* The actual business decisions behind reacting to a payment outcome —
* previously these lived entirely inside Modules\Core\Order\Listeners\
* ApplyResolvedPaymentStatus, a listener with no Service behind it, even
* though "should this order be marked paid," "should its status advance,
* and to what," and "what does a refund do to status" are all genuine
* decisions about Order state, not side effects of Payment's own events.
* That listener is now a thin reactor: extract the order id from
* $event->context, load the Order, call this service, done.
*
* See ApplyResolvedPaymentStatus's own docblock for the full business
* reasoning (re-confirmed with the user) behind each rule enforced here —
* this class only re-documents what's specific to the decision logic
* itself, not the "why" already recorded there.
*/
class OrderPaymentResolutionService
{
public function __construct(
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {}
/**
* A captured or authorized payment: marks the order paid (capture
* only — an authorization is not yet captured funds), advances status
* out of 'awaiting_payment' (capture only), and marks the order
* placed if this is the first payment outcome it's seen.
*/
public function resolveCaptureOrAuthorization(Order $order, string $causeClass, bool $isCapture): void
{
$wasPlaced = ! blank($order->placed_at);
$this->writer->markPaid($order, $causeClass);
if ($isCapture) {
$this->advancePastAwaitingPayment($order, $causeClass);
}
if (! $wasPlaced) {
$order->update(['placed_at' => $order->placed_at ?? now()]);
Event::dispatch(new OrderPlaced($order));
}
}
/**
* Requires the refund Transaction row to already exist (Modules\Core\
* Order\Listeners\RecordPaymentTransaction must run first — see
* OrderServiceProvider's listener registration order for
* PaymentRefunded), so the relation is refreshed here rather than
* trusted from a possibly-stale $order instance.
*/
public function resolveRefund(Order $order, string $causeClass): void
{
$order->load('transactions');
$target = match (OrderStatus::payment($order)) {
PaymentStatus::Refunded => 'refunded',
PaymentStatus::PartialRefund => 'partially_refunded',
default => null,
};
if ($target !== null && $order->status !== $target) {
$this->writer->write($order, $target, $causeClass);
}
}
private function advancePastAwaitingPayment(Order $order, string $causeClass): void
{
if ($order->status !== 'awaiting_payment') {
return;
}
$next = $this->flow->nextOptions($order);
$target = array_key_first($next);
if ($target !== null) {
$this->writer->write($order, $target, $causeClass);
}
}
}
+16
View File
@@ -133,6 +133,22 @@ class OrderStatusFlow
return ! $order->paid && $this->isCod($order); return ! $order->paid && $this->isCod($order);
} }
/**
* Whether moving $order to $to is a valid transition from its CURRENT
* status — the single source of truth for "is this a legal next step,"
* so a caller reacting to an external event (a carrier tracking
* checkpoint, a staff action) doesn't need to hardcode its own "only
* fire from status X" guard duplicating what nextOptions() already
* knows. See e.g. Modules\Core\Order\Listeners\
* AdvanceFulfillmentOnCarrierCheckpoint, which used to compare
* $order->status to a literal 'ready_for_dispatch' inline instead of
* asking this class.
*/
public function isValidTransition(Order $order, string $to): bool
{
return array_key_exists($to, $this->nextOptions($order));
}
private function label(string $status): string private function label(string $status): string
{ {
return (string) str($status)->replace('_', ' ')->title(); return (string) str($status)->replace('_', ' ')->title();
@@ -2,6 +2,7 @@
namespace Modules\Core\Payment\Listeners; namespace Modules\Core\Payment\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Logging\ActivityLogService; use Modules\Core\Logging\ActivityLogService;
use Modules\Core\Payment\Events\PaymentMethodCreated; use Modules\Core\Payment\Events\PaymentMethodCreated;
use Modules\Core\Payment\Events\PaymentMethodDeleted; use Modules\Core\Payment\Events\PaymentMethodDeleted;
@@ -9,6 +10,8 @@ use Modules\Core\Payment\Events\PaymentMethodUpdated;
use Modules\Core\Payment\Models\PaymentMethod; use Modules\Core\Payment\Models\PaymentMethod;
/** /**
* Queued — a pure audit-log write with no same-request reader.
*
* Same pattern as Localization\Listeners\LogTranslationActivity — routes * Same pattern as Localization\Listeners\LogTranslationActivity — routes
* PaymentMethodService's own events through the existing * PaymentMethodService's own events through the existing
* Logging\ActivityLogService instead of PaymentMethod separately opting * Logging\ActivityLogService instead of PaymentMethod separately opting
@@ -29,7 +32,7 @@ use Modules\Core\Payment\Models\PaymentMethod;
* forcing into a one-subject shape or adding a new method to the shared * forcing into a one-subject shape or adding a new method to the shared
* service for. * service for.
*/ */
class LogPaymentMethodActivity class LogPaymentMethodActivity implements ShouldQueue
{ {
public function __construct( public function __construct(
private readonly ActivityLogService $activityLog, private readonly ActivityLogService $activityLog,
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Modules\Core\Review\Events;
use Modules\Core\Auth\Models\Staff;
use Modules\Core\Review\Models\ProductReview;
/**
* Dispatched whenever staff post or edit a reply to a review (see
* Modules\Core\Review\Services\ReviewService::reply()) — previously this
* happened with no event at all, so nothing could react to it (no audit
* trail, no "notify the reviewer their review got a reply" hook).
*
* $wasReply distinguishes a brand-new reply from an edit to an existing
* one — a listener building an audit trail or notification may care which
* happened (e.g. only notify the reviewer the first time, not on every
* subsequent edit).
*/
class ReviewReplied
{
public function __construct(
public readonly ProductReview $review,
public readonly Staff $repliedBy,
public readonly bool $wasReply,
) {}
}
@@ -13,11 +13,11 @@ use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Carbon;
use Illuminate\Support\HtmlString; use Illuminate\Support\HtmlString;
use Lunar\Admin\Filament\Resources\ProductResource; use Lunar\Admin\Filament\Resources\ProductResource;
use Lunar\Admin\Support\Pages\BaseManageRelatedRecords; use Lunar\Admin\Support\Pages\BaseManageRelatedRecords;
use Modules\Core\Review\Models\ProductReview; use Modules\Core\Review\Models\ProductReview;
use Modules\Core\Review\Services\ReviewService;
class ManageProductReviews extends BaseManageRelatedRecords class ManageProductReviews extends BaseManageRelatedRecords
{ {
@@ -139,10 +139,7 @@ class ManageProductReviews extends BaseManageRelatedRecords
]) ])
->fillForm(fn (ProductReview $record) => ['reply' => $record->reply]) ->fillForm(fn (ProductReview $record) => ['reply' => $record->reply])
->action(function (ProductReview $record, array $data) { ->action(function (ProductReview $record, array $data) {
$record->update([ app(ReviewService::class)->reply($record, $data['reply'], auth('staff')->user());
'reply' => $data['reply'],
'replied_at' => Carbon::now(),
]);
}), }),
DeleteAction::make(), DeleteAction::make(),
]) ])
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace Modules\Core\Review\Services;
use Illuminate\Support\Facades\Event;
use Modules\Core\Auth\Models\Staff;
use Modules\Core\Review\Events\ReviewReplied;
use Modules\Core\Review\Models\ProductReview;
/**
* The one place a reply is written onto a ProductReview — previously
* Modules\Core\Review\Filament\Pages\ManageProductReviews wrote directly
* to the record inside its own Action closure, with no Service and no
* event dispatched for a real state change (a customer's review getting a
* staff reply). Matches the write-then-dispatch shape every other
* module's Service already uses (e.g. Modules\Core\Payment\Services\
* PaymentMethodService, Modules\Core\Customer\Services\
* CustomerAccountService).
*/
class ReviewService
{
public function reply(ProductReview $review, string $reply, Staff $repliedBy): ProductReview
{
$wasReply = $review->replied_at === null;
$review->update([
'reply' => $reply,
'replied_at' => now(),
]);
Event::dispatch(new ReviewReplied($review, $repliedBy, $wasReply));
return $review;
}
}