diff --git a/StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj b/StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj index 50c0ebfd83b..69060e9b620 100644 --- a/StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj +++ b/StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj @@ -163,8 +163,6 @@ 6141C5072C0A47A700E81735 /* RightAccessoryButtonTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6141C5062C0A47A700E81735 /* RightAccessoryButtonTest.swift */; }; 614A8AE72BE53C6900E8688B /* SavedPaymentMethodManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6103F2BD2BE53737002D67F8 /* SavedPaymentMethodManagerTest.swift */; }; 6151DDC02B14FDCF00ED4F7E /* UpdatePaymentMethodViewControllerSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6151DDBF2B14FDCF00ED4F7E /* UpdatePaymentMethodViewControllerSnapshotTests.swift */; }; - 615AADAF2CB97A2000D0AED9 /* STPCardValidator+BrandFiltering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615AADAE2CB97A2000D0AED9 /* STPCardValidator+BrandFiltering.swift */; }; - 615AADB12CB97A9400D0AED9 /* STPCardValidator+BrandFilteringTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615AADB02CB97A9400D0AED9 /* STPCardValidator+BrandFilteringTest.swift */; }; 615C2C502CBDBA61003F0173 /* EmbeddedFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615C2C4F2CBDBA61003F0173 /* EmbeddedFormViewController.swift */; }; 6180A5C12C8222A9009D1536 /* EmbeddedPaymentMethodsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6180A5C02C8222A9009D1536 /* EmbeddedPaymentMethodsView.swift */; }; 6180A5C72C824377009D1536 /* RadioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6180A5C62C824377009D1536 /* RadioButton.swift */; }; @@ -590,8 +588,6 @@ 614068E12CB0BF10003D2F12 /* EmbeddedPaymentMethodsViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddedPaymentMethodsViewTests.swift; sourceTree = ""; }; 6141C5062C0A47A700E81735 /* RightAccessoryButtonTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightAccessoryButtonTest.swift; sourceTree = ""; }; 6151DDBF2B14FDCF00ED4F7E /* UpdatePaymentMethodViewControllerSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePaymentMethodViewControllerSnapshotTests.swift; sourceTree = ""; }; - 615AADAE2CB97A2000D0AED9 /* STPCardValidator+BrandFiltering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPCardValidator+BrandFiltering.swift"; sourceTree = ""; }; - 615AADB02CB97A9400D0AED9 /* STPCardValidator+BrandFilteringTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPCardValidator+BrandFilteringTest.swift"; sourceTree = ""; }; 615C2C4F2CBDBA61003F0173 /* EmbeddedFormViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddedFormViewController.swift; sourceTree = ""; }; 617C44F9338DE2E93E318291 /* PayWithLinkWebController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayWithLinkWebController.swift; sourceTree = ""; }; 6180A5C02C8222A9009D1536 /* EmbeddedPaymentMethodsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddedPaymentMethodsView.swift; sourceTree = ""; }; @@ -1071,7 +1067,6 @@ 2C59FD8C17CA1D740BCAFA4D /* STPPaymentIntentShippingDetailsParams+PaymentSheet.swift */, 61D842882CADE4B9009D2D51 /* PaymentElementConfiguration.swift */, 61C87E1A2CB818ED001B7DA9 /* CardBrandFilter.swift */, - 615AADAE2CB97A2000D0AED9 /* STPCardValidator+BrandFiltering.swift */, ); path = PaymentSheet; sourceTree = ""; @@ -1719,7 +1714,6 @@ E09C073021CE89593466548C /* STPCardBrandChoiceTest.swift */, E198795E4D8677B6ECFCEEDC /* STPElementsSessionTest.swift */, A39F7EBA2E9E3CE55E7AADC9 /* STPFixtures+PaymentSheet.swift */, - 615AADB02CB97A9400D0AED9 /* STPCardValidator+BrandFilteringTest.swift */, AC0DBA7D63BAD0182695E436 /* Stubbed */, D00C7F5905759525C9BF8BD4 /* TextFieldElement+CardTest.swift */, 6151DDBF2B14FDCF00ED4F7E /* UpdatePaymentMethodViewControllerSnapshotTests.swift */, @@ -2036,7 +2030,6 @@ 041E3F2DFDFD8FA7D3353CDB /* PaymentSheetSnapshotTests.swift in Sources */, 1330B53140DE10F641A82099 /* PaymentSheetViewControllerSnapshotTests.swift in Sources */, 614A8AE72BE53C6900E8688B /* SavedPaymentMethodManagerTest.swift in Sources */, - 615AADB12CB97A9400D0AED9 /* STPCardValidator+BrandFilteringTest.swift in Sources */, 619AF08A2BF56FC000D1C981 /* VerticalSavedPaymentMethodsViewControllerTests.swift in Sources */, 47AD56A9889DF5EFBBA9CEFB /* PollingViewTests.swift in Sources */, 61ED657C2D41AE1A00DD5E92 /* PaymentSheet+PaymentMethodAvailabilityTest.swift in Sources */, @@ -2242,7 +2235,6 @@ 49803444CD948F1ED28FF021 /* PaymentSheetFormFactory+FormSpec.swift in Sources */, B61E2C202C5C44FE0045B5CF /* PaymentSheetAnalyticsHelper.swift in Sources */, C5E3750BBCA700CF364F7578 /* PaymentSheetFormFactory+OXXO.swift in Sources */, - 615AADAF2CB97A2000D0AED9 /* STPCardValidator+BrandFiltering.swift in Sources */, 31AD3BE72B0C2D080080C800 /* UIApplication+StripePaymentSheet.swift in Sources */, 9806232CE48077E35B04FF98 /* PaymentSheetFormFactory+UPI.swift in Sources */, 401128A8DDC7B6E3CBB4381E /* PaymentSheetFormFactory.swift in Sources */, diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSavedPaymentMethodsCollectionViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSavedPaymentMethodsCollectionViewController.swift index fba4a6b6ac7..2548962ff97 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSavedPaymentMethodsCollectionViewController.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSavedPaymentMethodsCollectionViewController.swift @@ -444,7 +444,7 @@ extension CustomerSavedPaymentMethodsCollectionViewController: PaymentOptionCell hostedSurface: .customerSheet, cardBrandFilter: savedPaymentMethodsConfiguration.cardBrandFilter, canRemove: configuration.paymentMethodRemove && (savedPaymentMethods.count > 1 || configuration.allowsRemovalOfLastSavedPaymentMethod), - canUpdateCardBrand: paymentMethod.isCoBrandedCard && cbcEligible) + isCBCEligible: paymentMethod.isCoBrandedCard && cbcEligible) let editVc = UpdatePaymentMethodViewController( removeSavedPaymentMethodMessage: savedPaymentMethodsConfiguration.removeSavedPaymentMethodMessage, isTestMode: configuration.isTestMode, diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Elements/CardSection/CardSectionElement.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Elements/CardSection/CardSectionElement.swift index e35e3413b03..642a44dbde2 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Elements/CardSection/CardSectionElement.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Elements/CardSection/CardSectionElement.swift @@ -220,7 +220,7 @@ final class CardSectionElement: ContainerElement { // Clear any previously fetched card brands from the dropdown if !self.cardBrands.isEmpty { self.cardBrands = Set() - cardBrandDropDown?.update(items: DropdownFieldElement.items(from: self.cardBrands, theme: self.theme)) + cardBrandDropDown?.update(items: DropdownFieldElement.items(from: self.cardBrands, disallowedCardBrands: Set(), theme: self.theme)) self.panElement.setText(self.panElement.text) // Hack to get the accessory view to update } return @@ -228,7 +228,7 @@ final class CardSectionElement: ContainerElement { var fetchedCardBrands = Set() let hadBrands = !cardBrands.isEmpty - STPCardValidator.possibleBrands(forNumber: panElement.text, with: cardBrandFilter) { [weak self] result in + STPCardValidator.possibleBrands(forNumber: panElement.text) { [weak self] result in guard let self = self else { return } switch result { case .success(let brands): @@ -245,25 +245,51 @@ final class CardSectionElement: ContainerElement { if self.cardBrands != fetchedCardBrands { self.cardBrands = fetchedCardBrands - cardBrandDropDown.update(items: DropdownFieldElement.items(from: fetchedCardBrands, theme: self.theme)) - - // If we didn't previously have brands but now have them select based on merchant preference - // Select the first brand in the fetched brands that appears earliest in the merchants preferred networks - if !hadBrands, - let preferredNetworks = self.preferredNetworks, - let brandToSelect = preferredNetworks.first(where: { fetchedCardBrands.contains($0) }), - let indexToSelect = cardBrandDropDown.items.firstIndex(where: { $0.rawData == STPCardBrandUtilities.apiValue(from: brandToSelect) }) { + let disallowedCardBrands = fetchedCardBrands.filter { !self.cardBrandFilter.isAccepted(cardBrand: $0) } + + cardBrandDropDown.update(items: DropdownFieldElement.items( + from: fetchedCardBrands, + disallowedCardBrands: disallowedCardBrands, + theme: self.theme + )) + + // Prioritize merchant preference if we did not have brands prior to calling .possibleBrands, otherwise use default logic + if !hadBrands, let indexToSelect = hasPreferredBrandIndex(fetchedCardBrands: fetchedCardBrands, disallowedCardBrands: disallowedCardBrands, cardBrandDropDown: cardBrandDropDown) { + cardBrandDropDown.select(index: indexToSelect, shouldAutoAdvance: false) + } else if let indexToSelect = useDefaultSelectionLogic(disallowedCardBrands: disallowedCardBrands, cardBrandDropDown: cardBrandDropDown) { cardBrandDropDown.select(index: indexToSelect, shouldAutoAdvance: false) - } else if cardBrands.count == 1 && self.cardBrandFilter != .default { - // If we only fetched one card brand auto select it, 1 index due to 0 index being the placeholder. - // This case typically only occurs when card brand filtering is used with CBC and one of the fetched brands is filtered out. - cardBrandDropDown.select(index: 1, shouldAutoAdvance: false) } self.panElement.setText(self.panElement.text) // Hack to get the accessory view to update } } } + + // Select the first brand in the fetched brands that appears earliest in the merchants preferred networks + func hasPreferredBrandIndex(fetchedCardBrands: Set, disallowedCardBrands: Set, cardBrandDropDown: DropdownFieldElement) -> Int? { + guard let preferredNetworks = self.preferredNetworks, + let brandToSelect = preferredNetworks.first(where: { fetchedCardBrands.contains($0) && !disallowedCardBrands.contains($0) }), + let indexToSelect = cardBrandDropDown.items.firstIndex(where: { $0.rawData == STPCardBrandUtilities.apiValue(from: brandToSelect) }) else { + return nil + } + + return indexToSelect + + } + + // If we only fetched one card brand that is not disallowed, auto select it. + // This case typically only occurs when card brand filtering is used with CBC and one of the fetched brands is filtered out. + func useDefaultSelectionLogic(disallowedCardBrands: Set, cardBrandDropDown: DropdownFieldElement) -> Int? { + let validBrandSelections = cardBrandDropDown.items.filter { !$0.isPlaceholder && !$0.isDisabled } + guard validBrandSelections.count == 1, + !disallowedCardBrands.isEmpty, + let firstItem = validBrandSelections.first, + let indexToSelect = cardBrandDropDown.items.firstIndex(where: { $0.rawData == firstItem.rawData }) else { + return nil + } + + return indexToSelect + } } // MARK: - Helpers diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement+Internal.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement+Internal.swift index 4172954dad4..243383b5023 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement+Internal.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement+Internal.swift @@ -164,7 +164,7 @@ extension EmbeddedPaymentElement: EmbeddedPaymentMethodsViewDelegate { hostedSurface: .paymentSheet, cardBrandFilter: configuration.cardBrandFilter, canRemove: configuration.allowsRemovalOfLastSavedPaymentMethod && elementsSession.allowsRemovalOfPaymentMethodsForPaymentSheet(), - canUpdateCardBrand: paymentMethod.isCoBrandedCard && elementsSession.isCardBrandChoiceEligible, + isCBCEligible: paymentMethod.isCoBrandedCard && elementsSession.isCardBrandChoiceEligible, allowsSetAsDefaultPM: configuration.allowsSetAsDefaultPM, isDefault: paymentMethod == elementsSession.customer?.getDefaultPaymentMethod() ) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/STPCardValidator+BrandFiltering.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/STPCardValidator+BrandFiltering.swift deleted file mode 100644 index 626275feee9..00000000000 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/STPCardValidator+BrandFiltering.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// STPCardValidator+BrandFiltering.swift -// StripePaymentSheet -// -// Created by Nick Porter on 10/11/24. -// - -import Foundation - -extension STPCardValidator { - class func possibleBrands(forNumber cardNumber: String, - with cardBrandFilter: CardBrandFilter, - completion: @escaping (Result, Error>) -> Void) { - possibleBrands(forNumber: cardNumber) { result in - completion(result.map { brands in - brands.filter { cardBrandFilter.isAccepted(cardBrand: $0) } - }) - } - } -} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Saved Payment Method Screen/SavedPaymentOptionsViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Saved Payment Method Screen/SavedPaymentOptionsViewController.swift index 2f1d8637a31..21dd11ed23d 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Saved Payment Method Screen/SavedPaymentOptionsViewController.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Saved Payment Method Screen/SavedPaymentOptionsViewController.swift @@ -589,7 +589,7 @@ extension SavedPaymentOptionsViewController: PaymentOptionCellDelegate { hostedSurface: .paymentSheet, cardBrandFilter: paymentSheetConfiguration.cardBrandFilter, canRemove: configuration.allowsRemovalOfPaymentMethods && (savedPaymentMethods.count > 1 || configuration.allowsRemovalOfLastSavedPaymentMethod), - canUpdateCardBrand: paymentMethod.isCoBrandedCard && cbcEligible, + isCBCEligible: paymentMethod.isCoBrandedCard && cbcEligible, allowsSetAsDefaultPM: configuration.allowsSetAsDefaultPM, isDefault: paymentMethod == elementsSession.customer?.getDefaultPaymentMethod() ) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Saved Payment Method Screen/Vertical Saved Payment Method Screen/VerticalSavedPaymentMethodsViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Saved Payment Method Screen/Vertical Saved Payment Method Screen/VerticalSavedPaymentMethodsViewController.swift index ddee6684bcc..52119084dd6 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Saved Payment Method Screen/Vertical Saved Payment Method Screen/VerticalSavedPaymentMethodsViewController.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Saved Payment Method Screen/Vertical Saved Payment Method Screen/VerticalSavedPaymentMethodsViewController.swift @@ -333,7 +333,7 @@ extension VerticalSavedPaymentMethodsViewController: SavedPaymentMethodRowButton hostedSurface: .paymentSheet, cardBrandFilter: configuration.cardBrandFilter, canRemove: canRemovePaymentMethods, - canUpdateCardBrand: paymentMethod.isCoBrandedCard && isCBCEligible, + isCBCEligible: paymentMethod.isCoBrandedCard && isCBCEligible, allowsSetAsDefaultPM: configuration.allowsSetAsDefaultPM, isDefault: paymentMethod == elementsSession.customer?.getDefaultPaymentMethod() ) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/SavedPaymentMethodFormFactory/SavedPaymentMethodFormFactory+Card.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/SavedPaymentMethodFormFactory/SavedPaymentMethodFormFactory+Card.swift index 4a6c4bda81b..efc57d2ea6e 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/SavedPaymentMethodFormFactory/SavedPaymentMethodFormFactory+Card.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/SavedPaymentMethodFormFactory/SavedPaymentMethodFormFactory+Card.swift @@ -15,9 +15,12 @@ import UIKit extension SavedPaymentMethodFormFactory { func makeCard() -> Element { let cardBrandDropDown: DropdownFieldElement? = { - guard viewModel.canUpdateCardBrand else { return nil } - let cardBrands = viewModel.paymentMethod.card?.networks?.available.map({ STPCard.brand(from: $0) }).filter { viewModel.cardBrandFilter.isAccepted(cardBrand: $0) } ?? [] + guard viewModel.isCBCEligible else { return nil } + let cardBrands = viewModel.paymentMethod.card?.networks?.available.map({ STPCard.brand(from: $0) }) ?? [] + let disallowedCardBrands = cardBrands.filter{ !viewModel.cardBrandFilter.isAccepted(cardBrand: $0) } + let cardBrandDropDown = DropdownFieldElement.makeCardBrandDropdown(cardBrands: Set(cardBrands), + disallowedCardBrands: Set(disallowedCardBrands), theme: viewModel.appearance.asElementsTheme, includePlaceholder: false) { [weak self] in guard let self = self else { return } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetVerticalViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetVerticalViewController.swift index c3330e0b4a2..8352c57391a 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetVerticalViewController.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetVerticalViewController.swift @@ -618,7 +618,7 @@ class PaymentSheetVerticalViewController: UIViewController, FlowControllerViewCo hostedSurface: .paymentSheet, cardBrandFilter: configuration.cardBrandFilter, canRemove: configuration.allowsRemovalOfLastSavedPaymentMethod && elementsSession.allowsRemovalOfPaymentMethodsForPaymentSheet(), - canUpdateCardBrand: paymentMethod.isCoBrandedCard && elementsSession.isCardBrandChoiceEligible, + isCBCEligible: paymentMethod.isCoBrandedCard && elementsSession.isCardBrandChoiceEligible, allowsSetAsDefaultPM: configuration.allowsSetAsDefaultPM, isDefault: paymentMethod == elementsSession.customer?.getDefaultPaymentMethod() ) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/UpdatePaymentMethodViewModel.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/UpdatePaymentMethodViewModel.swift index 2a984dd462f..2ff93f60bbe 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/UpdatePaymentMethodViewModel.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/UpdatePaymentMethodViewModel.swift @@ -18,7 +18,7 @@ class UpdatePaymentMethodViewModel { let hostedSurface: HostedSurface let cardBrandFilter: CardBrandFilter let canRemove: Bool - let canUpdateCardBrand: Bool + let isCBCEligible: Bool let allowsSetAsDefaultPM: Bool let isDefault: Bool @@ -32,6 +32,14 @@ class UpdatePaymentMethodViewModel { var hasUpdates: Bool { return hasChangedCardBrand || hasChangedDefaultPaymentMethodCheckbox } + var canUpdateCardBrand: Bool { + guard paymentMethod.type == .card else { + return false + } + let availableBrands = paymentMethod.card?.networks?.available.map {$0.toCardBrand }.compactMap{ $0 } + let filteredCardBrands = availableBrands?.filter {cardBrandFilter.isAccepted(cardBrand: $0)} ?? [] + return isCBCEligible && filteredCardBrands.count > 1 + } lazy var header: String = { switch paymentMethod.type { @@ -59,7 +67,7 @@ class UpdatePaymentMethodViewModel { } }() - init(paymentMethod: STPPaymentMethod, appearance: PaymentSheet.Appearance, hostedSurface: HostedSurface, cardBrandFilter: CardBrandFilter = .default, canRemove: Bool, canUpdateCardBrand: Bool, allowsSetAsDefaultPM: Bool = false, isDefault: Bool = false) { + init(paymentMethod: STPPaymentMethod, appearance: PaymentSheet.Appearance, hostedSurface: HostedSurface, cardBrandFilter: CardBrandFilter = .default, canRemove: Bool, isCBCEligible: Bool, allowsSetAsDefaultPM: Bool = false, isDefault: Bool = false) { guard PaymentSheet.supportedSavedPaymentMethods.contains(paymentMethod.type) else { fatalError("Unsupported payment type \(paymentMethod.type) in UpdatePaymentMethodViewModel") } @@ -68,7 +76,7 @@ class UpdatePaymentMethodViewModel { self.hostedSurface = hostedSurface self.cardBrandFilter = cardBrandFilter self.canRemove = canRemove - self.canUpdateCardBrand = canUpdateCardBrand + self.isCBCEligible = isCBCEligible self.allowsSetAsDefaultPM = allowsSetAsDefaultPM self.isDefault = isDefault } diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/STPCardValidator+BrandFilteringTest.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/STPCardValidator+BrandFilteringTest.swift deleted file mode 100644 index a14069a9d38..00000000000 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/STPCardValidator+BrandFilteringTest.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// STPCardValidator+BrandFilteringTest.swift -// StripePaymentSheetTests -// -// Created by Nick Porter on 10/11/24. -// - -@testable @_spi(CardBrandFilteringBeta) import StripePaymentSheet -import StripePaymentsTestUtils -import XCTest - -class STPCardValidator_BrandFilteringTest: XCTestCase { - - let testVisaCoBrandedNumber = "49730197" - - func testPossibleBrands_allAllowed() { - STPAPIClient.shared.publishableKey = STPTestingDefaultPublishableKey - let expectation = expectation(description: "Visa/CB") - - STPCardValidator.possibleBrands(forNumber: testVisaCoBrandedNumber, with: .default) { result in - let brands = try! result.get() - XCTAssertEqual(brands, [.cartesBancaires, .visa]) - expectation.fulfill() - } - - wait(for: [expectation], timeout: 10.0) - } - - func testPossibleBrands_visaNotAllowed() { - STPAPIClient.shared.publishableKey = STPTestingDefaultPublishableKey - let expectation = expectation(description: "Visa/CB") - - let filter = CardBrandFilter(cardBrandAcceptance: .disallowed(brands: [.visa])) - STPCardValidator.possibleBrands(forNumber: testVisaCoBrandedNumber, with: filter) { result in - let brands = try! result.get() - XCTAssertEqual(brands, [.cartesBancaires]) - expectation.fulfill() - } - - wait(for: [expectation], timeout: 10.0) - } - - func testPossibleBrands_visaOnlyAllowed() { - STPAPIClient.shared.publishableKey = STPTestingDefaultPublishableKey - let expectation = expectation(description: "Visa/CB") - - let filter = CardBrandFilter(cardBrandAcceptance: .allowed(brands: [.visa])) - STPCardValidator.possibleBrands(forNumber: testVisaCoBrandedNumber, with: filter) { result in - let brands = try! result.get() - XCTAssertEqual(brands, [.visa]) - expectation.fulfill() - } - - wait(for: [expectation], timeout: 10.0) - } - -} diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/UpdatePaymentMethodViewControllerSnapshotTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/UpdatePaymentMethodViewControllerSnapshotTests.swift index 97c9b449e3d..069758d30be 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/UpdatePaymentMethodViewControllerSnapshotTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/UpdatePaymentMethodViewControllerSnapshotTests.swift @@ -6,46 +6,46 @@ // import StripeCoreTestUtils -@_spi(STP) @testable import StripePaymentSheet +@_spi(STP) @_spi(CardBrandFilteringBeta) @testable import StripePaymentSheet @testable import StripePaymentsTestUtils import XCTest final class UpdatePaymentMethodViewControllerSnapshotTests: STPSnapshotTestCase { func test_UpdatePaymentMethodViewControllerDarkMode() { - _test_UpdatePaymentMethodViewController(paymentMethodType: .card, darkMode: true, canUpdateCardBrand: true) + _test_UpdatePaymentMethodViewController(paymentMethodType: .card, darkMode: true, isCBCEligible: true) } func test_UpdatePaymentMethodViewControllerLightMode() { - _test_UpdatePaymentMethodViewController(paymentMethodType: .card, darkMode: false, canUpdateCardBrand: true) + _test_UpdatePaymentMethodViewController(paymentMethodType: .card, darkMode: false, isCBCEligible: true) } func test_UpdatePaymentMethodViewControllerAppearance() { - _test_UpdatePaymentMethodViewController(paymentMethodType: .card, darkMode: false, appearance: ._testMSPaintTheme, canUpdateCardBrand: true) + _test_UpdatePaymentMethodViewController(paymentMethodType: .card, darkMode: false, appearance: ._testMSPaintTheme, isCBCEligible: true) } func test_EmbeddedSingleCard_UpdatePaymentMethodViewControllerDarkMode() { - _test_UpdatePaymentMethodViewController(paymentMethodType: .card, darkMode: true, isEmbeddedSingle: true, canUpdateCardBrand: true) + _test_UpdatePaymentMethodViewController(paymentMethodType: .card, darkMode: true, isEmbeddedSingle: true, isCBCEligible: true) } func test_EmbeddedSingleCard_UpdatePaymentMethodViewControllerLightMode() { - _test_UpdatePaymentMethodViewController(paymentMethodType: .card, darkMode: false, isEmbeddedSingle: true, canUpdateCardBrand: true) + _test_UpdatePaymentMethodViewController(paymentMethodType: .card, darkMode: false, isEmbeddedSingle: true, isCBCEligible: true) } func test_EmbeddedSingleCard_UpdatePaymentMethodViewControllerAppearance() { - _test_UpdatePaymentMethodViewController(paymentMethodType: .card, darkMode: false, isEmbeddedSingle: true, appearance: ._testMSPaintTheme, canUpdateCardBrand: true) + _test_UpdatePaymentMethodViewController(paymentMethodType: .card, darkMode: false, isEmbeddedSingle: true, appearance: ._testMSPaintTheme, isCBCEligible: true) } func test_UpdatePaymentMethodViewControllerExpiredCard() { - _test_UpdatePaymentMethodViewController(paymentMethodType: .card, darkMode: false, canUpdateCardBrand: true, expired: true) + _test_UpdatePaymentMethodViewController(paymentMethodType: .card, darkMode: false, isCBCEligible: true, expired: true) } func test_UpdatePaymentMethodViewControllerSetAsDefaultCard() { - _test_UpdatePaymentMethodViewController(paymentMethodType: .card, darkMode: false, canUpdateCardBrand: true, allowsSetAsDefaultPM: true) + _test_UpdatePaymentMethodViewController(paymentMethodType: .card, darkMode: false, isCBCEligible: true, allowsSetAsDefaultPM: true) } func test_UpdatePaymentMethodViewControllerDefaultCard() { - _test_UpdatePaymentMethodViewController(paymentMethodType: .card, darkMode: false, canUpdateCardBrand: true, allowsSetAsDefaultPM: true, isDefault: true) + _test_UpdatePaymentMethodViewController(paymentMethodType: .card, darkMode: false, isCBCEligible: true, allowsSetAsDefaultPM: true, isDefault: true) } func test_UpdatePaymentMethodViewControllerRemoveOnlyCard() { @@ -111,8 +111,13 @@ final class UpdatePaymentMethodViewControllerSnapshotTests: STPSnapshotTestCase func test_EmbeddedSingleSEPADebit_UpdatePaymentMethodViewControllerAppearance() { _test_UpdatePaymentMethodViewController(paymentMethodType: .SEPADebit, darkMode: false, isEmbeddedSingle: true, appearance: ._testMSPaintTheme) } + + func test_UpdatePaymentMethodViewControllerLightMode_blockedBrands() { + let cardBrandFilter = CardBrandFilter(cardBrandAcceptance: .disallowed(brands: [.amex])) + _test_UpdatePaymentMethodViewController(paymentMethodType: .card, darkMode: false, isCBCEligible: true, cardBrandFilter: cardBrandFilter) + } - func _test_UpdatePaymentMethodViewController(paymentMethodType: STPPaymentMethodType, darkMode: Bool, isEmbeddedSingle: Bool = false, appearance: PaymentSheet.Appearance = .default, canRemove: Bool = true, canUpdateCardBrand: Bool = false, expired: Bool = false, allowsSetAsDefaultPM: Bool = false, isDefault: Bool = false) { + func _test_UpdatePaymentMethodViewController(paymentMethodType: STPPaymentMethodType, darkMode: Bool, isEmbeddedSingle: Bool = false, appearance: PaymentSheet.Appearance = .default, canRemove: Bool = true, isCBCEligible: Bool = false, expired: Bool = false, allowsSetAsDefaultPM: Bool = false, isDefault: Bool = false, cardBrandFilter: CardBrandFilter = .default) { let paymentMethod: STPPaymentMethod = { switch paymentMethodType { case .card: @@ -120,7 +125,7 @@ final class UpdatePaymentMethodViewControllerSnapshotTests: STPSnapshotTestCase return STPFixtures.paymentMethod() } else { - if canUpdateCardBrand { + if isCBCEligible { return STPPaymentMethod._testCardCoBranded() } else { @@ -138,8 +143,9 @@ final class UpdatePaymentMethodViewControllerSnapshotTests: STPSnapshotTestCase let updateViewModel = UpdatePaymentMethodViewModel(paymentMethod: paymentMethod, appearance: appearance, hostedSurface: .paymentSheet, + cardBrandFilter: cardBrandFilter, canRemove: canRemove, - canUpdateCardBrand: canUpdateCardBrand, + isCBCEligible: isCBCEligible, allowsSetAsDefaultPM: allowsSetAsDefaultPM, isDefault: isDefault ) diff --git a/StripePaymentsUI/StripePaymentsUI/Source/Internal/Categories/STPCardBrand+PaymentsUI.swift b/StripePaymentsUI/StripePaymentsUI/Source/Internal/Categories/STPCardBrand+PaymentsUI.swift index d9c3a7e43eb..09da4fc1881 100644 --- a/StripePaymentsUI/StripePaymentsUI/Source/Internal/Categories/STPCardBrand+PaymentsUI.swift +++ b/StripePaymentsUI/StripePaymentsUI/Source/Internal/Categories/STPCardBrand+PaymentsUI.swift @@ -7,6 +7,7 @@ import Foundation @_spi(STP) import StripeUICore +@_spi(STP) import StripeCore import UIKit extension STPCardBrand { @@ -26,17 +27,21 @@ extension STPCardBrand { return NSAttributedString(attachment: brandImageAttachment) } - func cardBrandItem(theme: ElementsAppearance = .default, maxWidth: CGFloat? = nil) -> DropdownFieldElement.DropdownItem { + func cardBrandItem(theme: ElementsAppearance = .default, isDisallowed: Bool, maxWidth: CGFloat? = nil) -> DropdownFieldElement.DropdownItem { let brandName = STPCardBrandUtilities.stringFrom(self) ?? "" let displayText = NSMutableAttributedString(attributedString: brandIconAttributedString(theme: theme)) displayText.append(NSAttributedString(string: " " + brandName)) - + if isDisallowed { + displayText.append(NSAttributedString(string: " \(String.Localized.brand_not_accepted)")) + } + return DropdownFieldElement.DropdownItem( pickerDisplayName: displayText, labelDisplayName: brandIconAttributedString(theme: theme, maxWidth: maxWidth), accessibilityValue: brandName, - rawData: STPCardBrandUtilities.apiValue(from: self) + rawData: STPCardBrandUtilities.apiValue(from: self), + isDisabled: isDisallowed ) } } diff --git a/StripePaymentsUI/StripePaymentsUI/Source/Internal/UI/Elements/DropDownFieldElement+CardBrand.swift b/StripePaymentsUI/StripePaymentsUI/Source/Internal/UI/Elements/DropDownFieldElement+CardBrand.swift index c97de51278e..965f69c88ff 100644 --- a/StripePaymentsUI/StripePaymentsUI/Source/Internal/UI/Elements/DropDownFieldElement+CardBrand.swift +++ b/StripePaymentsUI/StripePaymentsUI/Source/Internal/UI/Elements/DropDownFieldElement+CardBrand.swift @@ -14,6 +14,7 @@ import UIKit extension DropdownFieldElement { @_spi(STP) public static func makeCardBrandDropdown(cardBrands: Set = Set(), + disallowedCardBrands: Set = Set(), theme: ElementsAppearance = .default, includePlaceholder: Bool = true, maxWidth: CGFloat? = nil, @@ -21,7 +22,7 @@ extension DropdownFieldElement { didPresent: DropdownFieldElement.DidPresent? = nil, didTapClose: DropdownFieldElement.DidTapClose? = nil) -> DropdownFieldElement { let dropDown = DropdownFieldElement( - items: items(from: cardBrands, theme: theme, includePlaceholder: includePlaceholder, maxWidth: maxWidth), + items: items(from: cardBrands, disallowedCardBrands: disallowedCardBrands, theme: theme, includePlaceholder: includePlaceholder, maxWidth: maxWidth), defaultIndex: 0, label: nil, theme: theme, @@ -34,7 +35,7 @@ extension DropdownFieldElement { return dropDown } - @_spi(STP) public static func items(from cardBrands: Set, theme: ElementsAppearance, includePlaceholder: Bool = true, maxWidth: CGFloat? = nil) -> [DropdownItem] { + @_spi(STP) public static func items(from cardBrands: Set, disallowedCardBrands: Set, theme: ElementsAppearance, includePlaceholder: Bool = true, maxWidth: CGFloat? = nil) -> [DropdownItem] { let placeholderItem = DropdownItem( pickerDisplayName: NSAttributedString(string: .Localized.card_brand_dropdown_placeholder), labelDisplayName: STPCardBrand.unknown.brandIconAttributedString(theme: theme, maxWidth: maxWidth), @@ -43,7 +44,8 @@ extension DropdownFieldElement { isPlaceholder: true ) - let cardBrandItems = cardBrands.sorted().map { $0.cardBrandItem(theme: theme, maxWidth: maxWidth) } + let cardBrandItems = cardBrands.sorted().map { $0.cardBrandItem(theme: theme, isDisallowed: disallowedCardBrands.contains($0), maxWidth: maxWidth) } + return includePlaceholder ? [placeholderItem] + cardBrandItems : cardBrandItems } } diff --git a/StripeUICore/StripeUICore/Resources/Localizations/en.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/en.lproj/Localizable.strings index 375de00fb5f..8e310b7d8dc 100644 --- a/StripeUICore/StripeUICore/Resources/Localizations/en.lproj/Localizable.strings +++ b/StripeUICore/StripeUICore/Resources/Localizations/en.lproj/Localizable.strings @@ -1,6 +1,9 @@ /* The label of a text field that is optional. For example, 'Email (optional)' or 'Name (optional) */ "%@ (optional)" = "%@ (optional)"; +/* Shown in a dropdown picker next to a card brand that is not accepted by a merchant. E.g. \"Visa (not accepted)\" */ +"(not accepted)" = "(not accepted)"; + /* Caption for account number */ "Account number" = "Account number"; diff --git a/StripeUICore/StripeUICore/Source/Elements/DropdownFieldElement.swift b/StripeUICore/StripeUICore/Source/Elements/DropdownFieldElement.swift index 7245df2f55e..8a2957bf9ba 100644 --- a/StripeUICore/StripeUICore/Source/Elements/DropdownFieldElement.swift +++ b/StripeUICore/StripeUICore/Source/Elements/DropdownFieldElement.swift @@ -21,20 +21,22 @@ import UIKit public typealias DidTapClose = () -> Void public struct DropdownItem { - public init(pickerDisplayName: NSAttributedString, labelDisplayName: NSAttributedString, accessibilityValue: String, rawData: String, isPlaceholder: Bool = false) { + public init(pickerDisplayName: NSAttributedString, labelDisplayName: NSAttributedString, accessibilityValue: String, rawData: String, isPlaceholder: Bool = false, isDisabled: Bool = false) { self.pickerDisplayName = pickerDisplayName self.labelDisplayName = labelDisplayName self.accessibilityValue = accessibilityValue self.isPlaceholder = isPlaceholder self.rawData = rawData + self.isDisabled = isDisabled } - public init(pickerDisplayName: String, labelDisplayName: String, accessibilityValue: String, rawData: String, isPlaceholder: Bool = false) { + public init(pickerDisplayName: String, labelDisplayName: String, accessibilityValue: String, rawData: String, isPlaceholder: Bool = false, isDisabled: Bool = false) { self = .init(pickerDisplayName: NSAttributedString(string: pickerDisplayName), labelDisplayName: NSAttributedString(string: labelDisplayName), accessibilityValue: accessibilityValue, rawData: rawData, - isPlaceholder: isPlaceholder) + isPlaceholder: isPlaceholder, + isDisabled: isDisabled) } /// Item label displayed in the picker @@ -53,6 +55,8 @@ import UIKit /// If true, this item will be styled with greyed out secondary text public let isPlaceholder: Bool + + public let isDisabled: Bool } // MARK: - Public properties @@ -153,8 +157,8 @@ import UIKit didUpdate: DidUpdateSelectedIndex? = nil, didTapClose: DidTapClose? = nil ) { - assert(!items.isEmpty, "`items` must contain at least one item") - + assert(!items.filter{!$0.isDisabled}.isEmpty, "`items` must contain at least one non-disabled item") + self.label = label self.theme = theme self.items = items @@ -185,7 +189,7 @@ import UIKit public func update(items: [DropdownItem]) { assert(!items.isEmpty, "`items` must contain at least one item") - // Try to re-select the same item afer updating, if not possible default to the first item in the list + // Try to re-select the same item after updating, if not possible default to the first item in the list let newSelectedIndex = items.firstIndex(where: { $0.rawData == self.items[selectedIndex].rawData }) ?? 0 self.items = items @@ -243,11 +247,13 @@ extension DropdownFieldElement { guard let dropdownFieldElement else { return nil } let item = dropdownFieldElement.items[row] - guard item.isPlaceholder else { return item.pickerDisplayName } + guard item.isPlaceholder || item.isDisabled else { return item.pickerDisplayName } - // If this item is marked as a placeholder, apply placeholder text color + // If this item is marked as a placeholder or disabled, apply placeholder text color + let placeholderString = NSMutableAttributedString(attributedString: item.pickerDisplayName) let attributes: [NSAttributedString.Key: Any] = [.foregroundColor: dropdownFieldElement.theme.colors.placeholderText] - let placeholderString = NSAttributedString(string: item.pickerDisplayName.string, attributes: attributes) + placeholderString.addAttributes(attributes, range: NSRange(location: 0, length: placeholderString.length)) + return placeholderString } @@ -265,6 +271,17 @@ extension DropdownFieldElement { } public func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { + guard row < items.count else { + stpAssertionFailure("DropdownFieldElement selected row (\(row)) is out of bounds. Total dropdown items: \(items.count)") + return + } + let item = items[row] + // If a user selects a disable row, reset to the previous selection + if item.isDisabled { + pickerView.selectRow(selectedIndex, inComponent: 0, animated: true) + return + } + selectedIndex = row } } diff --git a/StripeUICore/StripeUICore/Source/Helpers/String+Localized.swift b/StripeUICore/StripeUICore/Source/Helpers/String+Localized.swift index 86fafb39c9d..c81b5933de5 100644 --- a/StripeUICore/StripeUICore/Source/Helpers/String+Localized.swift +++ b/StripeUICore/StripeUICore/Source/Helpers/String+Localized.swift @@ -348,4 +348,11 @@ import Foundation static var remove_card: String { STPLocalizedString("Remove card", "Label on a button for removing a card") } + + static var brand_not_accepted: String { + STPLocalizedString( + "(not accepted)", + "Shown in a dropdown picker next to a card brand that is not accepted by a merchant. E.g. \"Visa (not accepted)\"" + ) + } } diff --git a/StripeUICore/StripeUICoreTests/Unit/Elements/DropdownFieldElementTest.swift b/StripeUICore/StripeUICoreTests/Unit/Elements/DropdownFieldElementTest.swift index b853865875f..52d7a8b98ae 100644 --- a/StripeUICore/StripeUICoreTests/Unit/Elements/DropdownFieldElementTest.swift +++ b/StripeUICore/StripeUICoreTests/Unit/Elements/DropdownFieldElementTest.swift @@ -93,4 +93,29 @@ final class DropdownFieldElementTest: XCTestCase { element.didFinish(element.pickerFieldView, shouldAutoAdvance: true) XCTAssertEqual(index, 0) } + + func testCantSelectDisabledItem() { + let disabledItem = DropdownFieldElement.DropdownItem(pickerDisplayName: "Disabled", + labelDisplayName: "Disabled", + accessibilityValue: "Disabled", + rawData: "Disabled", + isDisabled: true) + let itemsWithDisabled = items + [disabledItem] + XCTAssertEqual(4, items.count) + + var index: Int? + let element = DropdownFieldElement(items: itemsWithDisabled, defaultIndex: 0, label: "", didUpdate: { index = $0 }) + XCTAssertNil(index) + + // Emulate a user changing the picker and hitting the done button + element.pickerView(element.pickerView, didSelectRow: 2, inComponent: 0) + element.didFinish(element.pickerFieldView, shouldAutoAdvance: true) + XCTAssertEqual(index, 2) + + element.pickerView(element.pickerView, didSelectRow: 4, inComponent: 0) + element.didFinish(element.pickerFieldView, shouldAutoAdvance: true) + // Should stay selected on previous selection + XCTAssertEqual(index, 2) + } + } diff --git a/Tests/ReferenceImages_64/StripePaymentSheetTests.UpdatePaymentMethodViewControllerSnapshotTests/test_UpdatePaymentMethodViewControllerLightMode_blockedBrands@3x.png b/Tests/ReferenceImages_64/StripePaymentSheetTests.UpdatePaymentMethodViewControllerSnapshotTests/test_UpdatePaymentMethodViewControllerLightMode_blockedBrands@3x.png new file mode 100644 index 00000000000..4545f6a7825 Binary files /dev/null and b/Tests/ReferenceImages_64/StripePaymentSheetTests.UpdatePaymentMethodViewControllerSnapshotTests/test_UpdatePaymentMethodViewControllerLightMode_blockedBrands@3x.png differ