diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Helpers/DownloadManager.swift b/StripePaymentSheet/StripePaymentSheet/Source/Helpers/DownloadManager.swift index d843c777102..92a19c5914a 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Helpers/DownloadManager.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Helpers/DownloadManager.swift @@ -10,7 +10,7 @@ import UIKit /// For internal SDK use only. @objc(STP_Internal_DownloadManager) -// TODO: Refactor this API shape! https://github.com/stripe/stripe-ios/pull/3487#discussion_r1561337866 +// TODO: https://jira.corp.stripe.com/browse/MOBILESDK-2604 Refactor this! @_spi(STP) public class DownloadManager: NSObject { public typealias UpdateImageHandler = (UIImage) -> Void diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement+Internal.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement+Internal.swift index 4e80858929c..744c024a4a1 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement+Internal.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement+Internal.swift @@ -4,6 +4,11 @@ // // Created by Yuki Tokuhiro on 10/10/24. // +@_spi(STP) import StripeCore +@_spi(STP) import StripePayments +@_spi(STP) import StripePaymentsUI +@_spi(STP) import StripeUICore +import UIKit extension EmbeddedPaymentElement { @MainActor @@ -11,8 +16,15 @@ extension EmbeddedPaymentElement { configuration: Configuration, loadResult: PaymentSheetLoader.LoadResult, analyticsHelper: PaymentSheetAnalyticsHelper, + previousPaymentOption: PaymentOption? = nil, delegate: EmbeddedPaymentMethodsViewDelegate? = nil ) -> EmbeddedPaymentMethodsView { + // Restore the customer's previous payment method. + // Caveats: + // - Only payment method details (including checkbox state) and billing details are restored + // - Only restored if the previous input resulted in a completed form i.e. partial or invalid input is still discarded + // TODO: Restore the form, if any + let shouldShowApplePay = PaymentSheet.isApplePayEnabled(elementsSession: loadResult.elementsSession, configuration: configuration) let shouldShowLink = PaymentSheet.isLinkEnabled(elementsSession: loadResult.elementsSession, configuration: configuration) let savedPaymentMethodAccessoryType = RowButton.RightAccessoryButton.getAccessoryButtonType( @@ -23,7 +35,23 @@ extension EmbeddedPaymentElement { allowsPaymentMethodRemoval: loadResult.elementsSession.allowsRemovalOfPaymentMethodsForPaymentSheet() ) let initialSelection: EmbeddedPaymentMethodsView.Selection? = { - // Default to the customer's default or the first saved payment method, if any + // Select the previous payment option + switch previousPaymentOption { + case .applePay: + return .applePay + case .link: + return .link + case .external(paymentMethod: let paymentMethod, billingDetails: _): + return .new(paymentMethodType: .external(paymentMethod)) + case .saved(paymentMethod: let paymentMethod, confirmParams: _): + return .saved(paymentMethod: paymentMethod) + case .new(confirmParams: let confirmParams): + return .new(paymentMethodType: confirmParams.paymentMethodType) + case nil: + break + } + + // 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) switch customerDefault { case .applePay: @@ -66,3 +94,34 @@ extension EmbeddedPaymentElement: EmbeddedPaymentMethodsViewDelegate { delegate?.embeddedPaymentElementDidUpdatePaymentOption(embeddedPaymentElement: self) } } + +// MARK: - EmbeddedPaymentElement.PaymentOptionDisplayData + +extension EmbeddedPaymentElement.PaymentOptionDisplayData { + init(paymentOption: PaymentOption, mandateText: NSAttributedString?) { + self.mandateText = mandateText + self.image = paymentOption.makeIcon(updateImageHandler: nil) // ☠️ This can make a blocking network request TODO: https://jira.corp.stripe.com/browse/MOBILESDK-2604 Refactor this! + switch paymentOption { + case .applePay: + label = String.Localized.apple_pay + paymentMethodType = "apple_pay" + billingDetails = nil + case .saved(let paymentMethod, _): + label = paymentMethod.paymentSheetLabel + paymentMethodType = paymentMethod.type.identifier + billingDetails = paymentMethod.billingDetails?.toPaymentSheetBillingDetails() + case .new(let confirmParams): + label = confirmParams.paymentSheetLabel + paymentMethodType = confirmParams.paymentMethodType.identifier + billingDetails = confirmParams.paymentMethodParams.billingDetails?.toPaymentSheetBillingDetails() + case .link(let option): + label = option.paymentSheetLabel + paymentMethodType = STPPaymentMethodType.link.identifier + billingDetails = option.billingDetails?.toPaymentSheetBillingDetails() + case .external(let paymentMethod, let stpBillingDetails): + label = paymentMethod.label + paymentMethodType = paymentMethod.type + billingDetails = stpBillingDetails.toPaymentSheetBillingDetails() + } + } +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement.swift index f5df948e4fe..a1ba6715ed0 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement.swift @@ -44,12 +44,16 @@ public final class EmbeddedPaymentElement { public let paymentMethodType: String /// If you set `configuration.embeddedViewDisplaysMandateText = false`, this text must be displayed to the customer near your “Buy” button to comply with regulations. public let mandateText: NSAttributedString? + } /// Contains information about the customer's selected payment option. /// Use this to display the payment option in your own UI public var paymentOption: PaymentOptionDisplayData? { - return embeddedPaymentMethodsView.displayData + guard let _paymentOption else { + return nil + } + return .init(paymentOption: _paymentOption, mandateText: embeddedPaymentMethodsView.mandateText) } /// An asynchronous failable initializer @@ -103,7 +107,8 @@ public final class EmbeddedPaymentElement { currentUpdateTask?.cancel() _ = await currentUpdateTask?.value // Start the new update task - let currentUpdateTask = Task { [weak self, configuration, paymentOption, analyticsHelper] in + let currentUpdateTask = Task { @MainActor [weak self, configuration, analyticsHelper] in + // ⚠️ Don't modify `self` until the end to avoid being canceled halfway through and leaving self in a partially updated state. // 1. Reload v1/elements/session. let loadResult: PaymentSheetLoader.LoadResult do { @@ -126,26 +131,28 @@ public final class EmbeddedPaymentElement { configuration: configuration, loadResult: loadResult, analyticsHelper: analyticsHelper, + previousPaymentOption: self?._paymentOption, delegate: self - // TODO: https://jira.corp.stripe.com/browse/MOBILESDK-2583 Restore previous payment option ) - // 2. Pre-load image into cache - // Hack: Accessing paymentOption has the side-effect of ensuring its `image` property is loaded (from the internet instead of disk) before we call the completion handler. + // 3. Pre-load image into cache // Call this on a detached Task b/c this synchronously (!) loads the image from network and we don't want to block the main actor let fetchPaymentOption = Task.detached(priority: .userInitiated) { - return await embeddedPaymentMethodsView.displayData + // This has the nasty side effect of synchronously downloading the image (see https://jira.corp.stripe.com/browse/MOBILESDK-2604) + // This caches it so that DownloadManager doesn't block the main thread when the merchant tries to access the image + return await embeddedPaymentMethodsView.selection?.paymentMethodType?.makeImage(updateHandler: nil) } _ = await fetchPaymentOption.value guard let self, !Task.isCancelled else { return .canceled } - // At this point, we're the latest update - update self properties and inform our delegate. + // At this point, we're still the latest update and update is successful - update self properties and inform our delegate. + let oldPaymentOption = self.paymentOption self.loadResult = loadResult self.embeddedPaymentMethodsView = embeddedPaymentMethodsView self.containerView.updateEmbeddedPaymentMethodsView(embeddedPaymentMethodsView) - if paymentOption != embeddedPaymentMethodsView.displayData { + if oldPaymentOption != self.paymentOption { self.delegate?.embeddedPaymentElementDidUpdatePaymentOption(embeddedPaymentElement: self) } return .succeeded @@ -169,6 +176,31 @@ public final class EmbeddedPaymentElement { internal private(set) var loadResult: PaymentSheetLoader.LoadResult internal private(set) var currentUpdateTask: Task? private let analyticsHelper: PaymentSheetAnalyticsHelper + internal var _paymentOption: PaymentOption? { + // TODO: Handle forms. See `PaymentSheetVerticalViewController.selectedPaymentOption`. + // TODO: Handle CVC recollection + switch embeddedPaymentMethodsView.selection { + case .applePay: + return .applePay + case .link: + return .link(option: .wallet) + case let .new(paymentMethodType: paymentMethodType): + let params = IntentConfirmParams(type: paymentMethodType) + params.setDefaultBillingDetailsIfNecessary(for: configuration) + switch paymentMethodType { + case .stripe: + return .new(confirmParams: params) + case .external(let type): + return .external(paymentMethod: type, billingDetails: params.paymentMethodParams.nonnil_billingDetails) + case .instantDebits, .linkCardBrand: + return .new(confirmParams: params) + } + case .saved(paymentMethod: let paymentMethod): + return .saved(paymentMethod: paymentMethod, confirmParams: nil) + case .none: + return nil + } + } private init( configuration: Configuration, @@ -278,3 +310,12 @@ extension EmbeddedPaymentElement { public typealias BillingDetailsCollectionConfiguration = PaymentSheet.BillingDetailsCollectionConfiguration public typealias ExternalPaymentMethodConfiguration = PaymentSheet.ExternalPaymentMethodConfiguration } + +// MARK: - EmbeddedPaymentElement.PaymentOptionDisplayData + +extension EmbeddedPaymentElement.PaymentOptionDisplayData { + public static func == (lhs: Self, rhs: Self) -> Bool { + // Unfortunately, we need to manually define this because the implementation of Equatable on UIImage does not work + return lhs.image.pngData() == rhs.image.pngData() && rhs.label == lhs.label && lhs.billingDetails == rhs.billingDetails && lhs.paymentMethodType == rhs.paymentMethodType && lhs.mandateText == rhs.mandateText + } +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentMethodsView.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentMethodsView.swift index 18df4d059e9..b23d5c268f7 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentMethodsView.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentMethodsView.swift @@ -6,7 +6,6 @@ // import Foundation -@_spi(STP) import StripeCore @_spi(STP) import StripeUICore import UIKit @@ -21,11 +20,6 @@ class EmbeddedPaymentMethodsView: UIView { typealias Selection = VerticalPaymentMethodListSelection // TODO(porter) Maybe define our own later - var displayData: EmbeddedPaymentElement.PaymentOptionDisplayData? { - guard let selection else { return nil } - return .init(selection: selection, mandateText: mandateView.attributedText) - } - private let appearance: PaymentSheet.Appearance private(set) var selection: Selection? { didSet { @@ -37,8 +31,13 @@ class EmbeddedPaymentMethodsView: UIView { } private let mandateProvider: MandateTextProvider private let shouldShowMandate: Bool + /// A bit hacky; this is the mandate text for the given payment method, *regardless* of whether it is shown in the view. + /// It'd be better if the source of truth of mandate text was not the view and instead an independent `func mandateText(...) -> NSAttributedString` function, but this is hard b/c US Bank Account doesn't show mandate in certain states. + var mandateText: NSAttributedString? { + mandateView.attributedText + } - lazy var stackView: UIStackView = { + private(set) lazy var stackView: UIStackView = { let stackView = UIStackView() stackView.axis = .vertical stackView.spacing = appearance.embeddedPaymentElement.style == .floatingButton ? appearance.embeddedPaymentElement.row.floating.spacing : 0 @@ -136,16 +135,24 @@ class EmbeddedPaymentMethodsView: UIView { stackView.addArrangedSubview(linkRowButton) } + // Add all non-card PMs (card is added above) for paymentMethodType in paymentMethodTypes where paymentMethodType != .stripe(.card) { - stackView.addArrangedSubview(RowButton.makeForPaymentMethodType(paymentMethodType: paymentMethodType, - subtitle: VerticalPaymentMethodListViewController.subtitleText(for: paymentMethodType), - savedPaymentMethodType: savedPaymentMethod?.type, - appearance: rowButtonAppearance, - shouldAnimateOnPress: true, - isEmbedded: true, - didTap: { [weak self] rowButton in - self?.didTap(selectedRowButton: rowButton, selection: .new(paymentMethodType: paymentMethodType)) - })) + let selection: Selection = .new(paymentMethodType: paymentMethodType) + let rowButton = RowButton.makeForPaymentMethodType( + paymentMethodType: paymentMethodType, + subtitle: VerticalPaymentMethodListViewController.subtitleText(for: paymentMethodType), + savedPaymentMethodType: savedPaymentMethod?.type, + appearance: rowButtonAppearance, + shouldAnimateOnPress: true, + isEmbedded: true, + didTap: { [weak self] rowButton in + self?.didTap(selectedRowButton: rowButton, selection: selection) + } + ) + if initialSelection == selection { + rowButton.isSelected = true + } + stackView.addArrangedSubview(rowButton) } if appearance.embeddedPaymentElement.style != .floatingButton { @@ -249,37 +256,3 @@ extension PaymentSheet.Appearance.EmbeddedPaymentElement.Style { } } } -@_spi(STP) import StripePayments -@_spi(STP) import StripePaymentsUI - -extension EmbeddedPaymentElement.PaymentOptionDisplayData { - init(selection: EmbeddedPaymentMethodsView.Selection, mandateText: NSAttributedString?) { - self.mandateText = mandateText - - switch selection { - case .new(paymentMethodType: let paymentMethodType): - image = paymentMethodType.makeImage( - forDarkBackground: UITraitCollection.current.isDarkMode, - updateHandler: nil - ) - label = paymentMethodType.displayName - self.paymentMethodType = paymentMethodType.identifier - billingDetails = nil // TODO(porter) Handle billing details when we present forms (maybe set this to defaultBillingDetails) if billingDetailsConfiguration.attachDefaultsToPaymentMethod is true - case .saved(paymentMethod: let paymentMethod): - image = paymentMethod.makeIcon() - label = paymentMethod.paymentSheetLabel - paymentMethodType = paymentMethod.type.identifier - billingDetails = paymentMethod.billingDetails?.toPaymentSheetBillingDetails() - case .applePay: - image = Image.apple_pay_mark.makeImage().withRenderingMode(.alwaysOriginal) - label = .Localized.apple_pay - paymentMethodType = "apple_pay" - billingDetails = nil // TODO(porter) Handle billing details when we present forms - case .link: - image = Image.link_logo.makeImage() - label = STPPaymentMethodType.link.displayName - paymentMethodType = STPPaymentMethodType.link.identifier - billingDetails = nil // TODO(porter) Handle billing details when we present forms - } - } -} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/MandateTextProvider.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/MandateTextProvider.swift index 95cc5f9e1d3..77ce5ea9f7b 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/MandateTextProvider.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/MandateTextProvider.swift @@ -31,46 +31,45 @@ class VerticalListMandateProvider: MandateTextProvider { /// - Parameter paymentMethodType: The payment method type who's mandate should be constructed /// - Parameter savedPaymentMethod: The currently selected saved payment method if any /// - Parameter bottomNoticeAttributedString: Passing this in just makes this method return it - /// - Returns: An `NSAttributedString` representing the mandate to be displayed for `paymentMethodType`. + /// - Returns: An `NSAttributedString` representing the mandate to be displayed for `paymentMethodType` or `nil` if there is no mandate. func mandate(for paymentMethodType: PaymentSheet.PaymentMethodType?, savedPaymentMethod: STPPaymentMethod?, bottomNoticeAttributedString: NSAttributedString? = nil) -> NSAttributedString? { - let newMandateText: NSAttributedString? = { - guard let paymentMethodType else { return nil } - if savedPaymentMethod != nil { - // 1. For saved PMs, manually build mandates - switch paymentMethodType { - case .stripe(.USBankAccount): - return USBankAccountPaymentMethodElement.attributedMandateTextSavedPaymentMethod(alignment: .natural, theme: configuration.appearance.asElementsTheme) - case .stripe(.SEPADebit): - return .init(string: String(format: String.Localized.sepa_mandate_text, configuration.merchantDisplayName)) - default: - return nil - } - } else { - // 2. For new PMs, see if we have a bottomNoticeAttributedString, typically just US bank acct. and Link Instant Debits - if let bottomNoticeAttributedString { - return bottomNoticeAttributedString - } - // 3. If not, generate the form - let form = PaymentSheetFormFactory( - intent: intent, - elementsSession: elementsSession, - configuration: .paymentSheet(configuration), - paymentMethod: paymentMethodType, - previousCustomerInput: nil, - linkAccount: LinkAccountContext.shared.account, - analyticsHelper: analyticsHelper - ).make() - - guard !form.collectsUserInput else { - // If it collects user input, the mandate will be displayed in the form and not here - return nil - } - // Get the mandate from the form, if available - // 🙋‍♂️ Note: assumes mandates are SimpleMandateElement! - return form.getAllUnwrappedSubElements().compactMap({ $0 as? SimpleMandateElement }).first?.mandateTextView.attributedText + guard let paymentMethodType else { return nil } + if savedPaymentMethod != nil { + // 1. For saved PMs, manually build mandates + switch paymentMethodType { + case .stripe(.USBankAccount): + return USBankAccountPaymentMethodElement.attributedMandateTextSavedPaymentMethod(alignment: .natural, theme: configuration.appearance.asElementsTheme) + case .stripe(.SEPADebit): + return .init(string: String(format: String.Localized.sepa_mandate_text, configuration.merchantDisplayName)) + default: + return nil + } + } else { + // 2. For new PMs, see if we have a bottomNoticeAttributedString, typically just US bank acct. and Link Instant Debits + if let bottomNoticeAttributedString { + return bottomNoticeAttributedString } - }() + // 3. If not, generate the form + let form = PaymentSheetFormFactory( + intent: intent, + elementsSession: elementsSession, + configuration: .paymentSheet(configuration), + paymentMethod: paymentMethodType, + previousCustomerInput: nil, + linkAccount: LinkAccountContext.shared.account, + analyticsHelper: analyticsHelper + ).make() - return newMandateText + guard !form.collectsUserInput else { + // If it collects user input, the mandate will be displayed in the form and not here + return nil + } + // Get the mandate from the form, if available + // 🙋‍♂️ Note: assumes mandates are SimpleMandateElement! + if let mandateText = form.getAllUnwrappedSubElements().compactMap({ $0 as? SimpleMandateElement }).first?.mandateTextView.attributedText, !mandateText.string.isEmpty { + return mandateText + } + return nil + } } } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Vertical Main Screen/RowButton.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Vertical Main Screen/RowButton.swift index 2c6a50d8de1..60bfb03779a 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Vertical Main Screen/RowButton.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Vertical Main Screen/RowButton.swift @@ -47,7 +47,7 @@ class RowButton: UIView { init(appearance: PaymentSheet.Appearance, imageView: UIImageView, text: String, subtext: String? = nil, rightAccessoryView: UIView? = nil, shouldAnimateOnPress: Bool = false, isEmbedded: Bool = false, didTap: @escaping DidTapClosure) { self.appearance = appearance - self.shouldAnimateOnPress = shouldAnimateOnPress + self.shouldAnimateOnPress = true self.didTap = didTap self.shadowRoundedRect = ShadowedRoundedRectangle(appearance: appearance) self.imageView = imageView diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Views/SimpleMandateTextView.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Views/SimpleMandateTextView.swift index cecd5f71a5e..c78ff4642c8 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Views/SimpleMandateTextView.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Views/SimpleMandateTextView.swift @@ -17,7 +17,7 @@ class SimpleMandateTextView: UIView { let textView: UITextView = UITextView() var attributedText: NSAttributedString? { get { - textView.attributedText + textView.attributedText.string.isEmpty ? nil : textView.attributedText } set { textView.attributedText = newValue diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/EmbeddedPaymentElementTest.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/EmbeddedPaymentElementTest.swift index 2c268b479c0..2cdacfc5df8 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/EmbeddedPaymentElementTest.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/EmbeddedPaymentElementTest.swift @@ -13,47 +13,69 @@ import XCTest @_spi(EmbeddedPaymentElementPrivateBeta) @_spi(STP) @testable import StripePaymentSheet @MainActor -class EmbeddedPaymentElementTest: STPNetworkStubbingTestCase { +// https://jira.corp.stripe.com/browse/MOBILESDK-2607 Make these STPNetworkStubbingTestCase; blocked on getting them to record image requests +class EmbeddedPaymentElementTest: XCTestCase { lazy var configuration: EmbeddedPaymentElement.Configuration = { var config = EmbeddedPaymentElement.Configuration._testValue_MostPermissive(isApplePayEnabled: false) config.apiClient = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) return config }() - let paymentIntentConfig = EmbeddedPaymentElement.IntentConfiguration(mode: .payment(amount: 1000, currency: "USD"), paymentMethodTypes: ["card"]) { _, _, _ in + let paymentIntentConfig = EmbeddedPaymentElement.IntentConfiguration(mode: .payment(amount: 1000, currency: "USD"), paymentMethodTypes: ["card", "cashapp"]) { _, _, _ in // These tests don't confirm, so this is unused } - let setupIntentConfig = EmbeddedPaymentElement.IntentConfiguration(mode: .setup(setupFutureUsage: .offSession), paymentMethodTypes: ["card"]) { _, _, _ in + let paymentIntentConfig2 = EmbeddedPaymentElement.IntentConfiguration(mode: .payment(amount: 999, currency: "USD"), paymentMethodTypes: ["card", "cashapp"]) { _, _, _ in // These tests don't confirm, so this is unused } + let setupIntentConfig = EmbeddedPaymentElement.IntentConfiguration(mode: .setup(setupFutureUsage: .offSession), paymentMethodTypes: ["card", "cashapp"]) { _, _, _ in + // These tests don't confirm, so this is unused + } + var delegateDidUpdatePaymentOptionCalled = false + var delegateDidUpdateHeightCalled = false // MARK: - `update` tests func testUpdate() async throws { STPAnalyticsClient.sharedClient._testLogHistory = [] - CustomerPaymentOption.setDefaultPaymentMethod(.applePay, forCustomer: nil) + CustomerPaymentOption.setDefaultPaymentMethod(nil, forCustomer: nil) // Given a EmbeddedPaymentElement instance... let sut = try await EmbeddedPaymentElement.create(intentConfiguration: paymentIntentConfig, configuration: configuration) sut.delegate = self + sut.view.autosizeHeight(width: 320) + // ...with cash app selected... + let cashAppPayRowButton = sut.embeddedPaymentMethodsView.getRowButton(accessibilityIdentifier: "Cash App Pay") + sut.embeddedPaymentMethodsView.didTap(selectedRowButton: cashAppPayRowButton, selection: .new(paymentMethodType: .stripe(.cashApp))) + delegateDidUpdatePaymentOptionCalled = false // This gets set to true when we select cash app ^ + XCTAssertNil(sut.paymentOption?.mandateText) // ...its intent should match the initial intent config... - XCTAssertFalse(sut.loadResult.intent.isSettingUp) - XCTAssertTrue(sut.loadResult.intent.isPaymentIntent) - - // ...and updating the intent config should succeed... - let update1Result = await sut.update(intentConfiguration: setupIntentConfig) + XCTAssertEqual(sut.loadResult.intent.amount, 1000) + // ...and updating the amount should succeed... + let update1Result = await sut.update(intentConfiguration: paymentIntentConfig2 + ) XCTAssertEqual(update1Result, .succeeded) - XCTAssertTrue(sut.loadResult.intent.isSettingUp) - XCTAssertFalse(sut.loadResult.intent.isPaymentIntent) - - // ...updating the intent config multiple times... + XCTAssertEqual(sut.loadResult.intent.amount, 999) + // ...without invoking the delegate (since neither height nor payment option updated) + XCTAssertFalse(delegateDidUpdateHeightCalled) + XCTAssertFalse(delegateDidUpdatePaymentOptionCalled) + // ...and preserve the cash app pay selection + XCTAssertEqual(sut.paymentOption?.label, "Cash App Pay") + + // Updating the intent config from payment to setup... // ...(using the completion block based API this time)... let secondUpdateExpectation = expectation(description: "Second update completes") - sut.update(intentConfiguration: paymentIntentConfig) { update2Result in + // ...(and resetting the delegate trackers)... + delegateDidUpdatePaymentOptionCalled = false + delegateDidUpdatePaymentOptionCalled = false + sut.update(intentConfiguration: setupIntentConfig) { update2Result in // ...should succeed. XCTAssertEqual(update2Result, .succeeded) - XCTAssertFalse(sut.loadResult.intent.isSettingUp) - XCTAssertTrue(sut.loadResult.intent.isPaymentIntent) - // TODO: Test paymentOption updates correctly. + XCTAssertFalse(sut.loadResult.intent.isPaymentIntent) + // ...and preserve the cash app pay selection + XCTAssertEqual(sut.paymentOption?.label, "Cash App Pay") + // ...and invoke both delegate methods since cash app now has a mandate + XCTAssertNotNil(sut.paymentOption?.mandateText) + XCTAssertTrue(self.delegateDidUpdateHeightCalled) + XCTAssertTrue(self.delegateDidUpdatePaymentOptionCalled) // Sanity check that the analytics... let analytics = STPAnalyticsClient.sharedClient._testLogHistory @@ -81,7 +103,7 @@ class EmbeddedPaymentElementTest: STPNetworkStubbingTestCase { intentConfig.mode = .setup(currency: "Invalid currency", setupFutureUsage: .offSession) let updateResult = await sut.update(intentConfiguration: intentConfig) switch updateResult { - case .failed(error: let error): + case .failed: break default: XCTFail() @@ -116,9 +138,13 @@ class EmbeddedPaymentElementTest: STPNetworkStubbingTestCase { extension EmbeddedPaymentElementTest: EmbeddedPaymentElementDelegate { // TODO: Test delegates are called - func embeddedPaymentElementDidUpdateHeight(embeddedPaymentElement: StripePaymentSheet.EmbeddedPaymentElement) {} + func embeddedPaymentElementDidUpdateHeight(embeddedPaymentElement: StripePaymentSheet.EmbeddedPaymentElement) { + delegateDidUpdateHeightCalled = true + } - func embeddedPaymentElementDidUpdatePaymentOption(embeddedPaymentElement: StripePaymentSheet.EmbeddedPaymentElement) {} + func embeddedPaymentElementDidUpdatePaymentOption(embeddedPaymentElement: StripePaymentSheet.EmbeddedPaymentElement) { + delegateDidUpdatePaymentOptionCalled = true + } } extension EmbeddedPaymentElement.UpdateResult: Equatable { @@ -131,3 +157,13 @@ extension EmbeddedPaymentElement.UpdateResult: Equatable { } } } + +extension EmbeddedPaymentMethodsView { + var rowButtons: [RowButton] { + return stackView.arrangedSubviews.compactMap { $0 as? RowButton } + } + + func getRowButton(accessibilityIdentifier: String) -> RowButton { + return rowButtons.first { $0.accessibilityIdentifier == accessibilityIdentifier }! + } +}