diff --git a/Example/PaymentSheet Example/PaymentSheet Example/CustomerSheetTestPlayground.swift b/Example/PaymentSheet Example/PaymentSheet Example/CustomerSheetTestPlayground.swift index 72a36a853e2..481c9161d0b 100644 --- a/Example/PaymentSheet Example/PaymentSheet Example/CustomerSheetTestPlayground.swift +++ b/Example/PaymentSheet Example/PaymentSheet Example/CustomerSheetTestPlayground.swift @@ -53,6 +53,7 @@ struct CustomerSheetTestPlayground: View { SettingPickerView(setting: $playgroundController.settings.paymentMethodRemove) SettingPickerView(setting: $playgroundController.settings.paymentMethodRemoveLast) SettingPickerView(setting: $playgroundController.settings.paymentMethodAllowRedisplayFilters) + SettingPickerView(setting: $playgroundController.settings.allowsSetAsDefaultPM) } } } diff --git a/Example/PaymentSheet Example/PaymentSheet Example/CustomerSheetTestPlaygroundController.swift b/Example/PaymentSheet Example/PaymentSheet Example/CustomerSheetTestPlaygroundController.swift index 6d8f22e6665..519921e1063 100644 --- a/Example/PaymentSheet Example/PaymentSheet Example/CustomerSheetTestPlaygroundController.swift +++ b/Example/PaymentSheet Example/PaymentSheet Example/CustomerSheetTestPlaygroundController.swift @@ -4,7 +4,7 @@ // import Combine -@_spi(STP) @_spi(CustomerSessionBetaAccess) @_spi(CardBrandFilteringBeta) import StripePaymentSheet +@_spi(STP) @_spi(CustomerSessionBetaAccess) @_spi(CardBrandFilteringBeta) @_spi(AllowsSetAsDefaultPM) import StripePaymentSheet import SwiftUI class CustomerSheetTestPlaygroundController: ObservableObject { @@ -147,6 +147,7 @@ class CustomerSheetTestPlaygroundController: ObservableObject { case .allowVisa: configuration.cardBrandAcceptance = .allowed(brands: [.visa]) } + configuration.allowsSetAsDefaultPM = settings.allowsSetAsDefaultPM == .on return configuration } diff --git a/Example/PaymentSheet Example/PaymentSheet Example/CustomerSheetTestPlaygroundSettings.swift b/Example/PaymentSheet Example/PaymentSheet Example/CustomerSheetTestPlaygroundSettings.swift index ecf1cfe11d8..365140d361e 100644 --- a/Example/PaymentSheet Example/PaymentSheet Example/CustomerSheetTestPlaygroundSettings.swift +++ b/Example/PaymentSheet Example/PaymentSheet Example/CustomerSheetTestPlaygroundSettings.swift @@ -148,6 +148,12 @@ public struct CustomerSheetTestPlaygroundSettings: Codable, Equatable { case allowVisa } + enum AllowsSetAsDefaultPM: String, PickerEnum { + static let enumName: String = "allowsSetAsDefaultPM" + case on + case off + } + var customerMode: CustomerMode var customerId: String? var customerKeyType: CustomerKeyType @@ -169,6 +175,7 @@ public struct CustomerSheetTestPlaygroundSettings: Codable, Equatable { var paymentMethodRemoveLast: PaymentMethodRemoveLast var paymentMethodAllowRedisplayFilters: PaymentMethodAllowRedisplayFilters var cardBrandAcceptance: CardBrandAcceptance + var allowsSetAsDefaultPM: AllowsSetAsDefaultPM static func defaultValues() -> CustomerSheetTestPlaygroundSettings { return CustomerSheetTestPlaygroundSettings(customerMode: .new, @@ -190,7 +197,8 @@ public struct CustomerSheetTestPlaygroundSettings: Codable, Equatable { paymentMethodRemove: .enabled, paymentMethodRemoveLast: .enabled, paymentMethodAllowRedisplayFilters: .always, - cardBrandAcceptance: .all) + cardBrandAcceptance: .all, + allowsSetAsDefaultPM: .off) } var base64Data: String { diff --git a/Example/PaymentSheet Example/PaymentSheet Example/PaymentSheetTestPlayground.swift b/Example/PaymentSheet Example/PaymentSheet Example/PaymentSheetTestPlayground.swift index 5345a87d8bd..a1e559bf1d1 100644 --- a/Example/PaymentSheet Example/PaymentSheet Example/PaymentSheetTestPlayground.swift +++ b/Example/PaymentSheet Example/PaymentSheet Example/PaymentSheetTestPlayground.swift @@ -119,6 +119,7 @@ struct PaymentSheetTestPlayground: View { if playgroundController.settings.paymentMethodRedisplay == .enabled { SettingPickerView(setting: $playgroundController.settings.paymentMethodAllowRedisplayFilters) } + SettingPickerView(setting: $playgroundController.settings.allowsSetAsDefaultPM) } } } diff --git a/Example/PaymentSheet Example/PaymentSheet Example/PaymentSheetTestPlaygroundSettings.swift b/Example/PaymentSheet Example/PaymentSheet Example/PaymentSheetTestPlaygroundSettings.swift index d021cc74f36..c13fb0a485c 100644 --- a/Example/PaymentSheet Example/PaymentSheet Example/PaymentSheetTestPlaygroundSettings.swift +++ b/Example/PaymentSheet Example/PaymentSheet Example/PaymentSheetTestPlaygroundSettings.swift @@ -444,6 +444,12 @@ struct PaymentSheetTestPlaygroundSettings: Codable, Equatable { case allowVisa } + enum AllowsSetAsDefaultPM: String, PickerEnum { + static let enumName: String = "allowsSetAsDefaultPM" + case on + case off + } + var uiStyle: UIStyle var layout: Layout var mode: Mode @@ -490,6 +496,7 @@ struct PaymentSheetTestPlaygroundSettings: Codable, Equatable { var formSheetAction: FormSheetAction var embeddedViewDisplaysMandateText: DisplaysMandateTextEnabled var cardBrandAcceptance: CardBrandAcceptance + var allowsSetAsDefaultPM: AllowsSetAsDefaultPM static func defaultValues() -> PaymentSheetTestPlaygroundSettings { return PaymentSheetTestPlaygroundSettings( @@ -535,7 +542,8 @@ struct PaymentSheetTestPlaygroundSettings: Codable, Equatable { collectAddress: .automatic, formSheetAction: .confirm, embeddedViewDisplaysMandateText: .on, - cardBrandAcceptance: .all) + cardBrandAcceptance: .all, + allowsSetAsDefaultPM: .off) } static let nsUserDefaultsKey = "PaymentSheetTestPlaygroundSettings" diff --git a/Example/PaymentSheet Example/PaymentSheet Example/PlaygroundController.swift b/Example/PaymentSheet Example/PaymentSheet Example/PlaygroundController.swift index 16d1e3bd5d6..d17cc15515b 100644 --- a/Example/PaymentSheet Example/PaymentSheet Example/PlaygroundController.swift +++ b/Example/PaymentSheet Example/PaymentSheet Example/PlaygroundController.swift @@ -14,7 +14,7 @@ import Contacts import PassKit @_spi(STP) import StripeCore @_spi(STP) import StripePayments -@_spi(CustomerSessionBetaAccess) @_spi(STP) @_spi(PaymentSheetSkipConfirmation) @_spi(ExperimentalAllowsRemovalOfLastSavedPaymentMethodAPI) @_spi(EmbeddedPaymentElementPrivateBeta) @_spi(CardBrandFilteringBeta) import StripePaymentSheet +@_spi(CustomerSessionBetaAccess) @_spi(STP) @_spi(PaymentSheetSkipConfirmation) @_spi(ExperimentalAllowsRemovalOfLastSavedPaymentMethodAPI) @_spi(EmbeddedPaymentElementPrivateBeta) @_spi(CardBrandFilteringBeta) @_spi(AllowsSetAsDefaultPM) import StripePaymentSheet import SwiftUI import UIKit @@ -184,6 +184,7 @@ class PlaygroundController: ObservableObject { case .allowVisa: configuration.cardBrandAcceptance = .allowed(brands: [.visa]) } + configuration.allowsSetAsDefaultPM = settings.allowsSetAsDefaultPM == .on return configuration } @@ -271,6 +272,7 @@ class PlaygroundController: ObservableObject { case .allowVisa: configuration.cardBrandAcceptance = .allowed(brands: [.visa]) } + configuration.allowsSetAsDefaultPM = settings.allowsSetAsDefaultPM == .on return configuration } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/v1-elements-sessions/ElementsCustomer.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/v1-elements-sessions/ElementsCustomer.swift index 5ff41532bed..76ab7b66b6b 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/v1-elements-sessions/ElementsCustomer.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/v1-elements-sessions/ElementsCustomer.swift @@ -39,4 +39,14 @@ struct ElementsCustomer: Equatable, Hashable { let defaultPaymentMethod = response["default_payment_method"] as? String return ElementsCustomer(paymentMethods: paymentMethods, defaultPaymentMethod: defaultPaymentMethod, customerSession: customerSession) } + + func getDefaultOrFirstPaymentMethod() -> STPPaymentMethod? { + // if customer has a default payment method from the elements session, return the default payment method + let defaultSavedPaymentMethod = paymentMethods.first { $0.stripeId == defaultPaymentMethod } + if let defaultSavedPaymentMethod = defaultSavedPaymentMethod { + return defaultSavedPaymentMethod + } + // otherwise, return the first payment method from the customer's list of saved payment methods + return paymentMethods.first + } } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSessionAdapter/CustomerSessionAdapter.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSessionAdapter/CustomerSessionAdapter.swift index 1eb5b5efc03..fd29c12eb5e 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSessionAdapter/CustomerSessionAdapter.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSessionAdapter/CustomerSessionAdapter.swift @@ -107,7 +107,14 @@ extension CustomerSessionAdapter { return stripePaymentMethodId } - func fetchSelectedPaymentOption(for customerId: String) -> CustomerPaymentOption? { + func fetchSelectedPaymentOption(for customerId: String, customer: ElementsCustomer? = nil) -> CustomerPaymentOption? { + // if opted in to the "set as default" feature, try to get default payment method from elements session + if configuration.allowsSetAsDefaultPM { + guard let customer = customer, + let defaultPaymentMethod = customer.getDefaultOrFirstPaymentMethod() else { return nil } + return CustomerPaymentOption.stripeId(defaultPaymentMethod.stripeId) + } + return CustomerPaymentOption.defaultPaymentMethod(for: customerId) } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSheet.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSheet.swift index c57a3b11e3a..79e088e701c 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSheet.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSheet.swift @@ -326,7 +326,18 @@ extension CustomerSheet { switch customerSheetDataSource.dataSource { case .customerSession(let customerSessionAdapter): let (elementsSession, customerSessionClientSecret) = try await customerSessionAdapter.elementsSessionWithCustomerSessionClientSecret() - let selectedPaymentOption = CustomerPaymentOption.defaultPaymentMethod(for: customerSessionClientSecret.customerId) + + var selectedPaymentOption: CustomerPaymentOption? + + // if opted in to the "set as default" feature, try to get default payment method from elements session + if configuration.allowsSetAsDefaultPM { + guard let customer = elementsSession.customer, + let defaultPaymentMethod = customer.getDefaultOrFirstPaymentMethod() else { return nil } + selectedPaymentOption = CustomerPaymentOption.stripeId(defaultPaymentMethod.stripeId) + } + else { + selectedPaymentOption = CustomerPaymentOption.defaultPaymentMethod(for: customerSessionClientSecret.customerId) + } switch selectedPaymentOption { case .applePay: diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSheetConfiguration.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSheetConfiguration.swift index 67f6785c5df..c1f253c575c 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSheetConfiguration.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSheetConfiguration.swift @@ -81,6 +81,11 @@ extension CustomerSheet { /// Note: Card brand filtering is not currently supported by Link. @_spi(CardBrandFilteringBeta) public var cardBrandAcceptance: PaymentSheet.CardBrandAcceptance = .all + /// This is an experimental feature that may be removed at any time. + /// If true, users can set a payment method as default and sync their default payment method across web and mobile + /// If false (default), users cannot set default payment methods. + @_spi(AllowsSetAsDefaultPM) public var allowsSetAsDefaultPM = false + public init () { } } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSheetDataSource.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSheetDataSource.swift index 2645ac50316..d714d540e20 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSheetDataSource.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSheetDataSource.swift @@ -41,8 +41,8 @@ class CustomerSheetDataSource { // Ensure local specs are loaded prior to the ones from elementSession await loadFormSpecs() let customerId = try await customerSessionClientSecret.customerId - let paymentOption = customerSessionAdapter.fetchSelectedPaymentOption(for: customerId) let elementSession = try await elementsSessionResult + let paymentOption = customerSessionAdapter.fetchSelectedPaymentOption(for: customerId, customer: elementSession.customer) // Override with specs from elementSession _ = FormSpecProvider.shared.loadFrom(elementSession.paymentMethodSpecs as Any) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement+Internal.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement+Internal.swift index 15a4eaa1dec..4acd2640687 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement+Internal.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement+Internal.swift @@ -54,7 +54,16 @@ extension EmbeddedPaymentElement { } // If there's no previous customer input, default to the customer's default or the first saved payment method, if any - let customerDefault = CustomerPaymentOption.defaultPaymentMethod(for: configuration.customer?.id) + var customerDefault: CustomerPaymentOption? + // if opted in to the "set as default" feature, try to get default payment method from elements session + if configuration.allowsSetAsDefaultPM { + if let defaultPaymentMethod = loadResult.elementsSession.customer?.getDefaultOrFirstPaymentMethod() { + customerDefault = CustomerPaymentOption.stripeId(defaultPaymentMethod.stripeId) + } + } + else { + customerDefault = CustomerPaymentOption.defaultPaymentMethod(for: configuration.customer?.id) + } switch customerDefault { case .applePay: return .applePay diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElementConfiguration.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElementConfiguration.swift index 4a6dbf14d50..1e2e8c0a600 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElementConfiguration.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElementConfiguration.swift @@ -131,6 +131,11 @@ extension EmbeddedPaymentElement { /// Note: Card brand filtering is not currently supported by Link. @_spi(CardBrandFilteringBeta) public var cardBrandAcceptance: PaymentSheet.CardBrandAcceptance = .all + /// This is an experimental feature that may be removed at any time. + /// If true, users can set a payment method as default and sync their default payment method across web and mobile + /// If false (default), users cannot set default payment methods. + @_spi(AllowsSetAsDefaultPM) public var allowsSetAsDefaultPM = false + /// The view can display payment methods like “Card” that, when tapped, open a form sheet where customers enter their payment method details. The sheet has a button at the bottom. `FormSheetAction` enumerates the actions the button can perform. public enum FormSheetAction { /// The button says “Pay” or “Setup”. When tapped, we confirm the payment or setup in the form sheet. diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentElementConfiguration.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentElementConfiguration.swift index c548c2080be..8c85ecae2e9 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentElementConfiguration.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentElementConfiguration.swift @@ -37,6 +37,7 @@ protocol PaymentElementConfiguration: PaymentMethodRequirementProvider { var cardBrandAcceptance: PaymentSheet.CardBrandAcceptance { get set } var analyticPayload: [String: Any] { get } var disableWalletPaymentMethodFiltering: Bool { get set } + var allowsSetAsDefaultPM: Bool { get set } var linkPaymentMethodsOnly: Bool { get set } var forceNativeLinkEnabled: Bool { get set } } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetConfiguration.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetConfiguration.swift index 85934702a49..38bebcecf5d 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetConfiguration.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetConfiguration.swift @@ -211,6 +211,10 @@ extension PaymentSheet { /// Note: Card brand filtering is not currently supported by Link. @_spi(CardBrandFilteringBeta) public var cardBrandAcceptance: PaymentSheet.CardBrandAcceptance = .all + /// This is an experimental feature that may be removed at any time. + /// If true, users can set a payment method as default and sync their default payment method across web and mobile + /// If false (default), users cannot set default payment methods. + @_spi(AllowsSetAsDefaultPM) public var allowsSetAsDefaultPM = false } /// Defines the layout orientations available for displaying payment methods in PaymentSheet. diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetLoader.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetLoader.swift index b1802dca622..c10a9cbaaff 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetLoader.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetLoader.swift @@ -116,7 +116,9 @@ final class PaymentSheetLoader { savedPaymentMethods: filteredSavedPaymentMethods, customerID: configuration.customer?.id, showApplePay: integrationShape.canDefaultToLinkOrApplePay ? isApplePayEnabled : false, - showLink: integrationShape.canDefaultToLinkOrApplePay ? isLinkEnabled : false + showLink: integrationShape.canDefaultToLinkOrApplePay ? isLinkEnabled : false, + allowsSetAsDefaultPM: configuration.allowsSetAsDefaultPM, + customer: elementsSession.customer ) let paymentMethodTypes = PaymentSheet.PaymentMethodType.filteredPaymentMethodTypes(from: intent, elementsSession: elementsSession, configuration: configuration, logAvailability: true) @@ -316,9 +318,18 @@ final class PaymentSheetLoader { // Move default PM to front if let customerID = configuration.customer?.id { - let defaultPaymentMethod = CustomerPaymentOption.defaultPaymentMethod(for: customerID) + var defaultPaymentMethodOption: CustomerPaymentOption? + // if opted in to the "set as default" feature, try to get default payment method from elements session + if configuration.allowsSetAsDefaultPM { + guard let customer = elementsSession.customer, + let defaultPaymentMethod = customer.getDefaultOrFirstPaymentMethod() else { return [] } + defaultPaymentMethodOption = CustomerPaymentOption.stripeId(defaultPaymentMethod.stripeId) + } + else { + defaultPaymentMethodOption = CustomerPaymentOption.defaultPaymentMethod(for: customerID) + } if let defaultPMIndex = savedPaymentMethods.firstIndex(where: { - $0.stripeId == defaultPaymentMethod?.value + $0.stripeId == defaultPaymentMethodOption?.value }) { let defaultPM = savedPaymentMethods.remove(at: defaultPMIndex) savedPaymentMethods.insert(defaultPM, at: 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 04f48004b12..07f58ae49c0 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Saved Payment Method Screen/SavedPaymentOptionsViewController.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Saved Payment Method Screen/SavedPaymentOptionsViewController.swift @@ -103,6 +103,7 @@ class SavedPaymentOptionsViewController: UIViewController { let isTestMode: Bool let allowsRemovalOfLastSavedPaymentMethod: Bool let allowsRemovalOfPaymentMethods: Bool + let allowsSetAsDefaultPM: Bool } // MARK: - Internal Properties @@ -216,6 +217,7 @@ class SavedPaymentOptionsViewController: UIViewController { } weak var delegate: SavedPaymentOptionsViewControllerDelegate? var appearance = PaymentSheet.Appearance.default + var elementsSession: STPElementsSession // MARK: - Private Properties private var selectedViewModelIndex: Int? @@ -311,6 +313,7 @@ class SavedPaymentOptionsViewController: UIViewController { paymentSheetConfiguration: PaymentSheet.Configuration, intent: Intent, appearance: PaymentSheet.Appearance, + elementsSession: STPElementsSession, cbcEligible: Bool = false, analyticsHelper: PaymentSheetAnalyticsHelper, delegate: SavedPaymentOptionsViewControllerDelegate? = nil @@ -320,6 +323,7 @@ class SavedPaymentOptionsViewController: UIViewController { self.paymentSheetConfiguration = paymentSheetConfiguration self.intent = intent self.appearance = appearance + self.elementsSession = elementsSession self.cbcEligible = cbcEligible self.delegate = delegate self.analyticsHelper = analyticsHelper @@ -363,7 +367,9 @@ class SavedPaymentOptionsViewController: UIViewController { savedPaymentMethods: savedPaymentMethods, customerID: configuration.customerID, showApplePay: configuration.showApplePay, - showLink: configuration.showLink + showLink: configuration.showLink, + allowsSetAsDefaultPM: configuration.allowsSetAsDefaultPM, + customer: elementsSession.customer ) collectionView.reloadData() @@ -437,9 +443,19 @@ class SavedPaymentOptionsViewController: UIViewController { /// Creates the list of viewmodels to display in the "saved payment methods" carousel e.g. `["+ Add", "Apple Pay", "Link", "Visa 4242"]` /// - Returns defaultSelectedIndex: The index of the view model that is the default e.g. in the above list, if "Visa 4242" is the default, the index is 3. - static func makeViewModels(savedPaymentMethods: [STPPaymentMethod], customerID: String?, showApplePay: Bool, showLink: Bool) -> (defaultSelectedIndex: Int, viewModels: [Selection]) { + static func makeViewModels(savedPaymentMethods: [STPPaymentMethod], customerID: String?, showApplePay: Bool, showLink: Bool, allowsSetAsDefaultPM: Bool, customer: ElementsCustomer?) -> (defaultSelectedIndex: Int, viewModels: [Selection]) { // Get the default - let defaultPaymentMethod = CustomerPaymentOption.defaultPaymentMethod(for: customerID) + var defaultPaymentMethodOption: CustomerPaymentOption? + // if opted in to the "set as default" feature, try to get default payment method from elements session + if allowsSetAsDefaultPM { + if let customer = customer, + let defaultPaymentMethod = customer.getDefaultOrFirstPaymentMethod() { + defaultPaymentMethodOption = CustomerPaymentOption.stripeId(defaultPaymentMethod.stripeId) + } + } + else { + defaultPaymentMethodOption = CustomerPaymentOption.defaultPaymentMethod(for: customerID) + } // Transform saved PaymentMethods into view models let savedPMViewModels = savedPaymentMethods.compactMap { paymentMethod in @@ -460,7 +476,7 @@ class SavedPaymentOptionsViewController: UIViewController { let firstPaymentMethodIsLink = !showApplePay && showLink let defaultIndex = firstPaymentMethodIsLink ? 2 : 1 - let defaultSelectedIndex = viewModels.firstIndex(where: { $0 == defaultPaymentMethod }) ?? defaultIndex + let defaultSelectedIndex = viewModels.firstIndex(where: { $0 == defaultPaymentMethodOption }) ?? defaultIndex return (defaultSelectedIndex, viewModels) } } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetFlowControllerViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetFlowControllerViewController.swift index c6ad0ceab98..65aabb4c205 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetFlowControllerViewController.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetFlowControllerViewController.swift @@ -206,11 +206,13 @@ class PaymentSheetFlowControllerViewController: UIViewController, FlowController isCVCRecollectionEnabled: false, isTestMode: configuration.apiClient.isTestmode, allowsRemovalOfLastSavedPaymentMethod: PaymentSheetViewController.allowsRemovalOfLastPaymentMethod(elementsSession: elementsSession, configuration: configuration), - allowsRemovalOfPaymentMethods: elementsSession.allowsRemovalOfPaymentMethodsForPaymentSheet() + allowsRemovalOfPaymentMethods: elementsSession.allowsRemovalOfPaymentMethodsForPaymentSheet(), + allowsSetAsDefaultPM: configuration.allowsSetAsDefaultPM ), paymentSheetConfiguration: configuration, intent: intent, appearance: configuration.appearance, + elementsSession: elementsSession, cbcEligible: elementsSession.isCardBrandChoiceEligible, analyticsHelper: analyticsHelper ) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetVerticalViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetVerticalViewController.swift index 76e3dc89dda..92d62f6f488 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetVerticalViewController.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetVerticalViewController.swift @@ -318,7 +318,17 @@ class PaymentSheetVerticalViewController: UIViewController, FlowControllerViewCo } } // Default to the customer's default or the first saved payment method, if any - let customerDefault = CustomerPaymentOption.defaultPaymentMethod(for: configuration.customer?.id) + var customerDefault: CustomerPaymentOption? + // if opted in to the "set as default" feature, try to get default payment method from elements session + if configuration.allowsSetAsDefaultPM { + if let customer = elementsSession.customer, + let defaultPaymentMethod = customer.getDefaultOrFirstPaymentMethod() { + customerDefault = CustomerPaymentOption.stripeId(defaultPaymentMethod.stripeId) + } + } + else { + customerDefault = CustomerPaymentOption.defaultPaymentMethod(for: configuration.customer?.id) + } switch customerDefault { case .applePay: return isFlowController ? .applePay : nil // Only default to Apple Pay in flow controller mode diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetViewController.swift index 37e9b77fd2b..9e91c30f020 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetViewController.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetViewController.swift @@ -174,11 +174,13 @@ class PaymentSheetViewController: UIViewController, PaymentSheetViewControllerPr isCVCRecollectionEnabled: isCVCRecollectionEnabled, isTestMode: configuration.apiClient.isTestmode, allowsRemovalOfLastSavedPaymentMethod: PaymentSheetViewController.allowsRemovalOfLastPaymentMethod(elementsSession: elementsSession, configuration: configuration), - allowsRemovalOfPaymentMethods: loadResult.elementsSession.allowsRemovalOfPaymentMethodsForPaymentSheet() + allowsRemovalOfPaymentMethods: loadResult.elementsSession.allowsRemovalOfPaymentMethodsForPaymentSheet(), + allowsSetAsDefaultPM: configuration.allowsSetAsDefaultPM ), paymentSheetConfiguration: configuration, intent: intent, appearance: configuration.appearance, + elementsSession: elementsSession, cbcEligible: elementsSession.isCardBrandChoiceEligible, analyticsHelper: analyticsHelper ) diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/CustomerSheet/CustomerSheetTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/CustomerSheet/CustomerSheetTests.swift index a533b7309e4..8059ad4999b 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/CustomerSheet/CustomerSheetTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/CustomerSheet/CustomerSheetTests.swift @@ -7,7 +7,7 @@ import Foundation @_spi(STP) @testable import StripeCore @_spi(STP) @testable import StripePayments -@_spi(CustomerSessionBetaAccess) @_spi(CardBrandFilteringBeta) @testable import StripePaymentSheet +@_spi(CustomerSessionBetaAccess) @_spi(CardBrandFilteringBeta) @_spi(AllowsSetAsDefaultPM) @testable import StripePaymentSheet import OHHTTPStubs import OHHTTPStubsSwift @@ -327,4 +327,52 @@ class CustomerSheetTests: APIStubbedTestCase { } wait(for: [loadPaymentMethodInfo], timeout: 5.0) } + + func testLoadPaymentMethodInfo_CustomerSession_NoDefaultPMHasSavedPaymentMethod() throws { + let stubbedAPIClient = stubbedAPIClient() + StubbedBackend.stubSessions(fileMock: .elementsSessions_customerSessionsCustomerSheetWithSavedPM_200) + var configuration = CustomerSheet.Configuration() + configuration.apiClient = stubbedAPIClient + configuration.allowsSetAsDefaultPM = true + + let loadPaymentMethodInfo = expectation(description: "loadPaymentMethodInfo completed") + let customerSheet = CustomerSheet(configuration: configuration, + intentConfiguration: .init(setupIntentClientSecretProvider: { return "si_123" }), + customerSessionClientSecretProvider: { return .init(customerId: "cus_123", clientSecret: "cuss_123") }) + let csDataSource = customerSheet.createCustomerSheetDataSource()! + csDataSource.loadPaymentMethodInfo { result in + guard case .success((let paymentMethods, let selectedPaymentMethod, _)) = result else { + XCTFail() + return + } + XCTAssertFalse(paymentMethods.isEmpty) + XCTAssertNotNil(selectedPaymentMethod) + loadPaymentMethodInfo.fulfill() + } + wait(for: [loadPaymentMethodInfo], timeout: 5.0) + } + + func testLoadPaymentMethodInfo_CustomerSession_NoDefaultPMNoSavedPaymentMethod() throws { + let stubbedAPIClient = stubbedAPIClient() + StubbedBackend.stubSessions(fileMock: .elementsSessions_customerSessionsCustomerSheet_200) + var configuration = CustomerSheet.Configuration() + configuration.apiClient = stubbedAPIClient + configuration.allowsSetAsDefaultPM = true + + let loadPaymentMethodInfo = expectation(description: "loadPaymentMethodInfo completed") + let customerSheet = CustomerSheet(configuration: configuration, + intentConfiguration: .init(setupIntentClientSecretProvider: { return "si_123" }), + customerSessionClientSecretProvider: { return .init(customerId: "cus_123", clientSecret: "cuss_123") }) + let csDataSource = customerSheet.createCustomerSheetDataSource()! + csDataSource.loadPaymentMethodInfo { result in + guard case .success((let paymentMethods, let selectedPaymentMethod, _)) = result else { + XCTFail() + return + } + XCTAssertTrue(paymentMethods.isEmpty) + XCTAssertNil(selectedPaymentMethod) + loadPaymentMethodInfo.fulfill() + } + wait(for: [loadPaymentMethodInfo], timeout: 5.0) + } } diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/STPElementsSessionTest.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/STPElementsSessionTest.swift index e269af7081c..d5fb658ab57 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/STPElementsSessionTest.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/STPElementsSessionTest.swift @@ -342,4 +342,54 @@ class STPElementsSessionTest: XCTestCase { XCTAssertTrue(allowsRemoval) XCTAssertFalse(elementsSession.paymentMethodRemoveLastForCustomerSheet) } + private let testCardJSON = [ + "id": "pm_123card", + "type": "card", + "card": [ + "last4": "4242", + "brand": "visa", + "fingerprint": "B8XXs2y2JsVBtB9f", + "networks": ["available": ["visa"]], + "exp_month": "01", + "exp_year": Calendar.current.component(.year, from: Date()) + 1 + ] + ] as [AnyHashable : Any] + private let testCardAmexJSON = [ + "id": "pm_123amexcard", + "type": "card", + "card": [ + "last4": "0005", + "brand": "amex", + ], + ] as [AnyHashable : Any] + func testElementsCustomerDefaultPaymentMethod() { + let elementsSession = STPElementsSession._testDefaultCardValue(defaultPaymentMethod: "pm_123card", paymentMethods: [testCardAmexJSON, testCardJSON]) + let customer = elementsSession.customer + XCTAssertNotNil(customer) + let defaultPaymentMethodId = customer?.defaultPaymentMethod + XCTAssertNotNil(defaultPaymentMethodId) + let defaultPaymentMethod = customer?.getDefaultOrFirstPaymentMethod() + XCTAssertNotNil(defaultPaymentMethod) + XCTAssertEqual(defaultPaymentMethod?.stripeId, defaultPaymentMethodId) + XCTAssertEqual(defaultPaymentMethod?.stripeId, "pm_123card") + } + func testElementsCustomerNoDefaultPaymentMethodHasSavedPaymentMethods() { + let elementsSession = STPElementsSession._testDefaultCardValue(defaultPaymentMethod: nil, paymentMethods: [testCardAmexJSON, testCardJSON]) + let customer = elementsSession.customer + XCTAssertNotNil(customer) + let defaultPaymentMethodId = customer?.defaultPaymentMethod + XCTAssertNil(defaultPaymentMethodId) + let defaultPaymentMethod = customer?.getDefaultOrFirstPaymentMethod() + XCTAssertNotNil(defaultPaymentMethod) + XCTAssertEqual(defaultPaymentMethod?.stripeId, "pm_123amexcard") + } + func testElementsCustomerNoDefaultPaymentMethodNoSavedPaymentMethods() { + let elementsSession = STPElementsSession._testDefaultCardValue(defaultPaymentMethod: nil, paymentMethods: []) + let customer = elementsSession.customer + XCTAssertNotNil(customer) + let defaultPaymentMethodId = customer?.defaultPaymentMethod + XCTAssertNil(defaultPaymentMethodId) + let defaultPaymentMethod = customer?.getDefaultOrFirstPaymentMethod() + XCTAssertNil(defaultPaymentMethod) + } } diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/STPFixtures+PaymentSheet.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/STPFixtures+PaymentSheet.swift index 43ed581fbc5..4bf0f2067e0 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/STPFixtures+PaymentSheet.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/STPFixtures+PaymentSheet.swift @@ -45,6 +45,19 @@ extension STPElementsSession { return _testValue(paymentMethodTypes: ["card"]) } + static func _testDefaultCardValue(defaultPaymentMethod: String?, paymentMethods: [[AnyHashable: Any]]? = nil) -> STPElementsSession { + return _testValue(paymentMethodTypes: ["card"], customerSessionData: [ + "mobile_payment_element": [ + "enabled": true, + "features": ["payment_method_save": "enabled", + "payment_method_remove": "enabled", + ], + ], + "customer_sheet": [ + "enabled": false, + ]], allowsSetAsDefaultPM: true, defaultPaymentMethod: defaultPaymentMethod, paymentMethods: paymentMethods) + } + static func _testValue( paymentMethodTypes: [String], externalPaymentMethodTypes: [String] = [], @@ -53,7 +66,10 @@ extension STPElementsSession { isLinkPassthroughModeEnabled: Bool? = nil, linkMode: LinkMode? = nil, linkFundingSources: Set = [], - disableLinkSignup: Bool? = nil + disableLinkSignup: Bool? = nil, + allowsSetAsDefaultPM: Bool = false, + defaultPaymentMethod: String? = nil, + paymentMethods: [[AnyHashable: Any]]? = nil ) -> STPElementsSession { var json = STPTestUtils.jsonNamed("ElementsSession")! json[jsonDict: "payment_method_preference"]?["ordered_payment_method_types"] = paymentMethodTypes @@ -74,8 +90,14 @@ extension STPElementsSession { "api_key_expiry": 12345, "customer": "cus_123", "components": customerSessionData, - ], + ] ] + if allowsSetAsDefaultPM, let defaultPaymentMethod { + json[jsonDict: "customer"]?["default_payment_method"] = defaultPaymentMethod + } + if let paymentMethods { + json[jsonDict: "customer"]?["payment_methods"] = paymentMethods + } } if let cardBrandChoiceData { diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/SavedPaymentOptionsViewControllerSnapshotTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/SavedPaymentOptionsViewControllerSnapshotTests.swift index bdba1b9a487..08ece3c4cf9 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/SavedPaymentOptionsViewControllerSnapshotTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/SavedPaymentOptionsViewControllerSnapshotTests.swift @@ -32,13 +32,14 @@ final class SavedPaymentOptionsViewControllerSnapshotTests: STPSnapshotTestCase STPPaymentMethod._testUSBankAccount(), STPPaymentMethod._testSEPA(), ] - let config = SavedPaymentOptionsViewController.Configuration(customerID: "cus_123", showApplePay: true, showLink: true, removeSavedPaymentMethodMessage: nil, merchantDisplayName: "Test Merchant", isCVCRecollectionEnabled: false, isTestMode: false, allowsRemovalOfLastSavedPaymentMethod: false, allowsRemovalOfPaymentMethods: true) + let config = SavedPaymentOptionsViewController.Configuration(customerID: "cus_123", showApplePay: true, showLink: true, removeSavedPaymentMethodMessage: nil, merchantDisplayName: "Test Merchant", isCVCRecollectionEnabled: false, isTestMode: false, allowsRemovalOfLastSavedPaymentMethod: false, allowsRemovalOfPaymentMethods: true, allowsSetAsDefaultPM: false) let intent = Intent.deferredIntent(intentConfig: .init(mode: .payment(amount: 0, currency: "USD", setupFutureUsage: nil, captureMethod: .automatic), confirmHandler: { _, _, _ in })) let sut = SavedPaymentOptionsViewController(savedPaymentMethods: paymentMethods, configuration: config, paymentSheetConfiguration: PaymentSheet.Configuration(), intent: intent, appearance: appearance, + elementsSession: .emptyElementsSession, analyticsHelper: ._testValue()) let testWindow = UIWindow() testWindow.isHidden = false diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/SavedPaymentOptionsViewControllerTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/SavedPaymentOptionsViewControllerTests.swift index 9dec04ccc72..2d2d52a7772 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/SavedPaymentOptionsViewControllerTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/SavedPaymentOptionsViewControllerTests.swift @@ -303,7 +303,8 @@ class SavedPaymentOptionsViewControllerTests: XCTestCase { isCVCRecollectionEnabled: true, isTestMode: true, allowsRemovalOfLastSavedPaymentMethod: allowsRemovalOfLastSavedPaymentMethod, - allowsRemovalOfPaymentMethods: allowsRemovalOfPaymentMethods) + allowsRemovalOfPaymentMethods: allowsRemovalOfPaymentMethods, + allowsSetAsDefaultPM: false) } func savedPaymentOptionsController(_ configuration: SavedPaymentOptionsViewController.Configuration, @@ -314,6 +315,7 @@ class SavedPaymentOptionsViewControllerTests: XCTestCase { paymentSheetConfiguration: paymentSheetConfiguration, intent: Intent._testValue(), appearance: .default, + elementsSession: .emptyElementsSession, cbcEligible: cbcEligible, analyticsHelper: ._testValue(), delegate: nil)