feat(finance): add vatNumber() - #3985
Conversation
Generates a VAT identification number for any of the EU member states, following the same shape as the existing iban(): a flat country table plus a countryCode option, so the method behaves identically in every locale and needs no locale definitions. Only the structure of each number is modelled -- lengths, character classes and mandated literals such as the Austrian U or the Dutch B. Check digits are random, matching faker-ruby's vat_number and the level of checking that validator's isVAT performs for 26 of the 27 member states. Portugal is deliberately absent because its check digit is verified by common validators, so a random one would be rejected; the same applies to non-EU schemes such as CH and AU. Those need real check-digit computation, which a later change can add behind an option without altering this signature.
✅ Deploy Preview for fakerjs ready!Built without sensitive environment variables
To edit notification comments on pull requests, go to your Netlify project configuration. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## next #3985 +/- ##
==========================================
- Coverage 98.92% 98.86% -0.06%
==========================================
Files 926 927 +1
Lines 3241 3260 +19
Branches 588 595 +7
==========================================
+ Hits 3206 3223 +17
- Misses 31 33 +2
Partials 4 4
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Adds locale-independent VAT identification number generation for supported EU countries.
Changes:
- Adds country-specific VAT format templates and
finance.vatNumber(). - Supports random or selected countries with documented checksum limitations.
- Adds validation tests, seeded snapshots, and API documentation snapshots.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/modules/finance/module.ts |
Implements the public VAT generator. |
src/modules/finance/vat-number.ts |
Defines supported VAT formats. |
test/modules/finance-vat-number.spec.ts |
Tests generation and errors. |
test/modules/finance.spec.ts |
Adds seeded test cases. |
test/modules/__snapshots__/finance.spec.ts.snap |
Records seeded VAT outputs. |
test/scripts/apidocs/__snapshots__/verify-jsdoc-tags.spec.ts.snap |
Registers the API method. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Key the formats by country in a Record with the name as a doc comment, and fold the country prefix into the pattern, which lets GR exist as an alias resolving to the EL-prefixed form Greek numbers actually carry. - Type countryCode as LiteralUnion<VatNumberCountryCode> so editors suggest the supported codes without narrowing the parameter, and list those codes in the JSDoc. - Branch on countryCode === undefined, so an explicitly passed empty string is reported as unsupported instead of silently returning a random country. - Move the tests into the finance spec. Also corrects six patterns that an audit against the tax authorities showed were wrong: Spain assigns a digit control character to national legal entities (so validator rejects real numbers such as ESA28015865, and Spain is asserted structurally instead), Cyprus issues numbers starting with 6 since 2023, Ireland may carry a trailing W, Lithuania fixes a 1 in the eighth position, Romania never starts with zero, and France omits I and O from the key.
- Inline GR into the main record rather than keeping a separate alias map, and re-export VatNumberCountryCode from the finance module index. - Default countryCode via objectKey in the destructuring, as suggested; an explicitly passed empty string still reports as unsupported. - Drop the two tests that restated the source patterns and the redundant seeded case, and loop over every country asserting the generated value matches its declared pattern. Also adds Portugal. The docs previously said check digits are random while omitting countries because their check digits are verified, which contradicted itself, and Spain was already generated per its real rules and asserted structurally. Portugal now follows the same rule, so the contract is uniform: check digits are random everywhere, and the three entries a third-party validator cannot confirm are covered by the pattern loop instead. Reported the Spanish false negative upstream as validatorjs/validator.js#2846.
…he draw
Two defects the suite passed straight over.
`vatNumber({ countryCode: 'toString' })` returned an empty string rather than
throwing: the lookup reached Object.prototype, so the guard saw a truthy value,
and the `Record<string, string | undefined>` cast is what hid that from the
type checker. Replaced with an `Object.hasOwn` check, which also lets the
remaining assertion be a narrowing one. `toString` is now in the throw test.
Inlining GR into the format table made Greece twice as likely as any other
country on an unparameterised call, measured at 7.15% against 3.57%, because
the draw runs over the table's keys. GR moves back out into an alias map, so
the objectKey default draws over 27 countries uniformly again.
The parameter now takes the strict VatNumberCountryCode, matching
system.networkInterface(), which has the identical table/keyof/objectKey shape.
The pattern loop no longer claims to prove correctness it cannot prove, and the
constraints validator is too loose to check are asserted over 100 draws each --
hand-verified to kill mutations of the BE, LT, SE and SI rules that previously
survived.
Documentation corrections: the description of the countryCode option moves into
the inline JSDoc, which is what the website renders, so the GR-in/EL-out
behaviour now reaches fakerjs.dev instead of only IDE hover; the file-level doc
block lands on the exported table rather than on a helper constant; and several
per-country comments were wrong -- Sweden's 01 is an establishment number
rather than a fixed suffix, Ireland's trailing W is the legacy marker for a
married woman on her husband's number, Belgium's 0 and 1 prefixes come from two
separate events, and Spain draws its two ends independently where real numbers
correlate them.
f55bc49 to
af4fc99
Compare
|
Fixed in af4fc99 and d1a8555: a prototype-chain hole ( Spanish false negative reported upstream as validatorjs/validator.js#2846. |
Four countries were being generated from a single character class per position, which pairs positions that real numbering schemes correlate or restrict. Documenting the gap was not enough, since the value still came out. Spain draws the entity class and the control character independently, so seed 10 produced ESS06742173 — an S entity with a digit control, which AEAT does not issue. The class decides the control character, so the two forms are now separate patterns: A, B, C, D, E, F, G, H, J, U and V take a digit, while N, P, Q, R, S and W take a letter. Cyprus allowed every first digit, so seed 5 produced CY28294675H. The legacy categories use 0, 1, 3, 4, 5 and 9 and the March 2023 format adds 6, so 2, 7 and 8 never occur. The Dutch branch number runs 01 to 99, but a plain digit pair also emits B00, as seed 178 did. Portugal allowed a leading zero, which no taxpayer range uses, as seed 21 did. The table now takes one pattern per shape where a country needs it, the independent test asserts the correlation rather than repeating the permissive pattern, and the expansion loop samples enough draws to reach every variant. Verified over 20,000 draws per country: no uncorrelated Spanish number, no Cypriot 2/7/8, no B00, no Portuguese leading zero.
|
Can you please turn down the verbosity of your responses? |
A review of the comments against the patterns beside them found several that describe something other than the code: - The Irish comment says "seven digits and two letters" is not modelled, but the optional W produces exactly that in about half of draws. The form that is genuinely absent is the 2013 one, whose second letter runs A to I. - The Spanish comment states that A through V all take a digit control character. Only A, B, E and H must; C, D, F, G, J, U and V may take either, and are generated with a digit, which under-generates rather than emitting something unissuable. - The header promises one pattern per shape, which the table does not deliver for Ireland, Lithuania or Spain, and it says a recomputing validator rejects "some" output when it rejects nearly all of it. It also said "check digit" where Cyprus, Ireland and part of Spain use a letter. - Cyprus asserted a category taxonomy that sources state differently; it now states the digits modelled without claiming what each denotes. - Lithuania justified its single pattern by an argument that stopped holding when the table gained multi-pattern support. The test comment listed NL among the countries validator checks as tightly as the table does, while NL appears in the very table below it — it has to, since validator accepts the B00 the patterns exclude — and it omitted FR entirely. Also adds vatNumber() to the module overview, and notes in the header that a country's listed shapes are drawn evenly rather than by real-world frequency.
Adds
faker.finance.vatNumber(), generating VAT identification numbers for the EU member states.Why
Two open issues ask for pieces of this, and neither has an implementation:
vatNumber({ countryCode: 'ES' })is exactly what that issue asks for. @matthewmayer suggested there that the answer should be one localizable method rather than a Spain-onlycif(), and @xDivisionByZerox' constraint on that thread — "We support over 60 locales…enis our default locale. So it must not error on functions with a 'not applicable' error" — is what drove the design below.This PR covers the government tax identifier half of that. It deliberately does not touch DUNS, which is a Dun & Bradstreet commercial identifier rather than a government one — conflating the two is what makes "what should this return for DE?" unanswerable.
Concretely, the gap today is that a test fixture needing a VAT ID has to hardcode
DE999999999or fill the field withfaker.string.sample()noise.Design — it's
iban()with a different tableThe method is deliberately a near-copy of the existing
iban(), so there is no new pattern to review:That choice answers the 60-locale constraint by construction: the method never reads
faker.localeorfaker.definitions, so it behaves identically in every locale, needs no locale data, and cannot raise a "not applicable" error.fakerEN,fakerDEandfakerJAall work. NoBROKEN_LOCALE_METHODSentry is needed —all-functional.spec.tspasses across all locales unchanged.Formatting reuses the existing
faker.helpers.replaceSymbols()(#digit,?letter, literals verbatim). I did not usereplaceCreditCardSymbols()because itsLplaceholder hard-codes Luhn, which fits almost no VAT scheme.Scope: structure only, no check digits
Each entry models length, character classes and mandated literals (Austria's
U, the Netherlands'B, Belgium's leading0). Where a country's real scheme defines a check digit, it is random here, and the JSDoc says so plainly.That matches the prior art — faker-ruby's
Faker::Finance.vat_numberis likewise template-driven with no checksums — and it matches what the ecosystem actually verifies. I measured this rather than assumed it: 200 generated values per country throughvalidator'sisVATgives 100% for 26 of the 27 member states, becauseisVATis a structural check for all of them.The exception is Portugal (7%), which really is checksum-verified, so it is left out rather than shipped knowingly broken. Same for non-EU schemes that are checksum-verified —
CH(9%) andAU(2%). Those want real check-digit computation, which can arrive later behind an option without changing this signature or its default behaviour.Greece is included as
EL, its VAT prefix, not theGRISO code.Tests
test/modules/finance-vat-number.spec.tsmirrorsfinance-iban.spec.ts: every country in the table is generated and asserted againstvalidator'sisVAT. The oracle is third-party, and since this PR ships no checksum code of its own there is nothing for the test to be circular with. Plus seeded snapshots infinance.spec.ts(noArgs, a numeric country, a country with letters), an unsupported-countryFakerErrorcase, and a guard that Greece stays onEL.pnpm run preflightpasses: 53,039 tests, no type errors.Cost
~70 lines of table, zero algorithm code, no locale files, no definitions change, no new test-matrix carve-outs — smaller than the
iban()feature it is modelled on.One note on timing: I see #3857 is migrating modules to standalone functions. Finance's source isn't converted yet, so this follows the current class shape; happy to rebase onto the new structure if that lands first, or to hold this until it does.