From 8eb3521e6c78ec733aac040dafbf30ed83d026fc Mon Sep 17 00:00:00 2001 From: Cedric Conday <277679649+CedricConday@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:39:07 +0000 Subject: [PATCH] vatin: reject a duplicated country code prefix (#420) vatin.validate() stripped the leading country code itself and then passed the remainder to the country module, which strips its own optional country code prefix again. For a doubled prefix such as 'BE BE 0308.357.159' both strips fired, leaving a valid national number, so the VATIN validated even though stdnum.eu.vat correctly rejects it. Validate the full number with the country module (which strips its own prefix once), mirroring stdnum.eu.vat, and only fall back to stripping the country code for modules that do not recognise it - guarding that fallback so a doubled prefix is not stripped a second time. Closes #420. --- stdnum/vatin.py | 24 +++++++++++++++++++++--- tests/test_vatin.doctest | 21 ++++++++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/stdnum/vatin.py b/stdnum/vatin.py index 3e7e2f56..c9b73ca0 100644 --- a/stdnum/vatin.py +++ b/stdnum/vatin.py @@ -87,11 +87,29 @@ def validate(number: str) -> str: This performs the country-specific check for the number. """ number = clean(number, '').strip() - module = _get_cc_module(number[:2]) + cc = number[:2].upper() + module = _get_cc_module(cc) try: - return number[:2].upper() + module.validate(number[2:]) + # Most country modules accept and strip the optional country code + # prefix themselves, so the number can be validated as it is. + result = module.validate(number) + if not result.upper().startswith(cc): + result = cc + result except ValidationError: - return module.validate(number) + # Other country modules only accept the national number, so retry + # with the country code prefix removed. + national = number[2:] + result = module.validate(national) + # If the country module stripped a second country code off the + # national number then the prefix was duplicated and the number is + # not valid (see #420). A national number that legitimately starts + # with the country code, such as the Mexican RFC MXDE111111GR2, + # keeps its prefix when compacted and is accepted. + if (re.sub(r'[^0-9A-Za-z]', '', national)[:2].upper() == cc and + not module.compact(national).upper().startswith(cc)): + raise InvalidFormat() + result = cc + result + return result def is_valid(number: str) -> bool: diff --git a/tests/test_vatin.doctest b/tests/test_vatin.doctest index ca8becbe..578f7588 100644 --- a/tests/test_vatin.doctest +++ b/tests/test_vatin.doctest @@ -104,7 +104,26 @@ True 'EU191849184' -Check seemingly double country codes are handled correctly: +A duplicated country code prefix should not be accepted (#420). This used to +pass because the country code was stripped twice, once here and once by the +country module: + +>>> vatin.is_valid('BE 0308.357.159') +True +>>> vatin.is_valid('BE BE 0308.357.159') +False +>>> vatin.is_valid('BEBE0308357159') +False +>>> vatin.validate('BE BE 0308.357.159') +Traceback (most recent call last): + ... +InvalidFormat: ... + + +Check seemingly double country codes are handled correctly. A national number +may legitimately start with the country code, in which case it is kept: >>> vatin.validate('MX MXDE 111111 GR2') # the second "MX" is part of the number 'MXMXDE111111GR2' +>>> vatin.validate('MXDE 111111 GR2') # here "MX" is only the country code +'MXDE111111GR2'