diff --git a/creditcard/card.py b/creditcard/card.py index 9d38418..83c20e4 100644 --- a/creditcard/card.py +++ b/creditcard/card.py @@ -1,7 +1,8 @@ import re +from functools import cached_property +from . import luhn from .exceptions import BrandNotFound -from .luhn import Luhn from .utils import sanitize BRAND_REGEX = { @@ -69,11 +70,23 @@ class CreditCard: def __init__(self, number): self.number = sanitize(number) - def is_valid(self): - return all([len(self.number) in range(13, 19), Luhn.checkdigit(self.number)]) - - def get_brand(self): + @cached_property + def _brand(self): for brand, regex in BRAND_REGEX.items(): if re.match(regex, self.number): return brand + return None + + def is_valid(self): + if not self.number.isdigit(): + return False + + if self._brand == "softnex": + return len(self.number) == 16 and luhn.check_softnex(self.number) + + return len(self.number) in range(13, 19) and luhn.check(self.number) + + def get_brand(self): + if brand := self._brand: + return brand raise BrandNotFound("Card number does not match any brand") diff --git a/creditcard/luhn.py b/creditcard/luhn.py index 9a1b5a3..9211a76 100644 --- a/creditcard/luhn.py +++ b/creditcard/luhn.py @@ -1,18 +1,31 @@ -class Luhn: - @classmethod - def checkdigit(cls, card_number): - """ checks to make sure that the card passes a luhn mod-10 checksum """ - _sum = 0 - num_digits = len(card_number) - oddeven = num_digits & 1 - for i in range(0, num_digits): - try: - digit = int(card_number[i]) - except ValueError: - return False - if not ((i & 1) ^ oddeven): - digit = digit * 2 - if digit > 9: - digit = digit - 9 - _sum = _sum + digit - return (_sum % 10) == 0 +def sum_digits(digits): + total = 0 + for i, digit in enumerate(digits, start=1): + # Se o índice (iniciando em 1) for ímpar, o valor do dígito é o dígito + # original multiplicado por dois, com os dígitos resultantes somados, + # o que equivale a subtrair 9 se o valor for maior que 9. + # Se for par, o próprio dígito é utilizado, que já está entre 0 e 9. + d = int(digit) + if i % 2 == 1: + d *= 2 + if d > 9: + d -= 9 + total += d + return total + + +def check(digits): + total = sum_digits(digits) + return total % 10 == 0 + + +def check_softnex(digits): + total = sum_digits(digits[:15]) + # A Softnex diverge do Luhn tradicional no cálculo do último dígito. + # Enquanto o Luhn apenas requer que a soma em módulo 10 seja 0, a Softnex + # modifica o dígito para 1 se o mesmo for 0, o que faz com que o Luhn recuse + # o número resultante. + dv = 10 - total % 10 + if dv > 9: + dv = 1 + return int(digits[-1]) == dv diff --git a/pytest.ini b/pytest.ini index f108fda..07f742c 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,5 +2,4 @@ norecursedirs = .git testpaths = ./tests pythonpaths = . ./creditcard -addopts = --flake8 --cov=creditcard -vv -flake8-max-line-length = 88 +addopts = --cov=creditcard -vv diff --git a/tests/unit/test_card.py b/tests/unit/test_card.py index b414971..2e3aa00 100644 --- a/tests/unit/test_card.py +++ b/tests/unit/test_card.py @@ -21,6 +21,11 @@ def test_is_valid(self): card = CreditCard("0000000000000a") assert card.is_valid() is False + # Cartão Softnex que não é válido pelo Luhn, mas deve ser aceito pelo + # algoritmo específico + card = CreditCard("6108000000000041") + assert card.is_valid() is True + @pytest.mark.parametrize( "brand, number", [ diff --git a/tests/unit/test_luhn.py b/tests/unit/test_luhn.py index 6301487..fecc6d0 100644 --- a/tests/unit/test_luhn.py +++ b/tests/unit/test_luhn.py @@ -1,9 +1,31 @@ -from creditcard.luhn import Luhn +import pytest + +from creditcard import luhn class TestLuhn: - def test_checkdigit(self): - assert Luhn.checkdigit('4539578763621486') is True - assert Luhn.checkdigit('5369835519963014') is True - assert Luhn.checkdigit('30346836403940') is True - assert Luhn.checkdigit('3034683640394a') is False + @pytest.mark.parametrize( + "number,expected", + [ + ("4539578763621486", True), + ("5369835519963014", True), + ("5369835519963013", False), + ("30346836403940", True), + ("6108000158537041", True), + ("6108000000000041", False), + ("6108000000000040", True), + ], + ) + def test_check(self, number, expected): + assert luhn.check(number) is expected + + @pytest.mark.parametrize( + "number,expected", + [ + ("6108000158537041", True), + ("6108000000000041", True), + ("6108000000000040", False), + ], + ) + def test_check_softnex(self, number, expected): + assert luhn.check_softnex(number) is expected