Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,17 @@ bench:
(cd "$$mod" && go test -bench=. -benchmem -benchtime=5s -timeout 300s ./...) || exit 1; \
done

.PHONY: fuzz
fuzz:
@for pkg in providers/vat providers/siren providers/siret providers/euid; do \
echo "==> fuzz $$pkg (FUZZTIME=$${FUZZTIME:-5m})"; \
go test -fuzz=FuzzValidateFormat -fuzztime=$${FUZZTIME:-5m} ./$$pkg/... || exit 1; \
done

.PHONY: fuzz-quick
fuzz-quick:
@$(MAKE) fuzz FUZZTIME=30s

.PHONY: lint
lint:
ifeq (, $(shell which golangci-lint))
Expand Down
134 changes: 132 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,146 @@ protobuf, no product-specific dependencies.
| DUNS | `providers/duns` | ✓ | — | — |
| EIN | `providers/ein` | ✓ | — | — |
| UK Company Number | `providers/companynumber` | ✓ | — | — |
| EUID | `providers/euid` | ✓ | | — |
| EUID | `providers/euid` | ✓ | Delegated to sub-validator | — |
| EORI | `providers/eori` | ✓ | — | — |
| VAT | `providers/vat` | ✓ | FR key + SIREN Luhn | — |
| VAT | `providers/vat` | ✓ | 28 countries (EU-27 + XI, GB) | — |
| National Registration Number | `providers/nationalregistration`| ✓ | — | — |
| Defaults wiring | `defaults` | — | — | — |

`RegistryLookup` is an interface with unsupported defaults today so real
HTTP integrations (INSEE, GLEIF, Companies House…) can be plugged in later
without breaking callers.

## Country coverage

### VAT (format validation)

Every layout below is enforced strictly — length AND character positions
must match, otherwise the result is `ReasonInvalidLength` or
`ReasonInvalidCharacters`. Prefixes not listed fall back to the generic
`2..13 alphanumeric` rule.

| Country | Code | Layout |
| -------------------- | :--: | ----------------------------------- |
| Austria | AT | `U` + 8 digits |
| Belgium | BE | 10 digits (first is 0 or 1) |
| Bulgaria | BG | 9 or 10 digits |
| Croatia | HR | 11 digits |
| Cyprus | CY | 8 digits + 1 letter |
| Czech Republic | CZ | 8, 9 or 10 digits |
| Germany | DE | 9 digits |
| Denmark | DK | 8 digits |
| Estonia | EE | 9 digits |
| Greece | EL | 9 digits (note: `EL`, not `GR`) |
| Spain | ES | alnum + 7 digits + alnum |
| Finland | FI | 8 digits |
| France | FR | 2 alnum key + 9-digit SIREN (Luhn + mod-97 checksum) |
| Hungary | HU | 8 digits |
| Ireland | IE | 7 digits + 1-2 letters, or 1 digit + 1 letter + 5 digits + 1 letter |
| Italy | IT | 11 digits |
| Lithuania | LT | 9 or 12 digits |
| Luxembourg | LU | 8 digits |
| Latvia | LV | 11 digits |
| Malta | MT | 8 digits |
| Netherlands | NL | 9 digits + `B` + 2 digits |
| Poland | PL | 10 digits |
| Portugal | PT | 9 digits |
| Romania | RO | 2 to 10 digits |
| Sweden | SE | 12 digits |
| Slovenia | SI | 8 digits |
| Slovakia | SK | 10 digits |
| United Kingdom | GB | 9 or 12 digits |
| Northern Ireland | XI | Same as GB (post-Brexit protocol) |
| Norway | NO | 9 digits (org. number) |
| Iceland | IS | 5, 6, or 10 (kennitala) digits |
| Liechtenstein | LI | 5 digits |

Checksum validation (`ValidateChecksum`) is implemented for **30 codes**:
EU-27 + `XI`, `GB`, `NO`, and `IS` (kennitala 10-digit form only). Each
algorithm is sourced from the national tax authority documentation and
cross-verified against `python-stdnum` test vectors. `LI` remains
format-only (no published algorithm). See
[providers/vat/checksums.go](providers/vat/checksums.go) for per-country
source references. Coverage per country includes multi-variant support:
`BG` (BULSTAT 9d, EGN 10d, foreigner LNCh 10d), `CZ` (legal 8d + 9d + rodné
číslo 10d), `LV` (legal entity + natural person personal code).

**Aliases**: `GR` → `EL` and `UK` → `GB` are canonicalized. `IE` legacy
VAT numbers containing `+` or `*` in position 2 are accepted with the
`vat.WithLegacy()` option. `BE` pre-2005 9-digit VAT numbers are
automatically canonicalized to their post-2005 10-digit form.

**Reserved prefixes**: `CY12…` is rejected as it is reserved for legacy
Cypriot TINs, not VAT.

### EUID (meta-format, native validators for EU-27)

