Repository: pydantic/pydantic

Issue:
Add validation for payment card numbers
A common validation for any payment system or e-commerce site is validating a debit / credit card number.

The validation would like _something_ like this and be defined in `pydantic.types`:

```python
class PaymentCardNumber(str):
    """
    Based on: https://en.wikipedia.org/wiki/Payment_card_number
    """
    strip_whitespace = True
    min_length: int = 12
    max_length: int = 19

    @classmethod
    def __get_validators__(cls) -> 'CallableGenerator':
        yield not_none_validator
        yield str_validator
        yield constr_strip_whitespace
        yield constr_length_validator
        yield cls.validate_digits
        yield cls.validate_length_based_on_bin
        yield cls.validate_luhn_check_digit

    @classmethod
    def validate_digits(cls, value: str) -> str:
        if not value.isdigit():
            raise ValueError('payment card number must be digits')
        return value

    @classmethod
    def validate_length_based_on_bin(cls, value: str) -> str:
        """
        Validate length based on BIN for major brands:
        https://en.wikipedia.org/wiki/Payment_card_number#Issuer_identification_number_(IIN)
        """
        if value[0] == '4':  # Visa
            length = 16
            brand = 'Visa'
        elif 51 <= int(value[:1]) <= 55:  # Most common Mastercard range
            length = 16
            brand = 'Mastercard'
        elif value[:1] in {'34', '37'}:  # Amex
            length = 15
            brand = 'Amex'
        else:
            length = None
            brand = None
        if length and len(value) != length:
            raise ValueError(f'Length for a {brand} card must be {length}')
        return value

    @classmethod
    def validate_luhn_check_digit(cls, value: str) -> str:
        """
        Based on: https://en.wikipedia.org/wiki/Luhn_algorithm
        """
        sum_ = int(value[-1])
        length = len(value)
        parity = length % 2
        for i in range(length - 1):
            digit = int(value[i])
            if i % 2 == parity:
                digit *= 2
            if digit > 9:
                digit -= 9
            sum_ += digit
        valid = sum_ % 10 == 0
        if not valid:
            raise ValueError('Card number is not luhn valid')
        return value
```


Comments:

Patch:
diff --git a/docs/examples/payment_card_number.py b/docs/examples/payment_card_number.py
new file mode 100644
--- /dev/null
+++ b/docs/examples/payment_card_number.py
@@ -0,0 +1,28 @@
+from datetime import date
+
+from pydantic import BaseModel
+from pydantic.types import PaymentCardBrand, PaymentCardNumber, constr
+
+class Card(BaseModel):
+    name: constr(strip_whitespace=True, min_length=1)
+    number: PaymentCardNumber
+    exp: date
+
+    @property
+    def brand(self) -> PaymentCardBrand:
+        return self.number.brand
+
+    @property
+    def expired(self) -> bool:
+        return self.exp < date.today()
+
+card = Card(
+    name='Georg Wilhelm Friedrich Hegel',
+    number='4000000000000002',
+    exp=date(2023, 9, 30)
+)
+
+assert card.number.brand == PaymentCardBrand.visa
+assert card.number.bin == '400000'
+assert card.number.last4 == '0002'
+assert card.number.masked == '400000******0002'
diff --git a/pydantic/errors.py b/pydantic/errors.py
--- a/pydantic/errors.py
+++ b/pydantic/errors.py
@@ -402,3 +402,18 @@ class ColorError(PydanticValueError):
 
 class StrictBoolError(PydanticValueError):
     msg_template = 'value is not a valid boolean'
+
+
+class NotDigitError(PydanticValueError):
+    code = 'payment_card_number.digits'
+    msg_template = 'card number is not all digits'
+
+
+class LuhnValidationError(PydanticValueError):
+    code = 'payment_card_number.luhn_check'
+    msg_template = 'card number is not luhn valid'
+
+
+class InvalidLengthForBrand(PydanticValueError):
+    code = 'payment_card_number.invalid_length_for_brand'
+    msg_template = 'Length for a {brand} card must be {required_length}'
diff --git a/pydantic/types.py b/pydantic/types.py
--- a/pydantic/types.py
+++ b/pydantic/types.py
@@ -1,9 +1,10 @@
 import json
 import re
 from decimal import Decimal
