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.'); } /** * Creates the shipment only — the order stays at ready_for_dispatch * until the carrier actually picks the parcel up. That move (and the * "on its way" email) happens in Modules\Core\Order\Listeners\ * AdvanceFulfillmentOnCarrierCheckpoint, on the first InTransit / * CollectedFromSender checkpoint — synced from the carrier, or entered * by hand for a manual carrier. */ public function createShipment(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()); } return OrderFulfillmentResult::success('Shipment created. The order moves to Dispatched when the carrier picks it up.'); } /** * Records an integrated carrier's voucher that wasn't created through * our API — the carrier's system was down and a pre-numbered paper * voucher was used, or the courier wrote his own at pickup. It's still * that carrier's voucher, so PollShipmentTrackingJob picks up its * history once the carrier's API has it. * * @param array $services */ public function addManualVoucher(Order $order, string $carrier, string $voucherNumber, array $services = []): OrderFulfillmentResult { if (! $this->canAddManualVoucher($order)) { return OrderFulfillmentResult::failure('A voucher can only be added from Ready for Dispatch until the order is delivered.'); } $voucherNumber = trim($voucherNumber); if (Shipment::where('tracking_reference', $voucherNumber)->exists()) { return OrderFulfillmentResult::failure("Voucher {$voucherNumber} is already recorded."); } Shipment::create([ 'order_id' => $order->id, 'carrier' => $carrier, 'source' => Shipment::SOURCE_MANUAL_VOUCHER, 'tracking_reference' => $voucherNumber, 'meta' => [ 'services' => array_map(fn (ExtraService $service) => $service->value, $services), 'cod_amount' => $this->flow->isCod($order) ? $order->total->decimal : null, ], ]); return OrderFulfillmentResult::success("Voucher {$voucherNumber} added."); } public function canAddManualVoucher(Order $order): bool { return in_array($order->status, ['ready_for_dispatch', 'dispatched', 'delivery_failed'], true) && ! $order->isStorePickupOrder(); } 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.'); } /** * A cancelled shipment doesn't block creating a new one. */ public function canCreateShipment(Order $order): bool { return $order->status === 'ready_for_dispatch' && ! $order->isStorePickupOrder() && $order->shipments()->whereNull('cancelled_at')->doesntExist() && $this->resolveFulfillmentService($order) !== null; } /** * The order's shipping method — manual carriers keep their name, * tracking URL template and cash-on-delivery setting in its `data`. */ public function shippingMethodFor(Order $order): ?ShippingMethod { $code = $order->shippingAddress?->shipping_option; return $code ? ShippingMethod::where('code', $code)->first() : 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]); } }