Feature: Order Updates, Events, Order Flows, Shipment And COD support

This commit is contained in:
2026-09-14 00:03:06 +03:00
parent 78bbd8390a
commit 44c6b7defd
73 changed files with 2854 additions and 464 deletions
+17
View File
@@ -48,4 +48,21 @@ return [
'unrecoverable_after' => '90 days', '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,
],
]; ];
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Splits Lunar's single flat `status` column into three independently
* tracked axes — payment, fulfillment, return — so a payment refund and a
* fulfillment dispatch stop racing to write the same field, and each axis
* can be filtered/queried directly instead of overloading one string for
* three unrelated concerns. See Modules\Core\Order\Enums\OrderPaymentStatus/
* OrderFulfillmentStatus/OrderReturnStatus for the value vocabularies, and
* Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus and friends for
* where these columns actually get written. `status` itself is left in
* place, unchanged — Lunar core still reads/writes it in places this
* package doesn't own — but nothing in this package's business logic keys
* off it anymore after this migration's consumers land.
*
* lunar_customers already has a direct precedent for a boboko-core
* migration altering a Lunar-owned table (see
* 2026_07_02_000002_drop_otp_from_lunar_customers_table.php) — this is not
* a new pattern for this codebase, just the first time it's applied to
* lunar_orders.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->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']);
});
}
};
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Append-only audit trail for Order's three status axes (see
* 2026_09_11_000001_add_status_axes_to_orders_table.php) — the thing
* `Lunar\Models\Order::getDefaultLogExcept()` explicitly denies (`status`
* is excluded from Lunar's own Spatie activity log), so this is a
* from-scratch mechanism, not a gap in an existing one.
*
* No `updated_at` — a row is never edited after it's written, only ever
* inserted. `event_class` is the FQCN of whatever business event/action
* caused the write (e.g. Modules\Core\Order\Events\OrderDispatched, or a
* plain string like 'Modules\Core\Shipping\Extensions\OrderViewExtension::
* markDispatchedAction' for a manual Filament action that has no backing
* event class of its own) — see Modules\Core\Order\Services\
* OrderStatusTransitionRecorder.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('order_status_transitions', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,79 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Lunar\Models\Order;
use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Order\Support\OrderStatus;
/**
* Maps every existing order's flat `status` (as it stood before
* 2026_09_11_000001_add_status_axes_to_orders_table.php) onto the new
* payment_status/fulfillment_status/return_status columns. A separate
* migration from the schema change so the schema migration stays simply
* reversible via down(), and this data pass can be independently re-run.
*
* The flat status never captured refunds at all (no 'refunded' value was
* ever added to config('lunar.orders.statuses')), so the table-driven
* mapping below is corrected per-order by re-deriving
* Modules\Core\Order\Support\OrderStatus::payment() — the existing,
* unchanged derived-enum logic — and overriding payment_status to
* refunded/partially_refunded wherever it disagrees with the flat-status
* mapping. This is the one place the "keep the old derived enums" design
* decision earns its keep: refund-fraction math isn't reimplemented here,
* just reused.
*/
return new class extends Migration
{
private const MAP = [
'awaiting-payment' => ['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.
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* captured_status/authorized_status/refunded_status let a merchant pick
* which per-method Order::status label a payment outcome resulted in — a
* mechanism that only made sense while Order.status was the single field
* carrying that meaning. Modules\Core\Order\Listeners\
* ApplyResolvedPaymentStatus now writes a fixed 3-value payment_status
* column instead (see 2026_09_11_000001_add_status_axes_to_orders_table.php);
* there is no longer any per-method flexibility to preserve — "paid" is
* "paid" regardless of which method captured it. Dropped rather than left
* vestigial: keeping them visible in the admin would let a merchant
* configure something that silently does nothing.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('payment_methods', function (Blueprint $table) {
$table->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();
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Order::paid/paid_at — entirely independent of the `status` column (see
* Modules\Core\Order\Services\OrderStatusFlow's own docblock for why
* payment timing, especially for cash-on-delivery, cannot be modeled as a
* status-sequence step). `paid` is the fast-filter boolean; `paid_at` is
* when it actually happened.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->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']);
});
}
};
@@ -0,0 +1,116 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Lunar\Models\Order;
use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Order\Support\OrderStatus;
/**
* Collapses the 3-axis (payment_status/fulfillment_status/return_status)
* model this session briefly built — abandoned before shipping — back
* onto a single `status` column plus the new independent `paid`/`paid_at`
* fields. Must run after 2026_09_12_000001 (adds paid/paid_at) and before
* 2026_09_12_000003 (drops the axis columns this migration still reads).
*
* Priority rule: axis data where it's genuinely non-default (this order
* was really moved through the axis system during this session's manual
* testing); the legacy `status` column (which may still hold pre-session
* hyphenated values) as fallback everywhere else.
*/
return new class extends Migration
{
private const LEGACY_MAP = [
'awaiting-payment' => '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".
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Reverses 2026_09_11_000001_add_status_axes_to_orders_table.php — the
* 3-axis model was abandoned before shipping in favor of a single
* `status` column plus independent `paid`/`paid_at` (see
* 2026_09_12_000001/000002). Must run after 2026_09_12_000002, which
* still reads these columns for the backfill.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_orders', function (Blueprint $table) {
$table->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();
});
}
};
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* There is only one status column left to audit (plus the synthetic
* 'paid' entry — see Modules\Core\Order\Listeners\RecordStatusTransition),
* so the `axis` column this table was created with
* (2026_09_11_000002_create_order_status_transitions_table.php) no longer
* means anything.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('order_status_transitions', function (Blueprint $table) {
$table->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']);
});
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* The seeded 'cash-on-delivery' PaymentMethod row
* (Modules\Core\Command\InstallLunarCommand::seedPaymentMethods()) was
* wired to driver => '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']);
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* 'return_window_open' is renamed to 'delivered' — same status value,
* same meaning (the parcel arrived AND the return window is now open,
* still one combined moment — see Modules\Core\Order\Listeners\
* AdvanceFulfillmentOnDelivered), just a name a merchant expects to read
* on the order page rather than an internal mechanic. Also renames it in
* order_status_transitions' audit rows so the history stays consistent
* with `status` going forward.
*/
return new class extends Migration
{
public function up(): void
{
DB::table('lunar_orders')->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']);
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A real record of "a manifest was issued", not just a loose
* manifest_reference string stamped onto each Shipment row — ACS's own
* ACS_Issue_Pickup_List call returns nothing beyond a PickupList_No (see
* Modules\Core\Shipping\Carriers\Acs\AcsFulfillmentService::issueManifest()),
* so this table is entirely our own bookkeeping: when the manifest was
* issued and how many shipments it included, not something re-derivable
* from the carrier later. `shipment_count` is denormalized (also
* countable via shipments()->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');
}
};
@@ -0,0 +1,82 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Replaces the loose manifest_reference string with a real manifests
* relation — see 2026_09_13_000002_create_manifests_table.php. Backfills
* one Manifest row per distinct (carrier, manifest_reference) pair
* already present in shipments, using the earliest label_printed_at (or
* updated_at as a fallback) among that group as a best-effort issued_at,
* since the exact original issue time was never recorded anywhere.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('shipments', function (Blueprint $table) {
$table->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');
});
}
};
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is complete. Thanks for shopping with us!</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is on its way.</p>
@@ -1,3 +0,0 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>
@@ -297,6 +297,7 @@ class CheckoutService
$order->meta = [ $order->meta = [
...($order->meta?->toArray() ?? []), ...($order->meta?->toArray() ?? []),
'payment_method' => $type,
'terms_accepted' => true, 'terms_accepted' => true,
'terms_accepted_at' => now()->toIso8601String(), 'terms_accepted_at' => now()->toIso8601String(),
'terms_accepted_policy_version' => $policyVersion, 'terms_accepted_policy_version' => $policyVersion,
+1 -2
View File
@@ -311,9 +311,8 @@ class InstallLunarCommand extends Command
PaymentMethod::create([ PaymentMethod::create([
'type' => 'cash-on-delivery', 'type' => 'cash-on-delivery',
'name' => 'Cash on Delivery', 'name' => 'Cash on Delivery',
'driver' => 'offline', 'driver' => 'cash-on-delivery',
'capture_mode' => 'pay', 'capture_mode' => 'pay',
'captured_status' => 'payment-offline',
'position' => 0, 'position' => 0,
'enabled' => false, 'enabled' => false,
'data' => [], 'data' => [],
+8 -4
View File
@@ -27,15 +27,18 @@ use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension;
use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension; use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource; use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Order\Filament\Extensions\OrderItemsTableExtension; 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\OrderRefundActionsExtension;
use Modules\Core\Order\Filament\Extensions\OrderTransactionsExtension; use Modules\Core\Order\Filament\Extensions\OrderTransactionsExtension;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
use Modules\Core\Review\Filament\Extensions\ProductResourceExtension; use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
use Modules\Core\Review\Models\ProductReview; use Modules\Core\Review\Models\ProductReview;
use Modules\Core\Shipping\Extensions\OrderShipmentsExtension;
use Modules\Core\Shipping\Extensions\OrderViewExtension; use Modules\Core\Shipping\Extensions\OrderViewExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodListExtension; use Modules\Core\Shipping\Extensions\ShippingMethodListExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension; 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 class CorePlugin implements Plugin
{ {
@@ -55,9 +58,10 @@ class CorePlugin implements Plugin
LanguageLineResource::class, LanguageLineResource::class,
CartResource::class, CartResource::class,
PaymentMethodResource::class, PaymentMethodResource::class,
ShipmentResource::class,
ManifestResource::class,
]) ])
->plugin(ShippingPlugin::make()) ->plugin(ShippingPlugin::make());
->pages([ManagePickupManifests::class]);
LunarPanel::extensions([ LunarPanel::extensions([
StaffResource::class => StaffResourceExtension::class, StaffResource::class => StaffResourceExtension::class,
@@ -66,7 +70,7 @@ class CorePlugin implements Plugin
ValuesRelationManager::class => ValuesRelationManagerExtension::class, ValuesRelationManager::class => ValuesRelationManagerExtension::class,
ShippingMethodResource::class => ShippingMethodResourceExtension::class, ShippingMethodResource::class => ShippingMethodResourceExtension::class,
ListShippingMethod::class => ShippingMethodListExtension::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, OrderItemsTable::class => OrderItemsTableExtension::class,
]); ]);
@@ -0,0 +1,68 @@
<?php
namespace Modules\Core\Order\Commands;
use Illuminate\Console\Command;
use Lunar\Models\Order;
use Modules\Core\Order\Events\OrderCompleted;
use Modules\Core\Order\Services\OrderStatusWriter;
/**
* Auto-completes a carrier order once its 14-day return window has
* elapsed with no return requested — the automatic counterpart to the
* staff "Update Status" action's manual completion. Store-pickup orders
* have no return-window step at all (Modules\Core\Order\Listeners\
* CompleteOrderOnPickedUp completes them immediately), so this only ever
* touches carrier orders sitting in 'delivered' (the status also carrying
* "return window is open" — see AdvanceFulfillmentOnDelivered).
*
* Registered at exactly dailyAt('00:00') in
* Modules\Core\Providers\OrderServiceProvider — a compliance requirement
* that this run at exact midnight, not Laravel's own arbitrary default
* time for a plain daily() schedule.
*
* "When did the window open" is read from order_status_transitions rather
* than Order::updated_at, which any unrelated field write would bump —
* this is the concrete reason the audit table exists beyond pure logging.
*
* Window length is config('core.order.return_window_days') — a legal/
* policy value a store may need to change without a code deploy, not a
* hardcoded constant.
*/
class CloseExpiredReturnWindows extends Command
{
protected $signature = 'boboko:order:close-expired-return-windows';
protected $description = 'Auto-complete carrier orders whose return window has elapsed with no return requested.';
public function handle(OrderStatusWriter $writer): void
{
$cutoff = now()->subDays(config('core.order.return_window_days', 14));
$orderIds = Order::query()
->where('status', 'delivered')
->whereHas('statusTransitions', function ($query) use ($cutoff) {
$query->where('to_status', 'delivered')
->where('created_at', '<=', $cutoff);
})
->pluck('id');
$completed = 0;
foreach ($orderIds as $orderId) {
$order = Order::find($orderId);
if (! $order || $order->status !== 'delivered') {
continue; // idempotent no-op — moved on since the query ran
}
$writer->write($order, 'completed', self::class);
OrderCompleted::dispatch($order);
$completed++;
}
$this->components->info("Completed {$completed} order(s) past their return window.");
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace Modules\Core\Order\DTOs;
/**
* What a Modules\Core\Order\Services\OrderFulfillmentService method
* returns instead of throwing/echoing a Filament notification directly —
* keeps that service usable outside a Filament action (a future API
* endpoint, a console command, a test) without dragging
* Filament\Notifications\Notification along. Modules\Core\Shipping\
* Extensions\OrderViewExtension is the one place that translates this
* into an actual on-screen notification.
*/
final class OrderFulfillmentResult
{
private function __construct(
public readonly bool $success,
public readonly string $message,
) {}
public static function success(string $message): self
{
return new self(true, $message);
}
public static function failure(string $message): self
{
return new self(false, $message);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
/**
* The one terminal signal every notification/reporting concern that only
* cares about "this order is fully done" should listen to, regardless of
* which path actually got it there — dispatched by all four:
* Modules\Core\Order\Listeners\CompleteOrderOnPickedUp (store-pickup),
* Modules\Core\Order\Commands\CloseExpiredReturnWindows (carrier,
* automatic 14-day return-window expiry), or Modules\Core\Shipping\
* Extensions\OrderViewExtension's "Mark Completed" action (manual
* universal fallback, either branch).
*/
class OrderCompleted
{
use Dispatchable;
public function __construct(
public readonly Order $order,
) {}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
use Modules\Core\Shipping\Models\ShipmentInfo;
/**
* Dispatched by either of the two paths that move a carrier order's
* `status` to 'dispatched' — Modules\Core\Order\Listeners\
* AdvanceFulfillmentOnCarrierCheckpoint (automatic, reacting to a real
* carrier checkpoint) or Modules\Core\Order\Services\
* OrderFulfillmentService::createShipmentAndDispatch() (staff-driven, via
* the single "Update Status" action). $shipmentInfo is nullable
* specifically because of that second path — populated with the
* triggering checkpoint when it's real, null when staff drove it
* manually. Mirrors OrderDelivered's {order, shipmentInfo} shape, just
* with the nullability this one event additionally needs.
*/
class OrderDispatched
{
use Dispatchable;
public function __construct(
public readonly Order $order,
public readonly ?ShipmentInfo $shipmentInfo = null,
) {}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
/**
* Dispatched by Modules\Core\Order\Services\OrderStatusWriter::markPaid()
* whenever Order::paid flips to true — entirely independent of the
* `status` column (see OrderStatusFlow's own docblock for why payment
* timing, especially for cash-on-delivery, cannot be modeled as a step in
* that sequence). Order::status changes are instead picked up generically
* by Modules\Core\Order\Events\OrderStatusUpdated (dispatched by
* OrderObserver whenever `status` changes, regardless of writer).
*/
class OrderPaidChanged
{
use Dispatchable;
public function __construct(
public readonly Order $order,
public readonly string $causeClass,
) {}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
/**
* Dispatched by Modules\Core\Order\Services\OrderFulfillmentService::
* markPickedUp(), the staff-driven "Update Status" action's handling of
* the 'picked_up' target — the customer has collected a store-pickup
* order in person. Store-pickup only; a carrier order's equivalent
* "arrived" moment is OrderDelivered. Modules\Core\Order\Listeners\
* CompleteOrderOnPickedUp reacts to this by moving `status` straight to
* 'completed' — no return-window step for store-pickup, per the business
* design.
*/
class OrderPickedUp
{
use Dispatchable;
public function __construct(
public readonly Order $order,
) {}
}
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
/**
* Dispatched by Modules\Core\Shipping\Extensions\OrderViewExtension's
* "Mark Ready" action, carrier branch (Order::isStorePickupOrder() ===
* false) — staff has packed/staged the order for carrier handoff.
* Staff-internal: nothing customer-facing happens at this moment, so no
* notification listens to this event (compare OrderReadyForPickup, which
* does trigger a customer email).
*/
class OrderReadyForDispatch
{
use Dispatchable;
public function __construct(
public readonly Order $order,
) {}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
/**
* Dispatched by Modules\Core\Shipping\Extensions\OrderViewExtension's
* "Mark Ready" action, store-pickup branch (Order::isStorePickupOrder()
* === true) — staff has packed/staged the order for the customer to
* collect in store. Drives Modules\Core\Order\Notifications\
* OrderPickupReadyNotification ("come collect your order").
*/
class OrderReadyForPickup
{
use Dispatchable;
public function __construct(
public readonly Order $order,
) {}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
/**
* Dispatched by Modules\Core\Order\Services\OrderStatusWriter::write()
* alongside the generic Modules\Core\Order\Events\OrderStatusUpdated
* (which Modules\Core\Order\Observers\OrderObserver dispatches for ANY
* `status` write, regardless of cause, and which
* OrderStatusUpdatedNotification already listens to). This event exists
* only because the audit trail (Modules\Core\Order\Listeners\
* RecordStatusTransition) needs $causeClass, which OrderStatusUpdated
* does not carry — OrderStatusWriter is the only writer of `status` this
* package has left, so it's the only place that needs to know its own
* cause.
*/
class OrderStatusChanged
{
use Dispatchable;
public function __construct(
public readonly Order $order,
public readonly ?string $previousStatus,
public readonly string $newStatus,
public readonly string $causeClass,
) {}
}
@@ -0,0 +1,47 @@
<?php
namespace Modules\Core\Order\Filament\Extensions;
use Filament\Infolists\Components\TextEntry;
use Lunar\Admin\Support\Extending\ViewPageExtension;
use Lunar\Models\Order;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* Adds a "Payment Method" entry to the order summary sidebar — previously
* nowhere on the order page told staff which payment method a shopper
* actually used. Reads Order.meta['payment_method'] (written by
* Modules\Core\Checkout\Services\CheckoutService::initiatePayment()), the
* same source Modules\Core\Order\Services\OrderStatusFlow::isCod() reads,
* so this entry and the "Mark Paid" action's visibility always agree on
* what payment method an order used. Falls back to the most recent
* Transaction.driver for an order placed before that field existed.
*
* Uses the extendOrderSummarySchema hook, same as the deleted 3-axis
* OrderStatusSummaryExtension did — see that class's git history for the
* hook's own docblock/rationale.
*/
class OrderPaymentMethodSummaryExtension extends ViewPageExtension
{
public function extendOrderSummarySchema(array $schema): array
{
$schema[] = TextEntry::make('payment_method')
->label('Payment method')
->state(fn (Order $record) => $this->resolveLabel($record))
->placeholder('—')
->alignEnd();
return $schema;
}
private function resolveLabel(Order $record): ?string
{
$type = $record->meta['payment_method'] ?? $record->transactions()->latest('id')->value('driver');
if ($type === null) {
return null;
}
return PaymentMethod::where('type', $type)->value('name') ?? $type;
}
}
@@ -0,0 +1,49 @@
<?php
namespace Modules\Core\Order\Listeners;
use Modules\Core\Order\Events\OrderDispatched;
use Modules\Core\Order\Services\OrderStatusWriter;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
/**
* The automatic half of "Dispatched" — the manual fallback is the staff
* "Update Status" action (Modules\Core\Shipping\Extensions\
* OrderViewExtension). Listens to ShipmentStatusUpdatedByCarrier directly,
* the same event Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment
* listens to.
*
* Reacts to either TrackingStatus::CollectedFromSender (the carrier
* collected the parcel from the merchant) or InTransit directly, for a
* carrier that skips straight there without a distinct collection
* checkpoint.
*
* Guarded to only fire from 'ready_for_dispatch' — a late/duplicate
* checkpoint, or an order the manual action already advanced, is a
* silent no-op.
*/
class AdvanceFulfillmentOnCarrierCheckpoint
{
public function __construct(
private readonly OrderStatusWriter $writer,
) {}
public function handle(ShipmentStatusUpdatedByCarrier $event): void
{
if ($event->shipmentInfo->status !== TrackingStatus::InTransit
&& $event->shipmentInfo->status !== TrackingStatus::CollectedFromSender) {
return;
}
$order = $event->shipmentInfo->shipment->order;
if (! $order || $order->status !== 'ready_for_dispatch') {
return;
}
$this->writer->write($order, 'dispatched', self::class);
OrderDispatched::dispatch($order, $event->shipmentInfo);
}
}
@@ -0,0 +1,43 @@
<?php
namespace Modules\Core\Order\Listeners;
use Modules\Core\Order\Events\OrderDelivered;
use Modules\Core\Order\Services\OrderStatusWriter;
/**
* Writes `status` to 'delivered' once a carrier confirms delivery, rather
* than jumping straight to 'completed'. Carrier orders get a return
* window between delivery and completion (see Modules\Core\Order\
* Commands\CloseExpiredReturnWindows, which auto-completes an order once
* that window elapses) — 'delivered' is both "the parcel arrived" and
* "the return window is now open"; nothing distinguishes those as
* separate instants, they're the same moment, so there is only the one
* status value.
*
* Kept separate from Modules\Core\Order\Listeners\
* DeriveOrderDeliveredFromShipment, which only ever dispatches
* OrderDelivered — deriving "was this delivered" and acting on it by
* writing `status` are deliberately two different listeners.
*
* Guarded to only fire from 'dispatched' — a duplicate/late Delivered
* checkpoint, or an order a manual action already moved past, is a
* silent no-op.
*/
class AdvanceFulfillmentOnDelivered
{
public function __construct(
private readonly OrderStatusWriter $writer,
) {}
public function handle(OrderDelivered $event): void
{
$order = $event->order;
if ($order->status !== 'dispatched') {
return;
}
$this->writer->write($order, 'delivered', self::class);
}
}
@@ -5,43 +5,45 @@ namespace Modules\Core\Order\Listeners;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Checkout\Events\OrderPlaced; 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\PaymentAuthorized;
use Modules\Core\Payment\Events\PaymentCaptured; use Modules\Core\Payment\Events\PaymentCaptured;
use Modules\Core\Payment\Events\PaymentRefunded; 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 * Registered against PaymentCaptured, PaymentAuthorized, AND
* payment outcome. Registered against PaymentCaptured, PaymentAuthorized, * PaymentRefunded (see OrderServiceProvider).
* AND PaymentRefunded (see OrderServiceProvider) — same handler for all *
* three, differing only in which PaymentMethod column decides the * A capture/authorization only ever writes Order::paid/paid_at (via
* resulting status and, for a refund, which PaymentMethod row that even * OrderStatusWriter::markPaid()) — never `status`. Confirmed with the
* is (see resolvePaymentMethod()). * 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 * 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 * belongs to — Payment has no concept of an Order.
* 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()).
* *
* Loads and saves the model (not a bulk ::whereKey()->update()) so * Dispatches Checkout\Events\OrderPlaced itself, once placed_at is set.
* Order::observe()'s updated() hook fires and OrderStatusUpdated goes out * Never fires from the PaymentRefunded path — a refund can only ever
* the same as any other status write — see that event's own docblock for * happen after an order was already placed.
* why it's meant to fire "regardless of what wrote it."
* *
* Dispatches Checkout\Events\OrderPlaced itself, once placed_at is set — * Deliberately does NOT react to PaymentVoided.
* 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.
*/ */
class ApplyResolvedPaymentStatus class ApplyResolvedPaymentStatus
{ {
public function __construct( public function __construct(
private readonly PaymentMethodCache $paymentMethods, private readonly OrderStatusWriter $writer,
) {} ) {}
public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void
@@ -54,59 +56,41 @@ class ApplyResolvedPaymentStatus
$order = Order::findOrFail($orderId); $order = Order::findOrFail($orderId);
$method = $this->resolvePaymentMethod($event, $order); if ($event instanceof PaymentRefunded) {
$column = match (true) { $this->applyRefund($order, $event);
$event instanceof PaymentCaptured => 'captured_status',
$event instanceof PaymentAuthorized => 'authorized_status',
$event instanceof PaymentRefunded => 'refunded_status',
};
$status = $method?->{$column};
if ($status === null) {
return; return;
} }
$wasPlaced = ! blank($order->placed_at); $wasPlaced = ! blank($order->placed_at);
$order->update([ $this->writer->markPaid($order, $event::class);
'status' => $status,
'placed_at' => $order->placed_at ?? now(),
]);
if (! $wasPlaced && ! $event instanceof PaymentRefunded) { if (! $wasPlaced) {
$order->update(['placed_at' => $order->placed_at ?? now()]);
Event::dispatch(new OrderPlaced($order)); Event::dispatch(new OrderPlaced($order));
} }
} }
/** /**
* PaymentCaptured/PaymentAuthorized carry $event->type as the * Requires the refund Transaction row to already exist (Modules\Core\
* PaymentMethod.type that was actually charged — a direct lookup. * Order\Listeners\RecordPaymentTransaction must run first — see
* * OrderServiceProvider's listener registration order for
* PaymentRefunded's $event->type is the REFUND driver's own registry * PaymentRefunded), so the relation is refreshed here rather than
* key (e.g. 'bank-transfer' — see BankTransferPaymentDriver::refund()), * trusted from a possibly-stale $order instance.
* 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.
*/ */
private function resolvePaymentMethod(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event, Order $order): ?PaymentMethod private function applyRefund(Order $order, PaymentRefunded $event): void
{ {
if (! $event instanceof PaymentRefunded) { $order->load('transactions');
return $this->paymentMethods->all()->firstWhere('type', $event->type);
$target = match (OrderStatus::payment($order)) {
PaymentStatus::Refunded => 'refunded',
PaymentStatus::PartialRefund => 'partially_refunded',
default => null,
};
if ($target !== null && $order->status !== $target) {
$this->writer->write($order, $target, $event::class);
} }
$originalType = $order->transactions()
->whereIn('type', ['capture', 'intent'])
->where('success', true)
->oldest('created_at')
->value('driver');
return $originalType !== null
? $this->paymentMethods->all()->firstWhere('type', $originalType)
: null;
} }
} }
@@ -1,36 +0,0 @@
<?php
namespace Modules\Core\Order\Listeners;
use Modules\Core\Order\Events\OrderDelivered;
/**
* Writes Order.status to 'completed' once a carrier confirms delivery —
* the terminal status a carrier-fulfilled order reaches on its own,
* without a human picking it from the dropdown, mirroring the store-pickup
* order's own terminal transition (Modules\Core\Shipping\Extensions\
* OrderViewExtension::markPickedUpAction()).
*
* Kept separate from Modules\Core\Order\Listeners\
* DeriveOrderDeliveredFromShipment, which only ever dispatches
* OrderDelivered — see that event's own docblock ("Order.status itself is
* left untouched here") for why deriving "was this delivered" and acting
* on it by writing status are deliberately two different listeners, same
* separation Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus
* already has from the Payment* events it reacts to.
*
* Guarded to only fire from 'dispatched' — a checkpoint arriving out of
* order, or against an order some other status flow has already moved
* past, shouldn't silently force it to 'completed'.
*/
class CompleteOrderOnDelivered
{
public function handle(OrderDelivered $event): void
{
if ($event->order->status !== 'dispatched') {
return;
}
$event->order->update(['status' => 'completed']);
}
}
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Order\Listeners;
use Modules\Core\Order\Events\OrderCompleted;
use Modules\Core\Order\Events\OrderPickedUp;
use Modules\Core\Order\Services\OrderStatusWriter;
/**
* The store-pickup mirror of AdvanceFulfillmentOnDelivered — reacts to
* OrderPickedUp (dispatched by Modules\Core\Order\Services\
* OrderFulfillmentService::markPickedUp() the moment staff confirm the
* customer collected the order) by moving `status` straight to
* 'completed'. No return-window step for store-pickup orders, per the
* business design — unlike the carrier branch, there is no 'delivered'
* intermediate value on this path.
*
* Guarded to only fire from 'picked_up' — a duplicate dispatch (e.g. a
* stale page re-submitting the action) is a silent no-op.
*/
class CompleteOrderOnPickedUp
{
public function __construct(
private readonly OrderStatusWriter $writer,
) {}
public function handle(OrderPickedUp $event): void
{
$order = $event->order;
if ($order->status !== 'picked_up') {
return;
}
$this->writer->write($order, 'completed', self::class);
OrderCompleted::dispatch($order);
}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\Core\Order\Listeners;
use Modules\Core\Order\Services\OrderStatusWriter;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
/**
* Wires TrackingStatus::Failed to the 'delivery_failed' status for the
* first time — previously an unused enum case. Guarded to only fire from
* 'dispatched': a stale/duplicate checkpoint, or an order a manual action
* already moved past, is a silent no-op.
*/
class MarkDeliveryFailedOnCarrierCheckpoint
{
public function __construct(
private readonly OrderStatusWriter $writer,
) {}
public function handle(ShipmentStatusUpdatedByCarrier $event): void
{
if ($event->shipmentInfo->status !== TrackingStatus::Failed) {
return;
}
$order = $event->shipmentInfo->shipment->order;
if (! $order || $order->status !== 'dispatched') {
return;
}
$this->writer->write($order, 'delivery_failed', self::class);
}
}
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Order\Listeners;
use Modules\Core\Order\Events\OrderPaidChanged;
use Modules\Core\Order\Events\OrderStatusChanged;
use Modules\Core\Order\Services\OrderStatusTransitionRecorder;
/**
* The one place order_status_transitions rows actually get written —
* listens to OrderStatusChanged (every write of the single `status`
* column, via Modules\Core\Order\Services\OrderStatusWriter::write()) and
* OrderPaidChanged (every write of Order::paid, via
* OrderStatusWriter::markPaid()). paid isn't really a "status", but gets
* one consistent audit trail entry ('paid', with a null from_status)
* rather than a second, separate table.
*/
class RecordStatusTransition
{
public function __construct(
private readonly OrderStatusTransitionRecorder $recorder,
) {}
public function handleStatusChanged(OrderStatusChanged $event): void
{
$this->recorder->record($event->order, $event->previousStatus, $event->newStatus, $event->causeClass);
}
public function handlePaidChanged(OrderPaidChanged $event): void
{
$this->recorder->record($event->order, null, 'paid', $event->causeClass);
}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\Order\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Lunar\Models\Order;
/**
* One append-only row per write to Order::status (plus one synthetic
* 'paid' entry per Order::paid write — see
* Modules\Core\Order\Listeners\RecordStatusTransition) — see
* database/migrations/2026_09_11_000002_create_order_status_transitions_table.php
* and Modules\Core\Order\Services\OrderStatusTransitionRecorder, which is
* the only thing that ever creates a row. Never updated after creation —
* $timestamps is disabled since there's no updated_at column and
* created_at is DB-defaulted (`useCurrent()`), not Eloquent-managed.
*/
class OrderStatusTransition extends Model
{
public $timestamps = false;
protected $guarded = [];
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
}
@@ -0,0 +1,49 @@
<?php
namespace Modules\Core\Order\Notifications;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderCompleted;
class OrderCompletedNotification extends BaseNotification
{
public function __construct(private readonly OrderCompleted $event) {}
public static function getKey(): string
{
return 'order.completed.customer.mail';
}
public static function listensTo(): string
{
return OrderCompleted::class;
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function notifiable(): AnonymousNotifiable
{
$order = $this->event->order;
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
return NotificationFacade::route('mail', $email);
}
public function toMail(object $notifiable): MailMessage
{
$order = $this->event->order;
return (new MailMessage)
->subject(__('Your order :reference is complete', ['reference' => $order->reference]))
->view('core::order.notifications.completed', [
'reference' => $order->reference,
]);
}
}
@@ -0,0 +1,54 @@
<?php
namespace Modules\Core\Order\Notifications;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderDispatched;
/**
* Fills a real, previously-unfilled customer-communication gap — before
* this redesign nothing notified a customer when their carrier order left
* the building at all.
*/
class OrderDispatchedNotification extends BaseNotification
{
public function __construct(private readonly OrderDispatched $event) {}
public static function getKey(): string
{
return 'order.dispatched.customer.mail';
}
public static function listensTo(): string
{
return OrderDispatched::class;
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function notifiable(): AnonymousNotifiable
{
$order = $this->event->order;
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
return NotificationFacade::route('mail', $email);
}
public function toMail(object $notifiable): MailMessage
{
$order = $this->event->order;
return (new MailMessage)
->subject(__('Your order :reference is on its way', ['reference' => $order->reference]))
->view('core::order.notifications.dispatched', [
'reference' => $order->reference,
]);
}
}
@@ -6,19 +6,21 @@ use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade; use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification; 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 * "Your order is ready to collect" — listens to the specific
* event Modules\Core\Order\Notifications\OrderStatusUpdatedNotification * OrderReadyForPickup event (dispatched by Modules\Core\Shipping\
* listens to, but only for the 'ready-for-pickup' transition; that other * Extensions\OrderViewExtension's "Mark Ready" action, store-pickup
* notification suppresses itself for this same transition (see its own * branch only), not the generic OrderStatusUpdated. Modules\Core\Order\
* via()) so a customer gets this richer, pickup-specific email instead of * Notifications\OrderStatusUpdatedNotification still separately
* the generic "order updated" one, not both. * 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 class OrderPickupReadyNotification extends BaseNotification
{ {
public function __construct(private readonly OrderStatusUpdated $event) {} public function __construct(private readonly OrderReadyForPickup $event) {}
public static function getKey(): string public static function getKey(): string
{ {
@@ -27,15 +29,11 @@ class OrderPickupReadyNotification extends BaseNotification
public static function listensTo(): string public static function listensTo(): string
{ {
return OrderStatusUpdated::class; return OrderReadyForPickup::class;
} }
public function via(object $notifiable): array public function via(object $notifiable): array
{ {
if ($this->event->newStatus !== 'ready-for-pickup') {
return [];
}
return ['mail']; return ['mail'];
} }
+22 -8
View File
@@ -5,18 +5,32 @@ namespace Modules\Core\Order\Observers;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Order\Events\OrderStatusUpdated; 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 class OrderObserver
{ {
public function updated(Order $order): void public function updated(Order $order): void
{ {
if (! $order->wasChanged('status')) { if ($order->wasChanged('status')) {
return; OrderStatusUpdated::dispatch(
$order,
$order->getOriginal('status'),
$order->status,
);
} }
OrderStatusUpdated::dispatch(
$order,
$order->getOriginal('status'),
$order->status,
);
} }
} }
@@ -0,0 +1,169 @@
<?php
namespace Modules\Core\Order\Services;
use Lunar\Models\Order;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Order\DTOs\OrderFulfillmentResult;
use Modules\Core\Order\Events\OrderPickedUp;
use Modules\Core\Order\Events\OrderReadyForDispatch;
use Modules\Core\Order\Events\OrderReadyForPickup;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Throwable;
/**
* The staff-facing fulfillment/return/payment workflow behind the three
* header actions in Modules\Core\Shipping\Extensions\OrderViewExtension
* ("Create Shipment", "Update Status", "Mark Paid") — every guard check,
* status write (via Modules\Core\Order\Services\OrderStatusWriter), and
* event dispatch lives here, keeping this workflow usable and testable
* independent of Filament.
*
* Every method re-validates its own precondition internally (not just
* trusted from the caller's own visible()-equivalent check) — protects
* against a stale page load racing a concurrent automatic transition
* (e.g. a carrier tracking checkpoint advancing the same order between
* page load and button click).
*/
class OrderFulfillmentService
{
public function __construct(
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {}
public function markReady(Order $order): OrderFulfillmentResult
{
if ($order->status !== 'processing') {
return OrderFulfillmentResult::failure('This order must be in Processing before it can be marked ready.');
}
$target = $order->isStorePickupOrder() ? 'ready_for_pickup' : 'ready_for_dispatch';
$this->writer->write($order, $target, self::class.'::markReady');
if ($order->isStorePickupOrder()) {
OrderReadyForPickup::dispatch($order);
} else {
OrderReadyForDispatch::dispatch($order);
}
return OrderFulfillmentResult::success('Order marked ready.');
}
public function createShipmentAndDispatch(Order $order, ShipmentRequest $request): OrderFulfillmentResult
{
if ($order->status !== 'ready_for_dispatch') {
return OrderFulfillmentResult::failure('This order is not ready to be dispatched.');
}
$service = $this->resolveFulfillmentService($order);
if (! $service) {
return OrderFulfillmentResult::failure('No carrier fulfillment integration is configured for this order.');
}
try {
$service->createShipment($order, $request);
} catch (Throwable $e) {
report($e);
return OrderFulfillmentResult::failure('Failed to create shipment: '.$e->getMessage());
}
$this->writer->write($order, 'dispatched', self::class.'::createShipmentAndDispatch');
return OrderFulfillmentResult::success('Shipment created and order dispatched.');
}
public function markPickedUp(Order $order): OrderFulfillmentResult
{
if ($order->status !== 'ready_for_pickup') {
return OrderFulfillmentResult::failure('This order is not ready for pickup.');
}
$this->writer->write($order, 'picked_up', self::class.'::markPickedUp');
OrderPickedUp::dispatch($order);
return OrderFulfillmentResult::success('Order marked as picked up.');
}
/**
* The general-purpose entry point for any transition with no special
* side effect — a manual override, not restricted to the guided next
* step(s), so staff can revert to an earlier status in the order's
* own branch. Validates $to is actually a member of
* OrderStatusFlow::allOptions() before writing (server-side
* re-validation of whatever the Select offered) — still refuses a
* status from the WRONG branch or an unknown value.
*/
public function transitionTo(Order $order, string $to): OrderFulfillmentResult
{
if (! array_key_exists($to, $this->flow->allOptions($order))) {
return OrderFulfillmentResult::failure('That status is not valid for this order.');
}
$this->writer->write($order, $to, self::class.'::transitionTo');
return OrderFulfillmentResult::success('Order status updated.');
}
/**
* Independent of `status` entirely — offered by the single "Update
* Status" action regardless of current status (see
* OrderStatusFlow::canMarkPaid()).
*/
public function markPaid(Order $order): OrderFulfillmentResult
{
if (! $this->flow->canMarkPaid($order)) {
return OrderFulfillmentResult::failure('This order cannot be marked paid right now.');
}
$this->writer->markPaid($order, self::class.'::markPaid');
return OrderFulfillmentResult::success('Order marked as paid.');
}
public function canCreateShipment(Order $order): bool
{
return $order->status === 'ready_for_dispatch'
&& ! $order->isStorePickupOrder()
&& $order->shipments()->exists() === false
&& $this->resolveFulfillmentService($order) !== null;
}
/**
* Public wrapper around resolveCarrier() — Modules\Core\Shipping\
* Extensions\OrderViewExtension needs to know which carrier an order
* uses to branch the "Create Shipment" form (Box Now's box-size
* repeater vs. every other carrier's plain weight field).
*/
public function carrierFor(Order $order): ?string
{
return $this->resolveCarrier($order);
}
private function resolveCarrier(Order $order): ?string
{
$code = $order->shippingAddress?->shipping_option;
if (! $code) {
return null;
}
return ShippingMethod::where('code', $code)->value('driver');
}
private function resolveFulfillmentService(Order $order): ?CarrierFulfillmentInterface
{
$carrier = $this->resolveCarrier($order);
if (! $carrier) {
return null;
}
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
}
}
+140
View File
@@ -0,0 +1,140 @@
<?php
namespace Modules\Core\Order\Services;
use Lunar\Models\Order;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* Two status sequences — carrier, pickup (Order::isStorePickupOrder()) —
* NOT four. Payment method (prepaid vs. cash-on-delivery) does not affect
* the status SEQUENCE at all; it only affects Order::paid, an entirely
* separate field this class also offers a transition for (see
* canMarkPaid()). `status` never includes a "paid" step — COD
* reconciliation can happen at any point in, or after, the fulfillment
* journey (same-day to months later), so it cannot occupy a fixed slot in
* a linear sequence.
*/
class OrderStatusFlow
{
private const FLOW_CARRIER = [
'awaiting_payment', 'processing', 'ready_for_dispatch', 'dispatched',
'delivered', 'completed', 'return_requested', 'returned',
];
private const FLOW_PICKUP = [
'awaiting_payment', 'processing', 'ready_for_pickup', 'picked_up',
'completed', 'return_requested', 'returned',
];
private const REFUND_OPTIONS = ['partially_refunded', 'refunded'];
private const RETURN_ELIGIBLE_FROM = ['delivered', 'picked_up', 'completed'];
public function resolveFlow(Order $order): array
{
return $order->isStorePickupOrder() ? self::FLOW_PICKUP : self::FLOW_CARRIER;
}
/**
* Order.meta['payment_method'] (written by
* Modules\Core\Checkout\Services\CheckoutService::initiatePayment())
* is the durable source of truth. Falls back to the most recent
* Transaction.driver (a payment TYPE slug) only if meta is missing —
* e.g. an order placed before this field existed.
*/
public function isCod(Order $order): bool
{
$type = $order->meta['payment_method'] ?? $order->transactions()->latest('id')->value('driver');
if ($type === null) {
return false;
}
return PaymentMethod::where('type', $type)->value('driver') === 'cash-on-delivery';
}
/**
* @return array<string, string> value => label — every status in the
* order's own branch (carrier or pickup), plus the refund options,
* for a manual-override "New status" select. Deliberately not
* filtered to nextOptions()'s guided next-step(s) — staff can jump
* to any status in their branch, including reverting to an earlier
* one (e.g. undoing a mistaken click). transitionTo() still
* validates $to is actually a member of this set server-side.
*/
public function allOptions(Order $order): array
{
$statuses = [...$this->resolveFlow($order), ...self::REFUND_OPTIONS, 'delivery_failed'];
return collect($statuses)
->unique()
->mapWithKeys(fn (string $status) => [$status => $this->label($status)])
->all();
}
/**
* @return array<string, string> value => label — status-sequence
* transitions offered as the guided next step(s). Does not include
* the "mark paid" pseudo-option — see canMarkPaid().
*/
public function nextOptions(Order $order): array
{
$flow = $this->resolveFlow($order);
$current = $order->status;
$position = array_search($current, $flow, true);
$options = [];
if ($position !== false && isset($flow[$position + 1])) {
$options[] = $flow[$position + 1];
}
// delivery_failed — a possible outcome of any delivery attempt,
// carrier flow only, checked on $current directly (not on the
// flow's literal next value) since it's a branch on the attempt
// itself, not on sequence position.
if ($current === 'dispatched') {
$options[] = 'delivery_failed';
}
// From delivery_failed: retry dispatch, or give up and treat as
// a return.
if ($current === 'delivery_failed') {
array_push($options, 'dispatched', 'return_requested');
}
if (in_array($current, self::RETURN_ELIGIBLE_FROM, true)) {
$options[] = 'return_requested';
}
if ($current === 'return_requested') {
$options[] = 'returned';
}
if ($current === 'returned') {
array_push($options, ...self::REFUND_OPTIONS);
}
return collect($options)
->unique()
->mapWithKeys(fn (string $status) => [$status => $this->label($status)])
->all();
}
/**
* Whether the "mark paid" option should be offered right now —
* entirely independent of $order->status. True whenever this is a
* cash-on-delivery order and payment hasn't been recorded yet,
* regardless of fulfillment progress (before OR after completed).
*/
public function canMarkPaid(Order $order): bool
{
return ! $order->paid && $this->isCod($order);
}
private function label(string $status): string
{
return (string) str($status)->replace('_', ' ')->title();
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Core\Order\Services;
use Lunar\Models\Order;
use Modules\Core\Order\Models\OrderStatusTransition;
/**
* The single place every order_status_transitions row gets written —
* called by Modules\Core\Order\Listeners\RecordStatusTransition, itself
* listening to Modules\Core\Order\Events\OrderStatusChanged and
* OrderPaidChanged, dispatched by Modules\Core\Order\Services\
* OrderStatusWriter (the only writer of Order::status/paid left in this
* package).
*/
final class OrderStatusTransitionRecorder
{
public function record(Order $order, ?string $from, string $to, string $eventClass): void
{
OrderStatusTransition::create([
'order_id' => $order->id,
'from_status' => $from,
'to_status' => $to,
'event_class' => $eventClass,
]);
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace Modules\Core\Order\Services;
use Lunar\Models\Order;
use Modules\Core\Order\Events\OrderPaidChanged;
use Modules\Core\Order\Events\OrderStatusChanged;
/**
* The one place Order::status/paid actually get written — replaces the
* earlier per-axis Modules\Core\Order\Services\OrderAxisWriter now that
* there is a single status column plus one independent `paid` field (see
* Modules\Core\Order\Services\OrderStatusFlow's own docblock for why
* payment timing is not a status-sequence step).
*
* write() relies on Modules\Core\Order\Observers\OrderObserver to
* generically dispatch OrderStatusUpdated whenever `status` actually
* changes — there's no separate axis-changed event to dispatch here
* anymore, since there's only one column left to watch. markPaid() is
* genuinely independent: it dispatches its own OrderPaidChanged, since
* OrderObserver only watches `status`, not `paid`.
*
* Cause is passed explicitly through every call rather than smuggled
* through a runtime property on the model — Lunar\Models\Order has
* $guarded = [], so Eloquent treats ANY property assignment as a real
* column to persist; an earlier design that tried
* $order->statusTransitionCause = ... broke immediately with an
* "undefined column" error the moment ->update() ran.
*/
class OrderStatusWriter
{
public function write(Order $order, string $to, string $causeClass): void
{
$from = $order->status;
if ($from === $to) {
return;
}
$order->update(['status' => $to]);
OrderStatusChanged::dispatch($order, $from, $to, $causeClass);
}
public function markPaid(Order $order, string $causeClass): void
{
if ($order->paid) {
return;
}
$order->update(['paid' => true, 'paid_at' => now()]);
OrderPaidChanged::dispatch($order, $causeClass);
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Core\Payment\Drivers;
use Illuminate\Support\Str;
use Lunar\DataTypes\Price;
use Modules\Core\Payment\Contracts\Configurable;
use Modules\Core\Payment\Contracts\SupportsPay;
use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus;
/**
* Cash-on-delivery/cash-on-pickup — the shopper pays staff in person, at
* delivery or pickup, not at checkout, and reconciliation can happen
* anywhere from same-day to months later, entirely independent of the
* order's fulfillment progress (this is WHY Order::paid is its own field,
* not a status-sequence step — see Modules\Core\Order\Services\
* OrderStatusFlow's own docblock).
*
* Unlike OfflinePaymentDriver (cash-in-hand, immediate capture), pay()
* here must NOT dispatch PaymentCaptured — doing so would immediately
* flip Order::paid via Modules\Core\Order\Listeners\
* ApplyResolvedPaymentStatus, which is exactly wrong: no money has
* changed hands yet. Returns PaymentResultStatus::Pending instead — the
* documented convention for "unresolved" (see SupportsPay's own
* docblock). ApplyResolvedPaymentStatus and RecordPaymentTransaction both
* only listen to Captured/Authorized/Voided/Refunded, so a Pending result
* triggers neither.
*
* Order::paid only ever becomes true for a COD order via staff explicitly
* marking it received (Modules\Core\Order\Services\
* OrderFulfillmentService::markPaid()), offered by the single "Update
* Status" action at any time, independent of status.
*/
class CashOnDeliveryPaymentDriver implements Configurable, SupportsPay
{
public function isConfigured(): bool
{
return true;
}
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
{
return new PaymentResult(
status: PaymentResultStatus::Pending,
reference: 'cod-'.Str::uuid(),
amount: $amount,
);
}
}
+6 -3
View File
@@ -11,9 +11,12 @@ use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Events\PaymentCaptured; use Modules\Core\Payment\Events\PaymentCaptured;
/** /**
* Shared by every payment type with no real gateway to confirm against — * Cash-in-hand — a shopper paying in person at the moment of pickup, with
* cash-in-hand, cash-on-delivery — where the shopper pays at pickup/on * nothing left to reconcile afterward, so capture is immediate. NOT used
* delivery, not at checkout. There is no separate hold-then-settle model * for cash-on-delivery, which has its own Modules\Core\Payment\Drivers\
* CashOnDeliveryPaymentDriver — COD payment happens at an unpredictable
* later time (same-day to months), so it must not capture immediately the
* way this driver does. There is no separate hold-then-settle model
* (SupportsAuthorization/SupportsCaptures/SupportsVoids) and no async * (SupportsAuthorization/SupportsCaptures/SupportsVoids) and no async
* resolution (HandlesPaymentCallback) — pay() decides success immediately * resolution (HandlesPaymentCallback) — pay() decides success immediately
* and dispatches PaymentCaptured before returning. * and dispatches PaymentCaptured before returning.
@@ -7,7 +7,6 @@ use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Component; use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn; use Filament\Tables\Columns\ToggleColumn;
@@ -55,11 +54,6 @@ use Modules\Core\Payment\Services\PaymentMethodService;
* admin needs the same at-a-glance warning for it, not just a silently * admin needs the same at-a-glance warning for it, not just a silently
* absent checkout option. * absent checkout option.
* *
* `authorized_status` only appears in the form when `capture_mode` is
* "Hold now, charge later" — it's simply unreachable for a "Charge
* immediately" method (that mode only ever produces PaymentCaptured,
* never PaymentAuthorized), so showing it unconditionally would just be
* a confusing, always-irrelevant field for most methods.
*/ */
class PaymentMethodResource extends Resource class PaymentMethodResource extends Resource
{ {
@@ -151,13 +145,6 @@ class PaymentMethodResource extends Resource
->default('pay') ->default('pay')
->live() ->live()
->required(), ->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(); ->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 public static function getPages(): array
{ {
return [ return [
@@ -211,7 +180,7 @@ class PaymentMethodResource extends Resource
->icon('heroicon-o-pencil-square') ->icon('heroicon-o-pencil-square')
->schema(static::getFormComponents()) ->schema(static::getFormComponents())
->fillForm(fn (PaymentMethod $record) => $record->only([ ->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)); ->action(fn (PaymentMethod $record, array $data) => app(PaymentMethodService::class)->update($record, $data));
} }
-10
View File
@@ -18,16 +18,6 @@ use Illuminate\Database\Eloquent\Model;
* - capture_mode: 'pay' or 'authorize' — which SupportsPay/ * - capture_mode: 'pay' or 'authorize' — which SupportsPay/
* SupportsAuthorization method CheckoutService::initiatePayment() * SupportsAuthorization method CheckoutService::initiatePayment()
* calls for this row. * 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. * - position: admin-controlled display/checkout order.
* - driver_missing_at: set by `payment:sync-drivers` when `driver` no * - driver_missing_at: set by `payment:sync-drivers` when `driver` no
* longer resolves via the registry — separate from `enabled`, so a * longer resolves via the registry — separate from `enabled`, so a
@@ -70,8 +70,7 @@ class PaymentMethodService
public function delete(PaymentMethod $method): void public function delete(PaymentMethod $method): void
{ {
$snapshot = $method->only([ $snapshot = $method->only([
'id', 'type', 'name', 'driver', 'capture_mode', 'id', 'type', 'name', 'driver', 'capture_mode', 'position', 'enabled',
'captured_status', 'authorized_status', 'position', 'enabled',
]); ]);
$method->delete(); $method->delete();
+48 -3
View File
@@ -2,20 +2,33 @@
namespace Modules\Core\Providers; namespace Modules\Core\Providers;
use Illuminate\Console\Scheduling\Schedule as ConsoleSchedule;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Lunar\Models\Order; use Lunar\Models\Order;
use Lunar\Models\Transaction; use Lunar\Models\Transaction;
use Modules\Core\Checkout\Events\OrderPlaced; use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Notification\NotificationRegistry; use Modules\Core\Notification\NotificationRegistry;
use Modules\Core\Order\Commands\CloseExpiredReturnWindows;
use Modules\Core\Order\Events\OrderDelivered; 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\ApplyResolvedPaymentStatus;
use Modules\Core\Order\Listeners\CompleteOrderOnDelivered; use Modules\Core\Order\Listeners\CompleteOrderOnPickedUp;
use Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced; use Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced;
use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment; use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment;
use Modules\Core\Order\Listeners\MarkDeliveryFailedOnCarrierCheckpoint;
use Modules\Core\Order\Listeners\RecordPaymentTransaction; 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\OrderCapturedNotification;
use Modules\Core\Order\Notifications\OrderCompletedNotification;
use Modules\Core\Order\Notifications\OrderDeliveredNotification; 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\OrderPlacedNotification;
use Modules\Core\Order\Notifications\OrderRefundedNotification; use Modules\Core\Order\Notifications\OrderRefundedNotification;
use Modules\Core\Order\Notifications\OrderStatusUpdatedNotification; use Modules\Core\Order\Notifications\OrderStatusUpdatedNotification;
@@ -38,23 +51,43 @@ class OrderServiceProvider extends ServiceProvider
Order::macro('paymentStatus', fn () => OrderStatus::payment($this)); Order::macro('paymentStatus', fn () => OrderStatus::payment($this));
Order::macro('fulfillmentStatus', fn () => OrderStatus::fulfillment($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(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(PaymentCaptured::class, ApplyResolvedPaymentStatus::class);
Event::listen(PaymentAuthorized::class, ApplyResolvedPaymentStatus::class); Event::listen(PaymentAuthorized::class, ApplyResolvedPaymentStatus::class);
Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class);
Event::listen(PaymentCaptured::class, RecordPaymentTransaction::class); Event::listen(PaymentCaptured::class, RecordPaymentTransaction::class);
Event::listen(PaymentAuthorized::class, RecordPaymentTransaction::class); Event::listen(PaymentAuthorized::class, RecordPaymentTransaction::class);
Event::listen(PaymentVoided::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, RecordPaymentTransaction::class);
Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class);
Event::listen(OrderPlaced::class, DecrementStockOnOrderPlaced::class); Event::listen(OrderPlaced::class, DecrementStockOnOrderPlaced::class);
Event::listen(OrderStatusChanged::class, [RecordStatusTransition::class, 'handleStatusChanged']);
Event::listen(OrderPaidChanged::class, [RecordStatusTransition::class, 'handlePaidChanged']);
NotificationRegistry::get()->register([ NotificationRegistry::get()->register([
OrderDeliveredNotification::class, OrderDeliveredNotification::class,
OrderStatusUpdatedNotification::class, OrderStatusUpdatedNotification::class,
OrderRefundedNotification::class, OrderRefundedNotification::class,
OrderCapturedNotification::class, OrderCapturedNotification::class,
OrderPlacedNotification::class, OrderPlacedNotification::class,
OrderPickupReadyNotification::class,
OrderDispatchedNotification::class,
OrderCompletedNotification::class,
]); ]);
// Lets the consuming app override copy/markup without forking core // Lets the consuming app override copy/markup without forking core
@@ -64,5 +97,17 @@ class OrderServiceProvider extends ServiceProvider
$this->publishes([ $this->publishes([
__DIR__ . '/../../resources/views/order/notifications' => resource_path('views/vendor/core/order/notifications'), __DIR__ . '/../../resources/views/order/notifications' => resource_path('views/vendor/core/order/notifications'),
], 'core-views'); ], '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');
});
} }
} }
+2
View File
@@ -9,6 +9,7 @@ use Lunar\Models\Contracts\Transaction as TransactionContract;
use Lunar\Pipelines\Cart\ApplyShipping; use Lunar\Pipelines\Cart\ApplyShipping;
use Modules\Core\Command\SyncPaymentDriversCommand; use Modules\Core\Command\SyncPaymentDriversCommand;
use Modules\Core\Payment\Drivers\BankTransferPaymentDriver; use Modules\Core\Payment\Drivers\BankTransferPaymentDriver;
use Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver;
use Modules\Core\Payment\Drivers\OfflinePaymentDriver; use Modules\Core\Payment\Drivers\OfflinePaymentDriver;
use Modules\Core\Payment\Drivers\StripePaymentDriver; use Modules\Core\Payment\Drivers\StripePaymentDriver;
use Modules\Core\Payment\Events\PaymentMethodCreated; use Modules\Core\Payment\Events\PaymentMethodCreated;
@@ -33,6 +34,7 @@ class PaymentServiceProvider extends ServiceProvider
$registry->register('offline', OfflinePaymentDriver::class, 'Offline / Manual'); $registry->register('offline', OfflinePaymentDriver::class, 'Offline / Manual');
$registry->register('stripe', StripePaymentDriver::class, 'Stripe'); $registry->register('stripe', StripePaymentDriver::class, 'Stripe');
$registry->register('bank-transfer', BankTransferPaymentDriver::class, 'Bank Transfer'); $registry->register('bank-transfer', BankTransferPaymentDriver::class, 'Bank Transfer');
$registry->register('cash-on-delivery', CashOnDeliveryPaymentDriver::class, 'Cash on Delivery');
$cartPipeline = config('lunar.cart.pipelines.cart', []); $cartPipeline = config('lunar.cart.pipelines.cart', []);
$insertAfter = array_search(ApplyShipping::class, $cartPipeline, true); $insertAfter = array_search(ApplyShipping::class, $cartPipeline, true);
+27 -10
View File
@@ -65,24 +65,34 @@ class ShippingServiceProvider extends ServiceProvider
__DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'), __DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'),
], 'core-config'); ], '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) { Order::resolveRelationUsing('shipments', function ($order) {
return $order->hasMany(Shipment::class); 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. // Order has no direct ShippingMethod relation — shippingAddress.
// shipping_option is only ever a code string (see // shipping_option is only ever a code string (see
// Modules\Core\Shipping\Extensions\OrderViewExtension:: // Modules\Core\Shipping\Extensions\OrderViewExtension::
// resolveCarrier() for the same lookup pattern already used to // resolveCarrier() for the same lookup pattern already used to
// resolve a carrier driver from it). // 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 () { Order::macro('isStorePickupOrder', function () {
/** @var Order $this */ /** @var Order $this */
$code = $this->shippingAddress?->shipping_option; $code = $this->shippingAddress?->shipping_option;
@@ -91,7 +101,14 @@ class ShippingServiceProvider extends ServiceProvider
return false; 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) { foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) {
@@ -14,6 +14,7 @@ use Modules\Core\Shipping\DTOs\ManifestResult;
use Modules\Core\Shipping\DTOs\ShipmentRequest; use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint; use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Manifest;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching, SupportsTracking class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching, SupportsTracking
@@ -88,7 +89,7 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
public function cancelShipment(Shipment $shipment): void 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.'); 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() return Shipment::query()
->where('carrier', 'acs') ->where('carrier', 'acs')
->whereNull('manifest_reference') ->whereNull('manifest_id')
->whereNull('cancelled_at') ->whereNull('cancelled_at')
->get(); ->get();
} }
@@ -123,11 +124,18 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
$pickupListNo = (string) $response->valueOutput['PickupList_No']; $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([ $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 public function trackShipment(Shipment $shipment): Collection
@@ -176,6 +184,11 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
{ {
$action = strtolower($action); $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) { return match (true) {
str_contains($action, 'delivery to consignee') => TrackingStatus::Delivered, str_contains($action, 'delivery to consignee') => TrackingStatus::Delivered,
str_contains($action, 'on delivery') => TrackingStatus::OutForDelivery, str_contains($action, 'on delivery') => TrackingStatus::OutForDelivery,
+2 -21
View File
@@ -11,6 +11,7 @@ use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\Concerns\CachesLivePricing; use Modules\Core\Shipping\Concerns\CachesLivePricing;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\SupportsLivePricing; use Modules\Core\Shipping\Contracts\SupportsLivePricing;
use Modules\Core\Shipping\Support\WeightCalculator;
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
{ {
@@ -103,26 +104,6 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
private function totalWeightInKg($cart): float private function totalWeightInKg($cart): float
{ {
$weight = 0.0; return max(WeightCalculator::totalKg($cart->lines), 0.5); // ACS minimum billable weight
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
} }
} }
@@ -21,10 +21,21 @@ use Modules\Core\Shipping\Models\Shipment;
* Box Now delivers to lockers, not addresses. The storefront locker-picker * Box Now delivers to lockers, not addresses. The storefront locker-picker
* is out of scope for this pass — createShipment() requires the chosen * is out of scope for this pass — createShipment() requires the chosen
* locker's Box Now locationId via ShipmentRequest::$destinationLocationId * 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 class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking
{ {
private const COMPARTMENT_SIZES = ['S' => 1, 'M' => 2, 'L' => 3];
public function __construct(private readonly BoxNowClient $client) {} public function __construct(private readonly BoxNowClient $client) {}
public function createShipment(Order $order, ShipmentRequest $request): Shipment 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.'); 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'; $isCod = $request->paymentMode === 'cod';
$response = $this->client->request('post', '/delivery-requests', [ $response = $this->client->request('post', '/delivery-requests', [
@@ -57,31 +72,37 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
'contactName' => trim("{$address->first_name} {$address->last_name}"), 'contactName' => trim("{$address->first_name} {$address->last_name}"),
'locationId' => $destinationLocationId, 'locationId' => $destinationLocationId,
], ],
'items' => [ 'items' => collect($request->boxes)->values()->map(fn (string $size, int $index) => [
[ 'id' => $order->id.'-'.($index + 1),
'id' => (string) $order->id, 'name' => 'Order '.$order->reference.' (box '.($index + 1).')',
'name' => 'Order '.$order->reference, 'value' => '0.00',
'value' => '0.00', 'compartmentSize' => self::COMPARTMENT_SIZES[$size] ?? self::COMPARTMENT_SIZES['S'],
'compartmentSize' => 1, ])->all(),
'weight' => $request->weight ?? 0,
],
],
]); ]);
$parcelId = (string) ($response['parcels'][0]['id'] ?? throw new BoxNowApiException( $parcels = collect($response['parcels'] ?? []);
'Box Now delivery request succeeded but returned no parcel id.',
$response,
));
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, 'order_id' => $order->id,
'carrier' => 'box-now', 'carrier' => 'box-now',
'tracking_reference' => $parcelId, 'tracking_reference' => (string) $parcel['id'],
'meta' => [ 'meta' => [
'delivery_request_id' => $response['id'] ?? null, 'delivery_request_id' => $response['id'] ?? null,
'locker_id' => $destinationLocationId, 'locker_id' => $destinationLocationId,
], ],
]); ]));
return $shipments->first();
} }
public function printLabel(Shipment $shipment): string public function printLabel(Shipment $shipment): string
@@ -136,6 +157,13 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
private function mapState(string $state): TrackingStatus 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) { return match ($state) {
'new' => TrackingStatus::Pending, 'new' => TrackingStatus::Pending,
'in-transit', 'in-depot' => TrackingStatus::InTransit, 'in-transit', 'in-depot' => TrackingStatus::InTransit,
+5 -3
View File
@@ -3,24 +3,26 @@
namespace Modules\Core\Shipping\DTOs; namespace Modules\Core\Shipping\DTOs;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Modules\Core\Shipping\Models\Manifest;
class ManifestResult class ManifestResult
{ {
private function __construct( private function __construct(
public readonly bool $success, public readonly bool $success,
public readonly ?string $reference, public readonly ?string $reference,
public readonly ?Manifest $manifest,
public readonly Collection $includedShipments, public readonly Collection $includedShipments,
public readonly Collection $blockedShipments, public readonly Collection $blockedShipments,
public readonly ?string $reason, 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 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);
} }
} }
+13 -2
View File
@@ -5,16 +5,27 @@ namespace Modules\Core\Shipping\DTOs;
/** /**
* Carrier-agnostic input for CarrierFulfillmentInterface::createShipment(). * Carrier-agnostic input for CarrierFulfillmentInterface::createShipment().
* Every field is optional — a carrier reads only what it needs and ignores * Every field is optional — a carrier reads only what it needs and ignores
* the rest (e.g. destinationLocationId only matters to locker-delivery * the rest (e.g. destinationLocationId/boxes only matter to locker-delivery
* carriers like Box Now; ACS has no use for it). * carriers like Box Now; ACS has no use for either — it ships by weight,
* not by box/compartment size).
*/ */
class ShipmentRequest class ShipmentRequest
{ {
/**
* @param array<int, string> $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 function __construct(
public readonly ?float $weight = null, public readonly ?float $weight = null,
public readonly int $packageCount = 1, public readonly int $packageCount = 1,
public readonly ?string $destinationLocationId = null, public readonly ?string $destinationLocationId = null,
public readonly ?string $paymentMode = null, public readonly ?string $paymentMode = null,
public readonly ?float $amountToCollect = null, public readonly ?float $amountToCollect = null,
public readonly array $boxes = [],
) {} ) {}
} }
+19
View File
@@ -11,6 +11,25 @@ namespace Modules\Core\Shipping\Enums;
enum TrackingStatus: string enum TrackingStatus: string
{ {
case Pending = 'pending'; 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 InTransit = 'in_transit';
case OutForDelivery = 'out_for_delivery'; case OutForDelivery = 'out_for_delivery';
case Delivered = 'delivered'; case Delivered = 'delivered';
@@ -0,0 +1,186 @@
<?php
namespace Modules\Core\Shipping\Extensions;
use Filament\Actions\Action;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Section;
use Illuminate\Support\Facades\URL;
use Lunar\Admin\Support\Extending\ViewPageExtension;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Models\Shipment;
use Throwable;
/**
* Adds a "Shipments" section to the order page's main column — previously
* "Create Shipment" (Modules\Core\Shipping\Extensions\OrderViewExtension)
* had no counterpart anywhere on the order to actually SEE what it
* created (carrier, tracking reference, current status, whether a label's
* been printed or the shipment cancelled). One row per Shipment record —
* a Box Now order with several boxes shows one row per box/parcel (see
* Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService, which
* creates one Shipment row per parcel Box Now returns), not one row per
* "Create Shipment" click.
*
* Uses the extendInfolistSchema hook (main column — alongside shipping
* address, order lines, totals, transactions, timeline), not
* extendInfolistAsideSchema (sidebar) — a shipment list can grow long
* (multi-box Box Now orders, a re-dispatched order after a delivery
* failure) and reads more naturally as a main-column section like
* Transactions, not a compact sidebar entry.
*
* Each shipment renders as two inline-labelled lines (carrier + tracking
* reference, then status + timestamp) rather than a grid of individually
* stacked label/value blocks — Filament's own multi-column grid still
* collapses to one column below its lg breakpoint (1024px), which is
* exactly where the admin's main content area commonly sits with the
* sidebar open, so a 5-6 field grid reads as a wall of repeated labels
* there.
*
* "Print Label" opens Modules\Core\Shipping\Http\Controllers\
* DownloadShipmentLabelController via a short-lived signed URL — the same
* auth model (and Action wiring pattern) Lunar's own vendor PdfDownload
* action uses for order PDFs. Previously the only place that called
* CarrierFulfillmentInterface::printLabel() (Modules\Core\Shipping\
* Filament\Pages\ManagePickupManifests) discarded the returned bytes
* entirely — this is the first place that actually delivers a label to
* staff.
*/
class OrderShipmentsExtension extends ViewPageExtension
{
/**
* Inserted right after Transactions and before Timeline — vendor
* ManageOrder::getInfolistSchema() builds this array as
* [shipping, orderLines, orderTotals, transactions, timeline] (see
* Lunar\Admin\...\Concerns\DisplaysTransactions/DisplaysTimeline), so
* splicing at index 4 lands the new section there regardless of how
* many earlier entries any OTHER extension on this same hook has
* already added/removed — counting from the end (timeline is always
* last) would be equally fragile to some other extension appending
* its own section after timeline, so this anchors on the known
* vendor order instead.
*/
public function extendInfolistSchema(array $schema): array
{
array_splice($schema, 4, 0, [$this->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();
}
}
+181 -105
View File
@@ -3,25 +3,90 @@
namespace Modules\Core\Shipping\Extensions; namespace Modules\Core\Shipping\Extensions;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; 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 Filament\Notifications\Notification;
use Lunar\Admin\Support\Extending\ViewPageExtension; use Lunar\Admin\Support\Extending\ViewPageExtension;
use Lunar\Models\Order; use Lunar\Models\Order;
use Lunar\Shipping\Models\ShippingMethod; use Modules\Core\Order\DTOs\OrderFulfillmentResult;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Order\Services\OrderFulfillmentService;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Shipping\DTOs\ShipmentRequest; 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 class OrderViewExtension extends ViewPageExtension
{ {
public function headerActions(array $actions): array 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->createShipmentAction();
$actions[] = $this->markPickedUpAction(); $actions[] = $this->updateStatusAction();
$actions[] = $this->markPaidAction();
return $actions; return $actions;
} }
@@ -32,122 +97,133 @@ class OrderViewExtension extends ViewPageExtension
->label('Create Shipment') ->label('Create Shipment')
->icon('heroicon-o-truck') ->icon('heroicon-o-truck')
->modalSubmitActionLabel('Create Shipment') ->modalSubmitActionLabel('Create Shipment')
->schema([ ->schema(function (Order $record) {
TextInput::make('weight') $isBoxNow = $this->service()->carrierFor($record) === 'box-now';
->label('Package weight (kg)') $lockerId = $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null;
->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);
if (! $service) { if (! $isBoxNow) {
Notification::make() return [
->title('No carrier fulfillment integration is configured for this order.') TextInput::make('weight')
->danger() ->label('Total weight (kg)')
->send(); ->numeric()
->minValue(0)
$action->halt(); ->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."),
return; 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( return [
weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null, TextInput::make('destination_location_id')
destinationLocationId: $data['destination_location_id'] ?? null, ->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 { $this->notify($result);
$service->createShipment($record, $request);
} catch (Throwable $e) {
report($e);
Notification::make()
->title('Failed to create shipment: '.$e->getMessage())
->danger()
->send();
if (! $result->success) {
$action->halt(); $action->halt();
return;
} }
Notification::make()
->title('Shipment created.')
->success()
->send();
}) })
->visible(fn (Order $record) => $record->status === 'ready-for-dispatch' ->visible(fn (Order $record) => $this->service()->canCreateShipment($record));
&& ! $record->isStorePickupOrder()
&& $record->shipments()->exists() === false
&& $this->resolveFulfillmentService($record) !== null);
} }
/** private function updateStatusAction(): Action
* 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
{ {
return Action::make('mark_picked_up') return Action::make('update_status')
->label('Mark Picked Up') ->label('Update Status')
->icon('heroicon-o-check-circle') ->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') ->color('success')
->requiresConfirmation() ->requiresConfirmation()
->modalDescription('Confirms the customer has collected this order in store.') ->modalDescription('Confirms payment for this order has been received outside the system.')
->action(function (Order $record) { ->visible(fn (Order $record) => app(OrderStatusFlow::class)->canMarkPaid($record))
$record->update(['status' => 'completed']); ->action(fn (Order $record) => $this->notify($this->service()->markPaid($record)));
Notification::make()
->title('Order marked as picked up.')
->success()
->send();
})
->visible(fn (Order $record) => $record->status === 'ready-for-pickup'
&& $record->isStorePickupOrder());
} }
private function resolveCarrier(Order $record): ?string private function service(): OrderFulfillmentService
{ {
$code = $record->shippingAddress?->shipping_option; return app(OrderFulfillmentService::class);
if (! $code) {
return null;
}
return ShippingMethod::where('code', $code)->value('driver');
} }
private function resolveFulfillmentService(Order $record): ?CarrierFulfillmentInterface private function notify(OrderFulfillmentResult $result): void
{ {
$carrier = $this->resolveCarrier($record); Notification::make()
->title($result->message)
if (! $carrier) { ->color($result->success ? 'success' : 'danger')
return null; ->send();
}
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
} }
} }
@@ -1,123 +0,0 @@
<?php
namespace Modules\Core\Shipping\Filament\Pages;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Models\Shipment;
class ManagePickupManifests extends Page implements HasTable
{
use InteractsWithTable;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-truck';
protected static ?string $navigationLabel = 'Pickup Manifests';
/**
* Without an explicit group, this page had no navigation group at all —
* Filament's Panel::getUrl() falls back to "first item in the first
* navigation group" when no homeUrl is set (neither Lunar nor CorePlugin
* sets one), and an ungrouped page sorted ahead of every one of Lunar's
* own grouped resources (Sales, Catalog, etc.), making this page the
* panel's de facto home instead of the real Dashboard. Grouping it under
* Sales — alongside CartResource, OrderResource — fixes that by letting
* a legitimate item sort first again. Sorted last within the group
* deliberately (a high explicit navigationSort — Lunar's own
* OrderResource uses 1) so this page never competes to be first even as
* more Sales-group items are added later.
*/
protected static string | \UnitEnum | null $navigationGroup = 'Sales';
protected static ?int $navigationSort = 100;
protected string $view = 'core::shipping.filament.pages.manage-pickup-manifests';
public function table(Table $table): Table
{
return $table
->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]);
}
}
@@ -0,0 +1,66 @@
<?php
namespace Modules\Core\Shipping\Filament\Resources;
use Filament\Actions\ViewAction;
use Filament\Resources\Resource;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages\ListManifests;
use Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages\ViewManifest;
use Modules\Core\Shipping\Filament\Resources\ManifestResource\RelationManagers\ShipmentsRelationManager;
use Modules\Core\Shipping\Models\Manifest;
/**
* Issued manifests — Modules\Core\Shipping\Models\Manifest is the only
* record of "which shipments were on manifest X, and when" this codebase
* keeps; ACS's own ACS_Issue_Pickup_List call returns nothing beyond a
* reference number, so there is nothing to re-fetch from the carrier
* later (see that model's own docblock). Complements
* Modules\Core\Shipping\Filament\Resources\ShipmentResource, which only
* ever shows shipments NOT YET on a manifest.
*/
class ManifestResource extends Resource
{
protected static ?string $model = Manifest::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-clipboard-document-list';
protected static string | \UnitEnum | null $navigationGroup = 'Sales';
protected static ?string $navigationLabel = 'Issued Manifests';
protected static ?string $modelLabel = 'Manifest';
protected static ?int $navigationSort = 101;
public static function table(Table $table): Table
{
return $table
->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}'),
];
}
}
@@ -0,0 +1,28 @@
<?php
namespace Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages;
use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\Tabs\Tab;
use Illuminate\Database\Eloquent\Builder;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Filament\Resources\ManifestResource;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
class ListManifests extends ListRecords
{
protected static string $resource = ManifestResource::class;
public function getTabs(): array
{
$carriers = collect(Shipping::getSupportedDrivers())
->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();
}
}
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Pages\ViewRecord;
use Filament\Schemas\Schema;
use Modules\Core\Shipping\Filament\Resources\ManifestResource;
/**
* Relation managers (ShipmentsRelationManager) are registered on
* ManifestResource::getRelations() — the actual wiring point
* (Filament\Resources\Pages\Concerns\HasRelationManagers::
* getAllRelationManagers() reads from Resource::getRelations(), not from
* an override here). An earlier version of this page overrode
* getRelationManagers() directly, bypassing that trait's own
* canViewForRecord()/caching logic and causing a broken Livewire
* component mount (surfaced as a 419/redirect loop on this exact page).
*/
class ViewManifest extends ViewRecord
{
protected static string $resource = ManifestResource::class;
public function infolist(Schema $schema): Schema
{
return $schema->components([
TextEntry::make('carrier')->badge(),
TextEntry::make('reference')->label('Reference')->copyable(),
TextEntry::make('shipment_count')->label('Shipments'),
TextEntry::make('issued_at')->label('Issued')->dateTime(),
]);
}
}
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Shipping\Filament\Resources\ManifestResource\RelationManagers;
use Filament\Actions\Action;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
use Modules\Core\Shipping\Models\Shipment;
/**
* The shipments a given Manifest actually included — read-only (a
* shipment's manifest membership is set once, at issueManifest() time,
* never edited here). Reuses ShipmentResource::printShipment() for the
* "Print" action rather than duplicating its try/catch-and-notify
* handling.
*/
class ShipmentsRelationManager extends RelationManager
{
protected static string $relationship = 'shipments';
public function table(Table $table): Table
{
return $table
->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)),
]);
}
}
@@ -0,0 +1,153 @@
<?php
namespace Modules\Core\Shipping\Filament\Resources;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource\Pages\ListShipments;
use Modules\Core\Shipping\Models\Shipment;
use Throwable;
/**
* Replaces the standalone Modules\Core\Shipping\Filament\Pages\
* ManagePickupManifests page — a bare Page has no access to Filament's
* resource-level pill-tab UI (Filament\Resources\Concerns\HasTabs is
* scoped to ListRecords), so carrier-by-carrier separation
* (ListShipments::getTabs(), one tab per SupportsManifestBatching
* implementer) needed a real Resource to attach to.
*
* Shows only shipments NOT yet on an issued manifest — see
* Modules\Core\Shipping\Filament\Resources\ManifestResource for
* shipments that already are.
*/
class ShipmentResource extends Resource
{
protected static ?string $model = Shipment::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-truck';
protected static string | \UnitEnum | null $navigationGroup = 'Sales';
protected static ?string $navigationLabel = 'Pending Vouchers';
protected static ?string $modelLabel = 'Shipment';
protected static ?int $navigationSort = 100;
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->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('/'),
];
}
}
@@ -0,0 +1,36 @@
<?php
namespace Modules\Core\Shipping\Filament\Resources\ShipmentResource\Pages;
use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\Tabs\Tab;
use Illuminate\Database\Eloquent\Builder;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
/**
* One tab per carrier that actually implements SupportsManifestBatching
* (ACS today) — a carrier with no manifest concept at all (Box Now,
* which books courier pickup at shipment-creation time, no separate
* batching step) never gets a tab here, since there is nothing to batch.
* Adding a new carrier (e.g. Speedex) that also implements the contract
* needs zero changes to this page — the tab list is derived from
* Shipping::getSupportedDrivers(), not hardcoded.
*/
class ListShipments extends ListRecords
{
protected static string $resource = ShipmentResource::class;
public function getTabs(): array
{
$carriers = collect(Shipping::getSupportedDrivers())
->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();
}
}
@@ -0,0 +1,55 @@
<?php
namespace Modules\Core\Shipping\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Models\Shipment;
/**
* Streams a carrier's raw label bytes (CarrierFulfillmentInterface::
* printLabel() — already whatever file format the carrier's own API
* returns, e.g. a PDF for both ACS and Box Now today) straight to the
* browser. Only reachable via a short-lived signed URL (see
* Modules\Core\Shipping\Extensions\OrderShipmentsExtension's "Print
* Label" action) — the same auth model Lunar's own vendor
* DownloadPdfController uses for order PDFs (a valid signature IS the
* auth check; there is no separate staff-session check here, matching
* that precedent), so the link only works for the few minutes it's
* actually open in a browser tab.
*
* Looks the Shipment up manually from a plain {shipment} id rather than
* relying on implicit route-model-binding — this route is registered via
* loadRoutesFrom() with no middleware group (see
* Modules\Core\Providers\ShippingServiceProvider::boot()), so
* SubstituteBindings never runs and a type-hinted Shipment parameter
* silently resolves to an empty, non-existent model instead of 404ing.
*
* Sets label_printed_at as a side effect of a successful stream — this is
* the first place in the codebase that actually delivers a label's bytes
* to a human; the existing Modules\Core\Shipping\Filament\Pages\
* ManagePickupManifests "Print" action calls printLabel() too, but only
* to mark the timestamp, discarding the returned bytes entirely (no
* download route existed until this one).
*/
class DownloadShipmentLabelController extends Controller
{
public function __invoke(Request $request, int $shipment)
{
if (! $request->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"',
]);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\Core\Shipping\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* A carrier pickup-list/manifest as WE recorded it at the moment it was
* issued (Modules\Core\Shipping\Contracts\SupportsManifestBatching::
* issueManifest()) — the carrier's own API (ACS's ACS_Issue_Pickup_List
* included) typically returns nothing beyond a reference number, so this
* table is the only place "which shipments were on manifest X, and when"
* is ever recorded; it cannot be re-derived from the carrier later.
*/
class Manifest extends Model
{
protected $guarded = [];
protected $casts = [
'issued_at' => 'datetime',
];
public function shipments(): HasMany
{
return $this->hasMany(Shipment::class);
}
}
+5
View File
@@ -23,6 +23,11 @@ class Shipment extends Model
return $this->belongsTo(Order::class); return $this->belongsTo(Order::class);
} }
public function manifest(): BelongsTo
{
return $this->belongsTo(Manifest::class);
}
public function shipmentInfo(): HasMany public function shipmentInfo(): HasMany
{ {
return $this->hasMany(ShipmentInfo::class); return $this->hasMany(ShipmentInfo::class);
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace Modules\Core\Shipping\Support;
use Illuminate\Support\Collection;
/**
* Sums ProductVariant::weight_value/weight_unit across a collection of
* Cart/Order lines into a single kg figure — the same conversion table
* Modules\Core\Shipping\Carriers\Acs\AcsRateDriver::totalWeightInKg()
* already used privately for live rate quoting, now shared so
* Modules\Core\Shipping\Extensions\OrderViewExtension's "Create Shipment"
* weight default can compute the identical figure for an already-placed
* Order instead of duplicating the unit table.
*/
class WeightCalculator
{
/**
* @param Collection $lines Cart::$lines or Order::$lines, each with
* its purchasable relation loaded (or loadable — ->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;
}
}
+7
View File
@@ -0,0 +1,7 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Core\Shipping\Http\Controllers\DownloadShipmentLabelController;
Route::get('shipments/{shipment}/label', DownloadShipmentLabelController::class)
->name('shipments.label');