shippingAddress->meta['box_now_locker']). * * Box Now ships by compartment size, not weight — unlike ACS, which bills * by kg. One 'items' entry per box in ShipmentRequest::$boxes, so an order * needing more than one physical parcel (doesn't fit one compartment) * sends that many entries in a single delivery request rather than * several separate ones. */ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking, SupportsVoucherListing, SupportsVoucherLookup { private const COMPARTMENT_SIZES = ['S' => 1, 'M' => 2, 'L' => 3]; public function __construct(private readonly BoxNowClient $client) {} public function createShipment(Order $order, ShipmentRequest $request): Shipment { $address = $order->shippingAddress; $destinationLocationId = $request->destinationLocationId; if (! $destinationLocationId) { throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.'); } if (empty($request->boxes)) { throw new BoxNowApiException('At least one box (compartment size) is required for a Box Now shipment.'); } $isCod = $request->paymentMode === 'cod'; // Box Now rejects an orderNumber it has seen before (P410), even // for a cancelled request — so a re-created shipment after a cancel // gets "-2", "-3", …; the first attempt keeps "{reference}-{id}". $previousRequests = Shipment::where('order_id', $order->id) ->where('carrier', 'box-now') ->where('source', Shipment::SOURCE_CREATED) ->get() ->map(fn (Shipment $shipment) => $shipment->meta['delivery_request_id'] ?? $shipment->id) ->unique() ->count(); $orderNumber = $order->reference.'-'.$order->id.($previousRequests > 0 ? '-'.($previousRequests + 1) : ''); $response = $this->client->request('post', '/delivery-requests', [ 'orderNumber' => $orderNumber, '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' => $this->internationalPhone($address->contact_phone), 'contactEmail' => $address->contact_email, 'contactName' => trim("{$address->first_name} {$address->last_name}"), 'locationId' => $destinationLocationId, ], 'items' => collect($request->boxes)->values()->map(fn (string $size, int $index) => [ 'id' => $order->id.'-'.($index + 1), 'name' => 'Order '.$order->reference.' (box '.($index + 1).')', 'value' => '0.00', 'compartmentSize' => self::COMPARTMENT_SIZES[$size] ?? self::COMPARTMENT_SIZES['S'], ])->all(), ]); $parcels = collect($response['parcels'] ?? []); if ($parcels->isEmpty()) { throw new BoxNowApiException('Box Now delivery request succeeded but returned no parcel ids.', $response); } // One Shipment row per box/parcel — each is independently // trackable/printable/cancellable via its own tracking_reference // (printLabel()/cancelShipment()/trackShipment() below already // operate per-Shipment), even though all boxes were submitted in // one delivery request. Siblings are linked via the shared // delivery_request_id in meta. // The cash is collected once for the whole request, so only the // first parcel carries cod_amount. $codAmount = $isCod ? (float) ($request->amountToCollect ?? $order->total->decimal) : null; $shipments = $parcels->values()->map(fn (array $parcel, int $index) => Shipment::create([ 'order_id' => $order->id, 'carrier' => 'box-now', 'tracking_reference' => (string) $parcel['id'], 'meta' => [ 'delivery_request_id' => $response['id'] ?? null, 'reference' => $orderNumber, 'locker_id' => $destinationLocationId, 'cod_amount' => $index === 0 ? $codAmount : null, ], ])); return $shipments->first(); } /** * Box Now rejects any contactNumber not in full international format * (error P405 — confirmed in practice: a plain Greek mobile like * "6955994563" 400s with {"code":"P405"}). Checkout collects phone * numbers in local format, with no international-format enforcement of * its own — this store is Greece-only (see CheckoutController:: * STORE_COUNTRY_ISO3), so a bare local number is assumed Greek and * prefixed accordingly, same convention ACS's own sender config already * uses (config('boxnow.sender.phone') is documented as "+30..." there * too). A number already carrying a country code (leading "+" or "00") * is passed through unchanged. */ private function internationalPhone(?string $phone): ?string { if ($phone === null) { return null; } $digitsOnly = preg_replace('/[^\d+]/', '', $phone); if (str_starts_with($digitsOnly, '+')) { return $digitsOnly; } if (str_starts_with($digitsOnly, '00')) { return '+'.substr($digitsOnly, 2); } return '+30'.ltrim($digitsOnly, '0'); } 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, )); } /** * Every parcel on the account (GET /parcels, 100 per page). Box Now has * no date filter, so pages are read until a whole page is older than * $from — its order isn't documented, so one old parcel alone doesn't * stop the scan. */ public function listVouchers(CarbonInterface $from, CarbonInterface $to): iterable { $pageToken = null; do { $response = $this->client->request('get', '/parcels', array_filter([ 'limit' => 100, 'pageToken' => $pageToken, ])); $parcels = $response['data'] ?? []; $anyInRange = false; foreach ($parcels as $parcel) { $created = Carbon::parse($parcel['createTime'] ?? 'now'); if ($created->lt($from)) { continue; } $anyInRange = true; if ($created->lte($to)) { yield $this->voucherFromParcel($parcel); } } $pageToken = $this->nextPageToken($response['pagination']['next'] ?? null); } while ($pageToken && $anyInRange && $parcels !== []); } public function lookupVoucher(string $voucherNumber): ?CarrierVoucher { $parcel = $this->client->request('get', '/parcels', ['parcelId' => trim($voucherNumber)])['data'][0] ?? null; return $parcel ? $this->voucherFromParcel($parcel) : null; } private function voucherFromParcel(array $parcel): CarrierVoucher { $request = $parcel['deliveryRequest'] ?? []; $destination = $request['destination'] ?? []; $state = $parcel['state'] ?? null; return new CarrierVoucher( carrier: 'box-now', voucherNumber: (string) $parcel['id'], reference: $request['orderNumber'] ?? null, recipientName: $destination['contactName'] ?? null, postcode: $destination['postalCode'] ?? ($destination['address']['postalCode'] ?? null), phone: $destination['contactNumber'] ?? null, statusText: $state, status: $state ? $this->mapState($state) : null, codAmount: ($request['paymentMode'] ?? null) === 'cod' ? ((float) ($request['amountToBeCollected'] ?? 0) ?: null) : null, isReturn: in_array($state, ['returned', 'expired-return', 'accepted-for-return'], true), date: isset($parcel['createTime']) ? Carbon::parse($parcel['createTime']) : null, raw: $parcel, ); } /** * pagination.next is documented only by name — accept either the bare * token or a URL carrying it as ?pageToken=. */ private function nextPageToken(mixed $next): ?string { if (blank($next) || ! is_string($next)) { return null; } if (str_contains($next, 'pageToken=')) { parse_str((string) parse_url($next, PHP_URL_QUERY), $query); return $query['pageToken'] ?? null; } return $next; } private function mapState(string $state): TrackingStatus { // TODO: confirm against a live BoxNow webhook payload whether a // distinct collected-from-sender state exists (e.g. between 'new' // and 'in-transit') before mapping it to // TrackingStatus::CollectedFromSender — BoxNow's own model is // locker-drop-off-based, so it may not have one. No guessed match // arm added; 'new' still falls through to Pending, InTransit // remains the earliest recognized checkpoint. return match ($state) { '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', 'canceled' => TrackingStatus::Cancelled, 'expired-return', 'missing' => TrackingStatus::Failed, default => TrackingStatus::Unknown, }; } }