From e3bd688d11603e89436af9230786d2d1502cc498 Mon Sep 17 00:00:00 2001 From: Felipe Trzaskowski Date: Wed, 3 Jun 2026 16:24:47 -0300 Subject: [PATCH 1/4] =?UTF-8?q?Adiciona=20exce=C3=A7=C3=A3o=20do=20Luhn=20?= =?UTF-8?q?check=20para=20a=20Softnex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- creditcard/card.py | 18 ++++++++++++++---- tests/unit/test_card.py | 4 ++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/creditcard/card.py b/creditcard/card.py index 9d38418..e00c878 100644 --- a/creditcard/card.py +++ b/creditcard/card.py @@ -1,4 +1,5 @@ import re +from functools import cached_property from .exceptions import BrandNotFound from .luhn import Luhn @@ -69,11 +70,20 @@ 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 self._brand == "softnex": + # Softnex emite cartões que não são válidos pelo Luhn + return True + return len(self.number) in range(13, 19) and Luhn.checkdigit(self.number) + + def get_brand(self): + if brand := self._brand: + return brand raise BrandNotFound("Card number does not match any brand") diff --git a/tests/unit/test_card.py b/tests/unit/test_card.py index b414971..948cf52 100644 --- a/tests/unit/test_card.py +++ b/tests/unit/test_card.py @@ -21,6 +21,10 @@ def test_is_valid(self): card = CreditCard("0000000000000a") assert card.is_valid() is False + # Cartão Softnex que não é válido pelo Luhn + card = CreditCard("6108000123456789") + assert card.is_valid() is True + @pytest.mark.parametrize( "brand, number", [ From 4be2aacbd0dfb8ec06ab9e6ce17c36a94913ace4 Mon Sep 17 00:00:00 2001 From: Felipe Trzaskowski Date: Thu, 27 Aug 2026 20:21:18 -0300 Subject: [PATCH 2/4] Remove flake8 do teste --- pytest.ini | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 From 9e0ce88c3c9be5621c10dcaa9d47a5d128a4c8cf Mon Sep 17 00:00:00 2001 From: Felipe Trzaskowski Date: Thu, 27 Aug 2026 20:21:34 -0300 Subject: [PATCH 3/4] =?UTF-8?q?Simplifica=20implementa=C3=A7=C3=A3o=20do?= =?UTF-8?q?=20Luhn,=20e=20adiciona=20suporte=20=C3=A0=20regra=20divergente?= =?UTF-8?q?=20da=20Softnex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- creditcard/card.py | 11 +++++---- creditcard/luhn.py | 49 ++++++++++++++++++++++++++--------------- tests/unit/test_card.py | 5 +++-- tests/unit/test_luhn.py | 32 ++++++++++++++++++++++----- 4 files changed, 67 insertions(+), 30 deletions(-) diff --git a/creditcard/card.py b/creditcard/card.py index e00c878..83c20e4 100644 --- a/creditcard/card.py +++ b/creditcard/card.py @@ -1,8 +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 = { @@ -78,10 +78,13 @@ def _brand(self): return None def is_valid(self): + if not self.number.isdigit(): + return False + if self._brand == "softnex": - # Softnex emite cartões que não são válidos pelo Luhn - return True - return len(self.number) in range(13, 19) and Luhn.checkdigit(self.number) + 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: 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/tests/unit/test_card.py b/tests/unit/test_card.py index 948cf52..2e3aa00 100644 --- a/tests/unit/test_card.py +++ b/tests/unit/test_card.py @@ -21,8 +21,9 @@ def test_is_valid(self): card = CreditCard("0000000000000a") assert card.is_valid() is False - # Cartão Softnex que não é válido pelo Luhn - card = CreditCard("6108000123456789") + # 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( diff --git a/tests/unit/test_luhn.py b/tests/unit/test_luhn.py index 6301487..f473d54 100644 --- a/tests/unit/test_luhn.py +++ b/tests/unit/test_luhn.py @@ -1,9 +1,29 @@ -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), + ("6108000000000040", True), + ("6108000000000041", False), + ], + ) + def test_check(self, number, expected): + assert luhn.check(number) is expected + + @pytest.mark.parametrize( + "number,expected", + [ + ("6108000000000041", True), + ("6108000000000040", False), + ], + ) + def test_check_softnex(self, number, expected): + assert luhn.check_softnex(number) is expected From 2e6e9dacc89449e199e49dcc311f3f27c0cd023b Mon Sep 17 00:00:00 2001 From: Felipe Trzaskowski Date: Thu, 27 Aug 2026 20:27:29 -0300 Subject: [PATCH 4/4] =?UTF-8?q?Adiciona=20test=20case=20para=20cart=C3=A3o?= =?UTF-8?q?=20terminado=20em=201=20que=20=C3=A9=20v=C3=A1lido=20tanto=20no?= =?UTF-8?q?=20Luhn=20quanto=20na=20Softnex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/test_luhn.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_luhn.py b/tests/unit/test_luhn.py index f473d54..fecc6d0 100644 --- a/tests/unit/test_luhn.py +++ b/tests/unit/test_luhn.py @@ -11,8 +11,9 @@ class TestLuhn: ("5369835519963014", True), ("5369835519963013", False), ("30346836403940", True), - ("6108000000000040", True), + ("6108000158537041", True), ("6108000000000041", False), + ("6108000000000040", True), ], ) def test_check(self, number, expected): @@ -21,6 +22,7 @@ def test_check(self, number, expected): @pytest.mark.parametrize( "number,expected", [ + ("6108000158537041", True), ("6108000000000041", True), ("6108000000000040", False), ],