Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 73 additions & 44 deletions pydantic_extra_types/payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class PaymentCardBrand(str, Enum):
troy = 'Troy'
unionpay = 'UnionPay'
jcb = 'JCB'
diners_club = 'Diners Club'
other = 'other'

def __str__(self) -> str:
Expand Down Expand Up @@ -126,6 +127,77 @@ def validate_luhn_check_digit(cls, card_number: str) -> str:
raise PydanticCustomError('payment_card_number_luhn', 'Card number is not luhn valid')
return card_number

@classmethod
def _identify_brand(cls, card_number: str) -> tuple[PaymentCardBrand, list[int]]:
"""Identify the brand and required length for a card number.

Args:
card_number: The card number to identify.

Returns:
A tuple of (brand, required_length)
"""
# VISA
if card_number[0] == '4':
return PaymentCardBrand.visa, [13, 16, 19]

# Mastercard
if (51 <= int(card_number[:2]) <= 55) or (2221 <= int(card_number[:4]) <= 2720):
return PaymentCardBrand.mastercard, [16]

# American Express
if card_number[:2] in {'34', '37'}:
return PaymentCardBrand.amex, [15]

# MIR
if 2200 <= int(card_number[:4]) <= 2204:
return PaymentCardBrand.mir, list(range(16, 20))

# Maestro
if card_number[:4] in {'5018', '5020', '5038', '5893', '6304', '6759', '6761', '6762', '6763'} or card_number[
:6
] in ('676770', '676774'):
return PaymentCardBrand.maestro, list(range(12, 20))

# Discover
if card_number.startswith('65') or 644 <= int(card_number[:3]) <= 649 or card_number.startswith('6011'):
return PaymentCardBrand.discover, list(range(16, 20))

# Verve
if (
506099 <= int(card_number[:6]) <= 506198
or 650002 <= int(card_number[:6]) <= 650027
or 507865 <= int(card_number[:6]) <= 507964
):
return PaymentCardBrand.verve, [16, 18, 19]

# Dankort
if card_number[:4] in {'5019', '4571'}:
return PaymentCardBrand.dankort, [16]

# Troy
if card_number.startswith('9792'):
return PaymentCardBrand.troy, [16]

# UnionPay
if card_number[:2] in {'62', '81'}:
return PaymentCardBrand.unionpay, [16, 19]

# JCB
if 3528 <= int(card_number[:4]) <= 3589:
return PaymentCardBrand.jcb, [16, 19]

# Diners Club
if card_number[:2] in {'30', '36', '38', '39'}:
return PaymentCardBrand.diners_club, list(range(14, 20))

# More Diners Club
if card_number.startswith('55'):
return PaymentCardBrand.diners_club, [16]

# Other / Unknown
return PaymentCardBrand.other, []

@staticmethod
def validate_brand(card_number: str) -> PaymentCardBrand:
"""Validate length based on
Expand All @@ -141,50 +213,7 @@ def validate_brand(card_number: str) -> PaymentCardBrand:
Raises:
PydanticCustomError: If the card number is not valid.
"""
brand = PaymentCardBrand.other

if card_number[0] == '4':
brand = PaymentCardBrand.visa
required_length = [13, 16, 19]
elif 51 <= int(card_number[:2]) <= 55:
brand = PaymentCardBrand.mastercard
required_length = [16]
elif card_number[:2] in {'34', '37'}:
brand = PaymentCardBrand.amex
required_length = [15]
elif 2200 <= int(card_number[:4]) <= 2204:
brand = PaymentCardBrand.mir
required_length = list(range(16, 20))
elif card_number[:4] in {'5018', '5020', '5038', '5893', '6304', '6759', '6761', '6762', '6763'} or card_number[
:6
] in (
'676770',
'676774',
):
brand = PaymentCardBrand.maestro
required_length = list(range(12, 20))
elif card_number.startswith('65') or 644 <= int(card_number[:3]) <= 649 or card_number.startswith('6011'):
brand = PaymentCardBrand.discover
required_length = list(range(16, 20))
elif (
506099 <= int(card_number[:6]) <= 506198
or 650002 <= int(card_number[:6]) <= 650027
or 507865 <= int(card_number[:6]) <= 507964
):
brand = PaymentCardBrand.verve
required_length = [16, 18, 19]
elif card_number[:4] in {'5019', '4571'}:
brand = PaymentCardBrand.dankort
required_length = [16]
elif card_number.startswith('9792'):
brand = PaymentCardBrand.troy
required_length = [16]
elif card_number[:2] in {'62', '81'}:
brand = PaymentCardBrand.unionpay
required_length = [16, 19]
elif 3528 <= int(card_number[:4]) <= 3589:
brand = PaymentCardBrand.jcb
required_length = [16, 19]
brand, required_length = PaymentCardNumber._identify_brand(card_number)

valid = len(card_number) in required_length if brand != PaymentCardBrand.other else True

Expand Down
11 changes: 11 additions & 0 deletions tests/test_types_payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
VALID_MAESTRO = '6759649826438453'
VALID_TROY = '9792000000000001'
VALID_OTHER = '2000000000000000008'
VALID_DINERS_CLUB_14 = '30500000000000'
VALID_DINERS_CLUB_16 = '3050000000000009'
VALID_DINERS_CLUB_17 = '30500000000000009'
VALID_DINERS_CLUB_19 = '3050000000000000009'
LUHN_INVALID = '4000000000000000'
LEN_INVALID = '40000000000000006'

Expand Down Expand Up @@ -117,6 +121,9 @@ def test_validate_luhn_check_digit(card_number: str, valid: bool):
(LEN_INVALID, PaymentCardBrand.visa, False),
(VALID_MAESTRO, PaymentCardBrand.maestro, True),
(VALID_TROY, PaymentCardBrand.troy, True),
(VALID_DINERS_CLUB_14, PaymentCardBrand.diners_club, True),
(VALID_DINERS_CLUB_16, PaymentCardBrand.diners_club, True),
(VALID_DINERS_CLUB_19, PaymentCardBrand.diners_club, True),
(VALID_OTHER, PaymentCardBrand.other, True),
],
)
Expand Down Expand Up @@ -144,6 +151,10 @@ def test_length_for_brand(card_number: str, brand: PaymentCardBrand, valid: bool
(VALID_JCB_16, PaymentCardBrand.jcb),
(VALID_OTHER, PaymentCardBrand.other),
(VALID_MAESTRO, PaymentCardBrand.maestro),
(VALID_DINERS_CLUB_14, PaymentCardBrand.diners_club),
(VALID_DINERS_CLUB_16, PaymentCardBrand.diners_club),
(VALID_DINERS_CLUB_17, PaymentCardBrand.diners_club),
(VALID_DINERS_CLUB_19, PaymentCardBrand.diners_club),
(VALID_TROY, PaymentCardBrand.troy),
],
)
Expand Down