Skip to content

Commit 1620fb8

Browse files
committed
Add risk score reasons
1 parent 10fbd64 commit 1620fb8

File tree

5 files changed

+244
-1
lines changed

5 files changed

+244
-1
lines changed

HISTORY.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@
33
History
44
-------
55

6-
2.11.1
6+
2.12.0-beta.1
77
+++++++++++++++++++
88

99
* ``setuptools`` was incorrectly listed as a runtime dependency. This has
1010
been removed.
11+
* Added support for the new risk reasons outputs in minFraud Factors. The risk
12+
reasons output codes and reasons are currently in beta and are subject to
13+
change. We recommend that you use these beta outputs with caution and avoid
14+
relying on them for critical applications.
1115

1216
2.11.0 (2024-07-08)
1317
+++++++++++++++++++

minfraud/models.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,6 +1075,143 @@ class Subscores:
10751075
}
10761076

10771077

1078+
@_inflate_to_namedtuple
1079+
class Reason:
1080+
"""The risk score reason for the multiplier.
1081+
1082+
This class provides both a machine-readable code and a human-readable
1083+
explanation of the reason for the risk score.
1084+
1085+
Although more codes may be added in the future, the current codes are:
1086+
1087+
- ``BROWSER_LANGUAGE`` - Riskiness of the browser user-agent and
1088+
language associated with the request.
1089+
- ``BUSINESS_ACTIVITY`` - Riskiness of business activity
1090+
associated with the request.
1091+
- ``COUNTRY`` - Riskiness of the country associated with the request.
1092+
- ``CUSTOMER_ID`` - Riskiness of a customer's activity.
1093+
- ``EMAIL_DOMAIN`` - Riskiness of email domain.
1094+
- ``EMAIL_DOMAIN_NEW`` - Riskiness of newly-sighted email domain.
1095+
- ``EMAIL_ADDRESS_NEW`` - Riskiness of newly-sighted email address.
1096+
- ``EMAIL_LOCAL_PART`` - Riskiness of the local part of the email address.
1097+
- ``EMAIL_VELOCITY`` - Velocity on email - many requests on same email
1098+
over short period of time.
1099+
- ``ISSUER_ID_NUMBER_COUNTRY_MISMATCH`` - Riskiness of the country mismatch
1100+
between IP, billing, shipping and IIN country.
1101+
- ``ISSUER_ID_NUMBER_ON_SHOP_ID`` - Risk of Issuer ID Number for the shop ID.
1102+
- ``ISSUER_ID_NUMBER_LAST_DIGITS_ACTIVITY`` - Riskiness of many recent requests
1103+
and previous high-risk requests on the IIN and last digits of the credit card.
1104+
- ``ISSUER_ID_NUMBER_SHOP_ID_VELOCITY`` - Risk of recent Issuer ID Number activity
1105+
for the shop ID.
1106+
- ``INTRACOUNTRY_DISTANCE`` - Risk of distance between IP, billing,
1107+
and shipping location.
1108+
- ``ANONYMOUS_IP`` - Risk due to IP being an Anonymous IP.
1109+
- ``IP_BILLING_POSTAL_VELOCITY`` - Velocity of distinct billing postal code
1110+
on IP address.
1111+
- ``IP_EMAIL_VELOCITY`` - Velocity of distinct email address on IP address.
1112+
- ``IP_HIGH_RISK_DEVICE`` - High-risk device sighted on IP address.
1113+
- ``IP_ISSUER_ID_NUMBER_VELOCITY`` - Velocity of distinct IIN on IP address.
1114+
- ``IP_ACTIVITY`` - Riskiness of IP based on minFraud network activity.
1115+
- ``LANGUAGE`` - Riskiness of browser language.
1116+
- ``MAX_RECENT_EMAIL`` - Riskiness of email address
1117+
based on past minFraud risk scores on email.
1118+
- ``MAX_RECENT_PHONE`` - Riskiness of phone number
1119+
based on past minFraud risk scores on phone.
1120+
- ``MAX_RECENT_SHIP`` - Riskiness of email address
1121+
based on past minFraud risk scores on ship address.
1122+
- ``MULTIPLE_CUSTOMER_ID_ON_EMAIL`` - Riskiness of email address
1123+
having many customer IDs.
1124+
- ``ORDER_AMOUNT`` - Riskiness of the order amount.
1125+
- ``ORG_DISTANCE_RISK`` - Risk of ISP and distance between
1126+
billing address and IP location.
1127+
- ``PHONE`` - Riskiness of the phone number or related numbers.
1128+
- ``CART`` - Riskiness of shopping cart contents.
1129+
- ``TIME_OF_DAY`` - Risk due to local time of day.
1130+
- ``TRANSACTION_REPORT_EMAIL`` - Risk due to transaction reports
1131+
on the email address.
1132+
- ``TRANSACTION_REPORT_IP`` - Risk due to transaction reports on the IP address.
1133+
- ``TRANSACTION_REPORT_PHONE`` - Risk due to transaction reports
1134+
on the phone number.
1135+
- ``TRANSACTION_REPORT_SHIP`` - Risk due to transaction reports
1136+
on the shipping address.
1137+
- ``EMAIL_ACTIVITY`` - Riskiness of the email address
1138+
based on minFraud network activity.
1139+
- ``PHONE_ACTIVITY`` - Riskiness of the phone number
1140+
based on minFraud network activity.
1141+
- ``SHIP_ACTIVITY`` - Riskiness of ship address based on minFraud network activity.
1142+
1143+
.. attribute:: code
1144+
1145+
This value is a machine-readable code identifying the
1146+
reason.
1147+
1148+
:type: str | None
1149+
1150+
.. attribute:: reason
1151+
1152+
This property provides a human-readable explanation of the
1153+
reason. The text may change at any time and should not be matched
1154+
against.
1155+
1156+
:type: str | None
1157+
"""
1158+
1159+
code: Optional[str]
1160+
reason: Optional[str]
1161+
1162+
__slots__ = ()
1163+
_fields = {
1164+
"code": None,
1165+
"reason": None,
1166+
}
1167+
1168+
1169+
def _create_reasons(reasons: Optional[List[Dict[str, str]]]) -> Tuple[Reason, ...]:
1170+
if not reasons:
1171+
return ()
1172+
return tuple(Reason(x) for x in reasons) # type: ignore
1173+
1174+
1175+
@_inflate_to_namedtuple
1176+
class RiskScoreReason:
1177+
"""The risk score multiplier and the reasons for that multiplier.
1178+
1179+
.. attribute:: multiplier
1180+
1181+
The factor by which the risk score is increased (if the value is greater than 1)
1182+
or decreased (if the value is less than 1) for given risk reason(s).
1183+
Multipliers greater than 1.5 and less than 0.66 are considered significant
1184+
and lead to risk reason(s) being present.
1185+
1186+
:type: float | None
1187+
1188+
.. attribute:: reasons
1189+
1190+
This tuple contains :class:`.Reason` objects that describe
1191+
one of the reasons for the multiplier.
1192+
1193+
:type: tuple[Reason]
1194+
1195+
"""
1196+
1197+
multiplier: float
1198+
reasons: Tuple[Reason, ...]
1199+
1200+
__slots__ = ()
1201+
_fields = {
1202+
"multiplier": None,
1203+
"reasons": _create_reasons,
1204+
}
1205+
1206+
1207+
def _create_risk_score_reasons(
1208+
risk_score_reasons: Optional[List[Dict[str, str]]]
1209+
) -> Tuple[RiskScoreReason, ...]:
1210+
if not risk_score_reasons:
1211+
return ()
1212+
return tuple(RiskScoreReason(x) for x in risk_score_reasons) # type: ignore
1213+
1214+
10781215
@_inflate_to_namedtuple
10791216
class Factors:
10801217
"""Model for Factors response.
@@ -1188,6 +1325,18 @@ class Factors:
11881325
A :class:`.Subscores` object containing scores for many of the
11891326
individual risk factors that are used to calculate the overall risk
11901327
score.
1328+
1329+
.. attribute:: risk_score_reasons
1330+
1331+
This tuple contains :class:`.RiskScoreReason` objects that describe
1332+
risk score reasons for a given transaction
1333+
that change the risk score significantly.
1334+
Risk score reasons are usually only returned for medium to
1335+
high risk transactions. If there were no significant changes to the risk
1336+
score due to these reasons, then this tuple will be empty.
1337+
1338+
:type: tuple[RiskScoreReason]
1339+
11911340
"""
11921341

