From ea7ed3853e7858c83af3082143ee85f495a746d1 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Mon, 8 Dec 2025 10:57:39 +0400 Subject: [PATCH] feat: add iban validation rule --- README.md | 1 + .../Classes/Rules/IBANValidationRule.swift | 71 ++++++++++++++++++ .../ValidatorCore/Validator.docc/Overview.md | 1 + .../Rules/IBANValidationRuleTests.swift | 72 +++++++++++++++++++ 4 files changed, 145 insertions(+) create mode 100644 Sources/ValidatorCore/Classes/Rules/IBANValidationRule.swift create mode 100644 Tests/ValidatorCoreTests/UnitTests/Rules/IBANValidationRuleTests.swift diff --git a/README.md b/README.md index 0b6575c..df2403f 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,7 @@ struct RegistrationView: View { | `ContainsValidationRule` | Validates that a string contains a specific substring | `ContainsValidationRule(substring: "@", error: "Must contain @")` | `EqualityValidationRule`| Validates that the input is equal to a given reference value | `EqualityValidationRule(compareTo: password, error: "Passwords do not match")` | `ComparisonValidationRule` | Validates that input against a comparison constraint | `ComparisonValidationRule(greaterThan: 0, error: "Must be greater than 0")` +| `IBANValidationRule` | Validates that a string is a valid IBAN (International Bank Account Number) | `IBANValidationRule(error: "Invalid IBAN")` ## Custom Validators diff --git a/Sources/ValidatorCore/Classes/Rules/IBANValidationRule.swift b/Sources/ValidatorCore/Classes/Rules/IBANValidationRule.swift new file mode 100644 index 0000000..8c9d24e --- /dev/null +++ b/Sources/ValidatorCore/Classes/Rules/IBANValidationRule.swift @@ -0,0 +1,71 @@ +// +// Validator +// Copyright © 2025 Space Code. All rights reserved. +// + +/// Validates that a string is a valid IBAN (International Bank Account Number). +/// +/// Uses a simplified IBAN validation algorithm: +/// 1. Checks length according to country code (2 letters + digits) +/// 2. Moves first 4 characters to the end +/// 3. Converts letters to numbers (A=10, B=11, ..., Z=35) +/// 4. Checks if the resulting number mod 97 equals 1 +/// +/// # Example: +/// ```swift +/// let rule = IBANValidationRule(error: "Invalid IBAN") +/// rule.validate(input: "GB82WEST12345698765432") // true +/// rule.validate(input: "INVALIDIBAN") // false +/// ``` +public struct IBANValidationRule: IValidationRule { + // MARK: - Types + + public typealias Input = String + + // MARK: Properties + + /// The validation error returned when validation fails. + public let error: IValidationError + + // MARK: Initialization + + /// Creates an IBAN validation rule. + /// + /// - Parameter error: The validation error returned if validation fails. + public init(error: IValidationError) { + self.error = error + } + + // MARK: IValidationRule + + public func validate(input: String) -> Bool { + let trimmed = input.replacingOccurrences(of: " ", with: "").uppercased() + guard trimmed.count >= 4 else { return false } + + let rearranged = String(trimmed.dropFirst(4) + trimmed.prefix(4)) + + var numericString = "" + for char in rearranged { + if let digit = char.wholeNumberValue { + numericString.append(String(digit)) + } else if let ascii = char.asciiValue, ascii >= 65, ascii <= 90 { + numericString.append(String(Int(ascii) - 55)) + } else { + return false + } + } + + var remainder = 0 + var chunk = "" + for char in numericString { + chunk.append(char) + if let number = Int(chunk), number >= 97 { + remainder = number % 97 + chunk = String(remainder) + } + } + + remainder = (Int(chunk) ?? 0) % 97 + return remainder == 1 + } +} diff --git a/Sources/ValidatorCore/Validator.docc/Overview.md b/Sources/ValidatorCore/Validator.docc/Overview.md index 5f09abc..78cce35 100644 --- a/Sources/ValidatorCore/Validator.docc/Overview.md +++ b/Sources/ValidatorCore/Validator.docc/Overview.md @@ -37,6 +37,7 @@ ValidatorCore contains all core validation rules, utilities, and mechanisms for - ``ContainsValidationRule`` - ``EqualityValidationRule`` - ``ComparisonValidationRule`` +- ``IBANValidationRule`` ### Articles diff --git a/Tests/ValidatorCoreTests/UnitTests/Rules/IBANValidationRuleTests.swift b/Tests/ValidatorCoreTests/UnitTests/Rules/IBANValidationRuleTests.swift new file mode 100644 index 0000000..3578a02 --- /dev/null +++ b/Tests/ValidatorCoreTests/UnitTests/Rules/IBANValidationRuleTests.swift @@ -0,0 +1,72 @@ +// +// Validator +// Copyright © 2025 Space Code. All rights reserved. +// + +@testable import ValidatorCore +import XCTest + +// MARK: - IBANValidationRuleTests + +final class IBANValidationRuleTests: XCTestCase { + // MARK: Properties + + private var sut: IBANValidationRule! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + sut = IBANValidationRule(error: String.error) + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatValidationRuleSetsProperties() { + // then + XCTAssertEqual(sut.error.message, .error) + } + + func test_thatIBANValidationRuleValidatesInput_whenInputIsCorrectValue() { + let validIBANs = [ + "GB82 WEST 1234 5698 7654 32", + "DE89370400440532013000", + "FR1420041010050500013M02606", + ] + + for iban in validIBANs { + XCTAssertTrue(sut.validate(input: iban)) + } + } + + func test_thatIBANValidationRuleValidatesInput_whenInputIsInCorrectValue() { + let invalidIBANs = [ + "GB82WEST12345698765431", + "INVALIDIBAN", + "DE123", + ] + + for iban in invalidIBANs { + XCTAssertFalse(sut.validate(input: iban)) + } + } + + func test_thatIBANValidationRuleValidatesInput_whenInputIsEmpty() { + XCTAssertFalse(sut.validate(input: "")) + } + + func test_thatIBANValidationRuleValidatesInput_whenInputHasInvalidCharacters() { + XCTAssertFalse(sut.validate(input: "GB82WEST1234$5698765432")) + } +} + +// MARK: Constants + +private extension String { + static let error = "Invalid IBAN" +}