+from enum import Enum
 from pathlib import Path
 from types import new_class
-from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Pattern, Type, TypeVar, Union, cast
+from typing import TYPE_CHECKING, Any, Callable, ClassVar, Dict, List, Optional, Pattern, Type, TypeVar, Union, cast
 from uuid import UUID
 
 from . import errors
@@ -63,6 +64,7 @@
     'SecretStr',
     'SecretBytes',
     'StrictBool',
+    'PaymentCardNumber',
 ]
 
 NoneStr = Optional[str]
@@ -502,3 +504,101 @@ def display(self) -> str:
 
     def get_secret_value(self) -> bytes:
         return self._secret_value
+
+
+class PaymentCardBrand(Enum):
+    amex = 'American Express'
+    mastercard = 'Mastercard'
+    visa = 'Visa'
+    other = 'other'
+
+    def __str__(self) -> str:
+        return self.value
+
+
+class PaymentCardNumber(str):
+    """
+    Based on: https://en.wikipedia.org/wiki/Payment_card_number
+    """
+
+    strip_whitespace: ClassVar[bool] = True
+    min_length: ClassVar[int] = 12
+    max_length: ClassVar[int] = 19
+    bin: str
+    last4: str
+    brand: PaymentCardBrand
+
+    def __init__(self, card_number: str):
+        self.bin = card_number[:6]
+        self.last4 = card_number[-4:]
+        self.brand = self._get_brand(card_number)
+
+    @classmethod
+    def __get_validators__(cls) -> 'CallableGenerator':
+        yield not_none_validator
+        yield str_validator
+        yield constr_strip_whitespace
+        yield constr_length_validator
+        yield cls.validate_digits
+        yield cls.validate_luhn_check_digit
+        yield cls
+        yield cls.validate_length_for_brand
+
+    @property
+    def masked(self) -> str:
+        num_masked = len(self) - 10  # len(bin) + len(last4) == 10
+        return f'{self.bin}{"*" * num_masked}{self.last4}'
+
+    @classmethod
+    def validate_digits(cls, card_number: str) -> str:
+        if not card_number.isdigit():
+            raise errors.NotDigitError
+        return card_number
+
+    @classmethod
+    def validate_luhn_check_digit(cls, card_number: str) -> str:
+        """
+        Based on: https://en.wikipedia.org/wiki/Luhn_algorithm
+        """
+        sum_ = int(card_number[-1])
+        length = len(card_number)
+        parity = length % 2
+        for i in range(length - 1):
+            digit = int(card_number[i])
+            if i % 2 == parity:
+                digit *= 2
+            sum_ += digit
+        valid = sum_ % 10 == 0
+        if not valid:
+            raise errors.LuhnValidationError
+        return card_number
+
+    @classmethod
+    def validate_length_for_brand(cls, card_number: 'PaymentCardNumber') -> 'PaymentCardNumber':
+        """
+        Validate length based on BIN for major brands:
+        https://en.wikipedia.org/wiki/Payment_card_number#Issuer_identification_number_(IIN)
+        """
+        if card_number.brand is (PaymentCardBrand.visa or PaymentCardBrand.mastercard):
+            required_length = 16
+            valid = len(card_number) == required_length
+        elif card_number.brand is PaymentCardBrand.amex:
+            required_length = 15
+            valid = len(card_number) == required_length
+        else:
+            valid = True
+        if not valid:
+            raise errors.InvalidLengthForBrand(brand=card_number.brand, required_length=required_length)
+        return card_number
+
+    @staticmethod
+    def _get_brand(card_number: str) -> PaymentCardBrand:
+        if card_number[0] == '4':
+            brand = PaymentCardBrand.visa
+        elif 51 <= int(card_number[:2]) <= 55:
+            brand = PaymentCardBrand.mastercard
+        elif card_number[:2] in {'34', '37'}:
+            brand = PaymentCardBrand.amex
+        else:
+            brand = PaymentCardBrand.other
+        return brand


