From c67be276b574ab7cb4ad3217d86d7707152e459e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Apr 2026 17:32:20 +0000 Subject: [PATCH] feat: add BIC (SWIFT) validation constraint (IGO-32) - validate.BIC with strict/case-insensitive modes and optional IBAN country check - it.IsBIC with CaseInsensitive, WithIBAN, and mismatch messages - is.BIC, messages, EN/RU translations, tests and examples - Aligned with Symfony BicValidator; regions via golang.org/x/text/language Co-authored-by: Igor Lazarev --- CHANGELOG.md | 1 + errors.go | 112 +++++++------- is/example_test.go | 10 ++ is/identifiers.go | 8 + it/bic.go | 142 +++++++++++++++++ it/example_test.go | 14 ++ message/messages.go | 112 +++++++------- message/translations/english/messages.go | 44 +++--- message/translations/russian/messages.go | 44 +++--- test/constraints_identifiers_cases_test.go | 103 +++++++++++++ validate/bic.go | 171 +++++++++++++++++++++ validate/bic_test.go | 57 +++++++ validate/example_test.go | 10 ++ 13 files changed, 676 insertions(+), 152 deletions(-) create mode 100644 it/bic.go create mode 100644 validate/bic.go create mode 100644 validate/bic_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 23d3978..90a4cdc 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 +- BIC / SWIFT validation: `it.IsBIC()` with `CaseInsensitive` and `WithIBAN` (and `WithIBANError` / `WithIBANMessage`), `validate.BIC` with `validate.BICCaseInsensitive` and `validate.BICWithIBAN`, `is.BIC`; `validation.ErrInvalidBIC`, `validation.ErrBICIBANCountryMismatch`, `message.InvalidBIC`, `message.BICNotAssociatedWithIBAN` and English and Russian translations (behavior aligned with Symfony `Bic` / `BicValidator`; country/territory check via `golang.org/x/text/language` regions plus Symfony’s BIC-to-IBAN territory map). - 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`). - **NoSuspiciousCharacters** (spoofing / homoglyph checks): `it.HasNoSuspiciousCharacters()` with `CheckInvisible` / `CheckMixedNumbers` / `CheckHiddenOverlay`, `WithoutInvisible` / `WithoutMixedNumbers` / `WithoutHiddenOverlay` (bitmask-based), locale and single-script restrictions; `validate.NoSuspiciousCharacters`; errors `validation.ErrSuspiciousInvisible`, `ErrSuspiciousMixedNumbers`, `ErrSuspiciousHiddenOverlay`, `ErrSuspiciousCharactersRestriction` and English/Russian messages (behavior inspired by Symfony `NoSuspiciousCharacters`, implemented without CGO; may differ from ICU in edge cases). - 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). diff --git a/errors.go b/errors.go index 298eb8b..f1e2ca7 100644 --- a/errors.go +++ b/errors.go @@ -9,61 +9,63 @@ import ( ) var ( - ErrInvalidDate = NewError("invalid date", message.InvalidDate) - ErrInvalidDateTime = NewError("invalid datetime", message.InvalidDateTime) - ErrInvalidEAN13 = NewError("invalid EAN-13", message.InvalidEAN13) - 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) - ErrInvalidIP = NewError("invalid IP address", message.InvalidIP) - ErrInvalidJSON = NewError("invalid JSON", message.InvalidJSON) - ErrInvalidLUHN = NewError("invalid LUHN", message.InvalidLUHN) - ErrInvalidTime = NewError("invalid time", message.InvalidTime) - ErrInvalidULID = NewError("invalid ULID", message.InvalidULID) - ErrInvalidUPCA = NewError("invalid UPC-A", message.InvalidUPCA) - ErrInvalidUPCE = NewError("invalid UPC-E", message.InvalidUPCE) - ErrInvalidURL = NewError("invalid URL", message.InvalidURL) - ErrInvalidUUID = NewError("invalid UUID", message.InvalidUUID) - ErrIsBlank = NewError("is blank", message.IsBlank) - ErrIsEqual = NewError("is equal", message.IsEqual) - ErrIsNil = NewError("is nil", message.IsNil) - ErrNoSuchChoice = NewError("no such choice", message.NoSuchChoice) - ErrNotBlank = NewError("is not blank", message.NotBlank) - ErrNotDivisible = NewError("is not divisible", message.NotDivisible) - ErrNotDivisibleCount = NewError("not divisible count", message.NotDivisibleCount) - ErrNotEqual = NewError("is not equal", message.NotEqual) - ErrNotExactCount = NewError("not exact count", message.NotExactCount) - ErrNotExactLength = NewError("not exact length", message.NotExactLength) - ErrNotFalse = NewError("is not false", message.NotFalse) - ErrNotInRange = NewError("is not in range", message.NotInRange) - ErrNotInteger = NewError("is not an integer", message.NotInteger) - ErrNotNegative = NewError("is not negative", message.NotNegative) - ErrNotNegativeOrZero = NewError("is not negative or zero", message.NotNegativeOrZero) - ErrNotNil = NewError("is not nil", message.NotNil) - ErrNotNumeric = NewError("is not numeric", message.NotNumeric) - ErrNotPositive = NewError("is not positive", message.NotPositive) - ErrNotPositiveOrZero = NewError("is not positive or zero", message.NotPositiveOrZero) - ErrNotTrue = NewError("is not true", message.NotTrue) - ErrNotUnique = NewError("is not unique", message.NotUnique) - ErrNotValid = NewError("is not valid", message.NotValid) - ErrProhibitedIP = NewError("is prohibited IP", message.ProhibitedIP) - ErrProhibitedURL = NewError("is prohibited URL", message.ProhibitedURL) - ErrTooEarly = NewError("is too early", message.TooEarly) - ErrTooEarlyOrEqual = NewError("is too early or equal", message.TooEarlyOrEqual) - ErrTooFewElements = NewError("too few elements", message.TooFewElements) - ErrTooHigh = NewError("is too high", message.TooHigh) - ErrTooHighOrEqual = NewError("is too high or equal", message.TooHighOrEqual) - ErrTooLate = NewError("is too late", message.TooLate) - ErrTooLateOrEqual = NewError("is too late or equal", message.TooLateOrEqual) - ErrTooLong = NewError("is too long", message.TooLong) - ErrTooLow = NewError("is too low", message.TooLow) - ErrTooLowOrEqual = NewError("is too low or equal", message.TooLowOrEqual) - ErrTooManyElements = NewError("too many elements", message.TooManyElements) - ErrTooShort = NewError("is too short", message.TooShort) + ErrInvalidDate = NewError("invalid date", message.InvalidDate) + ErrInvalidDateTime = NewError("invalid datetime", message.InvalidDateTime) + ErrInvalidEAN13 = NewError("invalid EAN-13", message.InvalidEAN13) + 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) + ErrInvalidBIC = NewError("invalid BIC", message.InvalidBIC) + ErrBICIBANCountryMismatch = NewError("BIC IBAN country mismatch", message.BICNotAssociatedWithIBAN) + ErrInvalidISIN = NewError("invalid ISIN", message.InvalidISIN) + ErrInvalidCIDR = NewError("invalid CIDR", message.InvalidCIDR) + ErrCIDRNetmaskOutOfRange = NewError("CIDR netmask out of range", message.CIDRNetmaskOutOfRange) + ErrInvalidIP = NewError("invalid IP address", message.InvalidIP) + ErrInvalidJSON = NewError("invalid JSON", message.InvalidJSON) + ErrInvalidLUHN = NewError("invalid LUHN", message.InvalidLUHN) + ErrInvalidTime = NewError("invalid time", message.InvalidTime) + ErrInvalidULID = NewError("invalid ULID", message.InvalidULID) + ErrInvalidUPCA = NewError("invalid UPC-A", message.InvalidUPCA) + ErrInvalidUPCE = NewError("invalid UPC-E", message.InvalidUPCE) + ErrInvalidURL = NewError("invalid URL", message.InvalidURL) + ErrInvalidUUID = NewError("invalid UUID", message.InvalidUUID) + ErrIsBlank = NewError("is blank", message.IsBlank) + ErrIsEqual = NewError("is equal", message.IsEqual) + ErrIsNil = NewError("is nil", message.IsNil) + ErrNoSuchChoice = NewError("no such choice", message.NoSuchChoice) + ErrNotBlank = NewError("is not blank", message.NotBlank) + ErrNotDivisible = NewError("is not divisible", message.NotDivisible) + ErrNotDivisibleCount = NewError("not divisible count", message.NotDivisibleCount) + ErrNotEqual = NewError("is not equal", message.NotEqual) + ErrNotExactCount = NewError("not exact count", message.NotExactCount) + ErrNotExactLength = NewError("not exact length", message.NotExactLength) + ErrNotFalse = NewError("is not false", message.NotFalse) + ErrNotInRange = NewError("is not in range", message.NotInRange) + ErrNotInteger = NewError("is not an integer", message.NotInteger) + ErrNotNegative = NewError("is not negative", message.NotNegative) + ErrNotNegativeOrZero = NewError("is not negative or zero", message.NotNegativeOrZero) + ErrNotNil = NewError("is not nil", message.NotNil) + ErrNotNumeric = NewError("is not numeric", message.NotNumeric) + ErrNotPositive = NewError("is not positive", message.NotPositive) + ErrNotPositiveOrZero = NewError("is not positive or zero", message.NotPositiveOrZero) + ErrNotTrue = NewError("is not true", message.NotTrue) + ErrNotUnique = NewError("is not unique", message.NotUnique) + ErrNotValid = NewError("is not valid", message.NotValid) + ErrProhibitedIP = NewError("is prohibited IP", message.ProhibitedIP) + ErrProhibitedURL = NewError("is prohibited URL", message.ProhibitedURL) + ErrTooEarly = NewError("is too early", message.TooEarly) + ErrTooEarlyOrEqual = NewError("is too early or equal", message.TooEarlyOrEqual) + ErrTooFewElements = NewError("too few elements", message.TooFewElements) + ErrTooHigh = NewError("is too high", message.TooHigh) + ErrTooHighOrEqual = NewError("is too high or equal", message.TooHighOrEqual) + ErrTooLate = NewError("is too late", message.TooLate) + ErrTooLateOrEqual = NewError("is too late or equal", message.TooLateOrEqual) + ErrTooLong = NewError("is too long", message.TooLong) + ErrTooLow = NewError("is too low", message.TooLow) + ErrTooLowOrEqual = NewError("is too low or equal", message.TooLowOrEqual) + ErrTooManyElements = NewError("too many elements", message.TooManyElements) + ErrTooShort = NewError("is too short", message.TooShort) ErrSuspiciousInvisible = NewError("suspicious invisible characters", message.SuspiciousInvisible) ErrSuspiciousMixedNumbers = NewError("suspicious mixed numbers", message.SuspiciousMixedNumbers) diff --git a/is/example_test.go b/is/example_test.go index 0ba3f4c..f76cb6c 100644 --- a/is/example_test.go +++ b/is/example_test.go @@ -273,6 +273,16 @@ func ExampleIBAN() { // false } +func ExampleBIC() { + fmt.Println(is.BIC("DEUTDEFF")) + fmt.Println(is.BIC("DEUTDEF")) + fmt.Println(is.BIC("deutdeff", validate.BICCaseInsensitive())) + // Output: + // true + // false + // true +} + func ExampleISIN() { fmt.Println(is.ISIN("US0378331005")) fmt.Println(is.ISIN("US037833100")) diff --git a/is/identifiers.go b/is/identifiers.go index 4e8f5e1..92ecf77 100644 --- a/is/identifiers.go +++ b/is/identifiers.go @@ -16,6 +16,14 @@ func IBAN(value string) bool { return validate.IBAN(value) == nil } +// BIC validates whether the value is a valid Business Identifier Code (BIC / SWIFT). +// See [github.com/muonsoft/validation/validate.BIC] for validation rules and options. +// +// See https://en.wikipedia.org/wiki/ISO_9362. +func BIC(value string, options ...func(*validate.BICOptions)) bool { + return validate.BIC(value, options...) == 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/bic.go b/it/bic.go new file mode 100644 index 0000000..186d18a --- /dev/null +++ b/it/bic.go @@ -0,0 +1,142 @@ +package it + +import ( + "context" + "errors" + + "github.com/muonsoft/validation" + "github.com/muonsoft/validation/validate" +) + +// BICConstraint validates whether a string value is a valid Business Identifier Code (BIC / SWIFT). +// +// By default, validation is in strict mode (uppercase ASCII only), matching Symfony Bic::VALIDATION_MODE_STRICT. +// Use [BICConstraint.CaseInsensitive] for Symfony's case-insensitive mode. +// +// Use [BICConstraint.WithIBAN] to assert that the BIC's country/territory matches the given IBAN's country code +// (Symfony Bic "iban" option). +// +// Empty values are skipped; combine with [NotBlank] or similar to reject empty strings. +// +// See https://en.wikipedia.org/wiki/ISO_9362. +type BICConstraint struct { + isIgnored bool + groups []string + caseInsensitive bool + iban string + err error + ibanErr error + messageTemplate string + ibanMessageTemplate string + messageParameters validation.TemplateParameterList + ibanMessageParameters validation.TemplateParameterList +} + +// IsBIC validates whether the value is a valid Business Identifier Code (BIC / SWIFT). +// Behavior is aligned with Symfony\Component\Validator\Constraints\Bic. +func IsBIC() BICConstraint { + return BICConstraint{ + err: validation.ErrInvalidBIC, + ibanErr: validation.ErrBICIBANCountryMismatch, + messageTemplate: validation.ErrInvalidBIC.Message(), + ibanMessageTemplate: validation.ErrBICIBANCountryMismatch.Message(), + } +} + +// CaseInsensitive enables Symfony Bic::VALIDATION_MODE_CASE_INSENSITIVE (lowercase letters allowed). +func (c BICConstraint) CaseInsensitive() BICConstraint { + c.caseInsensitive = true + return c +} + +// WithIBAN sets an IBAN to check that its country code matches the BIC (Symfony Bic "iban" option). +func (c BICConstraint) WithIBAN(iban string) BICConstraint { + c.iban = iban + return c +} + +// WithError overrides the default error for the main BIC format violation. +// IBAN country mismatch still uses [validation.ErrBICIBANCountryMismatch] unless overridden via [BICConstraint.WithIBANError]. +func (c BICConstraint) WithError(err error) BICConstraint { + c.err = err + return c +} + +// WithIBANError overrides the default error for BIC vs IBAN country mismatch. +func (c BICConstraint) WithIBANError(err error) BICConstraint { + c.ibanErr = err + return c +} + +// WithMessage sets the violation message template for the main BIC format violation. +func (c BICConstraint) WithMessage(template string, parameters ...validation.TemplateParameter) BICConstraint { + c.messageTemplate = template + c.messageParameters = parameters + return c +} + +// WithIBANMessage sets the violation message template when the BIC does not match the configured IBAN. +// Default parameters: {{ value }}, {{ iban }}. +func (c BICConstraint) WithIBANMessage(template string, parameters ...validation.TemplateParameter) BICConstraint { + c.ibanMessageTemplate = template + c.ibanMessageParameters = parameters + return c +} + +// When enables conditional validation of this constraint. +func (c BICConstraint) When(condition bool) BICConstraint { + c.isIgnored = !condition + return c +} + +// WhenGroups enables conditional validation by validation groups. +func (c BICConstraint) WhenGroups(groups ...string) BICConstraint { + c.groups = groups + return c +} + +func (c BICConstraint) bicOptions() []func(*validate.BICOptions) { + var opts []func(*validate.BICOptions) + if c.caseInsensitive { + opts = append(opts, validate.BICCaseInsensitive()) + } + if c.iban != "" { + opts = append(opts, validate.BICWithIBAN(c.iban)) + } + return opts +} + +func (c BICConstraint) ValidateString(ctx context.Context, validator *validation.Validator, value *string) error { + if c.isIgnored || validator.IsIgnoredForGroups(c.groups...) || value == nil || *value == "" { + return nil + } + + err := validate.BIC(*value, c.bicOptions()...) + if err == nil { + return nil + } + + if errors.Is(err, validate.ErrBICIBANCountryMismatch) { + return validator.BuildViolation(ctx, c.ibanErr, c.ibanMessageTemplate). + WithParameters( + c.ibanMessageParameters.Prepend( + validation.TemplateParameter{Key: "{{ iban }}", Value: c.iban}, + validation.TemplateParameter{Key: "{{ value }}", Value: *value}, + )..., + ). + Create() + } + + return validator.BuildViolation(ctx, c.err, c.messageTemplate). + WithParameters( + c.messageParameters.Prepend( + validation.TemplateParameter{Key: "{{ value }}", Value: *value}, + )..., + ). + Create() +} + +// Validate implements [validation.Constraint][string]. +func (c BICConstraint) Validate(ctx context.Context, validator *validation.Validator, v string) error { + return c.ValidateString(ctx, validator, &v) +} diff --git a/it/example_test.go b/it/example_test.go index 169a1c3..bfa207b 100644 --- a/it/example_test.go +++ b/it/example_test.go @@ -55,6 +55,20 @@ func ExampleIsIBAN_invalid() { // violation: "This is not a valid International Bank Account Number (IBAN)." } +func ExampleIsBIC_valid() { + err := validator.Validate(context.Background(), validation.String("DEUTDEFF", it.IsBIC())) + fmt.Println(err) + // Output: + // +} + +func ExampleIsBIC_invalid() { + err := validator.Validate(context.Background(), validation.String("deutdeff", it.IsBIC())) + fmt.Println(err) + // Output: + // violation: "This is not a valid Business Identifier Code (BIC)." +} + func ExampleIsISIN_valid() { err := validator.Validate(context.Background(), validation.String("US0378331005", it.IsISIN())) fmt.Println(err) diff --git a/message/messages.go b/message/messages.go index b4115d2..1c8e73d 100644 --- a/message/messages.go +++ b/message/messages.go @@ -27,61 +27,63 @@ package message const ( - InvalidDate = "This value is not a valid date." - InvalidDateTime = "This value is not a valid datetime." - InvalidEAN13 = "This value is not a valid EAN-13." - 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 }}." - InvalidIP = "This is not a valid IP address." - InvalidJSON = "This value should be valid JSON." - InvalidLUHN = "Invalid card number." - InvalidTime = "This value is not a valid time." - InvalidULID = "This is not a valid ULID." - InvalidUPCA = "This value is not a valid UPC-A." - InvalidUPCE = "This value is not a valid UPC-E." - InvalidURL = "This value is not a valid URL." - InvalidUUID = "This is not a valid UUID." - IsBlank = "This value should not be blank." - IsEqual = "This value should not be equal to {{ comparedValue }}." - IsNil = "This value should not be nil." - NoSuchChoice = "The value you selected is not a valid choice." - NotBlank = "This value should be blank." - NotDivisible = "This value should be a multiple of {{ comparedValue }}." - NotDivisibleCount = "The number of elements in this collection should be a multiple of {{ divisibleBy }}." - NotEqual = "This value should be equal to {{ comparedValue }}." - NotExactCount = "This collection should contain exactly {{ limit }} element(s)." - NotExactLength = "This value should have exactly {{ limit }} character(s)." - NotFalse = "This value should be false." - NotInRange = "This value should be between {{ min }} and {{ max }}." - NotInteger = "This value is not an integer." - NotNegative = "This value should be negative." - NotNegativeOrZero = "This value should be either negative or zero." - NotNil = "This value should be nil." - NotNumeric = "This value is not a numeric." - NotPositive = "This value should be positive." - NotPositiveOrZero = "This value should be either positive or zero." - NotTrue = "This value should be true." - NotUnique = "This collection should contain only unique elements." - NotValid = "This value is not valid." - ProhibitedIP = "This IP address is prohibited to use." - ProhibitedURL = "This URL is prohibited to use." - TooEarly = "This value should be later than {{ comparedValue }}." - TooEarlyOrEqual = "This value should be later than or equal to {{ comparedValue }}." - TooFewElements = "This collection should contain {{ limit }} element(s) or more." - TooHigh = "This value should be less than {{ comparedValue }}." - TooHighOrEqual = "This value should be less than or equal to {{ comparedValue }}." - TooLate = "This value should be earlier than {{ comparedValue }}." - TooLateOrEqual = "This value should be earlier than or equal to {{ comparedValue }}." - TooLong = "This value is too long. It should have {{ limit }} character(s) or less." - TooLow = "This value should be greater than {{ comparedValue }}." - TooLowOrEqual = "This value should be greater than or equal to {{ comparedValue }}." - TooManyElements = "This collection should contain {{ limit }} element(s) or less." - TooShort = "This value is too short. It should have {{ limit }} character(s) or more." + InvalidDate = "This value is not a valid date." + InvalidDateTime = "This value is not a valid datetime." + InvalidEAN13 = "This value is not a valid EAN-13." + 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)." + InvalidBIC = "This is not a valid Business Identifier Code (BIC)." + BICNotAssociatedWithIBAN = "This Business Identifier Code (BIC) is not associated with IBAN {{ 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 }}." + InvalidIP = "This is not a valid IP address." + InvalidJSON = "This value should be valid JSON." + InvalidLUHN = "Invalid card number." + InvalidTime = "This value is not a valid time." + InvalidULID = "This is not a valid ULID." + InvalidUPCA = "This value is not a valid UPC-A." + InvalidUPCE = "This value is not a valid UPC-E." + InvalidURL = "This value is not a valid URL." + InvalidUUID = "This is not a valid UUID." + IsBlank = "This value should not be blank." + IsEqual = "This value should not be equal to {{ comparedValue }}." + IsNil = "This value should not be nil." + NoSuchChoice = "The value you selected is not a valid choice." + NotBlank = "This value should be blank." + NotDivisible = "This value should be a multiple of {{ comparedValue }}." + NotDivisibleCount = "The number of elements in this collection should be a multiple of {{ divisibleBy }}." + NotEqual = "This value should be equal to {{ comparedValue }}." + NotExactCount = "This collection should contain exactly {{ limit }} element(s)." + NotExactLength = "This value should have exactly {{ limit }} character(s)." + NotFalse = "This value should be false." + NotInRange = "This value should be between {{ min }} and {{ max }}." + NotInteger = "This value is not an integer." + NotNegative = "This value should be negative." + NotNegativeOrZero = "This value should be either negative or zero." + NotNil = "This value should be nil." + NotNumeric = "This value is not a numeric." + NotPositive = "This value should be positive." + NotPositiveOrZero = "This value should be either positive or zero." + NotTrue = "This value should be true." + NotUnique = "This collection should contain only unique elements." + NotValid = "This value is not valid." + ProhibitedIP = "This IP address is prohibited to use." + ProhibitedURL = "This URL is prohibited to use." + TooEarly = "This value should be later than {{ comparedValue }}." + TooEarlyOrEqual = "This value should be later than or equal to {{ comparedValue }}." + TooFewElements = "This collection should contain {{ limit }} element(s) or more." + TooHigh = "This value should be less than {{ comparedValue }}." + TooHighOrEqual = "This value should be less than or equal to {{ comparedValue }}." + TooLate = "This value should be earlier than {{ comparedValue }}." + TooLateOrEqual = "This value should be earlier than or equal to {{ comparedValue }}." + TooLong = "This value is too long. It should have {{ limit }} character(s) or less." + TooLow = "This value should be greater than {{ comparedValue }}." + TooLowOrEqual = "This value should be greater than or equal to {{ comparedValue }}." + TooManyElements = "This collection should contain {{ limit }} element(s) or less." + TooShort = "This value is too short. It should have {{ limit }} character(s) or more." // NoSuspiciousCharacters (Symfony Validator wording). SuspiciousInvisible = "Using invisible characters is not allowed." diff --git a/message/translations/english/messages.go b/message/translations/english/messages.go index 8c88fc5..417d9ee 100644 --- a/message/translations/english/messages.go +++ b/message/translations/english/messages.go @@ -46,27 +46,29 @@ var Messages = map[language.Tag]map[string]catalog.Message{ message.TooManyElements: plural.Selectf(1, "", plural.One, "This collection should contain {{ limit }} element or less.", plural.Other, "This collection should contain {{ limit }} elements or less."), - message.NotEqual: catalog.String(message.NotEqual), - message.NotFalse: catalog.String(message.NotFalse), - message.InvalidDate: catalog.String(message.InvalidDate), - message.InvalidDateTime: catalog.String(message.InvalidDateTime), - message.InvalidEAN13: catalog.String(message.InvalidEAN13), - 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), - message.InvalidIP: catalog.String(message.InvalidIP), - message.InvalidJSON: catalog.String(message.InvalidJSON), - message.InvalidLUHN: catalog.String(message.InvalidLUHN), - message.InvalidTime: catalog.String(message.InvalidTime), - message.InvalidULID: catalog.String(message.InvalidULID), - message.InvalidUPCA: catalog.String(message.InvalidUPCA), - message.InvalidUPCE: catalog.String(message.InvalidUPCE), - message.InvalidURL: catalog.String(message.InvalidURL), - message.InvalidUUID: catalog.String(message.InvalidUUID), + message.NotEqual: catalog.String(message.NotEqual), + message.NotFalse: catalog.String(message.NotFalse), + message.InvalidDate: catalog.String(message.InvalidDate), + message.InvalidDateTime: catalog.String(message.InvalidDateTime), + message.InvalidEAN13: catalog.String(message.InvalidEAN13), + 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.InvalidBIC: catalog.String(message.InvalidBIC), + message.BICNotAssociatedWithIBAN: catalog.String(message.BICNotAssociatedWithIBAN), + message.InvalidISIN: catalog.String(message.InvalidISIN), + message.InvalidCIDR: catalog.String(message.InvalidCIDR), + message.CIDRNetmaskOutOfRange: catalog.String(message.CIDRNetmaskOutOfRange), + message.InvalidIP: catalog.String(message.InvalidIP), + message.InvalidJSON: catalog.String(message.InvalidJSON), + message.InvalidLUHN: catalog.String(message.InvalidLUHN), + message.InvalidTime: catalog.String(message.InvalidTime), + message.InvalidULID: catalog.String(message.InvalidULID), + message.InvalidUPCA: catalog.String(message.InvalidUPCA), + message.InvalidUPCE: catalog.String(message.InvalidUPCE), + message.InvalidURL: catalog.String(message.InvalidURL), + message.InvalidUUID: catalog.String(message.InvalidUUID), message.NotExactLength: plural.Selectf(1, "", plural.One, "This value should have exactly {{ limit }} character.", plural.Other, "This value should have exactly {{ limit }} characters."), diff --git a/message/translations/russian/messages.go b/message/translations/russian/messages.go index 321b9a2..3732690 100644 --- a/message/translations/russian/messages.go +++ b/message/translations/russian/messages.go @@ -49,27 +49,29 @@ var Messages = map[language.Tag]map[string]catalog.Message{ plural.One, "Эта коллекция должна содержать {{ limit }} элемент или меньше.", plural.Few, "Эта коллекция должна содержать {{ limit }} элемента или меньше.", plural.Other, "Эта коллекция должна содержать {{ limit }} элементов или меньше."), - message.NotEqual: catalog.String("Значение должно быть равно {{ comparedValue }}."), - message.NotFalse: catalog.String("Значение должно быть ложным."), - message.InvalidDate: catalog.String("Значение не является правильной датой."), - message.InvalidDateTime: catalog.String("Значение даты и времени недопустимо."), - message.InvalidEAN13: catalog.String("Значение не является допустимым EAN-13."), - 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 }}."), - message.InvalidIP: catalog.String("Значение не является допустимым IP адресом."), - message.InvalidJSON: catalog.String("Значение должно быть корректным JSON."), - message.InvalidLUHN: catalog.String("Недействительный номер карты."), - message.InvalidTime: catalog.String("Значение времени недопустимо."), - message.InvalidULID: catalog.String("Значение не соответствует формату ULID."), - message.InvalidUPCA: catalog.String("Значение не является допустимым UPC-A."), - message.InvalidUPCE: catalog.String("Значение не является допустимым UPC-E."), - message.InvalidURL: catalog.String("Значение не является допустимым URL."), - message.InvalidUUID: catalog.String("Значение не соответствует формату UUID."), + message.NotEqual: catalog.String("Значение должно быть равно {{ comparedValue }}."), + message.NotFalse: catalog.String("Значение должно быть ложным."), + message.InvalidDate: catalog.String("Значение не является правильной датой."), + message.InvalidDateTime: catalog.String("Значение даты и времени недопустимо."), + message.InvalidEAN13: catalog.String("Значение не является допустимым EAN-13."), + message.InvalidEAN8: catalog.String("Значение не является допустимым EAN-8."), + message.InvalidEmail: catalog.String("Значение адреса электронной почты недопустимо."), + message.InvalidHostname: catalog.String("Значение не является корректным именем хоста."), + message.InvalidIBAN: catalog.String("Значение не является допустимым международным номером банковского счёта (IBAN)."), + message.InvalidBIC: catalog.String("Значение не является допустимым банковским идентификатором (BIC)."), + message.BICNotAssociatedWithIBAN: catalog.String("Этот банковский идентификатор (BIC) не соответствует IBAN {{ iban }}."), + message.InvalidISIN: catalog.String("Значение не является допустимым международным идентификационным номером ценной бумаги (ISIN)."), + message.InvalidCIDR: catalog.String("Значение не является допустимой записью CIDR."), + message.CIDRNetmaskOutOfRange: catalog.String("Значение маски сети должно быть между {{ min }} и {{ max }}."), + message.InvalidIP: catalog.String("Значение не является допустимым IP адресом."), + message.InvalidJSON: catalog.String("Значение должно быть корректным JSON."), + message.InvalidLUHN: catalog.String("Недействительный номер карты."), + message.InvalidTime: catalog.String("Значение времени недопустимо."), + message.InvalidULID: catalog.String("Значение не соответствует формату ULID."), + message.InvalidUPCA: catalog.String("Значение не является допустимым UPC-A."), + message.InvalidUPCE: catalog.String("Значение не является допустимым UPC-E."), + message.InvalidURL: catalog.String("Значение не является допустимым URL."), + message.InvalidUUID: catalog.String("Значение не соответствует формату UUID."), message.NotExactLength: plural.Selectf(1, "", plural.One, "Значение должно быть равно {{ limit }} символу.", plural.Few, "Значение должно быть равно {{ limit }} символам.", diff --git a/test/constraints_identifiers_cases_test.go b/test/constraints_identifiers_cases_test.go index 4bcceae..2e326c0 100644 --- a/test/constraints_identifiers_cases_test.go +++ b/test/constraints_identifiers_cases_test.go @@ -10,6 +10,7 @@ var identifierConstraintsTestCases = mergeTestCases( ulidConstraintTestCases, uuidConstraintTestCases, ibanConstraintTestCases, + bicConstraintTestCases, isinConstraintTestCases, luhnConstraintTestCases, ) @@ -102,6 +103,108 @@ var ibanConstraintTestCases = []ConstraintValidationTestCase{ }, } +var bicConstraintTestCases = []ConstraintValidationTestCase{ + { + name: "IsBIC passes on empty value", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsBIC(), + stringValue: stringValue(""), + assert: assertNoError, + }, + { + name: "IsBIC passes on valid 8-char BIC", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("DEUTDEFF"), + constraint: it.IsBIC(), + assert: assertNoError, + }, + { + name: "IsBIC passes when spaces stripped", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("DEUT DE FF"), + constraint: it.IsBIC(), + assert: assertNoError, + }, + { + name: "IsBIC passes with CaseInsensitive on lowercase", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("deutdeff"), + constraint: it.IsBIC().CaseInsensitive(), + assert: assertNoError, + }, + { + name: "IsBIC violation on strict lowercase", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("deutdeff"), + constraint: it.IsBIC(), + assert: assertHasOneViolation(validation.ErrInvalidBIC, message.InvalidBIC), + }, + { + name: "IsBIC violation on wrong length", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("DEUTDEF"), + constraint: it.IsBIC(), + assert: assertHasOneViolation(validation.ErrInvalidBIC, message.InvalidBIC), + }, + { + name: "IsBIC violation on unknown country", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("DEUTZZFF"), + constraint: it.IsBIC(), + assert: assertHasOneViolation(validation.ErrInvalidBIC, message.InvalidBIC), + }, + { + name: "IsBIC violation on IBAN country mismatch", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("DEUTDEFF"), + constraint: it.IsBIC().WithIBAN("GB29NWBK60161331926819"), + assert: assertHasOneViolation( + validation.ErrBICIBANCountryMismatch, + "This Business Identifier Code (BIC) is not associated with IBAN GB29NWBK60161331926819.", + ), + }, + { + name: "IsBIC passes when IBAN country matches", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("DEUTDEFF"), + constraint: it.IsBIC().WithIBAN("DE89370400440532013000"), + assert: assertNoError, + }, + { + name: "IsBIC violation with given error and message", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsBIC(). + WithError(ErrCustom). + WithMessage( + `Invalid value "{{ value }}" for {{ custom }}.`, + validation.TemplateParameter{Key: "{{ custom }}", Value: "parameter"}, + ), + stringValue: stringValue("bad-bic"), + assert: assertHasOneViolation(ErrCustom, `Invalid value "bad-bic" for parameter.`), + }, + { + name: "IsBIC passes when condition is false", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsBIC().When(false), + stringValue: stringValue("bad"), + assert: assertNoError, + }, + { + name: "IsBIC violation when condition is true", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsBIC().When(true), + stringValue: stringValue("bad"), + assert: assertHasOneViolation(validation.ErrInvalidBIC, message.InvalidBIC), + }, + { + name: "IsBIC passes when groups not match", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsBIC().WhenGroups(testGroup), + stringValue: stringValue("bad"), + assert: assertNoError, + }, +} + var isinConstraintTestCases = []ConstraintValidationTestCase{ { name: "IsISIN passes on empty value", diff --git a/validate/bic.go b/validate/bic.go new file mode 100644 index 0000000..a914703 --- /dev/null +++ b/validate/bic.go @@ -0,0 +1,171 @@ +package validate + +import ( + "errors" + "strings" + "unicode" + + "golang.org/x/text/language" +) + +// ErrInvalidBIC is returned by [BIC] when the value is not a valid Business Identifier Code (BIC / SWIFT). +// Behavior is aligned with Symfony\Component\Validator\Constraints\Bic and BicValidator (country check via +// ISO 3166-1 alpha-2 regions known to [golang.org/x/text/language] plus Symfony's BIC-to-IBAN territory map). +var ErrInvalidBIC = errors.New("invalid BIC") + +// ErrBICIBANCountryMismatch is returned by [BIC] when an associated IBAN is set and its country code +// does not match the BIC's country/territory code (using the same territory mapping as Symfony BicValidator). +var ErrBICIBANCountryMismatch = errors.New("BIC country does not match IBAN") + +// bicTerritoryToIBANCountry maps BIC country/territory codes to the parent IBAN country code, matching +// Symfony\Component\Validator\Constraints\BicValidator::BIC_COUNTRY_TO_IBAN_COUNTRY_MAP. +var bicTerritoryToIBANCountry = map[string]string{ + "GF": "FR", "PF": "FR", "TF": "FR", "GP": "FR", "MQ": "FR", "YT": "FR", + "NC": "FR", "RE": "FR", "BL": "FR", "MF": "FR", "PM": "FR", "WF": "FR", + "JE": "GB", "IM": "GB", "GG": "GB", "VG": "GB", + "AX": "FI", + "IC": "ES", "EA": "ES", +} + +const ( + bicModeStrict = "strict" + bicModeCaseInsensitive = "case-insensitive" + defaultBICMode = bicModeStrict +) + +// BICOptions configures [BIC] validation. +type BICOptions struct { + mode string + iban string +} + +// BICCaseInsensitive enables case-insensitive validation (Symfony Bic::VALIDATION_MODE_CASE_INSENSITIVE): +// lowercase letters are allowed, and the BIC country/territory code is matched case-insensitively. +// The default is strict mode (uppercase ASCII only). +func BICCaseInsensitive() func(*BICOptions) { + return func(o *BICOptions) { + o.mode = bicModeCaseInsensitive + } +} + +// BICWithIBAN sets an IBAN value to assert that its country code matches the BIC's territory/country +// (same rules as Symfony Bic constraint "iban" option). +func BICWithIBAN(iban string) func(*BICOptions) { + return func(o *BICOptions) { + o.iban = iban + } +} + +func newBICOptions() BICOptions { + return BICOptions{mode: defaultBICMode} +} + +// BIC validates whether the value is a valid Business Identifier Code (BIC / SWIFT), aligned with +// Symfony\Component\Validator\Constraints\Bic and BicValidator. +// +// Spaces (U+0020) are stripped before validation, matching Symfony str_replace(' ', ”, $value). +// +// Empty string is considered valid (use [NotBlank] or similar to reject empty values). +// +// Possible errors: +// - [ErrInvalidBIC] when length, characters, country/territory, or strict-case rules fail; +// - [ErrBICIBANCountryMismatch] when [BICWithIBAN] is set with a non-empty IBAN whose first two letters +// (after IBAN canonicalization) form an alpha country code that does not match the BIC territory. +// +// See https://en.wikipedia.org/wiki/ISO_9362. +func BIC(value string, options ...func(*BICOptions)) error { + if value == "" { + return nil + } + + opts := newBICOptions() + for _, opt := range options { + opt(&opts) + } + + s := stripBICSpaces(value) + if err := bicValidateStructure(s); err != nil { + return err + } + + bicCC := bicCountryCodeFromBIC(s, opts.mode) + if !bicCountryOrTerritoryKnown(bicCC) { + return ErrInvalidBIC + } + + if opts.mode == bicModeStrict && strings.ToUpper(s) != s { + return ErrInvalidBIC + } + + return bicValidateAgainstIBAN(bicCC, opts.iban) +} + +func bicValidateStructure(s string) error { + if len(s) != 8 && len(s) != 11 { + return ErrInvalidBIC + } + for i := 0; i < len(s); i++ { + c := s[i] + if c >= utf8ASCIIUpperBound || !unicode.IsLetter(rune(c)) && !unicode.IsDigit(rune(c)) { + return ErrInvalidBIC + } + } + return nil +} + +func bicCountryCodeFromBIC(s string, mode string) string { + cc := s[4:6] + if mode == bicModeCaseInsensitive { + return strings.ToUpper(cc) + } + return cc +} + +func bicValidateAgainstIBAN(bicCC, iban string) error { + if iban == "" { + return nil + } + ibanCanon, ok := canonicalizeIBAN(iban) + if !ok || len(ibanCanon) < 2 { + return nil + } + ibanCC := ibanCanon[:2] + if !isAlpha2(ibanCC) { + return nil + } + if bicMatchesIBANCountry(bicCC, ibanCC) { + return nil + } + return ErrBICIBANCountryMismatch +} + +func stripBICSpaces(value string) string { + if !strings.Contains(value, " ") { + return value + } + return strings.ReplaceAll(value, " ", "") +} + +func bicCountryOrTerritoryKnown(bicCC string) bool { + if len(bicCC) != 2 { + return false + } + if _, ok := bicTerritoryToIBANCountry[bicCC]; ok { + return true + } + r, err := language.ParseRegion(bicCC) + if err != nil { + return false + } + return r.IsCountry() && !r.IsGroup() +} + +func bicMatchesIBANCountry(bicCountryCode, ibanCountryCode string) bool { + if ibanCountryCode == bicCountryCode { + return true + } + if parent, ok := bicTerritoryToIBANCountry[bicCountryCode]; ok && ibanCountryCode == parent { + return true + } + return false +} diff --git a/validate/bic_test.go b/validate/bic_test.go new file mode 100644 index 0000000..a20a311 --- /dev/null +++ b/validate/bic_test.go @@ -0,0 +1,57 @@ +package validate_test + +import ( + "errors" + "testing" + + "github.com/muonsoft/validation/validate" +) + +func TestBIC(t *testing.T) { + t.Parallel() + + deIBAN := "DE89370400440532013000" + gbIBAN := "GB29NWBK60161331926819" + + cases := []struct { + name string + value string + options []func(*validate.BICOptions) + expectedErr error + }{ + {name: "empty", value: "", expectedErr: nil}, + {name: "valid 8", value: "DEUTDEFF", expectedErr: nil}, + {name: "valid 11", value: "DEUTDEFF500", expectedErr: nil}, + {name: "spaces stripped", value: "DEUT DE FF", expectedErr: nil}, + {name: "territory JE maps to GB IBAN", value: "AAAAJE22", options: []func(*validate.BICOptions){validate.BICWithIBAN(gbIBAN)}, expectedErr: nil}, + {name: "territory JE mismatch DE IBAN", value: "AAAAJE22", options: []func(*validate.BICOptions){validate.BICWithIBAN(deIBAN)}, expectedErr: validate.ErrBICIBANCountryMismatch}, + {name: "DE BIC matches DE IBAN", value: "DEUTDEFF", options: []func(*validate.BICOptions){validate.BICWithIBAN(deIBAN)}, expectedErr: nil}, + {name: "DE BIC mismatch GB IBAN", value: "DEUTDEFF", options: []func(*validate.BICOptions){validate.BICWithIBAN(gbIBAN)}, expectedErr: validate.ErrBICIBANCountryMismatch}, + {name: "empty IBAN option skips cross-check", value: "DEUTDEFF", options: []func(*validate.BICOptions){validate.BICWithIBAN("")}, expectedErr: nil}, + {name: "too short", value: "DEUTDEF", expectedErr: validate.ErrInvalidBIC}, + {name: "too long", value: "DEUTDEFF5000", expectedErr: validate.ErrInvalidBIC}, + {name: "non alphanumeric", value: "DEUTDE#F", expectedErr: validate.ErrInvalidBIC}, + {name: "unknown country ZZ", value: "DEUTZZFF", expectedErr: validate.ErrInvalidBIC}, + {name: "group region EU", value: "DEUTEUXX", expectedErr: validate.ErrInvalidBIC}, + {name: "strict lowercase", value: "deutdeff", expectedErr: validate.ErrInvalidBIC}, + {name: "case insensitive lowercase", value: "deutdeff", options: []func(*validate.BICOptions){validate.BICCaseInsensitive()}, expectedErr: nil}, + {name: "case insensitive unknown country", value: "deutzzff", options: []func(*validate.BICOptions){validate.BICCaseInsensitive()}, expectedErr: validate.ErrInvalidBIC}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := validate.BIC(tc.value, tc.options...) + if tc.expectedErr == nil { + if err != nil { + t.Fatalf("BIC(%q): got %v, want nil", tc.value, err) + } + return + } + if !errors.Is(err, tc.expectedErr) { + t.Fatalf("BIC(%q): got %v, want %v", tc.value, err, tc.expectedErr) + } + }) + } +} diff --git a/validate/example_test.go b/validate/example_test.go index 1b7f02d..553f829 100644 --- a/validate/example_test.go +++ b/validate/example_test.go @@ -170,6 +170,16 @@ func ExampleIBAN() { // invalid IBAN } +func ExampleBIC() { + fmt.Println(validate.BIC("DEUTDEFF")) + fmt.Println(validate.BIC("DEUTDEF")) + fmt.Println(validate.BIC("deutdeff", validate.BICCaseInsensitive())) + // Output: + // + // invalid BIC + // +} + func ExampleISIN() { fmt.Println(validate.ISIN("US0378331005")) fmt.Println(validate.ISIN("US037833100"))