4 Commits

Author SHA1 Message Date
arvanitakis 772c008949 Feature: Wire up carrier-agnostic shipping admin UI
Registers ShippingServiceProvider (carrier config, driver/fulfillment bindings, Rates page override) and wires the shipping admin surface into CorePlugin: dynamic carrier dropdown on Shipping Method create/edit, a "Create Shipment" order action resolved generically by carrier, and a Pickup Manifests page for carriers that support manifest batching.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 00:52:01 +03:00
arvanitakis 77dbbd63a4 Feature: Add Box Now locker delivery integration
Box Now rate driver (fixed pricing only — no live pricing API) and fulfillment service (delivery request creation, label printing, cancellation). Unlike ACS, Box Now books courier pickup automatically on delivery request creation, so no manifest/pickup-list step is implemented. Storefront locker selection is not yet built; createShipment() expects the chosen locker's locationId to be supplied by the caller.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 00:51:23 +03:00
arvanitakis ca5c826849 Feature: Add ACS courier integration
ACS rate driver (live price quotes via ACS_Price_Calculation, cached postcode-to-station lookups) and fulfillment service (voucher creation, label printing, end-of-day pickup manifest). Adds a per-rate pricing_mode column so admins can choose live API pricing vs. a fixed price on ACS-driven shipping rates, surfaced via a custom Rates page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 00:50:51 +03:00
arvanitakis 851bdc6031 Feature: Add ManifestResult DTO for carrier manifest batching
Completes the carrier-agnostic shipping abstraction (CarrierFulfillmentInterface, SupportsManifestBatching, Shipment model) with the result type SupportsManifestBatching::issueManifest() returns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 00:50:15 +03:00
24 changed files with 1479 additions and 2 deletions
+2 -1
View File
@@ -33,7 +33,8 @@
"providers": [
"Modules\\Core\\Providers\\CoreServiceProvider",
"Modules\\Core\\Providers\\AuthServiceProvider",
"Modules\\Core\\Providers\\CustomerServiceProvider"
"Modules\\Core\\Providers\\CustomerServiceProvider",
"Modules\\Core\\Providers\\ShippingServiceProvider"
]
}
},
+50
View File
@@ -0,0 +1,50 @@
<?php
/*
|--------------------------------------------------------------------------
| ACS Courier credentials
|--------------------------------------------------------------------------
|
| ACS requires two credential mechanisms simultaneously: an AcsApiKey
| HTTP header (gates the REST gateway itself) and four account fields
| (Company_ID/Company_Password/User_ID/User_Password) sent in every
| request body. Both are supplied by ACS when your account is set up.
|
| Set these via environment variables — never commit real values.
|
| ACS_BASE_URL Root REST endpoint (unversioned, single URL for
| every ACSAlias call).
| ACS_API_KEY The AcsApiKey header value.
| ACS_COMPANY_ID Company_ID body field.
| ACS_COMPANY_PASSWORD Company_Password body field.
| ACS_USER_ID User_ID body field.
| ACS_USER_PASSWORD User_Password body field.
| ACS_BILLING_CODE Your ACS credit/billing code, used for price
| calculation and voucher creation.
| ACS_SENDER_* Static sender details reused on every voucher.
|
*/
return [
'base_url' => 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),
];
+47
View File
@@ -0,0 +1,47 @@
<?php
/*
|--------------------------------------------------------------------------
| Box Now credentials
|--------------------------------------------------------------------------
|
| Box Now uses OAuth2 client-credentials: exchange BOXNOW_CLIENT_ID /
| BOXNOW_CLIENT_SECRET for a Bearer access token (POST /auth-sessions,
| ~1hr expiry), then attach it as an Authorization header on every call.
| Unlike ACS, there is no separate per-request credential body — the
| token alone authorizes all calls once obtained.
|
| Set these via environment variables — never commit real values.
|
| BOXNOW_BASE_URL Root REST endpoint for delivery-requests/parcels.
| BOXNOW_LOCATION_API_URL Separate, faster endpoint for origins/destinations
| lookups (Box Now recommends this over the main
| base URL for those two calls specifically).
| BOXNOW_CLIENT_ID OAuth2 client id.
| BOXNOW_CLIENT_SECRET OAuth2 client secret.
| BOXNOW_ORIGIN_LOCATION_ID Your warehouse's Box Now locationId, used as
| the pickup origin on every delivery request.
| BOXNOW_SENDER_* Static sender contact details reused on every
| delivery request.
|
*/
return [
'base_url' => 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),
];
@@ -0,0 +1,3 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>
+12 -1
View File
@@ -6,17 +6,24 @@ use Filament\Contracts\Plugin;
use Filament\Panel;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Mail;
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;
use Modules\Core\Auth\Mail\InviteMail;
use Modules\Core\Review\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
{
@@ -32,11 +39,15 @@ class CorePlugin implements Plugin
->brandLogo(asset('static/logos/core/boboko-logo.svg'))
->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg'))
->login(Login::class)
->plugin(ShippingPlugin::make());
->plugin(ShippingPlugin::make())
->pages([ManagePickupManifests::class]);
LunarPanel::extensions([
StaffResource::class => StaffResourceExtension::class,
ProductResource::class => ProductResourceExtension::class,
ShippingMethodResource::class => ShippingMethodResourceExtension::class,
ListShippingMethod::class => ShippingMethodListExtension::class,
OrderResource\Pages\ManageOrder::class => OrderViewExtension::class,
]);
Product::macro('reviews', function (): HasMany {
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Console\Scheduling\Schedule as ConsoleSchedule;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
use Livewire\Mechanisms\ComponentRegistry;
use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Shipping\Carriers\Acs\AcsClient;
use Modules\Core\Shipping\Carriers\Acs\AcsFulfillmentService;
use Modules\Core\Shipping\Carriers\Acs\AcsRateDriver;
use Modules\Core\Shipping\Carriers\Acs\Jobs\WarmAcsAreaCacheJob;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
use Modules\Core\Shipping\Models\Shipment;
class ShippingServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->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->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);
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
class AcsArea
{
public function __construct(
public readonly string $stationId,
public readonly int $branchId,
) {}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
use Illuminate\Support\Facades\Http;
class AcsClient
{
public function __construct(private readonly array $config) {}
public function call(string $alias, array $parameters = []): AcsResponse
{
$response = Http::withHeaders([
'AcsApiKey' => $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'],
];
}
}
@@ -0,0 +1,133 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
use Illuminate\Support\Collection;
use Lunar\Models\Order;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\DataTransferObjects\ManifestResult;
use Modules\Core\Shipping\Models\Shipment;
class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching
{
public function __construct(
private readonly AcsClient $client,
private readonly AreaResolver $areaResolver,
) {}
public function createShipment(Order $order, array $overrides = []): Shipment
{
$address = $order->shippingAddress;
$destination = $this->areaResolver->resolve($address->postcode);
$response = $this->client->call('ACS_Create_Voucher', array_merge([
'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' => 1,
'Weight' => 0.5,
], $overrides))->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' => $overrides['Weight'] ?? 0.5,
'pickup_date' => now()->toDateString(),
],
]);
if (($overrides['Item_Quantity'] ?? 1) > 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);
}
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() ?? [],
]);
}
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
use Lunar\DataTypes\Price;
use Lunar\DataTypes\ShippingOption;
use Lunar\Facades\Pricing;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing
{
public ShippingRate $shippingRate;
public function __construct(
private readonly AcsClient $client,
private readonly AreaResolver $areaResolver,
) {}
public function name(): string
{
return 'ACS Courier';
}
public function description(): string
{
return 'Live rate quote from ACS Courier.';
}
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{
$shippingRate = $shippingOptionRequest->shippingRate;
$shippingMethod = $shippingRate->shippingMethod;
$cart = $shippingOptionRequest->cart;
$postcode = $cart->shippingAddress?->postcode;
if (! $postcode) {
return null;
}
if (($shippingRate->pricing_mode ?? 'live') === 'fixed') {
return $this->resolveFixedPrice($shippingRate, $shippingMethod, $cart);
}
return $this->resolveLivePrice($shippingRate, $shippingMethod, $cart, $postcode);
}
private function resolveFixedPrice(ShippingRate $shippingRate, $shippingMethod, $cart): ?ShippingOption
{
$subTotal = $cart->lines->sum('subTotal.value');
$pricing = Pricing::for($shippingRate)->qty($subTotal)->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(),
);
}
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
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
use Illuminate\Http\Client\Response;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
class AcsResponse
{
private function __construct(
public readonly bool $hasError,
public readonly ?string $errorMessage,
public readonly array $valueOutput,
public readonly array $tableOutput,
) {}
public static function fromHttpResponse(Response $response): self
{
$body = $response->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;
}
}
@@ -0,0 +1,81 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs;
use Illuminate\Support\Facades\Cache;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
class AreaResolver
{
public const CACHE_KEY = 'acs.areas';
public function __construct(private readonly AcsClient $client) {}
/**
* Resolve a Greek postcode to its ACS station/branch codes.
*
* Reads from the table warmed daily by WarmAcsAreaCacheJob. Falls
* back to a live lookup for that single postcode if the warmed cache is
* missing (e.g. the daily job never ran or failed) or doesn't contain it.
*/
public function resolve(string $postcode): AcsArea
{
$areas = Cache::get(self::CACHE_KEY);
if ($areas !== null && isset($areas[$postcode])) {
return $this->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'],
);
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs\Exceptions;
use RuntimeException;
class AcsApiException extends RuntimeException
{
public function __construct(string $message, public readonly array $tableOutput = [])
{
parent::__construct($message);
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Core\Shipping\Carriers\Acs\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Modules\Core\Shipping\Carriers\Acs\AreaResolver;
class WarmAcsAreaCacheJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function handle(AreaResolver $areaResolver): void
{
$areaResolver->warmAll();
}
}
@@ -0,0 +1,97 @@
<?php
namespace Modules\Core\Shipping\Carriers\BoxNow;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
class BoxNowClient
{
private const TOKEN_CACHE_KEY = 'boxnow.access_token';
public function __construct(private readonly array $config) {}
/**
* GET/POST against the main delivery/parcel API, authenticated with a
* cached Bearer token.
*/
public function request(string $method, string $path, array $payload = []): array
{
$response = Http::withToken($this->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');
});
}
}
@@ -0,0 +1,92 @@
<?php
namespace Modules\Core\Shipping\Carriers\BoxNow;
use Lunar\Models\Order;
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Models\Shipment;
/**
* Unlike ACS, Box Now has no manifest/pickup-list step — creating a
* delivery request also books the courier pickup, so this only implements
* CarrierFulfillmentInterface (not SupportsManifestBatching).
*
* Box Now delivers to lockers, not addresses. The storefront locker-picker
* is out of scope for this pass — createShipment() expects the chosen
* locker's Box Now locationId via $overrides['locationId'] (e.g. set
* manually by admin staff until checkout UI exists).
*/
class BoxNowFulfillmentService implements CarrierFulfillmentInterface
{
public function __construct(private readonly BoxNowClient $client) {}
public function createShipment(Order $order, array $overrides = []): Shipment
{
$address = $order->shippingAddress;
$destinationLocationId = $overrides['locationId'] ?? null;
if (! $destinationLocationId) {
throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.');
}
$response = $this->client->request('post', '/delivery-requests', [
'orderNumber' => $order->reference.'-'.$order->id,
'invoiceValue' => number_format($order->total->decimal, 2, '.', ''),
'paymentMode' => 'prepaid',
'amountToBeCollected' => '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' => $overrides['compartmentSize'] ?? 1,
'weight' => $overrides['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()]);
}
}
@@ -0,0 +1,61 @@
<?php
namespace Modules\Core\Shipping\Carriers\BoxNow;
use Lunar\DataTypes\ShippingOption;
use Lunar\Facades\Pricing;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingRate;
/**
* Box Now has no pricing API, so this always resolves the admin-configured
* price/price-breaks on the ShippingRate — the same mechanism the built-in
* flat-rate driver uses. Unlike AcsRateDriver, this does not implement
* SupportsLivePricing: there is nothing to toggle between.
*/
class BoxNowRateDriver implements ShippingRateInterface
{
public ShippingRate $shippingRate;
public function name(): string
{
return 'Box Now Locker Delivery';
}
public function description(): string
{
return 'Deliver to a Box Now parcel locker.';
}
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{
$shippingRate = $shippingOptionRequest->shippingRate;
$shippingMethod = $shippingRate->shippingMethod;
$cart = $shippingOptionRequest->cart;
$subTotal = $cart->lines->sum('subTotal.value');
$pricing = Pricing::for($shippingRate)->qty($subTotal)->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(),
);
}
public function on(ShippingRate $shippingRate): self
{
$this->shippingRate = $shippingRate;
return $this;
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\Shipping\Carriers\BoxNow\Exceptions;
use RuntimeException;
class BoxNowApiException extends RuntimeException
{
public function __construct(string $message, public readonly array $body = [])
{
parent::__construct($message);
}
}
@@ -0,0 +1,26 @@
<?php
namespace Modules\Core\Shipping\DataTransferObjects;
use Illuminate\Support\Collection;
class ManifestResult
{
private function __construct(
public readonly bool $success,
public readonly ?string $reference,
public readonly Collection $includedShipments,
public readonly Collection $blockedShipments,
public readonly ?string $reason,
) {}
public static function success(string $reference, Collection $includedShipments): self
{
return new self(true, $reference, $includedShipments, collect(), null);
}
public static function blocked(Collection $blockedShipments, string $reason): self
{
return new self(false, null, collect(), $blockedShipments, $reason);
}
}
@@ -0,0 +1,101 @@
<?php
namespace Modules\Core\Shipping\Extensions;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Lunar\Admin\Support\Extending\ViewPageExtension;
use Lunar\Models\Order;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
class OrderViewExtension extends ViewPageExtension
{
public function headerActions(array $actions): array
{
$actions[] = $this->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\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, 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;
}
try {
$service->createShipment($record);
} 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]);
}
}
@@ -0,0 +1,48 @@
<?php
namespace Modules\Core\Shipping\Extensions;
use Filament\Actions;
use Filament\Forms\Components\Group;
use Filament\Forms\Components\Select;
use Lunar\Admin\Support\Extending\BaseExtension;
use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Filament\Resources\ShippingMethodResource;
/**
* ListShippingMethod::getDefaultHeaderActions() builds its CreateAction's
* form inline (calling ShippingMethodResource::getDriverFormComponent()
* directly, a hardcoded 2-option Select) rather than through the resource's
* own extendForm() pipeline, so ShippingMethodResourceExtension's driver
* fix never reaches it. Re-declares the same create-action form with a
* dynamic driver Select instead.
*/
class ShippingMethodListExtension extends BaseExtension
{
public function headerActions(array $actions): array
{
foreach ($actions as $action) {
if ($action instanceof Actions\CreateAction) {
$action->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');
}
}
@@ -0,0 +1,83 @@
<?php
namespace Modules\Core\Shipping\Extensions;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\Concerns\HasChildComponents;
use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Lunar\Admin\Support\Extending\ResourceExtension;
use Lunar\Shipping\Facades\Shipping;
class ShippingMethodResourceExtension extends ResourceExtension
{
public function extendForm(Form $form): Form
{
return $form->schema(
$this->replaceDriverField($form->getComponents())
);
}
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<Component> $components
* @return array<Component>
*/
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');
}
}
@@ -0,0 +1,106 @@
<?php
namespace Modules\Core\Shipping\Filament\Pages;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkAction;
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 $navigationIcon = 'heroicon-o-truck';
protected static ?string $navigationLabel = 'Pickup Manifests';
protected static 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'),
])
->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]);
}
}
@@ -0,0 +1,165 @@
<?php
namespace Modules\Core\Shipping\Filament\Pages;
use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate;
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as BaseManageShippingRates;
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
/**
* Bound in place of the vendor ManageShippingRates page via the container
* (see ShippingServiceProvider), since that page has no extension hook of
* its own. Every reference to the vendor class name — routes, sub-nav,
* ShippingZoneResource::getPages() — is untouched; the container simply
* hands back this subclass whenever the vendor class is resolved.
*
* Adds a per-rate "Pricing" toggle (live API vs. fixed price) for methods
* whose driver supports live pricing (see SupportsLivePricing). Rates on
* methods without live pricing behave exactly as the vendor page always did
* — no toggle shown, price fields always required, vendor save logic used
* as-is.
*/
class ManageShippingRates extends BaseManageShippingRates
{
public function form(Form $form): Form
{
$form = parent::form($form);
return $form->schema(
$this->insertPricingModeFieldAfterShippingMethod(
$this->hidePriceFieldsWhenLive($form->getComponents())
)
);
}
/**
* Insert the "Pricing" field immediately after "shipping_method_id" so
* it reads as a pair, rather than appending it elsewhere in the form.
*/
private function insertPricingModeFieldAfterShippingMethod(array $components): array
{
$result = [];
foreach ($components as $component) {
$result[] = $component;
if (method_exists($component, 'getName') && $component->getName() === 'shipping_method_id') {
$result[] = $this->pricingModeField();
}
}
return $result;
}
private function pricingModeField(): Select
{
return Select::make('pricing_mode')
->label('Pricing')
->options([
'live' => 'Use live API pricing',
'fixed' => 'Use a fixed price',
])
->default('live')
->live()
->visible(fn (Get $get) => static::methodHasLivePricing($get('shipping_method_id')))
->columnSpan(2);
}
/**
* Hide the vendor's price / price-break fields whenever this rate is
* set to live pricing — they'd otherwise be dead configuration,
* silently ignored by the driver. Only the base "price" field was ever
* required by the vendor form; the "prices" repeater (price breaks) is
* always optional, so its required() state is left untouched.
*/
private function hidePriceFieldsWhenLive(array $components): array
{
$isFixedOrNotLiveCapable = fn (Get $get) => ! static::methodHasLivePricing($get('shipping_method_id'))
|| $get('pricing_mode') === 'fixed';
foreach ($components as $component) {
if (! method_exists($component, 'getName')) {
continue;
}
if ($component->getName() === 'price') {
$component->visible($isFixedOrNotLiveCapable)
->required($isFixedOrNotLiveCapable)
->dehydrated(true);
}
if ($component->getName() === 'prices') {
$component->visible($isFixedOrNotLiveCapable)
->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::methodHasLivePricing($record->shipping_method_id) && $record->pricing_mode === 'live') {
return 'Live API pricing';
}
return $state?->price->formatted;
});
}
return $column;
}, $table->getColumns())
);
}
protected static function saveShippingRate(?ShippingRate $shippingRate = null, array $data = []): void
{
$isLive = static::methodHasLivePricing($data['shipping_method_id'] ?? $shippingRate?->shipping_method_id)
&& ($data['pricing_mode'] ?? 'live') === 'live';
$shippingRate->pricing_mode = $isLive ? 'live' : 'fixed';
$shippingRate->save();
if ($isLive) {
return;
}
parent::saveShippingRate($shippingRate, $data);
}
protected static function methodHasLivePricing(ShippingMethod|int|string|null $method): bool
{
if (blank($method)) {
return false;
}
if (! $method instanceof ShippingMethod) {
$method = ShippingMethod::find($method);
}
if (! $method) {
return false;
}
try {
return Shipping::driver($method->driver) instanceof SupportsLivePricing;
} catch (\InvalidArgumentException) {
return false;
}
}
}