From 54079223b0f3c57400664cec7e7192442585b960 Mon Sep 17 00:00:00 2001 From: "christian.vogt@hiquaro.com" Date: Mon, 21 Sep 2026 18:48:28 +0200 Subject: [PATCH 1/3] Add VatIdSource for six countries with their real check digits Germany and Croatia run on ISO 7064 MOD 11,10, Austria on the BMF digit sum with its 96 minus sum step, the Netherlands and Poland on a weighted mod 11 and Italy on Luhn with the office code kept in the range that is handed out. The tests are calibrated against twelve registered VAT IDs of real companies and the worked example of the BMF specification before they judge the generator, and the validators run the arithmetic in the opposite direction: ISO 7064 via its closure property, the weighted schemes with the check digit weighted minus one. Co-Authored-By: Claude Fable 5 --- .../Identifiers/IdentifierValidation.cs | 139 +++++++++++ .../Identifiers/VatIdSourceTests.cs | 149 ++++++++++++ .../DataSources/Identifiers/VatIdSource.cs | 217 ++++++++++++++++++ holonsoft.AutoPoco/Util/CheckDigits.cs | 61 +++++ 4 files changed, 566 insertions(+) create mode 100644 holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/VatIdSourceTests.cs create mode 100644 holonsoft.AutoPoco/DataSources/Identifiers/VatIdSource.cs diff --git a/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/IdentifierValidation.cs b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/IdentifierValidation.cs index c43a727..ee43d35 100644 --- a/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/IdentifierValidation.cs +++ b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/IdentifierValidation.cs @@ -146,6 +146,145 @@ public static bool IsValidIsin(string? value) { return IsValidLuhn(expanded.ToString()); } + /// + /// True when a complete EU VAT ID with its country prefix carries a correct check digit. Only the + /// countries the source generates are known here. + /// + public static bool IsValidVatId(string? value) { + if (value is null || value.Length < 4) + return false; + + return value[..2] switch { + "AT" => IsValidAustrianVatId(value), + "DE" => IsValidGermanVatId(value), + "HR" => IsValidCroatianVatId(value), + "IT" => IsValidItalianVatId(value), + "NL" => IsValidDutchVatId(value), + "PL" => IsValidPolishVatId(value), + _ => false + }; + } + + /// + /// ATU and eight digits. Validated the other way round than the generator: the check digit is added to + /// the weighted digit sum, and the total has to land on 96 mod 10, i.e. on 6. + /// + private static bool IsValidAustrianVatId(string value) { + if (value.Length != 11 || value[2] != 'U') + return false; + + var sum = 0; + + for (var i = 0; i < 7; i++) { + var c = value[3 + i]; + if (c is < '0' or > '9') + return false; + + var digit = (c - '0') * (i % 2 == 0 ? 1 : 2); + if (digit > 9) + digit = (digit / 10) + (digit % 10); + + sum += digit; + } + + if (value[10] is < '0' or > '9') + return false; + + return (sum + (value[10] - '0')) % 10 == 6; + } + + /// + /// DE and nine digits, the first one no zero. Validated with the closure property of ISO 7064 MOD 11,10: + /// running the product chain over the complete number, check digit included, has to end on a sum of one. + /// + private static bool IsValidGermanVatId(string value) + => value.Length == 11 && value[2] != '0' && RunsIso7064Mod1110ToOne(value.AsSpan(2)); + + /// + /// HR and the eleven digit OIB, the same closure property as the German number. + /// + private static bool IsValidCroatianVatId(string value) + => value.Length == 13 && RunsIso7064Mod1110ToOne(value.AsSpan(2)); + + /// + /// IT and eleven digits: the Luhn check over the complete number and an office code between 001 and 100. + /// + private static bool IsValidItalianVatId(string value) { + if (value.Length != 13 || !IsValidLuhn(value[2..])) + return false; + + var office = int.Parse(value.AsSpan(9, 3), System.Globalization.CultureInfo.InvariantCulture); + return office is >= 1 and <= 100; + } + + /// + /// NL, nine digits, a B and a two digit suffix that is not 00. Validated with the check digit weighted + /// minus one, so the weighted sum over all nine digits has to be divisible by eleven. + /// + private static bool IsValidDutchVatId(string value) { + if (value.Length != 14 || value[11] != 'B') + return false; + + if (value[12] is < '0' or > '9' || value[13] is < '0' or > '9' || (value[12] == '0' && value[13] == '0')) + return false; + + var sum = 0; + + for (var i = 0; i < 9; i++) { + var c = value[2 + i]; + if (c is < '0' or > '9') + return false; + + sum += (c - '0') * (i < 8 ? 9 - i : -1); + } + + return sum % 11 == 0; + } + + /// + /// PL and the ten digit NIP, the first digit no zero, the check digit weighted minus one so the weighted + /// sum over all ten digits has to be divisible by eleven. + /// + private static bool IsValidPolishVatId(string value) { + if (value.Length != 12 || value[2] == '0') + return false; + + ReadOnlySpan weights = [6, 5, 7, 2, 3, 4, 5, 6, 7, -1]; + var sum = 0; + + for (var i = 0; i < 10; i++) { + var c = value[2 + i]; + if (c is < '0' or > '9') + return false; + + sum += (c - '0') * weights[i]; + } + + return sum % 11 == 0; + } + + /// + /// The closure property of ISO 7064 MOD 11,10: fold every digit into the running product, and a number + /// whose check digit is correct ends with a folded sum of exactly one. + /// + private static bool RunsIso7064Mod1110ToOne(ReadOnlySpan digits) { + var product = 10; + var sum = 0; + + foreach (var c in digits) { + if (c is < '0' or > '9') + return false; + + sum = (c - '0' + product) % 10; + if (sum == 0) + sum = 10; + + product = 2 * sum % 11; + } + + return sum == 1; + } + /// /// Every single character variation of the value at the given position, the original excluded. /// An ISBN-10 may carry an X in its last position, everything else is a digit. diff --git a/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/VatIdSourceTests.cs b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/VatIdSourceTests.cs new file mode 100644 index 0000000..c970bcf --- /dev/null +++ b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/VatIdSourceTests.cs @@ -0,0 +1,149 @@ +using Shouldly; +using Xunit; +using holonsoft.AutoPoco.DataSources.Identifiers; +using holonsoft.AutoPoco.Tests.Functionality.Tests.DataSources.Base; + +namespace holonsoft.AutoPoco.Tests.Functionality.Tests.DataSources.Identifiers; + +public class VatIdSourceTests : TestBase { + /// + /// Registered VAT IDs of real companies and the worked example of the Austrian BMF specification, so the + /// validator is measured against the world before it judges the source. A VAT ID is public data, it is + /// printed on every invoice. + /// + [Theory] + [InlineData("DE249051813")] // BZH Vogt + [InlineData("DE198612614")] // Sausalitos München + [InlineData("DE129430206")] + [InlineData("DE815243055")] + [InlineData("ATU13585627")] // the worked example of the BMF check digit specification + [InlineData("ATU12345675")] // digits 1234567: sum 31, check (96 - 31) mod 10 = 5 + [InlineData("NL002065538B01")] // Philips + [InlineData("IT00000010215")] + [InlineData("PL1130027802")] + [InlineData("PL5212207257")] + [InlineData("PL5850010588")] + [InlineData("HR29524210204")] + public void TheValidatorAcceptsRealVatIds(string value) + => IdentifierValidation.IsValidVatId(value).ShouldBeTrue(); + + [Theory] + [InlineData("DE249051814")] // check digit off by one + [InlineData("DE049051813")] // a German ID never starts with a zero + [InlineData("ATU12345679")] // the check digit a widespread wrong formula produces for 1234567 + [InlineData("ATA12345675")] // the letter has to be a U + [InlineData("NL002065538B00")] // the suffix starts at 01 + [InlineData("NL002065539B01")] // check digit relevant digit off by one + [InlineData("IT00000010216")] // Luhn check digit off by one + [InlineData("IT00000009993")] // the Luhn check digit is correct, but office code 999 is not handed out + [InlineData("PL5212207258")] // check digit off by one + [InlineData("PL0212207257")] // a NIP never starts with a zero + [InlineData("HR29524210205")] // check digit off by one + [InlineData("FR12345678901")] // a country the source does not generate + [InlineData("DE24905181")] // one digit short + [InlineData("DE2490518131")] // one digit too many + [InlineData("")] + [InlineData(null)] + public void TheValidatorRejectsABrokenVatId(string? value) + => IdentifierValidation.IsValidVatId(value).ShouldBeFalse(); + + [Theory] + [InlineData("AT", 11)] + [InlineData("DE", 11)] + [InlineData("HR", 13)] + [InlineData("IT", 13)] + [InlineData("NL", 14)] + [InlineData("PL", 12)] + public void EveryNumberOfAFixedCountryIsValid(string countryCode, int expectedLength) { + var source = new VatIdSource(countryCode); + + for (var i = 0; i < 200; i++) { + var value = source.Next(null); + value.Length.ShouldBe(expectedLength); + value.ShouldStartWith(countryCode); + IdentifierValidation.IsValidVatId(value).ShouldBeTrue($"'{value}' has a broken check digit"); + } + } + + [Fact] + public void TheRandomCountryProducesEveryCountry() { + var source = new VatIdSource(); + + var countries = Enumerable.Range(0, 500) + .Select(_ => source.Next(null)[..2]) + .Distinct() + .OrderBy(c => c, StringComparer.Ordinal) + .ToList(); + + countries.ShouldBe(VatIdSourceBase.SupportedCountries.OrderBy(c => c, StringComparer.Ordinal).ToList()); + } + + [Fact] + public void AFixedCountryStaysFixed() + => Enumerable.Range(0, 100) + .Select(_ => new VatIdSource("NL").Next(null)) + .ShouldAllBe(v => v.StartsWith("NL")); + + /// + /// Every check digit scheme in here detects any single digit substitution, so no mutation of the + /// checksummed digits may still validate. The Dutch suffix carries no check digit and is left alone. + /// + [Theory] + [InlineData("AT")] + [InlineData("DE")] + [InlineData("HR")] + [InlineData("IT")] + [InlineData("NL")] + [InlineData("PL")] + public void NoSingleDigitChangeOfTheChecksummedPartStillValidates(string countryCode) { + var source = new VatIdSource(countryCode); + + for (var i = 0; i < 20; i++) { + var value = source.Next(null); + + var digitsStart = countryCode == "AT" ? 3 : 2; + var digitsEnd = countryCode == "NL" ? 11 : value.Length; + var digits = value[digitsStart..digitsEnd]; + + foreach (var mutatedDigits in IdentifierValidation.SingleCharacterMutations(digits, false)) { + var mutated = value[..digitsStart] + mutatedDigits + value[digitsEnd..]; + IdentifierValidation.IsValidVatId(mutated).ShouldBeFalse($"'{mutated}' passed although '{value}' was the valid number"); + } + } + } + + [Theory] + [InlineData("XX")] + [InlineData("de")] // country codes are upper case + [InlineData("FR")] // real country, but its check digit is not implemented + [InlineData("")] + public void AnUnsupportedCountryThrows(string countryCode) + => Should.Throw(() => new VatIdSource(countryCode)) + .Message.ShouldContain(countryCode.Length == 0 ? "Supported" : countryCode); + + [Fact] + public void NextReturnsStableVatIdListInTermsOfTestability() + => NextReturnsStableElementListInTermsOfTestability( + new VatIdSource(), new string[] { "NL343138074B93", "HR26180963647", "IT22865000685", "PL7550609224", "PL6243891485", "IT29766910789", "HR43067100051", "DE963657675", "ATU34138037", "HR01712438307" }); + + [Fact] + public void NextReturnsStableVatIdListInTermsOfTestabilityAndListCanContainNull() + => NextReturnsStableElementListInTermsOfTestability( + new NullableVatIdSource()!, new string?[] { "NL343138074B93", "HR26180963647", "IT22865000685", "PL7550609224", "PL6243891485", "IT29766910789", "HR43067100051", "DE963657675", "ATU34138037", "HR01712438307", "NL731140308B64", "HR60080791588", "NL159046798B24", "DE412742380", "IT67204800139", "PL8035560965", "IT97678500642", "ATU29389792", "NL007817617B41", "ATU72797967", "PL9609770190", "DE401306280", "NL328678995B68", "PL9542138596", null, "DE231245175", "PL9731344115", null, "PL4213312990", "DE970703677" }); + + [Fact] + public void ANullableSourceReturnsNullsAndOtherwiseValidNumbers() { + var source = new NullableVatIdSource(50); + var values = Enumerable.Range(0, 500).Select(_ => source.Next(null)).ToList(); + + values.ShouldContain(v => v == null); + values.ShouldContain(v => v != null); + + foreach (var value in values.Where(v => v is not null)) + IdentifierValidation.IsValidVatId(value).ShouldBeTrue($"'{value}' has a broken check digit"); + } + + [Fact] + public void ANonNullableSourceNeverReturnsNull() + => Enumerable.Range(0, 500).Select(_ => new VatIdSource().Next(null)).ShouldAllBe(v => v != null); +} diff --git a/holonsoft.AutoPoco/DataSources/Identifiers/VatIdSource.cs b/holonsoft.AutoPoco/DataSources/Identifiers/VatIdSource.cs new file mode 100644 index 0000000..509e8d6 --- /dev/null +++ b/holonsoft.AutoPoco/DataSources/Identifiers/VatIdSource.cs @@ -0,0 +1,217 @@ +using holonsoft.AutoPoco.Configuration; +using holonsoft.AutoPoco.Engine; +using holonsoft.AutoPoco.Engine.Interfaces; +using holonsoft.AutoPoco.Util; + +namespace holonsoft.AutoPoco.DataSources.Identifiers; + +/// +/// Generates an EU VAT identification number with a valid check digit, the country prefix included, +/// e.g. DE249051813 or ATU13585627. +/// +/// +/// Only countries whose check digit algorithm this source implements are drawn, so every generated number +/// passes a validator that knows the national rules, not just the format. Currently: Germany and Croatia +/// (ISO 7064 MOD 11,10), Austria (the BMF digit sum with its 96 minus sum step), the Netherlands and +/// Poland (weighted mod 11) and Italy (Luhn over the eleven digits, office code kept in the range that is +/// handed out). More countries only carry a format without a published check digit, they can be added as +/// formats become verifiable. +/// The numbers are syntactically valid, they are not registered and do not identify a real company. A VIES +/// lookup will therefore report them as not registered, which is the correct answer for test data. +/// +public abstract class VatIdSourceBase : DataSourceBase { + /// + /// The countries this source can generate, as ISO 3166-1 alpha-2 codes. + /// + public static IReadOnlyCollection SupportedCountries { get; } = ["AT", "DE", "HR", "IT", "NL", "PL"]; + + private static readonly string[] _countries = [.. SupportedCountries]; + + /// + /// The weights of the Dutch check digit, applied to the first eight of the nine digits. + /// + private static readonly int[] _dutchWeights = [9, 8, 7, 6, 5, 4, 3, 2]; + + /// + /// The weights of the Polish check digit, applied to the first nine of the ten digits. + /// + private static readonly int[] _polishWeights = [6, 5, 7, 2, 3, 4, 5, 6, 7]; + + private readonly string? _fixedCountry; + + /// + /// Creates the source. + /// + /// + /// Optional ISO 3166-1 alpha-2 country code, e.g. DE. Drawn from + /// when null. + /// + /// Optional null creation threshold in percent. + /// is not a supported country. + protected VatIdSourceBase(string? countryCode = null, int? nullCreationThreshold = null) + : base(nullCreationThreshold) { + if (countryCode is not null) { + if (!SupportedCountries.Contains(countryCode)) + throw new ArgumentException( + $"'{countryCode}' is not a country this source can generate a VAT ID for. Supported: {string.Join(", ", SupportedCountries)}.", + nameof(countryCode)); + + _fixedCountry = countryCode; + } + } + + protected override string GetNextValue(IGenerationContext? context) { + if (NullCreationThreshold.HasValue) { + if (RandomNullEvaluator.ShouldNextValueReturnNull()) + return null!; + } + + var country = _fixedCountry ?? _countries[Random.Next(0, _countries.Length)]; + + return country switch { + "AT" => NextAustrianId(), + "DE" => NextGermanId(), + "HR" => NextCroatianId(), + "IT" => NextItalianId(), + "NL" => NextDutchId(), + "PL" => NextPolishId(), + _ => throw new InvalidOperationException($"Country '{country}' has no VAT ID generator.") + }; + } + + /// + /// ATU, seven digits and the BMF check digit: the digits are weighted 1 and 2 alternating, a doubled + /// value keeps its digit sum, and the check digit is 96 minus the sum, mod 10. + /// + private string NextAustrianId() { + Span digits = stackalloc char[8]; + + for (var i = 0; i < 7; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + var sum = 0; + for (var i = 0; i < 7; i++) { + var value = (digits[i] - '0') * (i % 2 == 0 ? 1 : 2); + if (value > 9) + value = (value / 10) + (value % 10); + + sum += value; + } + + digits[7] = (char) ('0' + ((96 - sum) % 10)); + + return $"ATU{new string(digits)}"; + } + + /// + /// DE and nine digits, the first one never a zero, the last one the ISO 7064 MOD 11,10 check digit. + /// + private string NextGermanId() { + Span digits = stackalloc char[9]; + digits[0] = (char) ('1' + Random.Next(0, 9)); + + for (var i = 1; i < 8; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + digits[8] = (char) ('0' + CheckDigits.Iso7064Mod1110(digits, 8)); + + return $"DE{new string(digits)}"; + } + + /// + /// HR and the eleven digit OIB, the last digit the same ISO 7064 MOD 11,10 check digit a German ID uses. + /// + private string NextCroatianId() { + Span digits = stackalloc char[11]; + + for (var i = 0; i < 10; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + digits[10] = (char) ('0' + CheckDigits.Iso7064Mod1110(digits, 10)); + + return $"HR{new string(digits)}"; + } + + /// + /// IT and eleven digits: seven digits of sequential number, a three digit office code inside the range + /// that is actually handed out (001 to 100), and the Luhn check digit. + /// + private string NextItalianId() { + Span digits = stackalloc char[11]; + + for (var i = 0; i < 7; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + var office = 1 + Random.Next(0, 100); + digits[7] = (char) ('0' + (office / 100)); + digits[8] = (char) ('0' + (office / 10 % 10)); + digits[9] = (char) ('0' + (office % 10)); + + digits[10] = (char) ('0' + CheckDigits.Luhn(digits, 10)); + + return $"IT{new string(digits)}"; + } + + /// + /// NL, nine digits, a B and a two digit suffix from 01 on. The ninth digit is the weighted mod 11 check + /// digit of the first eight; a draw whose weighted sum leaves remainder ten has no valid check digit, + /// so those eight digits are drawn again. + /// + private string NextDutchId() { + Span digits = stackalloc char[9]; + int check; + + do { + for (var i = 0; i < 8; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + check = CheckDigits.WeightedMod11(digits, _dutchWeights); + } while (check == 10); + + digits[8] = (char) ('0' + check); + + var suffix = 1 + Random.Next(0, 99); + + return $"NL{new string(digits)}B{suffix:00}"; + } + + /// + /// PL and the ten digit NIP, the first digit never a zero, the last one the weighted mod 11 check digit + /// of the first nine. A draw whose weighted sum leaves remainder ten is drawn again. + /// + private string NextPolishId() { + Span digits = stackalloc char[10]; + int check; + + do { + digits[0] = (char) ('1' + Random.Next(0, 9)); + + for (var i = 1; i < 9; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + check = CheckDigits.WeightedMod11(digits, _polishWeights); + } while (check == 10); + + digits[9] = (char) ('0' + check); + + return $"PL{new string(digits)}"; + } +} + +/// +/// An EU VAT identification number with a valid check digit. +/// +public class VatIdSource(string? countryCode) : VatIdSourceBase(countryCode) { + public VatIdSource() : this(null) { } +} + +/// +/// An EU VAT identification number that returns null every now and then. +/// +public class NullableVatIdSource(string? countryCode, int nullCreationThreshold) : VatIdSourceBase(countryCode, nullCreationThreshold) { + public NullableVatIdSource() : this(null, AutoPocoDefaults.NullCreationThreshold) { } + + public NullableVatIdSource(string? countryCode) : this(countryCode, AutoPocoDefaults.NullCreationThreshold) { } + + public NullableVatIdSource(int nullCreationThreshold) : this(null, nullCreationThreshold) { } +} diff --git a/holonsoft.AutoPoco/Util/CheckDigits.cs b/holonsoft.AutoPoco/Util/CheckDigits.cs index 93af485..4fc191a 100644 --- a/holonsoft.AutoPoco/Util/CheckDigits.cs +++ b/holonsoft.AutoPoco/Util/CheckDigits.cs @@ -56,6 +56,67 @@ public static int Luhn(ReadOnlySpan digits, int dataLength) { return (10 - (sum % 10)) % 10; } + /// + /// The mod 97 remainder of ISO/IEC 7064 as an IBAN uses it: the characters are read as one long number, + /// a letter counting as its position in the alphabet plus nine, so A becomes 10 and Z becomes 35. + /// For the two IBAN check digits the caller passes BBAN, country code and 00 and subtracts the remainder + /// from 98; a complete IBAN rearranged the same way is valid when the remainder is one. + /// + /// Digits and capital letters only, in the rearranged IBAN order. + public static int Mod97(ReadOnlySpan rearranged) { + var remainder = 0; + + foreach (var c in rearranged) { + if (c is >= '0' and <= '9') { + remainder = (remainder * 10 + (c - '0')) % 97; + } else { + var value = c - 'A' + 10; + remainder = (remainder * 10 + (value / 10)) % 97; + remainder = (remainder * 10 + (value % 10)) % 97; + } + } + + return remainder; + } + + /// + /// The MOD 11,10 hybrid of ISO/IEC 7064, used by the German VAT ID and the Croatian OIB: a running + /// product is folded through every data digit (sum mod 10, zero becomes ten, doubled mod 11), and the + /// check digit is what lifts the final product to eleven, where ten is written as zero. + /// + /// The number, at least characters long. + /// How many leading characters are data digits, the check digit not counted. + public static int Iso7064Mod1110(ReadOnlySpan digits, int dataLength) { + var product = 10; + + for (var i = 0; i < dataLength; i++) { + var sum = (digits[i] - '0' + product) % 10; + if (sum == 0) + sum = 10; + + product = 2 * sum % 11; + } + + var check = 11 - product; + return check == 10 ? 0 : check; + } + + /// + /// A weighted sum mod 11, the check digit scheme of the Dutch and Polish VAT IDs: every data digit is + /// multiplied with its weight, and the check digit is the sum mod eleven. + /// + /// The number, at least as many characters as entries. + /// One weight per data digit, the check digit not counted. + /// Zero to nine, or ten, which means the digits have no valid check digit and must be redrawn. + public static int WeightedMod11(ReadOnlySpan digits, ReadOnlySpan weights) { + var sum = 0; + + for (var i = 0; i < weights.Length; i++) + sum += (digits[i] - '0') * weights[i]; + + return sum % 11; + } + /// /// A mod 11 check digit over descending weights: the first data digit carries the highest weight, the /// last one carries two, and the check digit makes the weighted sum a multiple of eleven. From 7c2539db6d37128440e57829a7a89a1341ee912a Mon Sep 17 00:00:00 2001 From: "christian.vogt@hiquaro.com" Date: Mon, 21 Sep 2026 18:59:31 +0200 Subject: [PATCH 2/3] Add IbanSource for every SEPA country and TestBlzIbanSource The structures come from the SWIFT IBAN registry, the mod 97 check digits from ISO 7064, and where a country keeps its own check digits inside the BBAN they are calculated as well: the Belgian mod 97 tail, the Spanish DC pair, the French and Monegasque RIB key, the Italian and Sammarinese CIN letter, the Norwegian, Estonian, Finnish, Hungarian, Polish and Icelandic schemes and the Portuguese and Slovenian ISO 7064 pairs. The tests are calibrated against the official registry example of all thirty seven countries and verify every national check the other way round, with the check digit weighted into the sum; a constructed number with fresh mod 97 digits but broken national digits has to fall through. TestBlzIbanSource builds German IBANs on bank codes starting with a nine: the Bundesbank hands out the clearing areas one to eight only and the current official bank code file holds no code starting with a nine, so the number provably names nobody's account. The README says clearly why that matters more for IBANs than for card numbers: a SEPA direct debit needs no second factor. Co-Authored-By: Claude Fable 5 --- Directory.Build.props | 2 +- README.md | 14 + .../Identifiers/IbanSourceTests.cs | 221 +++++++++ .../Identifiers/IdentifierValidation.cs | 188 +++++++ .../DataSources/Identifiers/IbanSource.cs | 468 ++++++++++++++++++ 5 files changed, 892 insertions(+), 1 deletion(-) create mode 100644 holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/IbanSourceTests.cs create mode 100644 holonsoft.AutoPoco/DataSources/Identifiers/IbanSource.cs diff --git a/Directory.Build.props b/Directory.Build.props index 0eb6a6f..b020a87 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -25,7 +25,7 @@ git README.md autopoco;testdata;test-data;poco;generator;fluent;fake-data - 6.1: New data sources for trade identifiers in holonsoft.AutoPoco.DataSources.Identifiers: GtinSource and Ean13Source (GTIN-8/12/13/14, i.e. EAN-8, UPC-A, EAN-13 and ITF-14, with a valid GS1 mod 10 check digit and an optional GS1 company prefix), SsccSource (the eighteen digit number of a logistic unit, same GS1 check digit, optional prefix), IsbnSource (ISBN-13 inside an allocated registration group, ISBN-10 with its mod 11 check digit including the X), IssnSource (mod 11 check character including the X), IsinSource (a real country prefix or a given one, Luhn over the expanded letters), ImeiSource (Luhn check digit, optional Type Allocation Code prefix) and AsinSource, each with a Nullable variant. TestBinCreditCardSource in holonsoft.AutoPoco.DataSources.Business builds card numbers on the six digit test BINs the payment processors publish, so the data is recognisable as test data. Bug fixes: CreditCardSource doubled the wrong digits when calculating the Luhn check digit, so its numbers failed a real Luhn validation; and only the first digit of the scheme was fixed, so an "American Express" could be detected as a Diners Club and a "MasterCard" could start with 56, a range no scheme issues. Every number now carries a correct check digit and starts inside a range its scheme really issues in (34/37 for American Express, 51-55 and 2221-2720 for Mastercard, 6011/644-649/65 for Discover), and the generated sequences changed once for that. 6.0: Lambda data sources (FuncSource, Setup(...).From(() => ...), Source(expr, () => ...) at generation time). Opt-in nullability-aware null generation via RespectNullableAnnotations(threshold). Generic NumberSource<T> and NullableNumberSource<T> with min/max for every built-in numeric type (integers uniform and inclusive, floating point and decimal overflow safe). Records and immutable types are constructed through their constructor, parameters are fed from the registered members with the same name, no parameterless constructor needed anymore; unconfigured struct parameters stay default instead of throwing. Seeds: UseSeed(seed) per factory, CreateSession(recursionLimit, seed) per session, every member gets its own stream derived from the seed. Own deterministic generator (StableRandom, xoshiro256**) instead of System.Random, sequences are stable across .NET versions. Breaking: AutoPocoGlobalSettings removed (AutoPocoDefaults constants instead), every generated sequence changed once, the max of IntegerSource/LongSource/RandomNumberSource and their nullable variants is inclusive now (it was exclusive), Int128Source/NullableInt128Source pick uniformly instead of clamping to the bounds (they never produced a negative value before), RandomStringSource and UrlSource reach the last character resp. the last top level domain, ColorSource/RandomTextSource/RandomUtfTextSource treat their maximum as inclusive, ColorSource applies the alpha of SetColorRange, and UrlSource and ExtendedEmailAddressSource seed their nested sources (the URL host ignored the session seed completely). CreditCardSource never produced a Discover card. TimeSpanSource was rewritten: it returned TimeSpan.MinValue on every draw of the default range, produced values outside a requested range and looped forever on a reversed one. DoubleSource returned infinity over its default range, DoubleSource/FloatSource/DecimalSource interpolate overflow safe now and DecimalSource keeps its full precision. Index-aware Impose on lists and selections (value from the position, or from position and item), Impose(member, item => value) on a single generator. Invoke arguments: Use.From(lambda), constants and variables arrive as values. Context lambdas get a non-nullable IGenerationContext. Argument validation over the public API, meaningful exception messages, XML documentation file. IDataSource<T> is covariant. Bug fixes: Nullable* sources honor an explicit null creation threshold (null positions of stable sequences changed), DateTime/DateOnly/TimeOnly/DateOfBirth sources cover their whole range including the upper bound (sequences changed), RandomUtfTextSource no longer loops forever. Build and packaging modernized: GitHub Actions CI, trusted publishing to nuget.org, MinVer tag based versioning, central package management, xunit.v3 tests on net8/9/10. FluentAssertions and Moq are no longer dependencies of the library. See README for details. + 6.1: New data sources for trade identifiers in holonsoft.AutoPoco.DataSources.Identifiers: GtinSource and Ean13Source (GTIN-8/12/13/14, i.e. EAN-8, UPC-A, EAN-13 and ITF-14, with a valid GS1 mod 10 check digit and an optional GS1 company prefix), SsccSource (the eighteen digit number of a logistic unit, same GS1 check digit, optional prefix), IsbnSource (ISBN-13 inside an allocated registration group, ISBN-10 with its mod 11 check digit including the X), IssnSource (mod 11 check character including the X), IsinSource (a real country prefix or a given one, Luhn over the expanded letters), ImeiSource (Luhn check digit, optional Type Allocation Code prefix) and AsinSource, each with a Nullable variant. IbanSource generates the IBAN of any SEPA country with correct mod 97 check digits and the national check digits inside the BBAN (BE, EE, ES, FI, FR/MC, HU, IS, IT/SM, NO, PL, PT, SI), TestBlzIbanSource a German IBAN on a bank code outside the assigned range so the number is recognisable as test data, and VatIdSource an EU VAT ID with a valid check digit for DE, AT, HR, IT, NL and PL. TestBinCreditCardSource in holonsoft.AutoPoco.DataSources.Business builds card numbers on the six digit test BINs the payment processors publish, so the data is recognisable as test data. Bug fixes: CreditCardSource doubled the wrong digits when calculating the Luhn check digit, so its numbers failed a real Luhn validation; and only the first digit of the scheme was fixed, so an "American Express" could be detected as a Diners Club and a "MasterCard" could start with 56, a range no scheme issues. Every number now carries a correct check digit and starts inside a range its scheme really issues in (34/37 for American Express, 51-55 and 2221-2720 for Mastercard, 6011/644-649/65 for Discover), and the generated sequences changed once for that. 6.0: Lambda data sources (FuncSource, Setup(...).From(() => ...), Source(expr, () => ...) at generation time). Opt-in nullability-aware null generation via RespectNullableAnnotations(threshold). Generic NumberSource<T> and NullableNumberSource<T> with min/max for every built-in numeric type (integers uniform and inclusive, floating point and decimal overflow safe). Records and immutable types are constructed through their constructor, parameters are fed from the registered members with the same name, no parameterless constructor needed anymore; unconfigured struct parameters stay default instead of throwing. Seeds: UseSeed(seed) per factory, CreateSession(recursionLimit, seed) per session, every member gets its own stream derived from the seed. Own deterministic generator (StableRandom, xoshiro256**) instead of System.Random, sequences are stable across .NET versions. Breaking: AutoPocoGlobalSettings removed (AutoPocoDefaults constants instead), every generated sequence changed once, the max of IntegerSource/LongSource/RandomNumberSource and their nullable variants is inclusive now (it was exclusive), Int128Source/NullableInt128Source pick uniformly instead of clamping to the bounds (they never produced a negative value before), RandomStringSource and UrlSource reach the last character resp. the last top level domain, ColorSource/RandomTextSource/RandomUtfTextSource treat their maximum as inclusive, ColorSource applies the alpha of SetColorRange, and UrlSource and ExtendedEmailAddressSource seed their nested sources (the URL host ignored the session seed completely). CreditCardSource never produced a Discover card. TimeSpanSource was rewritten: it returned TimeSpan.MinValue on every draw of the default range, produced values outside a requested range and looped forever on a reversed one. DoubleSource returned infinity over its default range, DoubleSource/FloatSource/DecimalSource interpolate overflow safe now and DecimalSource keeps its full precision. Index-aware Impose on lists and selections (value from the position, or from position and item), Impose(member, item => value) on a single generator. Invoke arguments: Use.From(lambda), constants and variables arrive as values. Context lambdas get a non-nullable IGenerationContext. Argument validation over the public API, meaningful exception messages, XML documentation file. IDataSource<T> is covariant. Bug fixes: Nullable* sources honor an explicit null creation threshold (null positions of stable sequences changed), DateTime/DateOnly/TimeOnly/DateOfBirth sources cover their whole range including the upper bound (sequences changed), RandomUtfTextSource no longer loops forever. Build and packaging modernized: GitHub Actions CI, trusted publishing to nuget.org, MinVer tag based versioning, central package management, xunit.v3 tests on net8/9/10. FluentAssertions and Moq are no longer dependencies of the library. See README for details. diff --git a/README.md b/README.md index dd96110..c3709ed 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,20 @@ New in `holonsoft.AutoPoco.DataSources.Business`: `TestBinCreditCardSource` (and .Setup(c => c.CardNumber).Use(CreditCardType.Visa) ``` +## IBANs and VAT IDs + +Two more sources in `holonsoft.AutoPoco.DataSources.Identifiers`, for the numbers on an invoice. + +| Source | Produces | +| --- | --- | +| `IbanSource` | An IBAN of any SEPA country in the electronic format without spaces, with correct mod 97 check digits. Where a country keeps its own check digits inside the account part they are calculated as well: the Belgian mod 97 tail, the Spanish DC pair, the French and Monegasque RIB key, the Italian and Sammarinese CIN letter, the Norwegian, Estonian, Finnish, Hungarian, Polish and Icelandic schemes and the Portuguese and Slovenian ISO 7064 pairs. `new IbanSource("DE")` pins the country. | +| `TestBlzIbanSource` | A German IBAN whose bank code starts with a nine. The Bundesbank hands out clearing areas one to eight only, and in the current official bank code file not a single one of the 13760 codes starts with a nine, so the number is recognisable as test data and can name nobody's account. | +| `VatIdSource` | An EU VAT identification number with a valid check digit for Germany, Austria, Croatia, Italy, the Netherlands and Poland, e.g. `DE249051813` or `ATU13585627`. Only countries with an implemented check digit are drawn, `new VatIdSource("DE")` pins the country. | + +Each has a `Nullable...` variant. The structures come from the SWIFT IBAN registry and the official construction rules of the tax authorities; the tests are calibrated against the registry example of every country and against registered VAT IDs of real companies before they judge the generator. + +One warning, stronger than for the credit cards: an IBAN needs no CVV and no second factor for a SEPA direct debit, so a generated IBAN that coincidentally names a real account is a sharper tool than a generated card number. `IbanSource` draws its bank codes, so use `TestBlzIbanSource` whenever the data leaves your test systems. A generated VAT ID is harmless in comparison, VIES simply reports it as not registered. + ## holonsoft.AutoPoco.Faker, realistic values from Bogus A separate, optional package. The core package keeps its promise of having no dependency, and who wants believable names and addresses installs one more: diff --git a/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/IbanSourceTests.cs b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/IbanSourceTests.cs new file mode 100644 index 0000000..c67c760 --- /dev/null +++ b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/IbanSourceTests.cs @@ -0,0 +1,221 @@ +using Shouldly; +using Xunit; +using holonsoft.AutoPoco.DataSources.Identifiers; +using holonsoft.AutoPoco.Tests.Functionality.Tests.DataSources.Base; +using holonsoft.AutoPoco.Util; + +namespace holonsoft.AutoPoco.Tests.Functionality.Tests.DataSources.Identifiers; + +public class IbanSourceTests : TestBase { + /// + /// The official example IBAN of every SEPA country from the SWIFT registry, so the validator is + /// measured against the registry before it judges the source. Every example also exercises the + /// national check digits of its country. + /// + [Theory] + [InlineData("AD1200012030200359100100")] + [InlineData("AT611904300234573201")] + [InlineData("BE68539007547034")] + [InlineData("BG80BNBG96611020345678")] + [InlineData("CH9300762011623852957")] + [InlineData("CY17002001280000001200527600")] + [InlineData("CZ6508000000192000145399")] + [InlineData("DE89370400440532013000")] + [InlineData("DK5000400440116243")] + [InlineData("EE382200221020145685")] + [InlineData("ES9121000418450200051332")] + [InlineData("FI2112345600000785")] + [InlineData("FR1420041010050500013M02606")] + [InlineData("GB29NWBK60161331926819")] + [InlineData("GI75NWBK000000007099453")] + [InlineData("GR1601101250000000012300695")] + [InlineData("HR1210010051863000160")] + [InlineData("HU42117730161111101800000000")] + [InlineData("IE29AIBK93115212345678")] + [InlineData("IS140159260076545510730339")] + [InlineData("IT60X0542811101000000123456")] + [InlineData("LI21088100002324013AA")] + [InlineData("LT121000011101001000")] + [InlineData("LU280019400644750000")] + [InlineData("LV80BANK0000435195001")] + [InlineData("MC5811222000010123456789030")] + [InlineData("MT84MALT011000012345MTLCAST001S")] + [InlineData("NL91ABNA0417164300")] + [InlineData("NO9386011117947")] + [InlineData("PL61109010140000071219812874")] + [InlineData("PT50000201231234567890154")] + [InlineData("RO49AAAA1B31007593840000")] + [InlineData("SE4550000000058398257466")] + [InlineData("SI56263300012039086")] + [InlineData("SK3112000000198742637541")] + [InlineData("SM86U0322509800000000270100")] + [InlineData("VA59001123000012345678")] + public void TheValidatorAcceptsEveryRegistryExample(string value) + => IdentifierValidation.IsValidIban(value).ShouldBeTrue(); + + [Theory] + [InlineData("DE89370400440532013001")] // last digit changed, mod 97 breaks + [InlineData("DE88370400440532013000")] // check digits changed + [InlineData("DE8937040044053201300")] // one digit short + [InlineData("DE893704004405320130000")] // one digit too many + [InlineData("de89370400440532013000")] // country codes are upper case + [InlineData("XK051212012345678906")] // a country outside the registry + [InlineData("NL91abna0417164300")] // the bank code letters are upper case + [InlineData("DEAA370400440532013000")] // the check digits are digits + [InlineData("")] + [InlineData(null)] + public void TheValidatorRejectsABrokenIban(string? value) + => IdentifierValidation.IsValidIban(value).ShouldBeFalse(); + + /// + /// Correct mod 97 check digits alone are not enough: a number whose national check digits are broken + /// has to fall through even when its mod 97 digits are recalculated to match. + /// + [Theory] + [InlineData("BE", "539007547035")] // Belgian check is 34, not 35 + [InlineData("IT", "Y0542811101000000123456")] // the CIN of this BBAN is X, not Y + [InlineData("NO", "86011117948")] // Norwegian check is 7, not 8 + [InlineData("PL", "109010150000071219812874")] // the bank code check digit is 4, not 5 + public void CorrectMod97DigitsDoNotRescueBrokenNationalChecks(string countryCode, string bban) { + var check = 98 - CheckDigits.Mod97($"{bban}{countryCode}00"); + var iban = $"{countryCode}{check:00}{bban}"; + + IdentifierValidation.IsValidIban(iban).ShouldBeFalse($"'{iban}' passed although its national check digits are broken"); + } + + public static TheoryData AllCountries() + => [.. IbanSourceBase.SupportedCountries]; + + [Theory] + [MemberData(nameof(AllCountries))] + public void EveryIbanOfAFixedCountryIsValid(string countryCode) { + var source = new IbanSource(countryCode); + + for (var i = 0; i < 100; i++) { + var value = source.Next(null); + value.ShouldStartWith(countryCode); + IdentifierValidation.IsValidIban(value).ShouldBeTrue($"'{value}' does not validate"); + } + } + + [Fact] + public void TheRandomCountryProducesEveryCountry() { + var source = new IbanSource(); + + var countries = Enumerable.Range(0, 2000) + .Select(_ => source.Next(null)[..2]) + .Distinct() + .OrderBy(c => c, StringComparer.Ordinal) + .ToList(); + + countries.ShouldBe(IbanSourceBase.SupportedCountries.OrderBy(c => c, StringComparer.Ordinal).ToList()); + } + + [Fact] + public void AFixedCountryStaysFixed() + => Enumerable.Range(0, 100) + .Select(_ => new IbanSource("FR").Next(null)) + .ShouldAllBe(v => v.StartsWith("FR")); + + /// + /// Mod 97 detects every single digit substitution, so no mutation of a German IBAN may still validate. + /// + [Fact] + public void NoSingleDigitChangeOfAGeneratedIbanStillValidates() { + var source = new IbanSource("DE"); + + for (var i = 0; i < 10; i++) { + var value = source.Next(null); + + foreach (var mutatedDigits in IdentifierValidation.SingleCharacterMutations(value[2..], false)) { + var mutated = $"DE{mutatedDigits}"; + IdentifierValidation.IsValidIban(mutated).ShouldBeFalse($"'{mutated}' passed although '{value}' was the valid number"); + } + } + } + + [Theory] + [InlineData("XX")] + [InlineData("de")] + [InlineData("US")] // not a SEPA country + public void AnUnsupportedCountryThrows(string countryCode) + => Should.Throw(() => new IbanSource(countryCode)) + .Message.ShouldContain(countryCode); + + [Fact] + public void AnIbanCarriesNoSpaces() + => Enumerable.Range(0, 200).Select(_ => new IbanSource().Next(null)).ShouldAllBe(v => !v.Contains(' ')); + + [Fact] + public void NextReturnsStableIbanListInTermsOfTestability() + => NextReturnsStableElementListInTermsOfTestability( + new IbanSource(), new string[] { "NO5134313807225", "HU56809636442286500365506095", "SK8755243891483297669174", "NL39DQGO7100051863", "RO05WMVXQ7WJ4X3RRJFGCEHI", "CH1138307J4SUGDGWO0FP", "AT085815904679793127", "CH6423867A2W4VB0CN5WV", "IS945560963976785002938969", "IT55J0781761807ANPMU0BVNHPC" }); + + [Fact] + public void NextReturnsStableIbanListInTermsOfTestabilityAndListCanContainNull() + => NextReturnsStableElementListInTermsOfTestability( + new NullableIbanSource()!, new string?[] { "NO5134313807225", "HU56809636442286500365506095", "SK8755243891483297669174", "NL39DQGO7100051863", "RO05WMVXQ7WJ4X3RRJFGCEHI", "CH1138307J4SUGDGWO0FP", "AT085815904679793127", "CH6423867A2W4VB0CN5WV", "IS945560963976785002938969", "IT55J0781761807ANPMU0BVNHPC", "SM79R1306283286F83L4YFJ5PXV", "SI56312451787313411", "DE80778656253213312999", "FI5470703677052007", "SK7642732802303039249251", "IS475613600922413090076467", "AT446718929288077842", "IS667562078514892497667634", "IT89A5823633902I6BHIWN1THES", "LV83IQYB3FFC63NO95NDK", "GR769813341HPYHTNVJ7KDMZETG", "AD9819244352X6HYF09ORXX5", "CZ9677013095297720295334", "PL38565423709134939250743971", null, "BG08OHKM703324U6T5BERF", "PT50193008023856669286742", null, "AD6117966826O53X3IGR55NW", "HR8117517622098294691" }); + + [Fact] + public void ANullableSourceReturnsNullsAndOtherwiseValidIbans() { + var source = new NullableIbanSource(50); + var values = Enumerable.Range(0, 500).Select(_ => source.Next(null)).ToList(); + + values.ShouldContain(v => v == null); + values.ShouldContain(v => v != null); + + foreach (var value in values.Where(v => v is not null)) + IdentifierValidation.IsValidIban(value).ShouldBeTrue($"'{value}' does not validate"); + } + + [Fact] + public void ANonNullableSourceNeverReturnsNull() + => Enumerable.Range(0, 500).Select(_ => new IbanSource().Next(null)).ShouldAllBe(v => v != null); +} + +public class TestBlzIbanSourceTests : TestBase { + [Fact] + public void EveryNumberIsAGermanIbanOnANineBankCode() { + var source = new TestBlzIbanSource(); + + for (var i = 0; i < 300; i++) { + var value = source.Next(null); + value.Length.ShouldBe(22); + value.ShouldStartWith("DE"); + value[4].ShouldBe('9', $"'{value}' does not start its bank code with a nine"); + IdentifierValidation.IsValidIban(value).ShouldBeTrue($"'{value}' does not validate"); + } + } + + /// + /// The plain source draws the whole bank code, this one pins its first digit outside the assigned + /// range. Two sources on the same seed therefore have to disagree, otherwise nothing is pinned. + /// + [Fact] + public void TheNumbersDifferFromThePlainIbanSource() { + var plain = new IbanSource("DE"); + var onTestCodes = new TestBlzIbanSource(); + + var fromPlain = Enumerable.Range(0, 20).Select(_ => plain.Next(null)).ToList(); + var fromTestCodes = Enumerable.Range(0, 20).Select(_ => onTestCodes.Next(null)).ToList(); + + fromTestCodes.ShouldNotBe(fromPlain); + } + + [Fact] + public void NextReturnsStableIbanListInTermsOfTestability() + => NextReturnsStableElementListInTermsOfTestability( + new TestBlzIbanSource(), new string[] { "DE09934313807226180963", "DE93964322865003655060", "DE83992255243891483297", "DE54966917430671000518", "DE64963657670341380301", "DE87971243830731140302", "DE96960080791581590467", "DE25997931274238672048", "DE41907570355609639767", "DE25985002938979400781" }); + + [Fact] + public void ANullableSourceReturnsNullsAndOtherwiseValidIbans() { + var source = new NullableTestBlzIbanSource(50); + var values = Enumerable.Range(0, 500).Select(_ => source.Next(null)).ToList(); + + values.ShouldContain(v => v == null); + values.ShouldContain(v => v != null); + + foreach (var value in values.Where(v => v is not null)) + IdentifierValidation.IsValidIban(value).ShouldBeTrue($"'{value}' does not validate"); + } +} diff --git a/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/IdentifierValidation.cs b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/IdentifierValidation.cs index ee43d35..695e4c9 100644 --- a/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/IdentifierValidation.cs +++ b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/IdentifierValidation.cs @@ -146,6 +146,194 @@ public static bool IsValidIsin(string? value) { return IsValidLuhn(expanded.ToString()); } + /// + /// The BBAN shapes of the SEPA countries, written down independently of the source as regex patterns + /// from the SWIFT registry structures. + /// + private static readonly Dictionary _ibanBbanPatterns = new() { + ["AD"] = @"^\d{8}[0-9A-Z]{12}$", + ["AT"] = @"^\d{16}$", + ["BE"] = @"^\d{12}$", + ["BG"] = @"^[A-Z]{4}\d{6}[0-9A-Z]{8}$", + ["CH"] = @"^\d{5}[0-9A-Z]{12}$", + ["CY"] = @"^\d{8}[0-9A-Z]{16}$", + ["CZ"] = @"^\d{20}$", + ["DE"] = @"^\d{18}$", + ["DK"] = @"^\d{14}$", + ["EE"] = @"^\d{16}$", + ["ES"] = @"^\d{20}$", + ["FI"] = @"^\d{14}$", + ["FR"] = @"^\d{10}[0-9A-Z]{11}\d{2}$", + ["GB"] = @"^[A-Z]{4}\d{14}$", + ["GI"] = @"^[A-Z]{4}[0-9A-Z]{15}$", + ["GR"] = @"^\d{7}[0-9A-Z]{16}$", + ["HR"] = @"^\d{17}$", + ["HU"] = @"^\d{24}$", + ["IE"] = @"^[A-Z]{4}\d{14}$", + ["IS"] = @"^\d{22}$", + ["IT"] = @"^[A-Z]\d{10}[0-9A-Z]{12}$", + ["LI"] = @"^\d{5}[0-9A-Z]{12}$", + ["LT"] = @"^\d{16}$", + ["LU"] = @"^\d{3}[0-9A-Z]{13}$", + ["LV"] = @"^[A-Z]{4}[0-9A-Z]{13}$", + ["MC"] = @"^\d{10}[0-9A-Z]{11}\d{2}$", + ["MT"] = @"^[A-Z]{4}\d{5}[0-9A-Z]{18}$", + ["NL"] = @"^[A-Z]{4}\d{10}$", + ["NO"] = @"^\d{11}$", + ["PL"] = @"^\d{24}$", + ["PT"] = @"^\d{21}$", + ["RO"] = @"^[A-Z]{4}[0-9A-Z]{16}$", + ["SE"] = @"^\d{20}$", + ["SI"] = @"^\d{15}$", + ["SK"] = @"^\d{20}$", + ["SM"] = @"^[A-Z]\d{10}[0-9A-Z]{12}$", + ["VA"] = @"^\d{18}$", + }; + + /// + /// True when a complete IBAN in the electronic format has the right shape for its country, the mod 97 + /// remainder one, and correct national check digits where the country keeps some. Validated the other + /// way round than the source: every national check is verified by adding the check digit into the + /// weighted sum instead of recalculating it, wherever the arithmetic allows that. + /// + public static bool IsValidIban(string? value) { + if (value is null || value.Length < 5) + return false; + + var country = value[..2]; + if (!_ibanBbanPatterns.TryGetValue(country, out var pattern)) + return false; + + if (value[2] is < '0' or > '9' || value[3] is < '0' or > '9') + return false; + + var bban = value[4..]; + if (!System.Text.RegularExpressions.Regex.IsMatch(bban, pattern)) + return false; + + if (IbanMod97(bban + value[..4]) != 1) + return false; + + return country switch { + "BE" => IsValidBelgianBban(bban), + "EE" => WeightedSumIsMultipleOf(bban, start: 2, dataWeights: [7, 1, 3], dataLength: 13, modulus: 10), + "ES" => IsValidSpanishBban(bban), + "FI" => IsValidLuhn(bban), + "FR" or "MC" => IsValidRibKey(bban), + "HU" => WeightedSumIsMultipleOf(bban, start: 0, dataWeights: [9, 7, 3, 1], dataLength: 7, modulus: 10) + && WeightedSumIsMultipleOf(bban, start: 8, dataWeights: [9, 7, 3, 1], dataLength: 15, modulus: 10), + "IS" => WeightedSumIsMultipleOf(bban, start: 12, dataWeights: [3, 2, 7, 6, 5, 4, 3, 2], dataLength: 8, modulus: 11), + "IT" or "SM" => IsValidItalianCin(bban), + "NO" => WeightedSumIsMultipleOf(bban, start: 0, dataWeights: [5, 4, 3, 2, 7, 6, 5, 4, 3, 2], dataLength: 10, modulus: 11), + "PL" => WeightedSumIsMultipleOf(bban, start: 0, dataWeights: [3, 9, 7, 1, 3, 9, 7], dataLength: 7, modulus: 10), + "PT" => IbanMod97(bban) == 1, + "SI" => IbanMod97(bban) == 1, + _ => true + }; + } + + /// + /// The mod 97 of ISO 7064 over an already rearranged string, letters counting as ten to thirty five. + /// + private static int IbanMod97(string rearranged) { + var remainder = 0; + + foreach (var c in rearranged) { + if (c is >= '0' and <= '9') { + remainder = ((remainder * 10) + (c - '0')) % 97; + } else { + var value = c - 'A' + 10; + remainder = ((remainder * 10) + (value / 10)) % 97; + remainder = ((remainder * 10) + (value % 10)) % 97; + } + } + + return remainder; + } + + /// + /// A weighted block sum with the check digit weighted one and added in, so a correct block lands on a + /// multiple of the modulus. The data weights repeat when the block is longer than they are, and the + /// check digit sits directly behind the data digits. + /// + private static bool WeightedSumIsMultipleOf(string bban, int start, int[] dataWeights, int dataLength, int modulus) { + var sum = 0; + + for (var i = 0; i < dataLength; i++) + sum += (bban[start + i] - '0') * dataWeights[i % dataWeights.Length]; + + sum += bban[start + dataLength] - '0'; + + return sum % modulus == 0; + } + + private static bool IsValidBelgianBban(string bban) { + var body = long.Parse(bban[..10], System.Globalization.CultureInfo.InvariantCulture); + var check = int.Parse(bban[10..], System.Globalization.CultureInfo.InvariantCulture); + + var expected = (int) (body % 97); + if (expected == 0) + expected = 97; + + return check == expected; + } + + private static bool IsValidSpanishBban(string bban) { + int[] weights = [1, 2, 4, 8, 5, 10, 9, 7, 3, 6]; + + var first = SpanishCheckDigit("00" + bban[..8], weights); + var second = SpanishCheckDigit(bban[10..], weights); + + return bban[8] - '0' == first && bban[9] - '0' == second; + } + + private static int SpanishCheckDigit(string digits, int[] weights) { + var sum = 0; + for (var i = 0; i < 10; i++) + sum += (digits[i] - '0') * weights[i]; + + var digit = 11 - (sum % 11); + return digit switch { + 11 => 0, + 10 => 1, + _ => digit + }; + } + + private static bool IsValidRibKey(string bban) { + var bank = long.Parse(bban[..5], System.Globalization.CultureInfo.InvariantCulture); + var branch = long.Parse(bban[5..10], System.Globalization.CultureInfo.InvariantCulture); + var key = long.Parse(bban[21..], System.Globalization.CultureInfo.InvariantCulture); + + var account = 0L; + foreach (var c in bban[10..21]) + account = (account * 10) + c switch { + >= '0' and <= '9' => c - '0', + >= 'A' and <= 'I' => c - 'A' + 1, + >= 'J' and <= 'R' => c - 'J' + 1, + _ => c - 'S' + 2 + }; + + return ((89 * bank) + (15 * branch) + (3 * account) + key) % 97 == 0; + } + + private static bool IsValidItalianCin(string bban) { + int[] oddValues = [1, 0, 5, 7, 9, 13, 15, 17, 19, 21, 2, 4, 18, 20, 11, 3, 6, 8, 12, 14, 16, 10, 22, 25, 24, 23]; + var sum = 0; + + for (var i = 1; i < 23; i++) { + var baseValue = bban[i] is >= '0' and <= '9' + ? bban[i] - '0' + : bban[i] - 'A'; + + sum += i % 2 == 1 + ? oddValues[baseValue] + : baseValue; + } + + return bban[0] == 'A' + (sum % 26); + } + /// /// True when a complete EU VAT ID with its country prefix carries a correct check digit. Only the /// countries the source generates are known here. diff --git a/holonsoft.AutoPoco/DataSources/Identifiers/IbanSource.cs b/holonsoft.AutoPoco/DataSources/Identifiers/IbanSource.cs new file mode 100644 index 0000000..146c65f --- /dev/null +++ b/holonsoft.AutoPoco/DataSources/Identifiers/IbanSource.cs @@ -0,0 +1,468 @@ +using holonsoft.AutoPoco.Configuration; +using holonsoft.AutoPoco.Engine; +using holonsoft.AutoPoco.Engine.Interfaces; +using holonsoft.AutoPoco.Util; + +namespace holonsoft.AutoPoco.DataSources.Identifiers; + +/// +/// Generates an IBAN with correct check digits in the electronic format without spaces, +/// e.g. DE89370400440532013000. Every SEPA country is supported, and where the country keeps its +/// own check digits inside the account part, those are calculated as well, so the number passes a +/// validator that knows the national rules, not only the mod 97 check. +/// +/// +/// The structures come from the SWIFT IBAN registry (ISO 13616). The national check digits are +/// implemented for BE, EE, ES, FI, FR, HU, IS, IT, MC, NO, PL, PT, SI and SM; DK and SE carry a check +/// digit convention inside the account for which no reliably published algorithm exists, there the digit +/// is drawn like any other. The Polish check covers the bank code, the Icelandic one sits inside the +/// kennitala of the account holder. +/// The numbers are syntactically valid. The bank codes are drawn, so a number can name a bank that +/// exists, and an IBAN needs no second factor for a SEPA direct debit, so treat these numbers with the +/// same care as real ones and keep them inside your test systems. For data that leaves your machine use +/// , whose bank code provably belongs to no bank. +/// +public abstract class IbanSourceBase : DataSourceBase { + /// + /// One country of the IBAN registry: the BBAN as segments, e.g. 1a5n5n12c for Italy, and the + /// calculation of the national check digits where the country has some. A calculation returns false + /// when the drawn digits allow no valid check digit, the BBAN is then drawn again. + /// + private sealed record CountrySpec(string BbanSegments, Func? NationalCheck = null) { + public (int Length, char Kind)[] Segments { get; } = ParseSegments(BbanSegments); + public int BbanLength { get; } = ParseSegments(BbanSegments).Sum(s => s.Length); + + private static (int Length, char Kind)[] ParseSegments(string spec) { + var segments = new List<(int, char)>(); + var length = 0; + + foreach (var c in spec) + if (c is >= '0' and <= '9') + length = length * 10 + (c - '0'); + else { + segments.Add((length, c)); + length = 0; + } + + return [.. segments]; + } + } + + /// + /// The SEPA countries of the SWIFT IBAN registry. n stands for digits, a for capital letters, + /// c for digits and capital letters. + /// + private static readonly Dictionary _registry = new() { + ["AD"] = new("4n4n12c"), + ["AT"] = new("5n11n"), + ["BE"] = new("3n7n2n", FixBelgianCheck), + ["BG"] = new("4a4n2n8c"), + ["CH"] = new("5n12c"), + ["CY"] = new("3n5n16c"), + ["CZ"] = new("4n6n10n"), + ["DE"] = new("8n10n"), + ["DK"] = new("4n9n1n"), + ["EE"] = new("2n2n11n1n", FixEstonianCheck), + ["ES"] = new("4n4n2n10n", FixSpanishCheck), + ["FI"] = new("3n11n", FixFinnishCheck), + ["FR"] = new("5n5n11c2n", FixRibKey), + ["GB"] = new("4a6n8n"), + ["GI"] = new("4a15c"), + ["GR"] = new("3n4n16c"), + ["HR"] = new("7n10n"), + ["HU"] = new("3n4n1n15n1n", FixHungarianChecks), + ["IE"] = new("4a6n8n"), + ["IS"] = new("4n2n6n10n", FixIcelandicCheck), + ["IT"] = new("1a5n5n12c", FixItalianCin), + ["LI"] = new("5n12c"), + ["LT"] = new("5n11n"), + ["LU"] = new("3n13c"), + ["LV"] = new("4a13c"), + ["MC"] = new("5n5n11c2n", FixRibKey), + ["MT"] = new("4a5n18c"), + ["NL"] = new("4a10n"), + ["NO"] = new("4n6n1n", FixNorwegianCheck), + ["PL"] = new("8n16n", FixPolishBankCodeCheck), + ["PT"] = new("4n4n11n2n", FixPortugueseCheck), + ["RO"] = new("4a16c"), + ["SE"] = new("3n16n1n"), + ["SI"] = new("5n8n2n", FixSlovenianCheck), + ["SK"] = new("4n6n10n"), + ["SM"] = new("1a5n5n12c", FixItalianCin), + ["VA"] = new("3n15n"), + }; + + private static readonly string[] _countryCodes = [.. _registry.Keys.OrderBy(c => c, StringComparer.Ordinal)]; + + /// + /// The countries this source can generate, as ISO 3166-1 alpha-2 codes. + /// + public static IReadOnlyCollection SupportedCountries => _countryCodes; + + private const string _alphanumericAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + private readonly string? _fixedCountry; + + /// + /// Creates the source. + /// + /// + /// Optional ISO 3166-1 alpha-2 country code, e.g. DE. Drawn from + /// when null. + /// + /// Optional null creation threshold in percent. + /// is not a SEPA country of the registry. + protected IbanSourceBase(string? countryCode = null, int? nullCreationThreshold = null) + : base(nullCreationThreshold) { + if (countryCode is not null) { + if (!_registry.ContainsKey(countryCode)) + throw new ArgumentException( + $"'{countryCode}' is not a SEPA country this source can generate an IBAN for. Supported: {string.Join(", ", _countryCodes)}.", + nameof(countryCode)); + + _fixedCountry = countryCode; + } + } + + protected override string GetNextValue(IGenerationContext? context) { + if (NullCreationThreshold.HasValue) { + if (RandomNullEvaluator.ShouldNextValueReturnNull()) + return null!; + } + + var country = _fixedCountry ?? _countryCodes[Random.Next(0, _countryCodes.Length)]; + var spec = _registry[country]; + var bban = new char[spec.BbanLength]; + + do { + var position = 0; + + foreach (var (length, kind) in spec.Segments) + for (var i = 0; i < length; i++) + bban[position++] = kind switch { + 'n' => (char) ('0' + Random.Next(0, 10)), + 'a' => (char) ('A' + Random.Next(0, 26)), + _ => _alphanumericAlphabet[Random.Next(0, _alphanumericAlphabet.Length)] + }; + } while (spec.NationalCheck is not null && !spec.NationalCheck(bban)); + + return BuildIban(country, bban); + } + + /// + /// Puts the two ISO 7064 mod 97 check digits between country code and BBAN. + /// + private protected static string BuildIban(string countryCode, char[] bban) { + Span rearranged = stackalloc char[bban.Length + 4]; + bban.CopyTo(rearranged); + countryCode.AsSpan().CopyTo(rearranged[bban.Length..]); + rearranged[bban.Length + 2] = '0'; + rearranged[bban.Length + 3] = '0'; + + var check = 98 - CheckDigits.Mod97(rearranged); + + return $"{countryCode}{check:00}{new string(bban)}"; + } + + /// + /// Belgium closes its twelve digits with the first ten mod 97, a remainder of zero written as 97. + /// + private static bool FixBelgianCheck(char[] bban) { + var check = (int) (DigitsAsNumber(bban, 0, 10) % 97); + if (check == 0) + check = 97; + + WriteTwoDigits(bban, 10, check); + return true; + } + + /// + /// Estonia checks the thirteen digits behind the two digit bank prefix with the weights 7, 1, 3 + /// repeating; the last digit lifts the weighted sum to the next multiple of ten. + /// + private static bool FixEstonianCheck(char[] bban) { + ReadOnlySpan weights = [7, 1, 3]; + var sum = 0; + + for (var i = 2; i < 15; i++) + sum += (bban[i] - '0') * weights[(i - 2) % 3]; + + bban[15] = (char) ('0' + ((10 - (sum % 10)) % 10)); + return true; + } + + /// + /// Spain carries two check digits in the middle: one over 00 plus bank and branch, one over the ten + /// account digits, both weighted 1, 2, 4, 8, 5, 10, 9, 7, 3, 6 with 11 minus the sum mod 11, where + /// eleven becomes zero and ten becomes one. + /// + private static bool FixSpanishCheck(char[] bban) { + Span padded = stackalloc char[10]; + padded[0] = '0'; + padded[1] = '0'; + bban.AsSpan(0, 8).CopyTo(padded[2..]); + + bban[8] = (char) ('0' + SpanishDigit(padded)); + bban[9] = (char) ('0' + SpanishDigit(bban.AsSpan(10, 10))); + return true; + } + + private static int SpanishDigit(ReadOnlySpan digits) { + ReadOnlySpan weights = [1, 2, 4, 8, 5, 10, 9, 7, 3, 6]; + var sum = 0; + + for (var i = 0; i < 10; i++) + sum += (digits[i] - '0') * weights[i]; + + var digit = 11 - (sum % 11); + return digit switch { + 11 => 0, + 10 => 1, + _ => digit + }; + } + + /// + /// Finland closes its fourteen digits with the Luhn check digit of the first thirteen. + /// + private static bool FixFinnishCheck(char[] bban) { + bban[13] = (char) ('0' + CheckDigits.Luhn(bban, 13)); + return true; + } + + /// + /// France and Monaco close with the RIB key: 97 minus 89 times the bank, 15 times the branch and + /// 3 times the account, mod 97. A letter in the account counts by the RIB table, A to I as 1 to 9, + /// J to R again as 1 to 9 and S to Z as 2 to 9. + /// + private static bool FixRibKey(char[] bban) { + var bank = DigitsAsNumber(bban, 0, 5); + var branch = DigitsAsNumber(bban, 5, 5); + + var account = 0L; + for (var i = 10; i < 21; i++) + account = account * 10 + RibValue(bban[i]); + + var key = 97 - (int) (((89 * bank) + (15 * branch) + (3 * account)) % 97); + + WriteTwoDigits(bban, 21, key); + return true; + } + + private static int RibValue(char c) + => c switch { + >= '0' and <= '9' => c - '0', + >= 'A' and <= 'I' => c - 'A' + 1, + >= 'J' and <= 'R' => c - 'J' + 1, + _ => c - 'S' + 2 + }; + + /// + /// Hungary carries two check digits: the eighth digit checks bank and branch, the last one checks the + /// fifteen account digits, both with the weights 9, 7, 3, 1 repeating and the digit that lifts the sum + /// to the next multiple of ten. + /// + private static bool FixHungarianChecks(char[] bban) { + bban[7] = (char) ('0' + HungarianDigit(bban.AsSpan(0, 7))); + bban[23] = (char) ('0' + HungarianDigit(bban.AsSpan(8, 15))); + return true; + } + + private static int HungarianDigit(ReadOnlySpan digits) { + ReadOnlySpan weights = [9, 7, 3, 1]; + var sum = 0; + + for (var i = 0; i < digits.Length; i++) + sum += (digits[i] - '0') * weights[i % 4]; + + return (10 - (sum % 10)) % 10; + } + + /// + /// The last ten digits of an Icelandic BBAN are the kennitala of the account holder, whose ninth digit + /// is a mod 11 check over the first eight with the weights 3, 2, 7, 6, 5, 4, 3, 2. A remainder of one + /// allows no check digit, those digits are drawn again. + /// + private static bool FixIcelandicCheck(char[] bban) { + ReadOnlySpan weights = [3, 2, 7, 6, 5, 4, 3, 2]; + var sum = 0; + + // the kennitala fills the last ten positions of the 22 digit BBAN, so it starts at twelve + for (var i = 0; i < 8; i++) + sum += (bban[12 + i] - '0') * weights[i]; + + var remainder = sum % 11; + if (remainder == 1) + return false; + + bban[20] = (char) ('0' + ((11 - remainder) % 11)); + return true; + } + + /// + /// Italy and San Marino start the BBAN with the CIN letter over the twenty two characters behind it: + /// a character at an even position counts its base value, digits as themselves and letters as zero to + /// twenty five, one at an odd position counts by the CIN table, and the sum mod 26 names the letter. + /// + private static bool FixItalianCin(char[] bban) { + ReadOnlySpan oddValues = [1, 0, 5, 7, 9, 13, 15, 17, 19, 21, 2, 4, 18, 20, 11, 3, 6, 8, 12, 14, 16, 10, 22, 25, 24, 23]; + var sum = 0; + + for (var i = 1; i < 23; i++) { + var baseValue = bban[i] is >= '0' and <= '9' + ? bban[i] - '0' + : bban[i] - 'A'; + + // the first character behind the CIN is position one, an odd position + sum += i % 2 == 1 + ? oddValues[baseValue] + : baseValue; + } + + bban[0] = (char) ('A' + (sum % 26)); + return true; + } + + /// + /// Norway closes its eleven digits with a mod 11 check over the first ten, weighted 5, 4, 3, 2, 7, 6, + /// 5, 4, 3, 2. A remainder of one allows no check digit, those digits are drawn again. + /// + private static bool FixNorwegianCheck(char[] bban) { + ReadOnlySpan weights = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2]; + var sum = 0; + + for (var i = 0; i < 10; i++) + sum += (bban[i] - '0') * weights[i]; + + var remainder = sum % 11; + if (remainder == 1) + return false; + + bban[10] = (char) ('0' + ((11 - remainder) % 11)); + return true; + } + + /// + /// The eighth digit of a Polish bank code checks the seven before it, weighted 3, 9, 7, 1, 3, 9, 7, + /// with the digit that lifts the sum to the next multiple of ten. The sixteen account digits carry no + /// check of their own. + /// + private static bool FixPolishBankCodeCheck(char[] bban) { + ReadOnlySpan weights = [3, 9, 7, 1, 3, 9, 7]; + var sum = 0; + + for (var i = 0; i < 7; i++) + sum += (bban[i] - '0') * weights[i]; + + bban[7] = (char) ('0' + ((10 - (sum % 10)) % 10)); + return true; + } + + /// + /// Portugal closes its NIB with two ISO 7064 mod 97 digits over the nineteen digits before them. + /// + private static bool FixPortugueseCheck(char[] bban) { + WriteTwoDigits(bban, 19, 98 - Mod97OfDigitsTimesHundred(bban.AsSpan(0, 19))); + return true; + } + + /// + /// Slovenia closes with two ISO 7064 mod 97 digits over the thirteen digits before them. + /// + private static bool FixSlovenianCheck(char[] bban) { + WriteTwoDigits(bban, 13, 98 - Mod97OfDigitsTimesHundred(bban.AsSpan(0, 13))); + return true; + } + + private static int Mod97OfDigitsTimesHundred(ReadOnlySpan digits) { + Span padded = stackalloc char[digits.Length + 2]; + digits.CopyTo(padded); + padded[^2] = '0'; + padded[^1] = '0'; + + return CheckDigits.Mod97(padded); + } + + private static long DigitsAsNumber(char[] bban, int start, int length) { + var value = 0L; + + for (var i = start; i < start + length; i++) + value = value * 10 + (bban[i] - '0'); + + return value; + } + + private static void WriteTwoDigits(char[] bban, int position, int value) { + bban[position] = (char) ('0' + (value / 10)); + bban[position + 1] = (char) ('0' + (value % 10)); + } +} + +/// +/// An IBAN of a SEPA country with correct check digits, national ones included. +/// +public class IbanSource(string? countryCode) : IbanSourceBase(countryCode) { + public IbanSource() : this(null) { } +} + +/// +/// An IBAN of a SEPA country that returns null every now and then. +/// +public class NullableIbanSource(string? countryCode, int nullCreationThreshold) : IbanSourceBase(countryCode, nullCreationThreshold) { + public NullableIbanSource() : this(null, AutoPocoDefaults.NullCreationThreshold) { } + + public NullableIbanSource(string? countryCode) : this(countryCode, AutoPocoDefaults.NullCreationThreshold) { } + + public NullableIbanSource(int nullCreationThreshold) : this(null, nullCreationThreshold) { } +} + +/// +/// A German IBAN on a bank code that provably belongs to no bank, so the number is recognisable as test +/// data and cannot name anybody's account. +/// +/// +/// A German bank code starts with its clearing area, and the Bundesbank hands out the areas one to eight +/// (Merkblatt Bankleitzahlendatei; in the official bank code file of September 2026 not one of the 13760 +/// codes starts with a nine). This source therefore starts every bank code with a nine. The mod 97 check +/// digits are correct, so the number passes any structural IBAN validation, and a lookup of the bank code +/// comes back empty, which is the point. An officially reserved test bank code does not exist in Germany, +/// this convention is the closest safe equivalent. +/// Austria and Switzerland publish no unassigned range, so no such source exists for them. +/// +public abstract class TestBlzIbanSourceBase : DataSourceBase { + protected TestBlzIbanSourceBase(int? nullCreationThreshold = null) + : base(nullCreationThreshold) { } + + protected override string GetNextValue(IGenerationContext? context) { + if (NullCreationThreshold.HasValue) { + if (RandomNullEvaluator.ShouldNextValueReturnNull()) + return null!; + } + + var bban = new char[18]; + bban[0] = '9'; + + for (var i = 1; i < 18; i++) + bban[i] = (char) ('0' + Random.Next(0, 10)); + + Span rearranged = stackalloc char[22]; + bban.CopyTo(rearranged); + "DE00".AsSpan().CopyTo(rearranged[18..]); + + var check = 98 - CheckDigits.Mod97(rearranged); + + return $"DE{check:00}{new string(bban)}"; + } +} + +/// +/// A German IBAN on a bank code outside the assigned range, recognisable as test data. +/// +public class TestBlzIbanSource() : TestBlzIbanSourceBase(null); + +/// +/// A German IBAN on a bank code outside the assigned range that returns null every now and then. +/// +public class NullableTestBlzIbanSource(int nullCreationThreshold) : TestBlzIbanSourceBase(nullCreationThreshold) { + public NullableTestBlzIbanSource() : this(AutoPocoDefaults.NullCreationThreshold) { } +} From cdda27968cdae5c089c61bcbf82d165c5292c247 Mon Sep 17 00:00:00 2001 From: "christian.vogt@hiquaro.com" Date: Mon, 21 Sep 2026 19:22:35 +0200 Subject: [PATCH 3/3] Extend VatIdSource to every country with published check digit rules Thirteen more prefixes from the official BMF construction rules document, each calibrated against its worked example: BE (mod 97 pair), DK and SK (divisibility by eleven with a redraw when no last digit fits), EE (weights 3, 7, 1 behind the fixed 10), FI (the unusual weights 7, 9, 10, 5, 8, 4, 2), GB and XI (MOD 97 and MOD 9755 with their excluded ranges), HU (weights 9, 7, 3, 1), IE (check letter mod 23 in the eight and nine character forms), LT (the double weight pass whose worked example exercises the fallback), LU (mod 89 pair), PT and SI (mod 11 with their asymmetric special cases) and SE (Luhn over the organisation number plus the 01 to 94 suffix). EL, ES, FR, CY, CZ, LV, MT, BG and RO have not agreed to a publication of their rules and stay out on purpose. The mutation test skips GB, XI, LT and PT, whose schemes can legitimately accept a mutated number; everywhere else no single digit change survives. Co-Authored-By: Claude Fable 5 --- Directory.Build.props | 2 +- README.md | 2 +- .../Identifiers/IdentifierValidation.cs | 268 +++++++++++++ .../Identifiers/VatIdSourceTests.cs | 98 ++++- .../DataSources/Identifiers/VatIdSource.cs | 368 +++++++++++++++++- 5 files changed, 714 insertions(+), 24 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index b020a87..2d3ee6a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -25,7 +25,7 @@ git README.md autopoco;testdata;test-data;poco;generator;fluent;fake-data - 6.1: New data sources for trade identifiers in holonsoft.AutoPoco.DataSources.Identifiers: GtinSource and Ean13Source (GTIN-8/12/13/14, i.e. EAN-8, UPC-A, EAN-13 and ITF-14, with a valid GS1 mod 10 check digit and an optional GS1 company prefix), SsccSource (the eighteen digit number of a logistic unit, same GS1 check digit, optional prefix), IsbnSource (ISBN-13 inside an allocated registration group, ISBN-10 with its mod 11 check digit including the X), IssnSource (mod 11 check character including the X), IsinSource (a real country prefix or a given one, Luhn over the expanded letters), ImeiSource (Luhn check digit, optional Type Allocation Code prefix) and AsinSource, each with a Nullable variant. IbanSource generates the IBAN of any SEPA country with correct mod 97 check digits and the national check digits inside the BBAN (BE, EE, ES, FI, FR/MC, HU, IS, IT/SM, NO, PL, PT, SI), TestBlzIbanSource a German IBAN on a bank code outside the assigned range so the number is recognisable as test data, and VatIdSource an EU VAT ID with a valid check digit for DE, AT, HR, IT, NL and PL. TestBinCreditCardSource in holonsoft.AutoPoco.DataSources.Business builds card numbers on the six digit test BINs the payment processors publish, so the data is recognisable as test data. Bug fixes: CreditCardSource doubled the wrong digits when calculating the Luhn check digit, so its numbers failed a real Luhn validation; and only the first digit of the scheme was fixed, so an "American Express" could be detected as a Diners Club and a "MasterCard" could start with 56, a range no scheme issues. Every number now carries a correct check digit and starts inside a range its scheme really issues in (34/37 for American Express, 51-55 and 2221-2720 for Mastercard, 6011/644-649/65 for Discover), and the generated sequences changed once for that. 6.0: Lambda data sources (FuncSource, Setup(...).From(() => ...), Source(expr, () => ...) at generation time). Opt-in nullability-aware null generation via RespectNullableAnnotations(threshold). Generic NumberSource<T> and NullableNumberSource<T> with min/max for every built-in numeric type (integers uniform and inclusive, floating point and decimal overflow safe). Records and immutable types are constructed through their constructor, parameters are fed from the registered members with the same name, no parameterless constructor needed anymore; unconfigured struct parameters stay default instead of throwing. Seeds: UseSeed(seed) per factory, CreateSession(recursionLimit, seed) per session, every member gets its own stream derived from the seed. Own deterministic generator (StableRandom, xoshiro256**) instead of System.Random, sequences are stable across .NET versions. Breaking: AutoPocoGlobalSettings removed (AutoPocoDefaults constants instead), every generated sequence changed once, the max of IntegerSource/LongSource/RandomNumberSource and their nullable variants is inclusive now (it was exclusive), Int128Source/NullableInt128Source pick uniformly instead of clamping to the bounds (they never produced a negative value before), RandomStringSource and UrlSource reach the last character resp. the last top level domain, ColorSource/RandomTextSource/RandomUtfTextSource treat their maximum as inclusive, ColorSource applies the alpha of SetColorRange, and UrlSource and ExtendedEmailAddressSource seed their nested sources (the URL host ignored the session seed completely). CreditCardSource never produced a Discover card. TimeSpanSource was rewritten: it returned TimeSpan.MinValue on every draw of the default range, produced values outside a requested range and looped forever on a reversed one. DoubleSource returned infinity over its default range, DoubleSource/FloatSource/DecimalSource interpolate overflow safe now and DecimalSource keeps its full precision. Index-aware Impose on lists and selections (value from the position, or from position and item), Impose(member, item => value) on a single generator. Invoke arguments: Use.From(lambda), constants and variables arrive as values. Context lambdas get a non-nullable IGenerationContext. Argument validation over the public API, meaningful exception messages, XML documentation file. IDataSource<T> is covariant. Bug fixes: Nullable* sources honor an explicit null creation threshold (null positions of stable sequences changed), DateTime/DateOnly/TimeOnly/DateOfBirth sources cover their whole range including the upper bound (sequences changed), RandomUtfTextSource no longer loops forever. Build and packaging modernized: GitHub Actions CI, trusted publishing to nuget.org, MinVer tag based versioning, central package management, xunit.v3 tests on net8/9/10. FluentAssertions and Moq are no longer dependencies of the library. See README for details. + 6.1: New data sources for trade identifiers in holonsoft.AutoPoco.DataSources.Identifiers: GtinSource and Ean13Source (GTIN-8/12/13/14, i.e. EAN-8, UPC-A, EAN-13 and ITF-14, with a valid GS1 mod 10 check digit and an optional GS1 company prefix), SsccSource (the eighteen digit number of a logistic unit, same GS1 check digit, optional prefix), IsbnSource (ISBN-13 inside an allocated registration group, ISBN-10 with its mod 11 check digit including the X), IssnSource (mod 11 check character including the X), IsinSource (a real country prefix or a given one, Luhn over the expanded letters), ImeiSource (Luhn check digit, optional Type Allocation Code prefix) and AsinSource, each with a Nullable variant. IbanSource generates the IBAN of any SEPA country with correct mod 97 check digits and the national check digits inside the BBAN (BE, EE, ES, FI, FR/MC, HU, IS, IT/SM, NO, PL, PT, SI), TestBlzIbanSource a German IBAN on a bank code outside the assigned range so the number is recognisable as test data, and VatIdSource a VAT ID with a valid check digit for AT, BE, DE, DK, EE, FI, GB, HR, HU, IE, IT, LT, LU, NL, PL, PT, SE, SI, SK and XI, calibrated against the official construction rules; countries that have not published their rules are left out on purpose. TestBinCreditCardSource in holonsoft.AutoPoco.DataSources.Business builds card numbers on the six digit test BINs the payment processors publish, so the data is recognisable as test data. Bug fixes: CreditCardSource doubled the wrong digits when calculating the Luhn check digit, so its numbers failed a real Luhn validation; and only the first digit of the scheme was fixed, so an "American Express" could be detected as a Diners Club and a "MasterCard" could start with 56, a range no scheme issues. Every number now carries a correct check digit and starts inside a range its scheme really issues in (34/37 for American Express, 51-55 and 2221-2720 for Mastercard, 6011/644-649/65 for Discover), and the generated sequences changed once for that. 6.0: Lambda data sources (FuncSource, Setup(...).From(() => ...), Source(expr, () => ...) at generation time). Opt-in nullability-aware null generation via RespectNullableAnnotations(threshold). Generic NumberSource<T> and NullableNumberSource<T> with min/max for every built-in numeric type (integers uniform and inclusive, floating point and decimal overflow safe). Records and immutable types are constructed through their constructor, parameters are fed from the registered members with the same name, no parameterless constructor needed anymore; unconfigured struct parameters stay default instead of throwing. Seeds: UseSeed(seed) per factory, CreateSession(recursionLimit, seed) per session, every member gets its own stream derived from the seed. Own deterministic generator (StableRandom, xoshiro256**) instead of System.Random, sequences are stable across .NET versions. Breaking: AutoPocoGlobalSettings removed (AutoPocoDefaults constants instead), every generated sequence changed once, the max of IntegerSource/LongSource/RandomNumberSource and their nullable variants is inclusive now (it was exclusive), Int128Source/NullableInt128Source pick uniformly instead of clamping to the bounds (they never produced a negative value before), RandomStringSource and UrlSource reach the last character resp. the last top level domain, ColorSource/RandomTextSource/RandomUtfTextSource treat their maximum as inclusive, ColorSource applies the alpha of SetColorRange, and UrlSource and ExtendedEmailAddressSource seed their nested sources (the URL host ignored the session seed completely). CreditCardSource never produced a Discover card. TimeSpanSource was rewritten: it returned TimeSpan.MinValue on every draw of the default range, produced values outside a requested range and looped forever on a reversed one. DoubleSource returned infinity over its default range, DoubleSource/FloatSource/DecimalSource interpolate overflow safe now and DecimalSource keeps its full precision. Index-aware Impose on lists and selections (value from the position, or from position and item), Impose(member, item => value) on a single generator. Invoke arguments: Use.From(lambda), constants and variables arrive as values. Context lambdas get a non-nullable IGenerationContext. Argument validation over the public API, meaningful exception messages, XML documentation file. IDataSource<T> is covariant. Bug fixes: Nullable* sources honor an explicit null creation threshold (null positions of stable sequences changed), DateTime/DateOnly/TimeOnly/DateOfBirth sources cover their whole range including the upper bound (sequences changed), RandomUtfTextSource no longer loops forever. Build and packaging modernized: GitHub Actions CI, trusted publishing to nuget.org, MinVer tag based versioning, central package management, xunit.v3 tests on net8/9/10. FluentAssertions and Moq are no longer dependencies of the library. See README for details. diff --git a/README.md b/README.md index c3709ed..6d6758c 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Two more sources in `holonsoft.AutoPoco.DataSources.Identifiers`, for the number | --- | --- | | `IbanSource` | An IBAN of any SEPA country in the electronic format without spaces, with correct mod 97 check digits. Where a country keeps its own check digits inside the account part they are calculated as well: the Belgian mod 97 tail, the Spanish DC pair, the French and Monegasque RIB key, the Italian and Sammarinese CIN letter, the Norwegian, Estonian, Finnish, Hungarian, Polish and Icelandic schemes and the Portuguese and Slovenian ISO 7064 pairs. `new IbanSource("DE")` pins the country. | | `TestBlzIbanSource` | A German IBAN whose bank code starts with a nine. The Bundesbank hands out clearing areas one to eight only, and in the current official bank code file not a single one of the 13760 codes starts with a nine, so the number is recognisable as test data and can name nobody's account. | -| `VatIdSource` | An EU VAT identification number with a valid check digit for Germany, Austria, Croatia, Italy, the Netherlands and Poland, e.g. `DE249051813` or `ATU13585627`. Only countries with an implemented check digit are drawn, `new VatIdSource("DE")` pins the country. | +| `VatIdSource` | A VAT identification number with a valid check digit, e.g. `DE249051813` or `ATU13585627`, for AT, BE, DE, DK, EE, FI, GB, HR, HU, IE, IT, LT, LU, NL, PL, PT, SE, SI, SK and XI (Northern Ireland). Only countries with a published, calibrated check digit rule are drawn, `new VatIdSource("DE")` pins the country. EL, ES, FR, CY, CZ, LV, MT, BG and RO have not agreed to a publication of their construction rules, so they stay out on purpose: a number that only looks right is worse test data than a missing country. | Each has a `Nullable...` variant. The structures come from the SWIFT IBAN registry and the official construction rules of the tax authorities; the tests are calibrated against the registry example of every country and against registered VAT IDs of real companies before they judge the generator. diff --git a/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/IdentifierValidation.cs b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/IdentifierValidation.cs index 695e4c9..28a9bfa 100644 --- a/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/IdentifierValidation.cs +++ b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/IdentifierValidation.cs @@ -344,15 +344,283 @@ public static bool IsValidVatId(string? value) { return value[..2] switch { "AT" => IsValidAustrianVatId(value), + "BE" => IsValidBelgianVatId(value), "DE" => IsValidGermanVatId(value), + "DK" => IsValidDanishVatId(value), + "EE" => IsValidEstonianVatId(value), + "FI" => IsValidFinnishVatId(value), + "GB" or "XI" => IsValidUnitedKingdomVatId(value), "HR" => IsValidCroatianVatId(value), + "HU" => IsValidHungarianVatId(value), + "IE" => IsValidIrishVatId(value), "IT" => IsValidItalianVatId(value), + "LT" => IsValidLithuanianVatId(value), + "LU" => IsValidLuxembourgishVatId(value), "NL" => IsValidDutchVatId(value), "PL" => IsValidPolishVatId(value), + "PT" => IsValidPortugueseVatId(value), + "SE" => IsValidSwedishVatId(value), + "SI" => IsValidSlovenianVatId(value), + "SK" => IsValidSlovakVatId(value), _ => false }; } + private static bool AllDigits(ReadOnlySpan value) { + foreach (var c in value) + if (c is < '0' or > '9') + return false; + + return true; + } + + /// + /// BE and ten digits starting with a zero or a one: the last two are 97 minus the first eight mod 97. + /// + private static bool IsValidBelgianVatId(string value) { + if (value.Length != 12 || !AllDigits(value.AsSpan(2)) || value[2] is not ('0' or '1')) + return false; + + var body = int.Parse(value.AsSpan(2, 8), System.Globalization.CultureInfo.InvariantCulture); + var check = int.Parse(value.AsSpan(10, 2), System.Globalization.CultureInfo.InvariantCulture); + + return check == 97 - (body % 97); + } + + /// + /// DK and eight digits, the first off zero: weighted 2, 7, 6, 5, 4, 3, 2, 1 the number is divisible + /// by eleven. + /// + private static bool IsValidDanishVatId(string value) { + if (value.Length != 10 || !AllDigits(value.AsSpan(2)) || value[2] == '0') + return false; + + ReadOnlySpan weights = [2, 7, 6, 5, 4, 3, 2, 1]; + var sum = 0; + + for (var i = 0; i < 8; i++) + sum += (value[2 + i] - '0') * weights[i]; + + return sum % 11 == 0; + } + + /// + /// EE and nine digits starting with 10: weighted 3, 7, 1 repeating with the check digit added, the sum + /// is a multiple of ten. + /// + private static bool IsValidEstonianVatId(string value) { + if (value.Length != 11 || !AllDigits(value.AsSpan(2)) || value[2] != '1' || value[3] != '0') + return false; + + ReadOnlySpan weights = [3, 7, 1]; + var sum = value[10] - '0'; + + for (var i = 0; i < 8; i++) + sum += (value[2 + i] - '0') * weights[i % 3]; + + return sum % 10 == 0; + } + + /// + /// FI and eight digits: weighted 7, 9, 10, 5, 8, 4, 2 with the check digit added, the sum is a + /// multiple of eleven. + /// + private static bool IsValidFinnishVatId(string value) { + if (value.Length != 10 || !AllDigits(value.AsSpan(2))) + return false; + + ReadOnlySpan weights = [7, 9, 10, 5, 8, 4, 2]; + var sum = value[9] - '0'; + + for (var i = 0; i < 7; i++) + sum += (value[2 + i] - '0') * weights[i]; + + return sum % 11 == 0; + } + + /// + /// GB or XI and nine digits: the sum over the weights 8 down to 2 plus the two digit tail is a + /// multiple of 97, either directly or after adding 55, and the leading seven digits avoid the ranges + /// the matching scheme excludes. + /// + private static bool IsValidUnitedKingdomVatId(string value) { + if (value.Length != 11 || !AllDigits(value.AsSpan(2))) + return false; + + var sum = 0; + for (var i = 0; i < 7; i++) + sum += (value[2 + i] - '0') * (8 - i); + + sum += int.Parse(value.AsSpan(9, 2), System.Globalization.CultureInfo.InvariantCulture); + var body = int.Parse(value.AsSpan(2, 7), System.Globalization.CultureInfo.InvariantCulture); + + if (sum % 97 == 0) + return body is (< 100_000 or > 999_999) and (< 9_490_001 or > 9_700_000) and (< 9_990_001 or > 9_999_999); + + if ((sum + 55) % 97 == 0) + return body is 0 or > 1_000_000; + + return false; + } + + /// + /// HU and eight digits: weighted 9, 7, 3, 1, 9, 7, 3 with the check digit added, the sum is a + /// multiple of ten. The official rules name no constraint on the first digit, so none is checked. + /// + private static bool IsValidHungarianVatId(string value) { + if (value.Length != 10 || !AllDigits(value.AsSpan(2))) + return false; + + ReadOnlySpan weights = [9, 7, 3, 1, 9, 7, 3]; + var sum = value[9] - '0'; + + for (var i = 0; i < 7; i++) + sum += (value[2 + i] - '0') * weights[i]; + + return sum % 10 == 0; + } + + /// + /// IE and seven digits with a check letter mod 23, either as the eight character form or the nine + /// character form whose trailing letter A to I is weighted nine into the sum. + /// + private static bool IsValidIrishVatId(string value) { + const string checkCharacters = "WABCDEFGHIJKLMNOPQRSTUV"; + + if (value.Length is not (10 or 11) || !AllDigits(value.AsSpan(2, 7))) + return false; + + var sum = 0; + for (var i = 0; i < 7; i++) + sum += (value[2 + i] - '0') * (8 - i); + + if (value.Length == 11) { + if (value[10] is < 'A' or > 'I') + return false; + + sum += (value[10] - 'A' + 1) * 9; + } + + return value[9] == checkCharacters[sum % 23]; + } + + /// + /// LT and nine digits with an eighth digit of one, or twelve digits with an eleventh digit of one. + /// The check digit comes from the first weight pass mod 11, or from the second pass when the first + /// lands on ten, a ten there counting as zero. + /// + private static bool IsValidLithuanianVatId(string value) { + if (value.Length is not (11 or 14) || !AllDigits(value.AsSpan(2))) + return false; + + var dataLength = value.Length - 3; + + if (value[2 + dataLength - 1] != '1') + return false; + + var first = 0; + for (var i = 0; i < dataLength; i++) + first += (value[2 + i] - '0') * ((i % 9) + 1); + + var check = first % 11; + + if (check == 10) { + var second = 0; + for (var i = 0; i < dataLength; i++) + second += (value[2 + i] - '0') * (((i + 2) % 9) + 1); + + check = second % 11; + if (check == 10) + check = 0; + } + + return value[^1] - '0' == check; + } + + /// + /// LU and eight digits: the last two are the first six mod 89. + /// + private static bool IsValidLuxembourgishVatId(string value) { + if (value.Length != 10 || !AllDigits(value.AsSpan(2))) + return false; + + var body = int.Parse(value.AsSpan(2, 6), System.Globalization.CultureInfo.InvariantCulture); + var check = int.Parse(value.AsSpan(8, 2), System.Globalization.CultureInfo.InvariantCulture); + + return check == body % 89; + } + + /// + /// PT and nine digits, the first off zero: the check digit is 11 minus the sum over the weights 9 down + /// to 2 mod 11, a ten or eleven written as zero. + /// + private static bool IsValidPortugueseVatId(string value) { + if (value.Length != 11 || !AllDigits(value.AsSpan(2)) || value[2] == '0') + return false; + + var sum = 0; + for (var i = 0; i < 8; i++) + sum += (value[2 + i] - '0') * (9 - i); + + var check = 11 - (sum % 11); + if (check >= 10) + check = 0; + + return value[10] - '0' == check; + } + + /// + /// SE and twelve digits: the first ten pass the Luhn check and the suffix lies between 01 and 94. + /// + private static bool IsValidSwedishVatId(string value) { + if (value.Length != 14 || !AllDigits(value.AsSpan(2))) + return false; + + if (!IsValidLuhn(value[2..12])) + return false; + + var suffix = int.Parse(value.AsSpan(12, 2), System.Globalization.CultureInfo.InvariantCulture); + return suffix is >= 1 and <= 94; + } + + /// + /// SI and eight digits, the first off zero: the check digit is 11 minus the sum over the weights 8 + /// down to 2 mod 11, a ten written as zero and a sum divisible by eleven allowed no number at all. + /// + private static bool IsValidSlovenianVatId(string value) { + if (value.Length != 10 || !AllDigits(value.AsSpan(2)) || value[2] == '0') + return false; + + var sum = 0; + for (var i = 0; i < 7; i++) + sum += (value[2 + i] - '0') * (8 - i); + + var remainder = sum % 11; + if (remainder == 0) + return false; + + var check = 11 - remainder; + if (check == 10) + check = 0; + + return value[9] - '0' == check; + } + + /// + /// SK and ten digits, the first off zero and the third a 2, 3, 4, 7, 8 or 9: the whole number is + /// divisible by eleven. + /// + private static bool IsValidSlovakVatId(string value) { + if (value.Length != 12 || !AllDigits(value.AsSpan(2)) || value[2] == '0' || value[4] is not ('2' or '3' or '4' or '7' or '8' or '9')) + return false; + + var remainder = 0; + foreach (var c in value.AsSpan(2)) + remainder = ((remainder * 10) + (c - '0')) % 11; + + return remainder == 0; + } + /// /// ATU and eight digits. Validated the other way round than the generator: the check digit is added to /// the weighted digit sum, and the total has to land on 96 mod 10, i.e. on 6. diff --git a/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/VatIdSourceTests.cs b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/VatIdSourceTests.cs index c970bcf..a23bd35 100644 --- a/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/VatIdSourceTests.cs +++ b/holonsoft.AutoPoco.Tests/Functionality.Tests/DataSources/Identifiers/VatIdSourceTests.cs @@ -24,6 +24,24 @@ public class VatIdSourceTests : TestBase { [InlineData("PL5212207257")] [InlineData("PL5850010588")] [InlineData("HR29524210204")] + [InlineData("BE0776091951")] // the worked example of the BMF construction rules + [InlineData("DK88146328")] // BMF worked example + [InlineData("DK26259495")] // Coop Danmark + [InlineData("FI09853608")] // BMF worked example + [InlineData("GB434031494")] // BMF worked example, the MOD 97 branch + [InlineData("XI434031494")] // Northern Ireland runs on the same scheme + [InlineData("IE3628739L")] // BMF worked example, eight characters + [InlineData("IE3628739UA")] // BMF worked example, nine characters + [InlineData("LU10000356")] // BMF worked example + [InlineData("PT502757191")] // BMF worked example + [InlineData("SE556188840401")] // BMF worked example + [InlineData("EE100207415")] // BMF worked example + [InlineData("HU21376414")] // BMF worked example + [InlineData("HU10597190")] // BMF worked example, check digit zero + [InlineData("LT213179412")] // BMF worked example, decided by the second weight pass + [InlineData("LT290061371314")] // BMF worked example of the twelve digit form + [InlineData("SI15012557")] // BMF worked example + [InlineData("SK4030000007")] // BMF worked example public void TheValidatorAcceptsRealVatIds(string value) => IdentifierValidation.IsValidVatId(value).ShouldBeTrue(); @@ -42,24 +60,60 @@ public void TheValidatorAcceptsRealVatIds(string value) [InlineData("FR12345678901")] // a country the source does not generate [InlineData("DE24905181")] // one digit short [InlineData("DE2490518131")] // one digit too many + [InlineData("BE0776091952")] // check pair off by one + [InlineData("BE2776091951")] // the first digit is a zero or a one + [InlineData("DK88146327")] // no longer divisible by eleven + [InlineData("FI09853609")] // check digit off by one + [InlineData("GB434031493")] // neither scheme lands on a multiple of 97 + [InlineData("GB050000062")] // the sum fits MOD 97, but 0500000 lies in a range that scheme excludes + [InlineData("IE3628739M")] // check letter off by one + [InlineData("IE3628739UB")] // the trailing letter changes the sum, so the check letter no longer fits + [InlineData("LU10000357")] // check pair off by one + [InlineData("PT502757192")] // check digit off by one + [InlineData("SE556188840501")] // the Luhn digit of the organisation number is broken + [InlineData("SE556188840400")] // the suffix starts at 01 + [InlineData("SE556188840495")] // the suffix ends at 94 + [InlineData("EE100207416")] // check digit off by one + [InlineData("EE200207415")] // an Estonian number starts with 10 + [InlineData("HU21376415")] // check digit off by one + [InlineData("LT213179413")] // check digit off by one + [InlineData("LT213179402")] // the eighth digit is always a one + [InlineData("SI15012558")] // check digit off by one + [InlineData("SI05012557")] // a Slovenian number has no leading zero + [InlineData("SK4030000008")] // no longer divisible by eleven + [InlineData("SK5407062531")] // the counter example of the BMF document: third digit invalid, not divisible [InlineData("")] [InlineData(null)] public void TheValidatorRejectsABrokenVatId(string? value) => IdentifierValidation.IsValidVatId(value).ShouldBeFalse(); [Theory] - [InlineData("AT", 11)] - [InlineData("DE", 11)] - [InlineData("HR", 13)] - [InlineData("IT", 13)] - [InlineData("NL", 14)] - [InlineData("PL", 12)] - public void EveryNumberOfAFixedCountryIsValid(string countryCode, int expectedLength) { + [InlineData("AT", 11, 11)] + [InlineData("BE", 12, 12)] + [InlineData("DE", 11, 11)] + [InlineData("DK", 10, 10)] + [InlineData("EE", 11, 11)] + [InlineData("FI", 10, 10)] + [InlineData("GB", 11, 11)] + [InlineData("HR", 13, 13)] + [InlineData("HU", 10, 10)] + [InlineData("IE", 10, 11)] // eight or nine characters behind the prefix + [InlineData("IT", 13, 13)] + [InlineData("LT", 11, 11)] + [InlineData("LU", 10, 10)] + [InlineData("NL", 14, 14)] + [InlineData("PL", 12, 12)] + [InlineData("PT", 11, 11)] + [InlineData("SE", 14, 14)] + [InlineData("SI", 10, 10)] + [InlineData("SK", 12, 12)] + [InlineData("XI", 11, 11)] + public void EveryNumberOfAFixedCountryIsValid(string countryCode, int minLength, int maxLength) { var source = new VatIdSource(countryCode); for (var i = 0; i < 200; i++) { var value = source.Next(null); - value.Length.ShouldBe(expectedLength); + value.Length.ShouldBeInRange(minLength, maxLength); value.ShouldStartWith(countryCode); IdentifierValidation.IsValidVatId(value).ShouldBeTrue($"'{value}' has a broken check digit"); } @@ -85,16 +139,29 @@ public void AFixedCountryStaysFixed() .ShouldAllBe(v => v.StartsWith("NL")); /// - /// Every check digit scheme in here detects any single digit substitution, so no mutation of the - /// checksummed digits may still validate. The Dutch suffix carries no check digit and is left alone. + /// These check digit schemes detect any single digit substitution, so no mutation of the checksummed + /// digits may still validate. The Dutch suffix and the Irish and Swedish tails carry no check digit + /// and are left alone. GB and XI stay out because a mutation can legitimately flip a number from the + /// MOD 97 scheme into the MOD 9755 scheme, LT because its second weight pass can rescue a mutation, + /// and PT because its check digit folds ten and eleven onto the same zero. /// [Theory] [InlineData("AT")] + [InlineData("BE")] [InlineData("DE")] + [InlineData("DK")] + [InlineData("EE")] + [InlineData("FI")] [InlineData("HR")] + [InlineData("HU")] + [InlineData("IE")] [InlineData("IT")] + [InlineData("LU")] [InlineData("NL")] [InlineData("PL")] + [InlineData("SE")] + [InlineData("SI")] + [InlineData("SK")] public void NoSingleDigitChangeOfTheChecksummedPartStillValidates(string countryCode) { var source = new VatIdSource(countryCode); @@ -102,7 +169,12 @@ public void NoSingleDigitChangeOfTheChecksummedPartStillValidates(string country var value = source.Next(null); var digitsStart = countryCode == "AT" ? 3 : 2; - var digitsEnd = countryCode == "NL" ? 11 : value.Length; + var digitsEnd = countryCode switch { + "NL" => 11, // the B and the suffix carry no check digit + "IE" => 9, // the check letter and the trailing letter are not digits + "SE" => value.Length - 2, // the suffix carries no check digit + _ => value.Length + }; var digits = value[digitsStart..digitsEnd]; foreach (var mutatedDigits in IdentifierValidation.SingleCharacterMutations(digits, false)) { @@ -124,12 +196,12 @@ public void AnUnsupportedCountryThrows(string countryCode) [Fact] public void NextReturnsStableVatIdListInTermsOfTestability() => NextReturnsStableElementListInTermsOfTestability( - new VatIdSource(), new string[] { "NL343138074B93", "HR26180963647", "IT22865000685", "PL7550609224", "PL6243891485", "IT29766910789", "HR43067100051", "DE963657675", "ATU34138037", "HR01712438307" }); + new VatIdSource(), new string[] { "PT443138079", "DE361809634", "PL7432286509", "SE365506092370", "LU52438901", "BE0583297622", "IE1743067M", "BE0100518625", "DK51380304", "LU17124307" }); [Fact] public void NextReturnsStableVatIdListInTermsOfTestabilityAndListCanContainNull() => NextReturnsStableElementListInTermsOfTestability( - new NullableVatIdSource()!, new string?[] { "NL343138074B93", "HR26180963647", "IT22865000685", "PL7550609224", "PL6243891485", "IT29766910789", "HR43067100051", "DE963657675", "ATU34138037", "HR01712438307", "NL731140308B64", "HR60080791588", "NL159046798B24", "DE412742380", "IT67204800139", "PL8035560965", "IT97678500642", "ATU29389792", "NL007817617B41", "ATU72797967", "PL9609770190", "DE401306280", "NL328678995B68", "PL9542138596", null, "DE231245175", "PL9731344115", null, "PL4213312990", "DE970703677" }); + new NullableVatIdSource()!, new string?[] { "PT443138079", "DE361809634", "PL7432286509", "SE365506092370", "LU52438901", "BE0583297622", "IE1743067M", "BE0100518625", "DK51380304", "LU17124307", "HU40731142", "LU03026000", "ATU80791582", "LU15904603", "HR97931274232", "HU77204800", "LU75703501", "FI60963976", "GB483911274", "LT002938910", "LU79400738", "HU27618071", "LU27979669", "PL9609770190", null, "LU30130641", "LU28328688", null, "PT889973580", "EE102138595" }); [Fact] public void ANullableSourceReturnsNullsAndOtherwiseValidNumbers() { diff --git a/holonsoft.AutoPoco/DataSources/Identifiers/VatIdSource.cs b/holonsoft.AutoPoco/DataSources/Identifiers/VatIdSource.cs index 509e8d6..d5ac5e9 100644 --- a/holonsoft.AutoPoco/DataSources/Identifiers/VatIdSource.cs +++ b/holonsoft.AutoPoco/DataSources/Identifiers/VatIdSource.cs @@ -6,24 +6,27 @@ namespace holonsoft.AutoPoco.DataSources.Identifiers; /// -/// Generates an EU VAT identification number with a valid check digit, the country prefix included, +/// Generates a VAT identification number with a valid check digit, the country prefix included, /// e.g. DE249051813 or ATU13585627. /// /// -/// Only countries whose check digit algorithm this source implements are drawn, so every generated number -/// passes a validator that knows the national rules, not just the format. Currently: Germany and Croatia -/// (ISO 7064 MOD 11,10), Austria (the BMF digit sum with its 96 minus sum step), the Netherlands and -/// Poland (weighted mod 11) and Italy (Luhn over the eleven digits, office code kept in the range that is -/// handed out). More countries only carry a format without a published check digit, they can be added as -/// formats become verifiable. +/// Only countries whose check digit algorithm is published and calibrated against an official worked +/// example or a registered real number are drawn, so every generated number passes a validator that knows +/// the national rules, not just the format. The construction rules of most countries come from the +/// BMF document "Konstruktionsregeln der UID" (November 2020), Croatia from the OIB specification. +/// Greece, Spain, France, Cyprus, Czechia, Latvia, Malta, Bulgaria and Romania have not agreed to a +/// publication of their rules, so they stay out on purpose: a number that only looks right is worse test +/// data than a missing country. XI is Northern Ireland, which uses the United Kingdom scheme and is the +/// one GB style prefix VIES still knows. /// The numbers are syntactically valid, they are not registered and do not identify a real company. A VIES /// lookup will therefore report them as not registered, which is the correct answer for test data. /// public abstract class VatIdSourceBase : DataSourceBase { /// - /// The countries this source can generate, as ISO 3166-1 alpha-2 codes. + /// The countries this source can generate, as the prefixes the numbers carry. /// - public static IReadOnlyCollection SupportedCountries { get; } = ["AT", "DE", "HR", "IT", "NL", "PL"]; + public static IReadOnlyCollection SupportedCountries { get; } = + ["AT", "BE", "DE", "DK", "EE", "FI", "GB", "HR", "HU", "IE", "IT", "LT", "LU", "NL", "PL", "PT", "SE", "SI", "SK", "XI"]; private static readonly string[] _countries = [.. SupportedCountries]; @@ -70,15 +73,362 @@ protected override string GetNextValue(IGenerationContext? context) { return country switch { "AT" => NextAustrianId(), + "BE" => NextBelgianId(), "DE" => NextGermanId(), + "DK" => NextDanishId(), + "EE" => NextEstonianId(), + "FI" => NextFinnishId(), + "GB" or "XI" => NextUnitedKingdomId(country), "HR" => NextCroatianId(), + "HU" => NextHungarianId(), + "IE" => NextIrishId(), "IT" => NextItalianId(), + "LT" => NextLithuanianId(), + "LU" => NextLuxembourgishId(), "NL" => NextDutchId(), "PL" => NextPolishId(), + "PT" => NextPortugueseId(), + "SE" => NextSwedishId(), + "SI" => NextSlovenianId(), + "SK" => NextSlovakId(), _ => throw new InvalidOperationException($"Country '{country}' has no VAT ID generator.") }; } + /// + /// BE and ten digits: a leading zero or one, seven digits of enterprise number, and the pair 97 minus + /// the first eight digits mod 97. The second digit stays off zero, which both the old and the new + /// Belgian scheme allow. + /// + private string NextBelgianId() { + Span digits = stackalloc char[10]; + digits[0] = (char) ('0' + Random.Next(0, 2)); + digits[1] = (char) ('1' + Random.Next(0, 9)); + + for (var i = 2; i < 8; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + var body = 0; + for (var i = 0; i < 8; i++) + body = (body * 10) + (digits[i] - '0'); + + var check = 97 - (body % 97); + digits[8] = (char) ('0' + (check / 10)); + digits[9] = (char) ('0' + (check % 10)); + + return $"BE{new string(digits)}"; + } + + /// + /// DK and eight digits, the first one off zero. The whole number, weighted 2, 7, 6, 5, 4, 3, 2, 1, has + /// to be divisible by eleven; when no last digit achieves that, the first seven are drawn again. + /// + private string NextDanishId() { + ReadOnlySpan weights = [2, 7, 6, 5, 4, 3, 2]; + Span digits = stackalloc char[8]; + int check; + + do { + digits[0] = (char) ('1' + Random.Next(0, 9)); + + for (var i = 1; i < 7; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + var sum = 0; + for (var i = 0; i < 7; i++) + sum += (digits[i] - '0') * weights[i]; + + check = (11 - (sum % 11)) % 11; + } while (check == 10); + + digits[7] = (char) ('0' + check); + + return $"DK{new string(digits)}"; + } + + /// + /// EE and nine digits that always start with 10. The weights 3, 7, 1 repeat over the first eight, and + /// the last digit lifts the sum to the next multiple of ten. + /// + private string NextEstonianId() { + ReadOnlySpan weights = [3, 7, 1]; + Span digits = stackalloc char[9]; + digits[0] = '1'; + digits[1] = '0'; + + for (var i = 2; i < 8; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + var sum = 0; + for (var i = 0; i < 8; i++) + sum += (digits[i] - '0') * weights[i % 3]; + + digits[8] = (char) ('0' + ((10 - (sum % 10)) % 10)); + + return $"EE{new string(digits)}"; + } + + /// + /// FI and eight digits, the last one the mod 11 check over the unusual weights 7, 9, 10, 5, 8, 4, 2. + /// A weighted sum with remainder one allows no check digit, those digits are drawn again. + /// + private string NextFinnishId() { + ReadOnlySpan weights = [7, 9, 10, 5, 8, 4, 2]; + Span digits = stackalloc char[8]; + int remainder; + + do { + for (var i = 0; i < 7; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + var sum = 0; + for (var i = 0; i < 7; i++) + sum += (digits[i] - '0') * weights[i]; + + remainder = sum % 11; + } while (remainder == 1); + + digits[7] = (char) ('0' + ((11 - remainder) % 11)); + + return $"FI{new string(digits)}"; + } + + /// + /// GB or XI and nine digits: seven digits weighted 8 down to 2, closed by the two digit remainder that + /// brings the sum to a multiple of 97, either directly (the MOD 97 scheme) or after adding 55 + /// (the MOD 9755 scheme for newer numbers). Each scheme excludes a few ranges of the leading seven + /// digits, a draw inside them is repeated. + /// + private string NextUnitedKingdomId(string prefix) { + ReadOnlySpan weights = [8, 7, 6, 5, 4, 3, 2]; + Span digits = stackalloc char[9]; + var mod9755 = Random.Next(0, 2) == 1; + int body; + + do { + body = Random.Next(0, 10_000_000); + } while (mod9755 + ? body is >= 1 and <= 1_000_000 + : body is >= 100_000 and <= 999_999 or >= 9_490_001 and <= 9_700_000 or >= 9_990_001 and <= 9_999_999); + + for (var i = 6; i >= 0; i--) { + digits[i] = (char) ('0' + (body % 10)); + body /= 10; + } + + var sum = mod9755 ? 55 : 0; + for (var i = 0; i < 7; i++) + sum += (digits[i] - '0') * weights[i]; + + var check = (97 - (sum % 97)) % 97; + digits[7] = (char) ('0' + (check / 10)); + digits[8] = (char) ('0' + (check % 10)); + + return $"{prefix}{new string(digits)}"; + } + + /// + /// HU and eight digits, the last one lifting the sum over the weights 9, 7, 3, 1, 9, 7, 3 to the next + /// multiple of ten. The first digit stays off zero. + /// + private string NextHungarianId() { + ReadOnlySpan weights = [9, 7, 3, 1, 9, 7, 3]; + Span digits = stackalloc char[8]; + digits[0] = (char) ('1' + Random.Next(0, 9)); + + for (var i = 1; i < 7; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + var sum = 0; + for (var i = 0; i < 7; i++) + sum += (digits[i] - '0') * weights[i]; + + digits[7] = (char) ('0' + ((10 - (sum % 10)) % 10)); + + return $"HU{new string(digits)}"; + } + + /// + /// The letters the Irish check maps a remainder to: zero is a W, one to twenty two are A to V. + /// + private const string _irishCheckCharacters = "WABCDEFGHIJKLMNOPQRSTUV"; + + /// + /// IE and seven digits weighted 8 down to 2, closed by a check letter mod 23. Half of the numbers get + /// the nine character form with a trailing letter A to I, which is weighted nine into the sum. + /// + private string NextIrishId() { + Span digits = stackalloc char[7]; + + for (var i = 0; i < 7; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + var sum = 0; + for (var i = 0; i < 7; i++) + sum += (digits[i] - '0') * (8 - i); + + if (Random.Next(0, 2) == 0) + return $"IE{new string(digits)}{_irishCheckCharacters[sum % 23]}"; + + var trailing = (char) ('A' + Random.Next(0, 9)); + sum += (trailing - 'A' + 1) * 9; + + return $"IE{new string(digits)}{_irishCheckCharacters[sum % 23]}{trailing}"; + } + + /// + /// LT and nine digits, the eighth always a one. The check digit is the sum over the weights 1 to 8 + /// mod 11; when that is ten, a second pass over the weights 3 to 9, 1 decides, and a ten there + /// becomes a zero. No draw is ever invalid. + /// + private string NextLithuanianId() { + Span digits = stackalloc char[9]; + + for (var i = 0; i < 7; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + digits[7] = '1'; + + var first = 0; + for (var i = 0; i < 8; i++) + first += (digits[i] - '0') * (i + 1); + + var check = first % 11; + + if (check == 10) { + var second = 0; + for (var i = 0; i < 8; i++) + second += (digits[i] - '0') * (((i + 2) % 9) + 1); + + check = second % 11; + if (check == 10) + check = 0; + } + + digits[8] = (char) ('0' + check); + + return $"LT{new string(digits)}"; + } + + /// + /// LU and eight digits: six digits of registration number, closed by their value mod 89 as a pair. + /// + private string NextLuxembourgishId() { + Span digits = stackalloc char[8]; + + var body = 0; + for (var i = 0; i < 6; i++) { + digits[i] = (char) ('0' + Random.Next(0, 10)); + body = (body * 10) + (digits[i] - '0'); + } + + var check = body % 89; + digits[6] = (char) ('0' + (check / 10)); + digits[7] = (char) ('0' + (check % 10)); + + return $"LU{new string(digits)}"; + } + + /// + /// PT and nine digits, the first one off zero, the last one 11 minus the sum over the weights 9 down + /// to 2 mod 11, where ten and eleven both become zero. No draw is ever invalid. + /// + private string NextPortugueseId() { + Span digits = stackalloc char[9]; + digits[0] = (char) ('1' + Random.Next(0, 9)); + + for (var i = 1; i < 8; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + var sum = 0; + for (var i = 0; i < 8; i++) + sum += (digits[i] - '0') * (9 - i); + + var check = 11 - (sum % 11); + if (check >= 10) + check = 0; + + digits[8] = (char) ('0' + check); + + return $"PT{new string(digits)}"; + } + + /// + /// SE and twelve digits: nine digits of organisation number, their Luhn check digit, and a suffix + /// between 01 and 94, in practice almost always 01. + /// + private string NextSwedishId() { + Span digits = stackalloc char[12]; + + for (var i = 0; i < 9; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + digits[9] = (char) ('0' + CheckDigits.Luhn(digits, 9)); + + var suffix = 1 + Random.Next(0, 94); + digits[10] = (char) ('0' + (suffix / 10)); + digits[11] = (char) ('0' + (suffix % 10)); + + return $"SE{new string(digits)}"; + } + + /// + /// SI and eight digits, the first one off zero, the last one 11 minus the sum over the weights 8 down + /// to 2 mod 11, where ten becomes zero and a sum divisible by eleven allows no check digit, those + /// digits are drawn again. + /// + private string NextSlovenianId() { + Span digits = stackalloc char[8]; + int remainder; + + do { + digits[0] = (char) ('1' + Random.Next(0, 9)); + + for (var i = 1; i < 7; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + var sum = 0; + for (var i = 0; i < 7; i++) + sum += (digits[i] - '0') * (8 - i); + + remainder = sum % 11; + } while (remainder == 0); + + var check = 11 - remainder; + digits[7] = (char) ('0' + (check == 10 ? 0 : check)); + + return $"SI{new string(digits)}"; + } + + /// + /// SK and ten digits: the first one off zero, the third one a 2, 3, 4, 7, 8 or 9, and the whole number + /// divisible by eleven. When no last digit achieves that, the first nine are drawn again. + /// + private string NextSlovakId() { + const string thirdDigits = "234789"; + Span digits = stackalloc char[10]; + int check; + + do { + digits[0] = (char) ('1' + Random.Next(0, 9)); + digits[1] = (char) ('0' + Random.Next(0, 10)); + digits[2] = thirdDigits[Random.Next(0, thirdDigits.Length)]; + + for (var i = 3; i < 9; i++) + digits[i] = (char) ('0' + Random.Next(0, 10)); + + var remainder = 0; + for (var i = 0; i < 9; i++) + remainder = ((remainder * 10) + (digits[i] - '0')) % 11; + + check = (11 - ((remainder * 10) % 11)) % 11; + } while (check == 10); + + digits[9] = (char) ('0' + check); + + return $"SK{new string(digits)}"; + } + /// /// ATU, seven digits and the BMF check digit: the digits are weighted 1 and 2 alternating, a doubled /// value keeps its digit sum, and the check digit is 96 minus the sum, mod 10.