Feature: Order Updates, Events, Order Flows, Shipment And COD support
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Order\Events\OrderCompleted;
|
||||
use Modules\Core\Order\Services\OrderStatusWriter;
|
||||
|
||||
/**
|
||||
* Auto-completes a carrier order once its 14-day return window has
|
||||
* elapsed with no return requested — the automatic counterpart to the
|
||||
* staff "Update Status" action's manual completion. Store-pickup orders
|
||||
* have no return-window step at all (Modules\Core\Order\Listeners\
|
||||
* CompleteOrderOnPickedUp completes them immediately), so this only ever
|
||||
* touches carrier orders sitting in 'delivered' (the status also carrying
|
||||
* "return window is open" — see AdvanceFulfillmentOnDelivered).
|
||||
*
|
||||
* Registered at exactly dailyAt('00:00') in
|
||||
* Modules\Core\Providers\OrderServiceProvider — a compliance requirement
|
||||
* that this run at exact midnight, not Laravel's own arbitrary default
|
||||
* time for a plain daily() schedule.
|
||||
*
|
||||
* "When did the window open" is read from order_status_transitions rather
|
||||
* than Order::updated_at, which any unrelated field write would bump —
|
||||
* this is the concrete reason the audit table exists beyond pure logging.
|
||||
*
|
||||
* Window length is config('core.order.return_window_days') — a legal/
|
||||
* policy value a store may need to change without a code deploy, not a
|
||||
* hardcoded constant.
|
||||
*/
|
||||
class CloseExpiredReturnWindows extends Command
|
||||
{
|
||||
protected $signature = 'boboko:order:close-expired-return-windows';
|
||||
|
||||
protected $description = 'Auto-complete carrier orders whose return window has elapsed with no return requested.';
|
||||
|
||||
public function handle(OrderStatusWriter $writer): void
|
||||
{
|
||||
$cutoff = now()->subDays(config('core.order.return_window_days', 14));
|
||||
|
||||
$orderIds = Order::query()
|
||||
->where('status', 'delivered')
|
||||
->whereHas('statusTransitions', function ($query) use ($cutoff) {
|
||||
$query->where('to_status', 'delivered')
|
||||
->where('created_at', '<=', $cutoff);
|
||||
})
|
||||
->pluck('id');
|
||||
|
||||
$completed = 0;
|
||||
|
||||
foreach ($orderIds as $orderId) {
|
||||
$order = Order::find($orderId);
|
||||
|
||||
if (! $order || $order->status !== 'delivered') {
|
||||
continue; // idempotent no-op — moved on since the query ran
|
||||
}
|
||||
|
||||
$writer->write($order, 'completed', self::class);
|
||||
|
||||
OrderCompleted::dispatch($order);
|
||||
|
||||
$completed++;
|
||||
}
|
||||
|
||||
$this->components->info("Completed {$completed} order(s) past their return window.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\DTOs;
|
||||
|
||||
/**
|
||||
* What a Modules\Core\Order\Services\OrderFulfillmentService method
|
||||
* returns instead of throwing/echoing a Filament notification directly —
|
||||
* keeps that service usable outside a Filament action (a future API
|
||||
* endpoint, a console command, a test) without dragging
|
||||
* Filament\Notifications\Notification along. Modules\Core\Shipping\
|
||||
* Extensions\OrderViewExtension is the one place that translates this
|
||||
* into an actual on-screen notification.
|
||||
*/
|
||||
final class OrderFulfillmentResult
|
||||
{
|
||||
private function __construct(
|
||||
public readonly bool $success,
|
||||
public readonly string $message,
|
||||
) {}
|
||||
|
||||
public static function success(string $message): self
|
||||
{
|
||||
return new self(true, $message);
|
||||
}
|
||||
|
||||
public static function failure(string $message): self
|
||||
{
|
||||
return new self(false, $message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
|
||||
/**
|
||||
* The one terminal signal every notification/reporting concern that only
|
||||
* cares about "this order is fully done" should listen to, regardless of
|
||||
* which path actually got it there — dispatched by all four:
|
||||
* Modules\Core\Order\Listeners\CompleteOrderOnPickedUp (store-pickup),
|
||||
* Modules\Core\Order\Commands\CloseExpiredReturnWindows (carrier,
|
||||
* automatic 14-day return-window expiry), or Modules\Core\Shipping\
|
||||
* Extensions\OrderViewExtension's "Mark Completed" action (manual
|
||||
* universal fallback, either branch).
|
||||
*/
|
||||
class OrderCompleted
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Shipping\Models\ShipmentInfo;
|
||||
|
||||
/**
|
||||
* Dispatched by either of the two paths that move a carrier order's
|
||||
* `status` to 'dispatched' — Modules\Core\Order\Listeners\
|
||||
* AdvanceFulfillmentOnCarrierCheckpoint (automatic, reacting to a real
|
||||
* carrier checkpoint) or Modules\Core\Order\Services\
|
||||
* OrderFulfillmentService::createShipmentAndDispatch() (staff-driven, via
|
||||
* the single "Update Status" action). $shipmentInfo is nullable
|
||||
* specifically because of that second path — populated with the
|
||||
* triggering checkpoint when it's real, null when staff drove it
|
||||
* manually. Mirrors OrderDelivered's {order, shipmentInfo} shape, just
|
||||
* with the nullability this one event additionally needs.
|
||||
*/
|
||||
class OrderDispatched
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
public readonly ?ShipmentInfo $shipmentInfo = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
|
||||
/**
|
||||
* Dispatched by Modules\Core\Order\Services\OrderStatusWriter::markPaid()
|
||||
* whenever Order::paid flips to true — entirely independent of the
|
||||
* `status` column (see OrderStatusFlow's own docblock for why payment
|
||||
* timing, especially for cash-on-delivery, cannot be modeled as a step in
|
||||
* that sequence). Order::status changes are instead picked up generically
|
||||
* by Modules\Core\Order\Events\OrderStatusUpdated (dispatched by
|
||||
* OrderObserver whenever `status` changes, regardless of writer).
|
||||
*/
|
||||
class OrderPaidChanged
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
public readonly string $causeClass,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
|
||||
/**
|
||||
* Dispatched by Modules\Core\Order\Services\OrderFulfillmentService::
|
||||
* markPickedUp(), the staff-driven "Update Status" action's handling of
|
||||
* the 'picked_up' target — the customer has collected a store-pickup
|
||||
* order in person. Store-pickup only; a carrier order's equivalent
|
||||
* "arrived" moment is OrderDelivered. Modules\Core\Order\Listeners\
|
||||
* CompleteOrderOnPickedUp reacts to this by moving `status` straight to
|
||||
* 'completed' — no return-window step for store-pickup, per the business
|
||||
* design.
|
||||
*/
|
||||
class OrderPickedUp
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
|
||||
/**
|
||||
* Dispatched by Modules\Core\Shipping\Extensions\OrderViewExtension's
|
||||
* "Mark Ready" action, carrier branch (Order::isStorePickupOrder() ===
|
||||
* false) — staff has packed/staged the order for carrier handoff.
|
||||
* Staff-internal: nothing customer-facing happens at this moment, so no
|
||||
* notification listens to this event (compare OrderReadyForPickup, which
|
||||
* does trigger a customer email).
|
||||
*/
|
||||
class OrderReadyForDispatch
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
|
||||
/**
|
||||
* Dispatched by Modules\Core\Shipping\Extensions\OrderViewExtension's
|
||||
* "Mark Ready" action, store-pickup branch (Order::isStorePickupOrder()
|
||||
* === true) — staff has packed/staged the order for the customer to
|
||||
* collect in store. Drives Modules\Core\Order\Notifications\
|
||||
* OrderPickupReadyNotification ("come collect your order").
|
||||
*/
|
||||
class OrderReadyForPickup
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
|
||||
/**
|
||||
* Dispatched by Modules\Core\Order\Services\OrderStatusWriter::write()
|
||||
* alongside the generic Modules\Core\Order\Events\OrderStatusUpdated
|
||||
* (which Modules\Core\Order\Observers\OrderObserver dispatches for ANY
|
||||
* `status` write, regardless of cause, and which
|
||||
* OrderStatusUpdatedNotification already listens to). This event exists
|
||||
* only because the audit trail (Modules\Core\Order\Listeners\
|
||||
* RecordStatusTransition) needs $causeClass, which OrderStatusUpdated
|
||||
* does not carry — OrderStatusWriter is the only writer of `status` this
|
||||
* package has left, so it's the only place that needs to know its own
|
||||
* cause.
|
||||
*/
|
||||
class OrderStatusChanged
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
public readonly ?string $previousStatus,
|
||||
public readonly string $newStatus,
|
||||
public readonly string $causeClass,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Filament\Extensions;
|
||||
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Lunar\Admin\Support\Extending\ViewPageExtension;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Payment\Models\PaymentMethod;
|
||||
|
||||
/**
|
||||
* Adds a "Payment Method" entry to the order summary sidebar — previously
|
||||
* nowhere on the order page told staff which payment method a shopper
|
||||
* actually used. Reads Order.meta['payment_method'] (written by
|
||||
* Modules\Core\Checkout\Services\CheckoutService::initiatePayment()), the
|
||||
* same source Modules\Core\Order\Services\OrderStatusFlow::isCod() reads,
|
||||
* so this entry and the "Mark Paid" action's visibility always agree on
|
||||
* what payment method an order used. Falls back to the most recent
|
||||
* Transaction.driver for an order placed before that field existed.
|
||||
*
|
||||
* Uses the extendOrderSummarySchema hook, same as the deleted 3-axis
|
||||
* OrderStatusSummaryExtension did — see that class's git history for the
|
||||
* hook's own docblock/rationale.
|
||||
*/
|
||||
class OrderPaymentMethodSummaryExtension extends ViewPageExtension
|
||||
{
|
||||
public function extendOrderSummarySchema(array $schema): array
|
||||
{
|
||||
$schema[] = TextEntry::make('payment_method')
|
||||
->label('Payment method')
|
||||
->state(fn (Order $record) => $this->resolveLabel($record))
|
||||
->placeholder('—')
|
||||
->alignEnd();
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
private function resolveLabel(Order $record): ?string
|
||||
{
|
||||
$type = $record->meta['payment_method'] ?? $record->transactions()->latest('id')->value('driver');
|
||||
|
||||
if ($type === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PaymentMethod::where('type', $type)->value('name') ?? $type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Listeners;
|
||||
|
||||
use Modules\Core\Order\Events\OrderDispatched;
|
||||
use Modules\Core\Order\Services\OrderStatusWriter;
|
||||
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
|
||||
|
||||
/**
|
||||
* The automatic half of "Dispatched" — the manual fallback is the staff
|
||||
* "Update Status" action (Modules\Core\Shipping\Extensions\
|
||||
* OrderViewExtension). Listens to ShipmentStatusUpdatedByCarrier directly,
|
||||
* the same event Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment
|
||||
* listens to.
|
||||
*
|
||||
* Reacts to either TrackingStatus::CollectedFromSender (the carrier
|
||||
* collected the parcel from the merchant) or InTransit directly, for a
|
||||
* carrier that skips straight there without a distinct collection
|
||||
* checkpoint.
|
||||
*
|
||||
* Guarded to only fire from 'ready_for_dispatch' — a late/duplicate
|
||||
* checkpoint, or an order the manual action already advanced, is a
|
||||
* silent no-op.
|
||||
*/
|
||||
class AdvanceFulfillmentOnCarrierCheckpoint
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OrderStatusWriter $writer,
|
||||
) {}
|
||||
|
||||
public function handle(ShipmentStatusUpdatedByCarrier $event): void
|
||||
{
|
||||
if ($event->shipmentInfo->status !== TrackingStatus::InTransit
|
||||
&& $event->shipmentInfo->status !== TrackingStatus::CollectedFromSender) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order = $event->shipmentInfo->shipment->order;
|
||||
|
||||
if (! $order || $order->status !== 'ready_for_dispatch') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->writer->write($order, 'dispatched', self::class);
|
||||
|
||||
OrderDispatched::dispatch($order, $event->shipmentInfo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Listeners;
|
||||
|
||||
use Modules\Core\Order\Events\OrderDelivered;
|
||||
use Modules\Core\Order\Services\OrderStatusWriter;
|
||||
|
||||
/**
|
||||
* Writes `status` to 'delivered' once a carrier confirms delivery, rather
|
||||
* than jumping straight to 'completed'. Carrier orders get a return
|
||||
* window between delivery and completion (see Modules\Core\Order\
|
||||
* Commands\CloseExpiredReturnWindows, which auto-completes an order once
|
||||
* that window elapses) — 'delivered' is both "the parcel arrived" and
|
||||
* "the return window is now open"; nothing distinguishes those as
|
||||
* separate instants, they're the same moment, so there is only the one
|
||||
* status value.
|
||||
*
|
||||
* Kept separate from Modules\Core\Order\Listeners\
|
||||
* DeriveOrderDeliveredFromShipment, which only ever dispatches
|
||||
* OrderDelivered — deriving "was this delivered" and acting on it by
|
||||
* writing `status` are deliberately two different listeners.
|
||||
*
|
||||
* Guarded to only fire from 'dispatched' — a duplicate/late Delivered
|
||||
* checkpoint, or an order a manual action already moved past, is a
|
||||
* silent no-op.
|
||||
*/
|
||||
class AdvanceFulfillmentOnDelivered
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OrderStatusWriter $writer,
|
||||
) {}
|
||||
|
||||
public function handle(OrderDelivered $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
|
||||
if ($order->status !== 'dispatched') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->writer->write($order, 'delivered', self::class);
|
||||
}
|
||||
}
|
||||
@@ -5,43 +5,45 @@ namespace Modules\Core\Order\Listeners;
|
||||
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\Services\OrderStatusWriter;
|
||||
use Modules\Core\Order\Support\OrderStatus;
|
||||
use Modules\Core\Payment\Events\PaymentAuthorized;
|
||||
use Modules\Core\Payment\Events\PaymentCaptured;
|
||||
use Modules\Core\Payment\Events\PaymentRefunded;
|
||||
use Modules\Core\Payment\Models\PaymentMethod;
|
||||
use Modules\Core\Payment\Services\PaymentMethodCache;
|
||||
|
||||
/**
|
||||
* The only place an Order's status column is written in reaction to a
|
||||
* payment outcome. Registered against PaymentCaptured, PaymentAuthorized,
|
||||
* AND PaymentRefunded (see OrderServiceProvider) — same handler for all
|
||||
* three, differing only in which PaymentMethod column decides the
|
||||
* resulting status and, for a refund, which PaymentMethod row that even
|
||||
* is (see resolvePaymentMethod()).
|
||||
* Registered against PaymentCaptured, PaymentAuthorized, AND
|
||||
* PaymentRefunded (see OrderServiceProvider).
|
||||
*
|
||||
* A capture/authorization only ever writes Order::paid/paid_at (via
|
||||
* OrderStatusWriter::markPaid()) — never `status`. Confirmed with the
|
||||
* user: status leaving 'awaiting_payment' is always a staff-driven
|
||||
* "Update Status" click, regardless of payment method — no special-casing
|
||||
* prepaid vs. cash-on-delivery. A prepaid order briefly sitting at
|
||||
* 'awaiting_payment' with paid = true (until staff notice and advance it)
|
||||
* is expected, not a bug.
|
||||
*
|
||||
* 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, so this is the one
|
||||
* place that context key gets consumed on the Order side (Payment's own
|
||||
* StripePaymentDriver reads $context['order_id'] independently, for its
|
||||
* own unrelated correlation need — see that class's rememberIntent()).
|
||||
* belongs to — Payment has no concept of an Order.
|
||||
*
|
||||
* Loads and saves the model (not a bulk ::whereKey()->update()) so
|
||||
* Order::observe()'s updated() hook fires and OrderStatusUpdated goes out
|
||||
* the same as any other status write — see that event's own docblock for
|
||||
* why it's meant to fire "regardless of what wrote it."
|
||||
* 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.
|
||||
*
|
||||
* Dispatches Checkout\Events\OrderPlaced itself, once placed_at is set —
|
||||
* see that event's own docblock for why this, not CheckoutService, is now
|
||||
* the dispatch point. Never fires from the PaymentRefunded path — a
|
||||
* refund can only ever happen after an order was already placed.
|
||||
*
|
||||
* Deliberately does NOT react to PaymentVoided — see PaymentMethod's own
|
||||
* docblock for why there's no void_status column at all yet.
|
||||
* Deliberately does NOT react to PaymentVoided.
|
||||
*/
|
||||
class ApplyResolvedPaymentStatus
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PaymentMethodCache $paymentMethods,
|
||||
private readonly OrderStatusWriter $writer,
|
||||
) {}
|
||||
|
||||
public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void
|
||||
@@ -54,59 +56,41 @@ class ApplyResolvedPaymentStatus
|
||||
|
||||
$order = Order::findOrFail($orderId);
|
||||
|
||||
$method = $this->resolvePaymentMethod($event, $order);
|
||||
$column = match (true) {
|
||||
$event instanceof PaymentCaptured => 'captured_status',
|
||||
$event instanceof PaymentAuthorized => 'authorized_status',
|
||||
$event instanceof PaymentRefunded => 'refunded_status',
|
||||
};
|
||||
$status = $method?->{$column};
|
||||
if ($event instanceof PaymentRefunded) {
|
||||
$this->applyRefund($order, $event);
|
||||
|
||||
if ($status === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$wasPlaced = ! blank($order->placed_at);
|
||||
|
||||
$order->update([
|
||||
'status' => $status,
|
||||
'placed_at' => $order->placed_at ?? now(),
|
||||
]);
|
||||
$this->writer->markPaid($order, $event::class);
|
||||
|
||||
if (! $wasPlaced && ! $event instanceof PaymentRefunded) {
|
||||
if (! $wasPlaced) {
|
||||
$order->update(['placed_at' => $order->placed_at ?? now()]);
|
||||
Event::dispatch(new OrderPlaced($order));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PaymentCaptured/PaymentAuthorized carry $event->type as the
|
||||
* PaymentMethod.type that was actually charged — a direct lookup.
|
||||
*
|
||||
* PaymentRefunded's $event->type is the REFUND driver's own registry
|
||||
* key (e.g. 'bank-transfer' — see BankTransferPaymentDriver::refund()),
|
||||
* which may not correspond to any PaymentMethod row at all when the
|
||||
* admin refunded through a different driver than the one that took
|
||||
* the original payment (Payment\Support\TransactionDriverAdapter::
|
||||
* refundVia()). refunded_status is a business decision about the
|
||||
* ORIGINAL payment method, not the refund mechanism, so this instead
|
||||
* finds the order's earliest successful capture/intent transaction —
|
||||
* the actual payment the refund is reversing — and resolves that
|
||||
* transaction's own driver (a real PaymentMethod.type) instead.
|
||||
* 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 resolvePaymentMethod(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event, Order $order): ?PaymentMethod
|
||||
private function applyRefund(Order $order, PaymentRefunded $event): void
|
||||
{
|
||||
if (! $event instanceof PaymentRefunded) {
|
||||
return $this->paymentMethods->all()->firstWhere('type', $event->type);
|
||||
$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);
|
||||
}
|
||||
|
||||
$originalType = $order->transactions()
|
||||
->whereIn('type', ['capture', 'intent'])
|
||||
->where('success', true)
|
||||
->oldest('created_at')
|
||||
->value('driver');
|
||||
|
||||
return $originalType !== null
|
||||
? $this->paymentMethods->all()->firstWhere('type', $originalType)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Listeners;
|
||||
|
||||
use Modules\Core\Order\Events\OrderDelivered;
|
||||
|
||||
/**
|
||||
* Writes Order.status to 'completed' once a carrier confirms delivery —
|
||||
* the terminal status a carrier-fulfilled order reaches on its own,
|
||||
* without a human picking it from the dropdown, mirroring the store-pickup
|
||||
* order's own terminal transition (Modules\Core\Shipping\Extensions\
|
||||
* OrderViewExtension::markPickedUpAction()).
|
||||
*
|
||||
* Kept separate from Modules\Core\Order\Listeners\
|
||||
* DeriveOrderDeliveredFromShipment, which only ever dispatches
|
||||
* OrderDelivered — see that event's own docblock ("Order.status itself is
|
||||
* left untouched here") for why deriving "was this delivered" and acting
|
||||
* on it by writing status are deliberately two different listeners, same
|
||||
* separation Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus
|
||||
* already has from the Payment* events it reacts to.
|
||||
*
|
||||
* Guarded to only fire from 'dispatched' — a checkpoint arriving out of
|
||||
* order, or against an order some other status flow has already moved
|
||||
* past, shouldn't silently force it to 'completed'.
|
||||
*/
|
||||
class CompleteOrderOnDelivered
|
||||
{
|
||||
public function handle(OrderDelivered $event): void
|
||||
{
|
||||
if ($event->order->status !== 'dispatched') {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->order->update(['status' => 'completed']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Listeners;
|
||||
|
||||
use Modules\Core\Order\Events\OrderCompleted;
|
||||
use Modules\Core\Order\Events\OrderPickedUp;
|
||||
use Modules\Core\Order\Services\OrderStatusWriter;
|
||||
|
||||
/**
|
||||
* The store-pickup mirror of AdvanceFulfillmentOnDelivered — reacts to
|
||||
* OrderPickedUp (dispatched by Modules\Core\Order\Services\
|
||||
* OrderFulfillmentService::markPickedUp() the moment staff confirm the
|
||||
* customer collected the order) by moving `status` straight to
|
||||
* 'completed'. No return-window step for store-pickup orders, per the
|
||||
* business design — unlike the carrier branch, there is no 'delivered'
|
||||
* intermediate value on this path.
|
||||
*
|
||||
* Guarded to only fire from 'picked_up' — a duplicate dispatch (e.g. a
|
||||
* stale page re-submitting the action) is a silent no-op.
|
||||
*/
|
||||
class CompleteOrderOnPickedUp
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OrderStatusWriter $writer,
|
||||
) {}
|
||||
|
||||
public function handle(OrderPickedUp $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
|
||||
if ($order->status !== 'picked_up') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->writer->write($order, 'completed', self::class);
|
||||
|
||||
OrderCompleted::dispatch($order);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Listeners;
|
||||
|
||||
use Modules\Core\Order\Services\OrderStatusWriter;
|
||||
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
|
||||
|
||||
/**
|
||||
* Wires TrackingStatus::Failed to the 'delivery_failed' status for the
|
||||
* first time — previously an unused enum case. Guarded to only fire from
|
||||
* 'dispatched': a stale/duplicate checkpoint, or an order a manual action
|
||||
* already moved past, is a silent no-op.
|
||||
*/
|
||||
class MarkDeliveryFailedOnCarrierCheckpoint
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OrderStatusWriter $writer,
|
||||
) {}
|
||||
|
||||
public function handle(ShipmentStatusUpdatedByCarrier $event): void
|
||||
{
|
||||
if ($event->shipmentInfo->status !== TrackingStatus::Failed) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order = $event->shipmentInfo->shipment->order;
|
||||
|
||||
if (! $order || $order->status !== 'dispatched') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->writer->write($order, 'delivery_failed', self::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Listeners;
|
||||
|
||||
use Modules\Core\Order\Events\OrderPaidChanged;
|
||||
use Modules\Core\Order\Events\OrderStatusChanged;
|
||||
use Modules\Core\Order\Services\OrderStatusTransitionRecorder;
|
||||
|
||||
/**
|
||||
* The one place order_status_transitions rows actually get written —
|
||||
* listens to OrderStatusChanged (every write of the single `status`
|
||||
* column, via Modules\Core\Order\Services\OrderStatusWriter::write()) and
|
||||
* OrderPaidChanged (every write of Order::paid, via
|
||||
* OrderStatusWriter::markPaid()). paid isn't really a "status", but gets
|
||||
* one consistent audit trail entry ('paid', with a null from_status)
|
||||
* rather than a second, separate table.
|
||||
*/
|
||||
class RecordStatusTransition
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OrderStatusTransitionRecorder $recorder,
|
||||
) {}
|
||||
|
||||
public function handleStatusChanged(OrderStatusChanged $event): void
|
||||
{
|
||||
$this->recorder->record($event->order, $event->previousStatus, $event->newStatus, $event->causeClass);
|
||||
}
|
||||
|
||||
public function handlePaidChanged(OrderPaidChanged $event): void
|
||||
{
|
||||
$this->recorder->record($event->order, null, 'paid', $event->causeClass);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Lunar\Models\Order;
|
||||
|
||||
/**
|
||||
* One append-only row per write to Order::status (plus one synthetic
|
||||
* 'paid' entry per Order::paid write — see
|
||||
* Modules\Core\Order\Listeners\RecordStatusTransition) — see
|
||||
* database/migrations/2026_09_11_000002_create_order_status_transitions_table.php
|
||||
* and Modules\Core\Order\Services\OrderStatusTransitionRecorder, which is
|
||||
* the only thing that ever creates a row. Never updated after creation —
|
||||
* $timestamps is disabled since there's no updated_at column and
|
||||
* created_at is DB-defaulted (`useCurrent()`), not Eloquent-managed.
|
||||
*/
|
||||
class OrderStatusTransition extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Notifications;
|
||||
|
||||
use Illuminate\Notifications\AnonymousNotifiable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Modules\Core\Notification\BaseNotification;
|
||||
use Modules\Core\Order\Events\OrderCompleted;
|
||||
|
||||
class OrderCompletedNotification extends BaseNotification
|
||||
{
|
||||
public function __construct(private readonly OrderCompleted $event) {}
|
||||
|
||||
public static function getKey(): string
|
||||
{
|
||||
return 'order.completed.customer.mail';
|
||||
}
|
||||
|
||||
public static function listensTo(): string
|
||||
{
|
||||
return OrderCompleted::class;
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function notifiable(): AnonymousNotifiable
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
|
||||
|
||||
return NotificationFacade::route('mail', $email);
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('Your order :reference is complete', ['reference' => $order->reference]))
|
||||
->view('core::order.notifications.completed', [
|
||||
'reference' => $order->reference,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Notifications;
|
||||
|
||||
use Illuminate\Notifications\AnonymousNotifiable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Modules\Core\Notification\BaseNotification;
|
||||
use Modules\Core\Order\Events\OrderDispatched;
|
||||
|
||||
/**
|
||||
* Fills a real, previously-unfilled customer-communication gap — before
|
||||
* this redesign nothing notified a customer when their carrier order left
|
||||
* the building at all.
|
||||
*/
|
||||
class OrderDispatchedNotification extends BaseNotification
|
||||
{
|
||||
public function __construct(private readonly OrderDispatched $event) {}
|
||||
|
||||
public static function getKey(): string
|
||||
{
|
||||
return 'order.dispatched.customer.mail';
|
||||
}
|
||||
|
||||
public static function listensTo(): string
|
||||
{
|
||||
return OrderDispatched::class;
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function notifiable(): AnonymousNotifiable
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
|
||||
|
||||
return NotificationFacade::route('mail', $email);
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('Your order :reference is on its way', ['reference' => $order->reference]))
|
||||
->view('core::order.notifications.dispatched', [
|
||||
'reference' => $order->reference,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,19 +6,21 @@ use Illuminate\Notifications\AnonymousNotifiable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Modules\Core\Notification\BaseNotification;
|
||||
use Modules\Core\Order\Events\OrderStatusUpdated;
|
||||
use Modules\Core\Order\Events\OrderReadyForPickup;
|
||||
|
||||
/**
|
||||
* "Your order is ready to collect" — fires on the same OrderStatusUpdated
|
||||
* event Modules\Core\Order\Notifications\OrderStatusUpdatedNotification
|
||||
* listens to, but only for the 'ready-for-pickup' transition; that other
|
||||
* notification suppresses itself for this same transition (see its own
|
||||
* via()) so a customer gets this richer, pickup-specific email instead of
|
||||
* the generic "order updated" one, not both.
|
||||
* "Your order is ready to collect" — listens to the specific
|
||||
* OrderReadyForPickup event (dispatched by Modules\Core\Shipping\
|
||||
* Extensions\OrderViewExtension's "Mark Ready" action, store-pickup
|
||||
* branch only), not the generic OrderStatusUpdated. Modules\Core\Order\
|
||||
* Notifications\OrderStatusUpdatedNotification still separately
|
||||
* suppresses itself for the legacy 'ready-for-pickup' status string, kept
|
||||
* defensively even though nothing writes that literal value to
|
||||
* Order::status anymore after this redesign.
|
||||
*/
|
||||
class OrderPickupReadyNotification extends BaseNotification
|
||||
{
|
||||
public function __construct(private readonly OrderStatusUpdated $event) {}
|
||||
public function __construct(private readonly OrderReadyForPickup $event) {}
|
||||
|
||||
public static function getKey(): string
|
||||
{
|
||||
@@ -27,15 +29,11 @@ class OrderPickupReadyNotification extends BaseNotification
|
||||
|
||||
public static function listensTo(): string
|
||||
{
|
||||
return OrderStatusUpdated::class;
|
||||
return OrderReadyForPickup::class;
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
if ($this->event->newStatus !== 'ready-for-pickup') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
|
||||
@@ -5,18 +5,32 @@ namespace Modules\Core\Order\Observers;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Order\Events\OrderStatusUpdated;
|
||||
|
||||
/**
|
||||
* Generically dispatches OrderStatusUpdated for ANY write to `status`,
|
||||
* regardless of what wrote it (Modules\Core\Order\Services\
|
||||
* OrderStatusWriter, artisan tinker, a future API) — the general-purpose
|
||||
* hook notifications listen to. OrderStatusWriter separately dispatches
|
||||
* its own OrderStatusChanged (carrying $causeClass, which this event does
|
||||
* not) for the audit trail — see Modules\Core\Order\Listeners\
|
||||
* RecordStatusTransition.
|
||||
*
|
||||
* Does NOT try to generically watch Order::paid — an earlier design had
|
||||
* this observer thread a "what caused this" value through a runtime
|
||||
* $order->statusTransitionCause property, abandoned because
|
||||
* Lunar\Models\Order's $guarded = [] means Eloquent tries to persist any
|
||||
* property set that way as a real column. OrderStatusWriter::markPaid()
|
||||
* dispatches OrderPaidChanged directly instead.
|
||||
*/
|
||||
class OrderObserver
|
||||
{
|
||||
public function updated(Order $order): void
|
||||
{
|
||||
if (! $order->wasChanged('status')) {
|
||||
return;
|
||||
if ($order->wasChanged('status')) {
|
||||
OrderStatusUpdated::dispatch(
|
||||
$order,
|
||||
$order->getOriginal('status'),
|
||||
$order->status,
|
||||
);
|
||||
}
|
||||
|
||||
OrderStatusUpdated::dispatch(
|
||||
$order,
|
||||
$order->getOriginal('status'),
|
||||
$order->status,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Services;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Shipping\Models\ShippingMethod;
|
||||
use Modules\Core\Order\DTOs\OrderFulfillmentResult;
|
||||
use Modules\Core\Order\Events\OrderPickedUp;
|
||||
use Modules\Core\Order\Events\OrderReadyForDispatch;
|
||||
use Modules\Core\Order\Events\OrderReadyForPickup;
|
||||
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
||||
use Modules\Core\Shipping\DTOs\ShipmentRequest;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The staff-facing fulfillment/return/payment workflow behind the three
|
||||
* header actions in Modules\Core\Shipping\Extensions\OrderViewExtension
|
||||
* ("Create Shipment", "Update Status", "Mark Paid") — every guard check,
|
||||
* status write (via Modules\Core\Order\Services\OrderStatusWriter), and
|
||||
* event dispatch lives here, keeping this workflow usable and testable
|
||||
* independent of Filament.
|
||||
*
|
||||
* Every method re-validates its own precondition internally (not just
|
||||
* trusted from the caller's own visible()-equivalent check) — protects
|
||||
* against a stale page load racing a concurrent automatic transition
|
||||
* (e.g. a carrier tracking checkpoint advancing the same order between
|
||||
* page load and button click).
|
||||
*/
|
||||
class OrderFulfillmentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OrderStatusWriter $writer,
|
||||
private readonly OrderStatusFlow $flow,
|
||||
) {}
|
||||
|
||||
public function markReady(Order $order): OrderFulfillmentResult
|
||||
{
|
||||
if ($order->status !== 'processing') {
|
||||
return OrderFulfillmentResult::failure('This order must be in Processing before it can be marked ready.');
|
||||
}
|
||||
|
||||
$target = $order->isStorePickupOrder() ? 'ready_for_pickup' : 'ready_for_dispatch';
|
||||
|
||||
$this->writer->write($order, $target, self::class.'::markReady');
|
||||
|
||||
if ($order->isStorePickupOrder()) {
|
||||
OrderReadyForPickup::dispatch($order);
|
||||
} else {
|
||||
OrderReadyForDispatch::dispatch($order);
|
||||
}
|
||||
|
||||
return OrderFulfillmentResult::success('Order marked ready.');
|
||||
}
|
||||
|
||||
public function createShipmentAndDispatch(Order $order, ShipmentRequest $request): OrderFulfillmentResult
|
||||
{
|
||||
if ($order->status !== 'ready_for_dispatch') {
|
||||
return OrderFulfillmentResult::failure('This order is not ready to be dispatched.');
|
||||
}
|
||||
|
||||
$service = $this->resolveFulfillmentService($order);
|
||||
|
||||
if (! $service) {
|
||||
return OrderFulfillmentResult::failure('No carrier fulfillment integration is configured for this order.');
|
||||
}
|
||||
|
||||
try {
|
||||
$service->createShipment($order, $request);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return OrderFulfillmentResult::failure('Failed to create shipment: '.$e->getMessage());
|
||||
}
|
||||
|
||||
$this->writer->write($order, 'dispatched', self::class.'::createShipmentAndDispatch');
|
||||
|
||||
return OrderFulfillmentResult::success('Shipment created and order dispatched.');
|
||||
}
|
||||
|
||||
public function markPickedUp(Order $order): OrderFulfillmentResult
|
||||
{
|
||||
if ($order->status !== 'ready_for_pickup') {
|
||||
return OrderFulfillmentResult::failure('This order is not ready for pickup.');
|
||||
}
|
||||
|
||||
$this->writer->write($order, 'picked_up', self::class.'::markPickedUp');
|
||||
|
||||
OrderPickedUp::dispatch($order);
|
||||
|
||||
return OrderFulfillmentResult::success('Order marked as picked up.');
|
||||
}
|
||||
|
||||
/**
|
||||
* The general-purpose entry point for any transition with no special
|
||||
* side effect — a manual override, not restricted to the guided next
|
||||
* step(s), so staff can revert to an earlier status in the order's
|
||||
* own branch. Validates $to is actually a member of
|
||||
* OrderStatusFlow::allOptions() before writing (server-side
|
||||
* re-validation of whatever the Select offered) — still refuses a
|
||||
* status from the WRONG branch or an unknown value.
|
||||
*/
|
||||
public function transitionTo(Order $order, string $to): OrderFulfillmentResult
|
||||
{
|
||||
if (! array_key_exists($to, $this->flow->allOptions($order))) {
|
||||
return OrderFulfillmentResult::failure('That status is not valid for this order.');
|
||||
}
|
||||
|
||||
$this->writer->write($order, $to, self::class.'::transitionTo');
|
||||
|
||||
return OrderFulfillmentResult::success('Order status updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Independent of `status` entirely — offered by the single "Update
|
||||
* Status" action regardless of current status (see
|
||||
* OrderStatusFlow::canMarkPaid()).
|
||||
*/
|
||||
public function markPaid(Order $order): OrderFulfillmentResult
|
||||
{
|
||||
if (! $this->flow->canMarkPaid($order)) {
|
||||
return OrderFulfillmentResult::failure('This order cannot be marked paid right now.');
|
||||
}
|
||||
|
||||
$this->writer->markPaid($order, self::class.'::markPaid');
|
||||
|
||||
return OrderFulfillmentResult::success('Order marked as paid.');
|
||||
}
|
||||
|
||||
public function canCreateShipment(Order $order): bool
|
||||
{
|
||||
return $order->status === 'ready_for_dispatch'
|
||||
&& ! $order->isStorePickupOrder()
|
||||
&& $order->shipments()->exists() === false
|
||||
&& $this->resolveFulfillmentService($order) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public wrapper around resolveCarrier() — Modules\Core\Shipping\
|
||||
* Extensions\OrderViewExtension needs to know which carrier an order
|
||||
* uses to branch the "Create Shipment" form (Box Now's box-size
|
||||
* repeater vs. every other carrier's plain weight field).
|
||||
*/
|
||||
public function carrierFor(Order $order): ?string
|
||||
{
|
||||
return $this->resolveCarrier($order);
|
||||
}
|
||||
|
||||
private function resolveCarrier(Order $order): ?string
|
||||
{
|
||||
$code = $order->shippingAddress?->shipping_option;
|
||||
|
||||
if (! $code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ShippingMethod::where('code', $code)->value('driver');
|
||||
}
|
||||
|
||||
private function resolveFulfillmentService(Order $order): ?CarrierFulfillmentInterface
|
||||
{
|
||||
$carrier = $this->resolveCarrier($order);
|
||||
|
||||
if (! $carrier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Services;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Payment\Models\PaymentMethod;
|
||||
|
||||
/**
|
||||
* Two status sequences — carrier, pickup (Order::isStorePickupOrder()) —
|
||||
* NOT four. Payment method (prepaid vs. cash-on-delivery) does not affect
|
||||
* the status SEQUENCE at all; it only affects Order::paid, an entirely
|
||||
* separate field this class also offers a transition for (see
|
||||
* canMarkPaid()). `status` never includes a "paid" step — COD
|
||||
* reconciliation can happen at any point in, or after, the fulfillment
|
||||
* journey (same-day to months later), so it cannot occupy a fixed slot in
|
||||
* a linear sequence.
|
||||
*/
|
||||
class OrderStatusFlow
|
||||
{
|
||||
private const FLOW_CARRIER = [
|
||||
'awaiting_payment', 'processing', 'ready_for_dispatch', 'dispatched',
|
||||
'delivered', 'completed', 'return_requested', 'returned',
|
||||
];
|
||||
|
||||
private const FLOW_PICKUP = [
|
||||
'awaiting_payment', 'processing', 'ready_for_pickup', 'picked_up',
|
||||
'completed', 'return_requested', 'returned',
|
||||
];
|
||||
|
||||
private const REFUND_OPTIONS = ['partially_refunded', 'refunded'];
|
||||
|
||||
private const RETURN_ELIGIBLE_FROM = ['delivered', 'picked_up', 'completed'];
|
||||
|
||||
public function resolveFlow(Order $order): array
|
||||
{
|
||||
return $order->isStorePickupOrder() ? self::FLOW_PICKUP : self::FLOW_CARRIER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order.meta['payment_method'] (written by
|
||||
* Modules\Core\Checkout\Services\CheckoutService::initiatePayment())
|
||||
* is the durable source of truth. Falls back to the most recent
|
||||
* Transaction.driver (a payment TYPE slug) only if meta is missing —
|
||||
* e.g. an order placed before this field existed.
|
||||
*/
|
||||
public function isCod(Order $order): bool
|
||||
{
|
||||
$type = $order->meta['payment_method'] ?? $order->transactions()->latest('id')->value('driver');
|
||||
|
||||
if ($type === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return PaymentMethod::where('type', $type)->value('driver') === 'cash-on-delivery';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string> value => label — every status in the
|
||||
* order's own branch (carrier or pickup), plus the refund options,
|
||||
* for a manual-override "New status" select. Deliberately not
|
||||
* filtered to nextOptions()'s guided next-step(s) — staff can jump
|
||||
* to any status in their branch, including reverting to an earlier
|
||||
* one (e.g. undoing a mistaken click). transitionTo() still
|
||||
* validates $to is actually a member of this set server-side.
|
||||
*/
|
||||
public function allOptions(Order $order): array
|
||||
{
|
||||
$statuses = [...$this->resolveFlow($order), ...self::REFUND_OPTIONS, 'delivery_failed'];
|
||||
|
||||
return collect($statuses)
|
||||
->unique()
|
||||
->mapWithKeys(fn (string $status) => [$status => $this->label($status)])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string> value => label — status-sequence
|
||||
* transitions offered as the guided next step(s). Does not include
|
||||
* the "mark paid" pseudo-option — see canMarkPaid().
|
||||
*/
|
||||
public function nextOptions(Order $order): array
|
||||
{
|
||||
$flow = $this->resolveFlow($order);
|
||||
$current = $order->status;
|
||||
$position = array_search($current, $flow, true);
|
||||
|
||||
$options = [];
|
||||
|
||||
if ($position !== false && isset($flow[$position + 1])) {
|
||||
$options[] = $flow[$position + 1];
|
||||
}
|
||||
|
||||
// delivery_failed — a possible outcome of any delivery attempt,
|
||||
// carrier flow only, checked on $current directly (not on the
|
||||
// flow's literal next value) since it's a branch on the attempt
|
||||
// itself, not on sequence position.
|
||||
if ($current === 'dispatched') {
|
||||
$options[] = 'delivery_failed';
|
||||
}
|
||||
|
||||
// From delivery_failed: retry dispatch, or give up and treat as
|
||||
// a return.
|
||||
if ($current === 'delivery_failed') {
|
||||
array_push($options, 'dispatched', 'return_requested');
|
||||
}
|
||||
|
||||
if (in_array($current, self::RETURN_ELIGIBLE_FROM, true)) {
|
||||
$options[] = 'return_requested';
|
||||
}
|
||||
|
||||
if ($current === 'return_requested') {
|
||||
$options[] = 'returned';
|
||||
}
|
||||
|
||||
if ($current === 'returned') {
|
||||
array_push($options, ...self::REFUND_OPTIONS);
|
||||
}
|
||||
|
||||
return collect($options)
|
||||
->unique()
|
||||
->mapWithKeys(fn (string $status) => [$status => $this->label($status)])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the "mark paid" option should be offered right now —
|
||||
* entirely independent of $order->status. True whenever this is a
|
||||
* cash-on-delivery order and payment hasn't been recorded yet,
|
||||
* regardless of fulfillment progress (before OR after completed).
|
||||
*/
|
||||
public function canMarkPaid(Order $order): bool
|
||||
{
|
||||
return ! $order->paid && $this->isCod($order);
|
||||
}
|
||||
|
||||
private function label(string $status): string
|
||||
{
|
||||
return (string) str($status)->replace('_', ' ')->title();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Services;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Order\Models\OrderStatusTransition;
|
||||
|
||||
/**
|
||||
* The single place every order_status_transitions row gets written —
|
||||
* called by Modules\Core\Order\Listeners\RecordStatusTransition, itself
|
||||
* listening to Modules\Core\Order\Events\OrderStatusChanged and
|
||||
* OrderPaidChanged, dispatched by Modules\Core\Order\Services\
|
||||
* OrderStatusWriter (the only writer of Order::status/paid left in this
|
||||
* package).
|
||||
*/
|
||||
final class OrderStatusTransitionRecorder
|
||||
{
|
||||
public function record(Order $order, ?string $from, string $to, string $eventClass): void
|
||||
{
|
||||
OrderStatusTransition::create([
|
||||
'order_id' => $order->id,
|
||||
'from_status' => $from,
|
||||
'to_status' => $to,
|
||||
'event_class' => $eventClass,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Services;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Order\Events\OrderPaidChanged;
|
||||
use Modules\Core\Order\Events\OrderStatusChanged;
|
||||
|
||||
/**
|
||||
* The one place Order::status/paid actually get written — replaces the
|
||||
* earlier per-axis Modules\Core\Order\Services\OrderAxisWriter now that
|
||||
* there is a single status column plus one independent `paid` field (see
|
||||
* Modules\Core\Order\Services\OrderStatusFlow's own docblock for why
|
||||
* payment timing is not a status-sequence step).
|
||||
*
|
||||
* write() relies on Modules\Core\Order\Observers\OrderObserver to
|
||||
* generically dispatch OrderStatusUpdated whenever `status` actually
|
||||
* changes — there's no separate axis-changed event to dispatch here
|
||||
* anymore, since there's only one column left to watch. markPaid() is
|
||||
* genuinely independent: it dispatches its own OrderPaidChanged, since
|
||||
* OrderObserver only watches `status`, not `paid`.
|
||||
*
|
||||
* Cause is passed explicitly through every call rather than smuggled
|
||||
* through a runtime property on the model — Lunar\Models\Order has
|
||||
* $guarded = [], so Eloquent treats ANY property assignment as a real
|
||||
* column to persist; an earlier design that tried
|
||||
* $order->statusTransitionCause = ... broke immediately with an
|
||||
* "undefined column" error the moment ->update() ran.
|
||||
*/
|
||||
class OrderStatusWriter
|
||||
{
|
||||
public function write(Order $order, string $to, string $causeClass): void
|
||||
{
|
||||
$from = $order->status;
|
||||
|
||||
if ($from === $to) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order->update(['status' => $to]);
|
||||
|
||||
OrderStatusChanged::dispatch($order, $from, $to, $causeClass);
|
||||
}
|
||||
|
||||
public function markPaid(Order $order, string $causeClass): void
|
||||
{
|
||||
if ($order->paid) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order->update(['paid' => true, 'paid_at' => now()]);
|
||||
|
||||
OrderPaidChanged::dispatch($order, $causeClass);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user