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.'); } /** * For a COD order, independent of `status` entirely — offered by the * single "Update Status" action regardless of current status (see * OrderStatusFlow::canMarkPaid()). For a bank transfer order, status * genuinely does advance here too (see below) — unlike COD, a bank * transfer order has been sitting at 'awaiting_payment' since checkout * (BankTransferPaymentDriver::pay() deliberately never advances it), * and this click is the only thing that ever will. */ public function markPaid(Order $order): OrderFulfillmentResult { if (! $this->flow->canMarkPaid($order)) { return OrderFulfillmentResult::failure('This order cannot be marked paid right now.'); } // canMarkPaid() only ever returns true for an order whose payment // method resolves to the cash-on-delivery or bank-transfer DRIVER // (see OrderStatusFlow::isCod()/isBankTransfer(), which check // PaymentMethod::driver, never the merchant-chosen `type` slug // directly — a store could name that method "cod", "pay-on-delivery", // "wire", anything). Neither ever runs a Transaction-recording event // through to completion at checkout (COD dispatches nothing capture- // shaped at all; bank transfer's pay() returns Pending with no event // dispatched — see that driver's own docblock). Money changes hands // right here, at this click, so this is the one place that write can // happen; there is no earlier Payment event to hang it off of the way // Modules\Core\Order\Listeners\RecordPaymentTransaction does for a // gateway driver. See TransactionRecorder's own docblock — it already // anticipated exactly this "manually-triggered ... from Filament" // call site. // // $driver below is the payment method's own `type` slug (whatever // the merchant named it, e.g. 'cash-on-delivery' or 'cod') — // Transaction.driver's established meaning everywhere else in this // codebase (see RecordPaymentTransaction/TransactionRecorder's own // docblocks) is that type key, never the underlying driver CLASS. // No fallback guess here: CheckoutService::initiatePayment() always // writes Order.meta['payment_method'] before charging, and // canMarkPaid() already guarantees this order got that far. $type = (string) $order->meta['payment_method']; $this->transactions->record( $order, type: 'capture', driver: $type, result: new PaymentResult( status: PaymentResultStatus::Succeeded, reference: "manual-{$type}-{$order->id}", amount: $order->total, ), ); $this->writer->markPaid($order, self::class.'::markPaid'); if ($this->flow->isBankTransfer($order)) { $this->resolution->advancePastAwaitingPayment($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]); } }