vn-ident
Vietnamese tax codes, mobile numbers and card numbers — validated against the sources that define them, not against a regex somebody liked. Standard library only, zero runtime dependencies.
pip install vn-ident
from vn_ident import is_valid_mst, mst_check_digit, normalize_phone, detect_carrier, parse_cccd, classify_id
is_valid_mst("0100047516") # True — the tenth digit is computed, not matched
mst_check_digit("010004751") # 6 — the worked example in the circular
mst_check_digit("000000000") # None — and None is the interesting part
normalize_phone("+84912345678") # '0912345678'
detect_carrier("0987654321") # 'viettel'
detect_carrier("0953456780") # None — a valid number that belongs to nobody
parse_cccd("001087123456") # CardNumber(province_code='001', province='Hà Nội',
# century=20, gender='male', birth_year=1987, …)
classify_id("0912345678") # 'phone'
The TypeScript port is @vntoolkit/vn-ident
and the two are held to the same
conformance suite — 258 cases, read by
both. That suite is the contract; this package is one implementation of it.
The thing to read first
A tax code has a check digit. A card number does not.
A tax code's tenth digit is computed from the first nine, so is_valid_mst tells
you the code is right. A card number's last six digits are random, so there is
nothing to verify: is_valid_cccd is a format check and nothing more —
twelve digits, a province code that exists, a fourth digit that is defined. It
cannot detect a number that is well-formed and wrong, and it will not pretend to.
It also cannot tell you which generation of card it is. The old unchipped
12-digit number and the current chip number have the same structure, and the 2025
reduction to 34 provincial units did not renumber any card issued before it.
classify_id returning "cccd" does not mean a chip card.
The zero remainder
Ten minus zero is ten, which is not a digit, so the circular skips that sequence
number rather than assigning a check digit. That is why the function is typed
int | None and not int:
mst_check_digit("000000000") # None
mst_check_digit("000000007") # 0
A naive implementation returns 10 there and then compares it against a
character. It rejects valid codes and never says why. No tax code whose first
nine digits sum to a multiple of 11 can be valid — and one that claims to be is
not.
The weights are not a progression
31 29 23 19 17 13 7 5 3 is transcribed from Thông tư 105/2020/TT-BTC, Phụ
lục 1, and pinned in the suite. That is the reason the parity check generates ten
thousand tax codes rather than trusting the hand-picked cases: a transposed pair
of weights still produces a well-formed digit for every code, so nothing
downstream would ever notice.
ident parity: 673311/673311 operations identical across both ports
Six port breakages are introduced on purpose to prove the check catches them —
two transposed weights, the divisible-by-11 case, the Bắc Giang 023/024 typo
that one of the four published province tables actually carries, two swapped
carrier prefixes, the trunk-zero inference, and a \d widened to Unicode:
node scripts/prove-ident-parity-fails.mjs
# 6/6 real port breakages were caught
Errors are values, not exceptions
Every function returns False or None on bad input, and every one accepts
anything at runtime. A validator that crashes on the wrong type has turned a
validation error into a 500, and form posts and JSON bodies do not respect your
type hints:
is_valid_mst(None) # False
parse_cccd(12345) # None
classify_id({"a": 1}) # 'unknown'
The one exception is mst_check_digit, which raises TypeError on a prefix that
is not nine digits. That is a programming error in the caller rather than bad
user data, and it should not be a None you discover three lines later.
Reading an amount out of a card number
The fourth digit is the century and the sex in one character, so the two year digits alone are ambiguous and it is what settles them:
parse_cccd("001085123456").birth_year # 1985
parse_cccd("001285123456").birth_year # 2085
A year in the future parses, deliberately: with no check digit nothing can detect
it, and a date-dependent valid would make the conformance suite change its own
answer over time.
Known limitations
- No card number verification is possible. No check digit exists.
- No province lookup for a tax code.
region_codecomes from the tax authority's catalogue, not the card-number table, and this package does not carry a join between the two. detect_carrierdoes not normalise.detect_carrier("+84912345678")isNone. Callnormalize_phonefirst.is_valid_cmndis a shape test. Nine digits, no structure, no check digit. It accepts000000000, which was never issued to anyone.- Non-ASCII digits are refused everywhere. Both ports agree on that, and the proof script checks that they do.
Licence
MIT
Metadata
Release files for vn-ident 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| vn_ident-0.1.0.tar.gz | 14.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| vn_ident-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 28.2 kB
Release files / vn_ident-0.1.0.tar.gz
| Download URL | vn_ident-0.1.0.tar.gz |
|---|---|
| Size | 14.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
129af6543461a45dc4a8fa942438edc53685736f9fcef5decf646b9123385433
|
|
BLAKE2b-256 checksum How to use checksums |
50528374831054e212e702c56ada5e84d0055fc1abb796538b005ecf034955b5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / vn_ident-0.1.0-py3-none-any.whl
| Download URL | vn_ident-0.1.0-py3-none-any.whl |
|---|---|
| Size | 13.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
05a0d28fbb30a7746dfa06920422465608d26a6f6f76ce63175dd4425c82dcd6
|
|
BLAKE2b-256 checksum How to use checksums |
bc36a714296dd0a7c66f7f7c2789a69d5fd67d114e5ac871133e6731e20d2230
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|