diff --git a/README.md b/README.md index 79e3c7b..b916e4a 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,11 @@ If you are using Carthage you will need to add this to your `Cartfile` ```bash github "jpotts18/SwiftValidator" ``` +## Dependencies + +CardNumberRule uses CardParser from => https://github.com/Raizlabs/CardParser.git +Cards can be validated to ensure they conform to LUHN algorigthm. + ## Usage diff --git a/SwiftValidator/Parsers/CardParser.swift b/SwiftValidator/Parsers/CardParser.swift new file mode 100644 index 0000000..b98bfe8 --- /dev/null +++ b/SwiftValidator/Parsers/CardParser.swift @@ -0,0 +1,249 @@ + +// +// CardParser.swift +// +// Created by Jason Clark on 6/28/16. +// Copyright © 2016 Raizlabs. All rights reserved. +// +//MARK: - CardType +enum CardType { + case amex + case diners + case discover + case jcb + case mastercard + case visa + case verve + + static let allValues: [CardType] = [.visa, .mastercard, .amex, .diners, .discover, .jcb, .verve] + + private var validationRequirements: ValidationRequirement { + + let prefix: [PrefixContainable], length: [Int] + + switch self { + /* // IIN prefixes and length requriements retreived from https://en.wikipedia.org/wiki/Bank_card_number on June 28, 2016 */ + + case .amex: + prefix = ["34", "37"] + length = [15] + break + + case .diners: + prefix = ["300"..."305", "309", "36", "38"..."39"] + length = [14] + break + + case .discover: + prefix = ["6011", "65", "644"..."649", "622126"..."622925"] + length = [16] + break + + case .jcb: + prefix = ["3528"..."3589"] + length = [16] + break + + case .mastercard: + prefix = ["51"..."55", "2221"..."2720"] + length = [16] + break + + case .visa: + prefix = ["4"] + length = [13, 16, 19] + break + + case .verve: + prefix = ["5060", "5061", "5078", "5079", "6500"] + length = [16, 19] + break + + } + + return ValidationRequirement(prefixes: prefix, lengths: length) + } + + + + var segmentGroupings: [Int] { + switch self { + case .amex: + return [4, 6, 5] + case .diners: + return [4, 6, 4] + case .verve: + return [4, 4, 4, 6] + default: + return [4, 4, 4, 4] + } + } + + var maxLength: Int { + return validationRequirements.lengths.max() ?? 16 + } + + var cvvLength: Int { + switch self { + case .amex: + return 4 + default: + return 3 + } + } + + func isValid(_ accountNumber: String) -> Bool { + return validationRequirements.isValid(accountNumber) && CardType.luhnCheck(accountNumber) + } + + func isPrefixValid(_ accountNumber: String) -> Bool { + return validationRequirements.isPrefixValid(accountNumber) + } + +} + + +//MARK: Validation requirements and rules + +fileprivate extension CardType { + + struct ValidationRequirement { + let prefixes: [PrefixContainable] + let lengths: [Int] + + func isValid(_ accountNumber: String) -> Bool { + return isLengthValid(accountNumber) && isPrefixValid(accountNumber) + } + + func isPrefixValid(_ accountNumber: String) -> Bool { + + guard prefixes.count > 0 else { return true } + return prefixes.contains { $0.hasCommonPrefix(with: accountNumber) } + } + + func isLengthValid(_ accountNumber: String) -> Bool { + guard lengths.count > 0 else { return true } + return lengths.contains { accountNumber.length == $0 } + } + } + + // from: https://gist.github.com/cwagdev/635ce973e8e86da0403a + static func luhnCheck(_ cardNumber: String) -> Bool { + + guard let _ = Int64(cardNumber) else { + //if string is not convertible to int, return false + return false + } + let numberOfChars = cardNumber.count + let numberToCheck = numberOfChars % 2 == 0 ? cardNumber : "0" + cardNumber + + let digits = numberToCheck.map { Int(String($0)) } + + let sum = digits.enumerated().reduce(0) { (sum, val: (offset: Int, element: Int?)) in + if (val.offset + 1) % 2 == 1 { + let element = val.element! + return sum + (element == 9 ? 9 : (element * 2) % 9) + } + // else + return sum + val.element! + } + let validates = sum % 10 == 0 + print("card valid") + return validates + } + +} + + +//MARK: - CardState +enum CardState { + case identified(CardType) + case indeterminate([CardType]) + case invalid +} + + +extension CardState: Equatable {} + func ==(lhs: CardState, rhs: CardState) -> Bool { + switch (lhs, rhs) { + case (.invalid, .invalid): return true + case (let .indeterminate(cards1), let .indeterminate(cards2)): return cards1 == cards2 + case (let .identified(card1), let .identified(card2)): return card1 == card2 + default: return false + } +} + + +extension CardState { + + init(fromNumber number: String) { + if let card = CardType.allValues.first(where: { $0.isValid(number) }) { + print("card found \(card)") + self = .identified(card) + } + else { + self = .invalid + } + } + + init(fromPrefix prefix: String) { + let possibleTypes = CardType.allValues.filter { $0.isPrefixValid(prefix) } + if possibleTypes.count >= 2 { + self = .indeterminate(possibleTypes) + } + else if possibleTypes.count == 1, let card = possibleTypes.first { + self = .identified(card) + } + else { + self = .invalid + } + } + +} + +//MARK: - PrefixContainable +fileprivate protocol PrefixContainable { + + func hasCommonPrefix(with text: String) -> Bool + +} + +extension ClosedRange: PrefixContainable { + + func hasCommonPrefix(with text: String) -> Bool { + //cannot include Where clause in protocol conformance, so have to ensure Bound == String :( + guard let lower = lowerBound as? String, let upper = upperBound as? String else { return false } + + let trimmedRange: ClosedRange = { + let length = text.length + let trimmedStart = lower.prefix(length) + let trimmedEnd = upper.prefix(length) + return trimmedStart...trimmedEnd + }() + + let trimmedText = text.prefix(trimmedRange.lowerBound.count) + return trimmedRange ~= trimmedText + } + +} + + +extension String: PrefixContainable { + + func hasCommonPrefix(with text: String) -> Bool { + return hasPrefix(text) || text.hasPrefix(self) + } + +} + +fileprivate extension String { + + func prefix(_ maxLength: Int) -> String { + return String(maxLength) + } + + var length: Int { + return count + } + +} diff --git a/SwiftValidator/Rules/CardExpiryRule.swift b/SwiftValidator/Rules/CardExpiryRule.swift new file mode 100644 index 0000000..7dc967d --- /dev/null +++ b/SwiftValidator/Rules/CardExpiryRule.swift @@ -0,0 +1,65 @@ +// +// CardExpiryRule.swift +// SwiftValidator +// +// Created by Mark Boleigha on 13/03/2019. +// Copyright © 2019 jpotts18. All rights reserved. +// + +import Foundation + + + +/** + `CardExpiryRule` is a subclass of `Rule` that defines how a credit/debit's card number field is validated + */ +public class CardExpiryRule: Rule { + /// Error message to be displayed if validation fails. + private var message : String + /** + Initializes `CardExpiryRule` object with error message. Used to validate a card's expiry year. + + - parameter message: String of error message. + - returns: An initialized `CardExpiryRule` object, or nil if an object could not be created for some reason that would not result in an exception. + */ + public init(message : String = "Card expiry date is invalid"){ + self.message = message + } + + /** + Validates a field. + + - parameter value: String to check for validation. must be a card expiry date in MM/YY format + - returns: Boolean value. True on successful validation, otherwise False on failed Validation. + */ + public func validate(_ value: String) -> Bool { + guard value.count > 4 else{ + return false + } + let date = value.replacingOccurrences(of: "/", with: "") + let monthIndex = date.index(date.startIndex, offsetBy: 2) + let Month = Int(date[..= thisYearTwoDigits + + } + + /** + Used to display error message when validation fails. + + - returns: String of error message. + */ + public func errorMessage() -> String { + return message + } + +} diff --git a/SwiftValidator/Rules/CardNumberRule.swift b/SwiftValidator/Rules/CardNumberRule.swift new file mode 100644 index 0000000..4ed8b49 --- /dev/null +++ b/SwiftValidator/Rules/CardNumberRule.swift @@ -0,0 +1,51 @@ +// +// CardNumberRule.swift +// Validator +// +// Created by Boleigha Mark on 08/03/2018. +// Copyright © 2017 jpotts18. All rights reserved. +// +import Foundation + +/** + `CardNumberRule` is a subclass of `Rule` that defines how a credit/debit's card number field is validated + */ +public class CardNumberRule: Rule { + /// Error message to be displayed if validation fails. + private var message : String + /** + Initializes `CardNumberRule` object with error message. Used to validate a card's expiry month. + + - parameter message: String of error message. + - returns: An initialized `CardNumberRule` object, or nil if an object could not be created for some reason that would not result in an exception. + */ + public init(message : String = "Card is Invalid"){ + self.message = message + } + + /** + Validates a field. + + - parameter value: String to check for validation. + - returns: Boolean value. True on successful validation, otherwise False on failed Validation. + */ + public func validate(_ value: String) -> Bool { + let cardNoFull = value.replacingOccurrences(of: " ", with: "") + guard CardState(fromNumber: cardNoFull) != .invalid else { + return false + } + + return true + + } + + /** + Used to display error message when validation fails. + + - returns: String of error message. + */ + public func errorMessage() -> String { + return message + } + +} diff --git a/SwiftValidatorTests/SwiftValidatorTests.swift b/SwiftValidatorTests/SwiftValidatorTests.swift index 4672e21..842821b 100644 --- a/SwiftValidatorTests/SwiftValidatorTests.swift +++ b/SwiftValidatorTests/SwiftValidatorTests.swift @@ -34,7 +34,7 @@ class SwiftValidatorTests: XCTestCase { let VALID_CARD_EXPIRY_MONTH = "10" let INVALID_CARD_EXPIRY_MONTH = "13" - let VALID_CARD_EXPIRY_YEAR = "2018" + let VALID_CARD_EXPIRY_YEAR = "2020" let INVALID_CARD_EXPIRY_YEAR = "2016" let LEN_3 = "hey" @@ -52,7 +52,38 @@ class SwiftValidatorTests: XCTestCase { let UNREGISTER_ERRORS_TXT_FIELD = UITextField() let UNREGISTER_ERRORS_VALIDATOR = Validator() + + /* + Card number Validation Tests + */ + + //VISA + let VALID_VISA_CARD = "4000056655665556" + let INVALID_VISA_CARD = "4960092245196342" + + //MASTERCARD + let VALID_MASTERCARD = "5105105105105100" + let INVALID_MASTERCARD = "53998383838623381" + + //VERVE(NIGERIA) + let VALID_VERVE_CARD = "5061460410120223210" + let INVALID_VERVE_CARD = "5061435662036050587" + + //AMEX + let VALID_AMEX = "344173993556638" + let INVALID_AMEX = "3441739936546638" + + //DISCOVER + let VALID_DISCOVER = "6011000990139424" + let INVALID_DISCOVER = "6011116641111117" + + + //JCB + let VALID_JCB = "3566111111111113" + let INVALID_JCB = "3566754297360505" + + let ERROR_LABEL = UILabel() override func setUp() { @@ -65,7 +96,74 @@ class SwiftValidatorTests: XCTestCase { super.tearDown() } + //CARD EXPIRY VALIDATION VALUES + let VALID_DATE = "10/29" + let INVALID_DATE = "10/12" + + //MARK: CARD NUMBER VALIDATION + + //VISA + func testVisaValid(){ + XCTAssertTrue(CardNumberRule().validate(VALID_VISA_CARD), "Valid Visa Card Should Return True") + } + + func testVisaInvalid(){ + XCTAssertFalse(CardNumberRule().validate(INVALID_VISA_CARD), "Invalid Visa Card should return false") + } + + + //AMEX + func testValidAmex(){ + XCTAssertTrue(CardNumberRule().validate(VALID_AMEX), "Valid amex card should return true") + } + func testInvalidAmex(){ + XCTAssertFalse(CardNumberRule().validate(INVALID_AMEX), "Invalid Amex should return false") + } + + //MASTERCARD + func testValidMasterCard(){ + XCTAssertTrue(CardNumberRule().validate(VALID_MASTERCARD), "Valid Mastercard should return true") + } + + func testInvalidMasterCard(){ + XCTAssertFalse(CardNumberRule().validate(INVALID_MASTERCARD), "Invalid mastercard should return false") + } + + //Discover + func testValidDiscover(){ + XCTAssertTrue(CardNumberRule().validate(VALID_DISCOVER), "Valid Discover card should return true") + } + + func testInvalidDiscover(){ + XCTAssertFalse(CardNumberRule().validate(INVALID_DISCOVER), "Invalid Discover card should return false") + } + + + + //JCB + func testValidJCB(){ + XCTAssertTrue(CardNumberRule().validate(VALID_JCB), "Valid JCB card should return true") + } + + func testInvalidJCB(){ + XCTAssertFalse(CardNumberRule().validate(INVALID_JCB), "Invalid JCB card should return false") + } + + + //Verve + func testValidVerve(){ + XCTAssertTrue(CardNumberRule().validate(VALID_VERVE_CARD), "Valid Verve Card should return true") + } + + func testInvalidVerve(){ + XCTAssertFalse(CardNumberRule().validate(INVALID_VERVE_CARD), "Invalid Verve Card should return false") + } + + + + + // MARK: Expiry Month func testCardExpiryMonthValid() { @@ -94,6 +192,22 @@ class SwiftValidatorTests: XCTestCase { XCTAssertNotNil(CardExpiryYearRule().errorMessage()) } + // MARK: CARD EXPIRY DATE + func testBlankDate(){ + XCTAssertFalse(CardExpiryRule().validate("12/1"), "Blank or incomplete date should return false") + } + func testValidCardExpiryDateFull(){ + XCTAssertTrue(CardExpiryRule().validate(VALID_DATE), "Valid card expiry date should retun true") + } + + func testInvalidCardExpiryDateFull(){ + XCTAssertFalse(CardExpiryRule().validate(INVALID_DATE), "Invalid card expiry date should return false") + } + + func testInvalidCardExpiryDateFullMessage(){ + XCTAssertNotNil(CardExpiryYearRule().errorMessage()) + } + // MARK: Required diff --git a/Validator.xcodeproj/project.pbxproj b/Validator.xcodeproj/project.pbxproj index fb03e8a..8cba319 100644 --- a/Validator.xcodeproj/project.pbxproj +++ b/Validator.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 256576A72232DFD900C8369F /* CardNumberRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 256576A62232DFD900C8369F /* CardNumberRule.swift */; }; + 256576AA2232E01500C8369F /* CardParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 256576A92232E01500C8369F /* CardParser.swift */; }; + 25FB0A2A22395AFA00373197 /* CardExpiryRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25FB0A2922395AFA00373197 /* CardExpiryRule.swift */; }; 62C1821D1C6312F5003788E7 /* ExactLengthRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C1821C1C6312F5003788E7 /* ExactLengthRule.swift */; }; 62D1AE1D1A1E6D4400E4DFF8 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62D1AE1C1A1E6D4400E4DFF8 /* AppDelegate.swift */; }; 62D1AE221A1E6D4400E4DFF8 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 62D1AE201A1E6D4400E4DFF8 /* Main.storyboard */; }; @@ -92,6 +95,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 256576A62232DFD900C8369F /* CardNumberRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardNumberRule.swift; sourceTree = ""; }; + 256576A92232E01500C8369F /* CardParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CardParser.swift; sourceTree = ""; }; + 25FB0A2922395AFA00373197 /* CardExpiryRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardExpiryRule.swift; sourceTree = ""; }; 62C1821C1C6312F5003788E7 /* ExactLengthRule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExactLengthRule.swift; sourceTree = ""; }; 62D1AE171A1E6D4400E4DFF8 /* Validator.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Validator.app; sourceTree = BUILT_PRODUCTS_DIR; }; 62D1AE1B1A1E6D4400E4DFF8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -170,6 +176,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 256576A82232E01500C8369F /* Parsers */ = { + isa = PBXGroup; + children = ( + 256576A92232E01500C8369F /* CardParser.swift */, + ); + name = Parsers; + path = SwiftValidator/Parsers; + sourceTree = SOURCE_ROOT; + }; 62D1AE0E1A1E6D4400E4DFF8 = { isa = PBXGroup; children = ( @@ -287,9 +302,12 @@ FB465CEB1B9889EA00398388 /* RegexRule.swift */, FB465CEC1B9889EA00398388 /* RequiredRule.swift */, FB465CED1B9889EA00398388 /* Rule.swift */, + 25FB0A2922395AFA00373197 /* CardExpiryRule.swift */, FB465CEE1B9889EA00398388 /* ValidationRule.swift */, FB465CEF1B9889EA00398388 /* ZipCodeRule.swift */, 62C1821C1C6312F5003788E7 /* ExactLengthRule.swift */, + 256576A62232DFD900C8369F /* CardNumberRule.swift */, + 256576A82232E01500C8369F /* Parsers */, ); path = Rules; sourceTree = ""; @@ -512,6 +530,7 @@ FB465CFA1B9889EA00398388 /* PhoneNumberRule.swift in Sources */, FB465CF51B9889EA00398388 /* FloatRule.swift in Sources */, C87F606C1E2B68C900EB8429 /* CardExpiryYearRule.swift in Sources */, + 256576AA2232E01500C8369F /* CardParser.swift in Sources */, 7CC1E4DB1C63BFA600AF013C /* HexColorRule.swift in Sources */, FB465D011B9889EA00398388 /* Validator.swift in Sources */, FB465CFE1B9889EA00398388 /* ValidationRule.swift in Sources */, @@ -519,8 +538,10 @@ FB465CF31B9889EA00398388 /* ConfirmRule.swift in Sources */, FB51E5B01CD208B8004DE696 /* Validatable.swift in Sources */, 7CC1E4D51C637C8500AF013C /* IPV4Rule.swift in Sources */, + 256576A72232DFD900C8369F /* CardNumberRule.swift in Sources */, 7CC1E4D71C637F6E00AF013C /* ISBNRule.swift in Sources */, FB465D001B9889EA00398388 /* ValidationError.swift in Sources */, + 25FB0A2A22395AFA00373197 /* CardExpiryRule.swift in Sources */, FB465CFC1B9889EA00398388 /* RequiredRule.swift in Sources */, FB465CFB1B9889EA00398388 /* RegexRule.swift in Sources */, 7CC1E4CF1C636B4500AF013C /* AlphaRule.swift in Sources */,