From 66caa9862b0af3bda1f914102c0cb4ba5585d23e Mon Sep 17 00:00:00 2001 From: Lorhan Sohaky Date: Tue, 3 Jun 2025 18:00:30 -0300 Subject: [PATCH 1/3] feat: add support for Diners Club card brand --- pydantic_extra_types/payment.py | 7 +++++++ tests/test_types_payment.py | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/pydantic_extra_types/payment.py b/pydantic_extra_types/payment.py index a22232ae..6cc239c0 100644 --- a/pydantic_extra_types/payment.py +++ b/pydantic_extra_types/payment.py @@ -25,6 +25,7 @@ class PaymentCardBrand(str, Enum): troy = 'Troy' unionpay = 'UnionPay' jcb = 'JCB' + diners_club = 'Diners Club' other = 'other' def __str__(self) -> str: @@ -185,6 +186,12 @@ def validate_brand(card_number: str) -> PaymentCardBrand: elif 3528 <= int(card_number[:4]) <= 3589: brand = PaymentCardBrand.jcb required_length = [16, 19] + elif card_number[:2] in {'30', '36', '38', '39'}: + brand = PaymentCardBrand.diners_club + required_length = list(range(14, 20)) + elif card_number.startswith('55'): + brand = PaymentCardBrand.diners_club + required_length = [16] valid = len(card_number) in required_length if brand != PaymentCardBrand.other else True diff --git a/tests/test_types_payment.py b/tests/test_types_payment.py index bd8fd905..3815feb9 100644 --- a/tests/test_types_payment.py +++ b/tests/test_types_payment.py @@ -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' @@ -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), ], ) @@ -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), ], ) From 3a0433fdd86b84434e2f89d80e09b47281371364 Mon Sep 17 00:00:00 2001 From: Lorhan Sohaky Date: Tue, 3 Jun 2025 18:00:50 -0300 Subject: [PATCH 2/3] feat: extend Mastercard brand validation to include new BIN range --- pydantic_extra_types/payment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_extra_types/payment.py b/pydantic_extra_types/payment.py index 6cc239c0..8dc65cf3 100644 --- a/pydantic_extra_types/payment.py +++ b/pydantic_extra_types/payment.py @@ -147,7 +147,7 @@ def validate_brand(card_number: str) -> PaymentCardBrand: if card_number[0] == '4': brand = PaymentCardBrand.visa required_length = [13, 16, 19] - elif 51 <= int(card_number[:2]) <= 55: + elif (51 <= int(card_number[:2]) <= 55) or (2221 <= int(card_number[:4]) <= 2720): brand = PaymentCardBrand.mastercard required_length = [16] elif card_number[:2] in {'34', '37'}: From 1497279ca8b371249d96baffd54ac935cf274459 Mon Sep 17 00:00:00 2001 From: Lorhan Sohaky Date: Thu, 5 Jun 2025 10:43:54 -0300 Subject: [PATCH 3/3] style: fix code style --- pydantic_extra_types/payment.py | 122 +++++++++++++++++++------------- 1 file changed, 72 insertions(+), 50 deletions(-) diff --git a/pydantic_extra_types/payment.py b/pydantic_extra_types/payment.py index 8dc65cf3..a7ccda02 100644 --- a/pydantic_extra_types/payment.py +++ b/pydantic_extra_types/payment.py @@ -127,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 @@ -142,56 +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) or (2221 <= int(card_number[:4]) <= 2720): - 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] - elif card_number[:2] in {'30', '36', '38', '39'}: - brand = PaymentCardBrand.diners_club - required_length = list(range(14, 20)) - elif card_number.startswith('55'): - brand = PaymentCardBrand.diners_club - required_length = [16] + brand, required_length = PaymentCardNumber._identify_brand(card_number) valid = len(card_number) in required_length if brand != PaymentCardBrand.other else True