diff --git a/CHANGELOG.md b/CHANGELOG.md index ef3d79f..e401c3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,40 @@ CHANGELOG country fields no longer fail in string validators. Billing and shipping region and phone country code fields, and credit card bank phone country codes, also pass through unchanged. +* When input validation is disabled, the event `party` and `type`, shipping + `delivery_speed`, and payment `method` and `processor` fields now pass + through unchanged. Before, an array or object value in one of these fields + caused a conversion warning or an error. +* When input validation is disabled, `MaxMind\MinFraud\ReportTransaction` + now sends the `ip_address`, `maxmind_id`, `minfraud_id`, and `tag` fields + unchanged. Before, an array or object value in one of these fields caused a + type error, a conversion warning, or an error. +* When input validation is disabled, all other `MaxMind\MinFraud` request + fields now also pass through unchanged. Before, an unexpected value type in + fields such as the device `ip_address`, the email `address` and `domain`, + the event `time`, or the credit card `token` caused a type error, a + conversion warning, or an error. The device `session_age` and `session_id` + and the shopping cart `item_id` are still converted for valid values, but + other values are no longer converted. Before, for example, a non-numeric + `session_age` was sent as `0`. With `hashEmail` enabled, an email address + that is not a string is sent without hashing. +* The RFC 3339 check for the event `time` input now accepts any number of + fractional second digits. Before, it rejected values with more than three + digits, such as `2026-10-01T14:30:00.123456Z`. +* The RFC 3339 check for the event `time` input now rejects out-of-range date + and time values, such as `2026-02-30T10:00:00Z` or `2026-01-01T25:00:00Z`. + Before, PHP rolled these values over and the check accepted them. * The PHPDoc for the `with()` and `with*()` methods of `MaxMind\MinFraud` now lists the `InvalidInputException` they throw when input validation is enabled. +* Added the `phone_verification_method`, `phone_was_verification_successful`, + and `phone_verification_time` inputs to the `/billing` and `/shipping` + objects. These describe the most recent verification of the billing or + shipping phone number. Use them with `withBilling()` and `withShipping()` as + array keys or as the `phoneVerificationMethod`, + `phoneWasVerificationSuccessful`, and `phoneVerificationTime` named + arguments. Omit `phone_was_verification_successful` if no verification was + attempted. 3.7.0 (2026-07-21) ------------------ diff --git a/README.md b/README.md index 15a1ed8..09a3633 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,10 @@ $request = $mf->withDevice( country: 'US', postal: '06510', phoneNumber: '123-456-7890', - phoneCountryCode: '1' + phoneCountryCode: '1', + phoneVerificationMethod: 'delivered_code', + phoneWasVerificationSuccessful: true, + phoneVerificationTime: '2026-10-01T14:30:00Z' )->withShipping( firstName: 'ShipFirst', lastName: 'ShipLast', @@ -195,6 +198,9 @@ $request = $mf->withDevice( postal: '73003', phoneNumber: '123-456-0000', phoneCountryCode: '1', + phoneVerificationMethod: 'network', + phoneWasVerificationSuccessful: false, + phoneVerificationTime: '2026-10-01T14:31:00Z', deliverySpeed: 'same_day' )->withPayment( method: 'card', diff --git a/src/MinFraud.php b/src/MinFraud.php index 7b17b33..f08f189 100644 --- a/src/MinFraud.php +++ b/src/MinFraud.php @@ -247,16 +247,21 @@ public function withDevice( $v = $this->remove($values, 'session_age', ['double', 'float', 'integer', 'string']); if ($v !== null) { - if (!is_numeric($v)) { + if (is_numeric($v)) { + $v = (float) $v; + } else { $this->maybeThrowInvalidInputException('Expected session_age to be a number'); } - $sessionAge = (float) $v; + $sessionAge = $v; } $v = $this->remove($values, 'session_id', ['integer', 'string']); if ($v !== null) { - $sessionId = (string) $v; + if (\is_int($v)) { + $v = (string) $v; + } + $sessionId = $v; } $trackingToken = $this->remove($values, 'tracking_token'); @@ -270,22 +275,22 @@ public function withDevice( } if ($ipAddress !== null) { - if (!filter_var($ipAddress, \FILTER_VALIDATE_IP)) { + if ($this->validateInput && !filter_var($ipAddress, \FILTER_VALIDATE_IP)) { $this->maybeThrowInvalidInputException("$ipAddress is an invalid IP address"); } $values['ip_address'] = $ipAddress; } if ($sessionAge !== null) { - if ($sessionAge < 0) { + if ($this->validateInput && $sessionAge < 0) { $this->maybeThrowInvalidInputException("Session age ($sessionAge) must be greater than or equal to 0"); } $values['session_age'] = $sessionAge; } if ($sessionId !== null) { - if ($sessionId === '' - || \strlen($sessionId) > 255) { + if ($this->validateInput + && ($sessionId === '' || \strlen($sessionId) > 255)) { $this->maybeThrowInvalidInputException( "Session ID ($sessionId) must be a string with length between 1 and 255", ); @@ -382,7 +387,7 @@ public function withEvent( } if ($party !== null) { - if (!\in_array($party, ['agent', 'customer'], true)) { + if ($this->validateInput && !\in_array($party, ['agent', 'customer'], true)) { $this->maybeThrowInvalidInputException("$party is not a valid party"); } $values['party'] = $party; @@ -393,12 +398,9 @@ public function withEvent( } if ($time !== null) { - if (\DateTime::createFromFormat(\DateTime::RFC3339, $time) === false - && \DateTime::createFromFormat(\DateTime::RFC3339_EXTENDED, $time) === false - ) { - $this->maybeThrowInvalidInputException("$time is not a valid RFC 3339 formatted datetime string"); + if ($this->validateInput) { + $this->verifyRfc3339DateTime($time); } - $values['time'] = $time; } @@ -407,7 +409,7 @@ public function withEvent( } if ($type !== null) { - if (!\in_array($type, [ + if ($this->validateInput && !\in_array($type, [ 'account_creation', 'account_login', 'credit_application', @@ -477,7 +479,7 @@ public function withAccount( } if ($usernameMd5 !== null) { - if (!preg_match('/^[a-fA-F0-9]{32}$/', $usernameMd5)) { + if ($this->validateInput && !preg_match('/^[a-fA-F0-9]{32}$/', $usernameMd5)) { $this->maybeThrowInvalidInputException("$usernameMd5 must be an MD5"); } $values['username_md5'] = $usernameMd5; @@ -530,7 +532,8 @@ public function withEmail( } if ($address !== null) { - if (!filter_var($address, \FILTER_VALIDATE_EMAIL) + if ($this->validateInput + && !filter_var($address, \FILTER_VALIDATE_EMAIL) && !preg_match('/^[a-fA-F0-9]{32}$/', $address)) { $this->maybeThrowInvalidInputException("$address is an invalid email address or MD5"); } @@ -538,7 +541,9 @@ public function withEmail( } if ($domain !== null) { - if (!filter_var($domain, \FILTER_VALIDATE_DOMAIN, \FILTER_FLAG_HOSTNAME) || !str_contains($domain, '.')) { + if ($this->validateInput + && (!filter_var($domain, \FILTER_VALIDATE_DOMAIN, \FILTER_FLAG_HOSTNAME) || !str_contains($domain, '.')) + ) { $this->maybeThrowInvalidInputException("$domain is an invalid domain name"); } $values['domain'] = $domain; @@ -561,33 +566,45 @@ public function withEmail( * @link https://dev.maxmind.com/minfraud/api-documentation/requests/?lang=en#schema--request--billing * minFraud billing API docs * - * @param array $values An array of billing data. The keys are the same as - * the JSON keys. You may use either this or the named - * arguments, but not both. - * @param string|null $address The first line of the user's billing address - * @param string|null $address2 The second line of the user's billing address - * @param string|null $city The city of the user's billing address - * @param string|null $company The company of the end user as provided in - * their billing information - * @param string|null $country The two character ISO 3166-1 alpha-2 country - * code of the user's billing address - * @param string|null $firstName The first name of the end user as provided - * in their billing information - * @param string|null $lastName The last name of the end user as provided - * in their billing information - * @param string|null $phoneCountryCode The country code for phone number - * associated with the user's billing - * address. If you provide this - * information then you must provide - * at least one digit. - * @param string|null $phoneNumber The phone number without the country code - * for the user's billing address. Punctuation - * characters will be stripped. After - * stripping punctuation characters, the - * number must contain only digits. - * @param string|null $postal The postal code of the user's billing address - * @param string|null $region The ISO 3166-2 subdivision code for the user's - * billing address + * @param array $values An array of billing data. The keys are the same as + * the JSON keys. You may use either this or the named + * arguments, but not both. + * @param string|null $address The first line of the user's billing address + * @param string|null $address2 The second line of the user's billing address + * @param string|null $city The city of the user's billing address + * @param string|null $company The company of the end user as provided in + * their billing information + * @param string|null $country The two character ISO 3166-1 alpha-2 country + * code of the user's billing address + * @param string|null $firstName The first name of the end user as provided + * in their billing information + * @param string|null $lastName The last name of the end user as provided + * in their billing information + * @param string|null $phoneCountryCode The country code for phone number + * associated with the user's billing + * address. If you provide this + * information then you must provide + * at least one digit. + * @param string|null $phoneNumber The phone number without the country code + * for the user's billing address. Punctuation + * characters will be stripped. After + * stripping punctuation characters, the + * number must contain only digits. + * @param string|null $postal The postal code of the user's billing address + * @param string|null $region The ISO 3166-2 subdivision code for the user's + * billing address + * @param string|null $phoneVerificationMethod The most recent method used to verify + * the billing phone number: delivered_code, + * network, or other + * @param string|null $phoneVerificationTime The date and time of the most recent + * verification of the billing phone number, + * in RFC 3339 date-time format. The time + * must not be before 1970-01-01T00:00:00Z + * or more than one day in the future. + * @param bool|null $phoneWasVerificationSuccessful Whether the most recent verification + * of the billing phone number succeeded. + * Omit this if no verification was + * attempted. * * @throws InvalidInputException if input validation is enabled and a * value is invalid, a value has the wrong @@ -609,84 +626,35 @@ public function withBilling( ?string $phoneNumber = null, ?string $postal = null, ?string $region = null, + ?string $phoneVerificationMethod = null, + ?string $phoneVerificationTime = null, + ?bool $phoneWasVerificationSuccessful = null, ): self { - if (\count($values) !== 0) { - if (\func_num_args() !== 1) { - throw new \InvalidArgumentException( - 'You may only provide the $values array or named arguments, not both.', - ); - } - - $address = $this->remove($values, 'address'); - $address2 = $this->remove($values, 'address_2'); - $city = $this->remove($values, 'city'); - $company = $this->remove($values, 'company'); - $country = $this->remove($values, 'country'); - $firstName = $this->remove($values, 'first_name'); - $lastName = $this->remove($values, 'last_name'); - $phoneCountryCode = $this->remove($values, 'phone_country_code'); - $phoneNumber = $this->remove($values, 'phone_number'); - $postal = $this->remove($values, 'postal'); - $region = $this->remove($values, 'region'); - - $this->verifyEmpty($values); - } - - if ($address !== null) { - $values['address'] = $address; - } - - if ($address2 !== null) { - $values['address_2'] = $address2; - } - - if ($city !== null) { - $values['city'] = $city; - } - - if ($company !== null) { - $values['company'] = $company; - } - - if ($country !== null) { - if ($this->validateInput) { - $this->verifyCountryCode($country); - } - $values['country'] = $country; - } - - if ($firstName !== null) { - $values['first_name'] = $firstName; - } - - if ($lastName !== null) { - $values['last_name'] = $lastName; - } - - if ($phoneCountryCode !== null) { - if ($this->validateInput) { - $this->verifyPhoneCountryCode($phoneCountryCode); - } - $values['phone_country_code'] = $phoneCountryCode; - } - - if ($phoneNumber !== null) { - $values['phone_number'] = $phoneNumber; - } - - if ($postal !== null) { - $values['postal'] = $postal; - } - - if ($region !== null) { - if ($this->validateInput) { - $this->verifyRegionCode($region); - } - $values['region'] = $region; + if (\count($values) !== 0 && \func_num_args() !== 1) { + throw new \InvalidArgumentException( + 'You may only provide the $values array or named arguments, not both.', + ); } $new = clone $this; - $new->content['billing'] = $values; + $new->content['billing'] = $this->addressValues( + $values, + [], + $address, + $address2, + $city, + $company, + $country, + $firstName, + $lastName, + $phoneCountryCode, + $phoneNumber, + $postal, + $region, + $phoneVerificationMethod, + $phoneVerificationTime, + $phoneWasVerificationSuccessful, + ); return $new; } @@ -698,25 +666,36 @@ public function withBilling( * @link https://dev.maxmind.com/minfraud/api-documentation/requests/?lang=en#schema--request--shipping * minFraud shipping API docs * - * @param array $values An array of shipping data. The keys are the same as - * the JSON keys. You may use either this or the named - * arguments, but not both. - * @param string|null $company The company of the end user as provided in - * their shipping information - * @param string|null $address The first line of the user's shipping address - * @param string|null $city The city of the user's shipping address - * @param string|null $region The ISO 3166-2 subdivision code for the user's - * shipping address - * @param string|null $country The two character ISO 3166-1 alpha-2 country - * code of the user's shipping address - * @param string|null $postal The postal code of the user's shipping address - * @param string|null $address2 the second line of the shipping address - * @param string|null $deliverySpeed the delivery speed: same_day, overnight, - * expedited, or standard - * @param string|null $firstName the recipient's first name - * @param string|null $lastName the recipient's last name - * @param string|null $phoneCountryCode the phone country code, with 1 to 4 digits - * @param string|null $phoneNumber the recipient's phone number + * @param array $values An array of shipping data. The keys are the same as + * the JSON keys. You may use either this or the named + * arguments, but not both. + * @param string|null $company The company of the end user as provided in + * their shipping information + * @param string|null $address The first line of the user's shipping address + * @param string|null $city The city of the user's shipping address + * @param string|null $region The ISO 3166-2 subdivision code for the user's + * shipping address + * @param string|null $country The two character ISO 3166-1 alpha-2 country + * code of the user's shipping address + * @param string|null $postal The postal code of the user's shipping address + * @param string|null $address2 the second line of the shipping address + * @param string|null $deliverySpeed the delivery speed: same_day, overnight, + * expedited, or standard + * @param string|null $firstName the recipient's first name + * @param string|null $lastName the recipient's last name + * @param string|null $phoneCountryCode the phone country code, with 1 to 4 digits + * @param string|null $phoneNumber the recipient's phone number + * @param string|null $phoneVerificationMethod the most recent method used to verify + * the shipping phone number: delivered_code, + * network, or other + * @param string|null $phoneVerificationTime the date and time of the most recent + * verification of the shipping phone number, + * in RFC 3339 date-time format. The time + * must not be before 1970-01-01T00:00:00Z + * or more than one day in the future. + * @param bool|null $phoneWasVerificationSuccessful whether the most recent verification of + * the shipping phone number succeeded. Omit this + * if no verification was attempted. * * @throws InvalidInputException if input validation is enabled and a * value is invalid, a value has the wrong @@ -739,6 +718,9 @@ public function withShipping( ?string $phoneNumber = null, ?string $postal = null, ?string $region = null, + ?string $phoneVerificationMethod = null, + ?string $phoneVerificationTime = null, + ?bool $phoneWasVerificationSuccessful = null, ): self { if (\count($values) !== 0) { if (\func_num_args() !== 1) { @@ -747,85 +729,39 @@ public function withShipping( ); } - $address = $this->remove($values, 'address'); - $address2 = $this->remove($values, 'address_2'); - $city = $this->remove($values, 'city'); - $company = $this->remove($values, 'company'); - $country = $this->remove($values, 'country'); $deliverySpeed = $this->remove($values, 'delivery_speed'); - $firstName = $this->remove($values, 'first_name'); - $lastName = $this->remove($values, 'last_name'); - $phoneCountryCode = $this->remove($values, 'phone_country_code'); - $phoneNumber = $this->remove($values, 'phone_number'); - $postal = $this->remove($values, 'postal'); - $region = $this->remove($values, 'region'); - - $this->verifyEmpty($values); - } - - if ($address !== null) { - $values['address'] = $address; - } - - if ($address2 !== null) { - $values['address_2'] = $address2; - } - - if ($city !== null) { - $values['city'] = $city; - } - - if ($company !== null) { - $values['company'] = $company; - } - - if ($country !== null) { - if ($this->validateInput) { - $this->verifyCountryCode($country); - } - $values['country'] = $country; } + $fields = []; if ($deliverySpeed !== null) { - if (!\in_array($deliverySpeed, ['same_day', 'overnight', 'expedited', 'standard'], true)) { + if ($this->validateInput + && !\in_array($deliverySpeed, ['same_day', 'overnight', 'expedited', 'standard'], true) + ) { $this->maybeThrowInvalidInputException("$deliverySpeed is not a valid delivery speed"); } - $values['delivery_speed'] = $deliverySpeed; - } - - if ($firstName !== null) { - $values['first_name'] = $firstName; - } - - if ($lastName !== null) { - $values['last_name'] = $lastName; - } - - if ($phoneCountryCode !== null) { - if ($this->validateInput) { - $this->verifyPhoneCountryCode($phoneCountryCode); - } - $values['phone_country_code'] = $phoneCountryCode; - } - - if ($phoneNumber !== null) { - $values['phone_number'] = $phoneNumber; - } - - if ($postal !== null) { - $values['postal'] = $postal; - } - - if ($region !== null) { - if ($this->validateInput) { - $this->verifyRegionCode($region); - } - $values['region'] = $region; + $fields['delivery_speed'] = $deliverySpeed; } $new = clone $this; - $new->content['shipping'] = $values; + $new->content['shipping'] = $this->addressValues( + $values, + $fields, + $address, + $address2, + $city, + $company, + $country, + $firstName, + $lastName, + $phoneCountryCode, + $phoneNumber, + $postal, + $region, + $phoneVerificationMethod, + $phoneVerificationTime, + $phoneWasVerificationSuccessful, + ); return $new; } @@ -894,7 +830,7 @@ public function withPayment( } if ($method !== null) { - if (!\in_array($method, [ + if ($this->validateInput && !\in_array($method, [ 'bank_debit', 'bank_redirect', 'bank_transfer', @@ -912,7 +848,7 @@ public function withPayment( } if ($processor !== null) { - if (!\in_array($processor, [ + if ($this->validateInput && !\in_array($processor, [ 'adyen', 'affirm', 'afterpay', @@ -1173,7 +1109,7 @@ public function withCreditCard( } if ($avsResult !== null) { - if (\strlen($avsResult) !== 1) { + if ($this->validateInput && \strlen($avsResult) !== 1) { $this->maybeThrowInvalidInputException('AVS result must be a string of length 1.'); } $values['avs_result'] = $avsResult; @@ -1203,34 +1139,34 @@ public function withCreditCard( } if ($cvvResult !== null) { - if (\strlen($cvvResult) !== 1) { + if ($this->validateInput && \strlen($cvvResult) !== 1) { $this->maybeThrowInvalidInputException('CVV result must be a string of length 1.'); } $values['cvv_result'] = $cvvResult; } if ($issuerIdNumber !== null) { - if (!preg_match('/^(?:[0-9]{6}|[0-9]{8})$/', $issuerIdNumber)) { + if ($this->validateInput && !preg_match('/^(?:[0-9]{6}|[0-9]{8})$/', $issuerIdNumber)) { $this->maybeThrowInvalidInputException('Issuer ID number must be a string of 6 or 8 digits.'); } $values['issuer_id_number'] = $issuerIdNumber; } if ($lastDigits !== null) { - if (!preg_match('/^(?:[0-9]{2}|[0-9]{4})$/', $lastDigits)) { + if ($this->validateInput && !preg_match('/^(?:[0-9]{2}|[0-9]{4})$/', $lastDigits)) { $this->maybeThrowInvalidInputException('Last digits must be a string of 2 or 4 digits.'); } $values['last_digits'] = $lastDigits; } if ($token !== null) { - if (!preg_match('/^[\x21-\x7E]{1,255}$/', $token)) { + if ($this->validateInput && !preg_match('/^[\x21-\x7E]{1,255}$/', $token)) { $this->maybeThrowInvalidInputException( 'Credit card token must be a string of 1 to 255 printable ASCII characters.', ); } - if (preg_match('/^[0-9]{1,19}$/', $token)) { + if ($this->validateInput && preg_match('/^[0-9]{1,19}$/', $token)) { $this->maybeThrowInvalidInputException( 'Credit card token cannot look like a card number or part of one.', ); @@ -1366,14 +1302,14 @@ public function withOrder( } if ($amount !== null) { - if ($amount < 0) { + if ($this->validateInput && $amount < 0) { $this->maybeThrowInvalidInputException("$amount must be greater than or equal to 0"); } $values['amount'] = $amount; } if ($currency !== null) { - if (!preg_match('/^[A-Z]{3}$/', $currency)) { + if ($this->validateInput && !preg_match('/^[A-Z]{3}$/', $currency)) { $this->maybeThrowInvalidInputException("$currency is not a valid currency code"); } $values['currency'] = $currency; @@ -1392,7 +1328,7 @@ public function withOrder( } if ($referrerUri !== null) { - if (!filter_var($referrerUri, \FILTER_VALIDATE_URL)) { + if ($this->validateInput && !filter_var($referrerUri, \FILTER_VALIDATE_URL)) { $this->maybeThrowInvalidInputException("$referrerUri is not a valid URL"); } $values['referrer_uri'] = $referrerUri; @@ -1452,7 +1388,10 @@ public function withShoppingCartItem( $v = $this->remove($values, 'item_id', ['integer', 'string']); if ($v !== null) { - $itemId = (string) $v; + if (\is_int($v)) { + $v = (string) $v; + } + $itemId = $v; } $price = $this->remove($values, 'price', ['double', 'float', 'integer']); @@ -1470,14 +1409,14 @@ public function withShoppingCartItem( } if ($price !== null) { - if ($price < 0) { + if ($this->validateInput && $price < 0) { $this->maybeThrowInvalidInputException("$price must be greater than or equal to 0"); } $values['price'] = $price; } if ($quantity !== null) { - if ($quantity < 0) { + if ($this->validateInput && $quantity < 0) { $this->maybeThrowInvalidInputException("$quantity must be greater than or equal to 0"); } $values['quantity'] = $quantity; @@ -1587,6 +1526,171 @@ private function post(string $class, string $path) ); } + /** + * Builds the address array that withBilling() and withShipping() share. + * + * @param array $values the array argument of the public + * method, without section-specific keys + * @param array $fields validated section-specific fields + * + * @throws InvalidInputException if input validation is enabled and a + * value is invalid, a value has the wrong + * type, or a key is unknown + * + * @return array + */ + private function addressValues( + array $values, + array $fields, + ?string $address, + ?string $address2, + ?string $city, + ?string $company, + ?string $country, + ?string $firstName, + ?string $lastName, + ?string $phoneCountryCode, + ?string $phoneNumber, + ?string $postal, + ?string $region, + ?string $phoneVerificationMethod, + ?string $phoneVerificationTime, + ?bool $phoneWasVerificationSuccessful, + ): array { + if (\count($values) !== 0) { + $address = $this->remove($values, 'address'); + $address2 = $this->remove($values, 'address_2'); + $city = $this->remove($values, 'city'); + $company = $this->remove($values, 'company'); + $country = $this->remove($values, 'country'); + $firstName = $this->remove($values, 'first_name'); + $lastName = $this->remove($values, 'last_name'); + $phoneCountryCode = $this->remove($values, 'phone_country_code'); + $phoneNumber = $this->remove($values, 'phone_number'); + $phoneVerificationMethod = $this->remove($values, 'phone_verification_method'); + $phoneVerificationTime = $this->remove($values, 'phone_verification_time'); + $phoneWasVerificationSuccessful = $this->remove( + $values, + 'phone_was_verification_successful', + ['boolean'], + ); + $postal = $this->remove($values, 'postal'); + $region = $this->remove($values, 'region'); + + $this->verifyEmpty($values); + } + + if ($address !== null) { + $fields['address'] = $address; + } + + if ($address2 !== null) { + $fields['address_2'] = $address2; + } + + if ($city !== null) { + $fields['city'] = $city; + } + + if ($company !== null) { + $fields['company'] = $company; + } + + if ($country !== null) { + if ($this->validateInput) { + $this->verifyCountryCode($country); + } + $fields['country'] = $country; + } + + if ($firstName !== null) { + $fields['first_name'] = $firstName; + } + + if ($lastName !== null) { + $fields['last_name'] = $lastName; + } + + if ($phoneCountryCode !== null) { + if ($this->validateInput) { + $this->verifyPhoneCountryCode($phoneCountryCode); + } + $fields['phone_country_code'] = $phoneCountryCode; + } + + if ($phoneNumber !== null) { + $fields['phone_number'] = $phoneNumber; + } + + if ($phoneVerificationMethod !== null) { + if ($this->validateInput + && !\in_array($phoneVerificationMethod, ['delivered_code', 'network', 'other'], true) + ) { + $this->maybeThrowInvalidInputException( + "$phoneVerificationMethod is not a valid phone verification method", + ); + } + $fields['phone_verification_method'] = $phoneVerificationMethod; + } + + if ($phoneVerificationTime !== null) { + if ($this->validateInput) { + $this->verifyRfc3339DateTime($phoneVerificationTime); + } + $fields['phone_verification_time'] = $phoneVerificationTime; + } + + if ($phoneWasVerificationSuccessful !== null) { + $fields['phone_was_verification_successful'] = $phoneWasVerificationSuccessful; + } + + if ($postal !== null) { + $fields['postal'] = $postal; + } + + if ($region !== null) { + if ($this->validateInput) { + $this->verifyRegionCode($region); + } + $fields['region'] = $region; + } + + // Keep the keys in alphabetical order, with any unknown keys that + // pass through when validation is off first. + ksort($fields); + + return $values + $fields; + } + + /** + * @throws InvalidInputException if the value is invalid and input + * validation is enabled + */ + private function verifyRfc3339DateTime(string $dateTime): void + { + // RFC3339_EXTENDED parses at most three fractional digits, but RFC 3339 + // allows any number, so only the first three are checked. + $millisecondDateTime = preg_replace('/\.(\d{3})\d+/', '.$1', $dateTime, 1); + if (!$this->isValidDateTime(\DateTime::RFC3339, $dateTime) + && !$this->isValidDateTime(\DateTime::RFC3339_EXTENDED, $millisecondDateTime) + ) { + $this->maybeThrowInvalidInputException("$dateTime is not a valid RFC 3339 formatted datetime string"); + } + } + + private function isValidDateTime(string $format, string $dateTime): bool + { + if (\DateTime::createFromFormat($format, $dateTime) === false) { + return false; + } + + // createFromFormat rolls over out-of-range values, such as February + // 30, and reports them only as warnings. + $errors = \DateTime::getLastErrors(); + + return $errors === false || $errors['warning_count'] === 0; + } + /** * @throws InvalidInputException if the value is invalid and input * validation is enabled diff --git a/src/MinFraud/ReportTransaction.php b/src/MinFraud/ReportTransaction.php index fc8e470..8664084 100644 --- a/src/MinFraud/ReportTransaction.php +++ b/src/MinFraud/ReportTransaction.php @@ -129,21 +129,21 @@ public function report( } if ($ipAddress !== null) { - if (!filter_var($ipAddress, \FILTER_VALIDATE_IP)) { + if ($this->validateInput && !filter_var($ipAddress, \FILTER_VALIDATE_IP)) { $this->maybeThrowInvalidInputException("$ipAddress is an invalid IP address"); } $values['ip_address'] = $ipAddress; } if ($maxmindId !== null) { - if (\strlen($maxmindId) !== 8) { + if ($this->validateInput && \strlen($maxmindId) !== 8) { $this->maybeThrowInvalidInputException("$maxmindId must be 8 characters long"); } $values['maxmind_id'] = $maxmindId; } if ($minfraudId !== null) { - if (!preg_match( + if ($this->validateInput && !preg_match( '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i', $minfraudId, )) { @@ -161,7 +161,9 @@ public function report( // This is required so we always throw an exception if it is not set throw new InvalidInputException('A tag is required'); } - if (!\in_array($tag, ['not_fraud', 'suspected_fraud', 'spam_or_abuse', 'chargeback', 'clear'], true)) { + if ($this->validateInput + && !\in_array($tag, ['not_fraud', 'suspected_fraud', 'spam_or_abuse', 'chargeback', 'clear'], true) + ) { $this->maybeThrowInvalidInputException( "$tag must be one of 'not_fraud', 'suspected_fraud', 'spam_or_abuse', 'chargeback', or 'clear'", ); diff --git a/src/MinFraud/Util.php b/src/MinFraud/Util.php index 7751461..a90f615 100644 --- a/src/MinFraud/Util.php +++ b/src/MinFraud/Util.php @@ -289,7 +289,9 @@ class Util */ public static function maybeHashEmail(array $values): array { - if (!isset($values['email']['address'])) { + // A non-string address can only get here when input validation is + // off, and then it is sent unchanged. + if (!isset($values['email']['address']) || !\is_string($values['email']['address'])) { return $values; } diff --git a/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php b/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php index 32af1e8..db68692 100644 --- a/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php +++ b/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php @@ -352,6 +352,40 @@ public static function invalidTags(): array ]; } + /** + * @dataProvider allFields + */ + public function testFieldWithoutValidation(string $field): void + { + foreach ([new \stdClass(), ['x'], 12, true] as $value) { + $req = ['ip_address' => '1.1.1.1', 'tag' => 'not_fraud']; + $req[$field] = $value; + // report() sends the fields in alphabetical order. + ksort($req); + $this->createReportTransactionRequest( + $req, + 1, + ['validateInput' => false] + )->report($req); + } + } + + /** + * @return list> + */ + public static function allFields(): array + { + return [ + ['chargeback_code'], + ['ip_address'], + ['maxmind_id'], + ['minfraud_id'], + ['notes'], + ['tag'], + ['transaction_id'], + ]; + } + /** * @param array $requestContent * @param array $options diff --git a/tests/MaxMind/Test/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index 410a90c..0294442 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -82,30 +82,69 @@ public static function invalidCustomInputs(): array } /** - * @dataProvider unvalidatedAddressFields + * @dataProvider allFields */ - public function testAddressFieldWithoutValidation(string $method, string $section, string $field): void + public function testFieldWithoutValidation(string $method, string $section, string $field): void { - $client = new MinFraud(1, 'key', ['validateInput' => false]); - $result = $client->{$method}([$field => 12]); - $this->assertSame(12, $result->jsonSerialize()['content'][$section][$field]); + $values = [new \stdClass(), ['x'], true]; + // Integers are valid for these fields, so they are converted as when + // validation is on. + if (!\in_array($field, ['item_id', 'session_age', 'session_id'], true)) { + $values[] = 12; + } + + foreach ([false, true] as $hashEmail) { + $client = new MinFraud(1, 'key', ['validateInput' => false, 'hashEmail' => $hashEmail]); + foreach ($values as $value) { + $content = $client->{$method}([$field => $value])->jsonSerialize()['content'][$section]; + if ($section === 'shopping_cart') { + $content = $content[0]; + } + $this->assertSame($value, $content[$field]); + } + } } /** - * @return array + * @return array */ - public static function unvalidatedAddressFields(): array + public static function allFields(): array { - return [ - ['withBilling', 'billing', 'country'], - ['withBilling', 'billing', 'region'], - ['withBilling', 'billing', 'phone_country_code'], - ['withShipping', 'shipping', 'country'], - ['withShipping', 'shipping', 'region'], - ['withShipping', 'shipping', 'phone_country_code'], - ['withCreditCard', 'credit_card', 'country'], - ['withCreditCard', 'credit_card', 'bank_phone_country_code'], + $address = [ + 'address', 'address_2', 'city', 'company', 'country', 'first_name', 'last_name', + 'phone_country_code', 'phone_number', 'phone_verification_method', 'phone_verification_time', + 'phone_was_verification_successful', 'postal', 'region', + ]; + $fields = [ + 'withAccount' => ['account', ['user_id', 'username_md5']], + 'withBilling' => ['billing', $address], + 'withCreditCard' => ['credit_card', [ + 'avs_result', 'bank_name', 'bank_phone_country_code', 'bank_phone_number', 'country', + 'cvv_result', 'issuer_id_number', 'last_digits', 'token', 'was_3d_secure_successful', + ]], + 'withCustomInputs' => ['custom_inputs', ['a']], + 'withDevice' => ['device', [ + 'accept_language', 'ip_address', 'session_age', 'session_id', 'tracking_token', 'user_agent', + ]], + 'withEmail' => ['email', ['address', 'domain']], + 'withEvent' => ['event', ['party', 'shop_id', 'time', 'transaction_id', 'type']], + 'withOrder' => ['order', [ + 'affiliate_id', 'amount', 'currency', 'discount_code', 'has_gift_message', 'is_gift', + 'referrer_uri', 'subaffiliate_id', + ]], + 'withPayment' => ['payment', ['decline_code', 'method', 'processor', 'was_authorized']], + 'withShipping' => ['shipping', [...$address, 'delivery_speed']], + 'withShoppingCartItem' => ['shopping_cart', ['category', 'item_id', 'price', 'quantity']], ]; + + $rows = []; + foreach ($fields as $method => [$section, $keys]) { + foreach ($keys as $key) { + $rows["$method $key"] = [$method, $section, $key]; + } + } + + return $rows; } public function testMinFraud(): void @@ -196,7 +235,10 @@ public function testFullInsightsRequestUsingNamedArgs(string $class, string $ser country: 'US', postal: '06510', phoneNumber: '123-456-7890', - phoneCountryCode: '1' + phoneCountryCode: '1', + phoneVerificationMethod: 'delivered_code', + phoneWasVerificationSuccessful: true, + phoneVerificationTime: '2026-10-01T14:30:00Z' ) ->withCreditCard( country: 'US', @@ -263,6 +305,9 @@ public function testFullInsightsRequestUsingNamedArgs(string $class, string $ser postal: '73003', phoneNumber: '123-456-0000', phoneCountryCode: '1', + phoneVerificationMethod: 'network', + phoneWasVerificationSuccessful: false, + phoneVerificationTime: '2026-10-01T14:31:00Z', deliverySpeed: 'same_day' ) ->withShoppingCartItem( @@ -763,6 +808,193 @@ public function testBadDeliverySpeed(): void )->withShipping(['delivery_speed' => 'slow']); } + /** + * @dataProvider goodPhoneVerificationMethods + */ + public function testGoodBillingPhoneVerificationMethod(string $good): void + { + $result = $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withBilling(['phone_verification_method' => $good]); + + $this->assertSame( + $good, + $result->jsonSerialize()['content']['billing']['phone_verification_method'], + ); + } + + /** + * @return array> + */ + public static function goodPhoneVerificationMethods(): array + { + return [ + ['delivered_code'], + ['network'], + ['other'], + ]; + } + + public function testBadBillingPhoneVerificationMethod(): void + { + $this->expectException(InvalidInputException::class); + $this->expectExceptionMessage('valid phone verification method'); + + $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withBilling(['phone_verification_method' => 'sms']); + } + + /** + * @dataProvider goodTimes + */ + public function testGoodBillingPhoneVerificationTimes(string $time): void + { + $result = $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withBilling(['phone_verification_time' => $time]); + + $this->assertSame( + $time, + $result->jsonSerialize()['content']['billing']['phone_verification_time'], + ); + } + + /** + * @dataProvider badTimes + */ + public function testBadBillingPhoneVerificationTime(string $time): void + { + $this->expectException(InvalidInputException::class); + $this->expectExceptionMessage('valid RFC 3339'); + + $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withBilling(['phone_verification_time' => $time]); + } + + public function testBillingPhoneWasVerificationSuccessfulFalse(): void + { + $result = $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withBilling(phoneWasVerificationSuccessful: false); + + $this->assertFalse( + $result->jsonSerialize()['content']['billing']['phone_was_verification_successful'], + ); + } + + public function testBadBillingPhoneWasVerificationSuccessful(): void + { + $this->expectException(InvalidInputException::class); + $this->expectExceptionMessage( + 'Expected phone_was_verification_successful to be in [boolean] but was string', + ); + + $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withBilling(['phone_was_verification_successful' => 'true']); + } + + /** + * @dataProvider goodPhoneVerificationMethods + */ + public function testGoodShippingPhoneVerificationMethod(string $good): void + { + $result = $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withShipping(['phone_verification_method' => $good]); + + $this->assertSame( + $good, + $result->jsonSerialize()['content']['shipping']['phone_verification_method'], + ); + } + + public function testBadShippingPhoneVerificationMethod(): void + { + $this->expectException(InvalidInputException::class); + $this->expectExceptionMessage('valid phone verification method'); + + $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withShipping(['phone_verification_method' => 'sms']); + } + + /** + * @dataProvider goodTimes + */ + public function testGoodShippingPhoneVerificationTimes(string $time): void + { + $result = $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withShipping(['phone_verification_time' => $time]); + + $this->assertSame( + $time, + $result->jsonSerialize()['content']['shipping']['phone_verification_time'], + ); + } + + /** + * @dataProvider badTimes + */ + public function testBadShippingPhoneVerificationTime(string $time): void + { + $this->expectException(InvalidInputException::class); + $this->expectExceptionMessage('valid RFC 3339'); + + $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withShipping(['phone_verification_time' => $time]); + } + + public function testShippingPhoneWasVerificationSuccessfulFalse(): void + { + $result = $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withShipping(phoneWasVerificationSuccessful: false); + + $this->assertFalse( + $result->jsonSerialize()['content']['shipping']['phone_was_verification_successful'], + ); + } + + public function testPositionalPostalAndRegion(): void + { + $content = $this->createMinFraudRequestWithFullResponse('insights', 0) + ->withBilling([], null, null, null, null, 'US', null, null, null, null, '06510', 'CT') + ->withShipping([], null, null, null, null, 'US', null, null, null, null, null, '73003', 'OK') + ->jsonSerialize()['content']; + + $this->assertSame(['country' => 'US', 'postal' => '06510', 'region' => 'CT'], $content['billing']); + $this->assertSame(['country' => 'US', 'postal' => '73003', 'region' => 'OK'], $content['shipping']); + } + + public function testBadShippingPhoneWasVerificationSuccessful(): void + { + $this->expectException(InvalidInputException::class); + $this->expectExceptionMessage( + 'Expected phone_was_verification_successful to be in [boolean] but was string', + ); + + $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withShipping(['phone_was_verification_successful' => 'true']); + } + /** * @dataProvider badIins */ @@ -1176,10 +1408,12 @@ public static function goodDomains(): array */ public function testGoodEventTimes(string $time): void { - $this->createMinFraudRequestWithFullResponse( + $result = $this->createMinFraudRequestWithFullResponse( 'insights', 0 )->withEvent(['time' => $time]); + + $this->assertSame($time, $result->jsonSerialize()['content']['event']['time']); } /** @@ -1195,13 +1429,20 @@ public static function goodTimes(): array array_push( $tests, ['2014-04-12T23:20:50.052+01:00'], - ['2014-04-12T23:20:50.052Z'] + ['2014-04-12T23:20:50.052Z'], + ['2014-04-12T23:20:50.5Z'], + ['2014-04-12T23:20:50.052123+01:00'], + ['2014-04-12T23:20:50.052123Z'], + ['2014-04-12T23:20:50.052123456Z'] ); return $tests; } - public function testBadEventTime(): void + /** + * @dataProvider badTimes + */ + public function testBadEventTime(string $time): void { $this->expectException(InvalidInputException::class); $this->expectExceptionMessage('valid RFC 3339'); @@ -1209,7 +1450,26 @@ public function testBadEventTime(): void $this->createMinFraudRequestWithFullResponse( 'insights', 0 - )->withEvent(['time' => '2014/04/04 19:20']); + )->withEvent(['time' => $time]); + } + + /** + * @return array> + */ + public static function badTimes(): array + { + return [ + ['2014/04/04 19:20'], + ['2014-04-12T23:20:50.Z'], + ['2014-04-12T23:20:50.052123'], + ['2014-02-30T23:20:50Z'], + ['2014-02-30T23:20:50.052123Z'], + ['2014-13-12T23:20:50Z'], + ['2014-04-12T24:20:50Z'], + ['2014-04-12T25:20:50Z'], + ['2014-04-12T23:60:50Z'], + ['2014-04-12T23:20:61Z'], + ]; } /** diff --git a/tests/data/minfraud/full-request.json b/tests/data/minfraud/full-request.json index 848fcc5..837ddad 100644 --- a/tests/data/minfraud/full-request.json +++ b/tests/data/minfraud/full-request.json @@ -25,7 +25,10 @@ "country": "US", "postal": "06510", "phone_number": "123-456-7890", - "phone_country_code": "1" + "phone_country_code": "1", + "phone_verification_method": "delivered_code", + "phone_was_verification_successful": true, + "phone_verification_time": "2026-10-01T14:30:00Z" }, "shipping": { "first_name": "ShipFirst", @@ -39,6 +42,9 @@ "postal": "73003", "phone_number": "123-456-0000", "phone_country_code": "1", + "phone_verification_method": "network", + "phone_was_verification_successful": false, + "phone_verification_time": "2026-10-01T14:31:00Z", "delivery_speed": "same_day" }, "payment": {