From 474496de3e47579b0301c7567eeba50d0db25159 Mon Sep 17 00:00:00 2001 From: DucMinhNe Date: Fri, 19 Jun 2026 10:52:59 +0700 Subject: [PATCH] fix: correct broken ISIN checksum (validator accepted any check digit) _isin_checksum computed a per-character val in the loop but never added it into check, which stayed 0. So (check % 10) == 0 was always True and isin() accepted any 12-character string with valid characters regardless of its check digit. The existing invalid-ISIN tests all tripped the earlier length or character guards, so the checksum branch was never actually exercised. Reimplement the ISO 6166 algorithm: require a numeric check digit, expand each letter to its two-digit value (A=10..Z=35), then run a right-to-left Luhn sum over the expanded digits. Validated against python-stdnum's isin.is_valid over 100k+ country-code-valid inputs with zero mismatches. Also dropped JP000K0VF054 from the valid samples (it is not a valid ISIN; it only passed because the checksum did nothing) and added wrong-check-digit cases to the invalid set. --- src/validators/finance.py | 19 +++++++++++++++---- tests/test_finance.py | 16 ++++++++++++++-- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/validators/finance.py b/src/validators/finance.py index 9df5a970..2605dbfd 100644 --- a/src/validators/finance.py +++ b/src/validators/finance.py @@ -32,21 +32,32 @@ def _cusip_checksum(cusip: str): def _isin_checksum(value: str): - check, val = 0, None + # The check digit (last character) is always numeric. + if not value[-1].isdecimal(): + return False + # Expand the code into a string of digits, mapping each letter to its + # two-digit value (A=10, ..., Z=35) and leaving digits unchanged. + digits = "" for idx in range(12): c = value[idx] if c >= "0" and c <= "9" and idx > 1: - val = ord(c) - ord("0") + digits += c elif c >= "A" and c <= "Z": - val = 10 + ord(c) - ord("A") + digits += str(10 + ord(c) - ord("A")) elif c >= "a" and c <= "z": - val = 10 + ord(c) - ord("a") + digits += str(10 + ord(c) - ord("a")) else: return False + # Luhn checksum over the expanded digit string: starting from the + # rightmost digit, double every second digit and sum the resulting digits. + check = 0 + for idx, c in enumerate(reversed(digits)): + val = ord(c) - ord("0") if idx & 1: val += val + check += (val // 10) + (val % 10) return (check % 10) == 0 diff --git a/tests/test_finance.py b/tests/test_finance.py index a40fd333..8425bcca 100644 --- a/tests/test_finance.py +++ b/tests/test_finance.py @@ -24,13 +24,25 @@ def test_returns_failed_validation_on_invalid_cusip(value: str): # ==> ISIN <== # -@pytest.mark.parametrize("value", ["US0004026250", "JP000K0VF054", "US0378331005"]) +@pytest.mark.parametrize("value", ["US0004026250", "US0378331005", "GB0002634946", "DE000BAY0017"]) def test_returns_true_on_valid_isin(value: str): """Test returns true on valid isin.""" assert isin(value) -@pytest.mark.parametrize("value", ["010378331005", "XCVF", "00^^^1234", "A000009"]) +@pytest.mark.parametrize( + "value", + [ + "010378331005", + "XCVF", + "00^^^1234", + "A000009", + # valid length and characters but wrong check digit + "US0378331006", + "US0004026251", + "GB0002634947", + ], +) def test_returns_failed_validation_on_invalid_isin(value: str): """Test returns failed validation on invalid isin.""" assert isinstance(isin(value), ValidationError)