11931342
billing_address: BillingAddress
@@ -1205,6 +1354,7 @@ class Factors:
12051354
shipping_phone: Phone
12061355
subscores: Subscores
12071356
warnings: Tuple[ServiceWarning, ...]
1357+
risk_score_reasons: Tuple[RiskScoreReason, ...]
12081358

12091359
__slots__ = ()
12101360
_fields = {
@@ -1223,6 +1373,7 @@ class Factors:
12231373
"shipping_phone": Phone,
12241374
"subscores": Subscores,
12251375
"warnings": _create_warnings,
1376+
"risk_score_reasons": _create_risk_score_reasons,
12261377
}
12271378

12281379

tests/data/factors-response.json

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,5 +204,43 @@
204204
"input_pointer": "/account/username_md5",
205205
"warning": "Encountered value at /account/username_md5 that does meet the required constraints"
206206
}
207+
],
208+
"risk_score_reasons": [
209+
{
210+
"multiplier": 45,
211+
"reasons": [
212+
{
213+
"code": "ANONYMOUS_IP",
214+
"reason": "Risk due to IP being an Anonymous IP"
215+
}
216+
]
217+
},
218+
{
219+
"multiplier": 1.8,
220+
"reasons": [
221+
{
222+
"code": "TIME_OF_DAY",
223+
"reason": "Risk due to local time of day"
224+
}
225+
]
226+
},
227+
{
228+
"multiplier": 1.6,
229+
"reasons": [
230+
{
231+
"reason": "Riskiness of newly-sighted email domain",
232+
"code": "EMAIL_DOMAIN_NEW"
233+
}
234+
]
235+
},
236+
{
237+
"multiplier": 0.34,
238+
"reasons": [
239+
{
240+
"code": "EMAIL_ADDRESS_NEW",
241+
"reason": "Riskiness of newly-sighted email address"
242+
}
243+
]
244+
}
207245
]
208246
}

