Skip to content

feat(finance): add vatNumber() - #3985

Open
rodrigobnogueira wants to merge 6 commits into
faker-js:nextfrom
rodrigobnogueira:feat/finance-vat-number
Open

feat(finance): add vatNumber()#3985
rodrigobnogueira wants to merge 6 commits into
faker-js:nextfrom
rodrigobnogueira:feat/finance-vat-number

Conversation

@rodrigobnogueira

Copy link
Copy Markdown
Contributor

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:

  • CIF generator #2518 — CIF generator (7 👍). Spain's CIF is its VAT identification number, so 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-only cif(), and @xDivisionByZerox' constraint on that thread — "We support over 60 locales… en is our default locale. So it must not error on functions with a 'not applicable' error" — is what drove the design below.
  • Add DUNS number generation #1202 — DUNS — where @camilleterol asked specifically for VAT numbers in October and got no reply: "many countries issue government/regulatory IDs, and it would be great (and quite useful to us) if Faker could generate those as well (SIREN/SIRET in France for example, VAT number, …)".

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 DE999999999 or fill the field with faker.string.sample() noise.

Design — it's iban() with a different table

The method is deliberately a near-copy of the existing iban(), so there is no new pattern to review:

const vatFormat = countryCode
  ? vatNumberFormats.find((f) => f.country === countryCode)
  : this.faker.helpers.arrayElement(vatNumberFormats);

if (!vatFormat) {
  throw new FakerError(`Country code ${countryCode} not supported.`);
}

return `${vatFormat.country}${this.faker.helpers.replaceSymbols(vatFormat.format)}`;

That choice answers the 60-locale constraint by construction: the method never reads faker.locale or faker.definitions, so it behaves identically in every locale, needs no locale data, and cannot raise a "not applicable" error. fakerEN, fakerDE and fakerJA all work. No BROKEN_LOCALE_METHODS entry is needed — all-functional.spec.ts passes across all locales unchanged.

Formatting reuses the existing faker.helpers.replaceSymbols() (# digit, ? letter, literals verbatim). I did not use replaceCreditCardSymbols() because its L placeholder hard-codes Luhn, which fits almost no VAT scheme.

faker.finance.vatNumber() // 'SK4318759382'
faker.finance.vatNumber({ countryCode: 'DE' }) // 'DE644073457'
faker.finance.vatNumber({ countryCode: 'NL' }) // 'NL840351580B96'

Scope: structure only, no check digits

Each entry models length, character classes and mandated literals (Austria's U, the Netherlands' B, Belgium's leading 0). 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_number is 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 through validator's isVAT gives 100% for 26 of the 27 member states, because isVAT is 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%) and AU (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 the GR ISO code.

Tests

test/modules/finance-vat-number.spec.ts mirrors finance-iban.spec.ts: every country in the table is generated and asserted against validator's isVAT. 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 in finance.spec.ts (noArgs, a numeric country, a country with letters), an unsupported-country FakerError case, and a guard that Greece stays on EL.

pnpm run preflight passes: 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.

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.
@rodrigobnogueira
rodrigobnogueira requested a review from a team as a code owner August 9, 2026 06:06
@netlify

netlify Bot commented Aug 9, 2026

Copy link
Copy Markdown

Deploy Preview for fakerjs ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit ff25c85
🔍 Latest deploy log https://app.netlify.com/projects/fakerjs/deploys/6a7e13c54a57130008a15a51
😎 Deploy Preview https://deploy-preview-3985.fakerjs.dev
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.86%. Comparing base (9616076) to head (ff25c85).
⚠️ Report is 8 commits behind head on next.

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              
Files with missing lines Coverage Δ
src/modules/finance/module.ts 100.00% <100.00%> (ø)
src/modules/finance/vat-number.ts 100.00% <100.00%> (ø)

... and 4 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread test/modules/finance-vat-number.spec.ts Outdated
Comment thread src/modules/finance/vat-number.ts Outdated
Comment thread src/modules/finance/module.ts Outdated
Comment thread src/modules/finance/module.ts Outdated
@ST-DDT ST-DDT added c: feature Request for new feature p: 1-normal Nothing urgent m: finance Something is referring to the finance module labels Aug 9, 2026
@ST-DDT ST-DDT modified the milestones: v10.x, vAnytime Aug 9, 2026
@ST-DDT
ST-DDT requested a balanced review from Copilot August 9, 2026 09:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/modules/finance/vat-number.ts Outdated
Comment thread src/modules/finance/module.ts Outdated
Comment thread src/modules/finance/module.ts Outdated
- 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.
Comment thread src/modules/finance/vat-number.ts Outdated
Comment thread src/index.ts Outdated
Comment thread test/modules/finance.spec.ts Outdated
Comment thread test/modules/finance.spec.ts Outdated
Comment thread test/modules/finance.spec.ts Outdated
Comment thread test/modules/finance.spec.ts Outdated
Comment thread src/modules/finance/vat-number.ts Outdated
Comment thread src/modules/finance/module.ts Outdated
Comment thread src/modules/finance/module.ts Outdated
Comment thread src/modules/finance/module.ts Outdated
- 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.
@rodrigobnogueira
rodrigobnogueira force-pushed the feat/finance-vat-number branch from f55bc49 to af4fc99 Compare August 11, 2026 02:13
@rodrigobnogueira

rodrigobnogueira commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in af4fc99 and d1a8555: a prototype-chain hole (countryCode: 'toString' returned ''), GR moved back out to an alias map because inlining it doubled Greece's odds on a no-arg call, countryCode strictly typed to match system.networkInterface(), and four countries that were emitting unissuable numbers (ES, CY, NL, PT).

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.
@ST-DDT

ST-DDT commented Aug 11, 2026

Copy link
Copy Markdown
Member

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c: feature Request for new feature m: finance Something is referring to the finance module p: 1-normal Nothing urgent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants