apiKey = config('courier.api_key'); $this->baseUrl = config('courier.api_base_url', 'https://api.shiplogic.com/api'); } /** * Create shipment with full validation and database updates * * @param Order $order * @return array{waybill_id: string, tracking_number: string, sticker_path: ?string, waybill_path: ?string} * @throws \Exception */ public function createShipmentForOrder(Order $order): array { Log::info('Creating shipment for order', ['order_uuid' => $order->uuid]); // Guard 1: Validate packing if (! $this->validatePacking($order)) { throw new \Exception('Order not yet packed with valid dimensions'); } // Guard 2: Validate payment/approval if (! $this->validatePayment($order)) { throw new \Exception($order->is_custom_order ? 'Custom order requires proof approved and balance paid' : 'Standard order must be fully paid' ); } // Guard 3: Validate order status if ($order->status !== 'ready_to_ship') { throw new \Exception('Order must be in Ready to Ship state'); } // Guard 4: Prevent duplicates if ($order->courier_waybill_id) { throw new \Exception('Shipment already exists for this order'); } try { // Call API to create shipment $shipmentData = $this->callCreateShipmentApi( $order->id, $order->packing_width, $order->packing_length, $order->packing_weight, ); // Save shipment details to database $order->update([ 'courier_waybill_id' => $shipmentData['waybill_id'], 'courier_tracking_number' => $shipmentData['tracking_number'], 'courier_status' => 'awaiting_collection', 'status' => 'awaiting_collection', ]); Log::info('Shipment created successfully', [ 'order_uuid' => $order->uuid, 'waybill_id' => $shipmentData['waybill_id'], ]); // Fetch shipping documents $stickerPath = $this->fetchSticker($shipmentData['shipment_id'], $order->id); $waybillPath = $this->fetchWaybill($shipmentData['shipment_id'], $order->id); return [ 'waybill_id' => $shipmentData['waybill_id'], 'tracking_number' => $shipmentData['tracking_number'], 'sticker_path' => $stickerPath, 'waybill_path' => $waybillPath, ]; } catch (\Exception $e) { Log::error('Failed to create shipment', [ 'order_uuid' => $order->uuid, 'error' => $e->getMessage(), ]); throw $e; } } /** * Create a shipment with Shiplogic API * * @param string $orderId * @param float $width Width in cm * @param float $length Length in cm * @param float $weight Weight in kg * @return array{shipment_id: string, waybill_id: string, tracking_number: string} * @throws \Exception */ private function callCreateShipmentApi(string $orderId, float $width, float $length, float $weight): array { if (! $this->isConfigured()) { throw new \Exception('Courier API not configured'); } if ($width <= 0 || $length <= 0 || $weight <= 0) { throw new \Exception('Invalid dimensions or weight: all must be greater than 0'); } try { // Fetch order to get customer and shipping details $order = Order::findOrFail($orderId); // Validate required shipping info if (! $order->customer_name || ! $order->shipping_street_address) { throw new \Exception('Order missing required customer name or shipping address'); } if (! $order->customer_email && ! $order->customer_phone) { throw new \Exception('Order must have at least email or phone number'); } // Build shipment payload for Shiplogic $payload = [ 'collection_address' => [ 'street' => 'Two Tales Designs', // TODO: Get from AppSetting 'city' => 'Cape Town', 'postcode' => '8000', 'country' => 'ZA', ], 'collection_contact' => [ 'email' => config('mail.from.address'), 'mobile_number' => '+27000000000', // TODO: Get from AppSetting ], 'delivery_address' => [ 'type' => $order->shipping_type ?? 'residential', 'street_address' => $order->shipping_street_address, 'local_area' => $order->shipping_local_area, 'city' => $order->shipping_city, 'zone' => $order->shipping_zone, 'code' => $order->shipping_postcode, 'country' => $order->shipping_country ?? 'ZA', ], 'delivery_contact' => [ 'name' => $order->customer_name, 'email' => $order->customer_email, 'mobile_number' => $order->customer_phone, ], 'parcels' => [ [ 'weight' => $weight, 'height' => 10, // TODO: Update when height is captured separately 'width' => $width, 'length' => $length, ], ], 'service_level_id' => $this->getServiceLevelId(), // Standard delivery 'customer_reference' => $order->order_number, 'mute_notifications' => false, ]; Log::info('Creating Shiplogic shipment', [ 'order_id' => $orderId, 'order_number' => $order->order_number, 'customer' => $order->customer_name, 'delivery_address' => $street, ]); $response = Http::withHeaders([ 'Authorization' => "Bearer {$this->apiKey}", ])->post("{$this->baseUrl}/shipments", $payload); if (! $response->successful()) { $errorMessage = $response->json('error.message', $response->json('message', 'Unknown error')); throw new \Exception("Courier API error: {$errorMessage}"); } $data = $response->json(); return [ 'shipment_id' => $data['id'] ?? null, 'waybill_id' => $data['waybill_number'] ?? null, 'tracking_number' => $data['tracking_number'] ?? null, ]; } catch (\Exception $e) { Log::error('Failed to call courier API', [ 'order_id' => $orderId, 'error' => $e->getMessage(), ]); throw $e; } } /** * Get service level ID for standard delivery * TODO: Move to AppSetting and make configurable */ private function getServiceLevelId(): int { return 1; // Standard service level } /** * Validate packing gate: order must be packed with dimensions */ private function validatePacking(Order $order): bool { $valid = $order->packing_completed_at !== null && $order->packing_width > 0 && $order->packing_length > 0 && $order->packing_weight > 0; if (! $valid) { Log::warning('Packing validation failed', [ 'order_uuid' => $order->uuid, 'packing_completed_at' => $order->packing_completed_at, 'width' => $order->packing_width, 'length' => $order->packing_length, 'weight' => $order->packing_weight, ]); } return $valid; } /** * Validate payment and approval gate */ private function validatePayment(Order $order): bool { // Standard orders: must be fully paid if ($order->is_custom_order === false) { $valid = $order->payment_status === 'paid'; } else { // Custom orders: proof must be approved and balance must be paid // TODO: Add proof_approved and balance_status fields to CustomOrder model $valid = true; // Placeholder } if (! $valid) { Log::warning('Payment validation failed', [ 'order_uuid' => $order->uuid, 'is_custom_order' => $order->is_custom_order, 'payment_status' => $order->payment_status, ]); } return $valid; } /** * Fetch shipping label/sticker PDF from Shiplogic */ public function fetchSticker(string $shipmentId, string $orderId): ?string { if (! $this->isConfigured()) { return null; } try { $response = Http::withHeaders([ 'Authorization' => "Bearer {$this->apiKey}", ])->get("{$this->baseUrl}/shipments/{$shipmentId}/sticker"); if ($response->successful()) { $path = "shipments/{$orderId}/sticker.pdf"; Storage::disk('public')->put($path, $response->body()); return $path; } Log::warning('Failed to fetch sticker from courier', ['shipment_id' => $shipmentId]); return null; } catch (\Exception $e) { Log::error('Exception fetching sticker', ['error' => $e->getMessage()]); return null; } } /** * Fetch waybill PDF from Shiplogic */ public function fetchWaybill(string $shipmentId, string $orderId): ?string { if (! $this->isConfigured()) { return null; } try { $response = Http::withHeaders([ 'Authorization' => "Bearer {$this->apiKey}", ])->get("{$this->baseUrl}/shipments/{$shipmentId}/label"); if ($response->successful()) { $path = "shipments/{$orderId}/waybill.pdf"; Storage::disk('public')->put($path, $response->body()); return $path; } Log::warning('Failed to fetch waybill from courier', ['shipment_id' => $shipmentId]); return null; } catch (\Exception $e) { Log::error('Exception fetching waybill', ['error' => $e->getMessage()]); return null; } } /** * Check if courier is configured */ protected function isConfigured(): bool { return ! empty($this->apiKey); } }