Hubertech.Belgium 0.2.0

dotnet add package Hubertech.Belgium --version 0.2.0
                    
NuGet\Install-Package Hubertech.Belgium -Version 0.2.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Hubertech.Belgium" Version="0.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Hubertech.Belgium" Version="0.2.0" />
                    
Directory.Packages.props
<PackageReference Include="Hubertech.Belgium" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Hubertech.Belgium --version 0.2.0
                    
#r "nuget: Hubertech.Belgium, 0.2.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Hubertech.Belgium@0.2.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Hubertech.Belgium&version=0.2.0
                    
Install as a Cake Addin
#tool nuget:?package=Hubertech.Belgium&version=0.2.0
                    
Install as a Cake Tool

Hubertech.Belgium

NuGet CI

Belgian administrative identifiers and rules for .NET, in one dependency-free package: the enterprise number (BCE/KBO), the structured communication (OGM/VCS), the Belgian IBAN, the social security identification number (NISS/INSZ), public holidays and business days. Every validation error comes with a stable code and a message in English, French or Dutch that can be shown to end users as is.

Status: before 1.0. While the major version is 0, a minor version may change the API; every change is listed in the changelog.

  • One API for every identifier: Parse, TryParse, Validate, IParsable<T>, ISpanFormattable.
  • Errors made for end users: stable codes, localized messages that say what to correct.
  • Modern .NET: value types, no allocation when parsing or formatting, trimming and Native AOT compatible, no dependency beyond the base class library.
  • Ready for ASP.NET Core: System.Text.Json converters and validation attributes, with nothing to register.

This package checks the syntax and the check digits of identifiers. It never calls a remote service: a valid enterprise number is not necessarily assigned to an active enterprise, nor registered for VAT, and a valid IBAN is not necessarily an open account.

Installation

dotnet add package Hubertech.Belgium

The package targets .NET 10.

Enterprise number

EnterpriseNumber is the ten-digit number of the Crossroads Bank for Enterprises (BCE in French, KBO in Dutch), which, prefixed with BE, is also the VAT number of the enterprises registered for VAT.

using Hubertech.Belgium;

var number = EnterpriseNumber.Parse("BE 0202.239.951");

number.ToString();     // "0202.239.951"
number.ToString("N");  // "0202239951"
number.ToString("V");  // "BE0202239951", the VAT form

EnterpriseNumber.Parse("202239951") == number;  // true: the legacy nine-digit form

Spaces, dots, hyphens and the BE prefix, in any case, are accepted. The number must then have ten digits (nine for a legacy number, completed with a leading zero), start with 0 or 1, and end with two check digits equal to 97 minus the remainder of the division of the first eight digits by 97.

Structured communication

StructuredCommunication is the twelve-digit payment reference written +++123/4567/89002+++, whose last two digits check the first ten.

var reference = StructuredCommunication.FromNumber(2_026_000_123);  // from an invoice number, for example

reference.ToString();     // "+++202/6000/12320+++"
reference.ToString("*");  // "***202/6000/12320***"
reference.ToString("N");  // "202600012320"
reference.BaseNumber;     // 2026000123

StructuredCommunication.Parse("123/4567/89002");  // markers are optional

The check digits are the remainder of the division of the first ten digits by 97, or 97 when that remainder is 0. FromNumber accepts numbers from 0 to StructuredCommunication.MaxBaseNumber, 9 999 999 999.

Belgian IBAN

BelgianIban accepts Belgian IBANs only. An IBAN of another country is rejected with a clear message rather than a checksum error.

var iban = BelgianIban.Parse("be68 5390 0754 7034");

iban.ToString();     // "BE68 5390 0754 7034", the paper format
iban.ToString("E");  // "BE68539007547034", the electronic format
iban.BankCode;       // "539"
iban.AccountNumber;  // "539-0075470-34"
iban.Bic;            // null: the National Bank of Belgium gives no BIC for code 539

BelgianIban.Parse("BE48 0011 2345 6727").Bic;  // "GEBABEBB"

BelgianIban.FromLegacyAccountNumber("539-0075470-34") == iban;  // true

