From cf872e633b25a4df2adef0862f2c0e908504a953 Mon Sep 17 00:00:00 2001 From: Apoorv Darshan Date: Wed, 8 Jul 2026 17:53:28 +0530 Subject: [PATCH] fix(finance): validate the ISIN check digit _isin_checksum() never accumulated into `check`, so it stayed 0 and `check % 10 == 0` was always true. Any 12-character string with a two-letter country code and a trailing digit was accepted regardless of its check digit (e.g. US0378331004, an invalid variant of Apple's US0378331005, validated as a valid ISIN). Implement the real algorithm: expand letters to digits (A=10..Z=35), keep digits as-is, then run the Luhn checksum over the result. Also enforce that the country code is alphabetic and the check digit numeric, rejecting lowercase/malformed input as requested in the issue. Cross-checked against python-stdnum: this also revealed that the existing 'valid' test fixture JP000K0VF054 was itself an invalid ISIN (correct check digit is 5) that only passed because the checksum was never computed; corrected to JP000K0VF055 and added wrong-check-digit cases. Fixes #440. --- src/validators/finance.py | 33 +++++++++++++++++++++++---------- tests/test_finance.py | 22 ++++++++++++++++++++-- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/validators/finance.py b/src/validators/finance.py index 9df5a970..bba565b7 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 - + # Expand the ISIN to digits: each letter maps to two digits (A=10, ..., Z=35) + # while digits are kept as-is. The country code (first two characters) must be + # letters and the check digit (last character) must be numeric. + digits = "" for idx in range(12): c = value[idx] - if c >= "0" and c <= "9" and idx > 1: - val = ord(c) - ord("0") + if c >= "0" and c <= "9": + if idx < 2: + return False + digits += c elif c >= "A" and c <= "Z": - val = 10 + ord(c) - ord("A") - elif c >= "a" and c <= "z": - val = 10 + ord(c) - ord("a") + if idx == 11: + return False + digits += str(10 + ord(c) - ord("A")) else: return False + # Luhn checksum: doubling every second digit from the right. + check = 0 + for idx, digit in enumerate(reversed(digits)): + val = int(digit) if idx & 1: - val += val + val *= 2 + if val > 9: + val -= 9 + check += val return (check % 10) == 0 @@ -82,10 +93,12 @@ def isin(value: str): [1]: https://en.wikipedia.org/wiki/International_Securities_Identification_Number Examples: + >>> isin('US0378331005') + True + >>> isin('US0378331004') + ValidationError(func=isin, args={'value': 'US0378331004'}) >>> isin('037833DP2') ValidationError(func=isin, args={'value': '037833DP2'}) - >>> isin('037833DP3') - ValidationError(func=isin, args={'value': '037833DP3'}) Args: value: ISIN string to validate. diff --git a/tests/test_finance.py b/tests/test_finance.py index a40fd333..26e777e3 100644 --- a/tests/test_finance.py +++ b/tests/test_finance.py @@ -24,13 +24,31 @@ def test_returns_failed_validation_on_invalid_cusip(value: str): # ==> ISIN <== # -@pytest.mark.parametrize("value", ["US0004026250", "JP000K0VF054", "US0378331005"]) +@pytest.mark.parametrize( + "value", + ["US0004026250", "JP000K0VF055", "US0378331005", "AU0000XVGZA3", "GB0002634946"], +) 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 format but incorrect check digit (previously accepted, see gh-440) + "US0378331004", + "GB0002634947", + "AU0000XVGZA4", + "JP000K0VF054", + # lowercase country code is not a valid ISIN + "us0378331005", + ], +) def test_returns_failed_validation_on_invalid_isin(value: str): """Test returns failed validation on invalid isin.""" assert isinstance(isin(value), ValidationError)