From 754bb985a357d27f6a49dc88eb75ec83e46aeaab Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 4 Apr 2026 19:43:53 +0000 Subject: [PATCH 1/4] Add IBAN validation (it, validate, is, translations) - validate.IBAN with mod-97 check and Symfony 7.2 country patterns - it.IsIBAN, is.IBAN, ErrInvalidIBAN, EN/RU messages - Tests and examples; changelog entry Co-authored-by: Igor Lazarev --- CHANGELOG.md | 1 + errors.go | 1 + is/example_test.go | 10 ++ is/identifiers.go | 8 + it/example_test.go | 14 ++ it/identifiers.go | 10 ++ message/messages.go | 1 + message/translations/english/messages.go | 1 + message/translations/russian/messages.go | 1 + test/constraints_identifiers_cases_test.go | 72 +++++++++ validate/example_test.go | 10 ++ validate/iban.go | 172 +++++++++++++++++++++ validate/iban_formats.go | 140 +++++++++++++++++ validate/iban_test.go | 49 ++++++ 14 files changed, 490 insertions(+) create mode 100644 validate/iban.go create mode 100644 validate/iban_formats.go create mode 100644 validate/iban_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f23a0d..023010e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- IBAN validation: `it.IsIBAN()`, `validate.IBAN`, `is.IBAN`, with `validation.ErrInvalidIBAN` / `message.InvalidIBAN` and English and Russian translations (behavior aligned with Symfony `Iban`; country patterns from Symfony 7.2 `IbanValidator`). - CIDR notation validation: `it.IsCIDR()` with `IPv4Only`, `IPv6Only`, `WithVersion`, `WithNetmaskRange`, and separate invalid vs netmask-range messages; `validate.CIDR`, `validate.CIDRViolationNetmaskBounds`, `is.CIDR`; `validation.ErrInvalidCIDR` / `validation.ErrCIDRNetmaskOutOfRange` and English and Russian translations (behavior aligned with Symfony `Cidr`). Exported names use the **CIDR** initialism per [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments). - LUHN (mod 10 / Luhn) checksum validation: `it.IsLUHN()`, `validate.LUHN`, `is.LUHN`, with `validation.ErrInvalidLUHN` / `message.InvalidLUHN` and English and Russian translations (behavior aligned with Symfony `Luhn`). - ISIN (International Securities Identification Number) validation: `it.IsISIN()`, `validate.ISIN`, `is.ISIN`, with `validation.ErrInvalidISIN` / `message.InvalidISIN` and English and Russian translations (behavior aligned with Symfony `Isin`). diff --git a/errors.go b/errors.go index 6ed9fe3..7c8df41 100644 --- a/errors.go +++ b/errors.go @@ -15,6 +15,7 @@ var ( ErrInvalidEAN8 = NewError("invalid EAN-8", message.InvalidEAN8) ErrInvalidEmail = NewError("invalid email", message.InvalidEmail) ErrInvalidHostname = NewError("invalid hostname", message.InvalidHostname) + ErrInvalidIBAN = NewError("invalid IBAN", message.InvalidIBAN) ErrInvalidISIN = NewError("invalid ISIN", message.InvalidISIN) ErrInvalidCIDR = NewError("invalid CIDR", message.InvalidCIDR) ErrCIDRNetmaskOutOfRange = NewError("CIDR netmask out of range", message.CIDRNetmaskOutOfRange) diff --git a/is/example_test.go b/is/example_test.go index 2c5c9ca..0ba3f4c 100644 --- a/is/example_test.go +++ b/is/example_test.go @@ -263,6 +263,16 @@ func ExampleULID() { // false } +func ExampleIBAN() { + fmt.Println(is.IBAN("DE89370400440532013000")) + fmt.Println(is.IBAN("DE89370400440532013001")) + fmt.Println(is.IBAN("US64SVBX1101057138")) + // Output: + // true + // false + // false +} + func ExampleISIN() { fmt.Println(is.ISIN("US0378331005")) fmt.Println(is.ISIN("US037833100")) diff --git a/is/identifiers.go b/is/identifiers.go index 0f2e529..4e8f5e1 100644 --- a/is/identifiers.go +++ b/is/identifiers.go @@ -8,6 +8,14 @@ func ULID(value string) bool { return validate.ULID(value) == nil } +// IBAN validates whether the value is a valid International Bank Account Number (IBAN). +// See [github.com/muonsoft/validation/validate.IBAN] for validation rules. +// +// See https://en.wikipedia.org/wiki/International_Bank_Account_Number. +func IBAN(value string) bool { + return validate.IBAN(value) == nil +} + // ISIN validates whether the value is a valid International Securities Identification Number (ISIN). // See [github.com/muonsoft/validation/validate.ISIN] for validation rules and possible errors. // diff --git a/it/example_test.go b/it/example_test.go index 1b82c1d..562d17a 100644 --- a/it/example_test.go +++ b/it/example_test.go @@ -41,6 +41,20 @@ func ExampleIsUPCE() { // violation: "This value is not a valid UPC-E." } +func ExampleIsIBAN_valid() { + err := validator.Validate(context.Background(), validation.String("DE89370400440532013000", it.IsIBAN())) + fmt.Println(err) + // Output: + // +} + +func ExampleIsIBAN_invalid() { + err := validator.Validate(context.Background(), validation.String("DE89370400440532013001", it.IsIBAN())) + fmt.Println(err) + // Output: + // violation: "This is not a valid International Bank Account Number (IBAN)." +} + func ExampleIsISIN_valid() { err := validator.Validate(context.Background(), validation.String("US0378331005", it.IsISIN())) fmt.Println(err) diff --git a/it/identifiers.go b/it/identifiers.go index 9ce68aa..ac26cf9 100644 --- a/it/identifiers.go +++ b/it/identifiers.go @@ -16,6 +16,16 @@ func IsULID() validation.StringFuncConstraint { WithMessage(validation.ErrInvalidULID.Message()) } +// IsIBAN validates whether the value is a valid International Bank Account Number (IBAN). +// Behavior is aligned with Symfony\Component\Validator\Constraints\Iban. +// +// See https://en.wikipedia.org/wiki/International_Bank_Account_Number. +func IsIBAN() validation.StringFuncConstraint { + return validation.OfStringBy(is.IBAN). + WithError(validation.ErrInvalidIBAN). + WithMessage(validation.ErrInvalidIBAN.Message()) +} + // IsISIN validates whether the value is a valid International Securities Identification Number (ISIN). // See https://en.wikipedia.org/wiki/International_Securities_Identification_Number. func IsISIN() validation.StringFuncConstraint { diff --git a/message/messages.go b/message/messages.go index e848304..e437f2f 100644 --- a/message/messages.go +++ b/message/messages.go @@ -33,6 +33,7 @@ const ( InvalidEAN8 = "This value is not a valid EAN-8." InvalidEmail = "This value is not a valid email address." InvalidHostname = "This value is not a valid hostname." + InvalidIBAN = "This is not a valid International Bank Account Number (IBAN)." InvalidISIN = "This value is not a valid International Securities Identification Number (ISIN)." InvalidCIDR = "This value is not a valid CIDR notation." CIDRNetmaskOutOfRange = "The value of the netmask should be between {{ min }} and {{ max }}." diff --git a/message/translations/english/messages.go b/message/translations/english/messages.go index 1823983..2a185f4 100644 --- a/message/translations/english/messages.go +++ b/message/translations/english/messages.go @@ -54,6 +54,7 @@ var Messages = map[language.Tag]map[string]catalog.Message{ message.InvalidEAN8: catalog.String(message.InvalidEAN8), message.InvalidEmail: catalog.String(message.InvalidEmail), message.InvalidHostname: catalog.String(message.InvalidHostname), + message.InvalidIBAN: catalog.String(message.InvalidIBAN), message.InvalidISIN: catalog.String(message.InvalidISIN), message.InvalidCIDR: catalog.String(message.InvalidCIDR), message.CIDRNetmaskOutOfRange: catalog.String(message.CIDRNetmaskOutOfRange), diff --git a/message/translations/russian/messages.go b/message/translations/russian/messages.go index 0c9f65c..d82738f 100644 --- a/message/translations/russian/messages.go +++ b/message/translations/russian/messages.go @@ -57,6 +57,7 @@ var Messages = map[language.Tag]map[string]catalog.Message{ message.InvalidEAN8: catalog.String("Значение не является допустимым EAN-8."), message.InvalidEmail: catalog.String("Значение адреса электронной почты недопустимо."), message.InvalidHostname: catalog.String("Значение не является корректным именем хоста."), + message.InvalidIBAN: catalog.String("Значение не является допустимым международным номером банковского счёта (IBAN)."), message.InvalidISIN: catalog.String("Значение не является допустимым международным идентификационным номером ценной бумаги (ISIN)."), message.InvalidCIDR: catalog.String("Значение не является допустимой записью CIDR."), message.CIDRNetmaskOutOfRange: catalog.String("Значение маски сети должно быть между {{ min }} и {{ max }}."), diff --git a/test/constraints_identifiers_cases_test.go b/test/constraints_identifiers_cases_test.go index 8e9629d..4bcceae 100644 --- a/test/constraints_identifiers_cases_test.go +++ b/test/constraints_identifiers_cases_test.go @@ -9,6 +9,7 @@ import ( var identifierConstraintsTestCases = mergeTestCases( ulidConstraintTestCases, uuidConstraintTestCases, + ibanConstraintTestCases, isinConstraintTestCases, luhnConstraintTestCases, ) @@ -30,6 +31,77 @@ var ulidConstraintTestCases = []ConstraintValidationTestCase{ }, } +var ibanConstraintTestCases = []ConstraintValidationTestCase{ + { + name: "IsIBAN passes on empty value", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsIBAN(), + stringValue: stringValue(""), + assert: assertNoError, + }, + { + name: "IsIBAN passes on valid value", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("DE89370400440532013000"), + constraint: it.IsIBAN(), + assert: assertNoError, + }, + { + name: "IsIBAN passes on spaced value", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("CH93 0076 2011 6238 5295 7"), + constraint: it.IsIBAN(), + assert: assertNoError, + }, + { + name: "IsIBAN violation on invalid checksum", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("DE89370400440532013001"), + constraint: it.IsIBAN(), + assert: assertHasOneViolation(validation.ErrInvalidIBAN, message.InvalidIBAN), + }, + { + name: "IsIBAN violation on unsupported country", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("US64SVBX1101057138"), + constraint: it.IsIBAN(), + assert: assertHasOneViolation(validation.ErrInvalidIBAN, message.InvalidIBAN), + }, + { + name: "IsIBAN violation with given error and message", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsIBAN(). + WithError(ErrCustom). + WithMessage( + `Invalid value "{{ value }}" for {{ custom }}.`, + validation.TemplateParameter{Key: "{{ custom }}", Value: "parameter"}, + ), + stringValue: stringValue("bad-iban"), + assert: assertHasOneViolation(ErrCustom, `Invalid value "bad-iban" for parameter.`), + }, + { + name: "IsIBAN passes when condition is false", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsIBAN().When(false), + stringValue: stringValue("bad"), + assert: assertNoError, + }, + { + name: "IsIBAN violation when condition is true", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsIBAN().When(true), + stringValue: stringValue("bad"), + assert: assertHasOneViolation(validation.ErrInvalidIBAN, message.InvalidIBAN), + }, + { + name: "IsIBAN passes when groups not match", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsIBAN().WhenGroups(testGroup), + stringValue: stringValue("bad"), + assert: assertNoError, + }, +} + var isinConstraintTestCases = []ConstraintValidationTestCase{ { name: "IsISIN passes on empty value", diff --git a/validate/example_test.go b/validate/example_test.go index 77d875e..977584f 100644 --- a/validate/example_test.go +++ b/validate/example_test.go @@ -160,6 +160,16 @@ func ExampleULID() { // too large } +func ExampleIBAN() { + fmt.Println(validate.IBAN("DE89370400440532013000")) + fmt.Println(validate.IBAN("DE89370400440532013001")) + fmt.Println(validate.IBAN("US64SVBX1101057138")) + // Output: + // + // invalid IBAN + // invalid IBAN +} + func ExampleISIN() { fmt.Println(validate.ISIN("US0378331005")) fmt.Println(validate.ISIN("US037833100")) diff --git a/validate/iban.go b/validate/iban.go new file mode 100644 index 0000000..22a17a4 --- /dev/null +++ b/validate/iban.go @@ -0,0 +1,172 @@ +package validate + +import ( + "errors" + "strconv" + "strings" +) + +// ErrInvalidIBAN is returned by [IBAN] when the value is not a valid International Bank Account Number. +// Behavior is aligned with Symfony\Component\Validator\Constraints\Iban and IbanValidator. +var ErrInvalidIBAN = errors.New("invalid IBAN") + +// IBAN validates whether the value is a valid International Bank Account Number. +// Spaces (including U+00A0 and U+202F), ASCII letters, and digits are accepted; letters are normalized to upper case. +// +// Empty string is considered valid (use [NotBlank] or similar to reject empty values). +// +// Possible errors: +// - [ErrInvalidIBAN] when the value fails any IBAN rule (invalid characters, country, format, check digits, or mod-97). +// +// See https://en.wikipedia.org/wiki/International_Bank_Account_Number. +func IBAN(value string) error { + if value == "" { + return nil + } + + s, ok := canonicalizeIBAN(value) + if !ok { + return ErrInvalidIBAN + } + + return validateCanonicalIBAN(s) +} + +func validateCanonicalIBAN(s string) error { + if len(s) < 4 { + return ErrInvalidIBAN + } + if ibanHasNonAlphanumeric(s) { + return ErrInvalidIBAN + } + + cc := s[:2] + if !isAlpha2(cc) { + return ErrInvalidIBAN + } + + pat, ok := ibanCountryPatterns[cc] + if !ok || pat == nil || !pat.MatchString(s) { + return ErrInvalidIBAN + } + + if err := ibanCheckDigitRange(s[2:4]); err != nil { + return err + } + + if ibanMod97(s) != 1 { + return ErrInvalidIBAN + } + + return nil +} + +func ibanHasNonAlphanumeric(s string) bool { + for i := 0; i < len(s); i++ { + c := s[i] + if c >= '0' && c <= '9' || c >= 'A' && c <= 'Z' { + continue + } + return true + } + return false +} + +func ibanCheckDigitRange(two string) error { + cd, err := strconv.Atoi(two) + if err != nil || cd < 2 || cd > 98 { + return ErrInvalidIBAN + } + return nil +} + +func isAlpha2(cc string) bool { + if len(cc) != 2 { + return false + } + return cc[0] >= 'A' && cc[0] <= 'Z' && cc[1] >= 'A' && cc[1] <= 'Z' +} + +// canonicalizeIBAN strips IBAN grouping spaces and returns upper-case ASCII; ok is false on disallowed bytes/UTF-8. +func canonicalizeIBAN(value string) (string, bool) { + var b strings.Builder + b.Grow(len(value)) + + for i := 0; i < len(value); { + c := value[i] + if c == ' ' { + i++ + continue + } + if n := ibanNBSPPrefix(value, i); n > 0 { + i += n + continue + } + if n := ibanNNBSPPrefix(value, i); n > 0 { + i += n + continue + } + if c >= utf8ASCIIUpperBound { + return "", false + } + if c >= 'a' && c <= 'z' { + c -= 'a' - 'A' + } + b.WriteByte(c) + i++ + } + + return b.String(), true +} + +const utf8ASCIIUpperBound = 0x80 + +func ibanNBSPPrefix(value string, i int) int { + if value[i] == 0xc2 && i+1 < len(value) && value[i+1] == 0xa0 { + return 2 + } + return 0 +} + +func ibanNNBSPPrefix(value string, i int) int { + if i+3 <= len(value) && value[i] == 0xe2 && value[i+1] == 0x80 && value[i+2] == 0xaf { + return 3 + } + return 0 +} + +func ibanExpandedNumeric(s string) string { + rearr := s[4:] + s[:4] + var b strings.Builder + b.Grow(len(rearr) * 2) + for i := 0; i < len(rearr); i++ { + c := rearr[i] + switch { + case c >= '0' && c <= '9': + b.WriteByte(c) + case c >= 'A' && c <= 'Z': + b.WriteString(strconv.Itoa(int(c - 'A' + 10))) + default: + return "" + } + } + return b.String() +} + +func ibanMod97(s string) int { + digits := ibanExpandedNumeric(s) + if digits == "" { + return 0 + } + rest := 0 + for start := 0; start < len(digits); start += 7 { + end := start + 7 + if end > len(digits) { + end = len(digits) + } + for j := start; j < end; j++ { + rest = (rest*10 + int(digits[j]-'0')) % 97 + } + } + return rest +} diff --git a/validate/iban_formats.go b/validate/iban_formats.go new file mode 100644 index 0000000..6ca75c5 --- /dev/null +++ b/validate/iban_formats.go @@ -0,0 +1,140 @@ +// Code generated from Symfony IbanValidator FORMATS; DO NOT EDIT. +// Source: https://github.com/symfony/symfony/blob/7.2/src/Symfony/Component/Validator/Constraints/IbanValidator.php + +package validate + +import "regexp" + +var ibanCountryPatterns map[string]*regexp.Regexp + +func init() { + ibanCountryPatterns = map[string]*regexp.Regexp{ + "AD": regexp.MustCompile(`^AD\d{2}\d{4}\d{4}[\dA-Z]{12}$`), + "AE": regexp.MustCompile(`^AE\d{2}\d{3}\d{16}$`), + "AL": regexp.MustCompile(`^AL\d{2}\d{8}[\dA-Z]{16}$`), + "AO": regexp.MustCompile(`^AO\d{2}\d{21}$`), + "AT": regexp.MustCompile(`^AT\d{2}\d{5}\d{11}$`), + "AX": regexp.MustCompile(`^FI\d{2}\d{3}\d{11}$`), + "AZ": regexp.MustCompile(`^AZ\d{2}[A-Z]{4}[\dA-Z]{20}$`), + "BA": regexp.MustCompile(`^BA\d{2}\d{3}\d{3}\d{8}\d{2}$`), + "BE": regexp.MustCompile(`^BE\d{2}\d{3}\d{7}\d{2}$`), + "BF": regexp.MustCompile(`^BF\d{2}[\dA-Z]{2}\d{22}$`), + "BG": regexp.MustCompile(`^BG\d{2}[A-Z]{4}\d{4}\d{2}[\dA-Z]{8}$`), + "BH": regexp.MustCompile(`^BH\d{2}[A-Z]{4}[\dA-Z]{14}$`), + "BI": regexp.MustCompile(`^BI\d{2}\d{5}\d{5}\d{11}\d{2}$`), + "BJ": regexp.MustCompile(`^BJ\d{2}[\dA-Z]{2}\d{22}$`), + "BL": regexp.MustCompile(`^FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}$`), + "BR": regexp.MustCompile(`^BR\d{2}\d{8}\d{5}\d{10}[A-Z]{1}[\dA-Z]{1}$`), + "BY": regexp.MustCompile(`^BY\d{2}[\dA-Z]{4}\d{4}[\dA-Z]{16}$`), + "CF": regexp.MustCompile(`^CF\d{2}\d{23}$`), + "CG": regexp.MustCompile(`^CG\d{2}\d{23}$`), + "CH": regexp.MustCompile(`^CH\d{2}\d{5}[\dA-Z]{12}$`), + "CI": regexp.MustCompile(`^CI\d{2}[A-Z]{1}\d{23}$`), + "CM": regexp.MustCompile(`^CM\d{2}\d{23}$`), + "CR": regexp.MustCompile(`^CR\d{2}\d{4}\d{14}$`), + "CV": regexp.MustCompile(`^CV\d{2}\d{21}$`), + "CY": regexp.MustCompile(`^CY\d{2}\d{3}\d{5}[\dA-Z]{16}$`), + "CZ": regexp.MustCompile(`^CZ\d{2}\d{4}\d{6}\d{10}$`), + "DE": regexp.MustCompile(`^DE\d{2}\d{8}\d{10}$`), + "DJ": regexp.MustCompile(`^DJ\d{2}\d{5}\d{5}\d{11}\d{2}$`), + "DK": regexp.MustCompile(`^DK\d{2}\d{4}\d{9}\d{1}$`), + "DO": regexp.MustCompile(`^DO\d{2}[\dA-Z]{4}\d{20}$`), + "DZ": regexp.MustCompile(`^DZ\d{2}\d{22}$`), + "EE": regexp.MustCompile(`^EE\d{2}\d{2}\d{14}$`), + "EG": regexp.MustCompile(`^EG\d{2}\d{4}\d{4}\d{17}$`), + "ES": regexp.MustCompile(`^ES\d{2}\d{4}\d{4}\d{1}\d{1}\d{10}$`), + "FI": regexp.MustCompile(`^FI\d{2}\d{3}\d{11}$`), + "FK": regexp.MustCompile(`^FK\d{2}[A-Z]{2}\d{12}$`), + "FO": regexp.MustCompile(`^FO\d{2}\d{4}\d{9}\d{1}$`), + "FR": regexp.MustCompile(`^FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}$`), + "GA": regexp.MustCompile(`^GA\d{2}\d{23}$`), + "GB": regexp.MustCompile(`^GB\d{2}[A-Z]{4}\d{6}\d{8}$`), + "GE": regexp.MustCompile(`^GE\d{2}[A-Z]{2}\d{16}$`), + "GF": regexp.MustCompile(`^FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}$`), + "GG": regexp.MustCompile(`^GB\d{2}[A-Z]{4}\d{6}\d{8}$`), + "GI": regexp.MustCompile(`^GI\d{2}[A-Z]{4}[\dA-Z]{15}$`), + "GL": regexp.MustCompile(`^GL\d{2}\d{4}\d{9}\d{1}$`), + "GP": regexp.MustCompile(`^FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}$`), + "GQ": regexp.MustCompile(`^GQ\d{2}\d{23}$`), + "GR": regexp.MustCompile(`^GR\d{2}\d{3}\d{4}[\dA-Z]{16}$`), + "GT": regexp.MustCompile(`^GT\d{2}[\dA-Z]{4}[\dA-Z]{20}$`), + "GW": regexp.MustCompile(`^GW\d{2}[\dA-Z]{2}\d{19}$`), + "HN": regexp.MustCompile(`^HN\d{2}[A-Z]{4}\d{20}$`), + "HR": regexp.MustCompile(`^HR\d{2}\d{7}\d{10}$`), + "HU": regexp.MustCompile(`^HU\d{2}\d{3}\d{4}\d{1}\d{15}\d{1}$`), + "IE": regexp.MustCompile(`^IE\d{2}[A-Z]{4}\d{6}\d{8}$`), + "IL": regexp.MustCompile(`^IL\d{2}\d{3}\d{3}\d{13}$`), + "IM": regexp.MustCompile(`^GB\d{2}[A-Z]{4}\d{6}\d{8}$`), + "IQ": regexp.MustCompile(`^IQ\d{2}[A-Z]{4}\d{3}\d{12}$`), + "IR": regexp.MustCompile(`^IR\d{2}\d{22}$`), + "IS": regexp.MustCompile(`^IS\d{2}\d{4}\d{2}\d{6}\d{10}$`), + "IT": regexp.MustCompile(`^IT\d{2}[A-Z]{1}\d{5}\d{5}[\dA-Z]{12}$`), + "JE": regexp.MustCompile(`^GB\d{2}[A-Z]{4}\d{6}\d{8}$`), + "JO": regexp.MustCompile(`^JO\d{2}[A-Z]{4}\d{4}[\dA-Z]{18}$`), + "KM": regexp.MustCompile(`^KM\d{2}\d{23}$`), + "KW": regexp.MustCompile(`^KW\d{2}[A-Z]{4}[\dA-Z]{22}$`), + "KZ": regexp.MustCompile(`^KZ\d{2}\d{3}[\dA-Z]{13}$`), + "LB": regexp.MustCompile(`^LB\d{2}\d{4}[\dA-Z]{20}$`), + "LC": regexp.MustCompile(`^LC\d{2}[A-Z]{4}[\dA-Z]{24}$`), + "LI": regexp.MustCompile(`^LI\d{2}\d{5}[\dA-Z]{12}$`), + "LT": regexp.MustCompile(`^LT\d{2}\d{5}\d{11}$`), + "LU": regexp.MustCompile(`^LU\d{2}\d{3}[\dA-Z]{13}$`), + "LV": regexp.MustCompile(`^LV\d{2}[A-Z]{4}[\dA-Z]{13}$`), + "LY": regexp.MustCompile(`^LY\d{2}\d{3}\d{3}\d{15}$`), + "MA": regexp.MustCompile(`^MA\d{2}\d{24}$`), + "MC": regexp.MustCompile(`^MC\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}$`), + "MD": regexp.MustCompile(`^MD\d{2}[\dA-Z]{2}[\dA-Z]{18}$`), + "ME": regexp.MustCompile(`^ME\d{2}\d{3}\d{13}\d{2}$`), + "MF": regexp.MustCompile(`^FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}$`), + "MG": regexp.MustCompile(`^MG\d{2}\d{23}$`), + "MK": regexp.MustCompile(`^MK\d{2}\d{3}[\dA-Z]{10}\d{2}$`), + "ML": regexp.MustCompile(`^ML\d{2}[\dA-Z]{2}\d{22}$`), + "MN": regexp.MustCompile(`^MN\d{2}\d{4}\d{12}$`), + "MQ": regexp.MustCompile(`^FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}$`), + "MR": regexp.MustCompile(`^MR\d{2}\d{5}\d{5}\d{11}\d{2}$`), + "MT": regexp.MustCompile(`^MT\d{2}[A-Z]{4}\d{5}[\dA-Z]{18}$`), + "MU": regexp.MustCompile(`^MU\d{2}[A-Z]{4}\d{2}\d{2}\d{12}\d{3}[A-Z]{3}$`), + "MZ": regexp.MustCompile(`^MZ\d{2}\d{21}$`), + "NC": regexp.MustCompile(`^FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}$`), + "NE": regexp.MustCompile(`^NE\d{2}[A-Z]{2}\d{22}$`), + "NI": regexp.MustCompile(`^NI\d{2}[A-Z]{4}\d{20}$`), + "NL": regexp.MustCompile(`^NL\d{2}[A-Z]{4}\d{10}$`), + "NO": regexp.MustCompile(`^NO\d{2}\d{4}\d{6}\d{1}$`), + "OM": regexp.MustCompile(`^OM\d{2}\d{3}[\dA-Z]{16}$`), + "PF": regexp.MustCompile(`^FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}$`), + "PK": regexp.MustCompile(`^PK\d{2}[A-Z]{4}[\dA-Z]{16}$`), + "PL": regexp.MustCompile(`^PL\d{2}\d{8}\d{16}$`), + "PM": regexp.MustCompile(`^FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}$`), + "PS": regexp.MustCompile(`^PS\d{2}[A-Z]{4}[\dA-Z]{21}$`), + "PT": regexp.MustCompile(`^PT\d{2}\d{4}\d{4}\d{11}\d{2}$`), + "QA": regexp.MustCompile(`^QA\d{2}[A-Z]{4}[\dA-Z]{21}$`), + "RE": regexp.MustCompile(`^FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}$`), + "RO": regexp.MustCompile(`^RO\d{2}[A-Z]{4}[\dA-Z]{16}$`), + "RS": regexp.MustCompile(`^RS\d{2}\d{3}\d{13}\d{2}$`), + "RU": regexp.MustCompile(`^RU\d{2}\d{9}\d{5}[\dA-Z]{15}$`), + "SA": regexp.MustCompile(`^SA\d{2}\d{2}[\dA-Z]{18}$`), + "SC": regexp.MustCompile(`^SC\d{2}[A-Z]{4}\d{2}\d{2}\d{16}[A-Z]{3}$`), + "SD": regexp.MustCompile(`^SD\d{2}\d{2}\d{12}$`), + "SE": regexp.MustCompile(`^SE\d{2}\d{3}\d{16}\d{1}$`), + "SI": regexp.MustCompile(`^SI\d{2}\d{5}\d{8}\d{2}$`), + "SK": regexp.MustCompile(`^SK\d{2}\d{4}\d{6}\d{10}$`), + "SM": regexp.MustCompile(`^SM\d{2}[A-Z]{1}\d{5}\d{5}[\dA-Z]{12}$`), + "SN": regexp.MustCompile(`^SN\d{2}[A-Z]{2}\d{22}$`), + "SO": regexp.MustCompile(`^SO\d{2}\d{4}\d{3}\d{12}$`), + "ST": regexp.MustCompile(`^ST\d{2}\d{8}\d{11}\d{2}$`), + "SV": regexp.MustCompile(`^SV\d{2}[A-Z]{4}\d{20}$`), + "TD": regexp.MustCompile(`^TD\d{2}\d{23}$`), + "TF": regexp.MustCompile(`^FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}$`), + "TG": regexp.MustCompile(`^TG\d{2}[A-Z]{2}\d{22}$`), + "TL": regexp.MustCompile(`^TL\d{2}\d{3}\d{14}\d{2}$`), + "TN": regexp.MustCompile(`^TN\d{2}\d{2}\d{3}\d{13}\d{2}$`), + "TR": regexp.MustCompile(`^TR\d{2}\d{5}\d{1}[\dA-Z]{16}$`), + "UA": regexp.MustCompile(`^UA\d{2}\d{6}[\dA-Z]{19}$`), + "VA": regexp.MustCompile(`^VA\d{2}\d{3}\d{15}$`), + "VG": regexp.MustCompile(`^VG\d{2}[A-Z]{4}\d{16}$`), + "WF": regexp.MustCompile(`^FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}$`), + "XK": regexp.MustCompile(`^XK\d{2}\d{4}\d{10}\d{2}$`), + "YE": regexp.MustCompile(`^YE\d{2}[A-Z]{4}\d{4}[\dA-Z]{18}$`), + "YT": regexp.MustCompile(`^FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}$`), + } +} diff --git a/validate/iban_test.go b/validate/iban_test.go new file mode 100644 index 0000000..1e9be96 --- /dev/null +++ b/validate/iban_test.go @@ -0,0 +1,49 @@ +package validate_test + +import ( + "errors" + "testing" + + "github.com/muonsoft/validation/validate" +) + +func TestIBAN(t *testing.T) { + tests := []struct { + value string + expectedError error + }{ + {value: ""}, + {value: "DE89370400440532013000"}, + {value: "de89370400440532013000"}, + {value: "CH93 0076 2011 6238 5295 7"}, + {value: "GB82 WEST 1234 5698 7654 32"}, + {value: "GB82WEST12345698765432"}, + {value: "NL91ABNA0417164300"}, + {value: "FR1420041010050500013M02606"}, + {value: "US64SVBX1101057138", expectedError: validate.ErrInvalidIBAN}, + {value: "XX89370400440532013000", expectedError: validate.ErrInvalidIBAN}, + {value: "DE8937040044053201300", expectedError: validate.ErrInvalidIBAN}, + {value: "DE893704004405320130000", expectedError: validate.ErrInvalidIBAN}, + {value: "DE01370400440532013000", expectedError: validate.ErrInvalidIBAN}, + {value: "DE99370400440532013000", expectedError: validate.ErrInvalidIBAN}, + {value: "DE89370400440532013001", expectedError: validate.ErrInvalidIBAN}, + {value: "DE89\u00a0370400440532013000"}, + {value: "DE89\u202f370400440532013000"}, + {value: "DE89-370400440532013000", expectedError: validate.ErrInvalidIBAN}, + {value: "DE89з70400440532013000", expectedError: validate.ErrInvalidIBAN}, + } + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { + err := validate.IBAN(tt.value) + if tt.expectedError == nil { + if err != nil { + t.Fatalf("IBAN(%q): %v", tt.value, err) + } + return + } + if !errors.Is(err, tt.expectedError) { + t.Fatalf("IBAN(%q): got %v, want %v", tt.value, err, tt.expectedError) + } + }) + } +} From 1e9778b5af3f9563189c4e5c7c471f1563087de8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 4 Apr 2026 19:54:24 +0000 Subject: [PATCH 2/4] Expand IBAN unit tests (edge cases and more countries) - Named subtests; valid samples for BE, AT, IT, SE - Canonicalization: only spaces/NBSP, too short, bad UTF-8 prefixes - Reject: non-alpha country, non-numeric check digits, BBAN vs pattern, tab Co-authored-by: Igor Lazarev --- validate/iban_test.go | 56 +++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/validate/iban_test.go b/validate/iban_test.go index 1e9be96..a02ef0c 100644 --- a/validate/iban_test.go +++ b/validate/iban_test.go @@ -9,31 +9,47 @@ import ( func TestIBAN(t *testing.T) { tests := []struct { + name string value string expectedError error }{ - {value: ""}, - {value: "DE89370400440532013000"}, - {value: "de89370400440532013000"}, - {value: "CH93 0076 2011 6238 5295 7"}, - {value: "GB82 WEST 1234 5698 7654 32"}, - {value: "GB82WEST12345698765432"}, - {value: "NL91ABNA0417164300"}, - {value: "FR1420041010050500013M02606"}, - {value: "US64SVBX1101057138", expectedError: validate.ErrInvalidIBAN}, - {value: "XX89370400440532013000", expectedError: validate.ErrInvalidIBAN}, - {value: "DE8937040044053201300", expectedError: validate.ErrInvalidIBAN}, - {value: "DE893704004405320130000", expectedError: validate.ErrInvalidIBAN}, - {value: "DE01370400440532013000", expectedError: validate.ErrInvalidIBAN}, - {value: "DE99370400440532013000", expectedError: validate.ErrInvalidIBAN}, - {value: "DE89370400440532013001", expectedError: validate.ErrInvalidIBAN}, - {value: "DE89\u00a0370400440532013000"}, - {value: "DE89\u202f370400440532013000"}, - {value: "DE89-370400440532013000", expectedError: validate.ErrInvalidIBAN}, - {value: "DE89з70400440532013000", expectedError: validate.ErrInvalidIBAN}, + {name: "empty", value: ""}, + {name: "DE valid compact", value: "DE89370400440532013000"}, + {name: "DE valid lowercase", value: "de89370400440532013000"}, + {name: "CH valid spaced", value: "CH93 0076 2011 6238 5295 7"}, + {name: "GB valid spaced", value: "GB82 WEST 1234 5698 7654 32"}, + {name: "GB valid compact", value: "GB82WEST12345698765432"}, + {name: "NL valid", value: "NL91ABNA0417164300"}, + {name: "FR valid with letter in BBAN", value: "FR1420041010050500013M02606"}, + {name: "PL valid", value: "PL61109010140000071219812874"}, + {name: "ES valid", value: "ES9121000418450200051332"}, + {name: "BE valid", value: "BE68539007547034"}, + {name: "AT valid", value: "AT611904300234573201"}, + {name: "IT valid with X in BBAN", value: "IT60X0542811101000000123456"}, + {name: "SE valid", value: "SE4550000000058398257466"}, + {name: "unsupported US", value: "US64SVBX1101057138", expectedError: validate.ErrInvalidIBAN}, + {name: "unknown country XX", value: "XX89370400440532013000", expectedError: validate.ErrInvalidIBAN}, + {name: "DE too short", value: "DE8937040044053201300", expectedError: validate.ErrInvalidIBAN}, + {name: "DE too long", value: "DE893704004405320130000", expectedError: validate.ErrInvalidIBAN}, + {name: "check digits 01", value: "DE01370400440532013000", expectedError: validate.ErrInvalidIBAN}, + {name: "check digits 99", value: "DE99370400440532013000", expectedError: validate.ErrInvalidIBAN}, + {name: "wrong mod 97", value: "DE89370400440532013001", expectedError: validate.ErrInvalidIBAN}, + {name: "NBSP between groups", value: "DE89\u00a0370400440532013000"}, + {name: "NNBSP between groups", value: "DE89\u202f370400440532013000"}, + {name: "hyphen", value: "DE89-370400440532013000", expectedError: validate.ErrInvalidIBAN}, + {name: "non ascii cyrillic", value: "DE89з70400440532013000", expectedError: validate.ErrInvalidIBAN}, + {name: "only spaces", value: " ", expectedError: validate.ErrInvalidIBAN}, + {name: "only NBSP", value: "\u00a0\u00a0", expectedError: validate.ErrInvalidIBAN}, + {name: "too short after strip", value: "DE8", expectedError: validate.ErrInvalidIBAN}, + {name: "country code not letters", value: "12DE89370400440532013000", expectedError: validate.ErrInvalidIBAN}, + {name: "check digits not numeric", value: "DEAB370400440532013000", expectedError: validate.ErrInvalidIBAN}, + {name: "DE format letter where digit", value: "DE8937040A440532013000", expectedError: validate.ErrInvalidIBAN}, + {name: "tab", value: "DE89\t370400440532013000", expectedError: validate.ErrInvalidIBAN}, + {name: "incomplete UTF-8 NBSP", value: "DE89\xc2", expectedError: validate.ErrInvalidIBAN}, + {name: "incomplete UTF-8 NNBSP", value: "DE89\xe2\x80", expectedError: validate.ErrInvalidIBAN}, } for _, tt := range tests { - t.Run(tt.value, func(t *testing.T) { + t.Run(tt.name, func(t *testing.T) { err := validate.IBAN(tt.value) if tt.expectedError == nil { if err != nil { From 3bcef3c08d2da9fe84afc05c661f7242a9a34ba1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 4 Apr 2026 20:03:48 +0000 Subject: [PATCH 3/4] test(validate): extend IBAN cases for BR, MU, SC patterns - Valid samples exercising Symfony regex branches (trailing letters, long BBAN) - Negative: BR with correct length/pattern but failing mod-97; SC wrong total length; MU bank code width Co-authored-by: Igor Lazarev --- validate/iban_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/validate/iban_test.go b/validate/iban_test.go index a02ef0c..1f0c61c 100644 --- a/validate/iban_test.go +++ b/validate/iban_test.go @@ -27,6 +27,9 @@ func TestIBAN(t *testing.T) { {name: "AT valid", value: "AT611904300234573201"}, {name: "IT valid with X in BBAN", value: "IT60X0542811101000000123456"}, {name: "SE valid", value: "SE4550000000058398257466"}, + {name: "BR valid trailing bank branch letters", value: "BR1300000000140581368018290C1"}, + {name: "MU valid complex BBAN", value: "MU17BOMM0101101030300200000MUR"}, + {name: "SC valid bank code and currency suffix", value: "SC62SSCB11011000000000000000USD"}, {name: "unsupported US", value: "US64SVBX1101057138", expectedError: validate.ErrInvalidIBAN}, {name: "unknown country XX", value: "XX89370400440532013000", expectedError: validate.ErrInvalidIBAN}, {name: "DE too short", value: "DE8937040044053201300", expectedError: validate.ErrInvalidIBAN}, @@ -34,6 +37,9 @@ func TestIBAN(t *testing.T) { {name: "check digits 01", value: "DE01370400440532013000", expectedError: validate.ErrInvalidIBAN}, {name: "check digits 99", value: "DE99370400440532013000", expectedError: validate.ErrInvalidIBAN}, {name: "wrong mod 97", value: "DE89370400440532013001", expectedError: validate.ErrInvalidIBAN}, + {name: "BR format matches Symfony wrong mod 97", value: "BR1800000000140581368018290C1", expectedError: validate.ErrInvalidIBAN}, + {name: "SC wrong length for country pattern", value: "SC18SSCB110110000000000000000USD", expectedError: validate.ErrInvalidIBAN}, + {name: "MU wrong bank code width", value: "MU17BO1M0101101030300200000MUR", expectedError: validate.ErrInvalidIBAN}, {name: "NBSP between groups", value: "DE89\u00a0370400440532013000"}, {name: "NNBSP between groups", value: "DE89\u202f370400440532013000"}, {name: "hyphen", value: "DE89-370400440532013000", expectedError: validate.ErrInvalidIBAN}, From 82a0e6f39e8ee80873d135a87d131674c6bbd159 Mon Sep 17 00:00:00 2001 From: Igor Lazarev Date: Sun, 5 Apr 2026 08:13:45 +0300 Subject: [PATCH 4/4] test(validate): expand IBAN coverage with territory aliases, boundary digits, mixed whitespace (#94) Co-authored-by: Cursor Agent --- validate/iban_test.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/validate/iban_test.go b/validate/iban_test.go index 1f0c61c..44b9d9c 100644 --- a/validate/iban_test.go +++ b/validate/iban_test.go @@ -30,11 +30,28 @@ func TestIBAN(t *testing.T) { {name: "BR valid trailing bank branch letters", value: "BR1300000000140581368018290C1"}, {name: "MU valid complex BBAN", value: "MU17BOMM0101101030300200000MUR"}, {name: "SC valid bank code and currency suffix", value: "SC62SSCB11011000000000000000USD"}, + + // Territory alias codes: the pattern uses the parent country prefix, + // so a valid IBAN for the territory must start with the parent prefix. + {name: "FI valid (also covers AX alias)", value: "FI2112345600000785"}, + {name: "AX territory always rejected (pattern expects FI prefix)", value: "AX2112345600000785", expectedError: validate.ErrInvalidIBAN}, + {name: "GG territory always rejected (pattern expects GB prefix)", value: "GG29NWBK60161331926819", expectedError: validate.ErrInvalidIBAN}, + {name: "JE territory always rejected (pattern expects GB prefix)", value: "JE29NWBK60161331926819", expectedError: validate.ErrInvalidIBAN}, + {name: "IM territory always rejected (pattern expects GB prefix)", value: "IM29NWBK60161331926819", expectedError: validate.ErrInvalidIBAN}, + {name: "GF territory always rejected (pattern expects FR prefix)", value: "GF7630006000011234567890189", expectedError: validate.ErrInvalidIBAN}, + + // Mixed-case with letters in BBAN + {name: "IT valid lowercase with letter in BBAN", value: "it60x0542811101000000123456"}, + {name: "GB valid mixed case", value: "gb82West12345698765432"}, + {name: "unsupported US", value: "US64SVBX1101057138", expectedError: validate.ErrInvalidIBAN}, {name: "unknown country XX", value: "XX89370400440532013000", expectedError: validate.ErrInvalidIBAN}, {name: "DE too short", value: "DE8937040044053201300", expectedError: validate.ErrInvalidIBAN}, {name: "DE too long", value: "DE893704004405320130000", expectedError: validate.ErrInvalidIBAN}, + {name: "check digits 00", value: "DE00370400440532013000", expectedError: validate.ErrInvalidIBAN}, {name: "check digits 01", value: "DE01370400440532013000", expectedError: validate.ErrInvalidIBAN}, + {name: "check digits 02 wrong mod 97", value: "DE02370400440532013000", expectedError: validate.ErrInvalidIBAN}, + {name: "check digits 98 wrong mod 97", value: "DE98370400440532013000", expectedError: validate.ErrInvalidIBAN}, {name: "check digits 99", value: "DE99370400440532013000", expectedError: validate.ErrInvalidIBAN}, {name: "wrong mod 97", value: "DE89370400440532013001", expectedError: validate.ErrInvalidIBAN}, {name: "BR format matches Symfony wrong mod 97", value: "BR1800000000140581368018290C1", expectedError: validate.ErrInvalidIBAN}, @@ -42,17 +59,29 @@ func TestIBAN(t *testing.T) { {name: "MU wrong bank code width", value: "MU17BO1M0101101030300200000MUR", expectedError: validate.ErrInvalidIBAN}, {name: "NBSP between groups", value: "DE89\u00a0370400440532013000"}, {name: "NNBSP between groups", value: "DE89\u202f370400440532013000"}, + {name: "multiple NBSP", value: "DE89\u00a03704\u00a00044\u00a00532\u00a0013000"}, + {name: "multiple NNBSP", value: "DE89\u202f3704\u202f0044\u202f0532\u202f013000"}, + {name: "mixed spaces", value: "DE89 \u00a03704\u202f00440532013000"}, {name: "hyphen", value: "DE89-370400440532013000", expectedError: validate.ErrInvalidIBAN}, {name: "non ascii cyrillic", value: "DE89з70400440532013000", expectedError: validate.ErrInvalidIBAN}, {name: "only spaces", value: " ", expectedError: validate.ErrInvalidIBAN}, {name: "only NBSP", value: "\u00a0\u00a0", expectedError: validate.ErrInvalidIBAN}, + {name: "only NNBSP", value: "\u202f\u202f", expectedError: validate.ErrInvalidIBAN}, {name: "too short after strip", value: "DE8", expectedError: validate.ErrInvalidIBAN}, + {name: "single char", value: "D", expectedError: validate.ErrInvalidIBAN}, + {name: "two chars", value: "DE", expectedError: validate.ErrInvalidIBAN}, + {name: "three chars", value: "DE8", expectedError: validate.ErrInvalidIBAN}, {name: "country code not letters", value: "12DE89370400440532013000", expectedError: validate.ErrInvalidIBAN}, + {name: "country lowercase digits", value: "1289370400440532013000", expectedError: validate.ErrInvalidIBAN}, {name: "check digits not numeric", value: "DEAB370400440532013000", expectedError: validate.ErrInvalidIBAN}, {name: "DE format letter where digit", value: "DE8937040A440532013000", expectedError: validate.ErrInvalidIBAN}, {name: "tab", value: "DE89\t370400440532013000", expectedError: validate.ErrInvalidIBAN}, + {name: "newline", value: "DE89\n370400440532013000", expectedError: validate.ErrInvalidIBAN}, {name: "incomplete UTF-8 NBSP", value: "DE89\xc2", expectedError: validate.ErrInvalidIBAN}, {name: "incomplete UTF-8 NNBSP", value: "DE89\xe2\x80", expectedError: validate.ErrInvalidIBAN}, + {name: "NNBSP single byte only", value: "DE89\xe2", expectedError: validate.ErrInvalidIBAN}, + {name: "trailing space", value: "DE89370400440532013000 "}, + {name: "leading space", value: " DE89370400440532013000"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {