Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ struct AddEditCardItemView: View {
BitwardenTextField(
title: Localizations.number,
text: store.binding(
get: \.cardNumber,
get: \.formattedCardNumber,
send: AddEditCardItemAction.cardNumberChanged,
),
accessibilityIdentifier: "CardNumberEntry",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]()
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ final class AddEditItemProcessor: StateProcessor<// swiftlint:disable:this type_
case let .cardholderNameChanged(name):
state.cardItemState.cardholderName = name
case let .cardNumberChanged(number):
state.cardItemState.cardNumber = number
state.cardItemState.cardNumber = number.filter(\.isNumber)
case let .cardSecurityCodeChanged(code):
state.cardItemState.cardSecurityCode = code
case let .expirationMonthChanged(month):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1949,6 +1949,22 @@ class AddEditItemProcessorTests: BitwardenTestCase {
XCTAssertEqual(subject.state.cardItemState.cardNumber, "12345")
}

/// `receive(_:)` with `.cardFieldChanged(.cardNumberChanged)` strips spaces from the formatted
/// display value before storing, keeping `cardNumber` as digits only.
@MainActor
func test_receive_cardFieldChanged_cardNumberChanged_stripsSpaces() {
subject.receive(.cardFieldChanged(.cardNumberChanged("4111 1111 1111 1111")))
XCTAssertEqual(subject.state.cardItemState.cardNumber, "4111111111111111")
}

/// `receive(_:)` with `.cardFieldChanged(.cardNumberChanged)` strips spaces from a partial
/// formatted number correctly.
@MainActor
func test_receive_cardFieldChanged_cardNumberChanged_stripsSpacesPartial() {
subject.receive(.cardFieldChanged(.cardNumberChanged("4111 11")))
XCTAssertEqual(subject.state.cardItemState.cardNumber, "411111")
}

/// `receive(_:)` with `.cardFieldChanged(.cardSecurityCodeChanged)` with a value updates the state correctly.
@MainActor
func test_receive_cardFieldChanged_cardSecurityCodeChanged() {
Expand Down
Loading