`ValidateFormat` enforces the BRIS layout
`<CC><REGISTER>.<REGISTRATION>` (Regulation (EU) 2015/884). The register
segment is 1–20 upper-case alphanumeric characters; the registration
segment is 1–64 characters from `[A-Z0-9./\- +]`.

**Native register validators for all EU-27**: no external wiring
required. `euid.New()` alone validates the REGISTRATION segment against
the national register's format (and checksum where documented):

| Country | Register | Format | Checksum |
|---------|----------|--------|:--------:|
| AT | Firmenbuchnummer (FN) | 1-6 digits + 1 letter | — |
| BE | KBO/BCE | 10 digits | ✓ (mod-97) |
| BG | EIK | 9 or 13 digits | ✓ (BULSTAT) |
| HR | MBS | 8 digits | — |
| CY | HE number | 6 digits | — |
| CZ | IČO | 8 digits | ✓ (mod-11) |
| DE | Handelsregister | free-form (court/type/number) | — |
| DK | CVR | 8 digits | ✓ (mod-11) |
| EE | Registrikood | 8 digits | ✓ (mod-11) |
| EL | GEMI | 12 digits | — |
| ES | NIF/CIF | alnum + 7 digits + alnum | ✓ (DNI/NIE/CIF) |
| FI | Y-tunnus | 8 digits | ✓ (mod-11) |
| FR | SIREN | 9 digits | ✓ (Luhn) |
| HU | Cégjegyzékszám | 10 digits (with optional dashes) | — |
| IE | CRO number | 5-7 digits | — |
| IT | Codice Fiscale entità | 11 digits | ✓ (Luhn) |
| LT | Juridinio asmens kodas | 9 digits | ✓ (mod-11) |
| LU | RCSL | B + 4-6 digits | — |
| LV | Reģistrācijas numurs | 11 digits | ✓ (mod-11) |
| MT | Company number | C + 4-6 digits | — |
| NL | KVK | 8 digits | ✓ (mod-11) |
| PL | KRS | 10 digits | — |
| PT | NIPC | 9 digits | ✓ (mod-11) |
| RO | CUI | 2-10 digits | ✓ (mod-11) |
| SE | Organisationsnummer | 10 digits | ✓ (Luhn) |
| SI | Matična številka | 7 digits | — |
| SK | IČO | 8 digits | ✓ (mod-11) |

Non-BRIS prefixes (XI, GB, NO, IS, LI) are accepted for meta-format
shape only; use `euid.WithCountryValidator(cc, validator)` to inject a
custom validator for extension.

### SIREN / SIRET

Both validators enforce all-digit Luhn (mod-10). SIREN is 9 digits, SIRET
is 14 digits (9-digit SIREN + 5-digit NIC). The NIC `00000` is reserved by
INSEE and rejected at the format level.

**Dérogations**: La Poste (SIREN `356000000`) is fully supported — a La
Poste SIRET is accepted when either Luhn passes or the plain digit sum
is divisible by 5 (INSEE dérogatoire rule). Callers can register
additional non-Luhn rules for other historical SIRENs via
`siren.WithDerogation` / `siret.WithDerogation`.

### Extensibility (option pattern)

All four providers (`vat`, `siren`, `siret`, `euid`) accept functional
options at construction:

```go
v := vat.New(vat.WithLegacy()) // accept IE +/* legacy chars
s := siret.New(siret.WithDerogation("999999999", myRule)) // custom rule
e := euid.New(euid.WithCountryValidator("XI", myValidator)) // custom register validator
```

## Install

```sh
Expand Down
8 changes: 5 additions & 3 deletions canonicalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,11 @@ func IsAlnumUpper(s string) bool {
}

// IsRegistrationCharset reports whether s is non-empty and contains only
// characters from the set A-Z, 0-9, dot, slash, dash and space — the
// characters from the set A-Z, 0-9, dot, slash, dash, space and plus — the
// character class shared by EUID registration segments and generic
// national registration numbers.
// national registration numbers. The plus sign is accepted because some
// national registers (e.g. legacy Irish, some Nordic registrations) emit
// it as part of the local identifier.
func IsRegistrationCharset(s string) bool {
if s == "" {
return false
Expand All @@ -130,7 +132,7 @@ func IsRegistrationCharset(s string) bool {
switch {
case c >= '0' && c <= '9',
c >= 'A' && c <= 'Z',
c == '.', c == '/', c == '-', c == ' ':
c == '.', c == '/', c == '-', c == ' ', c == '+':
continue
default:
return false
Expand Down
3 changes: 3 additions & 0 deletions defaults/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ import (
)

// Providers returns freshly-constructed instances of every default provider.
//
// EUID has native validators for every EU-27 register — no wiring is
// needed at the caller level.
func Providers() []businessid.Provider {
return []businessid.Provider{
siren.New(),
Expand Down
Loading
Loading