tests/test_models.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,28 @@ def test_warning(self):
273273
self.assertEqual(msg, warning.warning)
274274
self.assertEqual("/first/second", warning.input_pointer)
275275

276+
def test_reason(self):
277+
code = "EMAIL_ADDRESS_NEW"
278+
msg = "Riskiness of newly-sighted email address"
279+
280+
reason = Reason({"code": code, "reason": msg})
281+
282+
self.assertEqual(code, reason.code)
283+
self.assertEqual(msg, reason.reason)
284+
285+
def test_risk_score_reason(self):
286+
multiplier = 0.34
287+
code = "EMAIL_ADDRESS_NEW"
288+
msg = "Riskiness of newly-sighted email address"
289+
290+
reason = RiskScoreReason(
291+
{"multiplier": 0.34, "reasons": [{"code": code, "reason": msg}]}
292+
)
293+
294+
self.assertEqual(multiplier, reason.multiplier)
295+
self.assertEqual(code, reason.reasons[0].code)
296+
self.assertEqual(msg, reason.reasons[0].reason)
297+
276298
def test_score(self):
277299
id = "b643d445-18b2-4b9d-bad4-c9c4366e402a"
278300
score = Score(
@@ -303,6 +325,7 @@ def test_factors(self):
303325
response = self.factors_response()
304326
factors = Factors(response)
305327
self.check_insights_data(factors, response["id"])
328+
self.check_risk_score_reasons_data(factors.risk_score_reasons)
306329
self.assertEqual(0.01, factors.subscores.avs_result)
307330
self.assertEqual(0.02, factors.subscores.billing_address)
308331
self.assertEqual(
@@ -371,6 +394,17 @@ def factors_response(self):
371394
"time_of_day": 0.17,
372395
},
373396
"warnings": [{"code": "INVALID_INPUT"}],
397+
"risk_score_reasons": [
398+
{
399+
"multiplier": 45,
400+
"reasons": [
401+
{
402+
"code": "ANONYMOUS_IP",
403+
"reason": "Risk due to IP being an Anonymous IP",
404+
}
405+
],
406+
}
407+
],
374408
}
375409

376410
def check_insights_data(self, insights, uuid):
@@ -396,3 +430,10 @@ def check_insights_data(self, insights, uuid):
396430
self.assertIsInstance(
397431
insights.warnings, tuple, "warnings is a tuple, not a dict"
398432
)
433+
434+
def check_risk_score_reasons_data(self, reasons):
435+
self.assertEqual(45, reasons[0].multiplier)
436+
self.assertEqual("ANONYMOUS_IP", reasons[0].reasons[0].code)
437+
self.assertEqual(
438+
"Risk due to IP being an Anonymous IP", reasons[0].reasons[0].reason
439+
)

tests/test_webservice.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,15 @@ def test_200_with_reserved_ip_warning(self):
267267

268268
self.assertEqual(12, model.risk_score)
269269

270+
def test_200_with_no_risk_score_reasons(self):
271+
if "risk_score_reasons" not in self.response:
272+
return
273+
274+
response = json.loads(self.response)
275+
del response["risk_score_reasons"]
276+
model = self.create_success(text=json.dumps(response))
277+
self.assertEqual(tuple(), model.risk_score_reasons)
278+
270279
def test_200_with_no_body(self):
271280
with self.assertRaisesRegex(
272281
MinFraudError,

0 commit comments

Comments
 (0)