Two checks must pass: the IBAN check digits (ISO 7064 MOD 97-10, as for every IBAN) and the check digits of the Belgian account number (the remainder of the division of its first ten digits by 97, or 97 when that remainder is 0). The bank code is not checked against the list of the National Bank of Belgium.

Bic gives the BIC that the list of bank identification codes of the National Bank of Belgium assigns to the bank code, or null when it gives none: a free or unavailable code, or an institution without a BIC. The BIC is returned as published, with eight characters or, for a few codes, eleven. The list is embedded in the package: this version carries the list of 1 September 2026, whereas the National Bank updates it each time a code changes, and refers to the institution or to SWIFT for a specific use. Note that a bank may no longer require the BIC from its customer for a credit transfer or a direct debit in euro within the European Union, at the latest since 1 February 2016 (Regulation (EU) No 260/2012, article 5(7)): banks still exchange it between them, but an application mostly needs it to name the bank or to fill an older format.

Social security identification number

SocialSecurityIdentificationNumber is the NISS (INSZ in Dutch): the national register number of a person registered in the National Register, or the BIS number of a person who is not but deals with the Belgian social security.

var number = SocialSecurityIdentificationNumber.Parse("85.07.30-033.28");

number.ToString();     // "**.**.**-***.28": masked, safe to log
number.ToString("D");  // "85.07.30-033.28"
number.ToString("N");  // "85073003328"
number.Kind;           // SocialSecurityIdentificationNumberKind.NationalRegister
number.BirthDate;      // 1985-07-30, or null when the encoded date is incomplete

Spaces, dots and hyphens are accepted. The number must then have eleven digits, a month of birth from 00 to 12, or increased by 20 or 40 for a BIS number, and two check digits equal to 97 minus the first nine digits modulo 97; for a person born from 2000, these nine digits are preceded by a 2. Check digits that only match a birth after the current year are rejected, so validation depends on the current date.

This number is personal data, and its use is regulated: article 8 of the law of 8 August 1983 organising a National Register restricts the use of the national register number. This package checks the syntax only; it does not tell whether an application may process the number. To keep the number out of logs, ToString() masks it, and the full number needs an explicit format. JSON, however, holds the full number. The sex encoded by the serial number is not exposed.

Since the check digits depend on the century of birth, a few typos go undetected: about 0.2% of the single-digit typos and 0.6% of the swaps of adjacent digits turn a number into a valid one of the other century, 06.02.27-549.42 into 00.02.27-549.42 for instance. For the same reason, BelgianValidationError.Expected is null for this type.

Holidays and business days

BelgianCalendar knows the ten legal holidays, computes Easter rather than reading a table, and counts business days: Monday to Friday, except the holidays asked for.

foreach (var holiday in BelgianCalendar.GetHolidays(2026))
{
    Console.WriteLine($"{holiday.Date:yyyy-MM-dd} {holiday.Name}");  // 2026-04-06 Easter Monday, ...
}

BelgianCalendar.IsBusinessDay(new DateOnly(2026, 4, 6));                                  // false: Easter Monday
BelgianCalendar.AddBusinessDays(new DateOnly(2026, 4, 2), 2);                             // 2026-04-07
BelgianCalendar.CountBusinessDays(new DateOnly(2025, 12, 31), new DateOnly(2026, 12, 31)); // 253

Holiday names follow the current UI culture, in English, French or Dutch; GetName(CultureInfo) asks for a given language. The days off of some employers only are opt-in, through a combination of HolidaySet flags:

HolidaySet Days
Legal (default) 1 January, Easter Monday, 1 May, Ascension Day, Whit Monday, 21 July, 15 August, 1 November, 11 November, 25 December
FlemishCommunity 11 July
FrenchCommunity 27 September
GermanSpeakingCommunity 15 November
FederalPublicService 2 November, 15 November, 26 December
var federal = HolidaySet.Legal | HolidaySet.FederalPublicService;

BelgianCalendar.AddBusinessDays(new DateOnly(2026, 10, 30), 1, federal);  // 2026-11-03: 2 November is off

