diff --git a/HISTORY.rst b/HISTORY.rst index 5108d27..c20c47a 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,11 +3,15 @@ History ------- -2.11.1 +2.12.0b1 +++++++++++++++++++ * ``setuptools`` was incorrectly listed as a runtime dependency. This has been removed. +* Added support for the new risk reasons outputs in minFraud Factors. The risk + reasons output codes and reasons are currently in beta and are subject to + change. We recommend that you use these beta outputs with caution and avoid + relying on them for critical applications. 2.11.0 (2024-07-08) +++++++++++++++++++ diff --git a/minfraud/models.py b/minfraud/models.py index 3c2bc2f..389a7ce 100644 --- a/minfraud/models.py +++ b/minfraud/models.py @@ -1075,6 +1075,146 @@ class Subscores: } +@_inflate_to_namedtuple +class Reason: + """The risk score reason for the multiplier. + + This class provides both a machine-readable code and a human-readable + explanation of the reason for the risk score. + + Although more codes_ may be added in the future, the current codes are: + + - ``BROWSER_LANGUAGE`` - Riskiness of the browser user-agent and + language associated with the request. + - ``BUSINESS_ACTIVITY`` - Riskiness of business activity + associated with the request. + - ``COUNTRY`` - Riskiness of the country associated with the request. + - ``CUSTOMER_ID`` - Riskiness of a customer's activity. + - ``EMAIL_DOMAIN`` - Riskiness of email domain. + - ``EMAIL_DOMAIN_NEW`` - Riskiness of newly-sighted email domain. + - ``EMAIL_ADDRESS_NEW`` - Riskiness of newly-sighted email address. + - ``EMAIL_LOCAL_PART`` - Riskiness of the local part of the email address. + - ``EMAIL_VELOCITY`` - Velocity on email - many requests on same email + over short period of time. + - ``ISSUER_ID_NUMBER_COUNTRY_MISMATCH`` - Riskiness of the country mismatch + between IP, billing, shipping and IIN country. + - ``ISSUER_ID_NUMBER_ON_SHOP_ID`` - Risk of Issuer ID Number for the shop ID. + - ``ISSUER_ID_NUMBER_LAST_DIGITS_ACTIVITY`` - Riskiness of many recent requests + and previous high-risk requests on the IIN and last digits of the credit card. + - ``ISSUER_ID_NUMBER_SHOP_ID_VELOCITY`` - Risk of recent Issuer ID Number activity + for the shop ID. + - ``INTRACOUNTRY_DISTANCE`` - Risk of distance between IP, billing, + and shipping location. + - ``ANONYMOUS_IP`` - Risk due to IP being an Anonymous IP. + - ``IP_BILLING_POSTAL_VELOCITY`` - Velocity of distinct billing postal code + on IP address. + - ``IP_EMAIL_VELOCITY`` - Velocity of distinct email address on IP address. + - ``IP_HIGH_RISK_DEVICE`` - High-risk device sighted on IP address. + - ``IP_ISSUER_ID_NUMBER_VELOCITY`` - Velocity of distinct IIN on IP address. + - ``IP_ACTIVITY`` - Riskiness of IP based on minFraud network activity. + - ``LANGUAGE`` - Riskiness of browser language. + - ``MAX_RECENT_EMAIL`` - Riskiness of email address + based on past minFraud risk scores on email. + - ``MAX_RECENT_PHONE`` - Riskiness of phone number + based on past minFraud risk scores on phone. + - ``MAX_RECENT_SHIP`` - Riskiness of email address + based on past minFraud risk scores on ship address. + - ``MULTIPLE_CUSTOMER_ID_ON_EMAIL`` - Riskiness of email address + having many customer IDs. + - ``ORDER_AMOUNT`` - Riskiness of the order amount. + - ``ORG_DISTANCE_RISK`` - Risk of ISP and distance between + billing address and IP location. + - ``PHONE`` - Riskiness of the phone number or related numbers. + - ``CART`` - Riskiness of shopping cart contents. + - ``TIME_OF_DAY`` - Risk due to local time of day. + - ``TRANSACTION_REPORT_EMAIL`` - Risk due to transaction reports + on the email address. + - ``TRANSACTION_REPORT_IP`` - Risk due to transaction reports on the IP address. + - ``TRANSACTION_REPORT_PHONE`` - Risk due to transaction reports + on the phone number. + - ``TRANSACTION_REPORT_SHIP`` - Risk due to transaction reports + on the shipping address. + - ``EMAIL_ACTIVITY`` - Riskiness of the email address + based on minFraud network activity. + - ``PHONE_ACTIVITY`` - Riskiness of the phone number + based on minFraud network activity. + - ``SHIP_ACTIVITY`` - Riskiness of ship address based on minFraud network activity. + + .. _codes: https://dev.maxmind.com/minfraud/api-documentation/responses\ + /#schema--response--risk-score-reason--multiplier-reason + + .. attribute:: code + + This value is a machine-readable code identifying the + reason. + + :type: str | None + + .. attribute:: reason + + This property provides a human-readable explanation of the + reason. The text may change at any time and should not be matched + against. + + :type: str | None + """ + + code: Optional[str] + reason: Optional[str] + + __slots__ = () + _fields = { + "code": None, + "reason": None, + } + + +def _create_reasons(reasons: Optional[List[Dict[str, str]]]) -> Tuple[Reason, ...]: + if not reasons: + return () + return tuple(Reason(x) for x in reasons) # type: ignore + + +@_inflate_to_namedtuple +class RiskScoreReason: + """The risk score multiplier and the reasons for that multiplier. + + .. attribute:: multiplier + + The factor by which the risk score is increased (if the value is greater than 1) + or decreased (if the value is less than 1) for given risk reason(s). + Multipliers greater than 1.5 and less than 0.66 are considered significant + and lead to risk reason(s) being present. + + :type: float | None + + .. attribute:: reasons + + This tuple contains :class:`.Reason` objects that describe + one of the reasons for the multiplier. + + :type: tuple[Reason] + + """ + + multiplier: float + reasons: Tuple[Reason, ...] + + __slots__ = () + _fields = { + "multiplier": None, + "reasons": _create_reasons, + } + + +def _create_risk_score_reasons( + risk_score_reasons: Optional[List[Dict[str, str]]] +) -> Tuple[RiskScoreReason, ...]: + if not risk_score_reasons: + return () + return tuple(RiskScoreReason(x) for x in risk_score_reasons) # type: ignore + + @_inflate_to_namedtuple class Factors: """Model for Factors response. @@ -1188,6 +1328,17 @@ class Factors: A :class:`.Subscores` object containing scores for many of the individual risk factors that are used to calculate the overall risk score. + + .. attribute:: risk_score_reasons + + This tuple contains :class:`.RiskScoreReason` objects that describe + risk score reasons for a given transaction that change the risk score + significantly. Risk score reasons are usually only returned for medium to + high risk transactions. If there were no significant changes to the risk + score due to these reasons, then this tuple will be empty. + + :type: tuple[RiskScoreReason] + """ billing_address: BillingAddress @@ -1205,6 +1356,7 @@ class Factors: shipping_phone: Phone subscores: Subscores warnings: Tuple[ServiceWarning, ...] + risk_score_reasons: Tuple[RiskScoreReason, ...] __slots__ = () _fields = { @@ -1223,6 +1375,7 @@ class Factors: "shipping_phone": Phone, "subscores": Subscores, "warnings": _create_warnings, + "risk_score_reasons": _create_risk_score_reasons, } diff --git a/tests/data/factors-response.json b/tests/data/factors-response.json index c328ef1..0b69b24 100644 --- a/tests/data/factors-response.json +++ b/tests/data/factors-response.json @@ -204,5 +204,43 @@ "input_pointer": "/account/username_md5", "warning": "Encountered value at /account/username_md5 that does meet the required constraints" } + ], + "risk_score_reasons": [ + { + "multiplier": 45.0, + "reasons": [ + { + "code": "ANONYMOUS_IP", + "reason": "Risk due to IP being an Anonymous IP" + } + ] + }, + { + "multiplier": 1.8, + "reasons": [ + { + "code": "TIME_OF_DAY", + "reason": "Risk due to local time of day" + } + ] + }, + { + "multiplier": 1.6, + "reasons": [ + { + "reason": "Riskiness of newly-sighted email domain", + "code": "EMAIL_DOMAIN_NEW" + } + ] + }, + { + "multiplier": 0.34, + "reasons": [ + { + "code": "EMAIL_ADDRESS_NEW", + "reason": "Riskiness of newly-sighted email address" + } + ] + } ] } diff --git a/tests/test_models.py b/tests/test_models.py index a8472fa..b42bacb 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -273,6 +273,28 @@ def test_warning(self): self.assertEqual(msg, warning.warning) self.assertEqual("/first/second", warning.input_pointer) + def test_reason(self): + code = "EMAIL_ADDRESS_NEW" + msg = "Riskiness of newly-sighted email address" + + reason = Reason({"code": code, "reason": msg}) + + self.assertEqual(code, reason.code) + self.assertEqual(msg, reason.reason) + + def test_risk_score_reason(self): + multiplier = 0.34 + code = "EMAIL_ADDRESS_NEW" + msg = "Riskiness of newly-sighted email address" + + reason = RiskScoreReason( + {"multiplier": 0.34, "reasons": [{"code": code, "reason": msg}]} + ) + + self.assertEqual(multiplier, reason.multiplier) + self.assertEqual(code, reason.reasons[0].code) + self.assertEqual(msg, reason.reasons[0].reason) + def test_score(self): id = "b643d445-18b2-4b9d-bad4-c9c4366e402a" score = Score( @@ -303,6 +325,7 @@ def test_factors(self): response = self.factors_response() factors = Factors(response) self.check_insights_data(factors, response["id"]) + self.check_risk_score_reasons_data(factors.risk_score_reasons) self.assertEqual(0.01, factors.subscores.avs_result) self.assertEqual(0.02, factors.subscores.billing_address) self.assertEqual( @@ -371,6 +394,17 @@ def factors_response(self): "time_of_day": 0.17, }, "warnings": [{"code": "INVALID_INPUT"}], + "risk_score_reasons": [ + { + "multiplier": 45, + "reasons": [ + { + "code": "ANONYMOUS_IP", + "reason": "Risk due to IP being an Anonymous IP", + } + ], + } + ], } def check_insights_data(self, insights, uuid): @@ -396,3 +430,12 @@ def check_insights_data(self, insights, uuid): self.assertIsInstance( insights.warnings, tuple, "warnings is a tuple, not a dict" ) + + def check_risk_score_reasons_data(self, reasons): + self.assertEqual(1, len(reasons)) + self.assertEqual(45, reasons[0].multiplier) + self.assertEqual(1, len(reasons[0].reasons)) + self.assertEqual("ANONYMOUS_IP", reasons[0].reasons[0].code) + self.assertEqual( + "Risk due to IP being an Anonymous IP", reasons[0].reasons[0].reason + ) diff --git a/tests/test_webservice.py b/tests/test_webservice.py index e4ba942..7f5a9c9 100644 --- a/tests/test_webservice.py +++ b/tests/test_webservice.py @@ -267,6 +267,15 @@ def test_200_with_reserved_ip_warning(self): self.assertEqual(12, model.risk_score) + def test_200_with_no_risk_score_reasons(self): + if "risk_score_reasons" not in self.response: + return + + response = json.loads(self.response) + del response["risk_score_reasons"] + model = self.create_success(text=json.dumps(response)) + self.assertEqual(tuple(), model.risk_score_reasons) + def test_200_with_no_body(self): with self.assertRaisesRegex( MinFraudError,