From 0f0392ab04bb7ba4ff52657d6767326eaa4f71dd Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 9 Oct 2026 20:47:10 +0000 Subject: [PATCH 1/9] Extract the RFC 3339 date-time check into a helper Move the event time check from withEvent into a private helper so other inputs can use the same check. Behavior does not change. Co-Authored-By: Claude Opus 5.5 --- src/MinFraud.php | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/MinFraud.php b/src/MinFraud.php index 7b17b33..c70443b 100644 --- a/src/MinFraud.php +++ b/src/MinFraud.php @@ -393,12 +393,7 @@ 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"); - } - + $this->verifyRfc3339DateTime($time); $values['time'] = $time; } @@ -1587,6 +1582,19 @@ private function post(string $class, string $path) ); } + /** + * @throws InvalidInputException if the value is invalid and input + * validation is enabled + */ + private function verifyRfc3339DateTime(string $dateTime): void + { + if (\DateTime::createFromFormat(\DateTime::RFC3339, $dateTime) === false + && \DateTime::createFromFormat(\DateTime::RFC3339_EXTENDED, $dateTime) === false + ) { + $this->maybeThrowInvalidInputException("$dateTime is not a valid RFC 3339 formatted datetime string"); + } + } + /** * @throws InvalidInputException if the value is invalid and input * validation is enabled From c968aa99b9f4d79e2807090c846acd6cd47bfb0a Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 12 Oct 2026 19:23:36 +0000 Subject: [PATCH 2/9] Accept any number of RFC 3339 fractional digits DateTime::RFC3339_EXTENDED parses at most three fractional second digits, so the RFC 3339 check rejected valid values such as 2026-10-01T14:30:00.123456Z. Check only the first three digits. The submitted string does not change. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ src/MinFraud.php | 5 ++++- tests/MaxMind/Test/MinFraudTest.php | 29 +++++++++++++++++++++++++---- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef3d79f..fdb2ca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ 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. +* 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 PHPDoc for the `with()` and `with*()` methods of `MaxMind\MinFraud` now lists the `InvalidInputException` they throw when input validation is enabled. diff --git a/src/MinFraud.php b/src/MinFraud.php index c70443b..7b42401 100644 --- a/src/MinFraud.php +++ b/src/MinFraud.php @@ -1588,8 +1588,11 @@ private function post(string $class, string $path) */ 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 (\DateTime::createFromFormat(\DateTime::RFC3339, $dateTime) === false - && \DateTime::createFromFormat(\DateTime::RFC3339_EXTENDED, $dateTime) === false + && \DateTime::createFromFormat(\DateTime::RFC3339_EXTENDED, $millisecondDateTime) === false ) { $this->maybeThrowInvalidInputException("$dateTime is not a valid RFC 3339 formatted datetime string"); } diff --git a/tests/MaxMind/Test/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index 410a90c..218af23 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -1176,10 +1176,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 +1197,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 +1218,19 @@ 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'], + ]; } /** From 2ffe8547a116da6b040b4fc737cf5bf1fa89eff9 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 12 Oct 2026 16:21:13 +0000 Subject: [PATCH 3/9] Skip enum checks when validation is off The event party and type, shipping delivery_speed, and payment method and processor checks built the error message from the value before maybeThrowInvalidInputException checked validateInput. With validation off, an object value threw an Error and an array value emitted a conversion warning. Check validateInput first so these values pass through unchanged, as the other fields do. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ src/MinFraud.php | 12 +++++++----- tests/MaxMind/Test/MinFraudTest.php | 26 ++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb2ca5..7adb1c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ 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. * 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`. diff --git a/src/MinFraud.php b/src/MinFraud.php index 7b42401..78eee68 100644 --- a/src/MinFraud.php +++ b/src/MinFraud.php @@ -382,7 +382,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; @@ -402,7 +402,7 @@ public function withEvent( } if ($type !== null) { - if (!\in_array($type, [ + if ($this->validateInput && !\in_array($type, [ 'account_creation', 'account_login', 'credit_application', @@ -782,7 +782,9 @@ public function withShipping( } 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"); } @@ -889,7 +891,7 @@ public function withPayment( } if ($method !== null) { - if (!\in_array($method, [ + if ($this->validateInput && !\in_array($method, [ 'bank_debit', 'bank_redirect', 'bank_transfer', @@ -907,7 +909,7 @@ public function withPayment( } if ($processor !== null) { - if (!\in_array($processor, [ + if ($this->validateInput && !\in_array($processor, [ 'adyen', 'affirm', 'afterpay', diff --git a/tests/MaxMind/Test/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index 218af23..a0c2173 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -108,6 +108,32 @@ public static function unvalidatedAddressFields(): array ]; } + /** + * @dataProvider unvalidatedEnumFields + */ + public function testEnumFieldWithoutValidation(string $method, string $section, string $field): void + { + $client = new MinFraud(1, 'key', ['validateInput' => false]); + foreach ([new \stdClass(), ['x']] as $value) { + $result = $client->{$method}([$field => $value]); + $this->assertSame($value, $result->jsonSerialize()['content'][$section][$field]); + } + } + + /** + * @return array + */ + public static function unvalidatedEnumFields(): array + { + return [ + ['withEvent', 'event', 'party'], + ['withEvent', 'event', 'type'], + ['withShipping', 'shipping', 'delivery_speed'], + ['withPayment', 'payment', 'method'], + ['withPayment', 'payment', 'processor'], + ]; + } + public function testMinFraud(): void { $minFraud = new MinFraud(0, '', ['hashEmail' => true, 'locales' => ['en', 'fr']]); From 1474289247fb9954e3e462b7ea34b1e18df9c784 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 12 Oct 2026 19:25:31 +0000 Subject: [PATCH 4/9] Skip report checks when validation is off The ip_address, maxmind_id, minfraud_id, and tag checks in ReportTransaction::report() ran before maybeThrowInvalidInputException checked validateInput. With validation off, an array or object value caused a type error, a conversion warning, or an error. Check validateInput first so these values pass through unchanged. A missing tag or identifier still throws. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +++ src/MinFraud/ReportTransaction.php | 10 +++--- .../ReportTransactionTest.php | 31 +++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7adb1c8..1b14656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ CHANGELOG `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. * 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`. 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/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php b/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php index 32af1e8..a7adbd5 100644 --- a/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php +++ b/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php @@ -352,6 +352,37 @@ public static function invalidTags(): array ]; } + /** + * @dataProvider validatedFields + */ + public function testFieldWithoutValidation(string $field): void + { + foreach ([new \stdClass(), ['x']] 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 validatedFields(): array + { + return [ + ['ip_address'], + ['maxmind_id'], + ['minfraud_id'], + ['tag'], + ]; + } + /** * @param array $requestContent * @param array $options From c26f2d16d3643cc290fa5a1929c5813a56ba7d4a Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 12 Oct 2026 19:48:42 +0000 Subject: [PATCH 5/9] Skip all other checks when validation is off The device, event time, account, email, credit card, order, and shopping cart checks ran before maybeThrowInvalidInputException checked validateInput. With validation off, a value of an unexpected type caused a type error, a conversion warning, or an error. Check validateInput first so every field passes through unchanged. The session_age, session_id, and item_id conversions now apply only to the types that are valid for them, so other values are no longer converted. With hashEmail on, an email address that is not a string throws InvalidInputException. Before, it caused a TypeError. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 ++ src/MinFraud.php | 72 +++++++----- src/MinFraud/Util.php | 7 ++ .../ReportTransactionTest.php | 9 +- tests/MaxMind/Test/MinFraudTest.php | 103 ++++++++++++------ 5 files changed, 135 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b14656..55691fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,16 @@ CHANGELOG 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 now causes an `InvalidInputException`. Before, it + caused a `TypeError`. * 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`. diff --git a/src/MinFraud.php b/src/MinFraud.php index 78eee68..37ba821 100644 --- a/src/MinFraud.php +++ b/src/MinFraud.php @@ -123,8 +123,10 @@ public function jsonSerialize(): array * @param array $values The request as a structured array * * @throws InvalidInputException if a section or shopping cart item is not - * an array, or if validation is enabled and - * a value is invalid or a key is unknown + * an array, if `hashEmail` is enabled and + * the email address is not a string, or if + * validation is enabled and a value is + * invalid or a key is unknown * * @return MinFraud A new immutable MinFraud object. This object is * a clone of the original with additional data. @@ -247,16 +249,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 +277,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", ); @@ -393,7 +400,9 @@ public function withEvent( } if ($time !== null) { - $this->verifyRfc3339DateTime($time); + if ($this->validateInput) { + $this->verifyRfc3339DateTime($time); + } $values['time'] = $time; } @@ -472,7 +481,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; @@ -500,9 +509,10 @@ public function withAccount( * transaction. Do not include the `@` in this * field. * - * @throws InvalidInputException if input validation is enabled and a - * value is invalid, a value has the wrong - * type, or a key is unknown + * @throws InvalidInputException if `hashEmail` is enabled and the address + * is not a string, or if input validation is + * enabled and a value is invalid, a value has + * the wrong type, or a key is unknown * * @return MinFraud A new immutable MinFraud object. This object is a clone * of the original with additional data. @@ -525,7 +535,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"); } @@ -533,7 +544,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; @@ -1170,7 +1183,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; @@ -1200,34 +1213,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.', ); @@ -1363,14 +1376,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; @@ -1389,7 +1402,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; @@ -1449,7 +1462,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']); @@ -1467,14 +1483,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; diff --git a/src/MinFraud/Util.php b/src/MinFraud/Util.php index 7751461..b05c8ce 100644 --- a/src/MinFraud/Util.php +++ b/src/MinFraud/Util.php @@ -4,6 +4,8 @@ namespace MaxMind\MinFraud; +use MaxMind\Exception\InvalidInputException; + /** * This is an internal class. It is not subject to our public API versioning * policy. @@ -285,6 +287,8 @@ class Util * * @param array $values * + * @throws InvalidInputException if the email address is not a string + * * @return array */ public static function maybeHashEmail(array $values): array @@ -292,6 +296,9 @@ public static function maybeHashEmail(array $values): array if (!isset($values['email']['address'])) { return $values; } + if (!\is_string($values['email']['address'])) { + throw new InvalidInputException('The email address must be a string to be hashed.'); + } $address = trim(strtolower($values['email']['address'])); diff --git a/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php b/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php index a7adbd5..db68692 100644 --- a/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php +++ b/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php @@ -353,11 +353,11 @@ public static function invalidTags(): array } /** - * @dataProvider validatedFields + * @dataProvider allFields */ public function testFieldWithoutValidation(string $field): void { - foreach ([new \stdClass(), ['x']] as $value) { + 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. @@ -373,13 +373,16 @@ public function testFieldWithoutValidation(string $field): void /** * @return list> */ - public static function validatedFields(): array + public static function allFields(): array { return [ + ['chargeback_code'], ['ip_address'], ['maxmind_id'], ['minfraud_id'], + ['notes'], ['tag'], + ['transaction_id'], ]; } diff --git a/tests/MaxMind/Test/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index a0c2173..3f062d9 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -82,56 +82,89 @@ 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) { + if ($hashEmail && $section === 'email' && $field === 'address') { + try { + $client->{$method}([$field => $value]); + $this->fail('Expected an InvalidInputException'); + } catch (InvalidInputException $e) { + $this->assertSame('The email address must be a string to be hashed.', $e->getMessage()); + } + + continue; + } + $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', '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']], ]; - } - /** - * @dataProvider unvalidatedEnumFields - */ - public function testEnumFieldWithoutValidation(string $method, string $section, string $field): void - { - $client = new MinFraud(1, 'key', ['validateInput' => false]); - foreach ([new \stdClass(), ['x']] as $value) { - $result = $client->{$method}([$field => $value]); - $this->assertSame($value, $result->jsonSerialize()['content'][$section][$field]); + $rows = []; + foreach ($fields as $method => [$section, $keys]) { + foreach ($keys as $key) { + $rows["$method $key"] = [$method, $section, $key]; + } } + + return $rows; } /** - * @return array + * @testWith [true] + * [false] */ - public static function unvalidatedEnumFields(): array + public function testWithNonStringEmailAddressAndHashing(bool $validate): void { - return [ - ['withEvent', 'event', 'party'], - ['withEvent', 'event', 'type'], - ['withShipping', 'shipping', 'delivery_speed'], - ['withPayment', 'payment', 'method'], - ['withPayment', 'payment', 'processor'], - ]; + $client = new MinFraud(1, 'key', ['validateInput' => $validate, 'hashEmail' => true]); + $this->expectException(InvalidInputException::class); + $client->with(['email' => ['address' => 12]]); } public function testMinFraud(): void From e48dbd01a63de53be6f0a7cc441983803dd1c72d Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 12 Oct 2026 19:34:56 +0000 Subject: [PATCH 6/9] Share billing and shipping address validation withBilling() and withShipping() repeated the same array handling, validation, and array building for the eleven address fields. Move it into one private helper. withShipping() keeps only the delivery_speed handling. The public signatures, exceptions, and the data sent do not change. Co-Authored-By: Claude Opus 5.5 --- src/MinFraud.php | 282 +++++++++++++++++++++++------------------------ 1 file changed, 140 insertions(+), 142 deletions(-) diff --git a/src/MinFraud.php b/src/MinFraud.php index 37ba821..09e05e3 100644 --- a/src/MinFraud.php +++ b/src/MinFraud.php @@ -618,83 +618,28 @@ public function withBilling( ?string $postal = null, ?string $region = 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, + ); return $new; } @@ -755,45 +700,10 @@ 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 ($this->validateInput && !\in_array($deliverySpeed, ['same_day', 'overnight', 'expedited', 'standard'], true) @@ -801,41 +711,25 @@ public function withShipping( $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, + ); return $new; } @@ -1600,6 +1494,110 @@ 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, + ): 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'); + $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 ($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 From 505e64a8aec69d57b0aef5e4db629fadde819448 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 9 Oct 2026 20:48:40 +0000 Subject: [PATCH 7/9] Add phone verification inputs Add the phone_verification_method, phone_was_verification_successful, and phone_verification_time inputs to both the /billing and /shipping objects. They describe the most recent verification of the billing or shipping phone number. Add them to withBilling() and withShipping() as array keys and as named arguments. The new arguments go after the existing ones, so positional calls keep working. With input validation on, the client checks the method value, the boolean type, and the RFC 3339 time format. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++ README.md | 8 +- src/MinFraud.php | 159 +++++++++++++++------ tests/MaxMind/Test/MinFraudTest.php | 192 +++++++++++++++++++++++++- tests/data/minfraud/full-request.json | 8 +- 5 files changed, 325 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55691fc..d9b1721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,14 @@ CHANGELOG * 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 09e05e3..78abba3 100644 --- a/src/MinFraud.php +++ b/src/MinFraud.php @@ -569,33 +569,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 two days 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 @@ -617,6 +629,9 @@ 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 && \func_num_args() !== 1) { throw new \InvalidArgumentException( @@ -639,6 +654,9 @@ public function withBilling( $phoneNumber, $postal, $region, + $phoneVerificationMethod, + $phoneVerificationTime, + $phoneWasVerificationSuccessful, ); return $new; @@ -651,25 +669,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 two days 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 @@ -692,6 +721,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) { @@ -729,6 +761,9 @@ public function withShipping( $phoneNumber, $postal, $region, + $phoneVerificationMethod, + $phoneVerificationTime, + $phoneWasVerificationSuccessful, ); return $new; @@ -1521,6 +1556,9 @@ private function addressValues( ?string $phoneNumber, ?string $postal, ?string $region, + ?string $phoneVerificationMethod, + ?string $phoneVerificationTime, + ?bool $phoneWasVerificationSuccessful, ): array { if (\count($values) !== 0) { $address = $this->remove($values, 'address'); @@ -1532,6 +1570,13 @@ private function addressValues( $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'); @@ -1580,6 +1625,28 @@ private function addressValues( $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; } diff --git a/tests/MaxMind/Test/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index 3f062d9..2046c07 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -122,7 +122,8 @@ public static function allFields(): array { $address = [ 'address', 'address_2', 'city', 'company', 'country', 'first_name', 'last_name', - 'phone_country_code', 'phone_number', 'postal', 'region', + 'phone_country_code', 'phone_number', 'phone_verification_method', 'phone_verification_time', + 'phone_was_verification_successful', 'postal', 'region', ]; $fields = [ 'withAccount' => ['account', ['user_id', 'username_md5']], @@ -255,7 +256,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', @@ -322,6 +326,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( @@ -822,6 +829,187 @@ 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'], + ); + } + + public function testBadBillingPhoneVerificationTime(): void + { + $this->expectException(InvalidInputException::class); + $this->expectExceptionMessage('valid RFC 3339'); + + $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withBilling(['phone_verification_time' => '2026/10/01 14:30']); + } + + 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'], + ); + } + + public function testBadShippingPhoneVerificationTime(): void + { + $this->expectException(InvalidInputException::class); + $this->expectExceptionMessage('valid RFC 3339'); + + $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withShipping(['phone_verification_time' => '2026/10/01 14:30']); + } + + 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 */ 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": { From ac431ea8859dbfb4619f18a8fdf574cfe0c5e5ac Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 12 Oct 2026 22:24:09 +0000 Subject: [PATCH 8/9] Reject RFC 3339 date-time values that the web service rejects DateTime::createFromFormat() rolls over out-of-range values, such as February 30 or hour 25, and reports them only as warnings. The RFC 3339 check tested only for a false return, so it accepted these values. It now also rejects a parse that has warnings. createFromFormat() also accepts forms that are not RFC 3339, such as time zone names, offsets without a colon, and a lowercase "z". The check now first matches the pattern that the web service uses. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++ src/MinFraud.php | 49 +++++++++++++++++++---------- tests/MaxMind/Test/MinFraudTest.php | 39 ++++++++++++++++++++--- 3 files changed, 72 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9b1721..59c553c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,11 @@ CHANGELOG * 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 the values that + the web service rejects. Before, it accepted out-of-range date and time + values, such as `2026-02-30T10:00:00Z`, because PHP rolled them over. It + also accepted time zone names, such as `EST`, offsets without a colon, such + as `+0000`, and a lowercase `z`. * The PHPDoc for the `with()` and `with*()` methods of `MaxMind\MinFraud` now lists the `InvalidInputException` they throw when input validation is enabled. diff --git a/src/MinFraud.php b/src/MinFraud.php index 78abba3..261e995 100644 --- a/src/MinFraud.php +++ b/src/MinFraud.php @@ -1529,6 +1529,39 @@ private function post(string $class, string $path) ); } + /** + * @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); + // The pattern is the format that the web service accepts. The + // date-time checks reject values that do not exist, such as February 30. + $pattern = '/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)\z/'; + if (!preg_match($pattern, $dateTime) + || (!$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; + } + /** * Builds the address array that withBilling() and withShipping() share. * @@ -1665,22 +1698,6 @@ private function addressValues( 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 (\DateTime::createFromFormat(\DateTime::RFC3339, $dateTime) === false - && \DateTime::createFromFormat(\DateTime::RFC3339_EXTENDED, $millisecondDateTime) === false - ) { - $this->maybeThrowInvalidInputException("$dateTime is not a valid RFC 3339 formatted datetime string"); - } - } - /** * @throws InvalidInputException if the value is invalid and input * validation is enabled diff --git a/tests/MaxMind/Test/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index 2046c07..3574102 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -884,7 +884,10 @@ public function testGoodBillingPhoneVerificationTimes(string $time): void ); } - public function testBadBillingPhoneVerificationTime(): void + /** + * @dataProvider badTimes + */ + public function testBadBillingPhoneVerificationTime(string $time): void { $this->expectException(InvalidInputException::class); $this->expectExceptionMessage('valid RFC 3339'); @@ -892,7 +895,7 @@ public function testBadBillingPhoneVerificationTime(): void $this->createMinFraudRequestWithFullResponse( 'insights', 0 - )->withBilling(['phone_verification_time' => '2026/10/01 14:30']); + )->withBilling(['phone_verification_time' => $time]); } public function testBillingPhoneWasVerificationSuccessfulFalse(): void @@ -963,7 +966,10 @@ public function testGoodShippingPhoneVerificationTimes(string $time): void ); } - public function testBadShippingPhoneVerificationTime(): void + /** + * @dataProvider badTimes + */ + public function testBadShippingPhoneVerificationTime(string $time): void { $this->expectException(InvalidInputException::class); $this->expectExceptionMessage('valid RFC 3339'); @@ -971,7 +977,7 @@ public function testBadShippingPhoneVerificationTime(): void $this->createMinFraudRequestWithFullResponse( 'insights', 0 - )->withShipping(['phone_verification_time' => '2026/10/01 14:30']); + )->withShipping(['phone_verification_time' => $time]); } public function testShippingPhoneWasVerificationSuccessfulFalse(): void @@ -1448,7 +1454,9 @@ public static function goodTimes(): array ['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'] + ['2014-04-12T23:20:50.052123456Z'], + ['2026-10-01T14:30:00+23:59'], + ['2024-02-29T00:00:00-05:00'] ); return $tests; @@ -1477,6 +1485,27 @@ public static function badTimes(): array ['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'], + ['2026-10-01T14:30:60Z'], + ['2026-10-01T24:00:00Z'], + ['2026-10-01t14:30:00Z'], + ['2026-10-01T14:30:00z'], + ['2026-10-01T14:30:00EST'], + ['2026-10-01T14:30:00America/New_York'], + ['2026-10-01T14:30:00GMT+2'], + ['2026-10-01T14:30:00+0000'], + ['2026-10-01T14:30:00+24:00'], + ['2026-10-01T14:30:00+25:00'], + ['2026-10-01T14:30:00+05:60'], + ['2026-1-1T14:30:00Z'], + ['26-10-01T14:30:00Z'], + ['2026-10-01T4:30:00Z'], ]; } From e0c3681a71969f9891ffbdd0312af2d873a4e5d4 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 15 Oct 2026 22:16:11 +0000 Subject: [PATCH 9/9] Simplify the RFC 3339 check The pattern now checks the hour, minute, and second ranges, and checkdate() rejects dates that do not exist. This replaces the two DateTime::createFromFormat() parses, the warning check, and the truncation of the fraction to three digits. The accepted values do not change. Co-Authored-By: Claude Opus 5.5 --- src/MinFraud.php | 29 +++++++---------------------- tests/MaxMind/Test/MinFraudTest.php | 7 ++++++- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/src/MinFraud.php b/src/MinFraud.php index 261e995..8468520 100644 --- a/src/MinFraud.php +++ b/src/MinFraud.php @@ -1535,33 +1535,18 @@ private function post(string $class, string $path) */ 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); - // The pattern is the format that the web service accepts. The - // date-time checks reject values that do not exist, such as February 30. - $pattern = '/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)\z/'; - if (!preg_match($pattern, $dateTime) - || (!$this->isValidDateTime(\DateTime::RFC3339, $dateTime) - && !$this->isValidDateTime(\DateTime::RFC3339_EXTENDED, $millisecondDateTime)) + // The pattern is the format that the web service accepts. checkdate() + // rejects dates that do not exist, such as February 30. + $pattern = '/\A(\d{4})-(\d{2})-(\d{2})' + . 'T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d+)?' + . '(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)\z/'; + if (!preg_match($pattern, $dateTime, $matches) + || !checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1]) ) { $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; - } - /** * Builds the address array that withBilling() and withShipping() share. * diff --git a/tests/MaxMind/Test/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index 3574102..3a6466f 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -1456,7 +1456,9 @@ public static function goodTimes(): array ['2014-04-12T23:20:50.052123Z'], ['2014-04-12T23:20:50.052123456Z'], ['2026-10-01T14:30:00+23:59'], - ['2024-02-29T00:00:00-05:00'] + ['2024-02-29T00:00:00-05:00'], + ['2024-02-29T00:00:00Z'], + ['2014-04-12T23:20:50.123456789012345678901234567890Z'] ); return $tests; @@ -1487,10 +1489,13 @@ public static function badTimes(): array ['2014-04-12T23:20:50.052123'], ['2014-02-30T23:20:50Z'], ['2014-02-30T23:20:50.052123Z'], + ['2026-02-29T00:00:00Z'], + ['2026-04-31T00:00:00Z'], ['2014-13-12T23:20:50Z'], ['2014-04-12T24:20:50Z'], ['2014-04-12T25:20:50Z'], ['2014-04-12T23:60:50Z'], + ['2026-10-01T14:60:00Z'], ['2014-04-12T23:20:61Z'], ['2026-10-01T14:30:60Z'], ['2026-10-01T24:00:00Z'],