Feature: Updating Listreners, Separating Logic from listeners, Queuing Policies
This commit is contained in:
+10
-2
@@ -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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user