Test patch:
diff --git a/tests/test_types_payment_card_number.py b/tests/test_types_payment_card_number.py
new file mode 100644
--- /dev/null
+++ b/tests/test_types_payment_card_number.py
@@ -0,0 +1,90 @@
+from collections import namedtuple
+from typing import Any
+
+import pytest
+
+from pydantic import BaseModel, ValidationError
+from pydantic.errors import InvalidLengthForBrand, LuhnValidationError, NotDigitError
+from pydantic.types import PaymentCardBrand, PaymentCardNumber
+
+VALID_AMEX = '370000000000002'
+VALID_MC = '5100000000000003'
+VALID_VISA = '4000000000000002'
+VALID_OTHER = '2000000000000000008'
+LUHN_INVALID = '4000000000000000'
+LEN_INVALID = '40000000000000006'
+
+# Mock PaymentCardNumber
+PCN = namedtuple('PaymentCardNumber', ['card_number', 'brand'])
+PCN.__len__ = lambda v: len(v.card_number)
+
+
+class PaymentCard(BaseModel):
+    card_number: PaymentCardNumber
+
+
+def test_validate_digits():
+    digits = '12345'
+    assert PaymentCardNumber.validate_digits(digits) == digits
+    with pytest.raises(NotDigitError):
+        PaymentCardNumber.validate_digits('hello')
+
+
+def test_validate_luhn_check_digit():
+    assert PaymentCardNumber.validate_luhn_check_digit(VALID_VISA) == VALID_VISA
+    with pytest.raises(LuhnValidationError):
+        PaymentCardNumber.validate_luhn_check_digit(LUHN_INVALID)
+
+
+@pytest.mark.parametrize(
+    'card_number, brand, valid',
+    [
+        (VALID_VISA, PaymentCardBrand.visa, True),
+        (VALID_MC, PaymentCardBrand.mastercard, True),
+        (VALID_AMEX, PaymentCardBrand.amex, True),
+        (VALID_OTHER, PaymentCardBrand.other, True),
+        (LEN_INVALID, PaymentCardBrand.visa, False),
+    ],
+)
+def test_length_for_brand(card_number: str, brand: PaymentCardBrand, valid: bool):
+    pcn = PCN(card_number, brand)
+    if valid:
+        assert PaymentCardNumber.validate_length_for_brand(pcn) == pcn
+    else:
+        with pytest.raises(InvalidLengthForBrand):
+            PaymentCardNumber.validate_length_for_brand(pcn)
+
+
+@pytest.mark.parametrize(
+    'card_number, brand',
+    [
+        (VALID_AMEX, PaymentCardBrand.amex),
+        (VALID_MC, PaymentCardBrand.mastercard),
+        (VALID_VISA, PaymentCardBrand.visa),
+        (VALID_OTHER, PaymentCardBrand.other),
+    ],
+)
+def test_get_brand(card_number: str, brand: PaymentCardBrand):
+    assert PaymentCardNumber._get_brand(card_number) == brand
+
+
+def test_valid():
+    card = PaymentCard(card_number=VALID_VISA)
+    assert str(card.card_number) == VALID_VISA
+    assert card.card_number.masked == '400000******0002'
+
+
+@pytest.mark.parametrize(
+    'card_number, error_message',
+    [
+        (None, 'type_error.none.not_allowed'),
+        ('1' * 11, 'value_error.any_str.min_length'),
+        ('1' * 20, 'value_error.any_str.max_length'),
+        ('h' * 16, 'value_error.payment_card_number.digits'),
+        (LUHN_INVALID, 'value_error.payment_card_number.luhn_check'),
+        (LEN_INVALID, 'value_error.payment_card_number.invalid_length_for_brand'),
+    ],
+)
+def test_error_types(card_number: Any, error_message: str):
+    with pytest.raises(ValidationError, match=error_message):
+        PaymentCard(card_number=card_number)