diff --git a/Stripe/StripeiOSTests/LinkInlineSignupElementSnapshotTests.swift b/Stripe/StripeiOSTests/LinkInlineSignupElementSnapshotTests.swift index 90d1e10a665..26c93413d43 100644 --- a/Stripe/StripeiOSTests/LinkInlineSignupElementSnapshotTests.swift +++ b/Stripe/StripeiOSTests/LinkInlineSignupElementSnapshotTests.swift @@ -148,6 +148,7 @@ extension LinkInlineSignupElementSnapshotTests { configuration: configuration, showCheckbox: showCheckbox, accountService: MockAccountService(), + previousCustomerInput: nil, linkAccount: linkAccount, country: country ) diff --git a/Stripe/StripeiOSTests/LinkSignupViewModelTests.swift b/Stripe/StripeiOSTests/LinkSignupViewModelTests.swift index a5d2441e12d..fd2f23be76e 100644 --- a/Stripe/StripeiOSTests/LinkSignupViewModelTests.swift +++ b/Stripe/StripeiOSTests/LinkSignupViewModelTests.swift @@ -12,7 +12,6 @@ import StripeCoreTestUtils import XCTest @testable@_spi(STP) import Stripe -@testable@_spi(STP) import StripeCore @testable@_spi(STP) import StripePayments @testable@_spi(STP) import StripePaymentSheet import StripePaymentsTestUtils @@ -208,6 +207,7 @@ extension LinkInlineSignupViewModelTests { configuration: PaymentSheet.Configuration(), showCheckbox: showCheckbox, accountService: MockAccountService(shouldFailLookup: shouldFailLookup), + previousCustomerInput: nil, linkAccount: linkAccount, country: country ) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Elements/InlineSignup/LinkInlineSignupElement.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Elements/InlineSignup/LinkInlineSignupElement.swift index e1218e38bd0..f70c56f3797 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Elements/InlineSignup/LinkInlineSignupElement.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Elements/InlineSignup/LinkInlineSignupElement.swift @@ -10,7 +10,7 @@ import UIKit // TODO: Refactor this to be a ContainerElement and contain its sub-elements. -final class LinkInlineSignupElement: Element { +final class LinkInlineSignupElement: PaymentMethodElement { let collectsUserInput: Bool = true let signupView: LinkInlineSignupView @@ -40,12 +40,14 @@ final class LinkInlineSignupElement: Element { configuration: PaymentSheet.Configuration, linkAccount: PaymentSheetLinkAccount?, country: String?, - showCheckbox: Bool + showCheckbox: Bool, + previousCustomerInput: IntentConfirmParams? ) { self.init(viewModel: LinkInlineSignupViewModel( configuration: configuration, showCheckbox: showCheckbox, accountService: LinkAccountService(apiClient: configuration.apiClient), + previousCustomerInput: previousCustomerInput?.linkInlineSignupCustomerInput, linkAccount: linkAccount, country: country )) @@ -56,6 +58,15 @@ final class LinkInlineSignupElement: Element { self.signupView.delegate = self } + func updateParams(params: IntentConfirmParams) -> IntentConfirmParams? { + params.linkInlineSignupCustomerInput = .init( + phoneNumber: signupView.phoneNumberElement.phoneNumber, + name: signupView.nameElement.text, + email: signupView.emailElement.emailAddressString, + checkboxSelected: signupView.checkboxElement.isChecked + ) + return params + } } extension LinkInlineSignupElement: LinkInlineSignupViewDelegate { diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Elements/InlineSignup/LinkInlineSignupView-CheckboxElement.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Elements/InlineSignup/LinkInlineSignupView-CheckboxElement.swift index 51a1d2f4f03..934467f11a2 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Elements/InlineSignup/LinkInlineSignupView-CheckboxElement.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Elements/InlineSignup/LinkInlineSignupView-CheckboxElement.swift @@ -21,6 +21,7 @@ extension LinkInlineSignupView { private let appearance: PaymentSheet.Appearance /// Controls the stroke color of the checkbox private let borderColor: UIColor + let initialIsSelectedValue: Bool var view: UIView { return checkboxButton @@ -56,15 +57,16 @@ extension LinkInlineSignupView { let checkbox = CheckboxButton(text: text, description: description, theme: appearanceCopy.asElementsTheme) checkbox.addTarget(self, action: #selector(didToggleCheckbox), for: .touchUpInside) - checkbox.isSelected = false + checkbox.isSelected = initialIsSelectedValue return checkbox }() - init(merchantName: String, appearance: PaymentSheet.Appearance, borderColor: UIColor) { + init(merchantName: String, appearance: PaymentSheet.Appearance, borderColor: UIColor, isSelected: Bool) { self.merchantName = merchantName self.appearance = appearance self.borderColor = borderColor + self.initialIsSelectedValue = isSelected } func setUserInteraction(isUserInteractionEnabled: Bool) { diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Elements/InlineSignup/LinkInlineSignupView.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Elements/InlineSignup/LinkInlineSignupView.swift index c78c5ffe111..c0ab7c9e343 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Elements/InlineSignup/LinkInlineSignupView.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Elements/InlineSignup/LinkInlineSignupView.swift @@ -31,11 +31,12 @@ final class LinkInlineSignupView: UIView { private(set) lazy var checkboxElement = CheckboxElement( merchantName: viewModel.configuration.merchantDisplayName, appearance: viewModel.configuration.appearance, - borderColor: borderColor + borderColor: borderColor, + isSelected: viewModel.saveCheckboxChecked ) private(set) lazy var emailElement: LinkEmailElement = { - let element = LinkEmailElement(defaultValue: viewModel.emailAddress, + let element = LinkEmailElement(defaultValue: viewModel.initialEmail ?? viewModel.emailAddress, isOptional: viewModel.isEmailOptional, showLogo: viewModel.mode != .textFieldsOnlyPhoneFirst, theme: theme) @@ -44,7 +45,7 @@ final class LinkInlineSignupView: UIView { }() private(set) lazy var nameElement: TextFieldElement = { - let configuration = TextFieldElement.NameConfiguration(type: .full, defaultValue: viewModel.legalName) + let configuration = TextFieldElement.NameConfiguration(type: .full, defaultValue: viewModel.initialName ?? viewModel.legalName) return TextFieldElement(configuration: configuration, theme: theme) }() @@ -53,15 +54,28 @@ final class LinkInlineSignupView: UIView { // Otherwise, we'd imply consumer consent when it hasn't occurred. switch viewModel.mode { case .checkbox: + let defaultCountryCode = viewModel.initialPhoneNumber?.countryCode ?? viewModel.configuration.defaultBillingDetails.address.country + let defaultPhoneNumber = viewModel.initialPhoneNumber?.number ?? viewModel.configuration.defaultBillingDetails.phone return PhoneNumberElement( - defaultCountryCode: viewModel.configuration.defaultBillingDetails.address.country, - defaultPhoneNumber: viewModel.configuration.defaultBillingDetails.phone, + defaultCountryCode: defaultCountryCode, + defaultPhoneNumber: defaultPhoneNumber, theme: theme ) case .textFieldsOnlyEmailFirst: - return PhoneNumberElement(isOptional: viewModel.isPhoneNumberOptional, theme: theme) + return PhoneNumberElement( + defaultCountryCode: viewModel.initialPhoneNumber?.countryCode, + defaultPhoneNumber: viewModel.initialPhoneNumber?.number, + isOptional: viewModel.isPhoneNumberOptional, + theme: theme + ) case .textFieldsOnlyPhoneFirst: - return PhoneNumberElement(isOptional: viewModel.isPhoneNumberOptional, infoView: LinkMoreInfoView(), theme: theme) + return PhoneNumberElement( + defaultCountryCode: viewModel.initialPhoneNumber?.countryCode, + defaultPhoneNumber: viewModel.initialPhoneNumber?.number, + isOptional: viewModel.isPhoneNumberOptional, + infoView: LinkMoreInfoView(), + theme: theme + ) } }() diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/ViewModels/LinkInlineSignupViewModel.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/ViewModels/LinkInlineSignupViewModel.swift index f5fee365ace..3cb9411c091 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/ViewModels/LinkInlineSignupViewModel.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/ViewModels/LinkInlineSignupViewModel.swift @@ -15,6 +15,13 @@ protocol LinkInlineSignupViewModelDelegate: AnyObject { func signupViewModelDidUpdate(_ viewModel: LinkInlineSignupViewModel) } +struct LinkInlineSignupCustomerInput: Equatable { + let phoneNumber: PhoneNumber? + let name: String? + let email: String? + let checkboxSelected: Bool? +} + final class LinkInlineSignupViewModel { enum Action: Equatable { case signupAndPay(account: PaymentSheetLinkAccount, phoneNumber: PhoneNumber, legalName: String?) @@ -38,8 +45,11 @@ final class LinkInlineSignupViewModel { let configuration: PaymentSheet.Configuration let mode: Mode + let initialEmail: String? + let initialPhoneNumber: PhoneNumber? + let initialName: String? - var saveCheckboxChecked: Bool = false { + var saveCheckboxChecked: Bool { didSet { if saveCheckboxChecked != oldValue { notifyUpdate() @@ -291,6 +301,7 @@ final class LinkInlineSignupViewModel { configuration: PaymentSheet.Configuration, showCheckbox: Bool, accountService: LinkAccountServiceProtocol, + previousCustomerInput: LinkInlineSignupCustomerInput?, linkAccount: PaymentSheetLinkAccount? = nil, country: String? = nil ) { @@ -298,6 +309,10 @@ final class LinkInlineSignupViewModel { self.accountService = accountService self.linkAccount = linkAccount self.emailAddress = linkAccount?.email + self.saveCheckboxChecked = previousCustomerInput?.checkboxSelected ?? false + self.initialEmail = previousCustomerInput?.email + self.initialPhoneNumber = previousCustomerInput?.phoneNumber + self.initialName = previousCustomerInput?.name if let email = self.emailAddress, !email.isEmpty { emailWasPrefilled = true diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/IntentConfirmParams.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/IntentConfirmParams.swift index f418964aad4..879adf42157 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/IntentConfirmParams.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/IntentConfirmParams.swift @@ -36,6 +36,8 @@ final class IntentConfirmParams { var financialConnectionsLinkedBank: FinancialConnectionsLinkedBank? var instantDebitsLinkedBank: InstantDebitsLinkedBank? + /// Hack: Contains the customer input in the link inline signup element (e.g. email, checkbox state) so that it can be preserved across `FlowController.update` etc. + var linkInlineSignupCustomerInput: LinkInlineSignupCustomerInput? var paymentSheetLabel: String { if let last4 = (financialConnectionsLinkedBank?.last4 ?? instantDebitsLinkedBank?.last4) { diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetFormFactory/PaymentSheetFormFactory+Card.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetFormFactory/PaymentSheetFormFactory+Card.swift index 3e3d35d570b..e380ed03741 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetFormFactory/PaymentSheetFormFactory+Card.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetFormFactory/PaymentSheetFormFactory+Card.swift @@ -93,7 +93,8 @@ extension PaymentSheetFormFactory { configuration: configuration, linkAccount: linkAccount, country: countryCode, - showCheckbox: !shouldDisplaySaveCheckbox + showCheckbox: !shouldDisplaySaveCheckbox, + previousCustomerInput: previousCustomerInput ) elements.append(inlineSignupElement) } diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/CardSectionElementTest.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/CardSectionElementTest.swift index 626f8d9b41b..ae8e2cdf31b 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/CardSectionElementTest.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/CardSectionElementTest.swift @@ -5,19 +5,19 @@ // Created by Yuki Tokuhiro on 10/2/24. // -import XCTest @testable@_spi(STP) import StripeCore @testable@_spi(STP) import StripePayments @testable@_spi(STP) import StripePaymentSheet @testable@_spi(STP) import StripePaymentsTestUtils @testable@_spi(STP) import StripePaymentsUI @testable@_spi(STP) import StripeUICore +import XCTest @MainActor final class CardSectionElementTest: XCTestCase { let window: UIWindow = UIWindow(frame: .init(x: 0, y: 0, width: 428, height: 926)) - func testPreservesPreviousCustomerInput() async { + func testLinkSignupPreservesPreviousCustomerInput() async { await PaymentSheetLoader.loadMiscellaneousSingletons() func makeForm(previousCustomerInput: IntentConfirmParams?) -> PaymentMethodElement { let intent: Intent = ._testPaymentIntent(paymentMethodTypes: [.card]) @@ -27,7 +27,7 @@ final class CardSectionElementTest: XCTestCase { elementsSession: ._testValue(paymentMethodTypes: ["card"], isLinkPassthroughModeEnabled: true), previousCustomerInput: previousCustomerInput, formCache: .init(), - configuration: configuration, + configuration: .init(), headerView: nil, analyticsHelper: ._testValue(), delegate: self @@ -40,29 +40,25 @@ final class CardSectionElementTest: XCTestCase { formVC.viewDidAppear(false) return formVC.form } - var configuration = PaymentSheet.Configuration() - configuration.customer = .init(id: "id", ephemeralKeySecret: "sec") let form = makeForm(previousCustomerInput: nil) - let checkbox = form.getCheckboxElement(startingWith: "Save payment details")! let linkInlineSignupElement: LinkInlineSignupElement = form.getElement()! let linkInlineView = linkInlineSignupElement.signupView - - XCTAssertNotNil(checkbox) // Checkbox should appear since this is a PI w/ customer + XCTAssertNotNil(linkInlineView.checkboxElement) // Checkbox should appear since this is a PI w/o customer form.getTextFieldElement("Card number")?.setText("4242424242424242") form.getTextFieldElement("MM / YY").setText("1232") form.getTextFieldElement("CVC").setText("123") form.getTextFieldElement("ZIP").setText("65432") - - XCTAssertEqual(form.getAllUnwrappedSubElements().count, 14) -// XCTAssertNotNil(form.mandateString) + // Simulate selecting checkbox - checkbox.isSelected = true - checkbox.didToggleCheckbox() - + linkInlineView.checkboxElement.isChecked = true + linkInlineView.checkboxElement.didToggleCheckbox() + // Set the email & phone number - linkInlineView.emailElement.emailAddressElement.setText("\(UUID().uuidString)@foo.com") + let email = "\(UUID().uuidString)@foo.com" + linkInlineView.emailElement.emailAddressElement.setText(email) linkInlineView.phoneNumberElement.countryDropdownElement.setRawData("GB") linkInlineView.phoneNumberElement.textFieldElement.setText("1234567890") + linkInlineView.nameElement.setText("John Doe") // Generate params from the form guard let intentConfirmParams = form.updateParams(params: IntentConfirmParams(type: .stripe(.card))) else { @@ -72,17 +68,17 @@ final class CardSectionElementTest: XCTestCase { // Re-generate the form and validate that it carries over all previous customer input let regeneratedForm = makeForm(previousCustomerInput: intentConfirmParams) + let regeneratedLinkInlineSignupElement: LinkInlineSignupElement = regeneratedForm.getElement()! + let regeneratedLinkInlineView = linkInlineSignupElement.signupView guard let regeneratedIntentConfirmParams = regeneratedForm.updateParams(params: IntentConfirmParams(type: .stripe(.card))) else { XCTFail("Regenerated form failed to create params. Validation state: \(regeneratedForm.validationState) \n Form: \(regeneratedForm)") return } - // Ensure checkbox remains selected - XCTAssertTrue(regeneratedForm.getCheckboxElement(startingWith: "Save payment details")!.isSelected) XCTAssertEqual(regeneratedIntentConfirmParams, intentConfirmParams) - let linkInlineSignupElement2: LinkInlineSignupElement = regeneratedForm.getElement()! - let linkInlineView2 = linkInlineSignupElement2.signupView - print(linkInlineView2) - + XCTAssertTrue(regeneratedLinkInlineSignupElement.isChecked) + XCTAssertEqual(regeneratedLinkInlineView.emailElement.emailAddressString, email) + XCTAssertEqual(regeneratedLinkInlineView.nameElement.text, "John Doe") + XCTAssertEqual(regeneratedLinkInlineView.phoneNumberElement.phoneNumber, PhoneNumber(number: "1234567890", countryCode: "GB")) } } diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetLPMConfirmFlowTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetLPMConfirmFlowTests.swift index 8b7859001b4..d77ac2c7d2a 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetLPMConfirmFlowTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetLPMConfirmFlowTests.swift @@ -979,11 +979,15 @@ extension IntentConfirmParams: Equatable { print("Instant debits linked banks not equal: \(lhs.instantDebitsLinkedBank.debugDescription) vs \(rhs.instantDebitsLinkedBank.debugDescription)") return false } + if lhs.linkInlineSignupCustomerInput != rhs.linkInlineSignupCustomerInput { + print("Link inline signup customer input not equal: \(lhs.linkInlineSignupCustomerInput.debugDescription) vs \(rhs.linkInlineSignupCustomerInput.debugDescription)") + return false + } // Sanity check to make sure when we add new properties, we check them here let mirror = Mirror(reflecting: lhs) let propertyCount = mirror.children.count - XCTAssertEqual(propertyCount, 7) + XCTAssertEqual(propertyCount, 8) return true }