Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 143
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
USPS_Address_Validator
0.00% covered (danger)
0.00%
0 / 143
0.00% covered (danger)
0.00%
0 / 4
756
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 wc_address_array_to_usps_address_object
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 validate
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 1
110
 add_address_common_mistakes
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 1
240
1<?php
2/**
3 * @see https://www.usps.com/business/web-tools-apis/address-information-api.htm
4 */
5
6namespace BrianHenryIE\WC_Address_Validation\API\Validators;
7
8use BrianHenryIE\WC_Address_Validation\API\Address_Validator_Interface;
9use BrianHenryIE\WC_Address_Validation\USPS\Address;
10use BrianHenryIE\WC_Address_Validation\USPS\AddressVerify;
11use Psr\Log\LoggerAwareTrait;
12use Psr\Log\LoggerInterface;
13
14class USPS_Address_Validator implements Address_Validator_Interface {
15
16    use LoggerAwareTrait;
17
18    /**
19     * @var AddressVerify
20     */
21    protected AddressVerify $address_verify;
22
23    public function __construct( AddressVerify $address_verify, LoggerInterface $logger ) {
24
25        $this->setLogger( $logger );
26        $this->address_verify = $address_verify;
27
28        $this->address_verify->setRevision( 1 );
29    }
30
31    /**
32     * Given an address array from WooCommerce (i.e. keyed as WooCommerce does), this returns an object as expected by
33     * the USPS validator. In particular, the USPS Address object is sensitive to the order in which the address parts are added.
34     *
35     * @param array array{address_1: string, address_2: string, city: string, state: string, postcode: string, country: string} $address
36     *
37     * @return Address
38     */
39    protected function wc_address_array_to_usps_address_object( array $address ): Address {
40        $address_to_validate = new Address();
41
42        // This needs to be set in order.
43        $address_to_validate->setApt( $address['address_2'] );
44        $address_to_validate->setAddress( $address['address_1'] );
45        $address_to_validate->setCity( $address['city'] );
46        $address_to_validate->setState( $address['state'] );
47        $address_to_validate->setZip5( substr( $address['postcode'], 0, 5 ) );
48        $address_to_validate->setZip4( '' );
49
50        return $address_to_validate;
51    }
52
53    /**
54     * TODO: AE (military) addresses cannot be validated.
55     *
56     * @param array{address_1: string, address_2: string, city: string, state: string, postcode: string, country: string} $address
57     * @return array{success: bool, original_address: array, updated_address: ?array, message: ?string, error_message: ?string}
58     */
59    public function validate( array $address ): array {
60
61        $result                     = array();
62        $original_address           = $address;
63        $result['original_address'] = $address;
64
65        // Since USPS always returns the address in all-uppercase letters, let's convert the input to
66        // all uppercase so we are later comparing like to like.
67        $address = array_map( 'strtoupper', $address );
68
69        // Remove plural 's.
70        $address = str_replace( '\'S', 'S', $address );
71
72        // Let's clean up punctuation which USPS does not like.
73        // TODO: is "#" ok?
74        // forward slash is ok, e.g. 123 1/2 22nd st.
75        $address = preg_replace( '/[^a-zA-Z\d\s\/]+/', ' ', $address );
76        // Trim and remove double spaces.
77        $address = array_map( 'normalize_whitespace', $address );
78
79        $address_to_validate = $this->wc_address_array_to_usps_address_object( $address );
80
81        if ( in_array( $address['state'], array( 'AE', 'AP', 'AA' ), true ) ) {
82
83            $result['success'] = true;
84
85            $message = 'Military address – cannot be validated, assumed to be correct. Sanitized from: `' . implode( ', ', array_filter( $original_address ) ) . '` to: `' . implode( ', ', array_filter( $address ) ) . '`';
86
87            $this->logger->debug( $message, $result );
88
89            $result['message'] = $message;
90
91            return $result;
92        }
93
94        // Add the address object to the address verify class
95        $this->address_verify->addAddress( $address_to_validate );
96
97        $this->add_address_common_mistakes( $address );
98
99        // Perform the request and return result
100        $xml_response   = $this->address_verify->verify();
101        $array_response = $this->address_verify->convertResponseToArray();
102
103        // See if it was successful
104        // AddressVerify::isSuccess() will return false if any are errors.
105        // success = true could still be everything error!
106        $success = isset( $array_response['AddressValidateResponse'] ) && isset( $array_response['AddressValidateResponse']['Address'] );
107
108        if ( ! $success ) {
109
110            // TODO: Check the reason for failure... e.g. timeout rather than bad address.
111
112            // "Multiple addresses were found" (not really here).
113            // "Peer’s Certificate has expired."
114
115            $result['success'] = false;
116
117            $error_message = 'USPS Address Information API failed validation: ' . $this->address_verify->getErrorMessage() . "\n" . implode( ', ', array_filter( $original_address ) );
118
119            $result['error_message'] = $error_message;
120
121            $this->logger->error(
122                $error_message,
123                array(
124                    'request'        => $this->address_verify->getPostData(),
125                    'error_message'  => $this->address_verify->getErrorMessage(),
126                    'xml_response'   => $xml_response,
127                    'array_response' => $array_response,
128                    'address'        => $address,
129                )
130            );
131
132            return $result;
133
134        } else {
135
136            $response_address = $array_response['AddressValidateResponse']['Address'];
137
138            // If one address was returned.
139            if ( isset( $response_address['@attributes'] ) ) {
140                $returned_addresses = array( $response_address );
141            } else {
142                // If many were found, especially when common errors were found in the input.
143                $returned_addresses = $response_address;
144            }
145
146            foreach ( $returned_addresses as $index => $usps_address ) {
147
148                // If it's an error, continue.
149                // Later, use $returned_addresses[0] for detailed error message if none found at all.
150                if ( isset( $usps_address['Error'] ) ) {
151
152                    continue;
153
154                } else {
155
156                    $updated_address = array();
157
158                    // WooCommerce address_1 is the building and street, whereas USPS Address1 is the apartment number.
159                    $updated_address['address_1'] = $usps_address['Address2'];
160                    $updated_address['address_2'] = isset( $usps_address['Address1'] ) ? $usps_address['Address1'] : '';
161                    $updated_address['city']      = $usps_address['City'];
162                    $updated_address['state']     = $usps_address['State'];
163                    $updated_address['country']   = 'US';
164
165                    $zip = $usps_address['Zip5'];
166
167                    // Not all addresses have a +4 zip code.
168                    if ( ! empty( $usps_address['Zip4'] ) ) {
169                        $zip .= '-' . $usps_address['Zip4'];
170                    }
171                    $updated_address['postcode'] = $zip;
172
173                    $result['updated_address'] = $updated_address;
174
175                    $result['success'] = true;
176
177                    $message = "Shipping address validated by USPS Address Verification API. \n\nOld address was: \n`" . implode( ", \n", array_filter( $original_address ) ) . "`. \n\nNew address is: \n`" . implode( ", \n", array_filter( $updated_address ) ) . '`.';
178
179                    $result['message'] = $message;
180
181                    $this->logger->debug( preg_replace( '/\n+/', '', $message ) );
182
183                    return $result;
184                }
185
186                // If it's a match, break.
187
188                // TODO: This is a weird scenario, but it has happened.
189                // TODO: This shouldn't be in here...
190                if ( $address['state'] !== $usps_address['State'] ) {
191                    $error_message = 'State returned from USPS ' . $usps_address['State'] . ' did not match customer supplied state ' . strtoupper( $address['state'] );
192                    $this->logger->notice( $error_message, array( 'array_response' => $array_response ) );
193
194                    $result['error_message'] = $error_message;
195                    $result['success']       = false;
196
197                    return $result;
198                }
199            }
200
201            // No valid address found.
202            $usps_address = $returned_addresses[0];
203
204            $message = $usps_address['Error']['Description'] . ' for address: `' . implode( ', ', array_filter( $original_address ) ) . '`.';
205
206            $result['success'] = false;
207
208            $this->logger->debug( $message, $result );
209
210            $result['error_message'] = $message;
211
212            return $result;
213
214        }
215    }
216
217
218    /**
219     * Probably unnecessary to apply every combination of these?
220     *
221     * TODO: document if the supplied address is assumed to be uppercase
222     *
223     * @param array $address
224     *
225     * @return void
226     */
227    protected function add_address_common_mistakes( array $address ) {
228
229        // Switch line one and line two
230        if ( ! empty( $address['address_1'] && ! empty( $address['address_2'] ) ) ) {
231            $new_address              = $address;
232            $new_address['address_1'] = $address['address_2'];
233            $new_address['address_2'] = $address['address_1'];
234            $this->address_verify->addAddress( $this->wc_address_array_to_usps_address_object( $new_address ) );
235            unset( $new_address );
236        }
237
238        $us_to_highway = preg_replace( '/(\d+\s)(US)(\s\d+.*)/', ' $1Highway$3', $address['address_1'] );
239        if ( $us_to_highway !== $address['address_1'] ) {
240            $new_address              = $address;
241            $new_address['address_1'] = $us_to_highway;
242            $this->address_verify->addAddress( $this->wc_address_array_to_usps_address_object( $new_address ) );
243            unset( $new_address );
244        }
245
246        // Remove space in "W123 N123 ...".
247        $remove_space = preg_replace( '/(\w\d+)\s(\w\d+.*)/', '$1$2', $address['address_1'] );
248        if ( $remove_space !== $address['address_1'] ) {
249            $new_address              = $address;
250            $new_address['address_1'] = $remove_space;
251            $this->address_verify->addAddress( $this->wc_address_array_to_usps_address_object( $new_address ) );
252            unset( $new_address );
253        }
254
255        // Remove an accidental space in the street number, e.g. 13 12 63st -> 1312 63rd st.
256        $remove_accidental_space = preg_replace( '/(\d+)\s(\d+\s.*)/', '$1$2', $address['address_1'] );
257        if ( $remove_accidental_space !== $address['address_1'] ) {
258            $new_address              = $address;
259            $new_address['address_1'] = $remove_accidental_space;
260            $this->address_verify->addAddress( $this->wc_address_array_to_usps_address_object( $new_address ) );
261            unset( $new_address );
262        }
263
264        // Replace the word Interstate with just the initial I.
265        if ( false !== strpos( $address['address_1'], 'INTERSTATE' ) ) {
266            $new_address              = $address;
267            $new_address['address_1'] = str_replace( 'INTERSTATE', 'I', $address['address_1'] );
268            $this->address_verify->addAddress( $this->wc_address_array_to_usps_address_object( $new_address ) );
269            unset( $new_address );
270        }
271
272        // Replace the word DRIVE with just DR.
273        if ( false !== strpos( $address['address_1'], 'DRIVE' ) ) {
274            $new_address              = $address;
275            $new_address['address_1'] = str_replace( 'DRIVE', 'DR', $address['address_1'] );
276            $this->address_verify->addAddress( $this->wc_address_array_to_usps_address_object( $new_address ) );
277            unset( $new_address );
278        }
279
280        // If "1 APT" is written, swap it to be "APT 1".
281        $num_apt_swap = preg_replace( '/(\d+)\s(.+)/', '$2 $1', $address['address_2'] );
282        if ( $num_apt_swap !== $address['address_2'] ) {
283            $new_address              = $address;
284            $new_address['address_2'] = $num_apt_swap;
285            $this->address_verify->addAddress( $this->wc_address_array_to_usps_address_object( $new_address ) );
286            unset( $new_address );
287        }
288
289        // Try merging address_1 and address_2, sometimes people write the house number on one line and the street on the second.
290        $new_address              = $address;
291        $new_address['address_1'] = $new_address['address_1'] . ' ' . $new_address['address_2'];
292        $new_address['address_2'] = '';
293        $this->address_verify->addAddress( $this->wc_address_array_to_usps_address_object( $new_address ) );
294        unset( $new_address );
295
296        // Sometimes autofill fills the complete address on line one, then the rest of the address as normal.
297        $address_parts = array( 'postcode', 'state', 'city', 'address_2', 'address_1' );
298        $new_address   = $address;
299        foreach ( $address_parts as $part_to_search ) {
300            if ( empty( $part_to_search ) ) {
301                continue;
302            }
303            foreach ( $address_parts as $part_to_edit ) {
304                if ( $part_to_edit === $part_to_search ) {
305                    continue;
306                }
307                $new_address[ $part_to_edit ] = normalize_whitespace( preg_replace( '/\b' . preg_quote( $new_address[ $part_to_search ], '/' ) . '\b/', '', $new_address[ $part_to_edit ] ) );
308            }
309        }
310        // If changes have been made, use the new address.
311        if ( implode( '', $address ) !== implode( '', $new_address ) ) {
312            $this->address_verify->addAddress( $this->wc_address_array_to_usps_address_object( $new_address ) );
313        }
314        unset( $new_address );
315
316        // If "RD" has been misspelled as "RE".
317        $re_to_rd = preg_replace( '/.*\bRE\b/', '$1RD', $address['address_1'] );
318        if ( $re_to_rd !== $address['address_1'] ) {
319            $new_address              = $address;
320            $new_address['address_1'] = $re_to_rd;
321            $this->address_verify->addAddress( $this->wc_address_array_to_usps_address_object( $new_address ) );
322            unset( $new_address );
323        }
324    }
325}