Skip to content

Commit a7b7688

Browse files
committed
Add support for 8 digit IINs and 2 digit last_digits
Previously issuer_id_number was expected to be 6 digits and last_4_digits to be 4 digits. This changes the validation to additionally allow for 8 digit issuer_id_numbers + 2 digit last_4_digits. Additionally last_4_digits has been deprecated in favor of the more appropriately named last_digits.
1 parent 305e687 commit a7b7688

6 files changed

Lines changed: 209 additions & 70 deletions

File tree

README.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,10 @@ Score, Insights and Factors Example
191191
>>> 'bank_phone_country_code': '1',
192192
>>> 'avs_result': 'Y',
193193
>>> 'bank_phone_number': '123-456-1234',
194-
>>> 'last_4_digits': '7643',
194+
>>> 'last_digits': '7643',
195195
>>> 'cvv_result': 'N',
196196
>>> 'bank_name': 'Bank of No Hope',
197-
>>> 'issuer_id_number': '411111'
197+
>>> 'issuer_id_number': '411111',
198198
>>> 'was_3d_secure_successful': True
199199
>>> },
200200
>>> 'payment': {

minfraud/request.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
66
"""
77

8+
import warnings
89
import hashlib
910
from typing import Any, Dict
1011
from voluptuous import MultipleInvalid
@@ -53,6 +54,9 @@ def prepare_transaction(
5354
if hash_email:
5455
maybe_hash_email(cleaned_request)
5556

57+
if cleaned_request.get("credit_card", None):
58+
clean_credit_card(cleaned_request)
59+
5660
return cleaned_request
5761

5862

@@ -65,6 +69,17 @@ def _copy_and_clean(data: Any) -> Any:
6569
return data
6670

6771

72+
def clean_credit_card(transaction):
73+
"""Clean the credit_card input of a transaction request"""
74+
last4 = transaction["credit_card"].pop("last_4_digits", None)
75+
if last4:
76+
warnings.warn(
77+
"last_4_digits has been deprecated in favor of last_digits",
78+
DeprecationWarning,
79+
)
80+
transaction["credit_card"]["last_digits"] = last4
81+
82+
6883
def maybe_hash_email(transaction):
6984
"""Hash email address in transaction, if present"""
7085
try:

minfraud/validation.py

Lines changed: 89 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
88
"""
99

10-
1110
import ipaddress
1211
import re
1312
import uuid
@@ -17,7 +16,7 @@
1716

1817
from email_validator import validate_email # type: ignore
1918
from voluptuous import All, Any, In, Match, Range, Required, Schema
20-
from voluptuous.error import UrlInvalid
19+
from voluptuous.error import LengthInvalid, UrlInvalid
2120

2221
# Pylint doesn't like the private function type naming for the callable
2322
# objects below. Given the consistent use of them, the current names seem
@@ -250,9 +249,9 @@ def _hostname(hostname: str) -> str:
250249

251250
_single_char = Match("^[A-Za-z0-9]$")
252251

253-
_iin = Match("^[0-9]{6}$")
252+
_iin = Match("^(?:[0-9]{6}|[0-9]{8})$")
254253

255-
_credit_card_last_4 = Match("^[0-9]{4}$")
254+
_credit_card_last_digits = Match("^(?:[0-9]{2}|[0-9]{4})$")
256255

257256

258257
def _credit_card_token(s: str) -> str:
@@ -292,67 +291,94 @@ def _uri(s: str) -> str:
292291
return s
293292

294293

294+
def _validate_last_digits(req):
295+
cc = req.get("credit_card")
296+
if cc is None:
297+
return
298+
299+
iin = cc.get("issuer_id_number")
300+
if iin is None:
301+
return
302+
303+
if iin and len(iin) == 8:
304+
last_digits = cc.get("last_digits")
305+
last_4_digits = cc.get("last_4_digits")
306+
if last_digits and len(last_digits) != 2:
307+
raise LengthInvalid(
308+
"last_digits must be two digits when the issuer_id_number is eight digits."
309+
)
310+
if last_4_digits and len(last_4_digits) != 2:
311+
raise LengthInvalid(
312+
"last_4_digits must be two digits when the issuer_id_number is eight digits."
313+
)
314+
return
315+
316+
295317
validate_transaction = Schema(
296-
{
297-
"account": {
298-
"user_id": str,
299-
"username_md5": _md5,
300-
},
301-
"billing": _address,
302-
"payment": {
303-
"processor": _payment_processor,
304-
"was_authorized": bool,
305-
"decline_code": str,
306-
},
307-
"credit_card": {
308-
"avs_result": _single_char,
309-
"bank_name": str,
310-
"bank_phone_country_code": _telephone_country_code,
311-
"bank_phone_number": str,
312-
"cvv_result": _single_char,
313-
"issuer_id_number": _iin,
314-
"last_4_digits": _credit_card_last_4,
315-
"token": _credit_card_token,
316-
"was_3d_secure_successful": bool,
317-
},
318-
"custom_inputs": {_custom_input_key: _custom_input_value},
319-
"device": {
320-
"accept_language": str,
321-
"ip_address": _ip_address,
322-
"session_age": All(_any_number, Range(min=0)),
323-
"session_id": str,
324-
"user_agent": str,
325-
},
326-
"email": {
327-
"address": _email_or_md5,
328-
"domain": _hostname,
329-
},
330-
"event": {
331-
"shop_id": str,
332-
"time": _rfc3339_datetime,
333-
"type": _event_type,
334-
"transaction_id": str,
335-
},
336-
"order": {
337-
"affiliate_id": str,
338-
"amount": _price,
339-
"currency": _currency_code,
340-
"discount_code": str,
341-
"has_gift_message": bool,
342-
"is_gift": bool,
343-
"referrer_uri": _uri,
344-
"subaffiliate_id": str,
345-
},
346-
"shipping": _shipping_address,
347-
"shopping_cart": [
348-
{
349-
"category": str,
350-
"item_id": str,
351-
"price": _price,
352-
"quantity": All(int, Range(min=1)),
318+
All(
319+
{
320+
"account": {
321+
"user_id": str,
322+
"username_md5": _md5,
353323
},
354-
],
355-
},
324+
"billing": _address,
325+
"payment": {
326+
"processor": _payment_processor,
327+
"was_authorized": bool,
328+
"decline_code": str,
329+
},
330+
"credit_card": {
331+
"avs_result": _single_char,
332+
"bank_name": str,
333+
"bank_phone_country_code": _telephone_country_code,
334+
"bank_phone_number": str,
335+
"cvv_result": _single_char,
336+
"issuer_id_number": _iin,
337+
"last_digits": _credit_card_last_digits,
338+
"last_4_digits": _credit_card_last_digits,
339+
"token": _credit_card_token,
340+
"was_3d_secure_successful": bool,
341+
},
342+
"custom_inputs": {_custom_input_key: _custom_input_value},
343+
"device": {
344+
"accept_language": str,
345+
"ip_address": _ip_address,
346+
"session_age": All(_any_number, Range(min=0)),
347+
"session_id": str,
348+
"user_agent": str,
349+
},
350+
"email": {
351+
"address": _email_or_md5,
352+
"domain": _hostname,
353+
},
354+
"event": {
355+
"shop_id": str,
356+
"time": _rfc3339_datetime,
357+
"type": _event_type,
358+
"transaction_id": str,
359+
},
360+
"order": {
361+
"affiliate_id": str,
362+
"amount": _price,
363+
"currency": _currency_code,
364+
"discount_code": str,
365+
"has_gift_message": bool,
366+
"is_gift": bool,
367+
"referrer_uri": _uri,
368+
"subaffiliate_id": str,
369+
},
370+
"shipping": _shipping_address,
371+
"shopping_cart": [
372+
{
373+
"category": str,
374+
"item_id": str,
375+
"price": _price,
376+
"quantity": All(int, Range(min=1)),
377+
},
378+
],
379+
},
380+
_validate_last_digits,
381+
)
356382
)
357383

358384

tests/data/full-transaction-request.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
},
4848
"credit_card": {
4949
"issuer_id_number": "411111",
50-
"last_4_digits": "7643",
50+
"last_digits": "7643",
5151
"bank_name": "Bank of No Hope",
5252
"bank_phone_country_code": "1",
5353
"bank_phone_number": "123-456-1234",

tests/test_request.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import unittest
22

3-
from minfraud.request import maybe_hash_email
3+
from minfraud.request import maybe_hash_email, clean_credit_card
44

55

66
class TestRequest(unittest.TestCase):
@@ -146,3 +146,83 @@ def test_maybe_hash_email(self):
146146
maybe_hash_email(transaction)
147147

148148
self.assertEqual(test["expected"], transaction)
149+
150+
def test_clean_credit_card(self):
151+
tests = [
152+
{
153+
"name": "deprecated last_4_digits is cleaned to last_digits",
154+
"input": {
155+
"credit_card": {
156+
"issuer_id_number": "123456",
157+
"last_4_digits": "1234",
158+
},
159+
"device": {"ip_address": "1.1.1.1"},
160+
},
161+
"expected": {
162+
"credit_card": {
163+
"issuer_id_number": "123456",
164+
"last_digits": "1234",
165+
},
166+
"device": {"ip_address": "1.1.1.1"},
167+
},
168+
},
169+
{
170+
"name": "6 digit iin, 4 digit last_digits",
171+
"input": {
172+
"credit_card": {
173+
"issuer_id_number": "123456",
174+
"last_digits": "1234",
175+
},
176+
"device": {"ip_address": "1.1.1.1"},
177+
},
178+
"expected": {
179+
"credit_card": {
180+
"issuer_id_number": "123456",
181+
"last_digits": "1234",
182+
},
183+
"device": {"ip_address": "1.1.1.1"},
184+
},
185+
},
186+
{
187+
"name": "8 digit iin, 2 digit last_digits",
188+
"input": {
189+
"credit_card": {
190+
"issuer_id_number": "12345678",
191+
"last_digits": "34",
192+
},
193+
"device": {"ip_address": "1.1.1.1"},
194+
},
195+
"expected": {
196+
"credit_card": {
197+
"issuer_id_number": "12345678",
198+
"last_digits": "34",
199+
},
200+
"device": {"ip_address": "1.1.1.1"},
201+
},
202+
},
203+
{
204+
"name": "8 digit iin, 4 digit last_digits",
205+
"input": {
206+
"credit_card": {
207+
"issuer_id_number": "12345678",
208+
"last_digits": "1234",
209+
},
210+
"device": {"ip_address": "1.1.1.1"},
211+
},
212+
"expected": {
213+
"credit_card": {
214+
"issuer_id_number": "12345678",
215+
"last_digits": "1234",
216+
},
217+
"device": {"ip_address": "1.1.1.1"},
218+
},
219+
},
220+
]
221+
222+
for test in tests:
223+
with self.subTest(test["name"]):
224+
transaction = test["input"]
225+
226+
clean_credit_card(transaction)
227+
228+
self.assertEqual(test["expected"], transaction)

tests/test_validation.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,16 +143,34 @@ def test_delivery_speed(self):
143143

144144
class TestCreditCard(ValidationBase, unittest.TestCase):
145145
def test_issuer_id_number(self):
146-
for iin in ("123456", "532313"):
146+
for iin in ("123456", "532313", "88888888"):
147147
self.check_transaction({"credit_card": {"issuer_id_number": iin}})
148148
for invalid in ("12345", "1234567", 123456, "12345a"):
149149
self.check_invalid_transaction(
150150
{"credit_card": {"issuer_id_number": invalid}}
151151
)
152152

153+
def test_last_digits(self):
154+
for last_digits in ("1234", "9323", "34"):
155+
self.check_transaction({"credit_card": {"last_digits": last_digits}})
156+
for invalid in ("12345", "123", 1234, "123a"):
157+
self.check_invalid_transaction({"credit_card": {"last_digits": invalid}})
158+
self.check_transaction(
159+
{"credit_card": {"issuer_id_number": "88888888", "last_digits": "12"}}
160+
)
161+
self.check_invalid_transaction(
162+
{"credit_card": {"issuer_id_number": "88888888", "last_digits": "1234"}}
163+
)
164+
self.check_transaction(
165+
{"credit_card": {"issuer_id_number": "666666", "last_digits": "1234"}}
166+
)
167+
self.check_transaction(
168+
{"credit_card": {"issuer_id_number": "666666", "last_digits": "34"}}
169+
)
170+
153171
def test_last_4_digits(self):
154-
for iin in ("1234", "9323"):
155-
self.check_transaction({"credit_card": {"last_4_digits": iin}})
172+
for last_digits in ("1234", "9323", "34"):
173+
self.check_transaction({"credit_card": {"last_4_digits": last_digits}})
156174
for invalid in ("12345", "123", 1234, "123a"):
157175
self.check_invalid_transaction({"credit_card": {"last_4_digits": invalid}})
158176

0 commit comments

Comments
 (0)