diff --git a/CHANGELOG.md b/CHANGELOG.md index ef3d79f..0feb14c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ 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` object. These describe + the most recent verification of the billing phone number. Use them with + `withBilling()` 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..73b033a 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', diff --git a/src/MinFraud.php b/src/MinFraud.php index 7b17b33..3199cc4 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; } @@ -561,33 +556,43 @@ 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 $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 + * @param bool|null $phoneWasVerificationSuccessful Whether the most recent verification + * of the billing phone number succeeded. + * Omit this if no verification was + * attempted. + * @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 * * @throws InvalidInputException if input validation is enabled and a * value is invalid, a value has the wrong @@ -607,6 +612,9 @@ public function withBilling( ?string $lastName = null, ?string $phoneCountryCode = null, ?string $phoneNumber = null, + ?string $phoneVerificationMethod = null, + ?string $phoneVerificationTime = null, + ?bool $phoneWasVerificationSuccessful = null, ?string $postal = null, ?string $region = null, ): self { @@ -626,6 +634,13 @@ public function withBilling( $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'); @@ -674,6 +689,26 @@ public function withBilling( $values['phone_number'] = $phoneNumber; } + if ($phoneVerificationMethod !== null) { + if (!\in_array($phoneVerificationMethod, ['delivered_code', 'network', 'other'], true)) { + $this->maybeThrowInvalidInputException( + "$phoneVerificationMethod is not a valid phone verification method", + ); + } + $values['phone_verification_method'] = $phoneVerificationMethod; + } + + if ($phoneVerificationTime !== null) { + if ($this->validateInput) { + $this->verifyRfc3339DateTime($phoneVerificationTime); + } + $values['phone_verification_time'] = $phoneVerificationTime; + } + + if ($phoneWasVerificationSuccessful !== null) { + $values['phone_was_verification_successful'] = $phoneWasVerificationSuccessful; + } + if ($postal !== null) { $values['postal'] = $postal; } @@ -1587,6 +1622,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 diff --git a/tests/MaxMind/Test/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index 410a90c..d6ca695 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -100,6 +100,7 @@ public static function unvalidatedAddressFields(): array ['withBilling', 'billing', 'country'], ['withBilling', 'billing', 'region'], ['withBilling', 'billing', 'phone_country_code'], + ['withBilling', 'billing', 'phone_verification_time'], ['withShipping', 'shipping', 'country'], ['withShipping', 'shipping', 'region'], ['withShipping', 'shipping', 'phone_country_code'], @@ -196,7 +197,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', @@ -763,6 +767,97 @@ public function testBadDeliverySpeed(): void )->withShipping(['delivery_speed' => 'slow']); } + /** + * @dataProvider goodBillingPhoneVerificationMethods + */ + 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 goodBillingPhoneVerificationMethods(): 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 badIins */ diff --git a/tests/data/minfraud/full-request.json b/tests/data/minfraud/full-request.json index 848fcc5..c0a9349 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",