From 8e61dc168ad1de2e8db4e308b6c7e2029ae88d30 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 9 Oct 2026 20:42:50 +0000 Subject: [PATCH 1/5] Add phone verification inputs Add the optional phone_verification_method, phone_was_verification_successful, and phone_verification_time inputs to both the /billing and /shipping request objects. They describe the most recent verification of the phone number. Both objects share the same validation. Co-Authored-By: Claude Opus 5.5 --- HISTORY.rst | 8 ++++++++ README.rst | 6 ++++++ src/minfraud/validation.py | 15 ++++++++++----- tests/data/full-transaction-request.json | 8 +++++++- tests/test_validation.py | 19 +++++++++++++++++++ 5 files changed, 50 insertions(+), 6 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index ee3bd707..26274f3b 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,14 @@ 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. + 3.3.0 (2026-07-21) ++++++++++++++++++ diff --git a/README.rst b/README.rst index 56dece54..961ec22f 100644 --- a/README.rst +++ b/README.rst @@ -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', @@ -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': { diff --git a/src/minfraud/validation.py b/src/minfraud/validation.py index 18d7a1bf..c5fb3832 100644 --- a/src/minfraud/validation.py +++ b/src/minfraud/validation.py @@ -79,8 +79,15 @@ def _hostname(hostname: 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", +) + + _delivery_speed = In(["same_day", "overnight", "expedited", "standard"]) +_phone_verification_method = In(["delivered_code", "network", "other"]) + _address = { "address": str, "address_2": str, @@ -91,6 +98,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, + "phone_was_verification_successful": bool, "postal": str, "region": _subdivision_iso_code, } @@ -296,11 +306,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( diff --git a/tests/data/full-transaction-request.json b/tests/data/full-transaction-request.json index 40fd47e0..985d3333 100644 --- a/tests/data/full-transaction-request.json +++ b/tests/data/full-transaction-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_verification_time": "2026-10-01T14:30:00Z", + "phone_was_verification_successful": true }, "shipping": { "first_name": "ShipFirst", @@ -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": { diff --git a/tests/test_validation.py b/tests/test_validation.py index 30e77ad6..8246db4f 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -140,6 +140,25 @@ 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: + for good in ("2015-05-08T16:07:56+00:00", "2015-05-08T16:07:56Z"): + self.check_transaction({self.type: {"phone_verification_time": good}}) + for bad in ("2015-05-08T16:07:56", "2015-05-08 16:07:56Z"): + self.check_invalid_transaction( + {self.type: {"phone_verification_time": bad}}, + ) + class TestBillingAddress(AddressBase): type = "billing" From 2e99c0c40fab3b13597341758386c185b6c0368a Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 15 Oct 2026 21:53:02 +0000 Subject: [PATCH 2/5] Reject impossible times in RFC 3339 validation The RFC 3339 check used only a regex. It accepted values that the web service rejects, such as 2026-02-30T25:61:61Z or an offset of +24:00. The check now uses the server's regex with an uppercase T and Z only, then parses the date and time to reject impossible values. This applies to /event/time and phone_verification_time. Co-Authored-By: Claude Opus 5.5 --- HISTORY.rst | 4 ++++ src/minfraud/validation.py | 16 ++++++++++++++-- tests/test_validation.py | 33 +++++++++++++++++++++++++++++---- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 26274f3b..a20b966f 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -10,6 +10,10 @@ History ``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 the same values as the web service. It rejects impossible dates + and times, such as February 30 or 24:00, a lowercase ``t`` or ``z``, and + an offset of 24 hours or more. 3.3.0 (2026-07-21) ++++++++++++++++++ diff --git a/src/minfraud/validation.py b/src/minfraud/validation.py index c5fb3832..8aa210fe 100644 --- a/src/minfraud/validation.py +++ b/src/minfraud/validation.py @@ -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 @@ -79,8 +80,19 @@ def _hostname(hostname: 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", +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. + datetime.strptime(s[:19], "%Y-%m-%dT%H:%M:%S") # noqa: DTZ007 + return s + + +_rfc3339_datetime = All( + Match( + r"(?a)\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?" + r"(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)\Z", + ), + _datetime_exists, ) diff --git a/tests/test_validation.py b/tests/test_validation.py index 8246db4f..01911388 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -11,6 +11,31 @@ 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", + "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", +) + class ValidationBase(unittest.TestCase): def setup_transaction(self, transaction: dict[str, Any]) -> None: @@ -152,9 +177,9 @@ def test_phone_was_verification_successful(self) -> None: self.check_bool(self.type, "phone_was_verification_successful") def test_phone_verification_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({self.type: {"phone_verification_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( {self.type: {"phone_verification_time": bad}}, ) @@ -346,9 +371,9 @@ 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_type(self) -> None: From cf3ab80639d9b4c7f633a38a4af89e4ada9a3cf0 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 19 Oct 2026 20:43:09 +0000 Subject: [PATCH 3/5] Correct the RFC 3339 check entry in HISTORY The client does not check the time ranges or the length limit of the web service, so it does not reject every value that the web service rejects. Say that it now rejects more of these values. Co-Authored-By: Claude Opus 5.5 --- HISTORY.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index a20b966f..139f21b1 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -11,9 +11,8 @@ History 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 the same values as the web service. It rejects impossible dates - and times, such as February 30 or 24:00, a lowercase ``t`` or ``z``, and - an offset of 24 hours or more. + 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) ++++++++++++++++++ From 4f6a9b1aec0d602da34f12518dbab21d3d20fd26 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 19 Oct 2026 20:43:39 +0000 Subject: [PATCH 4/5] Test impossible days and non-string times Add times that fail only on the calendar day. Also test that the int 1 and a datetime object are rejected for event.time and phone_verification_time. Co-Authored-By: Claude Opus 5.5 --- tests/test_validation.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_validation.py b/tests/test_validation.py index 01911388..c802adb2 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -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 @@ -34,6 +35,14 @@ "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), ) @@ -184,6 +193,12 @@ def test_phone_verification_time(self) -> None: {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" @@ -376,6 +391,10 @@ def test_time(self) -> None: 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_type(self) -> None: for good in ( "account_creation", From 12450c73d49d50518192b748025e911cbe355024 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 19 Oct 2026 20:43:39 +0000 Subject: [PATCH 5/5] Give a clear error for an impossible date or time Before, the error was "not a valid value". Co-Authored-By: Claude Opus 5.5 --- src/minfraud/validation.py | 7 ++++++- tests/test_validation.py | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/minfraud/validation.py b/src/minfraud/validation.py index 8aa210fe..59b5c399 100644 --- a/src/minfraud/validation.py +++ b/src/minfraud/validation.py @@ -22,6 +22,7 @@ All, Any, In, + Invalid, Match, MultipleInvalid, Range, @@ -83,7 +84,11 @@ def _hostname(hostname: str) -> str: 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. - datetime.strptime(s[:19], "%Y-%m-%dT%H:%M:%S") # noqa: DTZ007 + 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 diff --git a/tests/test_validation.py b/tests/test_validation.py index c802adb2..32070854 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -395,6 +395,13 @@ 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",