diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/AddEditCardItemState.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/AddEditCardItemState.swift index f91fbbee73..0768097842 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/AddEditCardItemState.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/AddEditCardItemState.swift @@ -24,6 +24,9 @@ protocol AddEditCardItemState: Equatable, Sendable { /// The expiration year of the card. var expirationYear: String { get set } + /// The card number formatted with brand-appropriate digit grouping for display. + var formattedCardNumber: String { get } + /// Whether the card scanner sheet is currently presented. var isCardScannerPresented: Bool { get set } diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/AddEditCardItemView.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/AddEditCardItemView.swift index 7989b504db..33054e03a1 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/AddEditCardItemView.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/AddEditCardItemView.swift @@ -52,7 +52,7 @@ struct AddEditCardItemView: View { BitwardenTextField( title: Localizations.number, text: store.binding( - get: \.cardNumber, + get: \.formattedCardNumber, send: AddEditCardItemAction.cardNumberChanged, ), accessibilityIdentifier: "CardNumberEntry", diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardComponent.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardComponent.swift index 62d5e9e731..d57c8514b5 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardComponent.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardComponent.swift @@ -208,6 +208,65 @@ extension CardComponent.Brand { } } +extension CardComponent.Brand { + /// Returns the digit-group block sizes to use when formatting this brand for display. + /// + /// - Parameter digitCount: The number of digits in the card number being formatted. + /// Used for length-sensitive brands (Maestro, UnionPay). + /// - Returns: An array of block widths, where each element is the number of digits in one + /// space-separated group. + /// + func formattingBlocks(for digitCount: Int) -> [Int] { + switch self { + case .americanExpress: + [4, 6, 5] + case .dinersClub: + [4, 6, 4] + case .maestro: + switch digitCount { + case 13: [4, 4, 5] + case 15: [4, 6, 5] + case 19: [4, 4, 4, 4, 3] + default: [4, 4, 4, 4] + } + case .unionPay: + digitCount == 19 ? [6, 13] : [4, 4, 4, 4] + default: + [4, 4, 4, 4] + } + } + + /// Formats a card number string with brand-appropriate digit grouping. + /// + /// Partial numbers (e.g. while the user is typing) are handled greedily: blocks are filled + /// left-to-right until the digits run out. Any digits beyond the defined blocks are appended + /// as a trailing group to prevent silent data loss. + /// + /// Returns the input unchanged if it contains non-digit characters. + /// + /// - Parameter number: The raw card number containing only digit characters. + /// - Returns: A display string with spaces between digit groups. + /// + func formattedCardNumber(_ number: String) -> String { + guard !number.isEmpty, number.allSatisfy(\.isNumber) else { return number } + let digits = Array(number) + let blocks = formattingBlocks(for: digits.count) + var result = "" + var position = 0 + for (iPos, size) in blocks.enumerated() { + guard position < digits.count else { break } + if iPos > 0 { result += " " } + let end = min(position + size, digits.count) + result += String(digits[position ..< end]) + position = end + } + if position < digits.count { + result += " " + String(digits[position...]) + } + return result + } +} + extension CardComponent.Month: CaseIterable {} extension CardComponent.Month: Menuable { /// default state title for title type diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardComponentTests.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardComponentTests.swift index f317936c42..576e949837 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardComponentTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardComponentTests.swift @@ -125,4 +125,106 @@ class CardComponentBrandTests: BitwardenTestCase { func test_detect_other_emptyString() { XCTAssertEqual(CardComponent.Brand.detect(from: ""), .other) } + + // MARK: Tests – formattingBlocks(for:) + + /// `formattingBlocks(for:)` returns `[4, 6, 5]` for American Express. + func test_formattingBlocks_americanExpress() { + XCTAssertEqual(CardComponent.Brand.americanExpress.formattingBlocks(for: 15), [4, 6, 5]) + } + + /// `formattingBlocks(for:)` returns `[4, 6, 4]` for Diners Club. + func test_formattingBlocks_dinersClub() { + XCTAssertEqual(CardComponent.Brand.dinersClub.formattingBlocks(for: 14), [4, 6, 4]) + } + + /// `formattingBlocks(for:)` returns the correct blocks for each Maestro card length. + func test_formattingBlocks_maestro() { + XCTAssertEqual(CardComponent.Brand.maestro.formattingBlocks(for: 13), [4, 4, 5]) + XCTAssertEqual(CardComponent.Brand.maestro.formattingBlocks(for: 15), [4, 6, 5]) + XCTAssertEqual(CardComponent.Brand.maestro.formattingBlocks(for: 16), [4, 4, 4, 4]) + XCTAssertEqual(CardComponent.Brand.maestro.formattingBlocks(for: 19), [4, 4, 4, 4, 3]) + } + + /// `formattingBlocks(for:)` returns the correct blocks for each UnionPay card length. + func test_formattingBlocks_unionPay() { + XCTAssertEqual(CardComponent.Brand.unionPay.formattingBlocks(for: 16), [4, 4, 4, 4]) + XCTAssertEqual(CardComponent.Brand.unionPay.formattingBlocks(for: 19), [6, 13]) + } + + /// `formattingBlocks(for:)` returns `[4, 4, 4, 4]` for all standard 16-digit brands. + func test_formattingBlocks_standard16DigitBrands() { + for brand: CardComponent.Brand in [.visa, .mastercard, .discover, .jcb, .ruPay, .other] { + XCTAssertEqual(brand.formattingBlocks(for: 16), [4, 4, 4, 4], "Expected [4,4,4,4] for \(brand)") + } + } + + // MARK: Tests – formattedCardNumber(_:) + + /// `formattedCardNumber(_:)` formats a full Visa number with 4-4-4-4 grouping. + func test_formattedCardNumber_visa_full() { + XCTAssertEqual(CardComponent.Brand.visa.formattedCardNumber("4111111111111111"), "4111 1111 1111 1111") + } + + /// `formattedCardNumber(_:)` formats a partial Visa number greedily. + func test_formattedCardNumber_visa_partial() { + XCTAssertEqual(CardComponent.Brand.visa.formattedCardNumber("411111"), "4111 11") + XCTAssertEqual(CardComponent.Brand.visa.formattedCardNumber("4"), "4") + XCTAssertEqual(CardComponent.Brand.visa.formattedCardNumber(""), "") + } + + /// `formattedCardNumber(_:)` formats a full Amex number with 4-6-5 grouping. + func test_formattedCardNumber_amex_full() { + XCTAssertEqual(CardComponent.Brand.americanExpress.formattedCardNumber("378282246310005"), "3782 822463 10005") + } + + /// `formattedCardNumber(_:)` formats a partial Amex number greedily. + func test_formattedCardNumber_amex_partial() { + XCTAssertEqual(CardComponent.Brand.americanExpress.formattedCardNumber("37828"), "3782 8") + XCTAssertEqual(CardComponent.Brand.americanExpress.formattedCardNumber("3782822"), "3782 822") + } + + /// `formattedCardNumber(_:)` formats a full Diners Club number with 4-6-4 grouping. + func test_formattedCardNumber_dinersClub_full() { + XCTAssertEqual(CardComponent.Brand.dinersClub.formattedCardNumber("36000000000000"), "3600 000000 0000") + } + + /// `formattedCardNumber(_:)` formats a Maestro 13-digit number with 4-4-5 grouping. + func test_formattedCardNumber_maestro_13digits() { + XCTAssertEqual(CardComponent.Brand.maestro.formattedCardNumber("6304000000000"), "6304 0000 00000") + } + + /// `formattedCardNumber(_:)` formats a Maestro 15-digit number with 4-6-5 grouping. + func test_formattedCardNumber_maestro_15digits() { + XCTAssertEqual(CardComponent.Brand.maestro.formattedCardNumber("630400000000000"), "6304 000000 00000") + } + + /// `formattedCardNumber(_:)` formats a Maestro 16-digit number with 4-4-4-4 grouping. + func test_formattedCardNumber_maestro_16digits() { + XCTAssertEqual(CardComponent.Brand.maestro.formattedCardNumber("6304000000000000"), "6304 0000 0000 0000") + } + + /// `formattedCardNumber(_:)` formats a Maestro 19-digit number with 4-4-4-4-3 grouping. + func test_formattedCardNumber_maestro_19digits() { + XCTAssertEqual( + CardComponent.Brand.maestro.formattedCardNumber("6304000000000000000"), + "6304 0000 0000 0000 000", + ) + } + + /// `formattedCardNumber(_:)` formats a UnionPay 16-digit number with 4-4-4-4 grouping. + func test_formattedCardNumber_unionPay_16digits() { + XCTAssertEqual(CardComponent.Brand.unionPay.formattedCardNumber("6200000000000000"), "6200 0000 0000 0000") + } + + /// `formattedCardNumber(_:)` formats a UnionPay 19-digit number with 6-13 grouping. + func test_formattedCardNumber_unionPay_19digits() { + XCTAssertEqual(CardComponent.Brand.unionPay.formattedCardNumber("6200000000000000000"), "620000 0000000000000") + } + + /// `formattedCardNumber(_:)` returns the input unchanged when it contains non-digit characters. + func test_formattedCardNumber_nonDigitInput_returnsUnchanged() { + XCTAssertEqual(CardComponent.Brand.visa.formattedCardNumber("4111-1111-1111-1111"), "4111-1111-1111-1111") + XCTAssertEqual(CardComponent.Brand.visa.formattedCardNumber("abcd"), "abcd") + } } diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardItemState.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardItemState.swift index fe9c480b33..7391d037ae 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardItemState.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardItemState.swift @@ -73,11 +73,6 @@ extension CardItemState: ViewCardItemState { return brand.localizedName } - /// The formatted card number with spaces every 4 digits. - var formattedCardNumber: String { - cardNumber.formattedCreditCardNumber() - } - /// The card's formatted expiration string. var expirationString: String { var strings = [String]() @@ -90,6 +85,17 @@ extension CardItemState: ViewCardItemState { return strings.joined(separator: "/") } + /// The card number formatted with brand-appropriate digit grouping for display. + var formattedCardNumber: String { + let effectiveBrand = switch brand { + case let .custom(customBrand): + customBrand + default: + CardComponent.Brand.detect(from: cardNumber) + } + return effectiveBrand.formattedCardNumber(cardNumber) + } + /// Whether the card details section is empty. var isCardDetailsSectionEmpty: Bool { cardholderName.isEmpty diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardItemStateTests.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardItemStateTests.swift index e15bbfb0e1..f052ede5c8 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardItemStateTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditCardItem/CardItemStateTests.swift @@ -54,6 +54,54 @@ class CardItemStateTests: BitwardenTestCase { XCTAssertEqual(subject.expirationString, "2025") } + /// `formattedCardNumber` returns an empty string when the card number is empty. + func test_formattedCardNumber_emptyNumber() { + let subject = CardItemState() + XCTAssertEqual(subject.formattedCardNumber, "") + } + + /// `formattedCardNumber` auto-detects Amex from the number prefix when brand is `.default`. + func test_formattedCardNumber_defaultBrand_detectsAmex() { + var subject = CardItemState() + subject.brand = .default + subject.cardNumber = "378282246310005" + XCTAssertEqual(subject.formattedCardNumber, "3782 822463 10005") + } + + /// `formattedCardNumber` uses the Amex 4-6-5 grouping when the brand is explicitly set. + func test_formattedCardNumber_explicitAmex() { + var subject = CardItemState() + subject.brand = .custom(.americanExpress) + subject.cardNumber = "378282246310005" + XCTAssertEqual(subject.formattedCardNumber, "3782 822463 10005") + } + + /// `formattedCardNumber` uses the Visa 4-4-4-4 grouping when brand is explicitly Visa. + func test_formattedCardNumber_explicitVisa() { + var subject = CardItemState() + subject.brand = .custom(.visa) + subject.cardNumber = "4111111111111111" + XCTAssertEqual(subject.formattedCardNumber, "4111 1111 1111 1111") + } + + /// `formattedCardNumber` respects the explicitly selected brand even when the number + /// prefix would suggest a different brand. + func test_formattedCardNumber_explicitVisaBrandOverridesDetection() { + var subject = CardItemState() + subject.brand = .custom(.visa) + subject.cardNumber = "3782822463" + // Visa uses [4,4,4,4] blocks, not Amex [4,6,5] + XCTAssertEqual(subject.formattedCardNumber, "3782 8224 63") + } + + /// `formattedCardNumber` formats a partial number correctly with the auto-detected brand. + func test_formattedCardNumber_partialNumber_defaultBrand() { + var subject = CardItemState() + subject.brand = .default + subject.cardNumber = "411111" + XCTAssertEqual(subject.formattedCardNumber, "4111 11") + } + /// `isCardDetailsSectionEmpty` returns `false` if there are items to display in the card details section. func test_isCardDetailsSectionEmpty_false() { let subjectWithCardholderName = CardItemState(cardholderName: "John") diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemProcessor.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemProcessor.swift index e805c88d63..934b19ca4a 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemProcessor.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemProcessor.swift @@ -465,7 +465,7 @@ final class AddEditItemProcessor: StateProcessor