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 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 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); } /** * 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 { return (string) str($status)->replace('_', ' ')->title(); } }