Skip to content

Commit

Permalink
Merge pull request #152 from maxmind/risk-reasons-beta
Browse files Browse the repository at this point in the history
Risk score reasons
  • Loading branch information
ugexe authored Aug 30, 2024
2 parents 10fbd64 + 071e8ea commit 18162a4
Show file tree
Hide file tree
Showing 5 changed files with 248 additions and 1 deletion.
6 changes: 5 additions & 1 deletion HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
+++++++++++++++++++
Expand Down
153 changes: 153 additions & 0 deletions minfraud/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -1205,6 +1356,7 @@ class Factors:
shipping_phone: Phone
subscores: Subscores
warnings: Tuple[ServiceWarning, ...]
risk_score_reasons: Tuple[RiskScoreReason, ...]

__slots__ = ()
_fields = {
Expand All @@ -1223,6 +1375,7 @@ class Factors:
"shipping_phone": Phone,
"subscores": Subscores,
"warnings": _create_warnings,
"risk_score_reasons": _create_risk_score_reasons,
}


Expand Down
38 changes: 38 additions & 0 deletions tests/data/factors-response.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
]
}
43 changes: 43 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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):
Expand All @@ -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
)
9 changes: 9 additions & 0 deletions tests/test_webservice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit 18162a4

Please sign in to comment.