Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
141 / 141
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
Ups
100.00% covered (success)
100.00%
141 / 141
100.00% covered (success)
100.00%
4 / 4
47
100.00% covered (success)
100.00%
1 / 1
 fetchRates
100.00% covered (success)
100.00%
88 / 88
100.00% covered (success)
100.00%
1 / 1
20
 parseRatesResponse
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 getTracking
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
10
 parseTrackingResponse
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
11
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 UPS 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 Ups extends AbstractAdapter
30{
31
32    /**
33     * Production API URL
34     * @var ?string
35     */
36    protected ?string $prodApiUrl = AbstractShippingClient::UPS_PROD_API_URL;
37
38    /**
39     * Test API URL
40     * @var ?string
41     */
42    protected ?string $testApiUrl = AbstractShippingClient::UPS_TEST_API_URL;
43
44    /**
45     * Rates API URL
46     * @var ?string
47     */
48    protected ?string $ratesApiUrl = '/api/rating/v2403/Shop'; // POST
49
50    /**
51     * Tracking API URL
52     * @var ?string
53     */
54    protected ?string $trackingApiUrl = '/api/track/v1/details/'; // GET - requires the tracking number to be appended to the URL
55
56    /**
57     * UPS Shipping Services
58     * @var array
59     */
60    protected array $shippingServices = [
61        '01' => 'Next Day Air',
62        '02' => '2nd Day Air',
63        '03' => 'Ground',
64        '12' => '3 Day Select',
65        '13' => 'Next Day Air Saver',
66        '14' => 'Next Day Air Early',
67        '59' => '2nd Day Air A.M.',
68        '75' => 'Heavy Goods',
69        '07' => 'Worldwide Express',
70        '08' => 'Worldwide Expedited',
71        '11' => 'Standard',
72        '54' => 'Worldwide Express Plus',
73        '65' => 'Worldwide Saver',
74        '70' => 'Access Point Economy',
75        '82' => 'Today Standard',
76        '83' => 'Today Dedicated Courier',
77        '84' => 'Today Intercity',
78        '85' => 'Today Express',
79        '86' => 'Today Express Saver',
80        '96' => 'Worldwide Express Freight',
81    ];
82
83    /**
84     * Fetch rates from the API
85     *
86     * @throws Exception
87     * @return array
88     */
89    public function fetchRates(): array
90    {
91        if (!$this->hasClient()) {
92            throw new Exception('Error: There is no HTTP client for this shipping adapter.');
93        }
94
95        $shipper   = [];
96        $recipient = [];
97        $packages  = [];
98
99        if (!empty($this->shipFrom['address1'])) {
100            $shipper['Address']['AddressLine'] = [$this->shipFrom['address1']];
101            if (!empty($this->shipFrom['address2'])) {
102                $shipper['Address']['AddressLine'] = $this->shipFrom['address2'];
103            }
104        }
105        if (!empty($this->shipFrom['city'])) {
106            $shipper['Address']['City'] = $this->shipFrom['city'];
107        }
108        if (!empty($this->shipFrom['state'])) {
109            $shipper['Address']['StateProvinceCode'] = $this->shipFrom['state'];
110        }
111        $shipper['Address']['PostalCode']  = $this->shipFrom['postal_code'];
112        $shipper['Address']['CountryCode'] = $this->shipFrom['country'] ?? 'US';
113
114        if (!empty($this->shipTo['address1'])) {
115            $recipient['Address']['AddressLine'] = [$this->shipTo['address1']];
116            if (!empty($this->shipTo['address2'])) {
117                $recipient['Address']['AddressLine'] = $this->shipTo['address2'];
118            }
119        }
120        if (!empty($this->shipTo['city'])) {
121            $recipient['Address']['City'] = $this->shipTo['city'];
122        }
123        if (!empty($this->shipTo['state'])) {
124            $recipient['Address']['StateProvinceCode'] = $this->shipTo['state'];
125        }
126        $recipient['Address']['PostalCode']  = $this->shipTo['postal_code'];
127        $recipient['Address']['CountryCode'] = $this->shipTo['country'] ?? 'US';
128
129        foreach ($this->packages as $package) {
130            if ($package->getDimensionUnit() == 'OZ') {
131                $weightUnitDesc = 'Ounces';
132            } else if ($package->getDimensionUnit() == 'KG') {
133                $weightUnitDesc = 'Kilograms';
134            } else {
135                $weightUnitDesc = 'Pounds';
136            }
137
138            $pkg = [
139                "PackagingType" => [
140                    "Code" => "02",
141                    "Description" => "Packaging"
142                ],
143                'Dimensions' => [
144                    'Width'  => (string)$package->getWidth(),
145                    'Height' => (string)$package->getHeight(),
146                    'Length' => (string)$package->getDepth(),
147                    'UnitOfMeasurement' => [
148                        'Code'        => $package->getDimensionUnit(),
149                        'Description' => ($package->getDimensionUnit() == 'IN') ? 'Inches' : 'Centimeters'
150                    ]
151                ],
152                'PackageWeight' => [
153                    'Weight' => (string)$package->getWeight(),
154                    'UnitOfMeasurement' => [
155                        'Code'        => $package->getWeightUnit() . 'S',
156                        'Description' => $weightUnitDesc
157                    ]
158                ]
159            ];
160
161            if ($package->hasValue()) {
162                $pkg['declaredValue'] = [
163                    'amount'   => $package->getValue(),
164                    'currency' => $package->getValueUnit()
165                ];
166            }
167
168            $packages[] = $pkg;
169        }
170
171        $data = [
172            'RateRequest' => [
173                'Request' => [
174                    'RequestOption' => 'SHOP'
175                ],
176                'Shipment'   => [
177                    'Shipper'     => $shipper,
178                    'ShipTo'      => $recipient,
179                    'ShipFrom'    => $shipper,
180                    'NumOfPieces' => count($this->packages),
181                    'Package'     => $packages
182                ]
183            ]
184        ];
185
186        $transSource = $this->authClient->isProduction() ? $this->userAgent : 'testing';
187
188        $this->client->reset()->setData($data);
189        $this->client->addHeader('transId', uniqid())
190            ->addHeader('transactionSrc', $transSource);
191
192        $response = $this->client->post($this->ratesApiUrl);
193
194        if ($response->isSuccess()) {
195            $this->response = $response->getParsedResponse();
196        } else {
197            $this->errorCode = $response->getCode();
198            $parsedResponse  = $response->getParsedResponse();
199            if (isset($parsedResponse['response']['errors'])) {
200                $errorMessages = [];
201                foreach ($parsedResponse['response']['errors'] as $error) {
202                    $errorMessages[] = $error['message'] . ' (' . $error['code'] . ')';
203                }
204                $this->errorMessage = implode('; ', $errorMessages);
205            }
206        }
207
208        return (!empty($this->response)) ? $this->parseRatesResponse() : [];
209    }
210
211    /**
212     * Parse rates response
213     *
214     * @return array
215     */
216    public function parseRatesResponse(): array
217    {
218        $this->rates = [];
219
220        if (!empty($this->response) && is_array($this->response) &&
221            isset($this->response['RateResponse']) && isset($this->response['RateResponse']['RatedShipment'])) {
222            foreach ($this->response['RateResponse']['RatedShipment'] as $ratedShipment) {
223                $this->rates[] = [
224                    'service'     => 'UPS',
225                    'serviceType' => $ratedShipment['Service']['Code'],
226                    'serviceName' => $this->shippingServices[$ratedShipment['Service']['Code']] ?? null,
227                    'totalCharge' => number_format((float)$ratedShipment['TotalCharges']['MonetaryValue'], 2, '.', '')
228                ];
229            }
230
231            usort($this->rates, fn($a, $b) => $a['totalCharge'] <=> $b['totalCharge']);
232        }
233
234        return $this->rates;
235    }
236
237    /**
238     * Get tracking
239     *
240     * @param  string|array|null $trackingNumbers
241     * @throws Exception
242     * @return array
243     */
244    public function getTracking(string|array|null $trackingNumbers = null): array
245    {
246        if (!$this->hasClient()) {
247            throw new Exception('Error: There is no HTTP client for this shipping adapter.');
248        }
249        if ($trackingNumbers !== null) {
250            if (is_array($trackingNumbers)) {
251                $this->addTrackingNumbers($trackingNumbers);
252            } else {
253                $this->addTrackingNumber($trackingNumbers);
254            }
255        }
256
257        if (!$this->hasTrackingNumbers()) {
258            throw new Exception('Error: No tracking numbers have been passed.');
259        }
260
261        $responses   = [];
262        $transSource = $this->authClient->isProduction() ? $this->userAgent : 'testing';
263
264        $this->client->reset();
265        $this->client->addHeader('transactionSrc', $transSource);
266
267        foreach ($this->trackingNumbers as $trackingNumber) {
268            $this->client->addHeader('transId', uniqid());
269            $response = $this->client->get($this->trackingApiUrl . $trackingNumber);
270            if ($response->isSuccess()) {
271                $responses[] = $response->getParsedResponse();
272            }
273            $this->client->reset();
274        }
275
276        if (!empty($responses)) {
277            $this->response = $responses;
278        }
279
280        return (!empty($this->response)) ? $this->parseTrackingResponse() : [];
281    }
282
283    /**
284     * Parse tracking response
285     *
286     * @return array
287     */
288    public function parseTrackingResponse(): array
289    {
290        $results = [];
291
292        if (!empty($this->response) && is_array($this->response)) {
293            foreach ($this->response as $response) {
294                if (isset($response['trackResponse']) && isset($response['trackResponse']['shipment'])) {
295                    foreach ($response['trackResponse']['shipment'] as $shipment) {
296                        if (isset($shipment['package'][0]) && isset($shipment['package'][0]['activity'])) {
297                            $results[$shipment['inquiryNumber']] = [];
298                            foreach ($shipment['package'][0]['activity'] as $activity) {
299                                $date = substr($activity['date'], 0, 4) . '-' . substr($activity['date'], 4, 2) . '-' . substr($activity['date'], 6, 2);
300                                $time = substr($activity['time'], 0, 2) . ':' . substr($activity['time'], 2, 2) . ':' . substr($activity['time'], 4, 2);
301
302                                $results[$shipment['inquiryNumber']][] = [
303                                    'status'           => $activity['status']['statusCode'],
304                                    'eventType'        => $activity['status']['type'],
305                                    'eventDescription' => $activity['status']['description'],
306                                    'dateTime'         => $date . ' ' . $time
307                                ];
308                            }
309
310                            usort($results[$shipment['inquiryNumber']], fn($a, $b) => $a['dateTime'] <=> $b['dateTime']);
311                        } else if (isset($shipment['warnings'][0]['message'])) {
312                            $results[$shipment['inquiryNumber']] = $shipment['warnings'][0]['message'];
313                        }
314                    }
315                }
316            }
317        }
318
319        return $results;
320    }
321
322}