From 05cda479c9f173b0761f6362d1735c3d28954626 Mon Sep 17 00:00:00 2001 From: Yuki Date: Fri, 28 Jul 2023 16:22:06 -0700 Subject: [PATCH] Enable iDEAL for SetupIntents and PaymentIntents+setup_future_usage (#2786) * Move Elements helper methods out of test into dedicated file * Refactor makeContactInformation in preparation for usage w/ other PMs. * PR feedback * Undo moving string to different module * PR feedback * Update LPMConfirmFlowTest * Enable iDEAL for SetupIntents and PaymentIntents+setup_future_usage * lint * fix up, call out debug helper * Use makeiDEAL for vanilla PIs too * Only give section a title if theres more than one element to keep consistent with other forms --- CHANGELOG.md | 6 +- .../StripeiOSTests/Elements+TestHelpers.swift | 8 ++- ... => PaymentSheetLPMConfirmFlowTests.swift} | 64 +++++++++++++------ .../PaymentSheetPaymentMethodTypeTest.swift | 6 +- .../PaymentSheet/PaymentMethodType.swift | 8 ++- .../PaymentSheetFormFactory.swift | 46 +++++++++++-- 6 files changed, 109 insertions(+), 29 deletions(-) rename Stripe/StripeiOSTests/{PaymentSheet+LPMTests.swift => PaymentSheetLPMConfirmFlowTests.swift} (73%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 481bae19452..046f530b2ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,14 @@ +## x.y.z 2023-xx-yy +### PaymentSheet +* [Added] Enable SEPA Debit and iDEAL for SetupIntents and PaymentIntents with setup_future_usage. Note: PaymentSheet doesn't display saved SEPA Debit payment methods yet. + ### Identity * [ADDED][2452](https://github.com/stripe/stripe-ios/pull/2452) Supports [phone verification](https://stripe.com/docs/identity/phone) in Identity mobile SDK. + ## 23.11.2 2023-07-24 ### PaymentSheet * [Fixed] Update stp_icon_add@3x.png to 8bit color depth (Thanks @jszumski) -* [Added] Enable SEPA Debit for SetupIntents and PaymentIntents with setup_future_usage. Note: PaymentSheet doesn't display saved SEPA Debit payment methods yet. ### CustomerSheet * [Fixed] Ability to removing payment method immediately after adding it. diff --git a/Stripe/StripeiOSTests/Elements+TestHelpers.swift b/Stripe/StripeiOSTests/Elements+TestHelpers.swift index 5594dbc00bf..66307df1ab2 100644 --- a/Stripe/StripeiOSTests/Elements+TestHelpers.swift +++ b/Stripe/StripeiOSTests/Elements+TestHelpers.swift @@ -50,13 +50,19 @@ extension Element { } } +extension SectionElement: CustomDebugStringConvertible { + public var debugDescription: String { + return ["", title].compactMap { $0 }.joined(separator: " - ") + } +} + extension TextFieldElement: CustomDebugStringConvertible { public var debugDescription: String { return " - \"\(configuration.label)\" - \(validationState)" } } -extension DropdownFieldElement: CustomDebugStringConvertible { +extension DropdownFieldElement { public override var debugDescription: String { return " - \"\(label ?? "nil")\" - \(validationState)" } diff --git a/Stripe/StripeiOSTests/PaymentSheet+LPMTests.swift b/Stripe/StripeiOSTests/PaymentSheetLPMConfirmFlowTests.swift similarity index 73% rename from Stripe/StripeiOSTests/PaymentSheet+LPMTests.swift rename to Stripe/StripeiOSTests/PaymentSheetLPMConfirmFlowTests.swift index bbf85f7f256..603f38f6ea5 100644 --- a/Stripe/StripeiOSTests/PaymentSheet+LPMTests.swift +++ b/Stripe/StripeiOSTests/PaymentSheetLPMConfirmFlowTests.swift @@ -1,5 +1,5 @@ // -// PaymentSheet+LPMTests.swift +// PaymentSheet+LPMConfirmFlowTests.swift // StripeiOSTests // // Created by Yuki Tokuhiro on 7/18/23. @@ -14,14 +14,13 @@ import XCTest @testable@_spi(STP) import StripePaymentSheet @testable@_spi(STP) import StripeUICore -final class PaymentSheet_LPMTests: XCTestCase { +/// These tests exercise 9 different confirm flows based on the combination of: +/// - The Stripe Intent: PaymentIntent or PaymentIntent+SFU or SetupIntent +/// - The confirmation type: "Normal" intent-first client-side confirmation or "Deferred" client-side confirmation or "Deferred" server-side confirmation +/// They can also test the presence/absence of particular fields for a payment method form e.g. the SEPA test asserts that there's a mandate element. +/// 👀 See `testIdealConfirmFlows` for an example with comments. +final class PaymentSheet_LPM_ConfirmFlowTests: XCTestCase { let apiClient = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) - lazy var paymentHandler: STPPaymentHandler = { - return STPPaymentHandler( - apiClient: apiClient, - formSpecPaymentHandler: PaymentSheetFormSpecPaymentHandler() - ) - }() lazy var configuration: PaymentSheet.Configuration = { var config = PaymentSheet.Configuration() config.apiClient = apiClient @@ -47,10 +46,35 @@ final class PaymentSheet_LPMTests: XCTestCase { XCTAssertNotNil(form.getMandateElement()) } } + + /// 👋 👨‍🏫 Look at this test to understand how to write your own tests in this file + @MainActor + func testiDEALConfirmFlows() async throws { + try await _testConfirm(intentKinds: [.paymentIntent], currency: "EUR", paymentMethodType: .dynamic("ideal")) { form in + // Fill out your payment method form in here. + // Note: Each required field you fill out implicitly tests that the field exists; if the field doesn't exist, the test will fail because the form is incomplete. + form.getTextFieldElement("Full name")?.setText("Foo") + XCTAssertNotNil(form.getDropdownFieldElement("iDEAL Bank")) + // You can also explicitly assert for the existence/absence of certain elements. + // e.g. iDEAL shouldn't show a mandate or email field for a vanilla payment + XCTAssertNil(form.getMandateElement()) + XCTAssertNil(form.getTextFieldElement("Email")) + // Tip: To help you debug, print out `form.getAllUnwrappedSubElements()` + } + + // If your payment method shows different fields depending on the kind of intent, you can call `_testConfirm` multiple times with different intents. + // e.g. iDEAL should show an email field and mandate for PI+SFU and SIs, so we test those separately here: + try await _testConfirm(intentKinds: [.paymentIntentWithSetupFutureUsage, .setupIntent], currency: "EUR", paymentMethodType: .dynamic("ideal")) { form in + form.getTextFieldElement("Full name")?.setText("Foo") + form.getTextFieldElement("Email")?.setText("f@z.c") + XCTAssertNotNil(form.getDropdownFieldElement("iDEAL Bank")) + XCTAssertNotNil(form.getMandateElement()) + } + } } // MARK: - Helper methods -extension PaymentSheet_LPMTests { +extension PaymentSheet_LPM_ConfirmFlowTests { enum IntentKind: CaseIterable { case paymentIntent case paymentIntentWithSetupFutureUsage @@ -63,7 +87,7 @@ extension PaymentSheet_LPMTests { } } - /// A helper method that tests three confirmation flows successfully complete: + /// A helper method that creates a form for the given `paymentMethodType` and tests three confirmation flows successfully complete: /// 1. normal" client-side confirmation /// 2. deferred client-side confirmation /// 3. deferred server-side @@ -78,7 +102,8 @@ extension PaymentSheet_LPMTests { } let paymentMethodString = PaymentSheet.PaymentMethodType.string(from: paymentMethodType)! let intents: [(String, Intent)] - let mandateDataParamsForServerSideConfirmation: [String: Any] = [ // We require merchants to set this themselves for server-side confirmation + let paramsForServerSideConfirmation: [String: Any] = [ // We require merchants to set some extra parameters themselves for server-side confirmation + "return_url": "foo://bar", "mandate_data": [ "customer_acceptance": [ "type": "online", @@ -99,7 +124,7 @@ extension PaymentSheet_LPMTests { return try await STPTestingAPIClient.shared.fetchPaymentIntent(types: [paymentMethodString]) } let deferredSSC = PaymentSheet.IntentConfiguration(mode: .payment(amount: 1099, currency: currency)) { paymentMethod, _ in - return try await STPTestingAPIClient.shared.fetchPaymentIntent(types: [paymentMethodString], paymentMethodID: paymentMethod.stripeId, confirm: true, otherParams: mandateDataParamsForServerSideConfirmation) + return try await STPTestingAPIClient.shared.fetchPaymentIntent(types: [paymentMethodString], paymentMethodID: paymentMethod.stripeId, confirm: true, otherParams: paramsForServerSideConfirmation) } intents = [ ("PaymentIntent", .paymentIntent(paymentIntent)), @@ -117,7 +142,7 @@ extension PaymentSheet_LPMTests { let deferredSSC = PaymentSheet.IntentConfiguration(mode: .payment(amount: 1099, currency: currency, setupFutureUsage: .offSession)) { paymentMethod, _ in let otherParams = [ "setup_future_usage": "off_session", - ].merging(mandateDataParamsForServerSideConfirmation) { _, b in b } + ].merging(paramsForServerSideConfirmation) { _, b in b } return try await STPTestingAPIClient.shared.fetchPaymentIntent(types: [paymentMethodString], paymentMethodID: paymentMethod.stripeId, confirm: true, otherParams: otherParams) } intents = [ @@ -134,7 +159,7 @@ extension PaymentSheet_LPMTests { return try await STPTestingAPIClient.shared.fetchSetupIntent(types: [paymentMethodString]) } let deferredSSC = PaymentSheet.IntentConfiguration(mode: .setup(setupFutureUsage: .offSession)) { paymentMethod, _ in - return try await STPTestingAPIClient.shared.fetchSetupIntent(types: [paymentMethodString], paymentMethodID: paymentMethod.stripeId, confirm: true, otherParams: mandateDataParamsForServerSideConfirmation) + return try await STPTestingAPIClient.shared.fetchSetupIntent(types: [paymentMethodString], paymentMethodID: paymentMethod.stripeId, confirm: true, otherParams: paramsForServerSideConfirmation) } intents = [ ("SetupIntent", .setupIntent(setupIntent)), @@ -159,10 +184,13 @@ extension PaymentSheet_LPMTests { return } let e = expectation(description: "Confirm") + let paymentHandler = STPPaymentHandler(apiClient: apiClient, formSpecPaymentHandler: PaymentSheetFormSpecPaymentHandler()) + var redirectShimCalled = false paymentHandler._redirectShim = { _, _, _ in // This gets called instead of the PaymentSheet.confirm callback if the Intent is successfully confirmed and requires next actions. - print("✅ \(description): Successfully confirmed the intent. Its status is now requires_action.") - e.fulfill() + print("✅ \(description): Successfully confirmed the intent and saw a redirect attempt.") + paymentHandler._handleWillForegroundNotification() + redirectShimCalled = true } // Confirm the intent with the form details PaymentSheet.confirm( @@ -177,7 +205,7 @@ extension PaymentSheet_LPMTests { case .failed(error: let error): XCTFail("❌ \(description): PaymentSheet.confirm failed - \(error)") case .canceled: - XCTFail("❌ \(description): PaymentSheet.confirm canceled!") + XCTAssertTrue(redirectShimCalled, "❌ \(description): PaymentSheet.confirm canceled") case .completed: print("✅ \(description): PaymentSheet.confirm completed") } @@ -187,7 +215,7 @@ extension PaymentSheet_LPMTests { } } -extension PaymentSheet_LPMTests: STPAuthenticationContext { +extension PaymentSheet_LPM_ConfirmFlowTests: STPAuthenticationContext { func authenticationPresentingViewController() -> UIViewController { return UIViewController() } diff --git a/Stripe/StripeiOSTests/PaymentSheetPaymentMethodTypeTest.swift b/Stripe/StripeiOSTests/PaymentSheetPaymentMethodTypeTest.swift index bfe060ee862..29fc0627812 100644 --- a/Stripe/StripeiOSTests/PaymentSheetPaymentMethodTypeTest.swift +++ b/Stripe/StripeiOSTests/PaymentSheetPaymentMethodTypeTest.swift @@ -157,8 +157,10 @@ class PaymentSheetPaymentMethodTypeTest: XCTestCase { PaymentSheet.PaymentMethodType.dynamic("sofort"), ] func testCantSetupSEPAFamily() { - // All SEPA family pms excluding SEPA itself... - for pm in sepaFamily.dropFirst() { + for pm in [ + PaymentSheet.PaymentMethodType.dynamic("bancontact"), + PaymentSheet.PaymentMethodType.dynamic("sofort"), + ] { // ...can't be used for PIs... XCTAssertEqual( PaymentSheet.PaymentMethodType.supportsAdding( diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentMethodType.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentMethodType.swift index 56e68e2ed61..53c1b87d4f2 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentMethodType.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentMethodType.swift @@ -290,10 +290,12 @@ extension PaymentSheet { return [.returnURL] case .USBankAccount: return [.userSupportsDelayedPaymentMethods] - case .iDEAL, .bancontact, .sofort: - // SEPA-family PMs are disallowed until we can reuse them for PI+sfu and SI. - // n.b. While iDEAL and bancontact are themselves not delayed, they turn into SEPA upon save, which IS delayed. + case .bancontact, .sofort: + // n.b. While bancontact and sofort are themselves not delayed, they turn into SEPA upon save, which IS delayed. return [.returnURL, .userSupportsDelayedPaymentMethods, .unsupportedForSetup] + case .iDEAL: + // n.b. While iDEAL itself is not delayed, it turns into SEPA upon save, which IS delayed. + return [.returnURL, .userSupportsDelayedPaymentMethods] case .SEPADebit: return [.userSupportsDelayedPaymentMethods] case .bacsDebit: diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetFormFactory/PaymentSheetFormFactory.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetFormFactory/PaymentSheetFormFactory.swift index 1022806c0ca..c4f617462e5 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetFormFactory/PaymentSheetFormFactory.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetFormFactory/PaymentSheetFormFactory.swift @@ -155,10 +155,15 @@ class PaymentSheetFormFactory { additionalElements = [makePaypalMandate()] } - // 2. Element-based forms defined in JSON guard let spec = specFromJSONProvider() else { - fatalError() + assertionFailure("Failed to get form spec!") + return FormElement(elements: [], theme: theme) + } + if paymentMethod.stpPaymentMethodType == .iDEAL { + return makeiDEAL(spec: spec) } + + // 2. Element-based forms defined in JSON return makeFormElementFromSpec(spec: spec, additionalElements: additionalElements) } } @@ -325,7 +330,7 @@ extension PaymentSheetFormFactory { func makeBillingAddressSection( collectionMode: AddressSectionElement.CollectionMode = .all(), - countries: [String]? + countries: [String]? = nil ) -> PaymentMethodElementWrapper { let displayBillingSameAsShippingCheckbox: Bool let defaultAddress: AddressSectionElement.AddressDetails @@ -378,6 +383,30 @@ extension PaymentSheetFormFactory { // MARK: - PaymentMethod form definitions + func makeiDEAL(spec: FormSpec) -> PaymentMethodElement { + let contactSection: Element? = makeContactInformationSection( + nameRequiredByPaymentMethod: true, + emailRequiredByPaymentMethod: saveMode == .merchantRequired, + phoneRequiredByPaymentMethod: false + ) + // Hack: Use the luxe spec to make the dropdown for convenience; it has the latest list of banks + let bankDropdown: Element? = spec.fields.reduce(nil) { dropdown, spec in + // Find the dropdown spec + if case .selector(let spec) = spec { + return makeDropdown(for: spec) + } + return dropdown + } + + let addressSection: Element? = makeBillingAddressSectionIfNecessary(requiredByPaymentMethod: false) + let mandate: Element? = saveMode == .merchantRequired ? makeSepaMandate() : nil // Note: We show a SEPA mandate b/c iDEAL saves bank details as a SEPA Direct Debit Payment Method + let elements: [Element?] = [contactSection, bankDropdown, addressSection, mandate] + return FormElement( + autoSectioningElements: elements.compactMap { $0 }, + theme: theme + ) + } + func makeUSBankAccount(merchantName: String) -> PaymentMethodElement { let isSaving = BoolReference() let saveCheckbox = makeSaveCheckbox( @@ -556,11 +585,20 @@ extension PaymentSheetFormFactory { guard !elements.isEmpty else { return nil } return SectionElement( - title: .Localized.contact_information, + title: elements.count > 1 ? .Localized.contact_information : nil, elements: elements, theme: theme) } + func makeBillingAddressSectionIfNecessary(requiredByPaymentMethod: Bool) -> Element? { + if configuration.billingDetailsCollectionConfiguration.address == .full + || (configuration.billingDetailsCollectionConfiguration.address == .automatic && requiredByPaymentMethod) { + return makeBillingAddressSection() + } else { + return nil + } + } + func makeDefaultsApplierWrapper(for element: T) -> PaymentMethodElementWrapper { return PaymentMethodElementWrapper( element,