From 37c2e1194c965ac92092702e4eeee39a09e883ed Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sun, 19 Jul 2026 00:51:23 +0300 Subject: [PATCH] Feature: Add Box Now locker delivery integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- config/shippingCarriers/boxnow.php | 47 +++++++++ src/Shipping/Carriers/BoxNow/BoxNowClient.php | 97 +++++++++++++++++++ .../BoxNow/BoxNowFulfillmentService.php | 92 ++++++++++++++++++ .../Carriers/BoxNow/BoxNowRateDriver.php | 61 ++++++++++++ .../BoxNow/Exceptions/BoxNowApiException.php | 13 +++ 5 files changed, 310 insertions(+) create mode 100644 config/shippingCarriers/boxnow.php create mode 100644 src/Shipping/Carriers/BoxNow/BoxNowClient.php create mode 100644 src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php create mode 100644 src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php create mode 100644 src/Shipping/Carriers/BoxNow/Exceptions/BoxNowApiException.php 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/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..05266c5 --- /dev/null +++ b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php @@ -0,0 +1,92 @@ +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()]); + } +} diff --git a/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php b/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php new file mode 100644 index 0000000..4897657 --- /dev/null +++ b/src/Shipping/Carriers/BoxNow/BoxNowRateDriver.php @@ -0,0 +1,61 @@ +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; + } +} 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 @@ +