diff --git a/composer.json b/composer.json
index 4acfd07..7955955 100644
--- a/composer.json
+++ b/composer.json
@@ -38,7 +38,8 @@
"Modules\\Core\\Providers\\LocalizationServiceProvider",
"Modules\\Core\\Providers\\CatalogServiceProvider",
"Modules\\Core\\Providers\\CartServiceProvider",
- "Modules\\Core\\Providers\\ReviewServiceProvider"
+ "Modules\\Core\\Providers\\ReviewServiceProvider",
+ "Modules\\Core\\Providers\\ShippingServiceProvider"
]
}
},
diff --git a/config/shippingCarriers/acs.php b/config/shippingCarriers/acs.php
new file mode 100644
index 0000000..cea2928
--- /dev/null
+++ b/config/shippingCarriers/acs.php
@@ -0,0 +1,50 @@
+ env('ACS_BASE_URL', 'https://webservices.acscourier.net/ACSRestServices/api/ACSAutoRest'),
+
+ 'api_key' => env('ACS_API_KEY'),
+
+ 'company_id' => env('ACS_COMPANY_ID'),
+ 'company_password' => env('ACS_COMPANY_PASSWORD'),
+ 'user_id' => env('ACS_USER_ID'),
+ 'user_password' => env('ACS_USER_PASSWORD'),
+
+ 'billing_code' => env('ACS_BILLING_CODE'),
+
+ 'sender' => [
+ 'name' => env('ACS_SENDER_NAME'),
+ 'address' => env('ACS_SENDER_ADDRESS'),
+ 'zip_code' => env('ACS_SENDER_ZIP'),
+ 'phone' => env('ACS_SENDER_PHONE'),
+ ],
+
+ 'timeout' => env('ACS_HTTP_TIMEOUT', 10),
+
+];
diff --git a/config/shippingCarriers/boxnow.php b/config/shippingCarriers/boxnow.php
new file mode 100644
index 0000000..672b95f
--- /dev/null
+++ b/config/shippingCarriers/boxnow.php
@@ -0,0 +1,47 @@
+ env('BOXNOW_BASE_URL', 'https://api-production.boxnow.gr/api/v1'),
+ 'location_api_url' => env('BOXNOW_LOCATION_API_URL', 'https://locationapi-production.boxnow.gr/api/v1'),
+
+ 'client_id' => env('BOXNOW_CLIENT_ID'),
+ 'client_secret' => env('BOXNOW_CLIENT_SECRET'),
+
+ 'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'),
+
+ 'sender' => [
+ 'name' => env('BOXNOW_SENDER_NAME'),
+ 'email' => env('BOXNOW_SENDER_EMAIL'),
+ 'phone' => env('BOXNOW_SENDER_PHONE'),
+ ],
+
+ 'timeout' => env('BOXNOW_HTTP_TIMEOUT', 10),
+
+];
diff --git a/database/migrations/2026_07_16_000001_create_shipments_table.php b/database/migrations/2026_07_16_000001_create_shipments_table.php
new file mode 100644
index 0000000..ffccb42
--- /dev/null
+++ b/database/migrations/2026_07_16_000001_create_shipments_table.php
@@ -0,0 +1,29 @@
+id();
+ $table->foreignId('order_id')->constrained(config('lunar.database.table_prefix').'orders');
+ $table->string('carrier');
+ $table->string('tracking_reference')->unique();
+ $table->string('parent_reference')->nullable();
+ $table->timestamp('label_printed_at')->nullable();
+ $table->string('manifest_reference')->nullable();
+ $table->timestamp('cancelled_at')->nullable();
+ $table->json('meta')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('shipments');
+ }
+};
diff --git a/database/migrations/2026_07_21_000001_create_shipment_info_table.php b/database/migrations/2026_07_21_000001_create_shipment_info_table.php
new file mode 100644
index 0000000..fbfe887
--- /dev/null
+++ b/database/migrations/2026_07_21_000001_create_shipment_info_table.php
@@ -0,0 +1,28 @@
+id();
+ $table->foreignId('shipment_id')->constrained('shipments')->cascadeOnDelete();
+ $table->string('status');
+ $table->string('carrier_status')->nullable();
+ $table->text('message')->nullable();
+ $table->string('location')->nullable();
+ $table->timestamp('occurred_at');
+ $table->json('meta')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('shipment_info');
+ }
+};
diff --git a/resources/views/shipping/filament/pages/manage-pickup-manifests.blade.php b/resources/views/shipping/filament/pages/manage-pickup-manifests.blade.php
new file mode 100644
index 0000000..ce096a2
--- /dev/null
+++ b/resources/views/shipping/filament/pages/manage-pickup-manifests.blade.php
@@ -0,0 +1,3 @@
+
+ {{ $this->table }}
+
diff --git a/src/CorePlugin.php b/src/CorePlugin.php
index e18c627..e5c0920 100644
--- a/src/CorePlugin.php
+++ b/src/CorePlugin.php
@@ -8,11 +8,14 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Mail;
use Lunar\Admin\Filament\Resources\ProductOptionResource;
use Lunar\Admin\Filament\Resources\ProductOptionResource\RelationManagers\ValuesRelationManager;
+use Lunar\Admin\Filament\Resources\OrderResource;
use Lunar\Admin\Filament\Resources\ProductResource;
use Lunar\Admin\Filament\Resources\StaffResource;
use Lunar\Admin\Models\Staff as LunarStaff;
use Lunar\Admin\Support\Facades\LunarPanel;
use Lunar\Models\Product;
+use Lunar\Shipping\Filament\Resources\ShippingMethodResource;
+use Lunar\Shipping\Filament\Resources\ShippingMethodResource\Pages\ListShippingMethod;
use Lunar\Shipping\ShippingPlugin;
use Modules\Core\Auth\Extensions\StaffResourceExtension;
use Modules\Core\Auth\Filament\Pages\Login;
@@ -23,6 +26,10 @@ use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
use Modules\Core\Review\Models\ProductReview;
+use Modules\Core\Shipping\Extensions\OrderViewExtension;
+use Modules\Core\Shipping\Extensions\ShippingMethodListExtension;
+use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension;
+use Modules\Core\Shipping\Filament\Pages\ManagePickupManifests;
class CorePlugin implements Plugin
{
@@ -42,13 +49,18 @@ class CorePlugin implements Plugin
LanguageLineResource::class,
CartResource::class,
])
- ->plugin(ShippingPlugin::make());
+ ->plugin(ShippingPlugin::make())
+ ->plugin(ShippingPlugin::make())
+ ->pages([ManagePickupManifests::class]);
LunarPanel::extensions([
StaffResource::class => StaffResourceExtension::class,
ProductResource::class => ProductResourceExtension::class,
ProductOptionResource::class => ProductOptionResourceExtension::class,
ValuesRelationManager::class => ValuesRelationManagerExtension::class,
+ ShippingMethodResource::class => ShippingMethodResourceExtension::class,
+ ListShippingMethod::class => ShippingMethodListExtension::class,
+ OrderResource\Pages\ManageOrder::class => OrderViewExtension::class,
]);
Product::macro('reviews', function (): HasMany {
diff --git a/src/Providers/ShippingServiceProvider.php b/src/Providers/ShippingServiceProvider.php
new file mode 100644
index 0000000..c93b899
--- /dev/null
+++ b/src/Providers/ShippingServiceProvider.php
@@ -0,0 +1,102 @@
+mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/acs.php', 'acs');
+ $this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/boxnow.php', 'boxnow');
+
+ $this->app->singleton(AcsClient::class, fn () => new AcsClient(config('acs')));
+ $this->app->singleton(BoxNowClient::class, fn () => new BoxNowClient(config('boxnow')));
+
+ $this->app->bind(CarrierFulfillmentInterface::class, function ($app, array $params) {
+ return match ($params['carrier'] ?? null) {
+ 'acs' => $app->make(AcsFulfillmentService::class),
+ 'box-now' => $app->make(BoxNowFulfillmentService::class),
+ default => null,
+ };
+ });
+
+ // The vendor Rates page has no extension hook, so we swap it for
+ // our subclass everywhere. Route::get($path, VendorClass::class)
+ // instantiates the vendor class directly via the container for the
+ // initial full-page load (bypassing Livewire's component registry
+ // entirely), so this container bind is required in addition to the
+ // Livewire::component() re-registration below — the bind covers
+ // first load, the Livewire registration covers every AJAX
+ // round-trip (form submits, table interactions) afterwards.
+ $this->app->bind(VendorManageShippingRates::class, ManageShippingRates::class);
+ }
+
+ public function boot(): void
+ {
+ $this->publishes([
+ __DIR__ . '/../../config/shippingCarriers/acs.php' => config_path('shippingCarriers/acs.php'),
+ __DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'),
+ ], 'core-config');
+
+ Order::resolveRelationUsing('shipments', function ($order) {
+ return $order->hasMany(Shipment::class);
+ });
+
+ // Deferred: the Shipping facade resolves a binding registered in
+ // lunarphp/table-rate-shipping's own ShippingServiceProvider::boot(),
+ // and provider boot order between packages isn't guaranteed.
+ $this->app->booted(function () {
+ Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class));
+ Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class));
+
+ $this->app->make(ConsoleSchedule::class)
+ ->job(new WarmAcsAreaCacheJob)
+ ->dailyAt('06:00')
+ ->when(fn () => ShippingMethod::where('driver', 'acs')->exists());
+
+ $this->app->make(ConsoleSchedule::class)
+ ->job(new PollShipmentTrackingJob)
+ ->everyThirtyMinutes();
+
+ $this->overrideRatesPageLivewireComponent();
+ });
+ }
+
+ /**
+ * The vendor Rates page has no extension hook, so we swap it for our
+ * subclass (see Shipping/Filament/Pages/ManageShippingRates). Filament
+ * already registered the vendor class as a Livewire component under a
+ * name derived from its class string (see
+ * Panel::registerLivewireComponents()); Livewire's own registry is a
+ * simple last-write-wins name => class map, so re-registering the same
+ * derived name against our subclass here overrides it — keeping the
+ * route, sub-navigation, and every Livewire round-trip (including form
+ * submissions) pointed at one consistent component identity.
+ */
+ private function overrideRatesPageLivewireComponent(): void
+ {
+ $name = $this->app->make(ComponentRegistry::class)->getName(VendorManageShippingRates::class);
+
+ Livewire::component($name, ManageShippingRates::class);
+ }
+}
diff --git a/src/Shipping/Carriers/Acs/AcsArea.php b/src/Shipping/Carriers/Acs/AcsArea.php
new file mode 100644
index 0000000..a4fe1b5
--- /dev/null
+++ b/src/Shipping/Carriers/Acs/AcsArea.php
@@ -0,0 +1,11 @@
+ $this->config['api_key'],
+ ])
+ ->timeout($this->config['timeout'])
+ ->post($this->config['base_url'], [
+ 'ACSAlias' => $alias,
+ 'ACSInputParameters' => array_merge($this->credentialParams(), $parameters),
+ ]);
+
+ return AcsResponse::fromHttpResponse($response);
+ }
+
+ private function credentialParams(): array
+ {
+ return [
+ 'Company_ID' => $this->config['company_id'],
+ 'Company_Password' => $this->config['company_password'],
+ 'User_ID' => $this->config['user_id'],
+ 'User_Password' => $this->config['user_password'],
+ ];
+ }
+}
diff --git a/src/Shipping/Carriers/Acs/AcsFulfillmentService.php b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php
new file mode 100644
index 0000000..a40b9fb
--- /dev/null
+++ b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php
@@ -0,0 +1,202 @@
+shippingAddress;
+ $destination = $this->areaResolver->resolve($address->postcode);
+ $weight = $request->weight ?? 0.5;
+
+ $params = [
+ 'Pickup_Date' => now()->toDateString(),
+ 'Sender' => config('acs.sender.name'),
+ 'Recipient_Name' => trim("{$address->first_name} {$address->last_name}"),
+ 'Recipient_Address' => $address->line_one,
+ 'Recipient_Zipcode' => $address->postcode,
+ 'Recipient_Region' => $address->city,
+ 'Recipient_Phone' => $address->contact_phone,
+ 'Recipient_Country' => 'GR',
+ 'Acs_Station_Branch_Destination' => $destination->branchId,
+ 'Billing_Code' => config('acs.billing_code'),
+ 'Charge_Type' => 2,
+ 'Item_Quantity' => $request->packageCount,
+ 'Weight' => $weight,
+ ];
+
+ if ($request->paymentMode === 'cod') {
+ $params['Cod_Ammount'] = $request->amountToCollect ?? $order->total->decimal;
+ $params['Cod_Payment_Way'] = 0; // cash
+ $params['Acs_Delivery_Products'] = 'COD';
+ }
+
+ $response = $this->client->call('ACS_Create_Voucher', $params)->throwIfError();
+
+ $voucherNo = (string) $response->valueOutput['Voucher_No'];
+
+ $shipment = Shipment::create([
+ 'order_id' => $order->id,
+ 'carrier' => 'acs',
+ 'tracking_reference' => $voucherNo,
+ 'meta' => [
+ 'station_destination' => $destination->stationId,
+ 'weight' => $weight,
+ 'pickup_date' => now()->toDateString(),
+ ],
+ ]);
+
+ if ($request->packageCount > 1) {
+ $this->persistMultipartVouchers($shipment);
+ }
+
+ return $shipment;
+ }
+
+ public function printLabel(Shipment $shipment): string
+ {
+ $response = $this->client->call('ACS_Print_Voucher', [
+ 'Voucher_No' => $shipment->tracking_reference,
+ 'Print_Type' => 2,
+ 'Start_Position' => 1,
+ ])->throwIfError();
+
+ $shipment->update(['label_printed_at' => now()]);
+
+ return $response->valueOutput[$shipment->tracking_reference] ?? '';
+ }
+
+ public function cancelShipment(Shipment $shipment): void
+ {
+ if ($shipment->manifest_reference) {
+ throw new \RuntimeException('Cannot cancel a shipment already included in an issued manifest.');
+ }
+
+ $this->client->call('ACS_Delete_Voucher', [
+ 'Voucher_No' => $shipment->tracking_reference,
+ ])->throwIfError();
+
+ $shipment->update(['cancelled_at' => now()]);
+ }
+
+ public function pendingForManifest(): Collection
+ {
+ return Shipment::query()
+ ->where('carrier', 'acs')
+ ->whereNull('manifest_reference')
+ ->whereNull('cancelled_at')
+ ->get();
+ }
+
+ public function issueManifest(Collection $shipments): ManifestResult
+ {
+ $unprinted = $shipments->whereNull('label_printed_at');
+
+ if ($unprinted->isNotEmpty()) {
+ return ManifestResult::blocked($unprinted, 'unprinted');
+ }
+
+ $response = $this->client->call('ACS_Issue_Pickup_List', [
+ 'Pickup_Date' => now()->toDateString(),
+ 'MyData' => null,
+ ])->throwIfError();
+
+ $pickupListNo = (string) $response->valueOutput['PickupList_No'];
+
+ $shipments->each(fn (Shipment $shipment) => $shipment->update([
+ 'manifest_reference' => $pickupListNo,
+ ]));
+
+ return ManifestResult::success($pickupListNo, $shipments);
+ }
+
+ public function trackShipment(Shipment $shipment): Collection
+ {
+ $response = $this->client->call('ACS_TrackingDetails', [
+ 'Voucher_No' => $shipment->tracking_reference,
+ ])->throwIfError();
+
+ $rows = $response->tableOutput['Table_Data'] ?? [];
+
+ // ACS's per-checkpoint data (checkpoint_action) is free text with no
+ // status code, so the final checkpoint's status is corroborated
+ // against the structured summary call rather than guessed from text.
+ $isDelivered = $this->isDelivered($shipment);
+
+ return collect($rows)->values()->map(function (array $row, int $index) use ($rows, $isDelivered) {
+ $isLast = $index === count($rows) - 1;
+
+ return new TrackingCheckpoint(
+ status: $isLast && $isDelivered
+ ? TrackingStatus::Delivered
+ : $this->guessStatusFromAction($row['checkpoint_action'] ?? ''),
+ carrierStatus: $row['checkpoint_action'] ?? null,
+ message: $row['checkpoint_action'] ?? null,
+ location: $row['checkpoint_location'] ?? null,
+ occurredAt: Carbon::parse($row['checkpoint_date_time']),
+ meta: $row,
+ );
+ });
+ }
+
+ private function isDelivered(Shipment $shipment): bool
+ {
+ try {
+ $response = $this->client->call('ACS_Trackingsummary', [
+ 'Voucher_No' => $shipment->tracking_reference,
+ ])->throwIfError();
+ } catch (AcsApiException) {
+ return false;
+ }
+
+ return (int) ($response->valueOutput['shipment_status'] ?? 0) === 4;
+ }
+
+ private function guessStatusFromAction(string $action): TrackingStatus
+ {
+ $action = strtolower($action);
+
+ return match (true) {
+ str_contains($action, 'delivery to consignee') => TrackingStatus::Delivered,
+ str_contains($action, 'on delivery') => TrackingStatus::OutForDelivery,
+ str_contains($action, 'arrival') || str_contains($action, 'departure') => TrackingStatus::InTransit,
+ default => TrackingStatus::Pending,
+ };
+ }
+
+ private function persistMultipartVouchers(Shipment $mainShipment): void
+ {
+ $response = $this->client->call('ACS_Get_Multipart_Vouchers', [
+ 'Main_Voucher_No' => $mainShipment->tracking_reference,
+ ])->throwIfError();
+
+ foreach ($response->tableOutput['Table_Data'] ?? [] as $row) {
+ Shipment::create([
+ 'order_id' => $mainShipment->order_id,
+ 'carrier' => 'acs',
+ 'tracking_reference' => $row['MultiPart_Voucher_No'],
+ 'parent_reference' => $mainShipment->tracking_reference,
+ 'meta' => $mainShipment->meta?->toArray() ?? [],
+ ]);
+ }
+ }
+}
diff --git a/src/Shipping/Carriers/Acs/AcsRateDriver.php b/src/Shipping/Carriers/Acs/AcsRateDriver.php
new file mode 100644
index 0000000..6c8af72
--- /dev/null
+++ b/src/Shipping/Carriers/Acs/AcsRateDriver.php
@@ -0,0 +1,116 @@
+shippingRate;
+ $shippingMethod = $shippingRate->shippingMethod;
+ $cart = $shippingOptionRequest->cart;
+
+ if (($shippingMethod->data['charge_by'] ?? 'cart_total') !== 'live') {
+ return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart);
+ }
+
+ $postcode = $cart->shippingAddress?->postcode;
+
+ if (! $postcode) {
+ return null;
+ }
+
+ return $this->resolveLivePrice($shippingRate, $shippingMethod, $cart, $postcode);
+ }
+
+ private function resolveLivePrice(ShippingRate $shippingRate, $shippingMethod, $cart, string $postcode): ?ShippingOption
+ {
+ try {
+ $destination = $this->areaResolver->resolve($postcode);
+
+ $response = $this->client->call('ACS_Price_Calculation', [
+ 'Billing_Code' => config('acs.billing_code'),
+ 'Acs_Station_Destination' => $destination->stationId,
+ 'Weight' => $this->totalWeightInKg($cart),
+ 'Pickup_Date' => now()->toDateString(),
+ 'Charge_Type' => 2,
+ ])->throwIfError();
+ } catch (AcsApiException $e) {
+ report($e);
+
+ return null;
+ }
+
+ $amount = (int) round(($response->valueOutput['Total_Ammount'] ?? 0) * 100);
+
+ return new ShippingOption(
+ name: $shippingMethod->name ?: $this->name(),
+ description: $shippingMethod->description ?: $this->description(),
+ identifier: $shippingRate->getIdentifier(),
+ price: new Price($amount, $cart->currency, 1),
+ taxClass: $shippingRate->getTaxClass(),
+ taxReference: $shippingRate->getTaxReference(),
+ meta: ['acs_station_destination' => $destination->stationId],
+ );
+ }
+
+ public function on(ShippingRate $shippingRate): self
+ {
+ $this->shippingRate = $shippingRate;
+
+ return $this;
+ }
+
+ private function totalWeightInKg($cart): float
+ {
+ $weight = 0.0;
+
+ foreach ($cart->lines->load('purchasable') as $line) {
+ $variant = $line->purchasable;
+
+ if (! $variant || ! $variant->weight_value) {
+ continue;
+ }
+
+ $unit = $variant->weight_unit ?? 'kg';
+ $value = (float) $variant->weight_value;
+
+ $weight += match ($unit) {
+ 'g' => $value / 1000,
+ 'lb' => $value * 0.45359237,
+ 'oz' => $value * 0.0283495231,
+ default => $value, // kg
+ } * $line->quantity;
+ }
+
+ return max($weight, 0.5); // ACS minimum billable weight
+ }
+}
diff --git a/src/Shipping/Carriers/Acs/AcsResponse.php b/src/Shipping/Carriers/Acs/AcsResponse.php
new file mode 100644
index 0000000..3e1255d
--- /dev/null
+++ b/src/Shipping/Carriers/Acs/AcsResponse.php
@@ -0,0 +1,43 @@
+json() ?? [];
+
+ // ACS's own JSON key is misspelled ("Responce") — preserved here verbatim.
+ $output = $body['ACSOutputResponce'] ?? [];
+
+ return new self(
+ hasError: (bool) ($body['ACSExecution_HasError'] ?? ! $response->successful()),
+ errorMessage: $body['ACSExecutionErrorMessage'] ?? null,
+ valueOutput: $output['ACSValueOutput'][0] ?? [],
+ tableOutput: $output['ACSTableOutput'] ?? [],
+ );
+ }
+
+ public function throwIfError(): self
+ {
+ if ($this->hasError) {
+ throw new AcsApiException(
+ $this->errorMessage ?? ($this->valueOutput['Error_Message'] ?? 'Unknown ACS API error'),
+ $this->tableOutput,
+ );
+ }
+
+ return $this;
+ }
+}
diff --git a/src/Shipping/Carriers/Acs/AreaResolver.php b/src/Shipping/Carriers/Acs/AreaResolver.php
new file mode 100644
index 0000000..a87398f
--- /dev/null
+++ b/src/Shipping/Carriers/Acs/AreaResolver.php
@@ -0,0 +1,81 @@
+toArea($areas[$postcode]);
+ }
+
+ return $this->toArea($this->fetch($postcode));
+ }
+
+ /**
+ * Fetch and cache the full country's postcode-to-station map in one call.
+ */
+ public function warmAll(): void
+ {
+ $areas = [];
+
+ foreach ($this->fetchAll() as $row) {
+ $areas[$row['Zip_Code']] = $row;
+ }
+
+ Cache::forever(self::CACHE_KEY, $areas);
+ }
+
+ private function fetch(string $postcode): array
+ {
+ $response = $this->client->call('ACS_Area_Find_By_Zip_Code', [
+ 'Zip_Code' => $postcode,
+ 'Show_Only_Inaccessible_Areas' => 0,
+ 'Country' => 'GR',
+ ])->throwIfError();
+
+ $area = $response->tableOutput['Table_Data'][0] ?? null;
+
+ if (! $area) {
+ throw new AcsApiException("No ACS area found for postcode {$postcode}");
+ }
+
+ return $area;
+ }
+
+ private function fetchAll(): array
+ {
+ $response = $this->client->call('ACS_Area_Find_By_Zip_Code', [
+ 'Zip_Code' => null,
+ 'Show_Only_Inaccessible_Areas' => 0,
+ 'Country' => 'GR',
+ ])->throwIfError();
+
+ return $response->tableOutput['Table_Data'] ?? [];
+ }
+
+ private function toArea(array $row): AcsArea
+ {
+ return new AcsArea(
+ stationId: $row['Station_ID'],
+ branchId: (int) $row['Branch_ID'],
+ );
+ }
+}
diff --git a/src/Shipping/Carriers/Acs/Exceptions/AcsApiException.php b/src/Shipping/Carriers/Acs/Exceptions/AcsApiException.php
new file mode 100644
index 0000000..75ed50c
--- /dev/null
+++ b/src/Shipping/Carriers/Acs/Exceptions/AcsApiException.php
@@ -0,0 +1,13 @@
+warmAll();
+ }
+}
diff --git a/src/Shipping/Carriers/BoxNow/BoxNowClient.php b/src/Shipping/Carriers/BoxNow/BoxNowClient.php
new file mode 100644
index 0000000..dd8bbea
--- /dev/null
+++ b/src/Shipping/Carriers/BoxNow/BoxNowClient.php
@@ -0,0 +1,97 @@
+token())
+ ->timeout($this->config['timeout'])
+ ->{$method}("{$this->config['base_url']}{$path}", $payload);
+
+ if ($response->status() === 401) {
+ // Token expired early / was revoked — refresh once and retry.
+ Cache::forget(self::TOKEN_CACHE_KEY);
+
+ $response = Http::withToken($this->token())
+ ->timeout($this->config['timeout'])
+ ->{$method}("{$this->config['base_url']}{$path}", $payload);
+ }
+
+ if ($response->failed()) {
+ throw new BoxNowApiException(
+ "Box Now API error ({$response->status()}) on {$method} {$path}",
+ $response->json() ?? [],
+ );
+ }
+
+ return $response->json() ?? [];
+ }
+
+ /**
+ * The origins/destinations lookups are served faster from a separate
+ * location API host, per Box Now's own documentation.
+ */
+ public function locationRequest(string $path, array $query = []): array
+ {
+ $response = Http::withToken($this->token())
+ ->timeout($this->config['timeout'])
+ ->get("{$this->config['location_api_url']}{$path}", $query);
+
+ if ($response->failed()) {
+ throw new BoxNowApiException(
+ "Box Now location API error ({$response->status()}) on GET {$path}",
+ $response->json() ?? [],
+ );
+ }
+
+ return $response->json() ?? [];
+ }
+
+ /**
+ * Fetch raw bytes (e.g. a PDF label) rather than JSON.
+ */
+ public function requestRaw(string $path): string
+ {
+ $response = Http::withToken($this->token())
+ ->timeout($this->config['timeout'])
+ ->get("{$this->config['base_url']}{$path}");
+
+ if ($response->failed()) {
+ throw new BoxNowApiException("Box Now API error ({$response->status()}) on GET {$path}");
+ }
+
+ return $response->body();
+ }
+
+ private function token(): string
+ {
+ return Cache::remember(self::TOKEN_CACHE_KEY, now()->addMinutes(55), function () {
+ $response = Http::timeout($this->config['timeout'])
+ ->post("{$this->config['base_url']}/auth-sessions", [
+ 'grant_type' => 'client_credentials',
+ 'client_id' => $this->config['client_id'],
+ 'client_secret' => $this->config['client_secret'],
+ ]);
+
+ if ($response->failed()) {
+ throw new BoxNowApiException('Box Now authentication failed', $response->json() ?? []);
+ }
+
+ return $response->json('access_token');
+ });
+ }
+}
diff --git a/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php
new file mode 100644
index 0000000..ea32ea7
--- /dev/null
+++ b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php
@@ -0,0 +1,150 @@
+shippingAddress;
+ $destinationLocationId = $request->destinationLocationId;
+
+ if (! $destinationLocationId) {
+ throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.');
+ }
+
+ $isCod = $request->paymentMode === 'cod';
+
+ $response = $this->client->request('post', '/delivery-requests', [
+ 'orderNumber' => $order->reference.'-'.$order->id,
+ 'invoiceValue' => number_format($order->total->decimal, 2, '.', ''),
+ 'paymentMode' => $isCod ? 'cod' : 'prepaid',
+ 'amountToBeCollected' => $isCod
+ ? number_format($request->amountToCollect ?? $order->total->decimal, 2, '.', '')
+ : '0.00',
+ 'origin' => [
+ 'contactNumber' => config('boxnow.sender.phone'),
+ 'contactEmail' => config('boxnow.sender.email'),
+ 'contactName' => config('boxnow.sender.name'),
+ 'locationId' => config('boxnow.origin_location_id'),
+ ],
+ 'destination' => [
+ 'contactNumber' => $address->contact_phone,
+ 'contactEmail' => $address->contact_email,
+ 'contactName' => trim("{$address->first_name} {$address->last_name}"),
+ 'locationId' => $destinationLocationId,
+ ],
+ 'items' => [
+ [
+ 'id' => (string) $order->id,
+ 'name' => 'Order '.$order->reference,
+ 'value' => '0.00',
+ 'compartmentSize' => 1,
+ 'weight' => $request->weight ?? 0,
+ ],
+ ],
+ ]);
+
+ $parcelId = (string) ($response['parcels'][0]['id'] ?? throw new BoxNowApiException(
+ 'Box Now delivery request succeeded but returned no parcel id.',
+ $response,
+ ));
+
+ return Shipment::create([
+ 'order_id' => $order->id,
+ 'carrier' => 'box-now',
+ 'tracking_reference' => $parcelId,
+ 'meta' => [
+ 'delivery_request_id' => $response['id'] ?? null,
+ 'locker_id' => $destinationLocationId,
+ ],
+ ]);
+ }
+
+ public function printLabel(Shipment $shipment): string
+ {
+ $bytes = $this->client->requestRaw("/parcels/{$shipment->tracking_reference}/label.pdf");
+
+ $shipment->update(['label_printed_at' => now()]);
+
+ return $bytes;
+ }
+
+ public function cancelShipment(Shipment $shipment): void
+ {
+ $this->client->request('post', "/parcels/{$shipment->tracking_reference}:cancel");
+
+ $shipment->update(['cancelled_at' => now()]);
+ }
+
+ public function trackShipment(Shipment $shipment): Collection
+ {
+ $response = $this->client->request('get', '/parcels', [
+ 'parcelId' => $shipment->tracking_reference,
+ ]);
+
+ $parcel = $response['data'][0] ?? null;
+
+ if (! $parcel) {
+ return collect();
+ }
+
+ $events = $parcel['events'] ?? [];
+
+ // Fall back to a single checkpoint from the parcel's current state
+ // if Box Now didn't return a detailed events history.
+ if (empty($events)) {
+ $events = [[
+ 'type' => $parcel['state'] ?? 'new',
+ 'locationDisplayName' => null,
+ 'createTime' => $parcel['updateTime'] ?? $parcel['createTime'] ?? now()->toIso8601String(),
+ ]];
+ }
+
+ return collect($events)->map(fn (array $event) => new TrackingCheckpoint(
+ status: $this->mapState($event['type'] ?? $parcel['state'] ?? 'new'),
+ carrierStatus: $event['type'] ?? $parcel['state'] ?? null,
+ message: null,
+ location: $event['locationDisplayName'] ?? null,
+ occurredAt: Carbon::parse($event['createTime']),
+ meta: $event,
+ ));
+ }
+
+ private function mapState(string $state): TrackingStatus
+ {
+ return match ($state) {
+ 'new' => TrackingStatus::Pending,
+ 'in-transit', 'in-depot' => TrackingStatus::InTransit,
+ 'in-final-destination', 'wait-for-load' => TrackingStatus::OutForDelivery,
+ 'delivered' => TrackingStatus::Delivered,
+ 'returned', 'accepted-for-return' => TrackingStatus::Returned,
+ 'cancelled' => TrackingStatus::Cancelled,
+ 'expired-return', 'missing' => TrackingStatus::Failed,
+ default => TrackingStatus::Unknown,
+ };
+ }
+}
diff --git a/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php b/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php
new file mode 100644
index 0000000..46fe211
--- /dev/null
+++ b/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php
@@ -0,0 +1,48 @@
+resolveFixedPrice(
+ $shippingOptionRequest->shippingRate,
+ $shippingOptionRequest->shippingRate->shippingMethod,
+ $shippingOptionRequest->cart,
+ );
+ }
+
+ public function on(ShippingRate $shippingRate): self
+ {
+ $this->shippingRate = $shippingRate;
+
+ return $this;
+ }
+}
diff --git a/src/Shipping/Carriers/BoxNow/Exceptions/BoxNowApiException.php b/src/Shipping/Carriers/BoxNow/Exceptions/BoxNowApiException.php
new file mode 100644
index 0000000..59bc0c9
--- /dev/null
+++ b/src/Shipping/Carriers/BoxNow/Exceptions/BoxNowApiException.php
@@ -0,0 +1,13 @@
+data['charge_by'] ?? 'cart_total';
+
+ $tier = $chargeBy === 'weight'
+ ? $cart->lines->load('purchasable')->sum(fn ($line) => ($line->purchasable->weight_value ?? 0) * $line->quantity)
+ : $cart->lines->sum('subTotal.value');
+
+ $pricing = Pricing::for($shippingRate)->qty($tier)->get();
+
+ if (! $pricing->matched) {
+ return null;
+ }
+
+ return new ShippingOption(
+ name: $shippingMethod->name ?: $this->name(),
+ description: $shippingMethod->description ?: $this->description(),
+ identifier: $shippingRate->getIdentifier(),
+ price: $pricing->matched->price,
+ taxClass: $shippingRate->getTaxClass(),
+ taxReference: $shippingRate->getTaxReference(),
+ );
+ }
+}
diff --git a/src/Shipping/Contracts/CarrierFulfillmentInterface.php b/src/Shipping/Contracts/CarrierFulfillmentInterface.php
new file mode 100644
index 0000000..d76d8d4
--- /dev/null
+++ b/src/Shipping/Contracts/CarrierFulfillmentInterface.php
@@ -0,0 +1,25 @@
+
+ */
+ public function trackShipment(Shipment $shipment): Collection;
+}
diff --git a/src/Shipping/DataTransferObjects/ManifestResult.php b/src/Shipping/DataTransferObjects/ManifestResult.php
new file mode 100644
index 0000000..a855421
--- /dev/null
+++ b/src/Shipping/DataTransferObjects/ManifestResult.php
@@ -0,0 +1,26 @@
+ true,
+ default => false,
+ };
+ }
+}
diff --git a/src/Shipping/Events/ShipmentStatusUpdatedByCarrier.php b/src/Shipping/Events/ShipmentStatusUpdatedByCarrier.php
new file mode 100644
index 0000000..590dfd9
--- /dev/null
+++ b/src/Shipping/Events/ShipmentStatusUpdatedByCarrier.php
@@ -0,0 +1,17 @@
+createShipmentAction();
+
+ return $actions;
+ }
+
+ private function createShipmentAction(): Actions\Action
+ {
+ return Actions\Action::make('create_shipment')
+ ->label('Create Shipment')
+ ->icon('heroicon-o-truck')
+ ->modalSubmitActionLabel('Create Shipment')
+ ->form([
+ Forms\Components\TextInput::make('weight')
+ ->label('Package weight (kg)')
+ ->numeric()
+ ->minValue(0)
+ ->helperText('Leave blank to use the carrier\'s default.'),
+ Forms\Components\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),
+ Forms\Components\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, Actions\Action $action) {
+ $service = $this->resolveFulfillmentService($record);
+
+ if (! $service) {
+ Notification::make()
+ ->title('No carrier fulfillment integration is configured for this order.')
+ ->danger()
+ ->send();
+
+ $action->halt();
+
+ return;
+ }
+
+ $request = new ShipmentRequest(
+ weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null,
+ destinationLocationId: $data['destination_location_id'] ?? null,
+ );
+
+ try {
+ $service->createShipment($record, $request);
+ } catch (\Throwable $e) {
+ report($e);
+
+ Notification::make()
+ ->title('Failed to create shipment: '.$e->getMessage())
+ ->danger()
+ ->send();
+
+ $action->halt();
+
+ return;
+ }
+
+ Notification::make()
+ ->title('Shipment created.')
+ ->success()
+ ->send();
+ })
+ ->visible(fn (Order $record) => $record->shipments()->exists() === false
+ && $this->resolveFulfillmentService($record) !== null);
+ }
+
+ private function resolveCarrier(Order $record): ?string
+ {
+ $code = $record->shippingAddress?->shipping_option;
+
+ if (! $code) {
+ return null;
+ }
+
+ return ShippingMethod::where('code', $code)->value('driver');
+ }
+
+ private function resolveFulfillmentService(Order $record): ?CarrierFulfillmentInterface
+ {
+ $carrier = $this->resolveCarrier($record);
+
+ if (! $carrier) {
+ return null;
+ }
+
+ return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
+ }
+}
diff --git a/src/Shipping/Extensions/ShippingMethodListExtension.php b/src/Shipping/Extensions/ShippingMethodListExtension.php
new file mode 100644
index 0000000..f9d9b0c
--- /dev/null
+++ b/src/Shipping/Extensions/ShippingMethodListExtension.php
@@ -0,0 +1,48 @@
+form([
+ ShippingMethodResource::getNameFormComponent(),
+ Group::make([
+ ShippingMethodResource::getCodeFormComponent(),
+ $this->driverSelect(),
+ ])->columns(2),
+ ShippingMethodResource::getDescriptionFormComponent(),
+ ]);
+ }
+ }
+
+ return $actions;
+ }
+
+ private function driverSelect(): Select
+ {
+ return Select::make('driver')
+ ->label('Type')
+ ->options(fn () => collect(Shipping::getSupportedDrivers())
+ ->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()]))
+ ->default('flat-rate');
+ }
+}
diff --git a/src/Shipping/Extensions/ShippingMethodResourceExtension.php b/src/Shipping/Extensions/ShippingMethodResourceExtension.php
new file mode 100644
index 0000000..5cfadc7
--- /dev/null
+++ b/src/Shipping/Extensions/ShippingMethodResourceExtension.php
@@ -0,0 +1,152 @@
+schema(
+ $this->replaceChargeByField(
+ $this->replaceDriverField($form->getComponents())
+ )
+ );
+ }
+
+ /**
+ * Extend the vendor's cart_total/weight charge_by Select with a third
+ * "live" option — only offered when the currently selected driver
+ * supports live pricing (see SupportsLivePricing). Picking it is what
+ * tells the driver to call its carrier API instead of resolving a
+ * price break.
+ */
+ private function replaceChargeByField(array $components): array
+ {
+ return array_map(function (Component $component) {
+ if (method_exists($component, 'getName') && $component->getName() === 'charge_by') {
+ return $this->chargeBySelect();
+ }
+
+ if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
+ $component->schema(
+ $this->replaceChargeByField($component->getChildComponents())
+ );
+ }
+
+ return $component;
+ }, $components);
+ }
+
+ private function chargeBySelect(): Select
+ {
+ return Select::make('charge_by')
+ ->label('Charge by')
+ ->options(function (Get $get) {
+ $options = [
+ 'cart_total' => 'Cart Total',
+ 'weight' => 'Weight',
+ ];
+
+ // "charge_by" is nested inside a Group with
+ // ->statePath('data'), while "driver" sits one level up, at
+ // the form root. Note: an *absolute* path here would need to
+ // additionally account for the page's own form wrapper
+ // (EditRecord::getFormStatePath() === 'data'), which relative
+ // paths never cross — so "../driver" (relative) is the
+ // correct, page-independent way to reach it, not an
+ // absolute 'driver' string.
+ if ($this->driverSupportsLivePricing($get('../driver'))) {
+ $options['live'] = 'Live API pricing';
+ }
+
+ return $options;
+ })
+ ->live();
+ }
+
+ private function driverSupportsLivePricing(?string $driver): bool
+ {
+ if (! $driver) {
+ return false;
+ }
+
+ try {
+ return Shipping::driver($driver) instanceof SupportsLivePricing;
+ } catch (\InvalidArgumentException) {
+ return false;
+ }
+ }
+
+ public function extendTable(Table $table): Table
+ {
+ return $table->columns(
+ array_map(function ($column) {
+ if (method_exists($column, 'getName') && $column->getName() === 'driver') {
+ return $this->driverColumn();
+ }
+
+ return $column;
+ }, $table->getColumns())
+ );
+ }
+
+ private function driverColumn(): TextColumn
+ {
+ return TextColumn::make('driver')
+ ->label('Type')
+ ->formatStateUsing(fn ($state) => $this->driverLabel($state));
+ }
+
+ private function driverLabel(string $key): string
+ {
+ $driver = collect(Shipping::getSupportedDrivers())->get($key);
+
+ return $driver?->name() ?? $key;
+ }
+
+ /**
+ * Recursively walk the form tree and replace the hardcoded driver
+ * Select (nested inside Section > Group) with one listing every
+ * registered driver, built-in or custom.
+ *
+ * @param array $components
+ * @return array
+ */
+ private function replaceDriverField(array $components): array
+ {
+ return array_map(function (Component $component) {
+ if (method_exists($component, 'getName') && $component->getName() === 'driver') {
+ return $this->driverSelect();
+ }
+
+ if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
+ $component->schema(
+ $this->replaceDriverField($component->getChildComponents())
+ );
+ }
+
+ return $component;
+ }, $components);
+ }
+
+ private function driverSelect(): Select
+ {
+ return Select::make('driver')
+ ->label('Type')
+ ->options(fn () => collect(Shipping::getSupportedDrivers())
+ ->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()]))
+ ->default('flat-rate')
+ ->live();
+ }
+}
diff --git a/src/Shipping/Filament/Pages/ManagePickupManifests.php b/src/Shipping/Filament/Pages/ManagePickupManifests.php
new file mode 100644
index 0000000..993c513
--- /dev/null
+++ b/src/Shipping/Filament/Pages/ManagePickupManifests.php
@@ -0,0 +1,106 @@
+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'),
+ ])
+ ->actions([
+ Action::make('print')
+ ->label('Print')
+ ->icon('heroicon-o-printer')
+ ->action(fn (Shipment $record) => $this->printShipment($record)),
+ ])
+ ->bulkActions([
+ BulkAction::make('print_selected')
+ ->label('Print selected')
+ ->icon('heroicon-o-printer')
+ ->action(fn (Collection $records) => $records->each(fn (Shipment $shipment) => $this->printShipment($shipment))),
+ BulkAction::make('issue_manifest')
+ ->label('Issue Manifest')
+ ->icon('heroicon-o-check-circle')
+ ->action(fn (Collection $records) => $this->issueManifest($records)),
+ ]);
+ }
+
+ private function pendingQuery(): Builder
+ {
+ $carriers = collect(Shipping::getSupportedDrivers())->keys()->filter(
+ fn (string $carrier) => $this->fulfillmentService($carrier) instanceof SupportsManifestBatching
+ );
+
+ return Shipment::query()
+ ->whereIn('carrier', $carriers)
+ ->whereNull('manifest_reference')
+ ->whereNull('cancelled_at');
+ }
+
+ private function printShipment(Shipment $shipment): void
+ {
+ $this->fulfillmentService($shipment->carrier)?->printLabel($shipment);
+ }
+
+ private function issueManifest(Collection $shipments): void
+ {
+ $shipments->groupBy('carrier')->each(function (Collection $group, string $carrier) {
+ $service = $this->fulfillmentService($carrier);
+
+ if (! $service instanceof SupportsManifestBatching) {
+ return;
+ }
+
+ $result = $service->issueManifest($group);
+
+ if (! $result->success) {
+ Notification::make()
+ ->title("Manifest blocked for {$carrier}: {$result->reason}")
+ ->danger()
+ ->send();
+
+ return;
+ }
+
+ Notification::make()
+ ->title("Manifest issued for {$carrier}: {$result->reference}")
+ ->success()
+ ->send();
+ });
+ }
+
+ private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface
+ {
+ return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
+ }
+}
diff --git a/src/Shipping/Filament/Pages/ManageShippingRates.php b/src/Shipping/Filament/Pages/ManageShippingRates.php
new file mode 100644
index 0000000..bf6cdb3
--- /dev/null
+++ b/src/Shipping/Filament/Pages/ManageShippingRates.php
@@ -0,0 +1,104 @@
+schema(
+ $this->hidePriceFieldsWhenLive($form->getComponents())
+ );
+ }
+
+ private function hidePriceFieldsWhenLive(array $components): array
+ {
+ $isNotLive = fn (Get $get) => static::methodChargeBy($get('shipping_method_id')) !== 'live';
+
+ foreach ($components as $component) {
+ if (! method_exists($component, 'getName')) {
+ continue;
+ }
+
+ if ($component->getName() === 'price') {
+ $component->visible($isNotLive)->required($isNotLive)->dehydrated(true);
+ }
+
+ if ($component->getName() === 'prices') {
+ $component->visible($isNotLive)->dehydrated(true);
+ }
+ }
+
+ return $components;
+ }
+
+ public function table(Table $table): Table
+ {
+ $table = parent::table($table);
+
+ return $table->columns(
+ array_map(function ($column) {
+ if (method_exists($column, 'getName') && $column->getName() === 'basePrices.0') {
+ return TextColumn::make('basePrices.0')
+ ->label(__('lunarpanel.shipping::relationmanagers.shipping_rates.table.price.label'))
+ ->formatStateUsing(function ($state, ShippingRate $record) {
+ if (static::methodChargeBy($record->shipping_method_id) === 'live') {
+ return 'Live API pricing';
+ }
+
+ return $state?->price->formatted;
+ });
+ }
+
+ return $column;
+ }, $table->getColumns())
+ );
+ }
+
+ protected static function saveShippingRate(?ShippingRate $shippingRate = null, array $data = []): void
+ {
+ if (static::methodChargeBy($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id) === 'live') {
+ return;
+ }
+
+ parent::saveShippingRate($shippingRate, $data);
+ }
+
+ protected static function methodChargeBy(ShippingMethod|int|string|null $method): ?string
+ {
+ if (blank($method)) {
+ return null;
+ }
+
+ if (! $method instanceof ShippingMethod) {
+ $method = ShippingMethod::find($method);
+ }
+
+ return $method?->data['charge_by'] ?? null;
+ }
+}
diff --git a/src/Shipping/Jobs/PollShipmentTrackingJob.php b/src/Shipping/Jobs/PollShipmentTrackingJob.php
new file mode 100644
index 0000000..7714e22
--- /dev/null
+++ b/src/Shipping/Jobs/PollShipmentTrackingJob.php
@@ -0,0 +1,107 @@
+keys()->filter(
+ fn (string $carrier) => $this->fulfillmentService($carrier) instanceof SupportsTracking
+ );
+
+ if ($trackableCarriers->isEmpty()) {
+ return;
+ }
+
+ Shipment::query()
+ ->whereIn('carrier', $trackableCarriers)
+ ->whereNull('cancelled_at')
+ ->whereDoesntHave('shipmentInfo', function ($query) {
+ $query->whereIn('status', [
+ TrackingStatus::Delivered->value,
+ TrackingStatus::Returned->value,
+ TrackingStatus::Cancelled->value,
+ ]);
+ })
+ ->chunkById(50, function ($shipments) {
+ $shipments->groupBy('carrier')->each(
+ fn ($group, $carrier) => $this->pollCarrierShipments($carrier, $group)
+ );
+ });
+ }
+
+ private function pollCarrierShipments(string $carrier, $shipments): void
+ {
+ $service = $this->fulfillmentService($carrier);
+
+ if (! $service instanceof SupportsTracking) {
+ return;
+ }
+
+ foreach ($shipments as $shipment) {
+ $this->recordNewCheckpoints($shipment, $service->trackShipment($shipment));
+ }
+ }
+
+ private function recordNewCheckpoints(Shipment $shipment, $checkpoints): void
+ {
+ $existing = $shipment->shipmentInfo()
+ ->get(['status', 'occurred_at'])
+ ->map(fn ($info) => $info->status->value.'|'.$info->occurred_at->toIso8601String())
+ ->flip();
+
+ foreach ($checkpoints as $checkpoint) {
+ $fingerprint = $checkpoint->status->value.'|'.$checkpoint->occurredAt->toIso8601String();
+
+ if ($existing->has($fingerprint)) {
+ continue;
+ }
+
+ $info = ShipmentInfo::create([
+ 'shipment_id' => $shipment->id,
+ 'status' => $checkpoint->status,
+ 'carrier_status' => $checkpoint->carrierStatus,
+ 'message' => $checkpoint->message,
+ 'location' => $checkpoint->location,
+ 'occurred_at' => $checkpoint->occurredAt,
+ 'meta' => $checkpoint->meta,
+ ]);
+
+ ShipmentStatusUpdatedByCarrier::dispatch($info);
+ }
+ }
+
+ private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface
+ {
+ return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
+ }
+}
diff --git a/src/Shipping/Models/Shipment.php b/src/Shipping/Models/Shipment.php
new file mode 100644
index 0000000..c278f4c
--- /dev/null
+++ b/src/Shipping/Models/Shipment.php
@@ -0,0 +1,35 @@
+ AsArrayObject::class,
+ 'label_printed_at' => 'datetime',
+ 'cancelled_at' => 'datetime',
+ ];
+
+ public function order(): BelongsTo
+ {
+ return $this->belongsTo(Order::class);
+ }
+
+ public function shipmentInfo(): HasMany
+ {
+ return $this->hasMany(ShipmentInfo::class);
+ }
+
+ public function latestShipmentInfo(): ?ShipmentInfo
+ {
+ return $this->shipmentInfo()->latest('occurred_at')->first();
+ }
+}
diff --git a/src/Shipping/Models/ShipmentInfo.php b/src/Shipping/Models/ShipmentInfo.php
new file mode 100644
index 0000000..b2a401d
--- /dev/null
+++ b/src/Shipping/Models/ShipmentInfo.php
@@ -0,0 +1,31 @@
+ TrackingStatus::class,
+ 'occurred_at' => 'datetime',
+ 'meta' => AsArrayObject::class,
+ ];
+
+ public function shipment(): BelongsTo
+ {
+ return $this->belongsTo(Shipment::class);
+ }
+}