From 05f426b0a17a9813f90e76f2893bc24055e22989 Mon Sep 17 00:00:00 2001 From: David Koblas Date: Wed, 11 Nov 2026 08:10:55 -0500 Subject: [PATCH] feat: add validateVat (#50) Add validateVat(country, value), which looks up the euVat table only, so bundlers can drop the stdnum, personValidators and entityValidators tables when it is the only lookup used. Document it alongside validatePerson and validateEntity, and note that "sideEffects": false is what lets the root entry tree-shake. Fixes #50 --- README.md | 24 ++++++++++++++++++++++++ src/index.spec.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/README.md b/README.md index 4bc1e52d..09056dfe 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,30 @@ Subpaths follow the source layout: `stdnum//` (e.g. `stdnum/it/codicefiscale`), and `stdnum/` for a whole country's validators. +Tree-shaking the root `stdnum` entry works because `package.json` declares +`"sideEffects": false`: anything you import from it that you don't use, such as +the `stdnum` object or the validator tables below, can be dropped by the +bundler. + +## Validating by country code + +When the country is only known at runtime, these functions pick the +validators for an ISO 3166-1 alpha-2 country code (case-insensitive): + + import { validatePerson, validateEntity, validateVat } from 'stdnum'; + + validatePerson('BR', '390.533.447-05'); // personal ID numbers + validateEntity('US', '12-3456789'); // business and tax numbers + validateVat('DE', 'DE136695976'); // VAT numbers + +Each returns `{ checked: true, isValid, matchedValidators }` when it knows the +country, and `{ checked: false }` when it does not. + +`validateVat` covers the countries in the `euVat` table: the EU member states +plus some non-EU countries (such as AD and CH). It only pulls in those VAT +validators, so it adds far less to a bundle than `validateEntity`, which +references every country's business validators. + ## Supported Countries and Numbers How you can help! This library currently support about half the countries in the world. It would be great if we can get to 100%. Submit an issue with any reference documentation and I'll do my best to integrate it, bonus points if you can get detailed descriptions of checksums or other validation criteria. diff --git a/src/index.spec.ts b/src/index.spec.ts index 433c31f6..06099e0a 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -30,3 +30,49 @@ describe('index exports', () => { expect(lib.stdnum.AI.tin.validate('12345-67890').isValid).toBe(true); }); }); + +describe('validateVat', () => { + // Valid and invalid values are taken from the country specs. + it.each([ + ['DE', 'DE136695976', lib.DE.vat], // src/de/vat.spec.ts + ['FR', '40303265045', lib.FR.tva], // src/fr/tva.spec.ts + ['CH', 'CHE-107.787.577 IVA', lib.CH.vat], // src/ch/vat.spec.ts, non-EU + ])('accepts a valid %s VAT number', (country, value, validator) => { + expect(lib.validateVat(country, value)).toEqual({ + checked: true, + isValid: true, + matchedValidators: [validator], + }); + }); + + it('rejects an invalid VAT number', () => { + // Bad check digit, per src/fr/tva.spec.ts. + expect(lib.validateVat('FR', '84 323 140 391')).toEqual({ + checked: true, + isValid: false, + matchedValidators: [], + }); + }); + + it('accepts a lowercase country code', () => { + expect(lib.validateVat('de', 'DE136695976').isValid).toBe(true); + expect(lib.validateVat('fr', '84 323 140 391')).toEqual({ + checked: true, + isValid: false, + matchedValidators: [], + }); + }); + + it('does not check an unknown country', () => { + expect(lib.validateVat('XX', '40303265045')).toEqual({ checked: false }); + expect(lib.validateVat('XX', '40303265045')).toEqual( + lib.validateEntity('XX', '40303265045'), + ); + }); + + it('does not check a country that has entity validators but no VAT entry', () => { + // Valid US EIN, per src/us/ein.spec.ts. + expect(lib.validateEntity('US', '123-45-6789').isValid).toBe(true); + expect(lib.validateVat('US', '123-45-6789')).toEqual({ checked: false }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 7c591ea8..cda5c862 100644 --- a/src/index.ts +++ b/src/index.ts @@ -697,6 +697,32 @@ export function validateEntity( return { checked: true, isValid: match.length > 0, matchedValidators: match }; } +/** + * Apply the VAT validator for a given country to validate a VAT number. + * + * Covers the countries in `euVat`, which besides the EU member states + * includes some non-EU countries (such as AD and CH). A country that is not + * in `euVat` returns `{ checked: false }`, the same result `validateEntity` + * gives for a country it does not know. + * + * This only reads `euVat`, so a bundler can leave out the much larger + * `stdnum`, `personValidators` and `entityValidators` tables. + */ +export function validateVat( + country: string, + value: string, +): { checked: boolean; isValid?: boolean; matchedValidators?: Validator[] } { + const vset = euVat[country.toUpperCase()]; + + if (!vset || vset.length === 0) { + return { checked: false }; + } + + const match = vset.filter(grp => grp.validate(value).isValid); + + return { checked: true, isValid: match.length > 0, matchedValidators: match }; +} + /** * Validate a passport number for a given country. * Like validatePerson, returns `{ checked: false }` when the country has no