AddBusinessDays never counts the starting date. CountBusinessDays counts the business days after the start, up to and including the end, so that it is the exact inverse of AddBusinessDays, backward too.

Not modelled: a holiday falling on a weekend stays there, since its replacement day is set by each employer under labour law; the afternoon of 22 July off for federal staff; regional days off and school holidays. The rules in force today apply to every year.

Error handling

Every identifier type offers the same members:

Member Throws Use it to
Parse(string) BelgianFormatException read a value known to be valid
TryParse(string?, out T) never check a value, without the reason
TryParse(string?, out T, out BelgianValidationError) never check a value and explain what is wrong
Validate(string?) never get the error, or null for a valid value

Each one also exists for ReadOnlySpan<char>. There is no result type to adopt: the error composes with whatever the application already uses.

if (!EnterpriseNumber.TryParse(input, out var number, out var error))
{
    // error.Code    == BelgianErrorCode.InvalidChecksum
    // error.Message == "The enterprise number is invalid: its check digits do not match. Please check it for typing errors."
    return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["number"] = [error.Message] });
}

BelgianValidationError gives:

  • Code, a BelgianErrorCode whose values never change, safe to store or to send to a client: Empty, InvalidCharacter, InvalidLength, InvalidCountryCode, InvalidFirstDigit, InvalidChecksum, InvalidBirthDate.
  • Message, in the current UI culture, and GetMessage(CultureInfo), in English, French or Dutch. In French, the message above reads "Le numéro d'entreprise est invalide : ses chiffres de contrôle ne correspondent pas. Vérifiez qu'il ne contient pas de faute de frappe."
  • Position, the index of an invalid character, which the message shows counted from 1.
  • Expected, the check digits that would match the other digits. It is meant for logs and support, and deliberately left out of the message: a mismatch does not tell which digit is wrong, and suggesting new check digits to a user would turn a mistyped number into a valid but wrong one.
  • TypeName, the type whose parser failed.

Creating an error does not allocate; the message is built only when it is read. Parse throws a BelgianFormatException, a FormatException whose Error property carries the same error.

default(EnterpriseNumber), and likewise for the other identifiers, is not a valid value: IsEmpty is true, it formats as an empty string, and parsing never returns it.

ASP.NET Core and JSON

System.Text.Json. The identifiers carry their converter: there is nothing to register, with or without the source generator. JSON holds them without presentation characters, as "0202239951", "123456789002" and "BE68539007547034", and reading accepts any form that Parse accepts. An invalid value throws a JsonException with the localized message and a BelgianFormatException as inner exception. An optional value is a nullable member: null is not a valid EnterpriseNumber, and the empty default value cannot be written. Identifiers can also be dictionary keys.

Validation attributes. [BelgianEnterpriseNumber], [BelgianStructuredCommunication] and [BelgianIban] validate strings, or the identifier types, and report the localized message of the error. As with [RegularExpression], null and the empty string are left to [Required].

public sealed class SupplierForm
{
    [Required, BelgianEnterpriseNumber]
    public string? EnterpriseNumber { get; set; }

    [BelgianIban]
    public string? Account { get; set; }
}

Binding. The identifiers implement IParsable<T>, so ASP.NET Core binds them from a route or a query string. An invalid value then gets a plain 400, without its reason: to tell the user what is wrong, take a string and call the TryParse overload that returns the error.

Sample API

samples/Hubertech.Belgium.Samples.Api shows the error handling end to end, in a minimal API published with Native AOT. POST /invoices takes the identifiers as typed by a user and lists every invalid field in a problem details response (RFC 9457), with a JSON pointer, the stable code and the message in the language of the Accept-Language header:

POST /invoices
Accept-Language: fr-BE

{ "supplier": "0202.239.952", "account": "NL91 ABNA 0417 1643 00" }
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "The invoice is not valid.",
  "status": 400,
  "errors": [
    { "pointer": "#/supplier", "code": "InvalidChecksum", "detail": "Le numéro d'entreprise est invalide : ses chiffres de contrôle ne correspondent pas. Vérifiez qu'il ne contient pas de faute de frappe." },
    { "pointer": "#/account", "code": "InvalidCountryCode", "detail": "Seuls les IBAN belges sont acceptés (commençant par BE)." },
    { "pointer": "#/reference", "code": "Empty", "detail": "La communication structurée est vide." }
  ]
}

