Skip to content

Commit cf872e6

Browse files
committed
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.
1 parent 70de324 commit cf872e6

2 files changed

Lines changed: 43 additions & 12 deletions

File tree

src/validators/finance.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,32 @@ def _cusip_checksum(cusip: str):
3232

3333

3434
def _isin_checksum(value: str):
35-
check, val = 0, None
36-
35+
# Expand the ISIN to digits: each letter maps to two digits (A=10, ..., Z=35)
36+
# while digits are kept as-is. The country code (first two characters) must be
37+
# letters and the check digit (last character) must be numeric.
38+
digits = ""
3739
for idx in range(12):
3840
c = value[idx]
39-
if c >= "0" and c <= "9" and idx > 1:
40-
val = ord(c) - ord("0")
41+
if c >= "0" and c <= "9":
42+
if idx < 2:
43+
return False
44+
digits += c
4145
elif c >= "A" and c <= "Z":
42-
val = 10 + ord(c) - ord("A")
43-
elif c >= "a" and c <= "z":
44-
val = 10 + ord(c) - ord("a")
46+
if idx == 11:
47+
return False
48+
digits += str(10 + ord(c) - ord("A"))
4549
else:
4650
return False
4751

52+
# Luhn checksum: doubling every second digit from the right.
53+
check = 0
54+
for idx, digit in enumerate(reversed(digits)):
55+
val = int(digit)
4856
if idx & 1:
49-
val += val
57+
val *= 2
58+
if val > 9:
59+
val -= 9
60+
check += val
5061

5162
return (check % 10) == 0
5263

@@ -82,10 +93,12 @@ def isin(value: str):
8293
[1]: https://en.wikipedia.org/wiki/International_Securities_Identification_Number
8394
8495
Examples:
96+
>>> isin('US0378331005')
97+
True
98+
>>> isin('US0378331004')
99+
ValidationError(func=isin, args={'value': 'US0378331004'})
85100
>>> isin('037833DP2')
86101
ValidationError(func=isin, args={'value': '037833DP2'})
87-
>>> isin('037833DP3')
88-
ValidationError(func=isin, args={'value': '037833DP3'})
89102
90103
Args:
91104
value: ISIN string to validate.

tests/test_finance.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,31 @@ def test_returns_failed_validation_on_invalid_cusip(value: str):
2424
# ==> ISIN <== #
2525

2626

27-
@pytest.mark.parametrize("value", ["US0004026250", "JP000K0VF054", "US0378331005"])
27+
@pytest.mark.parametrize(
28+
"value",
29+
["US0004026250", "JP000K0VF055", "US0378331005", "AU0000XVGZA3", "GB0002634946"],
30+
)
2831
def test_returns_true_on_valid_isin(value: str):
2932
"""Test returns true on valid isin."""
3033
assert isin(value)
3134

3235

33-
@pytest.mark.parametrize("value", ["010378331005", "XCVF", "00^^^1234", "A000009"])
36+
@pytest.mark.parametrize(
37+
"value",
38+
[
39+
"010378331005",
40+
"XCVF",
41+
"00^^^1234",
42+
"A000009",
43+
# valid format but incorrect check digit (previously accepted, see gh-440)
44+
"US0378331004",
45+
"GB0002634947",
46+
"AU0000XVGZA4",
47+
"JP000K0VF054",
48+
# lowercase country code is not a valid ISIN
49+
"us0378331005",
50+
],
51+
)
3452
def test_returns_failed_validation_on_invalid_isin(value: str):
3553
"""Test returns failed validation on invalid isin."""
3654
assert isinstance(isin(value), ValidationError)

0 commit comments

Comments
 (0)