diff --git a/CHANGELOG.md b/CHANGELOG.md index a0c7e24..c97e87d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,11 @@ CHANGELOG `phoneWasVerificationSuccessful`, and `phoneVerificationTime` named arguments. Omit `phone_was_verification_successful` if no verification was attempted. +* Added the `verification_time` and `was_verification_successful` inputs to + the `/email` object. These describe the most recent verification of the + email address. Use them with `withEmail()` as array keys or as the + `verificationTime` and `wasVerificationSuccessful` named arguments. Omit + `was_verification_successful` if no verification was attempted. 3.7.0 (2026-07-21) ------------------ diff --git a/README.md b/README.md index 444ced1..56ba638 100644 --- a/README.md +++ b/README.md @@ -165,14 +165,16 @@ $request = $mf->withDevice( party: 'customer', transactionId: 'txn3134133', shopId: 's2123', - time: '2012-04-12T23:20:50+00:00', + time: '2026-10-05T23:20:50+00:00', type: 'purchase' )->withAccount( userId: 3132, usernameMd5: '4f9726678c438914fa04bdb8c1a24088' )->withEmail( address: 'test@maxmind.com', - domain: 'maxmind.com' + domain: 'maxmind.com', + verificationTime: '2026-10-01T14:30:00Z', + wasVerificationSuccessful: true )->withBilling( firstName: 'First', lastName: 'Last', diff --git a/src/MinFraud.php b/src/MinFraud.php index 2bf4287..b48522f 100644 --- a/src/MinFraud.php +++ b/src/MinFraud.php @@ -487,14 +487,26 @@ public function withAccount( * @link https://dev.maxmind.com/minfraud/api-documentation/requests/?lang=en#schema--request--email * minFraud email API docs * - * @param array $values An array of email 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 email address used in the transaction. - * This field must be a valid email address. - * @param string|null $domain The domain of the email address used in the - * transaction. Do not include the `@` in this - * field. + * @param array $values An array of email 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 email address used in the transaction. + * This field must be a valid email address. + * @param string|null $domain The domain of the email address used in the + * transaction. Do not include the `@` in this + * field. + * @param string|null $verificationTime The date and time of the most recent + * attempt to verify the email address, if + * known, 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 $wasVerificationSuccessful Whether the most recent verification + * that the end user controls the email + * address succeeded, for example by + * confirming a link sent to the address. + * Omit this if no verification was + * attempted. * * @throws InvalidInputException if a value has the wrong type, or if * input validation is enabled and a value is @@ -507,6 +519,8 @@ public function withEmail( array $values = [], ?string $address = null, ?string $domain = null, + ?string $verificationTime = null, + ?bool $wasVerificationSuccessful = null, ): self { if (\count($values) !== 0) { if (\func_num_args() !== 1) { @@ -516,6 +530,12 @@ public function withEmail( } $address = $this->remove($values, 'address'); $domain = $this->remove($values, 'domain'); + $verificationTime = $this->remove($values, 'verification_time'); + $wasVerificationSuccessful = $this->remove( + $values, + 'was_verification_successful', + ['boolean'], + ); $this->verifyEmpty($values); } @@ -535,6 +555,15 @@ public function withEmail( $values['domain'] = $domain; } + if ($verificationTime !== null) { + $this->verifyRfc3339DateTime($verificationTime); + $values['verification_time'] = $verificationTime; + } + + if ($wasVerificationSuccessful !== null) { + $values['was_verification_successful'] = $wasVerificationSuccessful; + } + $new = clone $this; $new->content['email'] = $values; diff --git a/tests/MaxMind/Test/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index 5a2d1ae..13d1a14 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -98,6 +98,7 @@ public function testWrongTypeWithoutValidation(string $method, string $field): v 'phone_was_verification_successful', 'was_3d_secure_successful', 'was_authorized', + 'was_verification_successful', ]; if (\in_array($field, $booleans, true)) { $values[] = 'true'; @@ -141,7 +142,7 @@ public static function allFields(): array 'withDevice' => [ 'accept_language', 'ip_address', 'session_age', 'session_id', 'tracking_token', 'user_agent', ], - 'withEmail' => ['address', 'domain'], + 'withEmail' => ['address', 'domain', 'verification_time', 'was_verification_successful'], 'withEvent' => ['party', 'shop_id', 'time', 'transaction_id', 'type'], 'withOrder' => [ 'affiliate_id', 'amount', 'currency', 'discount_code', 'has_gift_message', 'is_gift', @@ -342,7 +343,11 @@ public static function invalidValues(): array 'token' => '4111111111111111', ]], 'withDevice' => ['device', ['ip_address' => 'not an IP', 'session_age' => -1.5, 'session_id' => '']], - 'withEmail' => ['email', ['address' => 'not an email', 'domain' => 'not a domain']], + 'withEmail' => ['email', [ + 'address' => 'not an email', + 'domain' => 'not a domain', + 'verification_time' => 'not a time', + ]], 'withEvent' => ['event', ['party' => 'other', 'time' => 'not a time', 'type' => 'other']], 'withOrder' => ['order', ['amount' => -1.5, 'currency' => 'usd', 'referrer_uri' => 'not a URL']], 'withPayment' => ['payment', ['method' => 'other', 'processor' => 'other']], @@ -481,13 +486,15 @@ public function testFullInsightsRequestUsingNamedArgs(string $class, string $ser ) ->withEmail( address: '977577b140bfb7c516e4746204fbdb01', - domain: 'maxmind.com' + domain: 'maxmind.com', + verificationTime: '2026-10-01T14:30:00Z', + wasVerificationSuccessful: true ) ->withEvent( party: 'customer', transactionId: 'txn3134133', shopId: 's2123', - time: '2014-04-12T23:20:50+00:00', + time: '2026-10-05T23:20:50+00:00', type: 'purchase' ) ->withOrder( @@ -1060,36 +1067,6 @@ public function testBadBillingPhoneVerificationMethod(): void )->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( @@ -1142,36 +1119,6 @@ public function testBadShippingPhoneVerificationMethod(): void )->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( @@ -1208,6 +1155,66 @@ public function testBadShippingPhoneWasVerificationSuccessful(): void )->withShipping(['phone_was_verification_successful' => 'true']); } + public function testEmailWasVerificationSuccessfulFalse(): void + { + $result = $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withEmail(wasVerificationSuccessful: false); + + $this->assertFalse( + $result->jsonSerialize()['content']['email']['was_verification_successful'], + ); + } + + public function testBadEmailWasVerificationSuccessful(): void + { + $this->expectException(InvalidInputException::class); + $this->expectExceptionMessage( + 'Expected was_verification_successful to be in [boolean] but was string', + ); + + $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withEmail(['was_verification_successful' => 'true']); + } + + public function testEmailWasVerificationSuccessfulFalseArray(): void + { + $result = $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withEmail(['was_verification_successful' => false]); + + $this->assertFalse( + $result->jsonSerialize()['content']['email']['was_verification_successful'], + ); + } + + public function testEmailVerificationWithHashing(): void + { + $result = $this->createMinFraudRequestWithFullResponse( + 'insights', + 0, + ['hashEmail' => true], + )->withEmail([ + 'address' => 'test@gmail.com', + 'verification_time' => '2026-10-01T14:30:00Z', + 'was_verification_successful' => false, + ]); + + $this->assertSame( + [ + 'address' => '1aedb8d9dc4751e229a335e371db8058', + 'verification_time' => '2026-10-01T14:30:00Z', + 'was_verification_successful' => false, + 'domain' => 'gmail.com', + ], + $result->jsonSerialize()['content']['email'], + ); + } + /** * @dataProvider badIins */ @@ -1617,16 +1624,31 @@ public static function goodDomains(): array } /** - * @dataProvider goodTimes + * @dataProvider goodTimeInputs */ - public function testGoodEventTimes(string $time): void - { + public function testGoodTimes( + string $method, + string $section, + string $field, + string $time, + ): void { $result = $this->createMinFraudRequestWithFullResponse( 'insights', 0 - )->withEvent(['time' => $time]); + )->{$method}([$field => $time]); + + $this->assertSame( + $time, + $result->jsonSerialize()['content'][$section][$field], + ); + } - $this->assertSame($time, $result->jsonSerialize()['content']['event']['time']); + /** + * @return array> + */ + public static function goodTimeInputs(): array + { + return self::timeInputs(self::goodTimes()); } /** @@ -1657,17 +1679,29 @@ public static function goodTimes(): array } /** - * @dataProvider badTimes + * @dataProvider badTimeInputs */ - public function testBadEventTime(string $time): void - { + public function testBadTime( + string $method, + string $section, + string $field, + string $time, + ): void { $this->expectException(InvalidInputException::class); $this->expectExceptionMessage('valid RFC 3339'); $this->createMinFraudRequestWithFullResponse( 'insights', 0 - )->withEvent(['time' => $time]); + )->{$method}([$field => $time]); + } + + /** + * @return array> + */ + public static function badTimeInputs(): array + { + return self::timeInputs(self::badTimes()); } /** @@ -1706,6 +1740,30 @@ public static function badTimes(): array ]; } + /** + * @param array> $times + * + * @return array> + */ + private static function timeInputs(array $times): array + { + $fields = [ + ['withBilling', 'billing', 'phone_verification_time'], + ['withShipping', 'shipping', 'phone_verification_time'], + ['withEmail', 'email', 'verification_time'], + ['withEvent', 'event', 'time'], + ]; + + $tests = []; + foreach ($fields as [$method, $section, $field]) { + foreach ($times as [$time]) { + $tests["{$section} {$field} {$time}"] = [$method, $section, $field, $time]; + } + } + + return $tests; + } + /** * @dataProvider goodEventTypes */ diff --git a/tests/data/minfraud/full-request.json b/tests/data/minfraud/full-request.json index 837ddad..6c7cbdf 100644 --- a/tests/data/minfraud/full-request.json +++ b/tests/data/minfraud/full-request.json @@ -3,7 +3,7 @@ "party": "customer", "transaction_id": "txn3134133", "shop_id": "s2123", - "time": "2014-04-12T23:20:50+00:00", + "time": "2026-10-05T23:20:50+00:00", "type": "purchase" }, "account": { @@ -12,7 +12,9 @@ }, "email": { "address": "977577b140bfb7c516e4746204fbdb01", - "domain": "maxmind.com" + "domain": "maxmind.com", + "verification_time": "2026-10-01T14:30:00Z", + "was_verification_successful": true }, "billing": { "first_name": "First",