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.
This commit is contained in:
@@ -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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user