Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
126 / 126
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
Fedex
100.00% covered (success)
100.00%
126 / 126
100.00% covered (success)
100.00%
4 / 4
41
100.00% covered (success)
100.00%
1 / 1
 fetchRates
100.00% covered (success)
100.00%
72 / 72
100.00% covered (success)
100.00%
1 / 1
17
 parseRatesResponse
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 getTracking
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
9
 parseTrackingResponse
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <dev@noladev.com>
8 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Shipping\Adapter;
16
17use Pop\Shipping\Client\AbstractShippingClient;
18
19/**
20 * Pop shipping FedEx adapter class
21 *
22 * @category   Pop
23 * @package    Pop\Shipping
24 * @author     Nick Sagona, III <dev@noladev.com>
25 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
26 * @license    https://www.popphp.org/license     New BSD License
27 * @version    4.0.0
28 */
29class Fedex extends AbstractAdapter
30{
31
32    /**
33     * Production API URL
34     * @var ?string
35     */
36    protected ?string $prodApiUrl = AbstractShippingClient::FEDEX_PROD_API_URL;
37
38    /**
39     * Test API URL
40     * @var ?string
41     */
42    protected ?string $testApiUrl = AbstractShippingClient::FEDEX_TEST_API_URL;
43
44    /**
45     * Rates API URL
46     * @var ?string
47     */
48    protected ?string $ratesApiUrl = '/rate/v1/rates/quotes'; // POST
49
50    /**
51     * Tracking API URL
52     * @var ?string
53     */
54    protected ?string $trackingApiUrl = '/track/v1/trackingnumbers'; // POST
55
56    /**
57     * Fetch rates from the API
58     *
59     * @throws Exception
60     * @return array
61     */
62    public function fetchRates(): array
63    {
64        if (!$this->hasClient()) {
65            throw new Exception('Error: There is no HTTP client for this shipping adapter.');
66        }
67
68        $shipper   = ['address' => []];
69        $recipient = ['address' => []];
70        $packages  = [];
71
72        if (!empty($this->shipFrom['address1'])) {
73            $shipper['address']['streetLines'] = [$this->shipFrom['address1']];
74            if (!empty($this->shipFrom['address2'])) {
75                $shipper['address']['streetLines'][] = $this->shipFrom['address2'];
76            }
77        }
78        if (!empty($this->shipFrom['city'])) {
79            $shipper['address']['city'] = $this->shipFrom['city'];
80        }
81        if (!empty($this->shipFrom['state'])) {
82            $shipper['address']['stateOrProvinceCode'] = $this->shipFrom['state'];
83        }
84        $shipper['address']['postalCode']  = $this->shipFrom['postal_code'];
85        $shipper['address']['countryCode'] = $this->shipFrom['country'] ?? 'US';
86        $shipper['address']['residential'] = (bool)$this->shipFrom['residential'];
87
88        if (!empty($this->shipTo['address1'])) {
89            $recipient['address']['streetLines'] = [$this->shipTo['address1']];
90            if (!empty($this->shipTo['address2'])) {
91                $recipient['address']['streetLines'][] = $this->shipTo['address2'];
92            }
93        }
94        if (!empty($this->shipTo['city'])) {
95            $recipient['address']['city'] = $this->shipTo['city'];
96        }
97        if (!empty($this->shipTo['state'])) {
98            $recipient['address']['stateOrProvinceCode'] = $this->shipTo['state'];
99        }
100        $recipient['address']['postalCode']  = $this->shipTo['postal_code'];
101        $recipient['address']['countryCode'] = $this->shipTo['country'] ?? 'US';
102        $recipient['address']['residential'] = (bool)$this->shipTo['residential'];
103
104        foreach ($this->packages as $package) {
105            $pkg = [
106                'weight' => [
107                    'value' => $package->getWeight(),
108                    'units'  => $package->getWeightUnit()
109                ],
110                'dimensions' => [
111                    'width'  => $package->getWidth(),
112                    'height' => $package->getHeight(),
113                    'length' => $package->getDepth(),
114                    'units'  => $package->getDimensionUnit()
115                ]
116            ];
117
118            if ($package->hasValue()) {
119                $pkg['declaredValue'] = [
120                    'amount'   => $package->getValue(),
121                    'currency' => $package->getValueUnit()
122                ];
123            }
124
125            $packages[] = $pkg;
126        }
127
128        $data = [
129            'accountNumber' => [
130                'value' => $this->authClient->getAccountNumber()
131            ],
132            'requestedShipment' => [
133                'shipper'                   => $shipper,
134                'recipient'                 => $recipient,
135                'requestedPackageLineItems' => $packages,
136                'rateRequestType'           => ['LIST'],
137                'pickupType'                => $this->shipType ?? 'DROPOFF_AT_FEDEX_LOCATION'
138            ],
139        ];
140
141        $this->client->reset()->setData($data);
142
143        $response = $this->client->post($this->ratesApiUrl);
144
145        if ($response->isSuccess()) {
146            $this->response = $response->getParsedResponse();
147        } else {
148            $this->errorCode = $response->getCode();
149            $parsedResponse  = $response->getParsedResponse();
150            if (is_string($parsedResponse)) {
151                $parsedResponse = json_decode($parsedResponse, true);
152            }
153            if (isset($parsedResponse['errors'])) {
154                $errorMessages = [];
155                foreach ($parsedResponse['errors'] as $error) {
156                    $errorMessages[] = $error['message'] . ' (' . $error['code'] . ')';
157                }
158                $this->errorMessage = implode('; ', $errorMessages);
159            }
160        }
161
162        return (!empty($this->response)) ? $this->parseRatesResponse() : [];
163    }
164
165    /**
166     * Parse rates response
167     *
168     * @return array
169     */
170    public function parseRatesResponse(): array
171    {
172        $this->rates = [];
173
174        if (!empty($this->response) && is_array($this->response) &&
175            isset($this->response['output']) && isset($this->response['output']['rateReplyDetails'])) {
176            foreach ($this->response['output']['rateReplyDetails'] as $rateReplyDetail) {
177                if (!empty($rateReplyDetail['ratedShipmentDetails'][0]['totalNetCharge'])) {
178                    $this->rates[] = [
179                        'service'     => 'Fedex',
180                        'serviceType' => $rateReplyDetail['serviceType'],
181                        'serviceName' => $rateReplyDetail['serviceName'],
182                        'totalCharge' => number_format($rateReplyDetail['ratedShipmentDetails'][0]['totalNetCharge'], 2, '.', '')
183                    ];
184                }
185            }
186
187            usort($this->rates, fn($a, $b) => $a['totalCharge'] <=> $b['totalCharge']);
188        }
189
190        return $this->rates;
191    }
192
193    /**
194     * Get tracking
195     *
196     * @param  string|array|null $trackingNumbers
197     * @throws Exception
198     * @return array
199     */
200    public function getTracking(string|array|null $trackingNumbers = null): array
201    {
202        if (!$this->hasClient()) {
203            throw new Exception('Error: There is no HTTP client for this shipping adapter.');
204        }
205        if ($trackingNumbers !== null) {
206            if (is_array($trackingNumbers)) {
207                $this->addTrackingNumbers($trackingNumbers);
208            } else {
209                $this->addTrackingNumber($trackingNumbers);
210            }
211        }
212
213        if (!$this->hasTrackingNumbers()) {
214            throw new Exception('Error: No tracking numbers have been passed.');
215        }
216
217        $responses = [];
218
219        foreach ($this->trackingNumbers as $trackingNumber) {
220            $data = [
221                'includeDetailedScans' => true,
222                'trackingInfo'         => []
223            ];
224            $data['trackingInfo'][] = [
225                'trackingNumberInfo' => [
226                    'trackingNumber' => $trackingNumber
227                ]
228            ];
229
230            $this->client->reset()->setData($data);
231
232            $response = $this->client->post($this->trackingApiUrl);
233
234            if ($response->isSuccess()) {
235                $responses[] = $response->getParsedResponse();
236            }
237        }
238
239        if (!empty($responses)) {
240            $this->response = $responses;
241        }
242
243        return (!empty($this->response)) ? $this->parseTrackingResponse() : [];
244    }
245
246    /**
247     * Parse tracking response
248     *
249     * @return array
250     */
251    public function parseTrackingResponse(): array
252    {
253        $results = [];
254
255        if (!empty($this->response) && is_array($this->response)) {
256            foreach ($this->response as $response) {
257                if (isset($response['output']) && isset($response['output']['completeTrackResults'])) {
258                    foreach ($response['output']['completeTrackResults'] as $completeTrackResult) {
259                        $results[$completeTrackResult['trackingNumber']] = [];
260                        foreach ($completeTrackResult['trackResults'][0]['scanEvents'] as $scanEvent) {
261                            $results[$completeTrackResult['trackingNumber']][] = [
262                                'status'           => $scanEvent['derivedStatus'],
263                                'eventType'        => $scanEvent['eventType'],
264                                'eventDescription' => $scanEvent['eventDescription'],
265                                'dateTime'         => $scanEvent['date']
266                            ];
267                        }
268
269                        usort($results[$completeTrackResult['trackingNumber']], fn($a, $b) => $a['dateTime'] <=> $b['dateTime']);
270                    }
271                }
272            }
273        }
274
275        return $results;
276    }
277
278}