Skip to content

vatin: reject a duplicated country code prefix (#420) - #500

Open
CedricConday wants to merge 1 commit into
arthurdejong:masterfrom
CedricConday:fix/420-vatin-duplicate-country-code
Open

vatin: reject a duplicated country code prefix (#420)#500
CedricConday wants to merge 1 commit into
arthurdejong:masterfrom
CedricConday:fix/420-vatin-duplicate-country-code

Conversation

@CedricConday

@CedricConday CedricConday commented Jun 30, 2026

Copy link
Copy Markdown

Closes #420

vatin.is_valid() accepts a VAT number with a doubled country code, while stdnum.eu.vat correctly rejects it:

>>> stdnum.vatin.is_valid('BE 0308.357.159')
True
>>> stdnum.vatin.is_valid('BE BE 0308.357.159')
True      # should be False
>>> stdnum.eu.vat.is_valid('BE BE 0308.357.159')
False

Cause

vatin.validate() stripped the leading country code itself (module.validate(number[2:])) and then handed the remainder to the country module, which strips its own optional country-code prefix again. For BE BE 0308.357.159 both strips fired, leaving the valid national number 0308357159. Countries whose module doesn't strip a prefix (e.g. BR) weren't affected, which is why it reproduced for BE/NL but not MX.

Fix

Validate the full number with the country module (it strips its own prefix exactly once), mirroring stdnum.eu.vat. Only fall back to stripping the country code for modules that don't recognise it — and guard that fallback so a doubled prefix (even written BE BE … with whitespace) is not stripped a second time.

Tests

Added doctests in tests/test_vatin.doctest for the doubled-prefix case (with and without whitespace). They fail on master and pass with the fix. The full test suite stays green (405 passed, 9 network-skipped), including the existing multi-country vatin doctests (FR/DE/BR/EL→GR/CHE/XI/EU).


AI disclosure: prepared with AI assistance (Claude Code), human-reviewed and verified before opening.

@CedricConday
CedricConday force-pushed the fix/420-vatin-duplicate-country-code branch from 26a63eb to 0380834 Compare July 12, 2026 10:07
@arthurdejong

arthurdejong commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Hi @CedricConday,

Thanks for looking into this and sorry for not coming back to this sooner.

One problem with this approach is that this code will currently fail the following test (while passing on the master branch):

from stdnum import vatin
vatin.validate('MX MXDE 111111 GR2')

while the number validates just fine:

from stdnum.mx import rfc
rfc.validate('MXDE 111111 GR2')

This number is constructed and not a number that has been seen in the wild but still.

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 arthurdejong#420.
@CedricConday
CedricConday force-pushed the fix/420-vatin-duplicate-country-code branch from 0380834 to 8eb3521 Compare August 18, 2026 08:39
@CedricConday

Copy link
Copy Markdown
Author

Thanks for the counter-example — that one's decisive, and it showed the check I used was the wrong shape.

I was testing the prefix syntactically: if the remainder also started with the country code, reject. That can't distinguish BEBE0308357159 (a doubled prefix) from MX + MXDE111111GR2 (an RFC that genuinely begins with MX), so it rejected both.

Rebased on master and replaced it with a semantic check. The question isn't whether the remainder starts with the country code — it's whether the country module strips it:

>>> get_cc_module('be', 'vat').compact('BE0308357159')
'0308357159'          # stripped -> the prefix was duplicated
>>> get_cc_module('mx', 'vat').compact('MXDE111111GR2')
'MXDE111111GR2'       # kept     -> the "MX" is part of the number

So the fallback path now only rejects when the module consumed a second country code. Your test passes unchanged, including the 'MXMXDE111111GR2' return value:

>>> vatin.validate('MX MXDE 111111 GR2')
'MXMXDE111111GR2'
>>> vatin.validate('MXDE 111111 GR2')     # here MX is only the country code
'MXDE111111GR2'
>>> vatin.validate('BE BE 0308.357.159')
InvalidFormat

I kept your test_vatin.doctest addition as-is and added the MXDE 111111 GR2 case next to it, so both readings of a leading MX are pinned.

Two notes:

  • Comparing the prefix needs the non-alphanumerics stripped first ('BE BE 0308...' leaves the remainder starting with a space), so that normalisation is done for the comparison only — the raw value still goes to the module, which does its own cleaning.
  • compact() has the same double-strip: vatin.compact('BE BE 0308.357.159') silently returns 'BE0308357159'. I left it alone since compact() doesn't validate by contract, but happy to fix it here or separately if you'd rather they agree.

Full suite green at 100% coverage (413 passed, 9 skipped).

AI-assisted, human-reviewed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wrong VATIN validation on duplicate country code

2 participants