diff --git a/config/core.php b/config/core.php
index 4f09573..49d34f6 100644
--- a/config/core.php
+++ b/config/core.php
@@ -48,4 +48,21 @@ return [
'unrecoverable_after' => '90 days',
],
+ /*
+ |--------------------------------------------------------------------------
+ | Order Return Window
+ |--------------------------------------------------------------------------
+ |
+ | How many days after a carrier order is delivered (Order::fulfillment_status
+ | becomes 'return_window_open') before Modules\Core\Order\Commands\
+ | CloseExpiredReturnWindows auto-completes it, if no return was requested.
+ | Store-pickup orders have no return-window step and are unaffected by
+ | this value (see Modules\Core\Order\Listeners\CompleteOrderOnPickedUp).
+ |
+ */
+
+ 'order' => [
+ 'return_window_days' => 14,
+ ],
+
];
diff --git a/database/migrations/2026_09_11_000001_add_status_axes_to_orders_table.php b/database/migrations/2026_09_11_000001_add_status_axes_to_orders_table.php
new file mode 100644
index 0000000..001192a
--- /dev/null
+++ b/database/migrations/2026_09_11_000001_add_status_axes_to_orders_table.php
@@ -0,0 +1,43 @@
+string('payment_status')->default('awaiting_payment')->after('status')->index();
+ $table->string('fulfillment_status')->default('unfulfilled')->after('payment_status')->index();
+ $table->string('return_status')->default('none')->after('fulfillment_status')->index();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('lunar_orders', function (Blueprint $table) {
+ $table->dropColumn(['payment_status', 'fulfillment_status', 'return_status']);
+ });
+ }
+};
diff --git a/database/migrations/2026_09_11_000002_create_order_status_transitions_table.php b/database/migrations/2026_09_11_000002_create_order_status_transitions_table.php
new file mode 100644
index 0000000..38b9a29
--- /dev/null
+++ b/database/migrations/2026_09_11_000002_create_order_status_transitions_table.php
@@ -0,0 +1,42 @@
+id();
+ $table->foreignId('order_id')->constrained('lunar_orders')->cascadeOnDelete();
+ $table->string('axis');
+ $table->string('from_status')->nullable();
+ $table->string('to_status');
+ $table->string('event_class');
+ $table->timestamp('created_at')->useCurrent();
+ $table->index(['order_id', 'axis']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('order_status_transitions');
+ }
+};
diff --git a/database/migrations/2026_09_11_000003_backfill_order_status_axes.php b/database/migrations/2026_09_11_000003_backfill_order_status_axes.php
new file mode 100644
index 0000000..63a53d9
--- /dev/null
+++ b/database/migrations/2026_09_11_000003_backfill_order_status_axes.php
@@ -0,0 +1,79 @@
+ ['payment_status' => 'awaiting_payment', 'fulfillment_status' => 'unfulfilled'],
+ 'payment-offline' => ['payment_status' => 'awaiting_payment', 'fulfillment_status' => 'unfulfilled'],
+ 'payment-received' => ['payment_status' => 'paid', 'fulfillment_status' => 'unfulfilled'],
+ 'ready-for-dispatch' => ['payment_status' => 'paid', 'fulfillment_status' => 'ready'],
+ 'ready-for-pickup' => ['payment_status' => 'paid', 'fulfillment_status' => 'ready'],
+ 'dispatched' => ['payment_status' => 'paid', 'fulfillment_status' => 'in_transit'],
+ 'completed' => ['payment_status' => 'paid', 'fulfillment_status' => 'completed'],
+ ];
+
+ public function up(): void
+ {
+ Order::query()->with('transactions')->chunkById(200, function ($orders) {
+ foreach ($orders as $order) {
+ $mapped = self::MAP[$order->status] ?? null;
+
+ if ($mapped === null) {
+ Log::warning('Order status axis backfill: unmapped status, leaving column defaults', [
+ 'order_id' => $order->id,
+ 'status' => $order->status,
+ ]);
+
+ continue;
+ }
+
+ $paymentStatus = $mapped['payment_status'];
+
+ $derived = OrderStatus::payment($order);
+
+ if ($derived === PaymentStatus::Refunded) {
+ $paymentStatus = 'refunded';
+ } elseif ($derived === PaymentStatus::PartialRefund) {
+ $paymentStatus = 'partially_refunded';
+ }
+
+ DB::table('lunar_orders')->where('id', $order->id)->update([
+ 'payment_status' => $paymentStatus,
+ 'fulfillment_status' => $mapped['fulfillment_status'],
+ 'return_status' => 'none',
+ ]);
+ }
+ });
+ }
+
+ public function down(): void
+ {
+ // Column defaults (set in the schema migration) are the correct
+ // "undo" — no need to reverse-map back to the flat status, since
+ // `status` itself was never touched by this migration.
+ }
+};
diff --git a/database/migrations/2026_09_11_000004_drop_status_columns_from_payment_methods_table.php b/database/migrations/2026_09_11_000004_drop_status_columns_from_payment_methods_table.php
new file mode 100644
index 0000000..6e24878
--- /dev/null
+++ b/database/migrations/2026_09_11_000004_drop_status_columns_from_payment_methods_table.php
@@ -0,0 +1,36 @@
+dropColumn(['captured_status', 'authorized_status', 'refunded_status']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('payment_methods', function (Blueprint $table) {
+ $table->string('captured_status')->nullable();
+ $table->string('authorized_status')->nullable();
+ $table->string('refunded_status')->nullable();
+ });
+ }
+};
diff --git a/database/migrations/2026_09_12_000001_add_paid_to_orders_table.php b/database/migrations/2026_09_12_000001_add_paid_to_orders_table.php
new file mode 100644
index 0000000..e012efb
--- /dev/null
+++ b/database/migrations/2026_09_12_000001_add_paid_to_orders_table.php
@@ -0,0 +1,30 @@
+boolean('paid')->default(false)->after('status')->index();
+ $table->timestamp('paid_at')->nullable()->after('paid');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('lunar_orders', function (Blueprint $table) {
+ $table->dropColumn(['paid', 'paid_at']);
+ });
+ }
+};
diff --git a/database/migrations/2026_09_12_000002_backfill_single_order_status_and_paid.php b/database/migrations/2026_09_12_000002_backfill_single_order_status_and_paid.php
new file mode 100644
index 0000000..d9a9279
--- /dev/null
+++ b/database/migrations/2026_09_12_000002_backfill_single_order_status_and_paid.php
@@ -0,0 +1,116 @@
+ 'awaiting_payment',
+ 'payment-offline' => 'awaiting_payment',
+ 'payment-received' => 'processing',
+ 'ready-for-dispatch' => 'ready_for_dispatch',
+ 'ready-for-pickup' => 'ready_for_pickup',
+ 'dispatched' => 'dispatched',
+ 'completed' => 'completed',
+ ];
+
+ /**
+ * Axis fulfillment_status -> new single status, given branch. Axis
+ * 'delivered' folds into 'return_window_open' (same combined-value
+ * decision the going-forward design makes). Axis payment_status is
+ * used only to decide whether a fully-unfulfilled order should read
+ * as 'awaiting_payment' or 'processing'.
+ */
+ private function mapFromAxes(string $payment, string $fulfillment, string $return, bool $isPickup): ?string
+ {
+ if ($return === 'returned') {
+ return 'returned';
+ }
+ if ($return === 'requested') {
+ return 'return_requested';
+ }
+
+ return match ($fulfillment) {
+ 'unfulfilled' => $payment === 'paid' ? 'processing' : 'awaiting_payment',
+ 'processing' => 'processing',
+ 'ready' => $isPickup ? 'ready_for_pickup' : 'ready_for_dispatch',
+ 'in_transit' => 'dispatched',
+ 'delivered', 'return_window_open' => 'return_window_open',
+ 'picked_up' => 'picked_up',
+ 'completed' => 'completed',
+ default => null,
+ };
+ }
+
+ public function up(): void
+ {
+ Order::query()->with('transactions')->chunkById(200, function ($orders) {
+ foreach ($orders as $order) {
+ $isPickup = $order->isStorePickupOrder();
+
+ $axisIsDefault = $order->payment_status === 'awaiting_payment'
+ && $order->fulfillment_status === 'unfulfilled'
+ && $order->return_status === 'none';
+
+ $status = $axisIsDefault
+ ? (self::LEGACY_MAP[$order->status] ?? null)
+ : $this->mapFromAxes($order->payment_status, $order->fulfillment_status, $order->return_status, $isPickup);
+
+ if ($status === null) {
+ Log::warning('Single-status backfill: unmapped order, defaulting to awaiting_payment', [
+ 'order_id' => $order->id,
+ 'status' => $order->status,
+ 'payment_status' => $order->payment_status,
+ 'fulfillment_status' => $order->fulfillment_status,
+ 'return_status' => $order->return_status,
+ ]);
+ $status = 'awaiting_payment';
+ }
+
+ $derived = OrderStatus::payment($order);
+ $paid = $order->payment_status === 'paid'
+ || in_array($derived, [PaymentStatus::Captured, PaymentStatus::Refunded, PaymentStatus::PartialRefund], true);
+
+ // A refund implies the order concluded via a return —
+ // even one backfilled to an early status (e.g. an order
+ // refunded before fulfillment ever started) is corrected
+ // to refunded/partially_refunded here, not left stuck
+ // pre-fulfillment with no sign a refund ever happened.
+ if ($derived === PaymentStatus::Refunded) {
+ $status = 'refunded';
+ } elseif ($derived === PaymentStatus::PartialRefund) {
+ $status = 'partially_refunded';
+ }
+
+ DB::table('lunar_orders')->where('id', $order->id)->update([
+ 'status' => $status,
+ 'paid' => $paid,
+ 'paid_at' => $paid ? ($order->placed_at ?? now()) : null,
+ ]);
+ }
+ });
+ }
+
+ public function down(): void
+ {
+ // No reverse mapping — column defaults (post-rollback of the
+ // schema migrations) are the correct "undo".
+ }
+};
diff --git a/database/migrations/2026_09_12_000003_drop_status_axes_from_orders_table.php b/database/migrations/2026_09_12_000003_drop_status_axes_from_orders_table.php
new file mode 100644
index 0000000..8644e00
--- /dev/null
+++ b/database/migrations/2026_09_12_000003_drop_status_axes_from_orders_table.php
@@ -0,0 +1,34 @@
+dropColumn(['payment_status', 'fulfillment_status', 'return_status']);
+ });
+ }
+
+ public function down(): void
+ {
+ // Mirrors 2026_09_11_000001's own down() — restores columns
+ // empty/defaulted, does not attempt to resurrect real per-order
+ // values.
+ Schema::table('lunar_orders', function (Blueprint $table) {
+ $table->string('payment_status')->default('awaiting_payment')->after('paid_at')->index();
+ $table->string('fulfillment_status')->default('unfulfilled')->after('payment_status')->index();
+ $table->string('return_status')->default('none')->after('fulfillment_status')->index();
+ });
+ }
+};
diff --git a/database/migrations/2026_09_12_000004_drop_axis_from_order_status_transitions_table.php b/database/migrations/2026_09_12_000004_drop_axis_from_order_status_transitions_table.php
new file mode 100644
index 0000000..8ae0e36
--- /dev/null
+++ b/database/migrations/2026_09_12_000004_drop_axis_from_order_status_transitions_table.php
@@ -0,0 +1,33 @@
+dropIndex(['order_id', 'axis']);
+ $table->dropColumn('axis');
+ $table->index('order_id');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('order_status_transitions', function (Blueprint $table) {
+ $table->dropIndex(['order_id']);
+ $table->string('axis')->default('status')->after('order_id');
+ $table->index(['order_id', 'axis']);
+ });
+ }
+};
diff --git a/database/migrations/2026_09_12_000005_correct_cash_on_delivery_payment_method_driver.php b/database/migrations/2026_09_12_000005_correct_cash_on_delivery_payment_method_driver.php
new file mode 100644
index 0000000..e72c810
--- /dev/null
+++ b/database/migrations/2026_09_12_000005_correct_cash_on_delivery_payment_method_driver.php
@@ -0,0 +1,27 @@
+ 'offline' — the same immediate-capture driver as
+ * cash-in-hand. That's the bug that made COD "pay immediately" instead of
+ * waiting for staff to confirm cash was actually received. Repoints
+ * already-seeded environments to the new dedicated
+ * Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver; the seeder
+ * itself is fixed separately for fresh installs.
+ */
+return new class extends Migration
+{
+ public function up(): void
+ {
+ DB::table('payment_methods')->where('type', 'cash-on-delivery')->update(['driver' => 'cash-on-delivery']);
+ }
+
+ public function down(): void
+ {
+ DB::table('payment_methods')->where('type', 'cash-on-delivery')->update(['driver' => 'offline']);
+ }
+};
diff --git a/database/migrations/2026_09_13_000001_rename_return_window_open_status_to_delivered.php b/database/migrations/2026_09_13_000001_rename_return_window_open_status_to_delivered.php
new file mode 100644
index 0000000..001b528
--- /dev/null
+++ b/database/migrations/2026_09_13_000001_rename_return_window_open_status_to_delivered.php
@@ -0,0 +1,30 @@
+where('status', 'return_window_open')->update(['status' => 'delivered']);
+ DB::table('order_status_transitions')->where('from_status', 'return_window_open')->update(['from_status' => 'delivered']);
+ DB::table('order_status_transitions')->where('to_status', 'return_window_open')->update(['to_status' => 'delivered']);
+ }
+
+ public function down(): void
+ {
+ DB::table('lunar_orders')->where('status', 'delivered')->update(['status' => 'return_window_open']);
+ DB::table('order_status_transitions')->where('from_status', 'delivered')->update(['from_status' => 'return_window_open']);
+ DB::table('order_status_transitions')->where('to_status', 'delivered')->update(['to_status' => 'return_window_open']);
+ }
+};
diff --git a/database/migrations/2026_09_13_000002_create_manifests_table.php b/database/migrations/2026_09_13_000002_create_manifests_table.php
new file mode 100644
index 0000000..1e9abd4
--- /dev/null
+++ b/database/migrations/2026_09_13_000002_create_manifests_table.php
@@ -0,0 +1,43 @@
+count()) purely so the manifests list can
+ * render without an extra query per row.
+ *
+ * carrier-agnostic by design — see Modules\Core\Shipping\Contracts\
+ * SupportsManifestBatching, the same contract any future carrier
+ * (Speedex, etc.) implements to get manifest batching at all; this table
+ * has no ACS-specific columns.
+ */
+return new class extends Migration
+{
+ public function up(): void
+ {
+ Schema::create('manifests', function (Blueprint $table) {
+ $table->id();
+ $table->string('carrier');
+ $table->string('reference');
+ $table->unsignedInteger('shipment_count')->default(0);
+ $table->timestamp('issued_at');
+ $table->timestamps();
+
+ $table->unique(['carrier', 'reference']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('manifests');
+ }
+};
diff --git a/database/migrations/2026_09_13_000003_add_manifest_id_to_shipments_table.php b/database/migrations/2026_09_13_000003_add_manifest_id_to_shipments_table.php
new file mode 100644
index 0000000..4b3de28
--- /dev/null
+++ b/database/migrations/2026_09_13_000003_add_manifest_id_to_shipments_table.php
@@ -0,0 +1,82 @@
+foreignId('manifest_id')->nullable()->after('manifest_reference')->constrained()->nullOnDelete();
+ });
+
+ $groups = DB::table('shipments')
+ ->select('carrier', 'manifest_reference')
+ ->whereNotNull('manifest_reference')
+ ->distinct()
+ ->get();
+
+ foreach ($groups as $group) {
+ $shipments = DB::table('shipments')
+ ->where('carrier', $group->carrier)
+ ->where('manifest_reference', $group->manifest_reference)
+ ->get();
+
+ $issuedAt = $shipments->pluck('label_printed_at')->filter()->min()
+ ?? $shipments->pluck('updated_at')->min();
+
+ $manifestId = DB::table('manifests')->insertGetId([
+ 'carrier' => $group->carrier,
+ 'reference' => $group->manifest_reference,
+ 'shipment_count' => $shipments->count(),
+ 'issued_at' => $issuedAt,
+ 'created_at' => $issuedAt,
+ 'updated_at' => $issuedAt,
+ ]);
+
+ DB::table('shipments')
+ ->where('carrier', $group->carrier)
+ ->where('manifest_reference', $group->manifest_reference)
+ ->update(['manifest_id' => $manifestId]);
+ }
+
+ Schema::table('shipments', function (Blueprint $table) {
+ $table->dropColumn('manifest_reference');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('shipments', function (Blueprint $table) {
+ $table->string('manifest_reference')->nullable()->after('parent_reference');
+ });
+
+ DB::table('shipments')
+ ->whereNotNull('manifest_id')
+ ->orderBy('id')
+ ->each(function ($shipment) {
+ $manifest = DB::table('manifests')->find($shipment->manifest_id);
+
+ if ($manifest) {
+ DB::table('shipments')->where('id', $shipment->id)->update([
+ 'manifest_reference' => $manifest->reference,
+ ]);
+ }
+ });
+
+ Schema::table('shipments', function (Blueprint $table) {
+ $table->dropConstrainedForeignId('manifest_id');
+ });
+ }
+};
diff --git a/resources/views/order/notifications/completed.blade.php b/resources/views/order/notifications/completed.blade.php
new file mode 100644
index 0000000..d5df044
--- /dev/null
+++ b/resources/views/order/notifications/completed.blade.php
@@ -0,0 +1,3 @@
+
Hi,
+
+Your order {{ $reference }} is complete. Thanks for shopping with us!
diff --git a/resources/views/order/notifications/dispatched.blade.php b/resources/views/order/notifications/dispatched.blade.php
new file mode 100644
index 0000000..b89cf17
--- /dev/null
+++ b/resources/views/order/notifications/dispatched.blade.php
@@ -0,0 +1,3 @@
+Hi,
+
+Your order {{ $reference }} is on its way.
diff --git a/resources/views/shipping/filament/pages/manage-pickup-manifests.blade.php b/resources/views/shipping/filament/pages/manage-pickup-manifests.blade.php
deleted file mode 100644
index ce096a2..0000000
--- a/resources/views/shipping/filament/pages/manage-pickup-manifests.blade.php
+++ /dev/null
@@ -1,3 +0,0 @@
-
- {{ $this->table }}
-
diff --git a/src/Checkout/Services/CheckoutService.php b/src/Checkout/Services/CheckoutService.php
index 0991aba..aad91d5 100644
--- a/src/Checkout/Services/CheckoutService.php
+++ b/src/Checkout/Services/CheckoutService.php
@@ -297,6 +297,7 @@ class CheckoutService
$order->meta = [
...($order->meta?->toArray() ?? []),
+ 'payment_method' => $type,
'terms_accepted' => true,
'terms_accepted_at' => now()->toIso8601String(),
'terms_accepted_policy_version' => $policyVersion,
diff --git a/src/Command/InstallLunarCommand.php b/src/Command/InstallLunarCommand.php
index 0475e72..1c0c040 100644
--- a/src/Command/InstallLunarCommand.php
+++ b/src/Command/InstallLunarCommand.php
@@ -311,9 +311,8 @@ class InstallLunarCommand extends Command
PaymentMethod::create([
'type' => 'cash-on-delivery',
'name' => 'Cash on Delivery',
- 'driver' => 'offline',
+ 'driver' => 'cash-on-delivery',
'capture_mode' => 'pay',
- 'captured_status' => 'payment-offline',
'position' => 0,
'enabled' => false,
'data' => [],
diff --git a/src/CorePlugin.php b/src/CorePlugin.php
index 583e79d..31efae4 100644
--- a/src/CorePlugin.php
+++ b/src/CorePlugin.php
@@ -27,15 +27,18 @@ use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension;
use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Order\Filament\Extensions\OrderItemsTableExtension;
+use Modules\Core\Order\Filament\Extensions\OrderPaymentMethodSummaryExtension;
use Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension;
use Modules\Core\Order\Filament\Extensions\OrderTransactionsExtension;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
use Modules\Core\Review\Models\ProductReview;
+use Modules\Core\Shipping\Extensions\OrderShipmentsExtension;
use Modules\Core\Shipping\Extensions\OrderViewExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodListExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension;
-use Modules\Core\Shipping\Filament\Pages\ManagePickupManifests;
+use Modules\Core\Shipping\Filament\Resources\ManifestResource;
+use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
class CorePlugin implements Plugin
{
@@ -55,9 +58,10 @@ class CorePlugin implements Plugin
LanguageLineResource::class,
CartResource::class,
PaymentMethodResource::class,
+ ShipmentResource::class,
+ ManifestResource::class,
])
- ->plugin(ShippingPlugin::make())
- ->pages([ManagePickupManifests::class]);
+ ->plugin(ShippingPlugin::make());
LunarPanel::extensions([
StaffResource::class => StaffResourceExtension::class,
@@ -66,7 +70,7 @@ class CorePlugin implements Plugin
ValuesRelationManager::class => ValuesRelationManagerExtension::class,
ShippingMethodResource::class => ShippingMethodResourceExtension::class,
ListShippingMethod::class => ShippingMethodListExtension::class,
- ManageOrder::class => [OrderViewExtension::class, OrderRefundActionsExtension::class, OrderTransactionsExtension::class],
+ ManageOrder::class => [OrderViewExtension::class, OrderRefundActionsExtension::class, OrderTransactionsExtension::class, OrderPaymentMethodSummaryExtension::class, OrderShipmentsExtension::class],
OrderItemsTable::class => OrderItemsTableExtension::class,
]);
diff --git a/src/Order/Commands/CloseExpiredReturnWindows.php b/src/Order/Commands/CloseExpiredReturnWindows.php
new file mode 100644
index 0000000..2f65408
--- /dev/null
+++ b/src/Order/Commands/CloseExpiredReturnWindows.php
@@ -0,0 +1,68 @@
+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.");
+ }
+}
diff --git a/src/Order/DTOs/OrderFulfillmentResult.php b/src/Order/DTOs/OrderFulfillmentResult.php
new file mode 100644
index 0000000..32ca635
--- /dev/null
+++ b/src/Order/DTOs/OrderFulfillmentResult.php
@@ -0,0 +1,30 @@
+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;
+ }
+}
diff --git a/src/Order/Listeners/AdvanceFulfillmentOnCarrierCheckpoint.php b/src/Order/Listeners/AdvanceFulfillmentOnCarrierCheckpoint.php
new file mode 100644
index 0000000..e5451bc
--- /dev/null
+++ b/src/Order/Listeners/AdvanceFulfillmentOnCarrierCheckpoint.php
@@ -0,0 +1,49 @@
+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);
+ }
+}
diff --git a/src/Order/Listeners/AdvanceFulfillmentOnDelivered.php b/src/Order/Listeners/AdvanceFulfillmentOnDelivered.php
new file mode 100644
index 0000000..1ca6e9b
--- /dev/null
+++ b/src/Order/Listeners/AdvanceFulfillmentOnDelivered.php
@@ -0,0 +1,43 @@
+order;
+
+ if ($order->status !== 'dispatched') {
+ return;
+ }
+
+ $this->writer->write($order, 'delivered', self::class);
+ }
+}
diff --git a/src/Order/Listeners/ApplyResolvedPaymentStatus.php b/src/Order/Listeners/ApplyResolvedPaymentStatus.php
index 12869f1..2df32a2 100644
--- a/src/Order/Listeners/ApplyResolvedPaymentStatus.php
+++ b/src/Order/Listeners/ApplyResolvedPaymentStatus.php
@@ -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;
}
}
diff --git a/src/Order/Listeners/CompleteOrderOnDelivered.php b/src/Order/Listeners/CompleteOrderOnDelivered.php
deleted file mode 100644
index e67dc97..0000000
--- a/src/Order/Listeners/CompleteOrderOnDelivered.php
+++ /dev/null
@@ -1,36 +0,0 @@
-order->status !== 'dispatched') {
- return;
- }
-
- $event->order->update(['status' => 'completed']);
- }
-}
diff --git a/src/Order/Listeners/CompleteOrderOnPickedUp.php b/src/Order/Listeners/CompleteOrderOnPickedUp.php
new file mode 100644
index 0000000..581a9e3
--- /dev/null
+++ b/src/Order/Listeners/CompleteOrderOnPickedUp.php
@@ -0,0 +1,39 @@
+order;
+
+ if ($order->status !== 'picked_up') {
+ return;
+ }
+
+ $this->writer->write($order, 'completed', self::class);
+
+ OrderCompleted::dispatch($order);
+ }
+}
diff --git a/src/Order/Listeners/MarkDeliveryFailedOnCarrierCheckpoint.php b/src/Order/Listeners/MarkDeliveryFailedOnCarrierCheckpoint.php
new file mode 100644
index 0000000..96cab9f
--- /dev/null
+++ b/src/Order/Listeners/MarkDeliveryFailedOnCarrierCheckpoint.php
@@ -0,0 +1,35 @@
+shipmentInfo->status !== TrackingStatus::Failed) {
+ return;
+ }
+
+ $order = $event->shipmentInfo->shipment->order;
+
+ if (! $order || $order->status !== 'dispatched') {
+ return;
+ }
+
+ $this->writer->write($order, 'delivery_failed', self::class);
+ }
+}
diff --git a/src/Order/Listeners/RecordStatusTransition.php b/src/Order/Listeners/RecordStatusTransition.php
new file mode 100644
index 0000000..bb70d21
--- /dev/null
+++ b/src/Order/Listeners/RecordStatusTransition.php
@@ -0,0 +1,33 @@
+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);
+ }
+}
diff --git a/src/Order/Models/OrderStatusTransition.php b/src/Order/Models/OrderStatusTransition.php
new file mode 100644
index 0000000..b16f28c
--- /dev/null
+++ b/src/Order/Models/OrderStatusTransition.php
@@ -0,0 +1,29 @@
+belongsTo(Order::class);
+ }
+}
diff --git a/src/Order/Notifications/OrderCompletedNotification.php b/src/Order/Notifications/OrderCompletedNotification.php
new file mode 100644
index 0000000..14ef7cd
--- /dev/null
+++ b/src/Order/Notifications/OrderCompletedNotification.php
@@ -0,0 +1,49 @@
+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,
+ ]);
+ }
+}
diff --git a/src/Order/Notifications/OrderDispatchedNotification.php b/src/Order/Notifications/OrderDispatchedNotification.php
new file mode 100644
index 0000000..46bc43c
--- /dev/null
+++ b/src/Order/Notifications/OrderDispatchedNotification.php
@@ -0,0 +1,54 @@
+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,
+ ]);
+ }
+}
diff --git a/src/Order/Notifications/OrderPickupReadyNotification.php b/src/Order/Notifications/OrderPickupReadyNotification.php
index c68f42c..49c5844 100644
--- a/src/Order/Notifications/OrderPickupReadyNotification.php
+++ b/src/Order/Notifications/OrderPickupReadyNotification.php
@@ -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'];
}
diff --git a/src/Order/Observers/OrderObserver.php b/src/Order/Observers/OrderObserver.php
index 9cfbef8..ea7979e 100644
--- a/src/Order/Observers/OrderObserver.php
+++ b/src/Order/Observers/OrderObserver.php
@@ -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,
- );
}
}
diff --git a/src/Order/Services/OrderFulfillmentService.php b/src/Order/Services/OrderFulfillmentService.php
new file mode 100644
index 0000000..1874883
--- /dev/null
+++ b/src/Order/Services/OrderFulfillmentService.php
@@ -0,0 +1,169 @@
+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]);
+ }
+}
diff --git a/src/Order/Services/OrderStatusFlow.php b/src/Order/Services/OrderStatusFlow.php
new file mode 100644
index 0000000..daa4ed5
--- /dev/null
+++ b/src/Order/Services/OrderStatusFlow.php
@@ -0,0 +1,140 @@
+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);
+ }
+
+ private function label(string $status): string
+ {
+ return (string) str($status)->replace('_', ' ')->title();
+ }
+}
diff --git a/src/Order/Services/OrderStatusTransitionRecorder.php b/src/Order/Services/OrderStatusTransitionRecorder.php
new file mode 100644
index 0000000..a6e24af
--- /dev/null
+++ b/src/Order/Services/OrderStatusTransitionRecorder.php
@@ -0,0 +1,27 @@
+ $order->id,
+ 'from_status' => $from,
+ 'to_status' => $to,
+ 'event_class' => $eventClass,
+ ]);
+ }
+}
diff --git a/src/Order/Services/OrderStatusWriter.php b/src/Order/Services/OrderStatusWriter.php
new file mode 100644
index 0000000..8f9b588
--- /dev/null
+++ b/src/Order/Services/OrderStatusWriter.php
@@ -0,0 +1,55 @@
+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);
+ }
+}
diff --git a/src/Payment/Drivers/CashOnDeliveryPaymentDriver.php b/src/Payment/Drivers/CashOnDeliveryPaymentDriver.php
new file mode 100644
index 0000000..866167b
--- /dev/null
+++ b/src/Payment/Drivers/CashOnDeliveryPaymentDriver.php
@@ -0,0 +1,50 @@
+default('pay')
->live()
->required(),
- static::getOrderStatusSelect('captured_status', 'Order status once paid')
- ->helperText('Applied the moment a payment is fully charged.'),
- static::getOrderStatusSelect('authorized_status', 'Order status once held')
- ->helperText('Applied the moment a hold is placed, before it\'s charged.')
- ->visible(fn (Get $get) => $get('capture_mode') === 'authorize'),
- static::getOrderStatusSelect('refunded_status', 'Order status once refunded')
- ->helperText('Applied when a payment taken through this method is refunded — even if the refund itself is processed through a different method.'),
];
}
@@ -169,24 +156,6 @@ class PaymentMethodResource extends Resource
->required();
}
- /**
- * Lunar's own Order::status is a plain, admin-extensible string
- * (config('lunar.orders.statuses')) rather than a fixed enum —
- * deliberately so a store can add its own custom status without a
- * code change (see docs/payments.md). This Select still reads from
- * that same open-ended list, just so an admin picks a real status
- * instead of typing a slug from memory.
- */
- private static function getOrderStatusSelect(string $name, string $label): Select
- {
- return Select::make($name)
- ->label($label)
- ->options(collect(config('lunar.orders.statuses', []))
- ->map(fn (array $status) => $status['label'] ?? $status)
- ->all())
- ->native(false);
- }
-
public static function getPages(): array
{
return [
@@ -211,7 +180,7 @@ class PaymentMethodResource extends Resource
->icon('heroicon-o-pencil-square')
->schema(static::getFormComponents())
->fillForm(fn (PaymentMethod $record) => $record->only([
- 'name', 'type', 'driver', 'capture_mode', 'captured_status', 'authorized_status', 'refunded_status',
+ 'name', 'type', 'driver', 'capture_mode',
]))
->action(fn (PaymentMethod $record, array $data) => app(PaymentMethodService::class)->update($record, $data));
}
diff --git a/src/Payment/Models/PaymentMethod.php b/src/Payment/Models/PaymentMethod.php
index 25d50dc..39a6016 100644
--- a/src/Payment/Models/PaymentMethod.php
+++ b/src/Payment/Models/PaymentMethod.php
@@ -18,16 +18,6 @@ use Illuminate\Database\Eloquent\Model;
* - capture_mode: 'pay' or 'authorize' — which SupportsPay/
* SupportsAuthorization method CheckoutService::initiatePayment()
* calls for this row.
- * - captured_status / authorized_status / refunded_status: the
- * Order::status value Modules\Core\Order\Listeners\
- * ApplyResolvedPaymentStatus applies on a PaymentCaptured/
- * PaymentAuthorized/PaymentRefunded event. For a refund, this is
- * always the ORIGINAL payment method's row (the one the customer
- * actually paid with), never the driver the refund itself was routed
- * through (Payment\Support\TransactionDriverAdapter::refundVia() may
- * use a different one entirely — e.g. a cash-on-delivery order
- * refunded via a Bank Transfer driver with no PaymentMethod row of
- * its own) — see that listener's own docblock.
* - position: admin-controlled display/checkout order.
* - driver_missing_at: set by `payment:sync-drivers` when `driver` no
* longer resolves via the registry — separate from `enabled`, so a
diff --git a/src/Payment/Services/PaymentMethodService.php b/src/Payment/Services/PaymentMethodService.php
index c6c5853..737d277 100644
--- a/src/Payment/Services/PaymentMethodService.php
+++ b/src/Payment/Services/PaymentMethodService.php
@@ -70,8 +70,7 @@ class PaymentMethodService
public function delete(PaymentMethod $method): void
{
$snapshot = $method->only([
- 'id', 'type', 'name', 'driver', 'capture_mode',
- 'captured_status', 'authorized_status', 'position', 'enabled',
+ 'id', 'type', 'name', 'driver', 'capture_mode', 'position', 'enabled',
]);
$method->delete();
diff --git a/src/Providers/OrderServiceProvider.php b/src/Providers/OrderServiceProvider.php
index 86349d7..b5b57e5 100644
--- a/src/Providers/OrderServiceProvider.php
+++ b/src/Providers/OrderServiceProvider.php
@@ -2,20 +2,33 @@
namespace Modules\Core\Providers;
+use Illuminate\Console\Scheduling\Schedule as ConsoleSchedule;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Lunar\Models\Order;
use Lunar\Models\Transaction;
use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Notification\NotificationRegistry;
+use Modules\Core\Order\Commands\CloseExpiredReturnWindows;
use Modules\Core\Order\Events\OrderDelivered;
+use Modules\Core\Order\Events\OrderPaidChanged;
+use Modules\Core\Order\Events\OrderPickedUp;
+use Modules\Core\Order\Events\OrderStatusChanged;
+use Modules\Core\Order\Listeners\AdvanceFulfillmentOnCarrierCheckpoint;
+use Modules\Core\Order\Listeners\AdvanceFulfillmentOnDelivered;
use Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus;
-use Modules\Core\Order\Listeners\CompleteOrderOnDelivered;
+use Modules\Core\Order\Listeners\CompleteOrderOnPickedUp;
use Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced;
use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment;
+use Modules\Core\Order\Listeners\MarkDeliveryFailedOnCarrierCheckpoint;
use Modules\Core\Order\Listeners\RecordPaymentTransaction;
+use Modules\Core\Order\Listeners\RecordStatusTransition;
+use Modules\Core\Order\Models\OrderStatusTransition;
use Modules\Core\Order\Notifications\OrderCapturedNotification;
+use Modules\Core\Order\Notifications\OrderCompletedNotification;
use Modules\Core\Order\Notifications\OrderDeliveredNotification;
+use Modules\Core\Order\Notifications\OrderDispatchedNotification;
+use Modules\Core\Order\Notifications\OrderPickupReadyNotification;
use Modules\Core\Order\Notifications\OrderPlacedNotification;
use Modules\Core\Order\Notifications\OrderRefundedNotification;
use Modules\Core\Order\Notifications\OrderStatusUpdatedNotification;
@@ -38,23 +51,43 @@ class OrderServiceProvider extends ServiceProvider
Order::macro('paymentStatus', fn () => OrderStatus::payment($this));
Order::macro('fulfillmentStatus', fn () => OrderStatus::fulfillment($this));
+ Order::resolveRelationUsing('statusTransitions', function ($order) {
+ return $order->hasMany(OrderStatusTransition::class);
+ });
+
Event::listen(ShipmentStatusUpdatedByCarrier::class, DeriveOrderDeliveredFromShipment::class);
- Event::listen(OrderDelivered::class, CompleteOrderOnDelivered::class);
+ Event::listen(ShipmentStatusUpdatedByCarrier::class, AdvanceFulfillmentOnCarrierCheckpoint::class);
+ Event::listen(ShipmentStatusUpdatedByCarrier::class, MarkDeliveryFailedOnCarrierCheckpoint::class);
+ Event::listen(OrderDelivered::class, AdvanceFulfillmentOnDelivered::class);
+ Event::listen(OrderPickedUp::class, CompleteOrderOnPickedUp::class);
+
Event::listen(PaymentCaptured::class, ApplyResolvedPaymentStatus::class);
Event::listen(PaymentAuthorized::class, ApplyResolvedPaymentStatus::class);
- Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class);
Event::listen(PaymentCaptured::class, RecordPaymentTransaction::class);
Event::listen(PaymentAuthorized::class, RecordPaymentTransaction::class);
Event::listen(PaymentVoided::class, RecordPaymentTransaction::class);
+ // RecordPaymentTransaction must run before ApplyResolvedPaymentStatus
+ // for PaymentRefunded specifically — the latter now reads
+ // $order->transactions (via OrderStatus::payment()) to resolve
+ // Refunded vs PartiallyRefunded, which requires the refund
+ // Transaction row to already exist.
Event::listen(PaymentRefunded::class, RecordPaymentTransaction::class);
+ Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class);
+
Event::listen(OrderPlaced::class, DecrementStockOnOrderPlaced::class);
+ Event::listen(OrderStatusChanged::class, [RecordStatusTransition::class, 'handleStatusChanged']);
+ Event::listen(OrderPaidChanged::class, [RecordStatusTransition::class, 'handlePaidChanged']);
+
NotificationRegistry::get()->register([
OrderDeliveredNotification::class,
OrderStatusUpdatedNotification::class,
OrderRefundedNotification::class,
OrderCapturedNotification::class,
OrderPlacedNotification::class,
+ OrderPickupReadyNotification::class,
+ OrderDispatchedNotification::class,
+ OrderCompletedNotification::class,
]);
// Lets the consuming app override copy/markup without forking core
@@ -64,5 +97,17 @@ class OrderServiceProvider extends ServiceProvider
$this->publishes([
__DIR__ . '/../../resources/views/order/notifications' => resource_path('views/vendor/core/order/notifications'),
], 'core-views');
+
+ if ($this->app->runningInConsole()) {
+ $this->commands([CloseExpiredReturnWindows::class]);
+ }
+
+ // Exact midnight per an explicit compliance requirement — not a
+ // loose dailyAt() offset or plain daily().
+ $this->app->booted(function () {
+ $this->app->make(ConsoleSchedule::class)
+ ->command(CloseExpiredReturnWindows::class)
+ ->dailyAt('00:00');
+ });
}
}
diff --git a/src/Providers/PaymentServiceProvider.php b/src/Providers/PaymentServiceProvider.php
index bf36462..dc31e43 100644
--- a/src/Providers/PaymentServiceProvider.php
+++ b/src/Providers/PaymentServiceProvider.php
@@ -9,6 +9,7 @@ use Lunar\Models\Contracts\Transaction as TransactionContract;
use Lunar\Pipelines\Cart\ApplyShipping;
use Modules\Core\Command\SyncPaymentDriversCommand;
use Modules\Core\Payment\Drivers\BankTransferPaymentDriver;
+use Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver;
use Modules\Core\Payment\Drivers\OfflinePaymentDriver;
use Modules\Core\Payment\Drivers\StripePaymentDriver;
use Modules\Core\Payment\Events\PaymentMethodCreated;
@@ -33,6 +34,7 @@ class PaymentServiceProvider extends ServiceProvider
$registry->register('offline', OfflinePaymentDriver::class, 'Offline / Manual');
$registry->register('stripe', StripePaymentDriver::class, 'Stripe');
$registry->register('bank-transfer', BankTransferPaymentDriver::class, 'Bank Transfer');
+ $registry->register('cash-on-delivery', CashOnDeliveryPaymentDriver::class, 'Cash on Delivery');
$cartPipeline = config('lunar.cart.pipelines.cart', []);
$insertAfter = array_search(ApplyShipping::class, $cartPipeline, true);
diff --git a/src/Providers/ShippingServiceProvider.php b/src/Providers/ShippingServiceProvider.php
index 9e8d2ef..7e23709 100644
--- a/src/Providers/ShippingServiceProvider.php
+++ b/src/Providers/ShippingServiceProvider.php
@@ -65,24 +65,34 @@ class ShippingServiceProvider extends ServiceProvider
__DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'),
], 'core-config');
+ // Signed-URL auth only, same model as Lunar's own vendor
+ // lunar.pdf.download route — see Modules\Core\Shipping\Http\
+ // Controllers\DownloadShipmentLabelController's own docblock.
+ $this->loadRoutesFrom(__DIR__ . '/../Shipping/routes/web.php');
+
Order::resolveRelationUsing('shipments', function ($order) {
return $order->hasMany(Shipment::class);
});
- // ShippingMethod.data['fulfillment_type'] — see
- // ShippingMethodResourceExtension::fulfillmentTypeSelect() for
- // where it's set. Defaults to 'carrier' (false here) for any row
- // saved before this field existed.
- ShippingMethod::macro('isStorePickup', function () {
- /** @var ShippingMethod $this */
- return ($this->data['fulfillment_type'] ?? 'carrier') === 'store_pickup';
- });
-
// Order has no direct ShippingMethod relation — shippingAddress.
// shipping_option is only ever a code string (see
// Modules\Core\Shipping\Extensions\OrderViewExtension::
// resolveCarrier() for the same lookup pattern already used to
// resolve a carrier driver from it).
+ //
+ // Reads ShippingMethod.data['fulfillment_type'] directly rather
+ // than through a ShippingMethod::macro('isStorePickup', ...) —
+ // Lunar\Base\Traits\HasModelExtending::__callStatic() (used by
+ // Lunar\Shipping\Models\ShippingMethod via Lunar\Base\BaseModel)
+ // intercepts EVERY unmatched static call, including macro()
+ // itself, and dispatches it as (new static)->macro(...) instead
+ // of forwarding to Macroable — so a macro registered this way
+ // silently never gets stored, and hasMacro() always returns
+ // false. (Lunar\Models\Order is unaffected because it declares
+ // its own macro() method directly, bypassing __callStatic
+ // entirely — that's why Order::macro('isStorePickupOrder', ...)
+ // below still works.) Defaults to 'carrier' (false) for any row
+ // saved before this field existed.
Order::macro('isStorePickupOrder', function () {
/** @var Order $this */
$code = $this->shippingAddress?->shipping_option;
@@ -91,7 +101,14 @@ class ShippingServiceProvider extends ServiceProvider
return false;
}
- return ShippingMethod::where('code', $code)->first()?->isStorePickup() ?? false;
+ // ->value('data') is deliberately avoided here — on Postgres,
+ // ->value()/->pluck() on a JSON column silently return the
+ // wrong result (works fine in ->where(), not in a column
+ // projection); loading the model and reading its cast
+ // attribute avoids that entirely.
+ $method = ShippingMethod::where('code', $code)->first();
+
+ return ($method?->data['fulfillment_type'] ?? 'carrier') === 'store_pickup';
});
foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) {
diff --git a/src/Shipping/Carriers/Acs/AcsFulfillmentService.php b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php
index aac4d37..f2f67d3 100644
--- a/src/Shipping/Carriers/Acs/AcsFulfillmentService.php
+++ b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php
@@ -14,6 +14,7 @@ use Modules\Core\Shipping\DTOs\ManifestResult;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus;
+use Modules\Core\Shipping\Models\Manifest;
use Modules\Core\Shipping\Models\Shipment;
class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching, SupportsTracking
@@ -88,7 +89,7 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
public function cancelShipment(Shipment $shipment): void
{
- if ($shipment->manifest_reference) {
+ if ($shipment->manifest_id) {
throw new RuntimeException('Cannot cancel a shipment already included in an issued manifest.');
}
@@ -103,7 +104,7 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
{
return Shipment::query()
->where('carrier', 'acs')
- ->whereNull('manifest_reference')
+ ->whereNull('manifest_id')
->whereNull('cancelled_at')
->get();
}
@@ -123,11 +124,18 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
$pickupListNo = (string) $response->valueOutput['PickupList_No'];
+ $manifest = Manifest::create([
+ 'carrier' => 'acs',
+ 'reference' => $pickupListNo,
+ 'shipment_count' => $shipments->count(),
+ 'issued_at' => now(),
+ ]);
+
$shipments->each(fn (Shipment $shipment) => $shipment->update([
- 'manifest_reference' => $pickupListNo,
+ 'manifest_id' => $manifest->id,
]));
- return ManifestResult::success($pickupListNo, $shipments);
+ return ManifestResult::success($manifest, $shipments);
}
public function trackShipment(Shipment $shipment): Collection
@@ -176,6 +184,11 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
{
$action = strtolower($action);
+ // TODO: no live ACS payload sample yet showing a distinct
+ // collection checkpoint separate from transit ("arrival"/
+ // "departure" already map to InTransit) — add a str_contains()
+ // arm mapping to TrackingStatus::CollectedFromSender here once
+ // one is confirmed.
return match (true) {
str_contains($action, 'delivery to consignee') => TrackingStatus::Delivered,
str_contains($action, 'on delivery') => TrackingStatus::OutForDelivery,
diff --git a/src/Shipping/Carriers/Acs/AcsRateDriver.php b/src/Shipping/Carriers/Acs/AcsRateDriver.php
index 0daea90..74ce760 100644
--- a/src/Shipping/Carriers/Acs/AcsRateDriver.php
+++ b/src/Shipping/Carriers/Acs/AcsRateDriver.php
@@ -11,6 +11,7 @@ use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\Concerns\CachesLivePricing;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
+use Modules\Core\Shipping\Support\WeightCalculator;
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
{
@@ -103,26 +104,6 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
private function totalWeightInKg($cart): float
{
- $weight = 0.0;
-
- foreach ($cart->lines->load('purchasable') as $line) {
- $variant = $line->purchasable;
-
- if (! $variant || ! $variant->weight_value) {
- continue;
- }
-
- $unit = $variant->weight_unit ?? 'kg';
- $value = (float) $variant->weight_value;
-
- $weight += match ($unit) {
- 'g' => $value / 1000,
- 'lb' => $value * 0.45359237,
- 'oz' => $value * 0.0283495231,
- default => $value, // kg
- } * $line->quantity;
- }
-
- return max($weight, 0.5); // ACS minimum billable weight
+ return max(WeightCalculator::totalKg($cart->lines), 0.5); // ACS minimum billable weight
}
}
diff --git a/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php
index 4b8b643..971df90 100644
--- a/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php
+++ b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php
@@ -21,10 +21,21 @@ use Modules\Core\Shipping\Models\Shipment;
* Box Now delivers to lockers, not addresses. The storefront locker-picker
* is out of scope for this pass — createShipment() requires the chosen
* locker's Box Now locationId via ShipmentRequest::$destinationLocationId
- * (e.g. set manually by admin staff until checkout UI exists).
+ * (e.g. set manually by admin staff until checkout UI exists — see
+ * Modules\Core\Shipping\Extensions\OrderViewExtension, which locks the
+ * field instead once the shopper's own checkout selection is present in
+ * $order->shippingAddress->meta['box_now_locker']).
+ *
+ * Box Now ships by compartment size, not weight — unlike ACS, which bills
+ * by kg. One 'items' entry per box in ShipmentRequest::$boxes, so an order
+ * needing more than one physical parcel (doesn't fit one compartment)
+ * sends that many entries in a single delivery request rather than
+ * several separate ones.
*/
class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking
{
+ private const COMPARTMENT_SIZES = ['S' => 1, 'M' => 2, 'L' => 3];
+
public function __construct(private readonly BoxNowClient $client) {}
public function createShipment(Order $order, ShipmentRequest $request): Shipment
@@ -36,6 +47,10 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.');
}
+ if (empty($request->boxes)) {
+ throw new BoxNowApiException('At least one box (compartment size) is required for a Box Now shipment.');
+ }
+
$isCod = $request->paymentMode === 'cod';
$response = $this->client->request('post', '/delivery-requests', [
@@ -57,31 +72,37 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
'contactName' => trim("{$address->first_name} {$address->last_name}"),
'locationId' => $destinationLocationId,
],
- 'items' => [
- [
- 'id' => (string) $order->id,
- 'name' => 'Order '.$order->reference,
- 'value' => '0.00',
- 'compartmentSize' => 1,
- 'weight' => $request->weight ?? 0,
- ],
- ],
+ 'items' => collect($request->boxes)->values()->map(fn (string $size, int $index) => [
+ 'id' => $order->id.'-'.($index + 1),
+ 'name' => 'Order '.$order->reference.' (box '.($index + 1).')',
+ 'value' => '0.00',
+ 'compartmentSize' => self::COMPARTMENT_SIZES[$size] ?? self::COMPARTMENT_SIZES['S'],
+ ])->all(),
]);
- $parcelId = (string) ($response['parcels'][0]['id'] ?? throw new BoxNowApiException(
- 'Box Now delivery request succeeded but returned no parcel id.',
- $response,
- ));
+ $parcels = collect($response['parcels'] ?? []);
- return Shipment::create([
+ if ($parcels->isEmpty()) {
+ throw new BoxNowApiException('Box Now delivery request succeeded but returned no parcel ids.', $response);
+ }
+
+ // One Shipment row per box/parcel — each is independently
+ // trackable/printable/cancellable via its own tracking_reference
+ // (printLabel()/cancelShipment()/trackShipment() below already
+ // operate per-Shipment), even though all boxes were submitted in
+ // one delivery request. Siblings are linked via the shared
+ // delivery_request_id in meta.
+ $shipments = $parcels->map(fn (array $parcel) => Shipment::create([
'order_id' => $order->id,
'carrier' => 'box-now',
- 'tracking_reference' => $parcelId,
+ 'tracking_reference' => (string) $parcel['id'],
'meta' => [
'delivery_request_id' => $response['id'] ?? null,
'locker_id' => $destinationLocationId,
],
- ]);
+ ]));
+
+ return $shipments->first();
}
public function printLabel(Shipment $shipment): string
@@ -136,6 +157,13 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
private function mapState(string $state): TrackingStatus
{
+ // TODO: confirm against a live BoxNow webhook payload whether a
+ // distinct collected-from-sender state exists (e.g. between 'new'
+ // and 'in-transit') before mapping it to
+ // TrackingStatus::CollectedFromSender — BoxNow's own model is
+ // locker-drop-off-based, so it may not have one. No guessed match
+ // arm added; 'new' still falls through to Pending, InTransit
+ // remains the earliest recognized checkpoint.
return match ($state) {
'new' => TrackingStatus::Pending,
'in-transit', 'in-depot' => TrackingStatus::InTransit,
diff --git a/src/Shipping/DTOs/ManifestResult.php b/src/Shipping/DTOs/ManifestResult.php
index 682b79e..9342298 100644
--- a/src/Shipping/DTOs/ManifestResult.php
+++ b/src/Shipping/DTOs/ManifestResult.php
@@ -3,24 +3,26 @@
namespace Modules\Core\Shipping\DTOs;
use Illuminate\Support\Collection;
+use Modules\Core\Shipping\Models\Manifest;
class ManifestResult
{
private function __construct(
public readonly bool $success,
public readonly ?string $reference,
+ public readonly ?Manifest $manifest,
public readonly Collection $includedShipments,
public readonly Collection $blockedShipments,
public readonly ?string $reason,
) {}
- public static function success(string $reference, Collection $includedShipments): self
+ public static function success(Manifest $manifest, Collection $includedShipments): self
{
- return new self(true, $reference, $includedShipments, collect(), null);
+ return new self(true, $manifest->reference, $manifest, $includedShipments, collect(), null);
}
public static function blocked(Collection $blockedShipments, string $reason): self
{
- return new self(false, null, collect(), $blockedShipments, $reason);
+ return new self(false, null, null, collect(), $blockedShipments, $reason);
}
}
diff --git a/src/Shipping/DTOs/ShipmentRequest.php b/src/Shipping/DTOs/ShipmentRequest.php
index 35cb380..4665eff 100644
--- a/src/Shipping/DTOs/ShipmentRequest.php
+++ b/src/Shipping/DTOs/ShipmentRequest.php
@@ -5,16 +5,27 @@ namespace Modules\Core\Shipping\DTOs;
/**
* Carrier-agnostic input for CarrierFulfillmentInterface::createShipment().
* Every field is optional — a carrier reads only what it needs and ignores
- * the rest (e.g. destinationLocationId only matters to locker-delivery
- * carriers like Box Now; ACS has no use for it).
+ * the rest (e.g. destinationLocationId/boxes only matter to locker-delivery
+ * carriers like Box Now; ACS has no use for either — it ships by weight,
+ * not by box/compartment size).
*/
class ShipmentRequest
{
+ /**
+ * @param array $boxes Box Now only — one entry per
+ * physical parcel, each a compartment size ('S'|'M'|'L'). A single
+ * shipment can be split across several lockers of the same
+ * destinationLocationId's collection point, e.g. two Large boxes for
+ * an order that doesn't fit one compartment. Empty for every other
+ * carrier, which ships as a single package described by $weight
+ * instead.
+ */
public function __construct(
public readonly ?float $weight = null,
public readonly int $packageCount = 1,
public readonly ?string $destinationLocationId = null,
public readonly ?string $paymentMode = null,
public readonly ?float $amountToCollect = null,
+ public readonly array $boxes = [],
) {}
}
diff --git a/src/Shipping/Enums/TrackingStatus.php b/src/Shipping/Enums/TrackingStatus.php
index 8c66b38..5f0b9fb 100644
--- a/src/Shipping/Enums/TrackingStatus.php
+++ b/src/Shipping/Enums/TrackingStatus.php
@@ -11,6 +11,25 @@ namespace Modules\Core\Shipping\Enums;
enum TrackingStatus: string
{
case Pending = 'pending';
+
+ /**
+ * The carrier collected the parcel from the merchant — deliberately
+ * NOT named "PickedUp" to avoid colliding with the unrelated
+ * order status 'picked_up' (Modules\Core\Order\Services\
+ * OrderStatusFlow), which means the opposite end of a different flow
+ * (a CUSTOMER collecting a store-pickup order). Consumed by
+ * Modules\Core\Order\Listeners\AdvanceFulfillmentOnCarrierCheckpoint.
+ *
+ * Not yet mapped from either carrier driver's own checkpoint data —
+ * see Carriers\BoxNow\BoxNowFulfillmentService::mapState() and
+ * Carriers\Acs\AcsFulfillmentService::guessStatusFromAction() for
+ * TODOs on confirming against real payload samples before adding a
+ * mapping. Until then this case exists but nothing produces it, and
+ * InTransit remains the effective first real checkpoint for both
+ * carriers.
+ */
+ case CollectedFromSender = 'collected_from_sender';
+
case InTransit = 'in_transit';
case OutForDelivery = 'out_for_delivery';
case Delivered = 'delivered';
diff --git a/src/Shipping/Extensions/OrderShipmentsExtension.php b/src/Shipping/Extensions/OrderShipmentsExtension.php
new file mode 100644
index 0000000..0298812
--- /dev/null
+++ b/src/Shipping/Extensions/OrderShipmentsExtension.php
@@ -0,0 +1,186 @@
+shipmentsSection()]);
+
+ return $schema;
+ }
+
+ private function shipmentsSection(): Section
+ {
+ return Section::make('shipments')
+ ->heading('Shipments')
+ ->compact()
+ ->collapsed(fn ($record) => $record->shipments->isEmpty())
+ ->collapsible(fn ($record) => $record->shipments->isNotEmpty())
+ ->schema([
+ RepeatableEntry::make('shipments')
+ ->hiddenLabel()
+ ->placeholder('No shipments have been created for this order yet.')
+ ->contained(true)
+ ->schema([
+ TextEntry::make('tracking_reference')
+ ->label(fn (Shipment $record) => $this->carrierLabel($record))
+ ->inlineLabel()
+ ->copyable(),
+ TextEntry::make('status')
+ ->label('Status')
+ ->inlineLabel()
+ ->state(fn (Shipment $record) => $this->statusLabel($record))
+ ->badge()
+ ->color(fn (Shipment $record) => $this->statusColor($record))
+ ->helperText(fn (Shipment $record) => $this->helperText($record))
+ ->suffixActions([
+ Action::make('print_label')
+ ->label('Print Label')
+ ->icon('heroicon-o-printer')
+ ->url(fn (Shipment $record) => URL::temporarySignedRoute(
+ 'shipments.label',
+ now()->addMinutes(5),
+ ['shipment' => $record->id],
+ ), shouldOpenInNewTab: true)
+ ->visible(fn (Shipment $record) => ! $record->cancelled_at),
+ Action::make('cancel_shipment')
+ ->label('Cancel')
+ ->icon('heroicon-o-x-circle')
+ ->color('danger')
+ ->requiresConfirmation()
+ ->modalDescription('Cancels this shipment with the carrier. This cannot be undone.')
+ ->action(fn (Shipment $record) => $this->cancel($record))
+ ->visible(fn (Shipment $record) => ! $record->cancelled_at),
+ ]),
+ ]),
+ ]);
+ }
+
+ private function carrierLabel(Shipment $record): string
+ {
+ return match ($record->carrier) {
+ 'acs' => 'ACS',
+ 'box-now' => 'Box Now',
+ default => (string) str($record->carrier)->title(),
+ };
+ }
+
+ private function helperText(Shipment $record): string
+ {
+ $parts = ['Created '.$record->created_at->format('Y-m-d H:i')];
+
+ if ($record->carrier === 'box-now' && $locker = $record->meta['locker_id'] ?? null) {
+ $parts[] = 'Locker '.$locker;
+ }
+
+ return implode(' · ', $parts);
+ }
+
+ private function statusLabel(Shipment $record): string
+ {
+ if ($record->cancelled_at) {
+ return 'Cancelled';
+ }
+
+ $latest = $record->latestShipmentInfo();
+
+ return $latest ? (string) str($latest->status->value)->replace('_', ' ')->title() : 'Pending';
+ }
+
+ private function statusColor(Shipment $record): string
+ {
+ if ($record->cancelled_at) {
+ return 'danger';
+ }
+
+ return match ($record->latestShipmentInfo()?->status?->value) {
+ 'delivered' => 'success',
+ 'failed', 'returned' => 'danger',
+ 'in_transit', 'out_for_delivery', 'collected_from_sender' => 'warning',
+ default => 'gray',
+ };
+ }
+
+ private function cancel(Shipment $record): void
+ {
+ $service = app(CarrierFulfillmentInterface::class, ['carrier' => $record->carrier]);
+
+ try {
+ $service->cancelShipment($record);
+ } catch (Throwable $e) {
+ report($e);
+
+ Notification::make()
+ ->title('Failed to cancel shipment: '.$e->getMessage())
+ ->color('danger')
+ ->send();
+
+ return;
+ }
+
+ Notification::make()
+ ->title('Shipment cancelled.')
+ ->color('success')
+ ->send();
+ }
+}
diff --git a/src/Shipping/Extensions/OrderViewExtension.php b/src/Shipping/Extensions/OrderViewExtension.php
index eec8e28..f792860 100644
--- a/src/Shipping/Extensions/OrderViewExtension.php
+++ b/src/Shipping/Extensions/OrderViewExtension.php
@@ -3,25 +3,90 @@
namespace Modules\Core\Shipping\Extensions;
use Filament\Actions\Action;
+use Filament\Forms\Components\Repeater;
+use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
-use Filament\Forms\Components\Toggle;
-use Closure;
-use Throwable;
-use Filament\Actions;
-use Filament\Forms;
use Filament\Notifications\Notification;
use Lunar\Admin\Support\Extending\ViewPageExtension;
use Lunar\Models\Order;
-use Lunar\Shipping\Models\ShippingMethod;
-use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
+use Modules\Core\Order\DTOs\OrderFulfillmentResult;
+use Modules\Core\Order\Services\OrderFulfillmentService;
+use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
+use Modules\Core\Shipping\Support\WeightCalculator;
+/**
+ * Filament wiring only (labels, icons, visibility, form schema) for the
+ * staff-facing status workflow — every guard check, status write, and
+ * event dispatch lives in Modules\Core\Order\Services\
+ * OrderFulfillmentService/OrderStatusFlow, resolved via app() (a
+ * ViewPageExtension is instantiated by Lunar's own extension mechanism,
+ * not the container, so there's no constructor-injection seam here).
+ *
+ * Strips Lunar's own "Update Status" header action (registered by vendor
+ * ManageOrder as Action::make('update_status')) and replaces it with our
+ * own action of the same name — vendor's writes $record->status directly
+ * with no audit trail, no branch validation, and no side effects. Our
+ * replacement offers every status in the order's own branch (carrier or
+ * pickup — see OrderStatusFlow::allOptions()), not just the guided next
+ * step, so staff can freely revert to an earlier status too. It is a
+ * PLAIN status write — picking 'dispatched' here does not create a real
+ * shipment (see "Create Shipment" below for that).
+ *
+ * "Create Shipment" is its own separate header action, visible only for a
+ * carrier order sitting at 'ready_for_dispatch' — this is the one action
+ * that talks to a real carrier API and writes Order::status to
+ * 'dispatched' as a side effect of that succeeding, so it needs its own
+ * weight/locker inputs specific to that one real-world action, not
+ * bundled into the general-purpose status select where they'd appear for
+ * every revert/manual-override use of 'dispatched' too. The form branches
+ * on which carrier the order actually uses
+ * (OrderFulfillmentService::carrierFor()): a weight-billed carrier (ACS)
+ * gets a single TOTAL weight field for the whole shipment (ACS has no
+ * per-package weight concept — one Weight value is sent alongside
+ * Item_Quantity in the same ACS_Create_Voucher call, see
+ * AcsFulfillmentService::createShipment()), pre-filled from the order's
+ * own line weights (Modules\Core\Shipping\Support\WeightCalculator) but
+ * still staff-editable, plus a package count (ShipmentRequest::
+ * $packageCount) — more than 1 issues a main voucher plus a
+ * multi-part sub-voucher per extra package (persistMultipartVouchers()),
+ * each recorded as its own Shipment row sharing the same total weight in
+ * meta. Box Now, which bills by compartment size rather than
+ * weight, gets a repeatable list of boxes (one row per physical parcel,
+ * each with its own S/M/L size) instead — see
+ * Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService for how
+ * multiple boxes become multiple Shipment rows from one delivery request.
+ * Box Now's locker is locked to read-only once the shopper's own checkout
+ * selection ($order->shippingAddress->meta['box_now_locker']) is present
+ * — staff can only fill it in manually for the (current, checkout-UI-less)
+ * case where nothing set it yet.
+ *
+ * "Mark Paid" is a third, separate header action — Order::paid is
+ * independent of `status` (see OrderStatusFlow's own docblock), so it
+ * doesn't belong bundled into the status select either. Visible only when
+ * the order's payment method doesn't auto-capture at checkout (currently
+ * only cash-on-delivery — see OrderStatusFlow::canMarkPaid()); a
+ * processor-managed method (Stripe) or an immediate-capture offline
+ * method (cash-in-hand, bank-transfer) sets Order::paid automatically via
+ * Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus, so this button
+ * never appears for those.
+ *
+ * Also strips Lunar's own "Download PDF" header action (getDefaultHeaderActions()
+ * in vendor ManageOrder) — its lunarpanel::pdf.order template does not meet
+ * Greek AADE e-invoicing requirements, so it must not be offered as a
+ * downloadable document until a compliant invoice generator exists.
+ */
class OrderViewExtension extends ViewPageExtension
{
public function headerActions(array $actions): array
{
+ $actions = array_filter($actions, fn ($action) => method_exists($action, 'getName')
+ ? ! in_array($action->getName(), ['download_pdf', 'update_status'], true)
+ : true);
+
$actions[] = $this->createShipmentAction();
- $actions[] = $this->markPickedUpAction();
+ $actions[] = $this->updateStatusAction();
+ $actions[] = $this->markPaidAction();
return $actions;
}
@@ -32,122 +97,133 @@ class OrderViewExtension extends ViewPageExtension
->label('Create Shipment')
->icon('heroicon-o-truck')
->modalSubmitActionLabel('Create Shipment')
- ->schema([
- TextInput::make('weight')
- ->label('Package weight (kg)')
- ->numeric()
- ->minValue(0)
- ->helperText('Leave blank to use the carrier\'s default.'),
- TextInput::make('destination_location_id')
- ->label('Box Now locker ID')
- ->helperText('Only required for Box Now shipments.')
- ->default(fn (Order $record) => $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null),
- Toggle::make('confirm')
- ->label('Confirm')
- ->helperText('This will create a real shipment with the carrier.')
- ->rules([
- function () {
- return function (string $attribute, $value, Closure $fail) {
- if ($value !== true) {
- $fail('Please confirm before creating the shipment.');
- }
- };
- },
- ]),
- ])
- ->action(function (Order $record, array $data, Action $action) {
- $service = $this->resolveFulfillmentService($record);
+ ->schema(function (Order $record) {
+ $isBoxNow = $this->service()->carrierFor($record) === 'box-now';
+ $lockerId = $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null;
- if (! $service) {
- Notification::make()
- ->title('No carrier fulfillment integration is configured for this order.')
- ->danger()
- ->send();
-
- $action->halt();
-
- return;
+ if (! $isBoxNow) {
+ return [
+ TextInput::make('weight')
+ ->label('Total weight (kg)')
+ ->numeric()
+ ->minValue(0)
+ ->default(fn () => round(WeightCalculator::totalKg($record->lines), 2) ?: null)
+ ->helperText("Calculated from the order's line weights — adjust if needed, or leave blank to use the carrier's default. One figure for the whole shipment, not per package."),
+ TextInput::make('package_count')
+ ->label('Number of packages')
+ ->numeric()
+ ->integer()
+ ->minValue(1)
+ ->default(1)
+ ->required()
+ ->helperText('More than 1 issues a main voucher plus a sub-voucher per extra package, all sharing the total weight above.'),
+ ];
}
- $request = new ShipmentRequest(
- weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null,
- destinationLocationId: $data['destination_location_id'] ?? null,
+ return [
+ TextInput::make('destination_location_id')
+ ->label('Box Now locker ID')
+ ->default($lockerId)
+ // Locked once the shopper's own checkout selection is
+ // known — staff should not be able to redirect a
+ // parcel to a different locker than the one the
+ // customer picked. Only editable for the (current,
+ // checkout-UI-less) case where nothing set it yet.
+ ->disabled(filled($lockerId))
+ ->dehydrated()
+ ->required()
+ ->helperText($lockerId
+ ? 'Set by the customer at checkout.'
+ : 'No locker was selected at checkout — enter it manually.'),
+ Repeater::make('boxes')
+ ->label('Boxes')
+ ->schema([
+ Select::make('size')
+ ->label('Size')
+ ->options(['S' => 'Small', 'M' => 'Medium', 'L' => 'Large'])
+ ->default('S')
+ ->native(false)
+ ->required(),
+ ])
+ ->defaultItems(1)
+ ->addActionLabel('Add another box')
+ ->minItems(1)
+ ->helperText('One row per physical parcel — Box Now ships by compartment size, not weight.'),
+ ];
+ })
+ ->action(function (Order $record, array $data, Action $action) {
+ $result = $this->service()->createShipmentAndDispatch(
+ $record,
+ new ShipmentRequest(
+ weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null,
+ packageCount: (int) ($data['package_count'] ?? 1),
+ destinationLocationId: $data['destination_location_id'] ?? null,
+ boxes: collect($data['boxes'] ?? [])->pluck('size')->all(),
+ ),
);
- try {
- $service->createShipment($record, $request);
- } catch (Throwable $e) {
- report($e);
-
- Notification::make()
- ->title('Failed to create shipment: '.$e->getMessage())
- ->danger()
- ->send();
+ $this->notify($result);
+ if (! $result->success) {
$action->halt();
-
- return;
}
-
- Notification::make()
- ->title('Shipment created.')
- ->success()
- ->send();
})
- ->visible(fn (Order $record) => $record->status === 'ready-for-dispatch'
- && ! $record->isStorePickupOrder()
- && $record->shipments()->exists() === false
- && $this->resolveFulfillmentService($record) !== null);
+ ->visible(fn (Order $record) => $this->service()->canCreateShipment($record));
}
- /**
- * The store-pickup mirror of createShipmentAction() — a store-pickup
- * order never gets a Shipment record (no carrier is ever involved), so
- * it needs its own way to close out of 'ready-for-pickup' once the
- * customer has actually collected it. Sets status directly to
- * 'completed', same terminal status DeriveOrderDeliveredFromShipment
- * writes for a carrier order once tracking confirms delivery — see
- * that listener's own docblock.
- */
- private function markPickedUpAction(): Action
+ private function updateStatusAction(): Action
{
- return Action::make('mark_picked_up')
- ->label('Mark Picked Up')
- ->icon('heroicon-o-check-circle')
+ return Action::make('update_status')
+ ->label('Update Status')
+ ->icon('heroicon-o-adjustments-horizontal')
+ ->schema(fn (Order $record) => [
+ Select::make('to_status')
+ ->label('New status')
+ ->options(app(OrderStatusFlow::class)->allOptions($record))
+ ->default($record->status)
+ ->native(false)
+ ->required(),
+ ])
+ ->action(function (Order $record, array $data, Action $action) {
+ $to = $data['to_status'];
+ $service = $this->service();
+
+ $result = match (true) {
+ ($to === 'ready_for_dispatch' || $to === 'ready_for_pickup') && $record->status === 'processing' => $service->markReady($record),
+ $to === 'picked_up' && $record->status === 'ready_for_pickup' => $service->markPickedUp($record),
+ default => $service->transitionTo($record, $to),
+ };
+
+ $this->notify($result);
+
+ if (! $result->success) {
+ $action->halt();
+ }
+ });
+ }
+
+ private function markPaidAction(): Action
+ {
+ return Action::make('mark_paid')
+ ->label('Mark Paid')
+ ->icon('heroicon-o-banknotes')
->color('success')
->requiresConfirmation()
- ->modalDescription('Confirms the customer has collected this order in store.')
- ->action(function (Order $record) {
- $record->update(['status' => 'completed']);
-
- Notification::make()
- ->title('Order marked as picked up.')
- ->success()
- ->send();
- })
- ->visible(fn (Order $record) => $record->status === 'ready-for-pickup'
- && $record->isStorePickupOrder());
+ ->modalDescription('Confirms payment for this order has been received outside the system.')
+ ->visible(fn (Order $record) => app(OrderStatusFlow::class)->canMarkPaid($record))
+ ->action(fn (Order $record) => $this->notify($this->service()->markPaid($record)));
}
- private function resolveCarrier(Order $record): ?string
+ private function service(): OrderFulfillmentService
{
- $code = $record->shippingAddress?->shipping_option;
-
- if (! $code) {
- return null;
- }
-
- return ShippingMethod::where('code', $code)->value('driver');
+ return app(OrderFulfillmentService::class);
}
- private function resolveFulfillmentService(Order $record): ?CarrierFulfillmentInterface
+ private function notify(OrderFulfillmentResult $result): void
{
- $carrier = $this->resolveCarrier($record);
-
- if (! $carrier) {
- return null;
- }
-
- return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
+ Notification::make()
+ ->title($result->message)
+ ->color($result->success ? 'success' : 'danger')
+ ->send();
}
}
diff --git a/src/Shipping/Filament/Pages/ManagePickupManifests.php b/src/Shipping/Filament/Pages/ManagePickupManifests.php
deleted file mode 100644
index 2b6617b..0000000
--- a/src/Shipping/Filament/Pages/ManagePickupManifests.php
+++ /dev/null
@@ -1,123 +0,0 @@
-query($this->pendingQuery())
- ->columns([
- TextColumn::make('carrier')->badge(),
- TextColumn::make('tracking_reference')->label('Tracking #'),
- TextColumn::make('order.reference')->label('Order'),
- TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'),
- ])
- ->recordActions([
- Action::make('print')
- ->label('Print')
- ->icon('heroicon-o-printer')
- ->action(fn (Shipment $record) => $this->printShipment($record)),
- ])
- ->toolbarActions([
- BulkAction::make('print_selected')
- ->label('Print selected')
- ->icon('heroicon-o-printer')
- ->action(fn (Collection $records) => $records->each(fn (Shipment $shipment) => $this->printShipment($shipment))),
- BulkAction::make('issue_manifest')
- ->label('Issue Manifest')
- ->icon('heroicon-o-check-circle')
- ->action(fn (Collection $records) => $this->issueManifest($records)),
- ]);
- }
-
- private function pendingQuery(): Builder
- {
- $carriers = collect(Shipping::getSupportedDrivers())->keys()->filter(
- fn (string $carrier) => $this->fulfillmentService($carrier) instanceof SupportsManifestBatching
- );
-
- return Shipment::query()
- ->whereIn('carrier', $carriers)
- ->whereNull('manifest_reference')
- ->whereNull('cancelled_at');
- }
-
- private function printShipment(Shipment $shipment): void
- {
- $this->fulfillmentService($shipment->carrier)?->printLabel($shipment);
- }
-
- private function issueManifest(Collection $shipments): void
- {
- $shipments->groupBy('carrier')->each(function (Collection $group, string $carrier) {
- $service = $this->fulfillmentService($carrier);
-
- if (! $service instanceof SupportsManifestBatching) {
- return;
- }
-
- $result = $service->issueManifest($group);
-
- if (! $result->success) {
- Notification::make()
- ->title("Manifest blocked for {$carrier}: {$result->reason}")
- ->danger()
- ->send();
-
- return;
- }
-
- Notification::make()
- ->title("Manifest issued for {$carrier}: {$result->reference}")
- ->success()
- ->send();
- });
- }
-
- private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface
- {
- return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
- }
-}
diff --git a/src/Shipping/Filament/Resources/ManifestResource.php b/src/Shipping/Filament/Resources/ManifestResource.php
new file mode 100644
index 0000000..91f18dc
--- /dev/null
+++ b/src/Shipping/Filament/Resources/ManifestResource.php
@@ -0,0 +1,66 @@
+columns([
+ TextColumn::make('carrier')->badge(),
+ TextColumn::make('reference')->label('Reference')->copyable(),
+ TextColumn::make('shipment_count')->label('Shipments'),
+ TextColumn::make('issued_at')->label('Issued')->dateTime(),
+ ])
+ ->recordActions([
+ ViewAction::make(),
+ ])
+ ->defaultSort('issued_at', 'desc');
+ }
+
+ public static function getRelations(): array
+ {
+ return [
+ ShipmentsRelationManager::class,
+ ];
+ }
+
+ public static function getPages(): array
+ {
+ return [
+ 'index' => ListManifests::route('/'),
+ 'view' => ViewManifest::route('/{record}'),
+ ];
+ }
+}
diff --git a/src/Shipping/Filament/Resources/ManifestResource/Pages/ListManifests.php b/src/Shipping/Filament/Resources/ManifestResource/Pages/ListManifests.php
new file mode 100644
index 0000000..36c9eec
--- /dev/null
+++ b/src/Shipping/Filament/Resources/ManifestResource/Pages/ListManifests.php
@@ -0,0 +1,28 @@
+keys()
+ ->filter(fn (string $carrier) => ShipmentResource::fulfillmentService($carrier) instanceof SupportsManifestBatching);
+
+ return $carriers->mapWithKeys(fn (string $carrier) => [
+ $carrier => Tab::make(ucwords(str_replace('-', ' ', $carrier)))
+ ->modifyQueryUsing(fn (Builder $query) => $query->where('carrier', $carrier)),
+ ])->all();
+ }
+}
diff --git a/src/Shipping/Filament/Resources/ManifestResource/Pages/ViewManifest.php b/src/Shipping/Filament/Resources/ManifestResource/Pages/ViewManifest.php
new file mode 100644
index 0000000..7a316f6
--- /dev/null
+++ b/src/Shipping/Filament/Resources/ManifestResource/Pages/ViewManifest.php
@@ -0,0 +1,33 @@
+components([
+ TextEntry::make('carrier')->badge(),
+ TextEntry::make('reference')->label('Reference')->copyable(),
+ TextEntry::make('shipment_count')->label('Shipments'),
+ TextEntry::make('issued_at')->label('Issued')->dateTime(),
+ ]);
+ }
+}
diff --git a/src/Shipping/Filament/Resources/ManifestResource/RelationManagers/ShipmentsRelationManager.php b/src/Shipping/Filament/Resources/ManifestResource/RelationManagers/ShipmentsRelationManager.php
new file mode 100644
index 0000000..44b1eba
--- /dev/null
+++ b/src/Shipping/Filament/Resources/ManifestResource/RelationManagers/ShipmentsRelationManager.php
@@ -0,0 +1,39 @@
+columns([
+ TextColumn::make('tracking_reference')->label('Tracking #'),
+ TextColumn::make('order.reference')->label('Order'),
+ TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'),
+ TextColumn::make('cancelled_at')->label('Cancelled')->dateTime()->placeholder('—'),
+ ])
+ ->recordActions([
+ Action::make('print')
+ ->label('Print')
+ ->icon('heroicon-o-printer')
+ ->action(fn (Shipment $record) => ShipmentResource::printShipment($record)),
+ ]);
+ }
+}
diff --git a/src/Shipping/Filament/Resources/ShipmentResource.php b/src/Shipping/Filament/Resources/ShipmentResource.php
new file mode 100644
index 0000000..82835e9
--- /dev/null
+++ b/src/Shipping/Filament/Resources/ShipmentResource.php
@@ -0,0 +1,153 @@
+whereNull('manifest_id')
+ ->whereNull('cancelled_at');
+ }
+
+ public static function table(Table $table): Table
+ {
+ return $table
+ ->columns([
+ TextColumn::make('carrier')->badge(),
+ TextColumn::make('tracking_reference')->label('Tracking #'),
+ TextColumn::make('order.reference')->label('Order'),
+ TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'),
+ ])
+ ->recordActions([
+ Action::make('print')
+ ->label('Print')
+ ->icon('heroicon-o-printer')
+ ->action(fn (Shipment $record) => self::printShipment($record)),
+ ])
+ ->toolbarActions([
+ BulkAction::make('print_selected')
+ ->label('Print selected')
+ ->icon('heroicon-o-printer')
+ ->action(fn (Collection $records) => $records->each(fn (Shipment $shipment) => self::printShipment($shipment))),
+ BulkAction::make('issue_manifest')
+ ->label('Issue Manifest')
+ ->icon('heroicon-o-check-circle')
+ ->action(fn (Collection $records) => self::issueManifest($records)),
+ ]);
+ }
+
+ public static function printShipment(Shipment $shipment): void
+ {
+ $service = self::fulfillmentService($shipment->carrier);
+
+ if (! $service) {
+ Notification::make()
+ ->title("No fulfillment integration configured for {$shipment->carrier}.")
+ ->danger()
+ ->send();
+
+ return;
+ }
+
+ try {
+ $service->printLabel($shipment);
+ } catch (Throwable $e) {
+ report($e);
+
+ Notification::make()
+ ->title("Failed to print label for {$shipment->tracking_reference}: {$e->getMessage()}")
+ ->danger()
+ ->send();
+ }
+ }
+
+ public static function issueManifest(Collection $shipments): void
+ {
+ $shipments->groupBy('carrier')->each(function (Collection $group, string $carrier) {
+ $service = self::fulfillmentService($carrier);
+
+ if (! $service instanceof SupportsManifestBatching) {
+ return;
+ }
+
+ try {
+ $result = $service->issueManifest($group);
+ } catch (Throwable $e) {
+ report($e);
+
+ Notification::make()
+ ->title("Failed to issue manifest for {$carrier}: {$e->getMessage()}")
+ ->danger()
+ ->send();
+
+ return;
+ }
+
+ if (! $result->success) {
+ Notification::make()
+ ->title("Manifest blocked for {$carrier}: {$result->reason}")
+ ->danger()
+ ->send();
+
+ return;
+ }
+
+ Notification::make()
+ ->title("Manifest issued for {$carrier}: {$result->reference}")
+ ->success()
+ ->send();
+ });
+ }
+
+ public static function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface
+ {
+ return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
+ }
+
+ public static function getPages(): array
+ {
+ return [
+ 'index' => ListShipments::route('/'),
+ ];
+ }
+}
diff --git a/src/Shipping/Filament/Resources/ShipmentResource/Pages/ListShipments.php b/src/Shipping/Filament/Resources/ShipmentResource/Pages/ListShipments.php
new file mode 100644
index 0000000..331ec9c
--- /dev/null
+++ b/src/Shipping/Filament/Resources/ShipmentResource/Pages/ListShipments.php
@@ -0,0 +1,36 @@
+keys()
+ ->filter(fn (string $carrier) => ShipmentResource::fulfillmentService($carrier) instanceof SupportsManifestBatching);
+
+ return $carriers->mapWithKeys(fn (string $carrier) => [
+ $carrier => Tab::make(ucwords(str_replace('-', ' ', $carrier)))
+ ->modifyQueryUsing(fn (Builder $query) => $query->where('carrier', $carrier)),
+ ])->all();
+ }
+}
diff --git a/src/Shipping/Http/Controllers/DownloadShipmentLabelController.php b/src/Shipping/Http/Controllers/DownloadShipmentLabelController.php
new file mode 100644
index 0000000..c2a1d5c
--- /dev/null
+++ b/src/Shipping/Http/Controllers/DownloadShipmentLabelController.php
@@ -0,0 +1,55 @@
+hasValidSignature()) {
+ abort(401);
+ }
+
+ $shipment = Shipment::findOrFail($shipment);
+
+ $service = app(CarrierFulfillmentInterface::class, ['carrier' => $shipment->carrier]);
+
+ $bytes = $service->printLabel($shipment);
+
+ return response($bytes, 200, [
+ 'Content-Type' => 'application/pdf',
+ 'Content-Disposition' => 'inline; filename="shipment-'.$shipment->tracking_reference.'.pdf"',
+ ]);
+ }
+}
diff --git a/src/Shipping/Models/Manifest.php b/src/Shipping/Models/Manifest.php
new file mode 100644
index 0000000..a79d2de
--- /dev/null
+++ b/src/Shipping/Models/Manifest.php
@@ -0,0 +1,28 @@
+ 'datetime',
+ ];
+
+ public function shipments(): HasMany
+ {
+ return $this->hasMany(Shipment::class);
+ }
+}
diff --git a/src/Shipping/Models/Shipment.php b/src/Shipping/Models/Shipment.php
index c278f4c..e760f30 100644
--- a/src/Shipping/Models/Shipment.php
+++ b/src/Shipping/Models/Shipment.php
@@ -23,6 +23,11 @@ class Shipment extends Model
return $this->belongsTo(Order::class);
}
+ public function manifest(): BelongsTo
+ {
+ return $this->belongsTo(Manifest::class);
+ }
+
public function shipmentInfo(): HasMany
{
return $this->hasMany(ShipmentInfo::class);
diff --git a/src/Shipping/Support/WeightCalculator.php b/src/Shipping/Support/WeightCalculator.php
new file mode 100644
index 0000000..4ca4ea0
--- /dev/null
+++ b/src/Shipping/Support/WeightCalculator.php
@@ -0,0 +1,47 @@
+load('purchasable')
+ * is called here if not already eager-loaded).
+ */
+ public static function totalKg(Collection $lines): float
+ {
+ $weight = 0.0;
+
+ foreach ($lines->load('purchasable') as $line) {
+ $variant = $line->purchasable;
+
+ if (! $variant || ! $variant->weight_value) {
+ continue;
+ }
+
+ $unit = $variant->weight_unit ?? 'kg';
+ $value = (float) $variant->weight_value;
+
+ $weight += match ($unit) {
+ 'g' => $value / 1000,
+ 'lb' => $value * 0.45359237,
+ 'oz' => $value * 0.0283495231,
+ default => $value, // kg
+ } * $line->quantity;
+ }
+
+ return $weight;
+ }
+}
diff --git a/src/Shipping/routes/web.php b/src/Shipping/routes/web.php
new file mode 100644
index 0000000..ec0f91e
--- /dev/null
+++ b/src/Shipping/routes/web.php
@@ -0,0 +1,7 @@
+name('shipments.label');