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
11 changes: 11 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@
History
-------

3.4.0
++++++++++++++++++

* Added the ``phone_verification_method``,
``phone_was_verification_successful``, and ``phone_verification_time``
inputs to the ``/billing`` and ``/shipping`` request objects. They describe
the most recent verification of the phone number.
* The RFC 3339 check on ``/event/time`` and ``phone_verification_time`` now
rejects more values that the web service rejects, such as February 30,
hour 24, an offset of 24 hours or more, and a lowercase ``t`` or ``z``.

3.3.0 (2026-07-21)
++++++++++++++++++

Expand Down
6 changes: 6 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,9 @@ Score, Insights and Factors Example
>>> 'postal': '06510',
>>> 'phone_country_code': '1',
>>> 'phone_number': '123-456-7890',
>>> 'phone_verification_method': 'delivered_code',
>>> 'phone_verification_time': '2026-10-01T14:30:00Z',
>>> 'phone_was_verification_successful': True,
>>> },
>>> 'shipping': {
>>> 'first_name': 'John',
Expand All @@ -196,6 +199,9 @@ Score, Insights and Factors Example
>>> 'postal': '06510',
>>> 'phone_country_code': '1',
>>> 'phone_number': '123-456-0000',
>>> 'phone_verification_method': 'network',
>>> 'phone_verification_time': '2026-10-01T14:35:00Z',
>>> 'phone_was_verification_successful': False,
>>> 'delivery_speed': 'same_day',
>>> },
>>> 'credit_card': {
Expand Down
32 changes: 27 additions & 5 deletions src/minfraud/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import re
import urllib.parse
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Any as AnyType

Expand All @@ -21,6 +22,7 @@
All,
Any,
In,
Invalid,
Match,
MultipleInvalid,
Range,
Expand Down Expand Up @@ -79,8 +81,30 @@ def _hostname(hostname: str) -> str:
raise ValueError


def _datetime_exists(s: str) -> str:
# Reject impossible values such as Feb 30 or 24:00. The regex already
# checked the fraction and the offset, so parse only the part before them.
try:
datetime.strptime(s[:19], "%Y-%m-%dT%H:%M:%S") # noqa: DTZ007
except ValueError as e:
msg = "invalid date or time"
raise Invalid(msg) from e
return s


_rfc3339_datetime = All(
Match(
r"(?a)\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?"

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.

The fraction group (\.\d+)? has no length limit. The server calls getString(v, 255, false) before it parses the time, so it rejects a value longer than 255 characters.

"2026-10-01T14:30:00." + "1" * 300 + "Z" passes validate_transaction for event.time and phone_verification_time. The server rejects it with "the string is too long" and ignores the field. This is an edge case, but it conflicts with the changelog claim. A bound such as (\.\d{1,9})? (nanosecond precision, which Go keeps) would close it.

馃 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.

We will leave this. It is an edge case, and the HISTORY entry no longer claims a full match.

Reply by Claude Opus 5.5 on Greg's behalf.

r"(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)\Z",
),
_datetime_exists,
)


_delivery_speed = In(["same_day", "overnight", "expedited", "standard"])

_phone_verification_method = In(["delivered_code", "network", "other"])

_address = {
"address": str,
"address_2": str,
Expand All @@ -91,6 +115,9 @@ def _hostname(hostname: str) -> str:
"last_name": str,
"phone_country_code": _telephone_country_code,
"phone_number": str,
"phone_verification_method": _phone_verification_method,
"phone_verification_time": _rfc3339_datetime,

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.

phone_verification_time uses the shared _rfc3339_datetime validator, so the client does not check the range in the spec: from the Unix epoch to two days in the future.

validate_transaction({"billing": {"phone_verification_time": "1969-12-31T23:59:59Z"}}) passes, and so does "2099-01-01T00:00:00Z". The server (validatePhoneVerificationTime) rejects both with PHONE_VERIFICATION_TIME_OUT_OF_RANGE, ignores the field, and scores the transaction without the signal. The caller gets no error.

The lower bound does not depend on the clock, so the client can check it with no clock-skew risk. The upper bound depends on the client clock, so skipping it is defensible, but the decision should be the same in all the sibling client PRs. The client also does not check the one-year range for /event/time, so if the intent is "format only, no range", the changelog should say that (see the HISTORY.rst comment).

馃 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.

No client checks this range, the same as for /event/time. The web service ignores a time outside the range and returns a warning. cf3ab80 changes the HISTORY entry, so it no longer says that the check matches the web service.

Reply by Claude Opus 5.5 on Greg's behalf.

"phone_was_verification_successful": bool,
"postal": str,
"region": _subdivision_iso_code,
}
Expand Down Expand Up @@ -296,11 +323,6 @@ def _credit_card_token(s: str) -> str:
raise ValueError


_rfc3339_datetime = Match(
r"(?a)\A\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})\Z",
)


_event_party = In(["agent", "customer"])

_event_type = In(
Expand Down
8 changes: 7 additions & 1 deletion tests/data/full-transaction-request.json
Original file line number Diff line number Diff line change
Expand Up @@ -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_verification_time": "2026-10-01T14:30:00Z",
"phone_was_verification_successful": true
},
"shipping": {
"first_name": "ShipFirst",
Expand All @@ -39,6 +42,9 @@
"postal": "73003",
"phone_number": "123-456-0000",
"phone_country_code": "1",
"phone_verification_method": "network",
"phone_verification_time": "2026-10-01T14:35:00Z",
"phone_was_verification_successful": false,
"delivery_speed": "same_day"
},
"payment": {
Expand Down
74 changes: 72 additions & 2 deletions tests/test_validation.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import unittest
from datetime import datetime, timezone
from decimal import Decimal
from typing import TYPE_CHECKING, Any

Expand All @@ -11,6 +12,39 @@
if TYPE_CHECKING:
from collections.abc import Callable

GOOD_TIMES = (
"2015-05-08T16:07:56+00:00",
"2015-05-08T16:07:56Z",
"2026-10-01T14:30:00Z",
"2026-10-01T14:30:00.123456789Z",
"2026-10-01T14:30:00+23:59",
"2024-02-29T00:00:00-05:00",
)

BAD_TIMES = (
"2015-05-08T16:07:56",
"2015-05-08 16:07:56Z",
"2026-02-30T25:61:61Z",

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.

No BAD_TIMES case tests only an impossible day of the month. 2026-02-30T25:61:61Z also has hour 25 and minute and second 61, and 2026-13-45T10:00:00Z has month 13. Either one fails on the other fields first.

If _datetime_exists checked only field ranges (day 1 to 31), February 30 would pass and all tests would still pass. Suggest adding cases such as 2026-02-30T10:00:00Z, 2025-02-29T00:00:00Z (not a leap year) and 2026-04-31T00:00:00Z, so the "February 30" behavior in the changelog has a test.

馃 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.

Added in 4f6a9b1.

Reply by Claude Opus 5.5 on Greg's behalf.

"2026-13-45T10:00:00Z",
"0000-00-00T00:00:00Z",
"2026-10-01T14:30:00+24:00",
"2026-10-01T14:30:00+99:99",
"2026-10-01T24:00:00Z",
"2026-10-01T14:30:60Z",
"2026-10-01t14:30:00Z",
"2026-10-01T14:30:00z",
"2026-10-01T14:30:00+25:00",
"2026-10-01T14:30:00+05:60",
"2026-02-30T10:00:00Z",
"2025-02-29T00:00:00Z",
"2026-04-31T00:00:00Z",
)

NON_STRING_TIMES = (
1,
datetime(2026, 10, 1, 14, 30, tzinfo=timezone.utc),
)


class ValidationBase(unittest.TestCase):
def setup_transaction(self, transaction: dict[str, Any]) -> None:
Expand Down Expand Up @@ -140,6 +174,31 @@ def test_phone_country_code(self) -> None:
for invalid in ("", "12345", "U"):
self.check_invalid_transaction({self.type: {"phone_country_code": invalid}})

def test_phone_verification_method(self) -> None:
for good in ("delivered_code", "network", "other"):
self.check_transaction({self.type: {"phone_verification_method": good}})
for bad in ("sms", 1, ""):
self.check_invalid_transaction(
{self.type: {"phone_verification_method": bad}},
)

def test_phone_was_verification_successful(self) -> None:
self.check_bool(self.type, "phone_was_verification_successful")

def test_phone_verification_time(self) -> None:

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: this test covers only string inputs. test_phone_verification_method tests the int 1, but no test shows that a value that is not a string is rejected here, for example 1 or datetime(2026, 10, 1, tzinfo=UTC).

A Python caller can easily pass a datetime. Match rejects it now, but if a later change puts _datetime_exists first in All(...), s[:19] raises TypeError instead of a validation error, and no test fails.

馃 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.

Added in 4f6a9b1.

Reply by Claude Opus 5.5 on Greg's behalf.

for good in GOOD_TIMES:
self.check_transaction({self.type: {"phone_verification_time": good}})
for bad in BAD_TIMES:
self.check_invalid_transaction(
{self.type: {"phone_verification_time": bad}},
)

def test_phone_verification_time_not_string(self) -> None:
for bad in NON_STRING_TIMES:
self.check_invalid_transaction(
{self.type: {"phone_verification_time": bad}},
)


class TestBillingAddress(AddressBase):
type = "billing"
Expand Down Expand Up @@ -327,11 +386,22 @@ def test_shop_id(self) -> None:
self.check_transaction_str_type("event", "shop_id")

def test_time(self) -> None:
for good in ("2015-05-08T16:07:56+00:00", "2015-05-08T16:07:56Z"):
for good in GOOD_TIMES:
self.check_transaction({"event": {"time": good}})
for bad in ("2015-05-08T16:07:56", "2015-05-08 16:07:56Z"):
for bad in BAD_TIMES:
self.check_invalid_transaction({"event": {"time": bad}})

def test_time_not_string(self) -> None:
for bad in NON_STRING_TIMES:
self.check_invalid_transaction({"event": {"time": bad}})

def test_time_impossible_date_message(self) -> None:
transaction: dict[str, Any] = {"event": {"time": "2026-02-30T10:00:00Z"}}
self.setup_transaction(transaction)
with self.assertRaises(MultipleInvalid) as cm:
validate_transaction(transaction)
self.assertEqual(cm.exception.errors[0].msg, "invalid date or time")

def test_type(self) -> None:
for good in (
"account_creation",
Expand Down
Loading