It also binds an EnterpriseNumber from the route, and moves dates by business days. Its .http file sends these requests by hand.

Performance

Parsing and formatting into a span never allocate, for valid and invalid input alike: a unit test measures it on every build. Indicative figures, measured with BenchmarkDotNet on a shared cloud virtual machine (Intel Xeon 2.1 GHz, .NET 10.0.12, short runs), so orders of magnitude rather than precise numbers:

Operation Time Allocated
Parse an enterprise number, BE 0202.239.951 20 ns 0 B
Parse an invalid enterprise number, error included 20 ns 0 B
Parse a structured communication 40 ns 0 B
Parse an IBAN 35 ns 0 B
Format an IBAN into a span 30 ns 0 B
BelgianIban.ToString() 65 ns 64 B, the string
Read a JSON payment holding the three identifiers 650 ns 168 B
The same payment holding plain strings, unchecked 420 ns 280 B
BelgianCalendar.IsBusinessDay 35 ns 0 B
BelgianCalendar.CountBusinessDays over a year 20 µs 0 B

Reading typed identifiers from JSON costs about half as much time again as reading unchecked strings, and allocates less, since no string is created for them. Business day arithmetic is linear in the number of calendar days, about 50 ns per day: negligible for payment terms and deadlines, 2 ms for a century.

The benchmarks are in benchmarks; CONTRIBUTING.md tells how to run them.

Trimming and Native AOT

The library uses no reflection: it is marked trimmable and AOT compatible, and the analyzers check it on every build. In a Native AOT application, the French and Dutch messages are compiled into the executable.

The messages need culture data. In globalization-invariant mode, which the webapiaot template of ASP.NET Core turns on with <InvariantGlobalization>true</InvariantGlobalization>, no culture but the invariant one can be created; even with PredefinedCulturesOnly turned off, a regional culture such as fr-BE then loses its parent and gets English messages. Keep culture data to serve French and Dutch, as the sample does.

Sources

Rule Source
Structure of the enterprise number: ten digits, the last two being check digits Royal Decree of 24 June 2003, as amended
Enterprise numbers starting with 1 FPS Economy
Check digits of the enterprise number: 97 minus the first eight digits modulo 97 No official text found: see below
Check digits of the structured communication Febelfin banking standards
Format of the Belgian IBAN SWIFT IBAN registry
Bank codes and their BIC National Bank of Belgium, grouped list of 1 September 2026
Structure of the national register number and its check digits, including for a birth from 2000 Royal Decree of 3 April 1984; National Register, instruction TI000
Month of birth of the BIS number, increased by 20 or 40 Royal Decree of 8 February 1991, article 2
Restricted use of the national register number Law of 8 August 1983 organising a National Register, article 8
Legal holidays Royal Decree of 18 April 1974
Days off of the federal public services Royal Decree of 19 November 1998, article 14
Days of the Flemish and French communities Decrees of 7 November 1990 and 3 July 1991
Day of the German-speaking Community No decree reference found
Easter Sunday Anonymous Gregorian algorithm (Meeus, Jones, Butcher), checked against python-dateutil from 2020 to 2035

The Royal Decree of 24 June 2003 sets where the check digits of the enterprise number are, but no official text describing how they are computed was found. The rule applied here is the one that published enterprise numbers satisfy and that other validation libraries, such as python-stdnum, implement. Links to official texts for the rules marked as unsourced, and corrections, are welcome through an issue.

Roadmap

Candidates for the next versions, none of them committed yet:

  • German messages and holiday names, the third official language of Belgium.
  • The establishment unit number, once its check digit rule is confirmed in an official text.
  • Postal codes.
  • Separate packages for Entity Framework Core value converters and FluentValidation rules.

Contributing

See CONTRIBUTING.md. Design decisions are recorded in docs/adr.

License

MIT, by Dampsey.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.2.0 114 10/8/2026
0.1.0 128 10/5/2026