Skip to content

Commit e1e8ef1

Browse files
authored
Add CreditCardValidationRule implementation (#26)
* Add `CreditCardValidationRule` implementation * Write unit tests for `CreditCardValidationRule` * Update `README.md` * Update `CHANGELOG.md`
1 parent 4e03300 commit e1e8ef1

4 files changed

Lines changed: 243 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ All notable changes to this project will be documented in this file.
1313
- Added in Pull Request [#21](https://github.com/space-code/validator/pull/21).
1414
- Add `URLValidationRule`.
1515
- Added in Pull Request [#25](https://github.com/space-code/validator/pull/25).
16+
- Add `CreditCardValidationRule`.
17+
- Added in Pull Request [#26](https://github.com/space-code/validator/pull/26).
1618

1719
#### Updated
1820
- Update `Mintfile`

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ struct ContentView: View {
200200
| **SuffixValidationRule** | To validate whether a string contains a suffix |
201201
| **RegexValidationRule** | To validate if a pattern is matched |
202202
| **URLValidationRule** | To validate whether a string contains a URL |
203+
| **CreditCardValidationRule** | To validate whether a string has a valid credit card number |
203204

204205
## Custom Validation Rules
205206

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
//
2+
// Validator
3+
// Copyright © 2025 Space Code. All rights reserved.
4+
//
5+
6+
/// A credit card validation rule.
7+
public struct CreditCardValidationRule: IValidationRule {
8+
// MARK: Types
9+
10+
public enum CardType: String, Sendable, CaseIterable {
11+
case visa, masterCard, amex, jcb, unionPay
12+
}
13+
14+
public typealias Input = String
15+
16+
// MARK: Properties
17+
18+
public let types: [CardType]
19+
20+
/// The validation error.
21+
public let error: IValidationError
22+
23+
// MARK: Initialization
24+
25+
public init(types: [CardType] = CardType.allCases, error: IValidationError) {
26+
self.types = types
27+
self.error = error
28+
}
29+
30+
// MARK: IValidationRule
31+
32+
public func validate(input: String) -> Bool {
33+
let sanitized = input.replacingOccurrences(of: " ", with: "")
34+
35+
guard sanitized.allSatisfy(\.isNumber) else { return false }
36+
37+
guard types.contains(where: { matches(cardNumber: sanitized, type: $0) }) else { return false }
38+
39+
return isValidLuhn(sanitized)
40+
}
41+
42+
// MARK: Private
43+
44+
private func matches(cardNumber: String, type: CardType) -> Bool {
45+
switch type {
46+
case .visa:
47+
cardNumber.hasPrefix("4") && (cardNumber.count == 13 || cardNumber.count == 16 || cardNumber.count == 19)
48+
case .masterCard:
49+
(cardNumber.hasPrefix("51") || cardNumber.hasPrefix("52") ||
50+
cardNumber.hasPrefix("53") || cardNumber.hasPrefix("54") ||
51+
cardNumber.hasPrefix("55")) && cardNumber.count == 16
52+
case .amex:
53+
(cardNumber.hasPrefix("34") || cardNumber.hasPrefix("37")) && cardNumber.count == 15
54+
case .jcb:
55+
(cardNumber.hasPrefix("3528") || cardNumber.hasPrefix("3589")) && cardNumber.count == 16
56+
case .unionPay:
57+
cardNumber.hasPrefix("62") && (cardNumber.count >= 16 && cardNumber.count <= 19)
58+
}
59+
}
60+
61+
private func isValidLuhn(_ cardNumber: String) -> Bool {
62+
let reversedDigits = cardNumber.reversed().map { Int(String($0)) ?? 0 }
63+
var sum = 0
64+
65+
for (index, digit) in reversedDigits.enumerated() {
66+
if !index.isMultiple(of: 2) {
67+
let doubled = digit * 2
68+
sum += doubled > 9 ? doubled - 9 : doubled
69+
} else {
70+
sum += digit
71+
}
72+
}
73+
74+
return sum.isMultiple(of: 10)
75+
}
76+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
//
2+
// Validator
3+
// Copyright © 2025 Space Code. All rights reserved.
4+
//
5+
6+
@testable import ValidatorCore
7+
import XCTest
8+
9+
// MARK: - CreditCardValidationRuleTests
10+
11+
final class CreditCardValidationRuleTests: XCTestCase {
12+
// MARK: - Visa
13+
14+
func test_validate_visaCardValid_shouldReturnTrue() {
15+
// given
16+
let rule = CreditCardValidationRule(types: [.visa], error: String.error)
17+
let validVisa = "4111111111111111"
18+
19+
// when
20+
let isValid = rule.validate(input: validVisa)
21+
22+
// then
23+
XCTAssertTrue(isValid)
24+
}
25+
26+
func test_validate_visaCardInvalid_shouldReturnFalse() {
27+
// given
28+
let rule = CreditCardValidationRule(types: [.visa], error: String.error)
29+
let invalidVisa = "411111111111112"
30+
31+
// when
32+
let isValid = rule.validate(input: invalidVisa)
33+
34+
// then
35+
XCTAssertFalse(isValid)
36+
}
37+
38+
// MARK: - MasterCard
39+
40+
func test_validate_masterCardValid_shouldReturnTrue() {
41+
// given
42+
let rule = CreditCardValidationRule(types: [.masterCard], error: String.error)
43+
let validMaster = "5500000000000004"
44+
45+
// when
46+
let isValid = rule.validate(input: validMaster)
47+
48+
// then
49+
XCTAssertTrue(isValid)
50+
}
51+
52+
func test_validate_masterCardInvalid_shouldReturnFalse() {
53+
// given
54+
let rule = CreditCardValidationRule(types: [.masterCard], error: String.error)
55+
let invalidMaster = "550000000000"
56+
57+
// when
58+
let isValid = rule.validate(input: invalidMaster)
59+
60+
// then
61+
XCTAssertFalse(isValid)
62+
}
63+
64+
// MARK: - American Express
65+
66+
func test_validate_amexValid_shouldReturnTrue() {
67+
// given
68+
let rule = CreditCardValidationRule(types: [.amex], error: String.error)
69+
let validAmex = "340000000000009"
70+
71+
// when
72+
let isValid = rule.validate(input: validAmex)
73+
74+
// then
75+
XCTAssertTrue(isValid)
76+
}
77+
78+
func test_validate_amexInvalid_shouldReturnFalse() {
79+
// given
80+
let rule = CreditCardValidationRule(types: [.amex], error: String.error)
81+
let invalidAmex = "3400000000000099"
82+
83+
// when
84+
let isValid = rule.validate(input: invalidAmex)
85+
86+
// then
87+
XCTAssertFalse(isValid)
88+
}
89+
90+
// MARK: - JCB
91+
92+
func test_validate_jcbValid_shouldReturnTrue() {
93+
// given
94+
let rule = CreditCardValidationRule(types: [.jcb], error: String.error)
95+
let validJCB = "3528000000000007"
96+
97+
// when
98+
let isValid = rule.validate(input: validJCB)
99+
100+
// then
101+
XCTAssertTrue(isValid)
102+
}
103+
104+
// MARK: - UnionPay
105+
106+
func test_validate_unionPayValid_shouldReturnTrue() {
107+
// given
108+
let rule = CreditCardValidationRule(types: [.unionPay], error: String.error)
109+
let validUnionPay = "6221260000000000"
110+
111+
// when
112+
let isValid = rule.validate(input: validUnionPay)
113+
114+
// then
115+
XCTAssertTrue(isValid)
116+
}
117+
118+
// MARK: - Multiple Types
119+
120+
func test_validate_multipleTypesValid_shouldReturnTrue() {
121+
// given
122+
let rule = CreditCardValidationRule(types: [.visa, .amex, .masterCard], error: String.error)
123+
let validVisa = "4111111111111111"
124+
125+
// when
126+
let isValid = rule.validate(input: validVisa)
127+
128+
// then
129+
XCTAssertTrue(isValid)
130+
}
131+
132+
// MARK: - Unknown Card Type
133+
134+
func test_validate_unknownCardType_shouldReturnFalse() {
135+
// given
136+
let rule = CreditCardValidationRule(types: [.visa], error: String.error)
137+
let randomCard = "9999999999999999"
138+
139+
// when
140+
let isValid = rule.validate(input: randomCard)
141+
142+
// then
143+
XCTAssertFalse(isValid)
144+
}
145+
146+
// MARK: - Empty Input
147+
148+
func test_validate_emptyString_shouldReturnFalse() {
149+
// given
150+
let rule = CreditCardValidationRule(types: [.visa], error: String.error)
151+
152+
// when
153+
let isValid = rule.validate(input: "")
154+
155+
// then
156+
XCTAssertFalse(isValid)
157+
}
158+
}
159+
160+
// MARK: Constants
161+
162+
private extension String {
163+
static let error = "Invalid card number"
164+
}

0 commit comments

Comments
 (0)