Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
------------------
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: in this example, verificationTime (2026-10-01) is 14 years after the example event time (2012-04-12, line 168). The "most recent verification" then comes after the transaction that it describes. tests/data/minfraud/full-request.json has the same mismatch: verification_time (line 16) is 2026-10-01 and the event time (line 6) is 2014-04-12.

The phoneVerificationTime values (lines 192 and 207) have the same problem. They are not new in this PR.

Suggested fix: use a verification time before the event time, or move the event time to a recent date.

🤖 Comment by Claude Opus 5.5.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reply by Claude Opus 5.5 on Greg's behalf.

Fixed in f6695a7. The example event time is now 2026-10-05, after every verification time. This also fixes the phone examples.

wasVerificationSuccessful: true
)->withBilling(
firstName: 'First',
lastName: 'Last',
Expand Down
45 changes: 37 additions & 8 deletions src/MinFraud.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $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<string, mixed> $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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the $verificationTime PHPDoc wording differs from the phone fields.

This says "the most recent attempt to verify the email address, if known". The $phoneVerificationTime PHPDoc for billing (line 614) and shipping (line 705) says "the most recent verification of the ... phone number". The changelog bullets (lines 43-44 and 50-51) also say "most recent verification".

The two inputs have the same meaning: the time also applies when was_verification_successful is false. The "attempt" wording here is more accurate, and it matches the dev-site text. The phone inputs are also unreleased (3.8.0), so the phone PHPDoc and the changelog can use the same wording now.

🤖 Comment by Claude Opus 5.5.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is asking to change the phone text. So we can leave it I believe.

* 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
Expand All @@ -507,6 +519,8 @@ public function withEmail(
array $values = [],
?string $address = null,
?string $domain = null,
?string $verificationTime = null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: new parameters on withEmail() break a subclass that overrides it.

MinFraud is not final. A subclass that overrides withEmail() with the 3.7.0 signature gets a fatal error when PHP loads the class:

Fatal error: Declaration of C::withEmail(array $values = [], ?string $address = null, ?string $domain = null): P must be compatible with P::withEmail(array $values = [], ?string $address = null, ?string $domain = null, ?string $verificationTime = null, ?bool $wasVerificationSuccessful = null): P

I reproduced this with PHP 8.3. The PR description says that positional callers do not break, which is correct, but it does not cover subclasses.

The phone inputs cause the same break for withBilling() and withShipping() in the same 3.8.0 release. If you treat this as acceptable for a minor release, no change is necessary. Otherwise, add one changelog line that says a subclass that overrides withEmail(), withBilling(), or withShipping() must add the new parameters.

🤖 Comment by Claude Opus 5.5.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure this is something we need to worry about.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should make it final in the next major version.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reply by Claude Opus 5.5 on Greg's behalf.

Filed ENG-5698 to make the classes final in the next major version.

?bool $wasVerificationSuccessful = null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the wasVerificationSuccessful named argument coerces a string to a boolean, but the array key rejects it.

PHP applies the caller's type mode to typed parameters. If the calling file does not declare strict_types=1, withEmail(wasVerificationSuccessful: 'false') sends was_verification_successful: true. I confirmed this with PHP 8.3. withEmail(['was_verification_successful' => 'false']) throws InvalidInputException, even with input validation disabled. Thus a caller who passes a form value through the named argument can report a failed verification as a success.

The same problem exists in code that this PR did not add: $phoneWasVerificationSuccessful in withBilling() and withShipping(), and the other ?bool named arguments, such as $wasAuthorized and $isGift.

I do not suggest a change in this PR. A fix would need a mixed type and a manual check on every boolean named argument. If you want the two styles to act the same, do that in a separate change for all of them.

🤖 Comment by Claude Opus 5.5.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like a larger change not related exactly to this PR, if accurate and we wanted to do anything.

): self {
if (\count($values) !== 0) {
if (\func_num_args() !== 1) {
Expand All @@ -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);
}
Expand All @@ -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;

Expand Down
204 changes: 131 additions & 73 deletions tests/MaxMind/Test/MinFraudTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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']],
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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<string, list<string>>
*/
public static function goodTimeInputs(): array
{
return self::timeInputs(self::goodTimes());
}

/**
Expand Down Expand Up @@ -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<string, list<string>>
*/
public static function badTimeInputs(): array
{
return self::timeInputs(self::badTimes());
}

/**
Expand Down Expand Up @@ -1706,6 +1740,30 @@ public static function badTimes(): array
];
}

/**
* @param array<list<string>> $times
*
* @return array<string, list<string>>
*/
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
*/
Expand Down
Loading
Loading