Skip to content

Commit

Permalink
Enable iDEAL for SetupIntents and PaymentIntents+setup_future_usage (s…
Browse files Browse the repository at this point in the history
…tripe#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
  • Loading branch information
yuki-stripe authored Jul 28, 2023
1 parent ba5f49c commit 05cda47
Show file tree
Hide file tree
Showing 6 changed files with 109 additions and 29 deletions.
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 [email protected] 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.
Expand Down
8 changes: 7 additions & 1 deletion Stripe/StripeiOSTests/Elements+TestHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,19 @@ extension Element {
}
}

extension SectionElement: CustomDebugStringConvertible {
public var debugDescription: String {
return ["<SectionElement: \(Unmanaged.passUnretained(self).toOpaque())>", title].compactMap { $0 }.joined(separator: " - ")
}
}

extension TextFieldElement: CustomDebugStringConvertible {
public var debugDescription: String {
return "<TextFieldElement: \(Unmanaged.passUnretained(self).toOpaque())> - \"\(configuration.label)\" - \(validationState)"
}
}

extension DropdownFieldElement: CustomDebugStringConvertible {
extension DropdownFieldElement {
public override var debugDescription: String {
return "<DropdownFieldElement: \(Unmanaged.passUnretained(self).toOpaque())> - \"\(label ?? "nil")\" - \(validationState)"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// PaymentSheet+LPMTests.swift
// PaymentSheet+LPMConfirmFlowTests.swift
// StripeiOSTests
//
// Created by Yuki Tokuhiro on 7/18/23.
Expand All @@ -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
Expand All @@ -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("[email protected]")
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
Expand All @@ -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
Expand All @@ -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",
Expand All @@ -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)),
Expand All @@ -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 = [
Expand All @@ -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)),
Expand All @@ -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(
Expand All @@ -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")
}
Expand All @@ -187,7 +215,7 @@ extension PaymentSheet_LPMTests {
}
}

extension PaymentSheet_LPMTests: STPAuthenticationContext {
extension PaymentSheet_LPM_ConfirmFlowTests: STPAuthenticationContext {
func authenticationPresentingViewController() -> UIViewController {
return UIViewController()
}
Expand Down
6 changes: 4 additions & 2 deletions Stripe/StripeiOSTests/PaymentSheetPaymentMethodTypeTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -325,7 +330,7 @@ extension PaymentSheetFormFactory {

func makeBillingAddressSection(
collectionMode: AddressSectionElement.CollectionMode = .all(),
countries: [String]?
countries: [String]? = nil
) -> PaymentMethodElementWrapper<AddressSectionElement> {
let displayBillingSameAsShippingCheckbox: Bool
let defaultAddress: AddressSectionElement.AddressDetails
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<T: PaymentMethodElement>(for element: T) -> PaymentMethodElementWrapper<T> {
return PaymentMethodElementWrapper(
element,
Expand Down

0 comments on commit 05cda47

Please sign in to comment.