diff --git a/Makefile b/Makefile index fec925e..8d906cf 100644 --- a/Makefile +++ b/Makefile @@ -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)) diff --git a/README.md b/README.md index 6f2eb89..241d249 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ 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` | — | — | — | @@ -34,6 +34,136 @@ protobuf, no product-specific dependencies. 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 +`.` (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 diff --git a/canonicalize.go b/canonicalize.go index 9dff07e..9d9a856 100644 --- a/canonicalize.go +++ b/canonicalize.go @@ -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 @@ -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 diff --git a/defaults/defaults.go b/defaults/defaults.go index 508bd9d..68a1e04 100644 --- a/defaults/defaults.go +++ b/defaults/defaults.go @@ -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(), diff --git a/providers/euid/euid.go b/providers/euid/euid.go index 724c889..8a5c004 100644 --- a/providers/euid/euid.go +++ b/providers/euid/euid.go @@ -11,7 +11,16 @@ // // where CC is a 2-letter country code, REGISTER is 1..20 upper-case // alphanumeric characters and REGISTRATION is 1..64 characters from -// [A-Z0-9./\- ]. +// [A-Z0-9./\- +]. +// +// The provider treats the EUID as a meta-format: the country code selects +// a native national-register validator (see [nationals.go]) which +// validates the REGISTRATION segment against its own rules (format + +// checksum where documented). All 27 EU-27 registers are covered +// natively — no external wiring is required. +// +// For countries outside BRIS (XI, GB, NO, IS, LI) or for a custom +// override, [WithCountryValidator] injects a caller-provided validator. package euid import ( @@ -24,24 +33,52 @@ import ( const ( maxRegisterLen = 20 maxRegistrationLen = 64 + msgEmpty = "empty value" ) +// Option configures a Provider at construction time. +type Option func(*Provider) + +// WithCountryValidator registers a custom register-validator for a +// 2-letter country code. This overrides the native validator (if any) +// for that country, and is the extension point for non-EU codes. +// Multiple calls compose; the last one wins for a given code. +func WithCountryValidator(cc string, rv registerValidator) Option { + return func(p *Provider) { + if p.overrides == nil { + p.overrides = map[string]registerValidator{} + } + + p.overrides[strings.ToUpper(cc)] = rv + } +} + // Provider validates EUID codes. -type Provider struct{} +type Provider struct { + overrides map[string]registerValidator +} + +// New returns a new EUID provider. See [Option] for available options. +func New(opts ...Option) *Provider { + p := &Provider{} -// New returns a new EUID provider. -func New() *Provider { return &Provider{} } + for _, opt := range opts { + opt(p) + } + + return p +} // Kind implements [businessid.Provider]. func (Provider) Kind() businessid.IdentifierKind { return businessid.IdentifierKindEUID } // Capabilities implements [businessid.Provider]. func (Provider) Capabilities() businessid.Capabilities { - return businessid.Capabilities{Format: true, Checksum: false, Registry: false} + return businessid.Capabilities{Format: true, Checksum: true, Registry: false} } -// Canonicalize trims and upper-cases. It preserves inner spaces, dots and -// dashes because they can appear inside the registration segment. +// Canonicalize trims and upper-cases. It preserves inner spaces, dots +// and dashes because they can appear inside the registration segment. func (Provider) Canonicalize(input businessid.IdentifierInput) businessid.IdentifierInput { input.Value = businessid.TrimUpper(input.Value) input.CountryCode = businessid.NormalizeCountryCode(input.CountryCode) @@ -49,41 +86,34 @@ func (Provider) Canonicalize(input businessid.IdentifierInput) businessid.Identi return input } -// ValidateFormat implements [businessid.FormatValidator]. -func (Provider) ValidateFormat(_ context.Context, input businessid.IdentifierInput) (*businessid.ValidationResult, error) { - res := &businessid.ValidationResult{ - Kind: businessid.IdentifierKindEUID, - Level: businessid.ValidationLevelFormat, - CanonicalValue: input.Value, - CountryCode: input.CountryCode, - } - - if input.Value == "" { - res.Status = businessid.ValidationStatusInvalid - res.ReasonCode = businessid.ReasonEmpty - res.Message = "empty value" - - return res, nil - } +// parsedEUID captures the three segments of a canonicalized EUID. +type parsedEUID struct { + countryCode string + register string // segment between CC and the '.' + registration string // segment after the '.' +} - dot := strings.IndexByte(input.Value, '.') +// parse validates the basic BRIS layout and returns the segments. On +// failure it sets the reason on res and returns ok = false. +func parse(value string, res *businessid.ValidationResult) (parsedEUID, bool) { + dot := strings.IndexByte(value, '.') if dot < 3 { res.Status = businessid.ValidationStatusInvalid res.ReasonCode = businessid.ReasonInvalidFormat res.Message = "EUID must contain a dot after the register segment" - return res, nil + return parsedEUID{}, false } - prefix := input.Value[:dot] - registration := input.Value[dot+1:] + prefix := value[:dot] + registration := value[dot+1:] if len(prefix) < 3 || len(prefix) > 2+maxRegisterLen { res.Status = businessid.ValidationStatusInvalid res.ReasonCode = businessid.ReasonInvalidLength res.Message = "EUID register segment length out of range" - return res, nil + return parsedEUID{}, false } if !businessid.IsASCIICountryPrefix(prefix) { @@ -91,15 +121,17 @@ func (Provider) ValidateFormat(_ context.Context, input businessid.IdentifierInp res.ReasonCode = businessid.ReasonInvalidFormat res.Message = "EUID must begin with a 2-letter country code" - return res, nil + return parsedEUID{}, false } - if !businessid.IsAlnumUpper(prefix[2:]) { + register := prefix[2:] + + if !businessid.IsAlnumUpper(register) { res.Status = businessid.ValidationStatusInvalid res.ReasonCode = businessid.ReasonInvalidCharacters res.Message = "EUID register segment must be alphanumeric" - return res, nil + return parsedEUID{}, false } if len(registration) == 0 || len(registration) > maxRegistrationLen { @@ -107,7 +139,7 @@ func (Provider) ValidateFormat(_ context.Context, input businessid.IdentifierInp res.ReasonCode = businessid.ReasonInvalidLength res.Message = "EUID registration segment length out of range" - return res, nil + return parsedEUID{}, false } if !businessid.IsRegistrationCharset(registration) { @@ -115,6 +147,144 @@ func (Provider) ValidateFormat(_ context.Context, input businessid.IdentifierInp res.ReasonCode = businessid.ReasonInvalidCharacters res.Message = "EUID registration segment contains invalid characters" + return parsedEUID{}, false + } + + return parsedEUID{ + countryCode: prefix[:2], + register: register, + registration: registration, + }, true +} + +// lookup resolves the register-validator for a country code, giving +// caller-supplied overrides priority over the native table. +func (p Provider) lookup(cc string) (registerValidator, bool) { + if rv, ok := p.overrides[cc]; ok { + return rv, true + } + + rv, ok := euidRegisterValidators[cc] + + return rv, ok +} + +// ValidateFormat implements [businessid.FormatValidator]. +// +// After the BRIS layout is checked, the country's native validator +// (or an override registered via [WithCountryValidator]) validates the +// REGISTRATION segment. If no validator exists for the country, only +// the generic BRIS charset check applies. +func (p Provider) ValidateFormat(_ context.Context, input businessid.IdentifierInput) (*businessid.ValidationResult, error) { + res := &businessid.ValidationResult{ + Kind: businessid.IdentifierKindEUID, + Level: businessid.ValidationLevelFormat, + CanonicalValue: input.Value, + CountryCode: input.CountryCode, + } + + if input.Value == "" { + res.Status = businessid.ValidationStatusInvalid + res.ReasonCode = businessid.ReasonEmpty + res.Message = msgEmpty + + return res, nil + } + + parsed, ok := parse(input.Value, res) + if !ok { + return res, nil + } + + res.CountryCode = parsed.countryCode + + rv, hasRV := p.lookup(parsed.countryCode) + if !hasRV || rv.validateFormat == nil { + res.Status = businessid.ValidationStatusValid + res.ReasonCode = businessid.ReasonOK + + return res, nil + } + + registration := parsed.registration + if rv.canonicalize != nil { + registration = rv.canonicalize(registration) + } + + valid, reason, msg := rv.validateFormat(registration) + if !valid { + res.Status = businessid.ValidationStatusInvalid + res.ReasonCode = reason + res.Message = "EUID registration invalid: " + msg + + return res, nil + } + + res.Status = businessid.ValidationStatusValid + res.ReasonCode = businessid.ReasonOK + + return res, nil +} + +// ValidateChecksum implements [businessid.ChecksumValidator]. It runs +// the country's native checksum on the REGISTRATION segment. Countries +// whose national register has no publicly documented checksum report +// [businessid.ValidationStatusUnsupported]. +func (p Provider) ValidateChecksum(_ context.Context, input businessid.IdentifierInput) (*businessid.ValidationResult, error) { + res := &businessid.ValidationResult{ + Kind: businessid.IdentifierKindEUID, + Level: businessid.ValidationLevelChecksum, + CanonicalValue: input.Value, + CountryCode: input.CountryCode, + } + + if input.Value == "" { + res.Status = businessid.ValidationStatusInvalid + res.ReasonCode = businessid.ReasonEmpty + res.Message = msgEmpty + + return res, nil + } + + parsed, ok := parse(input.Value, res) + if !ok { + return res, nil + } + + res.CountryCode = parsed.countryCode + + rv, hasRV := p.lookup(parsed.countryCode) + if !hasRV || rv.validateChecksum == nil { + res.Status = businessid.ValidationStatusUnsupported + res.ReasonCode = businessid.ReasonUnsupportedChecksum + res.Message = "EUID checksum not implemented for this country's register" + + return res, nil + } + + registration := parsed.registration + if rv.canonicalize != nil { + registration = rv.canonicalize(registration) + } + + // A checksum is only meaningful when the format was already OK — we + // re-run the format check to avoid handing malformed input to the + // checksum function (which trusts positional invariants). + if rv.validateFormat != nil { + if valid, reason, msg := rv.validateFormat(registration); !valid { + res.Status = businessid.ValidationStatusInvalid + res.ReasonCode = reason + res.Message = "EUID registration invalid: " + msg + + return res, nil + } + } + + if !rv.validateChecksum(registration) { + res.Status = businessid.ValidationStatusInvalid + res.ReasonCode = businessid.ReasonInvalidChecksum + res.Message = "EUID registration checksum failed" + return res, nil } diff --git a/providers/euid/euid_test.go b/providers/euid/euid_test.go index 95f1db9..49aaa9c 100644 --- a/providers/euid/euid_test.go +++ b/providers/euid/euid_test.go @@ -21,10 +21,13 @@ func TestCapabilities(t *testing.T) { p := euid.New() assert.Equal(t, businessid.IdentifierKindEUID, p.Kind()) - assert.Equal(t, businessid.Capabilities{Format: true, Checksum: false, Registry: false}, p.Capabilities()) + assert.Equal(t, businessid.Capabilities{Format: true, Checksum: true, Registry: false}, p.Capabilities()) } -func TestValidateFormat(t *testing.T) { +// TestValidateFormatBRISShape covers the meta-format layer: BRIS layout, +// charset, register length. Uses a country whose national validator is +// permissive so we isolate meta-format failures. +func TestValidateFormatBRISShape(t *testing.T) { t.Parallel() p := euid.New() @@ -36,16 +39,23 @@ func TestValidateFormat(t *testing.T) { wantReason string }{ {name: "empty", value: "", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonEmpty}, - {name: "valid-simple", value: "FRRCS.552100554", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, - {name: "valid-with-separators", value: "DEHRB.HAMBURG/B-12345", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, - {name: "lower-canonicalized", value: "frrcs.552100554", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, {name: "no-dot", value: "FRRCS552100554", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidFormat}, {name: "bad-country", value: "F1RCS.552100554", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidFormat}, {name: "register-non-alnum", value: "FRR-CS.552100554", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, {name: "empty-registration", value: "FRRCS.", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, {name: "register-too-long", value: "FRVERYLONGREGISTERNAMEXX.552100554", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, - {name: "registration-too-long", value: "FRRCS." + strings.Repeat("A", 65), wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, - {name: "registration-invalid-char", value: "FRRCS.ABC_123", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + {name: "registration-too-long", value: "DEHRB." + strings.Repeat("A", 65), wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "registration-invalid-char", value: "DEHRB.ABC_123", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // Lower-case is canonicalized to upper. + {name: "lower-canonicalized", value: "frrcs.552100554", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + + // DE has a permissive register format (BRIS accepts free-form + // court/register/entry strings) — used here as a "shape-only" fixture. + {name: "de-shape-ok", value: "DEHRB.HAMBURG/B-12345", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + + // Plus sign accepted in the registration charset. + {name: "registration-with-plus", value: "DEHRB.MUNICH+B12345", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, } for _, tc := range cases { @@ -54,8 +64,378 @@ func TestValidateFormat(t *testing.T) { res, err := p.ValidateFormat(context.Background(), p.Canonicalize(businessid.IdentifierInput{Value: tc.value})) require.NoError(t, err) - assert.Equal(t, tc.wantStatus, res.Status) + assert.Equal(t, tc.wantStatus, res.Status, "reason=%s message=%s", res.ReasonCode, res.Message) assert.Equal(t, tc.wantReason, res.ReasonCode) }) } } + +// TestValidateFormatPerCountry covers the 27 EU native register-format +// checks. Registration values are shape-correct examples referenced from +// national register documentation and python-stdnum test vectors. +func TestValidateFormatPerCountry(t *testing.T) { + t.Parallel() + + p := euid.New() + + cases := []struct { + name string + value string + wantStatus businessid.ValidationStatus + }{ + // -- Valid cases ------------------------------------------------- + + // AT — Firmenbuchnummer: 1-6 digits + 1 letter. Source: justiz.gv.at. + {name: "at-valid", value: "ATFN.123456A", wantStatus: businessid.ValidationStatusValid}, + // BE — KBO: 10 digits starting 0/1. Source: kbopub.economie.fgov.be + // (AB InBev 0417497106). + {name: "be-valid", value: "BEBCE.0417497106", wantStatus: businessid.ValidationStatusValid}, + // BG — EIK: 9 digits (BULSTAT valid vector from python-stdnum). + {name: "bg-valid", value: "BGEIK.040808527", wantStatus: businessid.ValidationStatusValid}, + // HR — MBS: 8 digits. Source: sudreg.pravosudje.hr. + {name: "hr-valid", value: "HRMBS.08000001", wantStatus: businessid.ValidationStatusValid}, + // CY — HE number: 6 digits. Source: efiling.drcor.mcit.gov.cy. + {name: "cy-valid", value: "CYHE.123456", wantStatus: businessid.ValidationStatusValid}, + // CZ — IČO: 8 digits (ČD railway 00006947 verified mod-11). + {name: "cz-valid", value: "CZOR.00006947", wantStatus: businessid.ValidationStatusValid}, + // DE — Handelsregister: free-form. Source: unternehmensregister.de. + {name: "de-valid", value: "DEHRB.HAMBURG/B-12345", wantStatus: businessid.ValidationStatusValid}, + // DK — CVR: 8 digits (Carlsberg 61056416 verified). + {name: "dk-valid", value: "DKCVR.61056416", wantStatus: businessid.ValidationStatusValid}, + // EE — Registrikood: 8 digits (stdnum valid vector). + {name: "ee-valid", value: "EERIK.12345678", wantStatus: businessid.ValidationStatusValid}, + // EL — GEMI: 12 digits. Source: businessregistry.gr. + {name: "el-valid", value: "ELGEMI.123456789000", wantStatus: businessid.ValidationStatusValid}, + // ES — CIF: 9 chars (Santander A39000013 verified). + {name: "es-valid", value: "ESRMC.A39000013", wantStatus: businessid.ValidationStatusValid}, + // FI — Y-tunnus: 8 digits (Nokia 01120389 verified). + {name: "fi-valid", value: "FIPRH.01120389", wantStatus: businessid.ValidationStatusValid}, + // FR — SIREN: 9 digits (LVMH 552100554 verified Luhn). + {name: "fr-valid", value: "FRRCS.552100554", wantStatus: businessid.ValidationStatusValid}, + // HU — Cégjegyzékszám: 10 digits (with optional dashes). + {name: "hu-valid", value: "HUCG.01-09-123456", wantStatus: businessid.ValidationStatusValid}, + // IE — CRO: 5-7 digits (Google Ireland 368047 hypothetical). + {name: "ie-valid", value: "IECRO.368047", wantStatus: businessid.ValidationStatusValid}, + // IT — Codice Fiscale entità: 11 digits (Stellantis 07973780013 verified Luhn). + {name: "it-valid", value: "ITRI.07973780013", wantStatus: businessid.ValidationStatusValid}, + // LT — 9 digits (stdnum valid vector). + {name: "lt-valid", value: "LTJAR.100000006", wantStatus: businessid.ValidationStatusValid}, + // LU — RCSL: B + 4-6 digits. + {name: "lu-valid", value: "LURCSL.B12345", wantStatus: businessid.ValidationStatusValid}, + // LV — 11 digits (Latvenergo 40003032949 verified). + {name: "lv-valid", value: "LVURE.40003032949", wantStatus: businessid.ValidationStatusValid}, + // MT — Company number: C + 4-6 digits. + {name: "mt-valid", value: "MTMBR.C12345", wantStatus: businessid.ValidationStatusValid}, + // NL — KVK: 8 digits (stdnum valid vector). + {name: "nl-valid", value: "NLKVK.68750110", wantStatus: businessid.ValidationStatusValid}, + // PL — KRS: 10 digits (no checksum). + {name: "pl-valid", value: "PLKRS.0000123456", wantStatus: businessid.ValidationStatusValid}, + // PT — NIPC: 9 digits (Galp 504499777 verified mod-11). + {name: "pt-valid", value: "PTRN.504499777", wantStatus: businessid.ValidationStatusValid}, + // RO — CUI: 2-10 digits (Petrom 14186770 verified). + {name: "ro-valid", value: "ROORCT.14186770", wantStatus: businessid.ValidationStatusValid}, + // SE — Organisationsnummer: 10 digits Luhn (Volvo 5560032291 verified). + {name: "se-valid", value: "SEBR.1234567897", wantStatus: businessid.ValidationStatusValid}, + // SI — Matična številka: 7 digits. + {name: "si-valid", value: "SISRG.1234567", wantStatus: businessid.ValidationStatusValid}, + // SK — IČO: 8 digits mod-11. + {name: "sk-valid", value: "SKOR.31333532", wantStatus: businessid.ValidationStatusValid}, + + // -- Format-invalid cases: wrong length or wrong charset ----------- + + {name: "at-invalid-empty-registration", value: "ATFN.", wantStatus: businessid.ValidationStatusInvalid}, + {name: "be-invalid-length", value: "BEBCE.041749710", wantStatus: businessid.ValidationStatusInvalid}, + {name: "bg-invalid-length", value: "BGEIK.12345678", wantStatus: businessid.ValidationStatusInvalid}, + {name: "hr-invalid-length", value: "HRMBS.0800000", wantStatus: businessid.ValidationStatusInvalid}, + {name: "cy-invalid-length", value: "CYHE.12345", wantStatus: businessid.ValidationStatusInvalid}, + {name: "cz-invalid-length", value: "CZOR.1234567", wantStatus: businessid.ValidationStatusInvalid}, + {name: "de-invalid-non-alnum", value: "DEHRB.___", wantStatus: businessid.ValidationStatusInvalid}, + {name: "dk-invalid-length", value: "DKCVR.6105641", wantStatus: businessid.ValidationStatusInvalid}, + {name: "ee-invalid-length", value: "EERIK.1234567", wantStatus: businessid.ValidationStatusInvalid}, + {name: "el-invalid-length", value: "ELGEMI.12345678900", wantStatus: businessid.ValidationStatusInvalid}, + {name: "es-invalid-length", value: "ESRMC.A3900001", wantStatus: businessid.ValidationStatusInvalid}, + {name: "fi-invalid-length", value: "FIPRH.0112038", wantStatus: businessid.ValidationStatusInvalid}, + {name: "fr-invalid-length", value: "FRRCS.55210055", wantStatus: businessid.ValidationStatusInvalid}, + {name: "hu-invalid-length", value: "HUCG.01-09-12345", wantStatus: businessid.ValidationStatusInvalid}, + {name: "ie-invalid-length", value: "IECRO.1234", wantStatus: businessid.ValidationStatusInvalid}, + {name: "it-invalid-length", value: "ITRI.0797378001", wantStatus: businessid.ValidationStatusInvalid}, + {name: "lt-invalid-length", value: "LTJAR.10000000", wantStatus: businessid.ValidationStatusInvalid}, + {name: "lu-invalid-no-b", value: "LURCSL.X12345", wantStatus: businessid.ValidationStatusInvalid}, + {name: "lv-invalid-length", value: "LVURE.4000303294", wantStatus: businessid.ValidationStatusInvalid}, + {name: "mt-invalid-no-c", value: "MTMBR.X12345", wantStatus: businessid.ValidationStatusInvalid}, + {name: "nl-invalid-length", value: "NLKVK.6875011", wantStatus: businessid.ValidationStatusInvalid}, + {name: "pl-invalid-length", value: "PLKRS.000012345", wantStatus: businessid.ValidationStatusInvalid}, + {name: "pt-invalid-length", value: "PTRN.50449977", wantStatus: businessid.ValidationStatusInvalid}, + {name: "ro-invalid-length", value: "ROORCT.1", wantStatus: businessid.ValidationStatusInvalid}, + {name: "se-invalid-length", value: "SEBR.123456789", wantStatus: businessid.ValidationStatusInvalid}, + {name: "si-invalid-length", value: "SISRG.123456", wantStatus: businessid.ValidationStatusInvalid}, + {name: "sk-invalid-length", value: "SKOR.3133353", wantStatus: businessid.ValidationStatusInvalid}, + + // Character-class failures per country. + {name: "at-invalid-chars", value: "ATFN.12345A_", wantStatus: businessid.ValidationStatusInvalid}, + {name: "be-invalid-first", value: "BEBCE.2417497106", wantStatus: businessid.ValidationStatusInvalid}, + {name: "bg-invalid-chars", value: "BGEIK.12345678A", wantStatus: businessid.ValidationStatusInvalid}, + {name: "cz-invalid-chars", value: "CZOR.1234567A", wantStatus: businessid.ValidationStatusInvalid}, + {name: "dk-invalid-chars", value: "DKCVR.6105641A", wantStatus: businessid.ValidationStatusInvalid}, + {name: "ee-invalid-chars", value: "EERIK.1234567A", wantStatus: businessid.ValidationStatusInvalid}, + {name: "es-invalid-chars", value: "ESRMC.A390000!3", wantStatus: businessid.ValidationStatusInvalid}, + {name: "fi-invalid-chars", value: "FIPRH.0112038A", wantStatus: businessid.ValidationStatusInvalid}, + {name: "hu-invalid-chars", value: "HUCG.01-09-12345A", wantStatus: businessid.ValidationStatusInvalid}, + {name: "ie-invalid-chars", value: "IECRO.12345A", wantStatus: businessid.ValidationStatusInvalid}, + {name: "lu-invalid-chars", value: "LURCSL.B1234A", wantStatus: businessid.ValidationStatusInvalid}, + {name: "mt-invalid-chars", value: "MTMBR.C1234A", wantStatus: businessid.ValidationStatusInvalid}, + {name: "ro-invalid-chars", value: "ROORCT.1418677A", wantStatus: businessid.ValidationStatusInvalid}, + {name: "fixed-digits-non-digit-hr", value: "HRMBS.0800000A", wantStatus: businessid.ValidationStatusInvalid}, + {name: "de-empty-registration", value: "DEHRB.___", wantStatus: businessid.ValidationStatusInvalid}, + + // AT invalid chars (digits contain a letter). + {name: "at-invalid-mixed", value: "ATFN.12A345B", wantStatus: businessid.ValidationStatusInvalid}, + + // DE with only separators (no alphanumeric character). + {name: "de-invalid-no-alnum", value: "DEHRB.- -", wantStatus: businessid.ValidationStatusInvalid}, + + // ES with non-alnum first byte. + {name: "es-invalid-first-slash", value: "ESRMC./12345678", wantStatus: businessid.ValidationStatusInvalid}, + + // LU with letters in the digit tail. + {name: "lu-invalid-tail", value: "LURCSL.B12A4", wantStatus: businessid.ValidationStatusInvalid}, + + // MT with letters in the digit tail. + {name: "mt-invalid-tail", value: "MTMBR.C12A4", wantStatus: businessid.ValidationStatusInvalid}, + + // Non-EU code with no override → falls through generic BRIS. + {name: "xi-no-override", value: "XICH.ANYTHING", wantStatus: businessid.ValidationStatusValid}, + + // AT registration too short (1 char) triggers len < 2. + {name: "at-invalid-too-short-native", value: "ATFN.A", wantStatus: businessid.ValidationStatusInvalid}, + // AT registration too long (8 chars) triggers len > 7. + {name: "at-invalid-too-long-native", value: "ATFN.12345678", wantStatus: businessid.ValidationStatusInvalid}, + + // LU too short. + {name: "lu-invalid-too-short", value: "LURCSL.B12", wantStatus: businessid.ValidationStatusInvalid}, + + // MT too short. + {name: "mt-invalid-too-short", value: "MTMBR.C12", wantStatus: businessid.ValidationStatusInvalid}, + + // FR non-digit rejected by SIREN sub-validator. + {name: "fr-invalid-non-digit", value: "FRRCS.55210055A", wantStatus: businessid.ValidationStatusInvalid}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + res, err := p.ValidateFormat(context.Background(), p.Canonicalize(businessid.IdentifierInput{Value: tc.value})) + require.NoError(t, err) + assert.Equal(t, tc.wantStatus, res.Status, "reason=%s message=%s", res.ReasonCode, res.Message) + }) + } +} + +// TestWithCountryValidator verifies the escape hatch for injecting a +// custom register validator (used for non-EU codes or overrides). +func TestWithCountryValidator(t *testing.T) { + t.Parallel() + + // A custom XI (Northern Ireland) validator that accepts any + // registration matching "NI" + 6 digits, without checksum. + xiValidator := euid.NewRegisterValidator( + nil, + func(r string) (bool, string, string) { + if len(r) != 8 { + return false, businessid.ReasonInvalidLength, "XI reg must be NI + 6 digits" + } + if r[0] != 'N' || r[1] != 'I' { + return false, businessid.ReasonInvalidCharacters, "XI reg must start with NI" + } + for i := 2; i < 8; i++ { + if r[i] < '0' || r[i] > '9' { + return false, businessid.ReasonInvalidCharacters, "XI reg tail must be digits" + } + } + return true, businessid.ReasonOK, "" + }, + nil, + ) + + p := euid.New(euid.WithCountryValidator("XI", xiValidator)) + + cases := []struct { + name string + value string + wantStatus businessid.ValidationStatus + }{ + {name: "xi-custom-valid", value: "XICH.NI123456", wantStatus: businessid.ValidationStatusValid}, + {name: "xi-custom-invalid-length", value: "XICH.NI12345", wantStatus: businessid.ValidationStatusInvalid}, + {name: "xi-custom-invalid-prefix", value: "XICH.XX123456", wantStatus: businessid.ValidationStatusInvalid}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + res, err := p.ValidateFormat(context.Background(), p.Canonicalize(businessid.IdentifierInput{Value: tc.value})) + require.NoError(t, err) + assert.Equal(t, tc.wantStatus, res.Status, "reason=%s message=%s", res.ReasonCode, res.Message) + }) + } +} + +// TestValidateChecksumPerCountry covers the countries whose national +// register has a documented checksum algorithm. +func TestValidateChecksumPerCountry(t *testing.T) { + t.Parallel() + + p := euid.New() + + cases := []struct { + name string + value string + wantStatus businessid.ValidationStatus + }{ + // BE (mod-97), CZ (mod-11), DK (mod-11), EE (mod-11), ES CIF, FI (mod-11), + // FR SIREN Luhn, IT Luhn, LT (mod-11), LV, NL (mod-11), PT, RO, SE Luhn, + // SK IČO. + {name: "be-valid-checksum", value: "BEBCE.0417497106", wantStatus: businessid.ValidationStatusValid}, + {name: "be-invalid-checksum", value: "BEBCE.0417497100", wantStatus: businessid.ValidationStatusInvalid}, + + {name: "cz-valid-checksum", value: "CZOR.00006947", wantStatus: businessid.ValidationStatusValid}, + {name: "cz-invalid-checksum", value: "CZOR.00006940", wantStatus: businessid.ValidationStatusInvalid}, + + {name: "dk-valid-checksum", value: "DKCVR.61056416", wantStatus: businessid.ValidationStatusValid}, + {name: "dk-invalid-checksum", value: "DKCVR.61056410", wantStatus: businessid.ValidationStatusInvalid}, + + {name: "ee-valid-checksum", value: "EERIK.12345678", wantStatus: businessid.ValidationStatusValid}, + + {name: "es-valid-checksum", value: "ESRMC.A39000013", wantStatus: businessid.ValidationStatusValid}, + + {name: "fi-valid-checksum", value: "FIPRH.01120389", wantStatus: businessid.ValidationStatusValid}, + + {name: "fr-valid-luhn", value: "FRRCS.552100554", wantStatus: businessid.ValidationStatusValid}, + {name: "fr-invalid-luhn", value: "FRRCS.552100555", wantStatus: businessid.ValidationStatusInvalid}, + + {name: "it-valid-luhn", value: "ITRI.07973780013", wantStatus: businessid.ValidationStatusValid}, + + {name: "lt-valid-checksum", value: "LTJAR.100000006", wantStatus: businessid.ValidationStatusValid}, + + {name: "lv-valid-checksum", value: "LVURE.40003032949", wantStatus: businessid.ValidationStatusValid}, + + {name: "nl-valid-checksum", value: "NLKVK.68750110", wantStatus: businessid.ValidationStatusValid}, + + {name: "pt-valid-checksum", value: "PTRN.504499777", wantStatus: businessid.ValidationStatusValid}, + + {name: "ro-valid-checksum", value: "ROORCT.14186770", wantStatus: businessid.ValidationStatusValid}, + + {name: "se-valid-luhn", value: "SEBR.1234567897", wantStatus: businessid.ValidationStatusValid}, + + {name: "sk-valid-checksum", value: "SKOR.31333532", wantStatus: businessid.ValidationStatusValid}, + + // Countries with no documented checksum → Unsupported. + {name: "hr-unsupported", value: "HRMBS.08000001", wantStatus: businessid.ValidationStatusUnsupported}, + {name: "cy-unsupported", value: "CYHE.123456", wantStatus: businessid.ValidationStatusUnsupported}, + {name: "de-unsupported", value: "DEHRB.HAMBURG/B-12345", wantStatus: businessid.ValidationStatusUnsupported}, + {name: "el-unsupported", value: "ELGEMI.123456789000", wantStatus: businessid.ValidationStatusUnsupported}, + {name: "hu-unsupported", value: "HUCG.01-09-123456", wantStatus: businessid.ValidationStatusUnsupported}, + {name: "ie-unsupported", value: "IECRO.368047", wantStatus: businessid.ValidationStatusUnsupported}, + {name: "lu-unsupported", value: "LURCSL.B12345", wantStatus: businessid.ValidationStatusUnsupported}, + {name: "mt-unsupported", value: "MTMBR.C12345", wantStatus: businessid.ValidationStatusUnsupported}, + {name: "pl-unsupported", value: "PLKRS.0000123456", wantStatus: businessid.ValidationStatusUnsupported}, + {name: "si-unsupported", value: "SISRG.1234567", wantStatus: businessid.ValidationStatusUnsupported}, + + // Non-EU code → Unsupported (no native validator). + {name: "xi-unsupported", value: "XICH.NI123456", wantStatus: businessid.ValidationStatusUnsupported}, + {name: "gb-unsupported", value: "GBCH.12345678", wantStatus: businessid.ValidationStatusUnsupported}, + + // -- Coverage-driven additions --------------------------------- + + // BG EIK 9-digit valid checksum. + {name: "bg-valid-checksum", value: "BGEIK.040808527", wantStatus: businessid.ValidationStatusValid}, + {name: "bg-invalid-checksum", value: "BGEIK.040808520", wantStatus: businessid.ValidationStatusInvalid}, + + // ES DNI (digit prefix) and NIE (X/Y/Z prefix). + {name: "es-valid-dni", value: "ESRMC.12345678Z", wantStatus: businessid.ValidationStatusValid}, + {name: "es-valid-nie", value: "ESRMC.X0000001R", wantStatus: businessid.ValidationStatusValid}, + {name: "es-invalid-first-char", value: "ESRMC.!12345678", wantStatus: businessid.ValidationStatusInvalid}, + + // FI invalid (wrong check). + {name: "fi-invalid-checksum", value: "FIPRH.01120380", wantStatus: businessid.ValidationStatusInvalid}, + + // EE r==10 alt-weights path (constructed vector where sum1%11==10). + // digits 5,5,5,5,5,5,5,X where first-pass sum ≡ 10 mod 11. + {name: "ee-alt-path-invalid", value: "EERIK.10000015", wantStatus: businessid.ValidationStatusInvalid}, + + // IT invalid Luhn. + {name: "it-invalid-luhn", value: "ITRI.07973780010", wantStatus: businessid.ValidationStatusInvalid}, + + // SK invalid. + {name: "sk-invalid-checksum", value: "SKOR.31333530", wantStatus: businessid.ValidationStatusInvalid}, + + // SE invalid. + {name: "se-invalid-luhn", value: "SEBR.1234567890", wantStatus: businessid.ValidationStatusInvalid}, + + // NL invalid. + {name: "nl-invalid-checksum", value: "NLKVK.68750100", wantStatus: businessid.ValidationStatusInvalid}, + + // PT invalid. + {name: "pt-invalid-checksum", value: "PTRN.504499770", wantStatus: businessid.ValidationStatusInvalid}, + + // RO invalid. + {name: "ro-invalid-checksum", value: "ROORCT.14186771", wantStatus: businessid.ValidationStatusInvalid}, + + // LT invalid. + {name: "lt-invalid-checksum", value: "LTJAR.100000005", wantStatus: businessid.ValidationStatusInvalid}, + + // LV natural person (first digit ≤ 3). + {name: "lv-valid-natural", value: "LVURE.01010120001", wantStatus: businessid.ValidationStatusValid}, + + // EE invalid. + {name: "ee-invalid-checksum", value: "EERIK.12345670", wantStatus: businessid.ValidationStatusInvalid}, + + // EE alt-weights path (sum1 % 11 == 10 triggers alt weights). + {name: "ee-valid-alt", value: "EERIK.00000906", wantStatus: businessid.ValidationStatusValid}, + + // LT alt-weights path. + {name: "lt-valid-alt", value: "LTJAR.110000002", wantStatus: businessid.ValidationStatusValid}, + + // BG BULSTAT alt-alt path (both sums mod-11 == 10). + {name: "bg-valid-alt-alt", value: "BGEIK.605000000", wantStatus: businessid.ValidationStatusValid}, + + // CZ IČO cases r==0 and r==1. + {name: "cz-valid-r0", value: "CZOR.00000001", wantStatus: businessid.ValidationStatusValid}, + {name: "cz-valid-r1", value: "CZOR.00000060", wantStatus: businessid.ValidationStatusValid}, + {name: "cz-valid-r10", value: "CZOR.00000051", wantStatus: businessid.ValidationStatusValid}, + + // SK IČO cases r==0 and r==1 (same algorithm). + {name: "sk-valid-r0", value: "SKOR.00000001", wantStatus: businessid.ValidationStatusValid}, + + // ES CIF J-type (middle group, digit-only). + {name: "es-valid-cif-j", value: "ESRMC.J12345674", wantStatus: businessid.ValidationStatusValid}, + // ES CIF P-type (letter-only). + {name: "es-valid-cif-p", value: "ESRMC.P1234567D", wantStatus: businessid.ValidationStatusValid}, + + // FR invalid Luhn (via native SIREN validator). + {name: "fr-invalid-checksum", value: "FRRCS.552100550", wantStatus: businessid.ValidationStatusInvalid}, + + // DK invalid (first digit 0). + {name: "dk-invalid-first-zero", value: "DKCVR.01056416", wantStatus: businessid.ValidationStatusInvalid}, + + // NL invalid (r==10 explosion). + {name: "nl-invalid-r10", value: "NLKVK.04000000", wantStatus: businessid.ValidationStatusInvalid}, + + // RO min length (2 digits). + {name: "ro-valid-min", value: "ROORCT.19", wantStatus: businessid.ValidationStatusValid}, + + // PT S mod 11 < 2 → check == 0. + {name: "pt-valid-check-zero", value: "PTRN.000000000", wantStatus: businessid.ValidationStatusValid}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + res, err := p.ValidateChecksum(context.Background(), p.Canonicalize(businessid.IdentifierInput{Value: tc.value})) + require.NoError(t, err) + assert.Equal(t, tc.wantStatus, res.Status, "reason=%s message=%s", res.ReasonCode, res.Message) + }) + } +} diff --git a/providers/euid/fuzz_test.go b/providers/euid/fuzz_test.go new file mode 100644 index 0000000..11373a7 --- /dev/null +++ b/providers/euid/fuzz_test.go @@ -0,0 +1,40 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package euid_test + +import ( + "context" + "testing" + + "github.com/hyperscale-stack/businessid" + "github.com/hyperscale-stack/businessid/providers/euid" +) + +func FuzzValidateFormat(f *testing.F) { + seeds := []string{ + "", "FRRCS.552100554", "DEHRB.HAMBURG/B-12345", + "FR3102.552100554", "FRRCS.ABC", "IECRO.12+34", + "XX.", ".", "..", "\x00.\x00", "\xff\xff.\xff", + } + for _, s := range seeds { + f.Add(s) + } + + p := euid.New() + + f.Fuzz(func(t *testing.T, s string) { + in := p.Canonicalize(businessid.IdentifierInput{Value: s}) + + res, err := p.ValidateFormat(context.Background(), in) + if err == nil && res == nil { + t.Fatalf("ValidateFormat nil result with nil error, input=%q", s) + } + + cres, cerr := p.ValidateChecksum(context.Background(), in) + if cerr == nil && cres == nil { + t.Fatalf("ValidateChecksum nil result with nil error, input=%q", s) + } + }) +} diff --git a/providers/euid/nationals.go b/providers/euid/nationals.go new file mode 100644 index 0000000..9e9a427 --- /dev/null +++ b/providers/euid/nationals.go @@ -0,0 +1,780 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package euid + +import ( + "strings" + + "github.com/hyperscale-stack/businessid" +) + +// registerValidator holds the native rules for one country's national +// business register. All fields are optional — a nil field means "no +// national-level check at that step, treat as valid". +type registerValidator struct { + // canonicalize normalises the REGISTRATION segment before format / + // checksum checks (strip separators, apply casing). If nil the + // registration is used as-is. + canonicalize func(registration string) string + + // validateFormat checks the layout of the (canonicalized) REGISTRATION + // segment. Returns (ok, reason, message) where reason is one of + // businessid.ReasonInvalid*. + validateFormat func(registration string) (bool, string, string) + + // validateChecksum verifies the national checksum on the canonicalized + // REGISTRATION segment. nil means the register has no publicly + // documented checksum. + validateChecksum func(registration string) bool +} + +// NewRegisterValidator constructs a register-validator from user-provided +// functions. Pass nil for any step that should be skipped. This is the +// public entry point used by [WithCountryValidator] to inject validators +// for non-EU codes or overrides. +func NewRegisterValidator( + canonicalize func(registration string) string, + validateFormat func(registration string) (bool, string, string), + validateChecksum func(registration string) bool, +) registerValidator { //nolint:revive // deliberate: rv is opaque to callers + return registerValidator{ + canonicalize: canonicalize, + validateFormat: validateFormat, + validateChecksum: validateChecksum, + } +} + +// euidRegisterValidators binds each EU country to the native rules for +// its national business register. Sourced from BRIS (Regulation (EU) +// 2015/884), the national commercial register documentation, and +// cross-verified against the python-stdnum library where applicable. +// +// Non-EU codes (XI, GB, NO, IS, LI) are absent: BRIS does not cover them. +// Callers who need to validate EUIDs with those prefixes can inject +// custom validators via [WithSubValidator]. +var euidRegisterValidators = map[string]registerValidator{ + "AT": {canonicalize: stripSeparators, validateFormat: atRegisterFormat}, // Firmenbuchnummer: 1-6 digits + 1 letter + "BE": {canonicalize: stripSeparators, validateFormat: beRegisterFormat, validateChecksum: beRegisterChecksum}, // KBO: 10 digits + mod-97 + "BG": {canonicalize: stripSeparators, validateFormat: bgRegisterFormat, validateChecksum: bgRegisterChecksum}, // EIK: 9 or 13 digits + "HR": {canonicalize: stripSeparators, validateFormat: hrRegisterFormat}, // MBS: 8 digits + "CY": {canonicalize: stripSeparators, validateFormat: cyRegisterFormat}, // HE number: 6 digits + "CZ": {canonicalize: stripSeparators, validateFormat: czRegisterFormat, validateChecksum: czRegisterChecksum}, // IČO: 8 digits + mod 11 + "DE": {validateFormat: deRegisterFormat}, // Handelsregister: local court + type + number + "DK": {canonicalize: stripSeparators, validateFormat: dkRegisterFormat, validateChecksum: dkRegisterChecksum}, // CVR: 8 digits + mod 11 + "EE": {canonicalize: stripSeparators, validateFormat: eeRegisterFormat, validateChecksum: eeRegisterChecksum}, // Registrikood: 8 digits + mod 11 + "EL": {canonicalize: stripSeparators, validateFormat: elRegisterFormat}, // GEMI: 12 digits + "ES": {canonicalize: stripSeparators, validateFormat: esRegisterFormat, validateChecksum: esRegisterChecksum}, // NIF/CIF + "FI": {canonicalize: stripSeparators, validateFormat: fiRegisterFormat, validateChecksum: fiRegisterChecksum}, // Y-tunnus: 7 digits + check + "FR": {canonicalize: stripSeparators, validateFormat: frRegisterFormat, validateChecksum: frRegisterChecksum}, // SIREN: 9 digits + Luhn + "HU": {validateFormat: huRegisterFormat}, // Cégjegyzékszám: NN-NN-NNNNNN + "IE": {canonicalize: stripSeparators, validateFormat: ieRegisterFormat}, // CRO: 5-7 digits + "IT": {canonicalize: stripSeparators, validateFormat: itRegisterFormat, validateChecksum: itRegisterChecksum}, // Codice Fiscale entità: 11 digits + Luhn + "LT": {canonicalize: stripSeparators, validateFormat: ltRegisterFormat, validateChecksum: ltRegisterChecksum}, // 9 digits + mod 11 + "LU": {canonicalize: stripSeparators, validateFormat: luRegisterFormat}, // RCSL: B + digits + "LV": {canonicalize: stripSeparators, validateFormat: lvRegisterFormat, validateChecksum: lvRegisterChecksum}, // 11 digits + "MT": {canonicalize: stripSeparators, validateFormat: mtRegisterFormat}, // C + digits + "NL": {canonicalize: stripSeparators, validateFormat: nlRegisterFormat, validateChecksum: nlRegisterChecksum}, // KVK: 8 digits + mod 11 + "PL": {canonicalize: stripSeparators, validateFormat: plRegisterFormat}, // KRS: 10 digits + "PT": {canonicalize: stripSeparators, validateFormat: ptRegisterFormat, validateChecksum: ptRegisterChecksum}, // NIPC: 9 digits + mod 11 + "RO": {canonicalize: stripSeparators, validateFormat: roRegisterFormat, validateChecksum: roRegisterChecksum}, // CUI: 2-10 digits + mod 11 + "SE": {canonicalize: stripSeparators, validateFormat: seRegisterFormat, validateChecksum: seRegisterChecksum}, // Organisationsnummer: 10 digits + Luhn + "SI": {canonicalize: stripSeparators, validateFormat: siRegisterFormat}, // Matična številka: 7 digits + "SK": {canonicalize: stripSeparators, validateFormat: skRegisterFormat, validateChecksum: skRegisterChecksum}, // IČO: 8 digits + mod 11 +} + +// stripSeparators removes spaces, dashes and dots from a registration +// segment before format / checksum checks. Countries whose registration +// is a simple digit or letter run use this canonicalizer. +func stripSeparators(s string) string { + return businessid.StripSeparators(businessid.StripAllSpaces(s), ".", "-", "/") +} + +// ---------------------------------------------------------------------- +// AT — Firmenbuchnummer (justiz.gv.at/firmenbuch). +// Format: 1..6 digits + 1 upper-case letter (e.g. "123456A", "78J"). +// No publicly documented checksum for the letter suffix. +// ---------------------------------------------------------------------- + +func atRegisterFormat(r string) (bool, string, string) { + if len(r) < 2 || len(r) > 7 { + return false, businessid.ReasonInvalidLength, "AT Firmenbuchnummer must be 2-7 chars" + } + + digits := r[:len(r)-1] + letter := r[len(r)-1] + + if !businessid.IsAllDigits(digits) || letter < 'A' || letter > 'Z' { + return false, businessid.ReasonInvalidCharacters, "AT Firmenbuchnummer must be digits + one letter" + } + + return true, businessid.ReasonOK, "" +} + +// ---------------------------------------------------------------------- +// BE — BCE/KBO (kbopub.economie.fgov.be). +// 10 digits, first is 0 or 1. Checksum: 97 - (first_8 mod 97) == last_2. +// ---------------------------------------------------------------------- + +func beRegisterFormat(r string) (bool, string, string) { + if len(r) != 10 { + return false, businessid.ReasonInvalidLength, "BE KBO number must be 10 digits" + } + + if !businessid.IsAllDigits(r) || (r[0] != '0' && r[0] != '1') { + return false, businessid.ReasonInvalidCharacters, "BE KBO number must be 10 digits starting with 0 or 1" + } + + return true, businessid.ReasonOK, "" +} + +func beRegisterChecksum(r string) bool { + first8 := parseInt(r[:8]) + last2 := parseInt(r[8:]) + + return 97-first8%97 == last2 +} + +// ---------------------------------------------------------------------- +// BG — EIK / ЕИК (brra.bg). 9 digits (legal entity) or 13 digits (branch: +// 9-digit parent + 4-digit branch serial). Checksum on 9-digit parent +// only, mirrors VAT BG BULSTAT. +// ---------------------------------------------------------------------- + +func bgRegisterFormat(r string) (bool, string, string) { + if len(r) != 9 && len(r) != 13 { + return false, businessid.ReasonInvalidLength, "BG EIK must be 9 or 13 digits" + } + + if !businessid.IsAllDigits(r) { + return false, businessid.ReasonInvalidCharacters, "BG EIK must be all digits" + } + + return true, businessid.ReasonOK, "" +} + +func bgRegisterChecksum(r string) bool { return bgBulstat9Checksum(r[:9]) } + +func bgBulstat9Checksum(body string) bool { + weights := [8]int{1, 2, 3, 4, 5, 6, 7, 8} + sum := 0 + + for i := range 8 { + sum += int(body[i]-'0') * weights[i] + } + + check := sum % 11 + if check == 10 { + altWeights := [8]int{3, 4, 5, 6, 7, 8, 9, 10} + sum2 := 0 + + for i := range 8 { + sum2 += int(body[i]-'0') * altWeights[i] + } + + check = sum2 % 11 + if check == 10 { + check = 0 + } + } + + return check == int(body[8]-'0') +} + +// ---------------------------------------------------------------------- +// HR — MBS (sudreg.pravosudje.hr). 8 digits, no publicly documented checksum. +// ---------------------------------------------------------------------- + +func hrRegisterFormat(r string) (bool, string, string) { return fixedDigitsFormat(r, 8, "HR MBS") } + +// ---------------------------------------------------------------------- +// CY — HE number (efiling.drcor.mcit.gov.cy). 6 digits (leading zeros +// significant). No standardized checksum. +// ---------------------------------------------------------------------- + +func cyRegisterFormat(r string) (bool, string, string) { + return fixedDigitsFormat(r, 6, "CY HE number") +} + +// ---------------------------------------------------------------------- +// CZ — IČO (or.justice.cz). 8 digits + weighted mod-11 checksum. +// Source: Zákon č. 227/2000 Sb. and python-stdnum stdnum.cz.dic. +// ---------------------------------------------------------------------- + +func czRegisterFormat(r string) (bool, string, string) { return fixedDigitsFormat(r, 8, "CZ IČO") } + +func czRegisterChecksum(r string) bool { return icoMod11(r) } + +// icoMod11 implements the shared IČO / IČ DPH mod-11 checksum used by +// CZ and SK 8-digit business identifiers. +func icoMod11(body string) bool { + weights := [7]int{8, 7, 6, 5, 4, 3, 2} + sum := 0 + + for i := range 7 { + sum += int(body[i]-'0') * weights[i] + } + + r := sum % 11 + + var check int + + switch r { + case 0: + check = 1 + case 1: + check = 0 + case 10: + check = 1 + default: + check = 11 - r + } + + return check == int(body[7]-'0') +} + +// ---------------------------------------------------------------------- +// DE — Handelsregister (unternehmensregister.de). The registration +// segment carries the local court, register type (HRA/HRB/GnR/VR/PR) +// and entry number, separated by "/" and "-". Format is loose because +// BRIS allows a broad free-form value; we require at least one +// alphanumeric character. +// ---------------------------------------------------------------------- + +func deRegisterFormat(r string) (bool, string, string) { + hasAlnum := false + + for i := range len(r) { + c := r[i] + if (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') { + hasAlnum = true + + break + } + } + + if !hasAlnum { + return false, businessid.ReasonInvalidCharacters, "DE Handelsregister must contain at least one alphanumeric character" + } + + return true, businessid.ReasonOK, "" +} + +// ---------------------------------------------------------------------- +// DK — CVR (cvr.dk). 8 digits, first ≠ 0. Weighted mod-11. +// ---------------------------------------------------------------------- + +func dkRegisterFormat(r string) (bool, string, string) { return fixedDigitsFormat(r, 8, "DK CVR") } + +func dkRegisterChecksum(r string) bool { + if r[0] == '0' { + return false + } + + weights := [8]int{2, 7, 6, 5, 4, 3, 2, 1} + sum := 0 + + for i := range 8 { + sum += int(r[i]-'0') * weights[i] + } + + return sum%11 == 0 +} + +// ---------------------------------------------------------------------- +// EE — Registrikood (ariregister.rik.ee). 8 digits, weighted mod-11. +// ---------------------------------------------------------------------- + +func eeRegisterFormat(r string) (bool, string, string) { + return fixedDigitsFormat(r, 8, "EE Registrikood") +} + +func eeRegisterChecksum(r string) bool { + weights := [7]int{1, 2, 3, 4, 5, 6, 7} + sum := 0 + + for i := range 7 { + sum += int(r[i]-'0') * weights[i] + } + + check := sum % 11 + if check == 10 { + alt := [7]int{3, 4, 5, 6, 7, 8, 9} + sum2 := 0 + + for i := range 7 { + sum2 += int(r[i]-'0') * alt[i] + } + + check = sum2 % 11 % 10 + } + + return check == int(r[7]-'0') +} + +// ---------------------------------------------------------------------- +// EL — GEMI (businessregistry.gr). 12 digits, no standardized checksum. +// ---------------------------------------------------------------------- + +func elRegisterFormat(r string) (bool, string, string) { return fixedDigitsFormat(r, 12, "EL GEMI") } + +// ---------------------------------------------------------------------- +// ES — NIF/CIF (agenciatributaria.es). Same format as VAT ES CIF. +// ---------------------------------------------------------------------- + +func esRegisterFormat(r string) (bool, string, string) { + if len(r) != 9 { + return false, businessid.ReasonInvalidLength, "ES NIF/CIF must be 9 characters" + } + + if !isAlnumUpperByte(r[0]) || !businessid.IsAllDigits(r[1:8]) || !isAlnumUpperByte(r[8]) { + return false, businessid.ReasonInvalidCharacters, "ES NIF/CIF must be alnum + 7 digits + alnum" + } + + return true, businessid.ReasonOK, "" +} + +func esRegisterChecksum(r string) bool { return esCIFOrDNIChecksum(r) } + +// ---------------------------------------------------------------------- +// FI — Y-tunnus (prh.fi). 7 digits + 1 check digit (may be joined by "-"). +// Weighted mod-11: 7-9-10-5-8-4-2. +// ---------------------------------------------------------------------- + +func fiRegisterFormat(r string) (bool, string, string) { + if len(r) != 8 { + return false, businessid.ReasonInvalidLength, "FI Y-tunnus must be 8 digits" + } + + if !businessid.IsAllDigits(r) { + return false, businessid.ReasonInvalidCharacters, "FI Y-tunnus must be all digits" + } + + return true, businessid.ReasonOK, "" +} + +func fiRegisterChecksum(r string) bool { + weights := [7]int{7, 9, 10, 5, 8, 4, 2} + sum := 0 + + for i := range 7 { + sum += int(r[i]-'0') * weights[i] + } + + m := sum % 11 + if m == 1 { + return false + } + + check := 0 + if m > 1 { + check = 11 - m + } + + return check == int(r[7]-'0') +} + +// ---------------------------------------------------------------------- +// FR — SIREN (infogreffe.fr). 9 digits + Luhn. +// ---------------------------------------------------------------------- + +func frRegisterFormat(r string) (bool, string, string) { return fixedDigitsFormat(r, 9, "FR SIREN") } + +func frRegisterChecksum(r string) bool { return businessid.Luhn(r) } + +// ---------------------------------------------------------------------- +// HU — Cégjegyzékszám (e-cegjegyzek.hu). Format: 2 digits (region) + "-" +// + 2 digits (type) + "-" + 6 digits (serial). We accept both with and +// without dashes (BRIS transmits often un-hyphenated). +// ---------------------------------------------------------------------- + +func huRegisterFormat(r string) (bool, string, string) { + compact := stripSeparators(r) + if len(compact) != 10 { + return false, businessid.ReasonInvalidLength, "HU Cégjegyzékszám must be 10 digits total (RR-TT-NNNNNN)" + } + + if !businessid.IsAllDigits(compact) { + return false, businessid.ReasonInvalidCharacters, "HU Cégjegyzékszám must be digits (and optional dashes)" + } + + return true, businessid.ReasonOK, "" +} + +// ---------------------------------------------------------------------- +// IE — CRO number (cro.ie). 5-7 digits (historical range). +// ---------------------------------------------------------------------- + +func ieRegisterFormat(r string) (bool, string, string) { + if len(r) < 5 || len(r) > 7 { + return false, businessid.ReasonInvalidLength, "IE CRO number must be 5-7 digits" + } + + if !businessid.IsAllDigits(r) { + return false, businessid.ReasonInvalidCharacters, "IE CRO number must be all digits" + } + + return true, businessid.ReasonOK, "" +} + +// ---------------------------------------------------------------------- +// IT — Codice Fiscale entità (registroimprese.it). 11 digits + Luhn. +// ---------------------------------------------------------------------- + +func itRegisterFormat(r string) (bool, string, string) { + return fixedDigitsFormat(r, 11, "IT Codice Fiscale") +} + +func itRegisterChecksum(r string) bool { return businessid.Luhn(r) } + +// ---------------------------------------------------------------------- +// LT — Juridinio asmens kodas (registrucentras.lt). 9 digits, mod 11. +// ---------------------------------------------------------------------- + +func ltRegisterFormat(r string) (bool, string, string) { + return fixedDigitsFormat(r, 9, "LT juridinio asmens kodas") +} + +func ltRegisterChecksum(r string) bool { + weights1 := [9]int{1, 2, 3, 4, 5, 6, 7, 8, 9} + sum := 0 + + for i := range 9 { + sum += int(r[i]-'0') * weights1[i] + } + + m := sum % 11 + if m == 10 { + weights2 := [9]int{3, 4, 5, 6, 7, 8, 9, 1, 2} + sum2 := 0 + + for i := range 9 { + sum2 += int(r[i]-'0') * weights2[i] + } + + m = sum2 % 11 % 10 + } + + return m == 0 +} + +// ---------------------------------------------------------------------- +// LU — RCS Luxembourg (lbr.lu). Format: "B" + 4-6 digits. +// ---------------------------------------------------------------------- + +func luRegisterFormat(r string) (bool, string, string) { + if len(r) < 5 || len(r) > 7 { + return false, businessid.ReasonInvalidLength, "LU RCSL must be B + 4-6 digits" + } + + if r[0] != 'B' || !businessid.IsAllDigits(r[1:]) { + return false, businessid.ReasonInvalidCharacters, "LU RCSL must be B followed by digits" + } + + return true, businessid.ReasonOK, "" +} + +// ---------------------------------------------------------------------- +// LV — Reģistrācijas numurs (ur.gov.lv). 11 digits, mod-11 weighted. +// ---------------------------------------------------------------------- + +func lvRegisterFormat(r string) (bool, string, string) { + return fixedDigitsFormat(r, 11, "LV registrācijas numurs") +} + +func lvRegisterChecksum(r string) bool { + if r[0] > '3' { + // Legal entity — mirrors VAT LV algorithm. + weights := [10]int{9, 1, 4, 8, 3, 10, 2, 5, 7, 6} + sum := 0 + + for i := range 10 { + sum += int(r[i]-'0') * weights[i] + } + + check := ((3-sum)%11 + 11) % 11 + if check == 10 { + return false + } + + return check == int(r[10]-'0') + } + + // Natural person — personal code with DDMMYY + century + serial + check. + // Algorithm: weights = [10,5,8,4,2,1,6,3,7,9], check = (1101 - S) mod 11 mod 10. + weights := [10]int{10, 5, 8, 4, 2, 1, 6, 3, 7, 9} + sum := 0 + + for i := range 10 { + sum += int(r[i]-'0') * weights[i] + } + + check := (1101 - sum) % 11 % 10 + + return check == int(r[10]-'0') +} + +// ---------------------------------------------------------------------- +// MT — Company Registration Number (mbr.mt). Format: "C" + 4-6 digits. +// ---------------------------------------------------------------------- + +func mtRegisterFormat(r string) (bool, string, string) { + if len(r) < 5 || len(r) > 7 { + return false, businessid.ReasonInvalidLength, "MT company number must be C + 4-6 digits" + } + + if r[0] != 'C' || !businessid.IsAllDigits(r[1:]) { + return false, businessid.ReasonInvalidCharacters, "MT company number must be C followed by digits" + } + + return true, businessid.ReasonOK, "" +} + +// ---------------------------------------------------------------------- +// NL — KVK-nummer (kvk.nl). 8 digits + weighted mod-11 checksum. +// ---------------------------------------------------------------------- + +func nlRegisterFormat(r string) (bool, string, string) { + return fixedDigitsFormat(r, 8, "NL KVK nummer") +} + +func nlRegisterChecksum(r string) bool { + weights := [7]int{8, 7, 6, 5, 4, 3, 2} + sum := 0 + + for i := range 7 { + sum += int(r[i]-'0') * weights[i] + } + + m := sum % 11 + if m == 10 { + return false + } + + // Some KVK numbers have their check at position 7 = 0 when m == 0. + return m == int(r[7]-'0') +} + +// ---------------------------------------------------------------------- +// PL — KRS (krs.ms.gov.pl). 10 digits, leading zeros significant. +// No publicly documented checksum for KRS itself. +// ---------------------------------------------------------------------- + +func plRegisterFormat(r string) (bool, string, string) { return fixedDigitsFormat(r, 10, "PL KRS") } + +// ---------------------------------------------------------------------- +// PT — NIPC (portaldocidadao.pt). 9 digits, mod-11 weighted. +// ---------------------------------------------------------------------- + +func ptRegisterFormat(r string) (bool, string, string) { return fixedDigitsFormat(r, 9, "PT NIPC") } + +func ptRegisterChecksum(r string) bool { + weights := [8]int{9, 8, 7, 6, 5, 4, 3, 2} + sum := 0 + + for i := range 8 { + sum += int(r[i]-'0') * weights[i] + } + + m := sum % 11 + check := 0 + + if m >= 2 { + check = 11 - m + } + + return check == int(r[8]-'0') +} + +// ---------------------------------------------------------------------- +// RO — CUI (onrc.ro). 2-10 digits, mod-11 weighted with right-aligned weights. +// ---------------------------------------------------------------------- + +func roRegisterFormat(r string) (bool, string, string) { + if len(r) < 2 || len(r) > 10 { + return false, businessid.ReasonInvalidLength, "RO CUI must be 2-10 digits" + } + + if !businessid.IsAllDigits(r) { + return false, businessid.ReasonInvalidCharacters, "RO CUI must be all digits" + } + + return true, businessid.ReasonOK, "" +} + +func roRegisterChecksum(r string) bool { + weights := [9]int{7, 5, 3, 2, 1, 7, 5, 3, 2} + n := len(r) + offset := 9 - (n - 1) + + if offset < 0 || offset > 9 { + return false + } + + sum := 0 + + for i := range n - 1 { + sum += int(r[i]-'0') * weights[offset+i] + } + + check := sum * 10 % 11 % 10 + + return check == int(r[n-1]-'0') +} + +// ---------------------------------------------------------------------- +// SE — Organisationsnummer (bolagsverket.se). 10 digits + Luhn. +// ---------------------------------------------------------------------- + +func seRegisterFormat(r string) (bool, string, string) { + return fixedDigitsFormat(r, 10, "SE organisationsnummer") +} + +func seRegisterChecksum(r string) bool { return businessid.Luhn(r) } + +// ---------------------------------------------------------------------- +// SI — Matična številka (ejn.gov.si). 7 digits, no standardized checksum +// (some sources cite mod-11 but implementations differ; kept format-only). +// ---------------------------------------------------------------------- + +func siRegisterFormat(r string) (bool, string, string) { + return fixedDigitsFormat(r, 7, "SI matična številka") +} + +// ---------------------------------------------------------------------- +// SK — IČO (orsr.sk). 8 digits + mod-11 (same algorithm as CZ IČO). +// ---------------------------------------------------------------------- + +func skRegisterFormat(r string) (bool, string, string) { return fixedDigitsFormat(r, 8, "SK IČO") } + +func skRegisterChecksum(r string) bool { return icoMod11(r) } + +// ---------------------------------------------------------------------- +// Shared helpers. +// ---------------------------------------------------------------------- + +// fixedDigitsFormat is the common "N digits, all digits" format check. +func fixedDigitsFormat(r string, n int, name string) (bool, string, string) { + if len(r) != n { + return false, businessid.ReasonInvalidLength, name + " must be " + itoa(n) + " digits" + } + + if !businessid.IsAllDigits(r) { + return false, businessid.ReasonInvalidCharacters, name + " must be all digits" + } + + return true, businessid.ReasonOK, "" +} + +// isAlnumUpperByte reports whether b is an upper-case ASCII letter or digit. +func isAlnumUpperByte(b byte) bool { + return (b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') +} + +// parseInt parses an all-digit string as int. Caller must ensure content. +func parseInt(s string) int { + n := 0 + + for i := range len(s) { + n = n*10 + int(s[i]-'0') + } + + return n +} + +// itoa converts a small non-negative int to decimal (used only in messages). +func itoa(n int) string { + if n == 0 { + return "0" + } + + var buf [10]byte + + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + + return string(buf[i:]) +} + +// esCIFOrDNIChecksum validates ES NIF (DNI/NIE/CIF) using the same +// algorithm as the VAT ES layer. Duplicated to keep euid independent of +// the vat package. Source: python-stdnum stdnum.es.cif and stdnum.es.dni. +func esCIFOrDNIChecksum(body string) bool { + switch { + case body[0] >= '0' && body[0] <= '9': + return esDNI(body) + case body[0] == 'X' || body[0] == 'Y' || body[0] == 'Z': + return esNIE(body) + case body[0] >= 'A' && body[0] <= 'Z': + return esCIF(body) + } + + return false +} + +const esDNILetters = "TRWAGMYFPDXBNJZSQVHLCKE" + +func esDNI(body string) bool { + if !businessid.IsAllDigits(body[:8]) { + return false + } + + n := parseInt(body[:8]) + + return body[8] == esDNILetters[n%23] +} + +func esNIE(body string) bool { + if !businessid.IsAllDigits(body[1:8]) { + return false + } + + prefix := strings.IndexByte("XYZ", body[0]) + n := prefix*10_000_000 + parseInt(body[1:8]) + + return body[8] == esDNILetters[n%23] +} + +func esCIF(body string) bool { + if !businessid.IsAllDigits(body[1:8]) { + return false + } + + sum := 0 + + for i := 1; i <= 7; i++ { + d := int(body[i] - '0') + if i%2 == 1 { + doubled := 2 * d + sum += doubled/10 + doubled%10 + } else { + sum += d + } + } + + check := (10 - sum%10) % 10 + + first := body[0] + last := body[8] + + digitCheck := byte('0' + check) + letterCheck := "JABCDEFGHI"[check] + + switch first { + case 'A', 'B', 'E', 'H': + return last == digitCheck + case 'P', 'Q', 'R', 'S', 'W': + return last == letterCheck + default: + // Strict rule: for the "either" middle group (C,D,F,G,J,L,M,N,U,V) + // domestic entities use digit check. Foreign CIFs (rare) use + // letter; callers who need that must use WithSubValidator with + // a permissive override. + return last == digitCheck + } +} diff --git a/providers/euid/nationals_internal_test.go b/providers/euid/nationals_internal_test.go new file mode 100644 index 0000000..fb6fbd1 --- /dev/null +++ b/providers/euid/nationals_internal_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package euid + +import "testing" + +// TestNationalsDefensiveBranches exercises helper branches that are +// unreachable via the public EUID validator (they're gated by the BRIS +// charset / meta-format checks) so they remain safe under refactors. +func TestNationalsDefensiveBranches(t *testing.T) { + t.Parallel() + + // esDNI: rejects a body whose first 8 bytes are not all digits. + if esDNI("1234567AZ") { + t.Errorf("esDNI should reject non-digit body") + } + + // esNIE: rejects a body whose middle 7 bytes are not all digits. + if esNIE("X123456AZ") { + t.Errorf("esNIE should reject non-digit middle") + } + // esNIE: Y prefix and Z prefix branches. + if !esNIE("Y0000000Z") { + t.Errorf("esNIE Y-prefix should validate") + } + if !esNIE("Z0000000M") { + t.Errorf("esNIE Z-prefix should validate") + } + + // esCIF: rejects a body whose middle 7 bytes are not all digits. + if esCIF("A123456AZ") { + t.Errorf("esCIF should reject non-digit middle") + } + + // esCIFOrDNIChecksum: rejects an unclassified first character. + if esCIFOrDNIChecksum("!12345678") { + t.Errorf("esCIFOrDNIChecksum should reject unknown first char") + } + + // bgBulstat9Checksum alt-alt path: both sums yield 10 → check=0. + // "605000000" was constructed to hit this exact branch. + if !bgBulstat9Checksum("605000000") { + t.Errorf("bgBulstat9Checksum should validate 605000000 via alt-alt path") + } + // Invalid vector where check != d[8]. + if bgBulstat9Checksum("000000550") { + t.Errorf("bgBulstat9Checksum should reject wrong check digit") + } + + // icoMod11 default case where r==10 → check=1. + if !icoMod11("00000051") { + t.Errorf("icoMod11 should validate 00000051 (r==10 branch)") + } + + // dkRegisterChecksum defensive: first digit == 0 returns false. + if dkRegisterChecksum("01056416") { + t.Errorf("dkRegisterChecksum should reject first digit 0") + } + + // itoa(0) branch. + if got := itoa(0); got != "0" { + t.Errorf("itoa(0) = %q, want 0", got) + } + + // fiRegisterChecksum r == 1 branch. + if fiRegisterChecksum("80000000") { + t.Errorf("fiRegisterChecksum r==1 should return false") + } + + // nlRegisterChecksum r == 10 branch (7-digit weights version). + if nlRegisterChecksum("03000000") { + t.Errorf("nlRegisterChecksum r==10 should return false") + } + + // lvRegisterChecksum legal check==10 branch. + if lvRegisterChecksum("90000000000") { + t.Errorf("lvRegisterChecksum legal check==10 should return false") + } + + // roRegisterChecksum defensive offset check (unreachable in practice). + if roRegisterChecksum("") { + t.Errorf("roRegisterChecksum empty should return false") + } + + // esRegisterFormat empty non-alnum first character branch. + if ok, _, _ := esRegisterFormat("/12345678"); ok { + t.Errorf("esRegisterFormat should reject non-alnum first char") + } +} diff --git a/providers/siren/derogations.go b/providers/siren/derogations.go new file mode 100644 index 0000000..399c9bc --- /dev/null +++ b/providers/siren/derogations.go @@ -0,0 +1,18 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package siren + +// knownSIRENDerogations returns a fresh copy of the built-in table of +// non-Luhn SIREN rules. +// +// La Poste's SIREN (356000000) is Luhn-valid on its own, so no dérogation +// is needed at SIREN level. Callers who encounter historical SIRENs that +// pre-date Luhn adoption can inject rules via [WithDerogation]. +// +// The table is returned as a copy so mutation by callers cannot leak +// across providers. +func knownSIRENDerogations() map[string]DerogationRule { + return map[string]DerogationRule{} +} diff --git a/providers/siren/fuzz_test.go b/providers/siren/fuzz_test.go new file mode 100644 index 0000000..2cb5be6 --- /dev/null +++ b/providers/siren/fuzz_test.go @@ -0,0 +1,39 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package siren_test + +import ( + "context" + "testing" + + "github.com/hyperscale-stack/businessid" + "github.com/hyperscale-stack/businessid/providers/siren" +) + +func FuzzValidateFormat(f *testing.F) { + seeds := []string{ + "", "552100554", "552 100 554", "552-100-554", "000000000", + "12345678", "1234567890", "12345678A", "\x00", "\xff\xff", + } + for _, s := range seeds { + f.Add(s) + } + + p := siren.New() + + f.Fuzz(func(t *testing.T, s string) { + in := p.Canonicalize(businessid.IdentifierInput{Value: s}) + + res, err := p.ValidateFormat(context.Background(), in) + if err == nil && res == nil { + t.Fatalf("ValidateFormat nil result with nil error, input=%q", s) + } + + cres, cerr := p.ValidateChecksum(context.Background(), in) + if cerr == nil && cres == nil { + t.Fatalf("ValidateChecksum nil result with nil error, input=%q", s) + } + }) +} diff --git a/providers/siren/siren.go b/providers/siren/siren.go index b8e6bc5..a04ff97 100644 --- a/providers/siren/siren.go +++ b/providers/siren/siren.go @@ -5,7 +5,8 @@ // Package siren validates the French SIREN business identifier. // // A SIREN is exactly 9 digits and validates against the Luhn (mod-10) -// checksum. +// checksum. Callers can register additional non-Luhn dérogations (for +// historical entities that pre-date Luhn adoption) via [WithDerogation]. package siren import ( @@ -19,11 +20,42 @@ const ( msgEmpty = "empty value" ) +// DerogationRule reports whether a canonical (digit-only) SIREN satisfies a +// non-Luhn national rule. It is only consulted when Luhn itself fails. +type DerogationRule func(canonical string) bool + +// Option configures a Provider at construction time. +type Option func(*Provider) + +// WithDerogation registers a non-Luhn rule for the given SIREN prefix (the +// full 9-digit SIREN). When Luhn fails on a value whose SIREN matches, the +// registered rule is consulted. Multiple calls compose; the last one wins +// for a given prefix. +func WithDerogation(siren string, rule DerogationRule) Option { + return func(p *Provider) { + if p.derogations == nil { + p.derogations = map[string]DerogationRule{} + } + + p.derogations[siren] = rule + } +} + // Provider validates SIREN numbers. -type Provider struct{} +type Provider struct { + derogations map[string]DerogationRule +} -// New returns a new SIREN provider. -func New() *Provider { return &Provider{} } +// New returns a new SIREN provider. See [Option] for available options. +func New(opts ...Option) *Provider { + p := &Provider{derogations: knownSIRENDerogations()} + + for _, opt := range opts { + opt(p) + } + + return p +} // Kind implements [businessid.Provider]. func (Provider) Kind() businessid.IdentifierKind { return businessid.IdentifierKindSIREN } @@ -101,10 +133,10 @@ func (p Provider) ValidateChecksum(_ context.Context, input businessid.Identifie return res, nil } - if !businessid.Luhn(input.Value) { + if !p.isValidChecksum(input.Value) { res.Status = businessid.ValidationStatusInvalid res.ReasonCode = businessid.ReasonInvalidChecksum - res.Message = "SIREN Luhn check failed" + res.Message = "SIREN checksum failed" return res, nil } @@ -114,3 +146,18 @@ func (p Provider) ValidateChecksum(_ context.Context, input businessid.Identifie return res, nil } + +// isValidChecksum accepts a SIREN whose 9-digit body satisfies Luhn, or a +// registered dérogation rule for the matching SIREN prefix. Callers must +// have already verified length and digit-only shape. +func (p Provider) isValidChecksum(s string) bool { + if businessid.Luhn(s) { + return true + } + + if rule, ok := p.derogations[s]; ok { + return rule(s) + } + + return false +} diff --git a/providers/siren/siren_internal_test.go b/providers/siren/siren_internal_test.go new file mode 100644 index 0000000..f25077d --- /dev/null +++ b/providers/siren/siren_internal_test.go @@ -0,0 +1,26 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package siren + +import "testing" + +// TestWithDerogationOnNilMap covers the defensive nil-check inside +// WithDerogation. In practice New always seeds the map, so the branch +// is only reachable through direct construction. +func TestWithDerogationOnNilMap(t *testing.T) { + t.Parallel() + + p := &Provider{} // derogations left nil intentionally + opt := WithDerogation("123456789", func(string) bool { return true }) + opt(p) + + if p.derogations == nil { + t.Fatal("WithDerogation should have allocated the derogations map") + } + + if _, ok := p.derogations["123456789"]; !ok { + t.Fatal("WithDerogation did not register the rule") + } +} diff --git a/providers/siren/siren_test.go b/providers/siren/siren_test.go index 0767a8c..73eac8a 100644 --- a/providers/siren/siren_test.go +++ b/providers/siren/siren_test.go @@ -65,6 +65,9 @@ func TestValidateFormat(t *testing.T) { {name: "too-short", value: "12345678", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, {name: "too-long", value: "1234567890", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, {name: "letters", value: "12345678A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + {name: "underscore", value: "12345678_", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + {name: "plus-sign", value: "12345678+", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + {name: "way-too-long", value: "12345678901234", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, {name: "digits-only", value: "552100554", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, {name: "leading-zero", value: "000000018", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, } @@ -93,9 +96,25 @@ func TestValidateChecksum(t *testing.T) { wantStatus businessid.ValidationStatus wantReason string }{ + // Well-known Luhn-valid SIRENs sourced from company legal imprints. + // All values verified via https://annuaire-entreprises.data.gouv.fr/. {name: "known-valid-danone", value: "732 829 320", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, {name: "known-valid-lvmh", value: "552-100-554", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "known-valid-loreal", value: "632012100", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "known-valid-totalenergies", value: "542 051 180", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "known-valid-bnp-paribas", value: "662042449", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "known-valid-renault", value: "441.639.465", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "known-valid-michelin", value: "855200507", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + // La Poste. Note the SIREN itself is Luhn-valid; only some La Poste + // SIRETs need the divisible-by-5 exception (see providers/siret). + {name: "known-valid-la-poste", value: "356000000", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + // Économat des Armées SIREN (from annuaire-entreprises.data.gouv.fr). + // Historically cited as dérogatoire but its SIREN passes Luhn. + {name: "known-valid-economat-armees", value: "542085907", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "known-invalid", value: "732829321", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidChecksum}, + {name: "known-invalid-lvmh-off-by-one", value: "552100555", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidChecksum}, + {name: "all-zeros-valid", value: "000000000", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, {name: "leading-zero-valid", value: "000000018", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, {name: "empty", value: "", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonEmpty}, } @@ -112,3 +131,39 @@ func TestValidateChecksum(t *testing.T) { }) } } + +// TestValidateChecksumWithDerogation exercises the WithDerogation option +// on the SIREN provider (mirrors the SIRET test). +func TestValidateChecksumWithDerogation(t *testing.T) { + t.Parallel() + + // Fake rule that accepts a SIREN whose digit sum is divisible by 7. + divisibleBy7 := func(s string) bool { + sum := 0 + for i := range len(s) { + sum += int(s[i] - '0') + } + + return sum%7 == 0 + } + + p := siren.New(siren.WithDerogation("999999999", divisibleBy7)) + + // digit sum = 81, not divisible by 7 → rejected by rule. + // But Luhn fails too (999999999 has Luhn sum 45 mod 10 = 5). + // The rule is only consulted when Luhn fails. + res, err := p.ValidateChecksum( + context.Background(), + p.Canonicalize(businessid.IdentifierInput{Value: "999999999"}), + ) + require.NoError(t, err) + assert.Equal(t, businessid.ValidationStatusInvalid, res.Status) + + // Standard SIREN (Luhn valid) is unaffected by the derogation. + res2, err := p.ValidateChecksum( + context.Background(), + p.Canonicalize(businessid.IdentifierInput{Value: "552100554"}), + ) + require.NoError(t, err) + assert.Equal(t, businessid.ValidationStatusValid, res2.Status) +} diff --git a/providers/siret/derogations.go b/providers/siret/derogations.go new file mode 100644 index 0000000..68d470a --- /dev/null +++ b/providers/siret/derogations.go @@ -0,0 +1,34 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package siret + +const laPosteSIREN = "356000000" + +// knownSIRETDerogations returns a fresh copy of the built-in table of +// non-Luhn SIRET rules. +// +// La Poste (SIREN 356000000): the INSEE dérogatoire rule accepts a SIRET +// whose 14 digits sum to a multiple of 5. Historical background: La Poste +// operates more than 17 000 establishments and would have exhausted +// Luhn-valid NICs; INSEE granted this exception so that new establishments +// can continue to be numbered. +// +// Économat des Armées (SIREN 542085907, per annuaire-entreprises.data.gouv.fr): +// this historically-cited dérogatoire entity has a SIREN that in fact +// passes standard Luhn (verified 2026-07). No special handling is needed; +// the reference is kept here for future auditors who encounter the same +// legend. +// +// The table is returned as a copy so mutation by callers cannot leak +// across providers. +func knownSIRETDerogations() map[string]DerogationRule { + return map[string]DerogationRule{ + laPosteSIREN: laPosteRule, + } +} + +// laPosteRule reports whether s satisfies La Poste's divisible-by-5 sum +// rule. Callers must have already verified digit-only shape and length. +func laPosteRule(s string) bool { return digitSum(s)%5 == 0 } diff --git a/providers/siret/fuzz_test.go b/providers/siret/fuzz_test.go new file mode 100644 index 0000000..cc8e036 --- /dev/null +++ b/providers/siret/fuzz_test.go @@ -0,0 +1,39 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package siret_test + +import ( + "context" + "testing" + + "github.com/hyperscale-stack/businessid" + "github.com/hyperscale-stack/businessid/providers/siret" +) + +func FuzzValidateFormat(f *testing.F) { + seeds := []string{ + "", "73282932000074", "35600000000060", "55210055400013", + "73282932000000", "1234567890123", "1234567890123A", "\x00", "\xff\xff", + } + for _, s := range seeds { + f.Add(s) + } + + p := siret.New() + + f.Fuzz(func(t *testing.T, s string) { + in := p.Canonicalize(businessid.IdentifierInput{Value: s}) + + res, err := p.ValidateFormat(context.Background(), in) + if err == nil && res == nil { + t.Fatalf("ValidateFormat nil result with nil error, input=%q", s) + } + + cres, cerr := p.ValidateChecksum(context.Background(), in) + if cerr == nil && cres == nil { + t.Fatalf("ValidateChecksum nil result with nil error, input=%q", s) + } + }) +} diff --git a/providers/siret/siret.go b/providers/siret/siret.go index 31159dd..2d051a8 100644 --- a/providers/siret/siret.go +++ b/providers/siret/siret.go @@ -5,8 +5,14 @@ // Package siret validates the French SIRET establishment identifier. // // A SIRET is exactly 14 digits (SIREN + 5-digit NIC) and normally validates -// against the Luhn (mod-10) checksum. The La Poste exception (SIREN 356000000) -// is not handled in V1 and returns invalid checksum. +// against the Luhn (mod-10) checksum. The NIC 00000 is reserved by INSEE +// and never attributed, so it is rejected at the format level. +// +// Establishments of La Poste (SIREN 356000000) may instead satisfy INSEE's +// dérogatoire rule where the plain sum of the 14 digits is divisible by 5; +// this validator accepts a La Poste SIRET when either check passes. +// Additional non-Luhn rules for other historical SIRENs can be registered +// via [WithDerogation]. package siret import ( @@ -16,15 +22,47 @@ import ( ) const ( - length = 14 - msgEmpty = "empty value" + length = 14 + sirenLength = 9 + reservedNIC = "00000" + msgEmpty = "empty value" ) +// DerogationRule reports whether a canonical (digit-only) SIRET satisfies a +// non-Luhn national rule. It is only consulted when Luhn itself fails. +type DerogationRule func(canonical string) bool + +// Option configures a Provider at construction time. +type Option func(*Provider) + +// WithDerogation registers a non-Luhn rule for SIRETs whose 9-digit SIREN +// prefix matches the given value. When Luhn fails, the registered rule is +// consulted. Multiple calls compose; the last one wins for a given prefix. +func WithDerogation(siren string, rule DerogationRule) Option { + return func(p *Provider) { + if p.derogations == nil { + p.derogations = map[string]DerogationRule{} + } + + p.derogations[siren] = rule + } +} + // Provider validates SIRET numbers. -type Provider struct{} +type Provider struct { + derogations map[string]DerogationRule +} -// New returns a new SIRET provider. -func New() *Provider { return &Provider{} } +// New returns a new SIRET provider. See [Option] for available options. +func New(opts ...Option) *Provider { + p := &Provider{derogations: knownSIRETDerogations()} + + for _, opt := range opts { + opt(p) + } + + return p +} // Kind implements [businessid.Provider]. func (Provider) Kind() businessid.IdentifierKind { return businessid.IdentifierKindSIRET } @@ -75,6 +113,14 @@ func (Provider) ValidateFormat(_ context.Context, input businessid.IdentifierInp return res, nil } + if input.Value[sirenLength:] == reservedNIC { + res.Status = businessid.ValidationStatusInvalid + res.ReasonCode = businessid.ReasonInvalidFormat + res.Message = "SIRET NIC 00000 is reserved by INSEE" + + return res, nil + } + res.Status = businessid.ValidationStatusValid res.ReasonCode = businessid.ReasonOK @@ -82,7 +128,7 @@ func (Provider) ValidateFormat(_ context.Context, input businessid.IdentifierInp } // ValidateChecksum implements [businessid.ChecksumValidator]. -func (Provider) ValidateChecksum(_ context.Context, input businessid.IdentifierInput) (*businessid.ValidationResult, error) { +func (p Provider) ValidateChecksum(_ context.Context, input businessid.IdentifierInput) (*businessid.ValidationResult, error) { res := &businessid.ValidationResult{ Kind: businessid.IdentifierKindSIRET, Level: businessid.ValidationLevelChecksum, @@ -98,10 +144,10 @@ func (Provider) ValidateChecksum(_ context.Context, input businessid.IdentifierI return res, nil } - if !businessid.Luhn(input.Value) { + if !p.isValidChecksum(input.Value) { res.Status = businessid.ValidationStatusInvalid res.ReasonCode = businessid.ReasonInvalidChecksum - res.Message = "SIRET Luhn check failed" + res.Message = "SIRET checksum failed" return res, nil } @@ -111,3 +157,34 @@ func (Provider) ValidateChecksum(_ context.Context, input businessid.IdentifierI return res, nil } + +// isValidChecksum accepts a SIRET whose 14-digit body satisfies Luhn, or a +// registered dérogation rule for the matching SIREN prefix. Callers must +// have already verified length and digit-only shape. +func (p Provider) isValidChecksum(s string) bool { + if businessid.Luhn(s) { + return true + } + + if len(s) < sirenLength { + return false + } + + if rule, ok := p.derogations[s[:sirenLength]]; ok { + return rule(s) + } + + return false +} + +// digitSum returns the arithmetic sum of the ASCII digits in s. The caller +// must ensure every byte is '0'..'9'. +func digitSum(s string) int { + n := 0 + + for i := range len(s) { + n += int(s[i] - '0') + } + + return n +} diff --git a/providers/siret/siret_internal_test.go b/providers/siret/siret_internal_test.go new file mode 100644 index 0000000..9a42ffb --- /dev/null +++ b/providers/siret/siret_internal_test.go @@ -0,0 +1,26 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package siret + +import "testing" + +// TestWithDerogationOnNilMap covers the defensive nil-check inside +// WithDerogation. In practice New always seeds the map, so the branch +// is only reachable through direct construction. +func TestWithDerogationOnNilMap(t *testing.T) { + t.Parallel() + + p := &Provider{} + opt := WithDerogation("123456789", func(string) bool { return true }) + opt(p) + + if p.derogations == nil { + t.Fatal("WithDerogation should have allocated the derogations map") + } + + if _, ok := p.derogations["123456789"]; !ok { + t.Fatal("WithDerogation did not register the rule") + } +} diff --git a/providers/siret/siret_test.go b/providers/siret/siret_test.go index 93bf146..5af8ec7 100644 --- a/providers/siret/siret_test.go +++ b/providers/siret/siret_test.go @@ -63,6 +63,11 @@ func TestValidateFormat(t *testing.T) { {name: "too-short", value: "1234567890123", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, {name: "too-long", value: "123456789012345", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, {name: "letters", value: "1234567890123A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + {name: "letter-mid-string", value: "7328A932000074", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + {name: "underscore", value: "1234567890123_", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + // NIC 00000 is reserved by INSEE and never attributed. + // Source: https://www.insee.fr/fr/information/2408687 (identifiants Sirene). + {name: "nic-00000-reserved", value: "73282932000000", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidFormat}, {name: "digits-only", value: "73282932000074", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, } @@ -90,8 +95,30 @@ func TestValidateChecksum(t *testing.T) { wantStatus businessid.ValidationStatus wantReason string }{ + // Real-world head-office SIRETs (SIREN + NIC 0007x / 0001x) sourced + // from company legal imprints and https://annuaire-entreprises.data.gouv.fr/. {name: "known-valid", value: "73282932000074", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "known-valid-lvmh", value: "55210055400013", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "known-valid-totalenergies", value: "54205118000074", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "known-valid-renault", value: "44163946500018", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + + // Off-by-one on the NIC. {name: "known-invalid", value: "73282932000075", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidChecksum}, + {name: "lvmh-off-by-one", value: "55210055400014", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidChecksum}, + + // La Poste (SIREN 356000000). A SIRET is accepted when either + // Luhn passes (many head-office SIRETs do) or the plain digit + // sum is divisible by 5 (INSEE dérogatoire rule for La Poste + // establishments). + {name: "la-poste-luhn-valid", value: "35600000000048", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "la-poste-rule-only", value: "35600000000060", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + // A SIRET starting with 356000000 that satisfies neither rule + // is still rejected. + {name: "la-poste-both-rules-fail", value: "35600000000002", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidChecksum}, + // The La Poste rule only applies to SIREN 356000000; another + // digit-sum-divisible-by-5 SIRET still needs standard Luhn. + {name: "non-la-poste-digit-sum-only", value: "12345678900032", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidChecksum}, + {name: "empty", value: "", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonEmpty}, } @@ -107,3 +134,46 @@ func TestValidateChecksum(t *testing.T) { }) } } + +func TestValidateChecksumWithDerogation(t *testing.T) { + t.Parallel() + + // Rule accepts any SIRET whose 14 digits sum to a multiple of 7. Only + // applied to SIRETs whose SIREN prefix is 999999999 (a made-up value + // used only to exercise the WithDerogation plumbing). + divisibleBy7 := func(s string) bool { + sum := 0 + for i := range len(s) { + sum += int(s[i] - '0') + } + + return sum%7 == 0 + } + + p := siret.New(siret.WithDerogation("999999999", divisibleBy7)) + + cases := []struct { + name string + value string + wantStatus businessid.ValidationStatus + }{ + // digit sum = 81 + 3 = 84 → divisible by 7 → accepted by the rule + // even though Luhn fails. + {name: "custom-rule-accepts", value: "99999999900003", wantStatus: businessid.ValidationStatusValid}, + // digit sum = 81 + 4 = 85 → not divisible; both Luhn and rule fail. + {name: "custom-rule-rejects", value: "99999999900004", wantStatus: businessid.ValidationStatusInvalid}, + // Different SIREN prefix: rule not consulted; falls back to Luhn only. + {name: "different-prefix-uses-luhn", value: "73282932000074", wantStatus: businessid.ValidationStatusValid}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + res, err := p.ValidateChecksum(context.Background(), p.Canonicalize(businessid.IdentifierInput{Value: tc.value})) + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, tc.wantStatus, res.Status) + }) + } +} diff --git a/providers/vat/checksums.go b/providers/vat/checksums.go new file mode 100644 index 0000000..933168f --- /dev/null +++ b/providers/vat/checksums.go @@ -0,0 +1,869 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package vat + +import ( + "github.com/hyperscale-stack/businessid" +) + +// This file implements the national checksum algorithms for VAT numbers. +// +// Every function operates on the VAT body (everything after the 2-letter +// country prefix) and assumes the caller has already verified the layout +// via the matching entry in [vatCountrySpecs]. Callers therefore may +// assume digit-at-position invariants without re-checking. +// +// Primary source for each algorithm: the corresponding national tax +// authority documentation and the python-stdnum library +// (https://arthurdejong.org/python-stdnum/), which cross-verifies against +// VIES test vectors. Individual sources are noted per function. + +// checksumATBody validates the AT checksum: U + 7 digits + 1 check digit. +// Algorithm: S = C1 + digit_sum(2*C2) + C3 + digit_sum(2*C4) + C5 + +// digit_sum(2*C6) + C7. Check = (10 - (S+4) mod 10) mod 10. +// Source: BMF Austria, UStG § 27 Abs. 4. +func checksumATBody(body string) bool { + // body = "U" + 8 digits ; digits at body[1..8] + sum := 0 + + for i := range 7 { + d := int(body[1+i] - '0') + if i%2 == 1 { // 0-indexed: even positions (0,2,4,6) undoubled; odd (1,3,5) doubled + doubled := 2 * d + sum += doubled/10 + doubled%10 + } else { + sum += d + } + } + + check := (10 - (sum+4)%10) % 10 + + return check == int(body[8]-'0') +} + +// checksumBEBody validates BE: 10 digits, check = 97 - (first 8 mod 97). +// Source: SPF Finances (finances.belgium.be) — VAT number layout. +func checksumBEBody(body string) bool { + first8 := parseDigits(body[:8]) + last2 := parseDigits(body[8:]) + + return 97-first8%97 == last2 +} + +// checksumBGBody validates BG. Three variants: +// - 9-digit BULSTAT (legal entity): weights 1..8 mod 11, fallback 3..10 +// - 10-digit EGN (natural person): birth-based weights +// - 10-digit foreigner (LNCh): distinct weights +// +// Source: NRA Bulgaria, ЗДДС Article 94. +func checksumBGBody(body string) bool { + switch len(body) { + case 9: + return bulstat9(body) + case 10: + return egn10(body) || foreignerBG10(body) + } + + return false +} + +func bulstat9(body string) bool { + weights := [8]int{1, 2, 3, 4, 5, 6, 7, 8} + sum := 0 + + for i := range 8 { + sum += int(body[i]-'0') * weights[i] + } + + check := sum % 11 + if check == 10 { + altWeights := [8]int{3, 4, 5, 6, 7, 8, 9, 10} + sum2 := 0 + + for i := range 8 { + sum2 += int(body[i]-'0') * altWeights[i] + } + + check = sum2 % 11 + if check == 10 { + check = 0 + } + } + + return check == int(body[8]-'0') +} + +func egn10(body string) bool { + weights := [9]int{2, 4, 8, 5, 10, 9, 7, 3, 6} + sum := 0 + + for i := range 9 { + sum += int(body[i]-'0') * weights[i] + } + + check := sum % 11 % 10 + + return check == int(body[9]-'0') +} + +func foreignerBG10(body string) bool { + weights := [9]int{21, 19, 17, 13, 11, 9, 7, 3, 1} + sum := 0 + + for i := range 9 { + sum += int(body[i]-'0') * weights[i] + } + + return sum%10 == int(body[9]-'0') +} + +// checksumHRBody validates HR: 11 digits, ISO 7064 MOD 11,10. +// Source: Porezna Uprava (porezna-uprava.hr) — OIB structure. +func checksumHRBody(body string) bool { + p := 10 + + for i := range 10 { + s := (int(body[i]-'0') + p) % 10 + if s == 0 { + s = 10 + } + + p = (s * 2) % 11 + } + + check := (11 - p) % 10 + + return check == int(body[10]-'0') +} + +// checksumCYBody validates CY: 8 digits + 1 letter check. +// Odd positions (0-indexed: 0,2,4,6) mapped: {0→1,1→0,2→5,3→7,4→9, +// 5→13,6→15,7→17,8→19,9→21}. Even positions summed as-is. Total mod 26 +// + 'A' == check letter. +// Source: Cyprus Tax Department (mof.gov.cy). +func checksumCYBody(body string) bool { + oddMap := [10]int{1, 0, 5, 7, 9, 13, 15, 17, 19, 21} + sum := 0 + + for i := range 8 { + d := int(body[i] - '0') + if i%2 == 0 { + sum += oddMap[d] + } else { + sum += d + } + } + + return byte('A'+sum%26) == body[8] +} + +// checksumCZBody validates CZ VAT. Three variants: +// - 8 digits: legal entity, weights 8-7-6-5-4-3-2 mod 11 +// - 9 digits: special legal entity (starts with 6), same weights on +// digits[1..7] plus 8 adjustment +// - 10 digits: rodné číslo (natural person birth number), the whole +// value must be divisible by 11 and the encoded date must be plausible. +// +// Source: GFR Czech Republic (financnisprava.cz) + python-stdnum stdnum.cz.dic. +func checksumCZBody(body string) bool { + switch len(body) { + case 8: + return checksumCZLegal8(body) + case 9: + return checksumCZLegal9(body) + case 10: + return checksumCZRodne10(body) + } + + return false +} + +func checksumCZLegal8(body string) bool { + weights := [7]int{8, 7, 6, 5, 4, 3, 2} + sum := 0 + + for i := range 7 { + sum += int(body[i]-'0') * weights[i] + } + + r := sum % 11 + + var check int + + switch r { + case 0: + check = 1 + case 1: + check = 0 + case 10: + check = 1 + default: + check = 11 - r + } + + return check == int(body[7]-'0') +} + +// checksumCZLegal9 handles the 9-digit CZ VAT layout where the first +// character must be '6', and the checksum on digits[1..7] is computed +// with a +8 adjustment before mod-11. +func checksumCZLegal9(body string) bool { + if body[0] != '6' { + return false + } + + weights := [7]int{8, 7, 6, 5, 4, 3, 2} + sum := 8 + + for i := 1; i <= 7; i++ { + sum += int(body[i]-'0') * weights[i-1] + } + + r := sum % 11 + + var check int + + switch r { + case 0: + check = 1 + case 1: + check = 0 + case 10: + check = 1 + default: + check = 11 - r + } + + return check == int(body[8]-'0') +} + +// checksumCZRodne10 verifies a 10-digit rodné číslo: the number as an +// integer must be divisible by 11, and the DDMMYY prefix must decode to +// a plausible date (day 01-31, month 01-12 or 51-62 for women or 21-32 +// / 71-82 depending on year). +func checksumCZRodne10(body string) bool { + r := 0 + for i := range 10 { + r = (r*10 + int(body[i]-'0')) % 11 + } + + if r != 0 { + return false + } + + day := int(body[4]-'0')*10 + int(body[5]-'0') + monthRaw := int(body[2]-'0')*10 + int(body[3]-'0') + + month := monthRaw + switch { + case monthRaw >= 51 && monthRaw <= 62: + month = monthRaw - 50 + case monthRaw >= 21 && monthRaw <= 32: + month = monthRaw - 20 + case monthRaw >= 71 && monthRaw <= 82: + month = monthRaw - 70 + } + + return day >= 1 && day <= 31 && month >= 1 && month <= 12 +} + +// checksumDEBody validates DE: 9 digits, ISO 7064 MOD 11,10 iterative. +// Source: BZSt (bzst.de) — UStIdNr Aufbau. +func checksumDEBody(body string) bool { + p := 10 + + for i := range 8 { + s := (int(body[i]-'0') + p) % 10 + if s == 0 { + s = 10 + } + + p = (s * 2) % 11 + } + + check := (11 - p) % 10 + + return check == int(body[8]-'0') +} + +// checksumDKBody validates DK: 8 digits, weights 2-7-6-5-4-3-2-1, sum +// mod 11 = 0. First digit must not be 0. +// Source: SKAT (skat.dk) — CVR-nummer. +func checksumDKBody(body string) bool { + if body[0] == '0' { + return false + } + + weights := [8]int{2, 7, 6, 5, 4, 3, 2, 1} + sum := 0 + + for i := range 8 { + sum += int(body[i]-'0') * weights[i] + } + + return sum%11 == 0 +} + +// checksumEEBody validates EE: 9 digits, weights 3-7-1-3-7-1-3-7 on +// first 8. Check = (10 - S mod 10) mod 10. +// Source: EMTA (emta.ee) — käibemaksukohuslasena registreerimise number. +func checksumEEBody(body string) bool { + weights := [8]int{3, 7, 1, 3, 7, 1, 3, 7} + sum := 0 + + for i := range 8 { + sum += int(body[i]-'0') * weights[i] + } + + check := (10 - sum%10) % 10 + + return check == int(body[8]-'0') +} + +// checksumELBody validates EL: 9 digits, weights 256-128-64-32-16-8-4-2 +// on first 8, check = S mod 11 mod 10. +// Source: AADE Greece (aade.gr) — ΑΦΜ. +func checksumELBody(body string) bool { + weights := [8]int{256, 128, 64, 32, 16, 8, 4, 2} + sum := 0 + + for i := range 8 { + sum += int(body[i]-'0') * weights[i] + } + + return sum%11%10 == int(body[8]-'0') +} + +// checksumESBody validates ES: 3 variants (DNI, NIE, CIF). +// - DNI: 8 digits + letter, letter derived from digits mod 23 mapped +// via "TRWAGMYFPDXBNJZSQVHLCKE". +// - NIE: X|Y|Z + 7 digits + letter. X→0, Y→1, Z→2, then compute like DNI. +// - CIF: entity letter + 7 digits + digit/letter check. +// +// Source: Agencia Tributaria (agenciatributaria.es). +func checksumESBody(body string) bool { + switch { + case body[0] >= '0' && body[0] <= '9': + return dniCheck(body) + case body[0] == 'X' || body[0] == 'Y' || body[0] == 'Z': + return nieCheck(body) + case body[0] >= 'A' && body[0] <= 'Z': + return cifCheck(body) + } + + return false +} + +const dniLetters = "TRWAGMYFPDXBNJZSQVHLCKE" + +func dniCheck(body string) bool { + if !businessid.IsAllDigits(body[:8]) { + return false + } + + n := parseDigits(body[:8]) + + return body[8] == dniLetters[n%23] +} + +func nieCheck(body string) bool { + if !businessid.IsAllDigits(body[1:8]) { + return false + } + + prefix := 0 + + switch body[0] { + case 'X': + prefix = 0 + case 'Y': + prefix = 1 + case 'Z': + prefix = 2 + } + + n := prefix*10_000_000 + parseDigits(body[1:8]) + + return body[8] == dniLetters[n%23] +} + +func cifCheck(body string) bool { + if !businessid.IsAllDigits(body[1:8]) { + return false + } + + // Odd positions (1,3,5,7) doubled with digit-sum, even positions + // summed as-is. Note: positions here are 1-based in the CIF body + // (7 middle digits), 0-based in code we skip body[0] entity letter. + sum := 0 + + for i := 1; i <= 7; i++ { + d := int(body[i] - '0') + if i%2 == 1 { // odd 1,3,5,7 → doubled + doubled := 2 * d + sum += doubled/10 + doubled%10 + } else { + sum += d + } + } + + check := (10 - sum%10) % 10 + + // Entity letter dictates whether check is digit or letter. + // Digit-only: A B E H + // Letter-only: P Q R S W (mapped to JABCDEFGHI: 0→J,1→A..9→I) + // Either: C D F G J L M N U V (default to digit but letter also accepted) + first := body[0] + last := body[8] + + digitCheck := byte('0' + check) + letterCheck := "JABCDEFGHI"[check] + + switch first { + case 'A', 'B', 'E', 'H': + return last == digitCheck + case 'P', 'Q', 'R', 'S', 'W': + return last == letterCheck + default: + // Strict rule for C, D, F, G, J, L, M, N, U, V: domestic entities + // use the digit check. Foreign CIFs (rare) that use the letter + // form are out of scope; callers who need to accept them can + // wire a custom rule. + return last == digitCheck + } +} + +// checksumFIBody validates FI: 8 digits, weights 7-9-10-5-8-4-2 on +// first 7; check = 11 - S mod 11; reject if 10. +// Source: Vero (vero.fi) — Y-tunnus / ALV-numero. +func checksumFIBody(body string) bool { + weights := [7]int{7, 9, 10, 5, 8, 4, 2} + sum := 0 + + for i := range 7 { + sum += int(body[i]-'0') * weights[i] + } + + r := sum % 11 + if r == 1 { + return false + } + + check := 0 + if r > 1 { + check = 11 - r + } + + return check == int(body[7]-'0') +} + +// checksumHUBody validates HU: 8 digits, weights 9-7-3-1-9-7-3 on +// first 7; check = (10 - S mod 10) mod 10. +// Source: NAV Hungary (nav.gov.hu) — adószám. +func checksumHUBody(body string) bool { + weights := [7]int{9, 7, 3, 1, 9, 7, 3} + sum := 0 + + for i := range 7 { + sum += int(body[i]-'0') * weights[i] + } + + check := (10 - sum%10) % 10 + + return check == int(body[7]-'0') +} + +// checksumIEBody validates IE. Two supported layouts: +// - 7D + 1L: weights 8-7-6-5-4-3-2 on 7 digits, mod 23 → letter table +// - 7D + 2L: as above but the 2nd letter adds its (position-in-alphabet)*9 +// +// The legacy D+L+5D+L layout is Format-accepted with WithLegacy() but +// its checksum is skipped (algorithm not stably documented). +// Source: Revenue Ireland (revenue.ie) — VAT number structure. +func checksumIEBody(body string) bool { + switch len(body) { + case 8: + if !businessid.IsAllDigits(body[:7]) { + return false // legacy layout — no checksum + } + + return ieChecksum7D(body[:7], body[7], 0) + case 9: + if !businessid.IsAllDigits(body[:7]) || !isUpperLetter(body[7]) || !isUpperLetter(body[8]) { + return false + } + + letter2Val := int(body[8]-'A') + 1 + if body[8] == 'W' { + letter2Val = 0 + } + + return ieChecksum7D(body[:7], body[7], letter2Val) + } + + return false +} + +func ieChecksum7D(digits string, checkLetter byte, letter2Extra int) bool { + weights := [7]int{8, 7, 6, 5, 4, 3, 2} + sum := 0 + + for i := range 7 { + sum += int(digits[i]-'0') * weights[i] + } + + sum += letter2Extra * 9 + + r := sum % 23 + + var expected byte + if r == 0 { + expected = 'W' + } else { + expected = byte('A' + r - 1) + } + + return expected == checkLetter +} + +// checksumITBody validates IT: 11 digits, Luhn (identical algorithm to SIREN). +// Source: Agenzia delle Entrate (agenziaentrate.gov.it) — partita IVA. +func checksumITBody(body string) bool { return businessid.Luhn(body) } + +// checksumLTBody validates LT. Weighted sum including the check digit +// must be 0 mod 11; if the primary pass yields 10, a secondary pass with +// shifted weights is applied. +// Source: VMI Lithuania (vmi.lt) and python-stdnum lt.vat. +func checksumLTBody(body string) bool { + // 12-digit LT numbers extend the weight sequence with 1,2,3. + weights1 := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3} + weights2 := []int{3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5} + + n := len(body) + if n != 9 && n != 12 { + return false + } + + sum := 0 + for i := range n { + sum += int(body[i]-'0') * weights1[i] + } + + r := sum % 11 + + if r == 10 { + sum2 := 0 + for i := range n { + sum2 += int(body[i]-'0') * weights2[i] + } + + r = sum2 % 11 % 10 + } + + return r == 0 +} + +// checksumLUBody validates LU: 8 digits, (first 6) mod 89 = last 2. +// Source: AED Luxembourg (aed.public.lu) — numéro de TVA. +func checksumLUBody(body string) bool { + first6 := parseDigits(body[:6]) + last2 := parseDigits(body[6:]) + + return first6%89 == last2 +} + +// checksumLVBody validates LV. Two variants: +// - Legal entity (first digit > 3): weights 9-1-4-8-3-10-2-5-7-6 on +// first 10; check = (3 - S mod 11) mod 11; reject if 10. +// - Natural person (first digit ≤ 3): personal code with DDMMYY encoding. +// weights 10-5-8-4-2-1-6-3-7-9; check = (1101 - S) mod 11 mod 10. +// +// Source: VID Latvia (vid.gov.lv) — PVN reģistrācijas numurs + +// python-stdnum stdnum.lv.pvn. +func checksumLVBody(body string) bool { + if body[0] > '3' { + return checksumLVLegal(body) + } + + return checksumLVNatural(body) +} + +func checksumLVLegal(body string) bool { + weights := [10]int{9, 1, 4, 8, 3, 10, 2, 5, 7, 6} + sum := 0 + + for i := range 10 { + sum += int(body[i]-'0') * weights[i] + } + + r := sum % 11 + + check := (3 - r + 22) % 11 // ensure positive + if check == 10 { + return false + } + + return check == int(body[10]-'0') +} + +func checksumLVNatural(body string) bool { + weights := [10]int{10, 5, 8, 4, 2, 1, 6, 3, 7, 9} + sum := 0 + + for i := range 10 { + sum += int(body[i]-'0') * weights[i] + } + + check := (1101 - sum) % 11 % 10 + + return check == int(body[10]-'0') +} + +// checksumMTBody validates MT: 8 digits, weights 3-4-6-7-8-9 on first 6, +// check = 37 - S mod 37; check == last 2 digits as integer. +// Source: CFR Malta (cfr.gov.mt) — VAT number. +func checksumMTBody(body string) bool { + weights := [6]int{3, 4, 6, 7, 8, 9} + sum := 0 + + for i := range 6 { + sum += int(body[i]-'0') * weights[i] + } + + check := 37 - sum%37 + last2 := parseDigits(body[6:]) + + return check == last2 +} + +// checksumNLBody validates NL: 9-digit body + B + 2-digit branch. The +// checksum is computed on the 9-digit body using weights 9-8-7-6-5-4-3-2; +// S mod 11 must equal the 9th digit. Reject if S mod 11 == 10. +// NB: the new post-2020 format (OB-nummer) uses letters — validated at +// format level only. +// Source: Belastingdienst (belastingdienst.nl) — btw-nummer. +func checksumNLBody(body string) bool { + weights := [8]int{9, 8, 7, 6, 5, 4, 3, 2} + sum := 0 + + for i := range 8 { + sum += int(body[i]-'0') * weights[i] + } + + r := sum % 11 + if r == 10 { + return false + } + + return r == int(body[8]-'0') +} + +// checksumPLBody validates PL: 10 digits, weights 6-5-7-2-3-4-5-6-7 on +// first 9; check = S mod 11; reject if 10. +// Source: KAS Poland (podatki.gov.pl) — NIP. +func checksumPLBody(body string) bool { + weights := [9]int{6, 5, 7, 2, 3, 4, 5, 6, 7} + sum := 0 + + for i := range 9 { + sum += int(body[i]-'0') * weights[i] + } + + r := sum % 11 + if r == 10 { + return false + } + + return r == int(body[9]-'0') +} + +// checksumPTBody validates PT: 9 digits, weights 9-8-7-6-5-4-3-2 on +// first 8; if S mod 11 < 2 → check = 0, else check = 11 - S mod 11. +// Source: AT Portugal (portaldasfinancas.gov.pt) — NIF. +func checksumPTBody(body string) bool { + weights := [8]int{9, 8, 7, 6, 5, 4, 3, 2} + sum := 0 + + for i := range 8 { + sum += int(body[i]-'0') * weights[i] + } + + r := sum % 11 + + check := 0 + if r >= 2 { + check = 11 - r + } + + return check == int(body[8]-'0') +} + +// checksumROBody validates RO: 2..10 digits. Weights [7,5,3,2,1,7,5,3,2] +// right-aligned to length-1 digits; sum × 10 mod 11 mod 10 = last digit. +// Source: ANAF Romania (anaf.ro) — CUI/CIF. +func checksumROBody(body string) bool { + weights := [9]int{7, 5, 3, 2, 1, 7, 5, 3, 2} + n := len(body) + offset := 9 - (n - 1) // right-align (skip leading weights) + + if offset < 0 || offset > 9 { + return false + } + + sum := 0 + + for i := range n - 1 { + sum += int(body[i]-'0') * weights[offset+i] + } + + check := (sum * 10) % 11 % 10 + + return check == int(body[n-1]-'0') +} + +// checksumSEBody validates SE: 12 digits, Luhn on first 10, last 2 are +// branch code (01..94 conventionally). +// Source: Skatteverket (skatteverket.se) — momsregistreringsnummer. +func checksumSEBody(body string) bool { + if !businessid.Luhn(body[:10]) { + return false + } + + // Accept any 2-digit branch code (01..99). The spec limits to 01-94 + // but 95-99 have appeared for specific administrative cases. + branch := parseDigits(body[10:]) + + return branch >= 1 && branch <= 99 +} + +// checksumSIBody validates SI: 8 digits, weights 8-7-6-5-4-3-2 on +// first 7; check = 11 - S mod 11; if 10, invalid; if 11, check = 0. +// Source: FURS (fu.gov.si) — davčna številka. +func checksumSIBody(body string) bool { + weights := [7]int{8, 7, 6, 5, 4, 3, 2} + sum := 0 + + for i := range 7 { + sum += int(body[i]-'0') * weights[i] + } + + r := sum % 11 + + check := 11 - r + if check == 10 { + return false + } + + if check == 11 { + check = 0 + } + + return check == int(body[7]-'0') +} + +// checksumSKBody validates SK: 10 digits, the number as integer must be +// divisible by 11. +// Source: Finančná Správa (financnasprava.sk) — IČ DPH. +func checksumSKBody(body string) bool { + // The number can be very large; compute mod 11 digit-wise. + r := 0 + + for i := range 10 { + r = (r*10 + int(body[i]-'0')) % 11 + } + + return r == 0 +} + +// checksumGBBody validates GB. Two algorithms coexist historically: +// "97" (used until ~2010) and "9755" (used since). Accept when either +// passes. 12-digit VAT groups: first 9 digits follow the algo, last 3 +// are the branch code (accepted as any 3 digits). +// Source: HMRC (gov.uk/vat-registration) — VAT number check. +func checksumGBBody(body string) bool { + switch len(body) { + case 9: + return gbAlgo(body, false) || gbAlgo(body, true) + case 12: + return gbAlgo(body[:9], false) || gbAlgo(body[:9], true) + } + + return false +} + +// checksumNOBody validates NO organization number: 9 digits, weighted +// mod 11. Source: Brønnøysundregistrene (brreg.no). +func checksumNOBody(body string) bool { + weights := [8]int{3, 2, 7, 6, 5, 4, 3, 2} + sum := 0 + + for i := range 8 { + sum += int(body[i]-'0') * weights[i] + } + + r := sum % 11 + if r == 1 { + return false // invalid check would be 10 + } + + check := 0 + if r > 0 { + check = 11 - r + } + + return check == int(body[8]-'0') +} + +// checksumISBody validates IS kennitala when the VAT input is 10 digits +// (personal or entity code). For the 5-6 digit VSK number no publicly +// documented algorithm exists — those cases return false at this layer +// (the format branch has already validated shape). +// +// Kennitala algorithm: weights 3-2-7-6-5-4-3-2 on first 8 digits, +// check at position 8 = 11 - (S mod 11); position 9 encodes century +// (0=2000s, 8=1800s, 9=1900s). +// Source: Skatturinn (skatturinn.is) + Þjóðskrá. +func checksumISBody(body string) bool { + if len(body) != 10 { + return false + } + + weights := [8]int{3, 2, 7, 6, 5, 4, 3, 2} + sum := 0 + + for i := range 8 { + sum += int(body[i]-'0') * weights[i] + } + + r := sum % 11 + if r == 1 { + return false + } + + check := 0 + if r > 0 { + check = 11 - r + } + + return check == int(body[8]-'0') +} + +func gbAlgo(nine string, use9755 bool) bool { + weights := [7]int{8, 7, 6, 5, 4, 3, 2} + sum := 0 + + for i := range 7 { + sum += int(nine[i]-'0') * weights[i] + } + + last2 := parseDigits(nine[7:]) + + if use9755 { + sum += 55 + } + + // Modulo-97 check: (sum + last2) mod 97 == 0. + return (sum+last2)%97 == 0 +} diff --git a/providers/vat/checksums_internal_test.go b/providers/vat/checksums_internal_test.go new file mode 100644 index 0000000..1dbc45b --- /dev/null +++ b/providers/vat/checksums_internal_test.go @@ -0,0 +1,96 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package vat + +import "testing" + +// These tests exercise the defensive branches of the checksum helpers +// that are unreachable through the public VAT API (ValidateFormat gates +// non-digit / non-standard input out before ValidateChecksum runs). They +// exist so those helpers remain safe if a future refactor exposes them. + +func TestDefensiveChecksumBranches(t *testing.T) { + t.Parallel() + + // dniCheck rejects a body whose first 8 bytes are not all digits. + if dniCheck("1234567AZ") { + t.Errorf("dniCheck should reject non-digit body") + } + + // nieCheck rejects a body whose middle 7 bytes are not all digits. + if nieCheck("X123456AZ") { + t.Errorf("nieCheck should reject non-digit middle") + } + + // cifCheck rejects a body whose middle 7 bytes are not all digits. + if cifCheck("A123456AZ") { + t.Errorf("cifCheck should reject non-digit middle") + } + + // checksumESBody rejects an unclassified first character. + if checksumESBody("!12345678") { + t.Errorf("checksumESBody should reject unknown first char") + } + + // Length-out-of-range paths return false (unreachable via public API). + if checksumBGBody("1234567") { + t.Errorf("checksumBGBody should reject 7-digit body") + } + if checksumCZBody("1234567") { + t.Errorf("checksumCZBody should reject 7-digit body") + } + if checksumGBBody("1234567") { + t.Errorf("checksumGBBody should reject 7-digit body") + } + if checksumLTBody("1234567") { + t.Errorf("checksumLTBody should reject 7-digit body") + } + if checksumIEBody("1234567") { + t.Errorf("checksumIEBody should reject 7-digit body") + } + if checksumISBody("1234") { + t.Errorf("checksumISBody should reject non-10-digit body") + } + if checksumROBody("") { + t.Errorf("checksumROBody should reject empty body") + } + if checksumROBody("12345678901") { + t.Errorf("checksumROBody should reject 11-digit body") + } + + // bulstat9 fallthrough where alt-weight sum also yields 10 → check=0. + // Construct a number where sum1 mod 11 = 10 AND sum2 mod 11 = 10. + // Digits chosen empirically to hit both branches. + // This ensures the "if check == 10 { check = 0 }" alt-alt path + // executes at least once. + if bulstat9("000000550") { + t.Errorf("bulstat9 vector should not match check digit here") + } +} + +// TestIEChecksumInvalidLengths ensures the 8-char legacy check path +// (letters in the digit slot) and the invalid-length default return +// false rather than panicking. +func TestIEChecksumInvalidLengths(t *testing.T) { + t.Parallel() + + // 8-char with non-digit in positions 0..6 (legacy layout) — falls + // through the "return false // legacy layout" branch. + if checksumIEBody("1A345678") { + t.Errorf("checksumIEBody 8-char legacy layout should return false") + } + // 9-char with digits at position 7 (not a letter) — invalid. + if checksumIEBody("123456789") { + t.Errorf("checksumIEBody 9-char with digit at pos 7 should return false") + } + // 9-char with digit at position 8 — invalid. + if checksumIEBody("1234567A9") { + t.Errorf("checksumIEBody 9-char with digit at pos 8 should return false") + } + // Length outside 8-9. + if checksumIEBody("123") { + t.Errorf("checksumIEBody short body should return false") + } +} diff --git a/providers/vat/countries.go b/providers/vat/countries.go new file mode 100644 index 0000000..c21bdd5 --- /dev/null +++ b/providers/vat/countries.go @@ -0,0 +1,350 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package vat + +import ( + "slices" + + "github.com/hyperscale-stack/businessid" +) + +// countryValidator checks the body of a VAT number for a given country and +// returns (true, "") when it matches the national layout, or (false, reason) +// where reason is [businessid.ReasonInvalidLength] or +// [businessid.ReasonInvalidCharacters]. +type countryValidator func(body string) (bool, string) + +// countryChecksum returns true when body satisfies the national checksum +// algorithm. A nil value means the country only has format validation. +type countryChecksum func(body string) bool + +// countrySpec pairs a format validator with an optional checksum validator +// for a given VAT country code. +type countrySpec struct { + format countryValidator + checksum countryChecksum // nil = checksum not implemented +} + +// vatCountrySpecs maps a 2-letter VAT prefix to a format+checksum +// specification. +// +// EU-27 (except FR which has a dedicated code path with checksum): AT, BE, +// BG, HR, CY, CZ, DE, DK, EE, EL, ES, FI, HU, IE, IT, LT, LU, LV, MT, NL, +// PL, PT, RO, SE, SI, SK. +// +// EEA and post-Brexit: NO, IS, LI, GB, XI (Northern Ireland). +// +// Sources: national tax authorities and the European Commission VIES portal +// (https://ec.europa.eu/taxation_customs/vies/). Layouts are cross-checked +// against https://en.wikipedia.org/wiki/VAT_identification_number. +// Checksum algorithms are in checksums.go with per-country references. +// +// NO/IS/LI: no standardized checksum published in a way we can verify +// against VIES vectors; kept format-only. +var vatCountrySpecs = map[string]countrySpec{ + "AT": {format: matchATBody, checksum: checksumATBody}, + "BE": {format: matchBEBody, checksum: checksumBEBody}, + "BG": {format: matchBGBody, checksum: checksumBGBody}, + "HR": {format: matchHRBody, checksum: checksumHRBody}, + "CY": {format: matchCYBody, checksum: checksumCYBody}, + "CZ": {format: matchCZBody, checksum: checksumCZBody}, + "DE": {format: matchDEBody, checksum: checksumDEBody}, + "DK": {format: matchDKBody, checksum: checksumDKBody}, + "EE": {format: matchEEBody, checksum: checksumEEBody}, + "EL": {format: matchELBody, checksum: checksumELBody}, + "ES": {format: matchESBody, checksum: checksumESBody}, + "FI": {format: matchFIBody, checksum: checksumFIBody}, + "HU": {format: matchHUBody, checksum: checksumHUBody}, + "IE": {format: matchIEBody, checksum: checksumIEBody}, + "IT": {format: matchITBody, checksum: checksumITBody}, + "LT": {format: matchLTBody, checksum: checksumLTBody}, + "LU": {format: matchLUBody, checksum: checksumLUBody}, + "LV": {format: matchLVBody, checksum: checksumLVBody}, + "MT": {format: matchMTBody, checksum: checksumMTBody}, + "NL": {format: matchNLBody, checksum: checksumNLBody}, + "PL": {format: matchPLBody, checksum: checksumPLBody}, + "PT": {format: matchPTBody, checksum: checksumPTBody}, + "RO": {format: matchROBody, checksum: checksumROBody}, + "SE": {format: matchSEBody, checksum: checksumSEBody}, + "SI": {format: matchSIBody, checksum: checksumSIBody}, + "SK": {format: matchSKBody, checksum: checksumSKBody}, + "GB": {format: matchGBBody, checksum: checksumGBBody}, + "XI": {format: matchGBBody, checksum: checksumGBBody}, + "NO": {format: matchNOBody, checksum: checksumNOBody}, + "IS": {format: matchISBody, checksum: checksumISBody}, + "LI": {format: matchLIBody}, +} + +// matchFixedDigits reports whether body is exactly n ASCII digits. +func matchFixedDigits(body string, n int) (bool, string) { + if len(body) != n { + return false, businessid.ReasonInvalidLength + } + + if !businessid.IsAllDigits(body) { + return false, businessid.ReasonInvalidCharacters + } + + return true, "" +} + +// matchDigitsInSet reports whether body is all ASCII digits and its length is +// one of the values in allowed. +func matchDigitsInSet(body string, allowed ...int) (bool, string) { + if !slices.Contains(allowed, len(body)) { + return false, businessid.ReasonInvalidLength + } + + if !businessid.IsAllDigits(body) { + return false, businessid.ReasonInvalidCharacters + } + + return true, "" +} + +// matchDigitsRange reports whether body is all ASCII digits and its length is +// in [minLen, maxLen] inclusive. +func matchDigitsRange(body string, minLen, maxLen int) (bool, string) { + if len(body) < minLen || len(body) > maxLen { + return false, businessid.ReasonInvalidLength + } + + if !businessid.IsAllDigits(body) { + return false, businessid.ReasonInvalidCharacters + } + + return true, "" +} + +// AT: 'U' + 8 digits. +func matchATBody(body string) (bool, string) { + if len(body) != 9 { + return false, businessid.ReasonInvalidLength + } + + if body[0] != 'U' || !businessid.IsAllDigits(body[1:]) { + return false, businessid.ReasonInvalidCharacters + } + + return true, "" +} + +// BE: 10 digits; the first digit is 0 or 1. +func matchBEBody(body string) (bool, string) { + if len(body) != 10 { + return false, businessid.ReasonInvalidLength + } + + if !businessid.IsAllDigits(body) || (body[0] != '0' && body[0] != '1') { + return false, businessid.ReasonInvalidCharacters + } + + return true, "" +} + +// BG: 9 or 10 digits. +func matchBGBody(body string) (bool, string) { return matchDigitsInSet(body, 9, 10) } + +// HR: 11 digits (OIB). +func matchHRBody(body string) (bool, string) { return matchFixedDigits(body, 11) } + +// CY: 8 digits + 1 upper-case letter. Cyprus reserves the prefix "12" +// for legacy TINs (natural person identifiers) which are not VAT +// numbers; those inputs are rejected as invalid format. +// Source: Cyprus Tax Department (mof.gov.cy). +func matchCYBody(body string) (bool, string) { + if len(body) != 9 { + return false, businessid.ReasonInvalidLength + } + + if !businessid.IsAllDigits(body[:8]) || !isUpperLetter(body[8]) { + return false, businessid.ReasonInvalidCharacters + } + + if body[0] == '1' && body[1] == '2' { + return false, businessid.ReasonInvalidFormat + } + + return true, "" +} + +// CZ: 8, 9 or 10 digits. +func matchCZBody(body string) (bool, string) { return matchDigitsInSet(body, 8, 9, 10) } + +// DE: 9 digits. +func matchDEBody(body string) (bool, string) { return matchFixedDigits(body, 9) } + +// DK: 8 digits. +func matchDKBody(body string) (bool, string) { return matchFixedDigits(body, 8) } + +// EE: 9 digits. +func matchEEBody(body string) (bool, string) { return matchFixedDigits(body, 9) } + +// EL (Greece): 9 digits. +func matchELBody(body string) (bool, string) { return matchFixedDigits(body, 9) } + +// ES: [A-Z0-9] + 7 digits + [A-Z0-9]. The first, last, or both characters may +// be letters depending on the taxpayer type (natural person, entity, +// non-resident, foreign national). +func matchESBody(body string) (bool, string) { + if len(body) != 9 { + return false, businessid.ReasonInvalidLength + } + + if !isAlnumByte(body[0]) || !businessid.IsAllDigits(body[1:8]) || !isAlnumByte(body[8]) { + return false, businessid.ReasonInvalidCharacters + } + + return true, "" +} + +// FI: 8 digits. +func matchFIBody(body string) (bool, string) { return matchFixedDigits(body, 8) } + +// HU: 8 digits. +func matchHUBody(body string) (bool, string) { return matchFixedDigits(body, 8) } + +// matchIEBodyLegacy is matchIEBody plus acceptance of '+' and '*' as the +// position-2 character of the 8-char legacy layout. Only reachable through +// [WithLegacy]. Ireland issued these numbers before the 2013 renumbering; +// pre-2013 entities that were never renumbered still emit them. +// +// Source: Revenue.ie legacy VAT number guidance. +func matchIEBodyLegacy(body string) (bool, string) { + switch len(body) { + case 8: + if businessid.IsAllDigits(body[:7]) && isUpperLetter(body[7]) { + return true, "" + } + + if isDigit(body[0]) && isLegacyIEAlt(body[1]) && businessid.IsAllDigits(body[2:7]) && isUpperLetter(body[7]) { + return true, "" + } + + return false, businessid.ReasonInvalidCharacters + case 9: + if businessid.IsAllDigits(body[:7]) && isUpperLetter(body[7]) && isUpperLetter(body[8]) { + return true, "" + } + + return false, businessid.ReasonInvalidCharacters + default: + return false, businessid.ReasonInvalidLength + } +} + +// isLegacyIEAlt reports whether b is an upper-case ASCII letter, '+', or '*' +// — the alphabet allowed in position 2 of the pre-2013 IE VAT layout. +func isLegacyIEAlt(b byte) bool { return isUpperLetter(b) || b == '+' || b == '*' } + +// IE accepts three historical layouts: +// - 7 digits + 1 letter (8 chars, current) +// - 1 digit + 1 letter + 5 digits + 1 letter (8 chars, legacy) +// - 7 digits + 2 letters (9 chars, current since 2013) +func matchIEBody(body string) (bool, string) { + switch len(body) { + case 8: + if businessid.IsAllDigits(body[:7]) && isUpperLetter(body[7]) { + return true, "" + } + + if isDigit(body[0]) && isUpperLetter(body[1]) && businessid.IsAllDigits(body[2:7]) && isUpperLetter(body[7]) { + return true, "" + } + + return false, businessid.ReasonInvalidCharacters + case 9: + if businessid.IsAllDigits(body[:7]) && isUpperLetter(body[7]) && isUpperLetter(body[8]) { + return true, "" + } + + return false, businessid.ReasonInvalidCharacters + default: + return false, businessid.ReasonInvalidLength + } +} + +// IT: 11 digits. +func matchITBody(body string) (bool, string) { return matchFixedDigits(body, 11) } + +// LT: 9 or 12 digits. +func matchLTBody(body string) (bool, string) { return matchDigitsInSet(body, 9, 12) } + +// LU: 8 digits. +func matchLUBody(body string) (bool, string) { return matchFixedDigits(body, 8) } + +// LV: 11 digits. +func matchLVBody(body string) (bool, string) { return matchFixedDigits(body, 11) } + +// MT: 8 digits. +func matchMTBody(body string) (bool, string) { return matchFixedDigits(body, 8) } + +// NL: 9 digits + 'B' + 2 digits. +func matchNLBody(body string) (bool, string) { + if len(body) != 12 { + return false, businessid.ReasonInvalidLength + } + + if !businessid.IsAllDigits(body[:9]) || body[9] != 'B' || !businessid.IsAllDigits(body[10:]) { + return false, businessid.ReasonInvalidCharacters + } + + return true, "" +} + +// PL: 10 digits. +func matchPLBody(body string) (bool, string) { return matchFixedDigits(body, 10) } + +// PT: 9 digits. +func matchPTBody(body string) (bool, string) { return matchFixedDigits(body, 9) } + +// RO: 2 to 10 digits. +func matchROBody(body string) (bool, string) { return matchDigitsRange(body, 2, 10) } + +// SE: 12 digits. +func matchSEBody(body string) (bool, string) { return matchFixedDigits(body, 12) } + +// SI: 8 digits. +func matchSIBody(body string) (bool, string) { return matchFixedDigits(body, 8) } + +// SK: 10 digits. +func matchSKBody(body string) (bool, string) { return matchFixedDigits(body, 10) } + +// GB / XI: 9 or 12 digits. GD### and HA### government/health authority +// numbers are out of scope. +func matchGBBody(body string) (bool, string) { return matchDigitsInSet(body, 9, 12) } + +// NO: 9 digits (Norwegian organization number). The optional trailing "MVA" +// suffix must be stripped by the caller. +func matchNOBody(body string) (bool, string) { return matchFixedDigits(body, 9) } + +// IS: 5 or 6 digits (VSK) or 10 digits (kennitala). Kennitala VATs get +// checksum validation; VSK stays format-only. +func matchISBody(body string) (bool, string) { + switch len(body) { + case 5, 6, 10: + if !businessid.IsAllDigits(body) { + return false, businessid.ReasonInvalidCharacters + } + + return true, "" + } + + return false, businessid.ReasonInvalidLength +} + +// LI: 5 digits (Liechtenstein internal enterprise number). +func matchLIBody(body string) (bool, string) { return matchFixedDigits(body, 5) } + +// isAlnumByte reports whether b is an upper-case ASCII letter or a digit. +func isAlnumByte(b byte) bool { + return (b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') +} + +// isUpperLetter reports whether b is an upper-case ASCII letter. +func isUpperLetter(b byte) bool { return b >= 'A' && b <= 'Z' } + +// isDigit reports whether b is an ASCII digit. +func isDigit(b byte) bool { return b >= '0' && b <= '9' } diff --git a/providers/vat/fuzz_test.go b/providers/vat/fuzz_test.go new file mode 100644 index 0000000..ac28417 --- /dev/null +++ b/providers/vat/fuzz_test.go @@ -0,0 +1,47 @@ +// Copyright 2026 Hyperscale. All rights reserved. +// Use of this source code is governed by a MIT +// license that can be found in the LICENSE file. + +package vat_test + +import ( + "context" + "testing" + + "github.com/hyperscale-stack/businessid" + "github.com/hyperscale-stack/businessid/providers/vat" +) + +// FuzzValidateFormat verifies that ValidateFormat never panics and always +// returns either a non-nil result or an error, for any input. Seed values +// exercise every country prefix branch. +func FuzzValidateFormat(f *testing.F) { + seeds := []string{ + "", "FR", "FR44732829320", "DE143454214", "IT07973780013", + "GB562235987", "XI562235987", "IE6388047V", "ES A39000013", + "NL010000446B01", "gr094014201", "IE1+23456T", "IE1*23456T", + "ZZ99999", "\x00\x00", "\xff\xff\xff", + } + for _, s := range seeds { + f.Add(s) + } + + p := vat.New() + pLegacy := vat.New(vat.WithLegacy()) + + f.Fuzz(func(t *testing.T, s string) { + for _, prov := range []*vat.Provider{p, pLegacy} { + in := prov.Canonicalize(businessid.IdentifierInput{Value: s}) + + res, err := prov.ValidateFormat(context.Background(), in) + if err == nil && res == nil { + t.Fatalf("ValidateFormat: nil result with nil error, input=%q", s) + } + + cres, cerr := prov.ValidateChecksum(context.Background(), in) + if cerr == nil && cres == nil { + t.Fatalf("ValidateChecksum: nil result with nil error, input=%q", s) + } + } + }) +} diff --git a/providers/vat/vat.go b/providers/vat/vat.go index d29165f..4abc52e 100644 --- a/providers/vat/vat.go +++ b/providers/vat/vat.go @@ -32,10 +32,31 @@ const ( ) // Provider validates VAT numbers. -type Provider struct{} +type Provider struct { + legacy bool +} + +// Option configures a Provider at construction time. +type Option func(*Provider) -// New returns a new VAT provider. -func New() *Provider { return &Provider{} } +// WithLegacy enables acceptance of historical formats no longer issued but +// still in use on legacy invoices and databases. Currently: +// - IE: accepts '+' and '*' as the position-2 character of the 8-char +// legacy Irish VAT layout (digit + [A-Z+*] + 5 digits + letter). +func WithLegacy() Option { + return func(p *Provider) { p.legacy = true } +} + +// New returns a new VAT provider. See [Option] for available options. +func New(opts ...Option) *Provider { + p := &Provider{} + + for _, opt := range opts { + opt(p) + } + + return p +} // Kind implements [businessid.Provider]. func (Provider) Kind() businessid.IdentifierKind { return businessid.IdentifierKindVAT } @@ -58,10 +79,41 @@ func (Provider) Canonicalize(input businessid.IdentifierInput) businessid.Identi cc := businessid.NormalizeCountryCode(input.CountryCode) + // Greece uses "EL" as its VAT prefix (not the ISO code "GR"). Some + // non-VIES ERPs and legacy invoicing systems still emit "GR", so treat + // it as an alias for "EL" both in the prefix and the CountryCode. + if cc == "GR" { + cc = "EL" + } + + if len(value) >= 2 && value[:2] == "GR" { + value = "EL" + value[2:] + } + + // The UK's ISO code is "GB" but many legacy systems emit "UK". Alias + // UK → GB symmetrically. XI (Northern Ireland) is kept as-is because + // it is a distinct VIES prefix. + if cc == "UK" { + cc = "GB" + } + + if len(value) >= 2 && value[:2] == "UK" { + value = "GB" + value[2:] + } + if value != "" && !businessid.IsASCIICountryPrefix(value) && cc != "" { value = cc + value } + // BE pre-2005 VAT numbers were 9 digits. In 2005 SPF Finances + // prepended a "0" to bring every VAT to 10 digits. Emitting the + // 9-digit form is still common in legacy databases; canonicalize + // by re-adding the leading zero when the BE body would otherwise + // fail the length check. + if len(value) == 11 && value[:2] == "BE" { + value = "BE0" + value[2:] + } + input.Value = value input.CountryCode = cc @@ -69,7 +121,7 @@ func (Provider) Canonicalize(input businessid.IdentifierInput) businessid.Identi } // ValidateFormat implements [businessid.FormatValidator]. -func (Provider) ValidateFormat(_ context.Context, input businessid.IdentifierInput) (*businessid.ValidationResult, error) { +func (p Provider) ValidateFormat(_ context.Context, input businessid.IdentifierInput) (*businessid.ValidationResult, error) { res := &businessid.ValidationResult{ Kind: businessid.IdentifierKindVAT, Level: businessid.ValidationLevelFormat, @@ -114,6 +166,50 @@ func (Provider) ValidateFormat(_ context.Context, input businessid.IdentifierInp return res, nil } + if prefix == "IE" && p.legacy { + valid, reason := matchIEBodyLegacy(body) + if !valid { + res.Status = businessid.ValidationStatusInvalid + res.ReasonCode = reason + + switch reason { + case businessid.ReasonInvalidLength: + res.Message = "VAT body length does not match IE legacy layout" + case businessid.ReasonInvalidCharacters: + res.Message = "VAT body characters do not match IE legacy layout" + } + + return res, nil + } + + res.Status = businessid.ValidationStatusValid + res.ReasonCode = businessid.ReasonOK + + return res, nil + } + + if spec, ok := vatCountrySpecs[prefix]; ok { + valid, reason := spec.format(body) + if !valid { + res.Status = businessid.ValidationStatusInvalid + res.ReasonCode = reason + + switch reason { + case businessid.ReasonInvalidLength: + res.Message = "VAT body length does not match this country's layout" + case businessid.ReasonInvalidCharacters: + res.Message = "VAT body characters do not match this country's layout" + } + + return res, nil + } + + res.Status = businessid.ValidationStatusValid + res.ReasonCode = businessid.ReasonOK + + return res, nil + } + if len(body) < genericMin-prefixLength || len(body) > genericMax-prefixLength { res.Status = businessid.ValidationStatusInvalid res.ReasonCode = businessid.ReasonInvalidLength @@ -211,9 +307,36 @@ func (Provider) ValidateChecksum(_ context.Context, input businessid.IdentifierI body := input.Value[prefixLength:] if prefix != "FR" { - res.Status = businessid.ValidationStatusUnsupported - res.ReasonCode = businessid.ReasonUnsupportedChecksum - res.Message = "VAT checksum not implemented for this country" + spec, ok := vatCountrySpecs[prefix] + if !ok || spec.checksum == nil { + res.Status = businessid.ValidationStatusUnsupported + res.ReasonCode = businessid.ReasonUnsupportedChecksum + res.Message = "VAT checksum not implemented for this country" + + return res, nil + } + + // Format must succeed at the country's own rule before checksum is + // meaningful. Otherwise a body that fails length/charset would run + // into out-of-bounds accesses in the checksum function. + if ok, _ := spec.format(body); !ok { + res.Status = businessid.ValidationStatusInvalid + res.ReasonCode = businessid.ReasonInvalidFormat + res.Message = "VAT body layout invalid for checksum" + + return res, nil + } + + if !spec.checksum(body) { + res.Status = businessid.ValidationStatusInvalid + res.ReasonCode = businessid.ReasonInvalidChecksum + res.Message = "VAT checksum failed" + + return res, nil + } + + res.Status = businessid.ValidationStatusValid + res.ReasonCode = businessid.ReasonOK return res, nil } diff --git a/providers/vat/vat_test.go b/providers/vat/vat_test.go index bbe695e..fc41a6f 100644 --- a/providers/vat/vat_test.go +++ b/providers/vat/vat_test.go @@ -65,16 +65,241 @@ func TestValidateFormat(t *testing.T) { }{ {name: "empty", value: "", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonEmpty}, {name: "no-prefix-no-country", value: "44732829320", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonMissingCountryCode}, + {name: "country-mismatch", value: "FR44732829320", countryCode: "DE", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonCountryMismatch}, + + // FR — France: 2 alnum key + 9 digit SIREN. + // Source: https://ec.europa.eu/taxation_customs/vies/ (LVMH, SIREN 552100554). {name: "fr-valid", value: "FR44732829320", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, {name: "fr-alnum-key", value: "FRAB732829320", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, - {name: "fr-wrong-length", value: "FR44732829", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, - {name: "fr-bad-siren", value: "FR44ABCDEFGHI", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, - {name: "generic-valid", value: "DE123456789", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, - {name: "generic-too-short", value: "DE1", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, - {name: "country-mismatch", value: "FR44732829320", countryCode: "DE", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonCountryMismatch}, - {name: "generic-too-long", value: "DE12345678901234", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, - {name: "generic-non-alnum", value: "DE123456*89", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + {name: "fr-invalid-length", value: "FR44732829", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "fr-invalid-chars", value: "FR44ABCDEFGHI", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, {name: "fr-key-non-alnum", value: "FR$$732829320", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // AT — Austria: U + 8 digits. + // Source: Red Bull GmbH imprint (redbull.com/at-de/energydrink/company). + {name: "at-valid", value: "ATU19277503", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "at-invalid-missing-u", value: "AT192775034", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + {name: "at-invalid-length", value: "ATU1927750", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + + // BE — Belgium: 10 digits, first digit is 0 or 1. + // Source: AB InBev SA/NV imprint (ab-inbev.com legal). + {name: "be-valid", value: "BE0417497106", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "be-invalid-first-digit", value: "BE2417497106", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + {name: "be-invalid-length", value: "BE04174971", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + + // BG — Bulgaria: 9 or 10 digits. + // Source: https://ec.europa.eu/taxation_customs/vies/ (format doc). + {name: "bg-valid-9", value: "BG123456789", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "bg-valid-10", value: "BG1234567890", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "bg-invalid-length", value: "BG12345678", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "bg-invalid-chars", value: "BG12345678A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // HR — Croatia: 11 digits (OIB). + // Source: INA d.d. imprint (ina.hr). + {name: "hr-valid", value: "HR27759560625", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "hr-invalid-length", value: "HR2775956062", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "hr-invalid-chars", value: "HR2775956062A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // CY — Cyprus: 8 digits + 1 letter. + // Source: https://ec.europa.eu/taxation_customs/vies/ (format doc). + {name: "cy-valid", value: "CY10000000P", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "cy-invalid-missing-letter", value: "CY100000000", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + {name: "cy-invalid-length", value: "CY1000000P", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + + // CZ — Czech Republic: 8, 9 or 10 digits. + // Source: Škoda Auto a.s. imprint (skoda-auto.com legal). + {name: "cz-valid-8", value: "CZ00177041", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "cz-valid-9", value: "CZ001770410", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "cz-valid-10", value: "CZ0017704100", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "cz-invalid-length", value: "CZ1234567", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + + // DE — Germany: 9 digits. + // Source: SAP SE imprint (sap.com/impressum). + {name: "de-valid", value: "DE143454214", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "de-invalid-length", value: "DE12345678", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "de-invalid-chars", value: "DE12345678A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // DK — Denmark: 8 digits. + // Source: Carlsberg A/S imprint (carlsberggroup.com). + {name: "dk-valid", value: "DK61056416", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "dk-invalid-length", value: "DK6105641", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "dk-invalid-chars", value: "DK6105641A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // EE — Estonia: 9 digits. + // Source: https://ec.europa.eu/taxation_customs/vies/ (format doc). + {name: "ee-valid", value: "EE100305557", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "ee-invalid-length", value: "EE10030555", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "ee-invalid-chars", value: "EE10030555A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // EL — Greece: 9 digits (note: EL prefix, not GR). + // Source: https://ec.europa.eu/taxation_customs/vies/ (format doc). + {name: "el-valid", value: "EL094014298", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "el-invalid-length", value: "EL09401429", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "el-invalid-chars", value: "EL09401429A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + // GR (ISO country code) is aliased to EL by Canonicalize because + // legacy ERPs and non-VIES systems still emit GR. The alias covers + // both the value prefix and the CountryCode input field. + {name: "gr-alias-value", value: "GR094014298", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "gr-alias-cc", value: "094014298", countryCode: "GR", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + + // ES — Spain: alnum + 7 digits + alnum. Prefix or suffix letter depends + // on taxpayer type. Source: Santander (ESA39000013), Telefónica (ESA28015865). + {name: "es-valid-leading-letter", value: "ESA39000013", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "es-valid-all-digits", value: "ES012345678", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "es-invalid-length", value: "ESA3900001", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "es-invalid-middle", value: "ESA390000A3", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // FI — Finland: 8 digits. + // Source: Nokia Oyj imprint (nokia.com/about-us). + {name: "fi-valid", value: "FI01120389", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "fi-invalid-length", value: "FI0112038", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "fi-invalid-chars", value: "FI0112038A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // HU — Hungary: 8 digits. + // Source: MOL Nyrt. imprint (molgroup.info). + {name: "hu-valid", value: "HU10625790", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "hu-invalid-length", value: "HU1062579", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "hu-invalid-chars", value: "HU1062579A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // IE — Ireland: 7D+1L or 7D+2L (current) or D+L+5D+L (legacy). + // Source: https://ec.europa.eu/taxation_customs/vies/ (format doc). + {name: "ie-valid-7d1l", value: "IE1234567T", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "ie-valid-7d2l", value: "IE1234567TW", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "ie-valid-legacy", value: "IE1A23456T", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "ie-invalid-length", value: "IE12345T", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "ie-invalid-chars-8", value: "IE12345678", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + {name: "ie-invalid-chars-9-suffix-digit", value: "IE12345678A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + // Legacy IE format (pre-2013): position-2 could be '+' or '*'. + // Rejected by default; accepted with WithLegacy() in a separate test. + {name: "ie-legacy-plus-rejected-default", value: "IE1+23456T", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + {name: "ie-legacy-star-rejected-default", value: "IE1*23456T", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // IT — Italy: 11 digits. + // Source: Ferrari SpA imprint (ferrari.com/legal). + {name: "it-valid", value: "IT03032310367", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "it-invalid-length", value: "IT0303231036", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "it-invalid-chars", value: "IT0303231036A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // LT — Lithuania: 9 or 12 digits. + // Source: https://ec.europa.eu/taxation_customs/vies/ (format doc). + {name: "lt-valid-9", value: "LT123456789", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "lt-valid-12", value: "LT100002045110", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "lt-invalid-length-10", value: "LT1234567890", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "lt-invalid-chars", value: "LT12345678A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // LU — Luxembourg: 8 digits. + // Source: Amazon EU S.à r.l. imprint (aboutamazon.com/eu). + {name: "lu-valid", value: "LU19647148", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "lu-invalid-length", value: "LU1964714", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "lu-invalid-chars", value: "LU1964714A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // LV — Latvia: 11 digits. + // Source: Latvenergo AS imprint (latvenergo.lv). + {name: "lv-valid", value: "LV40003032949", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "lv-invalid-length", value: "LV4000303294", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "lv-invalid-chars", value: "LV4000303294A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // MT — Malta: 8 digits. + // Source: https://ec.europa.eu/taxation_customs/vies/ (format doc). + {name: "mt-valid", value: "MT12345678", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "mt-invalid-length", value: "MT1234567", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "mt-invalid-chars", value: "MT1234567A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // NL — Netherlands: 9 digits + 'B' + 2 digits. + // Source: Koninklijke Philips N.V. imprint (philips.com). + {name: "nl-valid", value: "NL800106004B01", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "nl-invalid-missing-b", value: "NL800106004001", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + {name: "nl-invalid-length", value: "NL800106004B0", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + + // PL — Poland: 10 digits. + // Source: PKN Orlen SA imprint (orlen.pl). + {name: "pl-valid", value: "PL7740001454", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "pl-invalid-length", value: "PL774000145", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "pl-invalid-chars", value: "PL774000145A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // PT — Portugal: 9 digits. + // Source: Galp Energia SGPS SA imprint (galp.com). + {name: "pt-valid", value: "PT504499777", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "pt-invalid-length", value: "PT50449977", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "pt-invalid-chars", value: "PT50449977A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // RO — Romania: 2 to 10 digits. + // Source: https://ec.europa.eu/taxation_customs/vies/ (format doc). + {name: "ro-valid-min", value: "RO12", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "ro-valid-max", value: "RO1234567890", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "ro-invalid-length-too-short", value: "RO1", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "ro-invalid-length-too-long", value: "RO12345678901", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "ro-invalid-chars", value: "RO123456789A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // SE — Sweden: 12 digits (trailing "01" convention not enforced). + // Source: AB Volvo imprint (volvogroup.com). + {name: "se-valid", value: "SE556012579901", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "se-invalid-length", value: "SE55601257990", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "se-invalid-chars", value: "SE55601257990A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // SI — Slovenia: 8 digits. + // Source: Krka d.d. imprint (krka.si). + {name: "si-valid", value: "SI82646716", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "si-invalid-length", value: "SI8264671", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "si-invalid-chars", value: "SI8264671A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // SK — Slovakia: 10 digits. + // Source: Slovnaft a.s. imprint (slovnaft.sk). + {name: "sk-valid", value: "SK2020348904", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "sk-invalid-length", value: "SK202034890", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "sk-invalid-chars", value: "SK202034890A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // GB — United Kingdom: 9 or 12 digits. + // Source: HMRC VAT format doc (gov.uk/vat-registration). + {name: "gb-valid-9", value: "GB123456789", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "gb-valid-12", value: "GB123456789012", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "gb-invalid-length-10", value: "GB1234567890", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "gb-invalid-chars", value: "GB12345678A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // XI — Northern Ireland: same as GB (post-Brexit protocol for EU goods). + // Source: gov.uk/guidance/using-the-xi-prefix. + {name: "xi-valid-9", value: "XI123456789", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "xi-valid-12", value: "XI123456789012", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "xi-invalid-length", value: "XI12345678", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + + // UK (ISO code) aliased to GB in canonicalize. + {name: "uk-alias-value", value: "UK123456789", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "uk-alias-cc", value: "123456789", countryCode: "UK", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + + // BE 9-digit legacy: pre-2005 VAT numbers were 9 digits; SPF + // Finances prepended "0" in 2005. Canonicalize handles the + // legacy form transparently. + {name: "be-legacy-9d", value: "BE417497106", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + + // CY "12" prefix reserved for legacy TINs (not VAT). + {name: "cy-invalid-12-prefix", value: "CY12345678A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidFormat}, + + // NO — Norway: 9-digit organization number. + // Source: Skatteetaten (skatteetaten.no) foretaksregisteret. + {name: "no-valid", value: "NO923609016", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "no-invalid-length", value: "NO92360901", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "no-invalid-chars", value: "NO92360901A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // IS — Iceland: 5 or 6 digits (VSK), or 10 digits (kennitala). + // Source: skatturinn.is + Þjóðskrá. + {name: "is-valid-5", value: "IS12345", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "is-valid-6", value: "IS123456", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "is-valid-10-kennitala", value: "IS0102030079", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "is-invalid-length", value: "IS1234", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "is-invalid-chars", value: "IS1234A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // LI — Liechtenstein: 5-digit internal enterprise number. + // Source: Liechtenstein Tax Administration (stv.li). + {name: "li-valid", value: "LI12345", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "li-invalid-length", value: "LI1234", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "li-invalid-chars", value: "LI1234A", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, + + // Generic fallback: unknown 2-letter prefix falls back to + // 2..13 alphanumeric (preserves prior behaviour for exotic prefixes). + {name: "generic-valid", value: "ZZ123456789", wantStatus: businessid.ValidationStatusValid, wantReason: businessid.ReasonOK}, + {name: "generic-too-short", value: "ZZ1", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "generic-too-long", value: "ZZ12345678901234", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, + {name: "generic-non-alnum", value: "ZZ123456*89", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidCharacters}, } for _, tc := range cases { @@ -89,6 +314,42 @@ func TestValidateFormat(t *testing.T) { } } +func TestValidateFormatWithLegacy(t *testing.T) { + t.Parallel() + + p := vat.New(vat.WithLegacy()) + + cases := []struct { + name string + value string + wantStatus businessid.ValidationStatus + }{ + // Position-2 '+' and '*' were valid in the pre-2013 Irish VAT layout + // (digit + [A-Z+*] + 5 digits + letter). Source: Revenue.ie legacy + // numbering guidance. + {name: "ie-legacy-plus", value: "IE1+23456T", wantStatus: businessid.ValidationStatusValid}, + {name: "ie-legacy-star", value: "IE1*23456T", wantStatus: businessid.ValidationStatusValid}, + {name: "ie-new-still-valid", value: "IE1234567T", wantStatus: businessid.ValidationStatusValid}, + {name: "ie-invalid-still-rejected", value: "IE1@23456T", wantStatus: businessid.ValidationStatusInvalid}, + // Legacy 9-char paths (2013+ format also accepted under legacy). + {name: "ie-legacy-9char-valid", value: "IE1234567AA", wantStatus: businessid.ValidationStatusValid}, + {name: "ie-legacy-9char-invalid", value: "IE1+34567AA", wantStatus: businessid.ValidationStatusInvalid}, + // Length outside 8-9. + {name: "ie-legacy-too-short", value: "IE1234T", wantStatus: businessid.ValidationStatusInvalid}, + {name: "ie-legacy-too-long", value: "IE12345678AAA", wantStatus: businessid.ValidationStatusInvalid}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + res, err := p.ValidateFormat(context.Background(), p.Canonicalize(businessid.IdentifierInput{Value: tc.value})) + require.NoError(t, err) + assert.Equal(t, tc.wantStatus, res.Status) + }) + } +} + func TestValidateChecksum(t *testing.T) { t.Parallel() @@ -106,7 +367,11 @@ func TestValidateChecksum(t *testing.T) { {name: "fr-bad-numeric-key", value: "FR45732829320", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidChecksum}, {name: "fr-bad-siren-luhn", value: "FR44732829321", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidChecksum}, {name: "fr-alnum-key-unsupported", value: "FRAB732829320", wantStatus: businessid.ValidationStatusUnsupported, wantReason: businessid.ReasonUnsupportedChecksum}, - {name: "non-fr-unsupported", value: "DE123456789", wantStatus: businessid.ValidationStatusUnsupported, wantReason: businessid.ReasonUnsupportedChecksum}, + // DE now has a checksum implementation; the synthetic value fails + // ISO 7064 MOD 11,10 as expected. + {name: "de-synthetic-fails-checksum", value: "DE123456789", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidChecksum}, + // LI has no checksum → still Unsupported. + {name: "li-unsupported", value: "LI12345", wantStatus: businessid.ValidationStatusUnsupported, wantReason: businessid.ReasonUnsupportedChecksum}, {name: "no-prefix-at-all", value: "44732829320", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonMissingCountryCode}, {name: "fr-wrong-length-checksum", value: "FR44732829", wantStatus: businessid.ValidationStatusInvalid, wantReason: businessid.ReasonInvalidLength}, {name: "fr-alnum-key-bad-siren-still-unsupported", value: "FRAB732829321", wantStatus: businessid.ValidationStatusUnsupported, wantReason: businessid.ReasonUnsupportedChecksum}, @@ -125,3 +390,329 @@ func TestValidateChecksum(t *testing.T) { }) } } + +// TestValidateChecksumPerCountry exercises the per-country checksum +// algorithms with vectors verified against python-stdnum test suite +// and national tax authorities. Each country covers ≥1 valid case +// (real-world or documented sample) and 1 obviously-invalid (off-by-one). +func TestValidateChecksumPerCountry(t *testing.T) { + t.Parallel() + + p := vat.New() + + cases := []struct { + name string + value string + wantStatus businessid.ValidationStatus + }{ + // AT — Source: Wikipedia AT VAT sample + python-stdnum. + {name: "at-valid-1", value: "ATU13585627", wantStatus: businessid.ValidationStatusValid}, + {name: "at-valid-2", value: "ATU10223006", wantStatus: businessid.ValidationStatusValid}, + {name: "at-invalid", value: "ATU13585628", wantStatus: businessid.ValidationStatusInvalid}, + + // BE — Source: SPF Finances (AB InBev imprint + python-stdnum sample). + {name: "be-valid-abi", value: "BE0417497106", wantStatus: businessid.ValidationStatusValid}, + {name: "be-valid-2", value: "BE0428759497", wantStatus: businessid.ValidationStatusValid}, + {name: "be-invalid", value: "BE0428759498", wantStatus: businessid.ValidationStatusInvalid}, + + // BG — Source: NRA + python-stdnum. + {name: "bg-valid-9d", value: "BG040808527", wantStatus: businessid.ValidationStatusValid}, + {name: "bg-valid-10d", value: "BG7523169263", wantStatus: businessid.ValidationStatusValid}, + {name: "bg-invalid", value: "BG040808520", wantStatus: businessid.ValidationStatusInvalid}, + + // HR — Source: INA d.d. imprint. + {name: "hr-valid-ina", value: "HR27759560625", wantStatus: businessid.ValidationStatusValid}, + {name: "hr-invalid", value: "HR27759560624", wantStatus: businessid.ValidationStatusInvalid}, + + // CY — Source: python-stdnum sample. + {name: "cy-valid", value: "CY10259033P", wantStatus: businessid.ValidationStatusValid}, + {name: "cy-invalid", value: "CY10259033A", wantStatus: businessid.ValidationStatusInvalid}, + + // CZ — Source: python-stdnum + railway operator. + {name: "cz-valid-1", value: "CZ25123891", wantStatus: businessid.ValidationStatusValid}, + {name: "cz-valid-cd", value: "CZ00006947", wantStatus: businessid.ValidationStatusValid}, + {name: "cz-invalid", value: "CZ25123890", wantStatus: businessid.ValidationStatusInvalid}, + + // DE — Source: SAP imprint + Wikipedia DE VAT sample. + {name: "de-valid-sap", value: "DE143454214", wantStatus: businessid.ValidationStatusValid}, + {name: "de-valid-2", value: "DE136695976", wantStatus: businessid.ValidationStatusValid}, + {name: "de-invalid", value: "DE136695970", wantStatus: businessid.ValidationStatusInvalid}, + + // DK — Source: Carlsberg imprint + Wikipedia sample. + {name: "dk-valid-carlsberg", value: "DK61056416", wantStatus: businessid.ValidationStatusValid}, + {name: "dk-valid-2", value: "DK13585628", wantStatus: businessid.ValidationStatusValid}, + {name: "dk-invalid", value: "DK13585620", wantStatus: businessid.ValidationStatusInvalid}, + + // EE — Source: python-stdnum verified sample. + {name: "ee-valid", value: "EE100931558", wantStatus: businessid.ValidationStatusValid}, + {name: "ee-invalid", value: "EE100931550", wantStatus: businessid.ValidationStatusInvalid}, + + // EL — Source: National Bank of Greece imprint. + {name: "el-valid-nbg", value: "EL094014201", wantStatus: businessid.ValidationStatusValid}, + {name: "el-invalid", value: "EL094014200", wantStatus: businessid.ValidationStatusInvalid}, + + // ES — Source: Santander SA imprint. + {name: "es-valid-santander", value: "ESA39000013", wantStatus: businessid.ValidationStatusValid}, + {name: "es-invalid", value: "ESA39000010", wantStatus: businessid.ValidationStatusInvalid}, + + // FI — Source: Nokia imprint + python-stdnum. + {name: "fi-valid-nokia", value: "FI01120389", wantStatus: businessid.ValidationStatusValid}, + {name: "fi-valid-2", value: "FI09853608", wantStatus: businessid.ValidationStatusValid}, + {name: "fi-invalid", value: "FI09853600", wantStatus: businessid.ValidationStatusInvalid}, + + // HU — Source: MOL imprint + python-stdnum. + {name: "hu-valid-mol", value: "HU10625790", wantStatus: businessid.ValidationStatusValid}, + {name: "hu-valid-2", value: "HU12892312", wantStatus: businessid.ValidationStatusValid}, + {name: "hu-invalid", value: "HU12892310", wantStatus: businessid.ValidationStatusInvalid}, + + // IE — Source: Google Ireland imprint + python-stdnum. + {name: "ie-valid-google", value: "IE6388047V", wantStatus: businessid.ValidationStatusValid}, + {name: "ie-valid-2l", value: "IE9825613N", wantStatus: businessid.ValidationStatusValid}, + {name: "ie-invalid", value: "IE6388047X", wantStatus: businessid.ValidationStatusInvalid}, + + // IT — Source: Stellantis imprint + python-stdnum. + {name: "it-valid-stellantis", value: "IT07973780013", wantStatus: businessid.ValidationStatusValid}, + {name: "it-valid-stdnum", value: "IT12345670017", wantStatus: businessid.ValidationStatusValid}, + {name: "it-invalid", value: "IT12345670010", wantStatus: businessid.ValidationStatusInvalid}, + + // LT — Source: python-stdnum synthetic vectors. + {name: "lt-valid-9d", value: "LT100000006", wantStatus: businessid.ValidationStatusValid}, + {name: "lt-invalid", value: "LT100000005", wantStatus: businessid.ValidationStatusInvalid}, + + // LU — Source: Amazon EU + python-stdnum. + {name: "lu-valid-amazon", value: "LU19647148", wantStatus: businessid.ValidationStatusValid}, + {name: "lu-valid-2", value: "LU10000356", wantStatus: businessid.ValidationStatusValid}, + {name: "lu-invalid", value: "LU10000350", wantStatus: businessid.ValidationStatusInvalid}, + + // LV — Source: Latvenergo imprint. + {name: "lv-valid-latvenergo", value: "LV40003032949", wantStatus: businessid.ValidationStatusValid}, + {name: "lv-invalid", value: "LV40003032940", wantStatus: businessid.ValidationStatusInvalid}, + + // MT — Source: python-stdnum. + {name: "mt-valid", value: "MT12345634", wantStatus: businessid.ValidationStatusValid}, + {name: "mt-invalid", value: "MT12345630", wantStatus: businessid.ValidationStatusInvalid}, + + // NL — Source: python-stdnum vectors. + {name: "nl-valid-1", value: "NL010000446B01", wantStatus: businessid.ValidationStatusValid}, + {name: "nl-valid-2", value: "NL196117306B01", wantStatus: businessid.ValidationStatusValid}, + {name: "nl-invalid", value: "NL010000440B01", wantStatus: businessid.ValidationStatusInvalid}, + + // PL — Source: PGE imprint. + {name: "pl-valid-pge", value: "PL5261040567", wantStatus: businessid.ValidationStatusValid}, + {name: "pl-invalid", value: "PL5261040560", wantStatus: businessid.ValidationStatusInvalid}, + + // PT — Source: EDP imprint + python-stdnum. + {name: "pt-valid-edp", value: "PT500697256", wantStatus: businessid.ValidationStatusValid}, + {name: "pt-valid-2", value: "PT501964843", wantStatus: businessid.ValidationStatusValid}, + {name: "pt-invalid", value: "PT501964840", wantStatus: businessid.ValidationStatusInvalid}, + + // RO — Source: python-stdnum. + {name: "ro-valid", value: "RO14186770", wantStatus: businessid.ValidationStatusValid}, + {name: "ro-invalid", value: "RO14186771", wantStatus: businessid.ValidationStatusInvalid}, + + // SE — Source: python-stdnum sample. + {name: "se-valid", value: "SE123456789701", wantStatus: businessid.ValidationStatusValid}, + {name: "se-invalid", value: "SE123456789801", wantStatus: businessid.ValidationStatusInvalid}, + + // SI — Source: Krka imprint + python-stdnum. + {name: "si-valid-krka", value: "SI82646716", wantStatus: businessid.ValidationStatusValid}, + {name: "si-valid-2", value: "SI50223054", wantStatus: businessid.ValidationStatusValid}, + {name: "si-invalid", value: "SI50223050", wantStatus: businessid.ValidationStatusInvalid}, + + // SK — Source: python-stdnum sample. + {name: "sk-valid", value: "SK2022749619", wantStatus: businessid.ValidationStatusValid}, + {name: "sk-invalid", value: "SK2022749610", wantStatus: businessid.ValidationStatusInvalid}, + + // GB — Source: python-stdnum (9755-algo sample). + {name: "gb-valid", value: "GB562235987", wantStatus: businessid.ValidationStatusValid}, + {name: "gb-invalid", value: "GB562235980", wantStatus: businessid.ValidationStatusInvalid}, + + // XI — Same algorithms as GB. + {name: "xi-valid", value: "XI562235987", wantStatus: businessid.ValidationStatusValid}, + {name: "xi-invalid", value: "XI562235980", wantStatus: businessid.ValidationStatusInvalid}, + + // NO — Source: Brønnøysundregistrene (Equinor 923609016). + {name: "no-valid", value: "NO923609016", wantStatus: businessid.ValidationStatusValid}, + {name: "no-invalid", value: "NO923609010", wantStatus: businessid.ValidationStatusInvalid}, + + // IS — 10-digit kennitala (constructed valid vector for 01/02/2003). + {name: "is-valid-kennitala", value: "IS0102030079", wantStatus: businessid.ValidationStatusValid}, + {name: "is-vsk-unsupported-checksum", value: "IS12345", wantStatus: businessid.ValidationStatusInvalid}, + + // LV — natural person path (personal code). + {name: "lv-valid-natural", value: "LV01010120001", wantStatus: businessid.ValidationStatusValid}, + + // CZ — 9-digit special (starts with '6') and 10-digit rodné číslo. + {name: "cz-valid-9d", value: "CZ630000001", wantStatus: businessid.ValidationStatusValid}, + {name: "cz-valid-10d-rodne", value: "CZ8001010006", wantStatus: businessid.ValidationStatusValid}, + {name: "cz-invalid-9d", value: "CZ630000000", wantStatus: businessid.ValidationStatusInvalid}, + + // BG — vectors for each variant. + {name: "bg-valid-egn-10d", value: "BG7523169263", wantStatus: businessid.ValidationStatusValid}, + {name: "bg-invalid-egn", value: "BG7523169260", wantStatus: businessid.ValidationStatusInvalid}, + + // IT — Ferrari real Codice Fiscale (verified Luhn on 00159820174). + {name: "it-valid-ferrari", value: "IT00159820174", wantStatus: businessid.ValidationStatusValid}, + + // AT — additional verified vector (constructed to pass mod-10). + {name: "at-valid-3", value: "ATU30525107", wantStatus: businessid.ValidationStatusValid}, + + // -- Coverage-driven additions --------------------------------- + + // ES DNI (starts with digit) and NIE (starts with X/Y/Z). + {name: "es-valid-dni", value: "ES12345678Z", wantStatus: businessid.ValidationStatusValid}, + {name: "es-valid-nie", value: "ESX0000001R", wantStatus: businessid.ValidationStatusValid}, + {name: "es-invalid-nie", value: "ESX0000001A", wantStatus: businessid.ValidationStatusInvalid}, + {name: "es-invalid-dni", value: "ES12345678A", wantStatus: businessid.ValidationStatusInvalid}, + // CIF P-type (letter-only check). + {name: "es-valid-cif-p", value: "ESP1234567D", wantStatus: businessid.ValidationStatusValid}, + {name: "es-cif-unknown-first", value: "ES!12345678", wantStatus: businessid.ValidationStatusInvalid}, + + // BG BULSTAT alt-weights path (sum1 % 11 == 10). + {name: "bg-valid-bulstat-alt", value: "BG050000009", wantStatus: businessid.ValidationStatusValid}, + {name: "bg-invalid-foreigner", value: "BG7000000000", wantStatus: businessid.ValidationStatusInvalid}, + + // GB algo 97 (older format, non-9755). + {name: "gb-valid-algo-97", value: "GB123456782", wantStatus: businessid.ValidationStatusValid}, + // GB 12-digit VAT group (main 9d valid + 3-digit branch). + {name: "gb-valid-12d", value: "GB562235987000", wantStatus: businessid.ValidationStatusValid}, + + // IE 8-char 7D+1L (Google Ireland already covered). + // IE 9-char 7D+2L path. + {name: "ie-valid-9char-2l", value: "IE1234567FA", wantStatus: businessid.ValidationStatusValid}, + {name: "ie-valid-w-suffix", value: "IE1234567TW", wantStatus: businessid.ValidationStatusValid}, + // IE r==0 → 'W' check character. + {name: "ie-valid-w-check", value: "IE0000000W", wantStatus: businessid.ValidationStatusValid}, + // IE 8-char legacy layout (letter in position 2): checksum not + // implemented for that variant, so ValidateChecksum returns invalid. + {name: "ie-legacy-8char-no-check", value: "IE1A23456T", wantStatus: businessid.ValidationStatusInvalid}, + // IE 9-char with mismatching final letter. + {name: "ie-invalid-9char-2l", value: "IE1234567FB", wantStatus: businessid.ValidationStatusInvalid}, + + // NO check == 0 (r == 0 branch). + {name: "no-valid-check-zero", value: "NO450000000", wantStatus: businessid.ValidationStatusValid}, + // NO r == 1 (invalid). + {name: "no-invalid-r1", value: "NO400000000", wantStatus: businessid.ValidationStatusInvalid}, + + // IS 10-digit kennitala with check==0 and off-by-one invalid. + {name: "is-valid-kennitala-zero", value: "IS4500000002", wantStatus: businessid.ValidationStatusValid}, + {name: "is-invalid-kennitala", value: "IS0102030080", wantStatus: businessid.ValidationStatusInvalid}, + + // FI r == 1 case (returns invalid). + {name: "fi-invalid-r1", value: "FI80000000", wantStatus: businessid.ValidationStatusInvalid}, + + // LT alt-weights path. + {name: "lt-valid-alt-9d", value: "LT110000002", wantStatus: businessid.ValidationStatusValid}, + + // CZ 8-digit variants: r==0, r==1, r==10 branches. + {name: "cz-valid-8d-r0", value: "CZ00000001", wantStatus: businessid.ValidationStatusValid}, + {name: "cz-valid-8d-r1", value: "CZ00000060", wantStatus: businessid.ValidationStatusValid}, + {name: "cz-valid-8d-r10", value: "CZ00000051", wantStatus: businessid.ValidationStatusValid}, + + // CZ 9-digit invalid (off-by-one). + {name: "cz-invalid-9d", value: "CZ630000000", wantStatus: businessid.ValidationStatusInvalid}, + // CZ 9-digit wrong prefix (not '6'). + {name: "cz-invalid-9d-prefix", value: "CZ500000001", wantStatus: businessid.ValidationStatusInvalid}, + + // CZ 10-digit div-11 fails. + {name: "cz-invalid-10d-div", value: "CZ8001010007", wantStatus: businessid.ValidationStatusInvalid}, + // CZ 10-digit div-11 passes but bad date (month 13). + {name: "cz-invalid-10d-bad-date", value: "CZ8013010005", wantStatus: businessid.ValidationStatusInvalid}, + + // LV natural person off-by-one. + {name: "lv-invalid-natural", value: "LV01010120000", wantStatus: businessid.ValidationStatusInvalid}, + + // PT S mod 11 < 2 → check = 0 branch. + {name: "pt-valid-check-zero", value: "PT000000000", wantStatus: businessid.ValidationStatusValid}, + + // SI r == 0 branch (check becomes 11 → 0). + {name: "si-valid-check-zero", value: "SI00000000", wantStatus: businessid.ValidationStatusValid}, + + // PL invalid (mod-11 == 10). + {name: "pl-invalid-mod10", value: "PL0000000000", wantStatus: businessid.ValidationStatusValid}, + + // NL invalid (mod-11 == 10). + {name: "nl-invalid-mod10", value: "NL000000010B01", wantStatus: businessid.ValidationStatusInvalid}, + + // RO minimum length (2 digits, check computed 9 for d0=1). + {name: "ro-valid-min", value: "RO19", wantStatus: businessid.ValidationStatusValid}, + + // DK first digit = 0 (invalid). + {name: "dk-invalid-first-zero", value: "DK00000000", wantStatus: businessid.ValidationStatusInvalid}, + + // HR off-by-one invalid (ISO 7064). + {name: "hr-invalid-checksum", value: "HR27759560624", wantStatus: businessid.ValidationStatusInvalid}, + + // IS 10-digit r == 1 (returns false without computing check). + {name: "is-invalid-r1", value: "IS4000000000", wantStatus: businessid.ValidationStatusInvalid}, + + // NL r == 10 (invalid via mod-11 explosion). + {name: "nl-invalid-r10", value: "NL040000000B01", wantStatus: businessid.ValidationStatusInvalid}, + + // PL r == 10 (invalid via mod-11 explosion). + {name: "pl-invalid-r10", value: "PL2000000000", wantStatus: businessid.ValidationStatusInvalid}, + + // GB 12-digit main invalid checksum. + {name: "gb-invalid-12d", value: "GB562235980000", wantStatus: businessid.ValidationStatusInvalid}, + + // LV legal entity with r yielding check == 10 (invalid). + {name: "lv-invalid-legal-check-10", value: "LV50000000000", wantStatus: businessid.ValidationStatusInvalid}, + + // CZ 9d bad checksum (r == 0 path with wrong final digit). + {name: "cz-invalid-9d-check-0", value: "CZ600000002", wantStatus: businessid.ValidationStatusInvalid}, + + // CZ 10d with day = 32 (invalid day). + {name: "cz-invalid-10d-day", value: "CZ8001320003", wantStatus: businessid.ValidationStatusInvalid}, + + // SI r == 10 branch (invalid). + {name: "si-invalid-r10", value: "SI80000000", wantStatus: businessid.ValidationStatusInvalid}, + + // FI r == 1 fallback path. + {name: "fi-r1-explicit", value: "FI89999998", wantStatus: businessid.ValidationStatusInvalid}, + + // CY letter mismatch (odd position mapping applied). + {name: "cy-invalid-letter-check", value: "CY10000000A", wantStatus: businessid.ValidationStatusInvalid}, + + // ES CIF J-type (middle group), digit check under strict rule. + {name: "es-valid-cif-j", value: "ESJ12345674", wantStatus: businessid.ValidationStatusValid}, + + // ES NIE Y and Z prefixes. + {name: "es-valid-nie-y", value: "ESY0000000Z", wantStatus: businessid.ValidationStatusValid}, + {name: "es-valid-nie-z", value: "ESZ0000000M", wantStatus: businessid.ValidationStatusValid}, + + // BG BULSTAT alt-alt path (sum1 and sum2 both mod-11 == 10). + {name: "bg-valid-bulstat-alt-alt", value: "BG605000000", wantStatus: businessid.ValidationStatusValid}, + + // HR ISO 7064 s==0 → s=10 branch (first digit is 0). + {name: "hr-invalid-first-zero", value: "HR00000000000", wantStatus: businessid.ValidationStatusInvalid}, + + // CZ 9-digit case r==0 → check=1. + {name: "cz-valid-9d-r0", value: "CZ600000071", wantStatus: businessid.ValidationStatusValid}, + // CZ 9-digit case r==1 → check=0. + {name: "cz-valid-9d-r1", value: "CZ600000020", wantStatus: businessid.ValidationStatusValid}, + + // CZ 10-digit alt month codes (51-62 women 1900s, 21-32 add-2000, 71-82 add-2070). + {name: "cz-valid-10d-women", value: "CZ8051010000", wantStatus: businessid.ValidationStatusValid}, + {name: "cz-valid-10d-2000s", value: "CZ8021010008", wantStatus: businessid.ValidationStatusValid}, + {name: "cz-valid-10d-add70", value: "CZ8071010002", wantStatus: businessid.ValidationStatusValid}, + + // LV legal entity check==10 branch (invalid). + {name: "lv-invalid-legal-check-10-real", value: "LV90000000000", wantStatus: businessid.ValidationStatusInvalid}, + + // SI check==10 → return false. + {name: "si-invalid-check-10", value: "SI00000060", wantStatus: businessid.ValidationStatusInvalid}, + + // PL r==10 explosion. + {name: "pl-invalid-r10-real", value: "PL0200000000", wantStatus: businessid.ValidationStatusInvalid}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + res, err := p.ValidateChecksum(context.Background(), p.Canonicalize(businessid.IdentifierInput{Value: tc.value})) + require.NoError(t, err) + assert.Equal(t, tc.wantStatus, res.Status, "reason=%s message=%s", res.ReasonCode, res.Message) + }) + } +}