Skip to content

Commit d2593cf

Browse files
Present forms in embedded (#4232)
## Summary - Now when tapping a selection in embedded, if the PM requires input we display a form to collect input and use that as the selected payment option. - Generate a new form upon update to preserve selections - Handle edge cases around canceling and reselecting the old option ## Motivation - Embedded ## Testing - Manual - Existing tests - New UI tests ## Changelog N/A
1 parent 91bdadf commit d2593cf

File tree

9 files changed

+350
-52
lines changed

9 files changed

+350
-52
lines changed

Example/PaymentSheet Example/PaymentSheet Example/EmbeddedPlaygroundViewController.swift

+4-1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class EmbeddedPlaygroundViewController: UIViewController {
5353
checkoutButton.setTitle("Checkout", for: .normal)
5454
checkoutButton.setTitleColor(.white, for: .normal)
5555
checkoutButton.translatesAutoresizingMaskIntoConstraints = false
56+
checkoutButton.isEnabled = false
5657
return checkoutButton
5758
}()
5859

@@ -115,7 +116,8 @@ class EmbeddedPlaygroundViewController: UIViewController {
115116
embeddedPaymentElement.delegate = self
116117
embeddedPaymentElement.presentingViewController = self
117118
self.embeddedPaymentElement = embeddedPaymentElement
118-
119+
self.embeddedPaymentElement?.presentingViewController = self
120+
119121
// Scroll view contains our content
120122
let scrollView = UIScrollView()
121123
scrollView.translatesAutoresizingMaskIntoConstraints = false
@@ -198,6 +200,7 @@ extension EmbeddedPlaygroundViewController: EmbeddedPaymentElementDelegate {
198200
}
199201

200202
func embeddedPaymentElementDidUpdatePaymentOption(embeddedPaymentElement: EmbeddedPaymentElement) {
203+
checkoutButton.isEnabled = embeddedPaymentElement.paymentOption != nil
201204
paymentOptionView.configure(with: embeddedPaymentElement.paymentOption, showMandate: !configuration.embeddedViewDisplaysMandateText)
202205
}
203206
}

Example/PaymentSheet Example/PaymentSheetUITest/EmbeddedUITest.swift

+183-5
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,93 @@ class EmbeddedUITests: PaymentSheetUITestCase {
1414
settings.mode = .payment
1515
settings.integrationType = .deferred_csc
1616
settings.uiStyle = .embedded
17+
settings.formSheetAction = .continue
1718
loadPlayground(app, settings)
1819
app.buttons["Present embedded payment element"].waitForExistenceAndTap()
19-
// TODO: Test card form (see PaymentSheetVerticalUITests testUpdate)
20-
21-
// Selecting Alipay w/ deferred PaymentIntent...
20+
21+
// Entering a card w/ deferred PaymentIntent...
22+
app.buttons["Card"].waitForExistenceAndTap()
23+
XCTAssertTrue(app.staticTexts["Add card"].waitForExistence(timeout: 10))
24+
try! fillCardData(app, postalEnabled: true)
25+
app.toolbars.buttons["Done"].waitForExistenceAndTap()
26+
XCTAssertTrue(app.buttons["Continue"].isEnabled)
27+
app.buttons["Continue"].waitForExistenceAndTap()
28+
XCTAssertTrue(app.staticTexts["Payment method"].waitForExistence(timeout: 10))
29+
XCTAssertEqual(app.staticTexts["Payment method"].label, "•••• 4242")
30+
31+
// ...and *updating* to a SetupIntent...
32+
app.buttons.matching(identifier: "Setup").element(boundBy: 1).waitForExistenceAndTap()
33+
// ...(wait for it to finish updating)...
34+
XCTAssertTrue(app.buttons["Reload"].waitForExistence(timeout: 10))
35+
// ...should cause Card to no longer be the selected payment method.
36+
XCTAssertFalse(app.staticTexts["Payment method"].exists)
37+
38+
// ....Tapping card should show the card form with details preserved
39+
app.buttons["Card"].waitForExistenceAndTap()
40+
// ...thus the Continue button should be enabled
41+
app.buttons["Continue"].waitForExistenceAndTap()
42+
// ...should cause the card ending in 4242 that was previously entered to be the selected payment method
43+
XCTAssertTrue(app.staticTexts["Payment method"].waitForExistence(timeout: 10))
44+
XCTAssertEqual(app.staticTexts["Payment method"].label, "•••• 4242")
45+
// ...switching from setup to payment should preserve this card as the selected payment method
46+
app.buttons.matching(identifier: "Payment").element(boundBy: 1).waitForExistenceAndTap()
47+
// ...(wait for it to finish updating)...
48+
XCTAssertTrue(app.buttons["Reload"].waitForExistence(timeout: 10))
49+
// ...card entered for setup should be preserved after update
50+
XCTAssertTrue(app.staticTexts["Payment method"].waitForExistence(timeout: 10))
51+
XCTAssertEqual(app.staticTexts["Payment method"].label, "•••• 4242")
52+
53+
// ...selecting Alipay w/ deferred PaymentIntent...
2254
app.buttons["Alipay"].waitForExistenceAndTap()
2355
XCTAssertEqual(app.staticTexts["Payment method"].label, "Alipay")
2456
// ...and *updating* to a SetupIntent...
25-
app.buttons.matching(identifier: "Setup").element(boundBy: 1).tap()
57+
app.buttons.matching(identifier: "Setup").element(boundBy: 1).waitForExistenceAndTap()
2658
// ...(wait for it to finish updating)...
27-
_ = app.buttons["Reload"].waitForExistence(timeout: 10)
59+
XCTAssertTrue(app.buttons["Reload"].waitForExistence(timeout: 10))
2860
// ...should cause Alipay to no longer be the selected payment method, since it is not valid for setup.
2961
XCTAssertFalse(app.staticTexts["Payment method"].exists)
62+
63+
// ...go back into deferred PaymentIntent mode
64+
app.buttons.matching(identifier: "Payment").element(boundBy: 1).waitForExistenceAndTap()
65+
// ...(wait for it to finish updating)...
66+
XCTAssertTrue(app.buttons["Reload"].waitForExistence(timeout: 10))
67+
//...selecting Cash App Pay w/ deferred PaymentIntent...
68+
app.buttons["Cash App Pay"].waitForExistenceAndTap()
69+
XCTAssertEqual(app.staticTexts["Payment method"].label, "Cash App Pay")
70+
// ...and *updating* to a SetupIntent...
71+
app.buttons.matching(identifier: "Setup").element(boundBy: 1).waitForExistenceAndTap()
72+
// ...(wait for it to finish updating)...
73+
XCTAssertTrue(app.buttons["Reload"].waitForExistence(timeout: 10))
74+
// ...should cause Cash App Pay to be the selected payment method, since it is valid for setup.
75+
XCTAssertEqual(app.staticTexts["Payment method"].label, "Cash App Pay")
76+
77+
// ...go back into deferred PaymentIntent mode
78+
app.buttons.matching(identifier: "Payment").element(boundBy: 1).waitForExistenceAndTap()
79+
// ...(wait for it to finish updating)...
80+
XCTAssertTrue(app.buttons["Reload"].waitForExistence(timeout: 10))
81+
//...selecting Klarna w/ deferred PaymentIntent...
82+
app.buttons["Klarna"].waitForExistenceAndTap()
83+
// ...fill out the form for Klarna
84+
let emailField = app.textFields["Email"]
85+
emailField.waitForExistenceAndTap()
86+
emailField.typeText("mobile-payments-sdk-ci+\(UUID())@stripe.com")
87+
app.buttons["Continue"].waitForExistenceAndTap()
88+
XCTAssertEqual(app.staticTexts["Payment method"].label, "Klarna")
89+
// ...and *updating* to a SetupIntent...
90+
app.buttons.matching(identifier: "Setup").element(boundBy: 1).waitForExistenceAndTap()
91+
// ...(wait for it to finish updating)...
92+
XCTAssertTrue(app.buttons["Reload"].waitForExistence(timeout: 10))
93+
// ...should cause Klarna to no longer be the selected payment method.
94+
XCTAssertFalse(app.staticTexts["Payment method"].exists)
95+
// ...selecting Klarna should present a Klarna form with the previously entered email
96+
app.buttons["Klarna"].waitForExistenceAndTap()
97+
app.buttons["Continue"].waitForExistenceAndTap()
98+
// ...switching back to payment should keep Klarna selected
99+
app.buttons.matching(identifier: "Payment").element(boundBy: 1).waitForExistenceAndTap()
100+
// ...(wait for it to finish updating)...
101+
XCTAssertTrue(app.buttons["Reload"].waitForExistence(timeout: 10))
102+
// ... Klarna should still be selected
103+
XCTAssertEqual(app.staticTexts["Payment method"].label, "Klarna")
30104
}
31105

32106
func testSingleCardCBC_update_and_remove_selectStateApplePay() {
@@ -293,6 +367,110 @@ class EmbeddedUITests: PaymentSheetUITestCase {
293367
XCTAssertTrue(applePayButton.waitForExistence(timeout: 3.0))
294368
XCTAssertTrue(applePayButton.isSelected)
295369
}
370+
371+
func testSelection() {
372+
var settings = PaymentSheetTestPlaygroundSettings.defaultValues()
373+
settings.mode = .paymentWithSetup
374+
settings.uiStyle = .paymentSheet
375+
settings.customerKeyType = .legacy
376+
settings.formSheetAction = .continue
377+
settings.customerMode = .new
378+
loadPlayground(app, settings)
379+
380+
// Start by saving a new card
381+
app.buttons["Present PaymentSheet"].waitForExistenceAndTap()
382+
try! fillCardData(app, postalEnabled: true)
383+
384+
// Complete payment
385+
app.buttons["Pay $50.99"].tap()
386+
XCTAssertTrue(app.staticTexts["Success!"].waitForExistence(timeout: 10))
387+
388+
// Switch to embedded mode kicks off a reload
389+
app.buttons["embedded"].waitForExistenceAndTap(timeout: 5)
390+
app.buttons["Payment"].waitForExistenceAndTap(timeout: 5)
391+
app.buttons["Present embedded payment element"].waitForExistenceAndTap()
392+
393+
// Should auto select a saved payment method
394+
XCTAssertEqual(app.staticTexts["Payment method"].label, "•••• 4242")
395+
396+
// Open card and cancel, should reset selection to saved card
397+
app.buttons["New card"].waitForExistenceAndTap()
398+
app.buttons["Close"].waitForExistenceAndTap()
399+
XCTAssertTrue(app.buttons["Checkout"].isEnabled)
400+
XCTAssertEqual(app.staticTexts["Payment method"].label, "•••• 4242")
401+
402+
// Select Cash App Pay
403+
app.buttons["Cash App Pay"].waitForExistenceAndTap()
404+
XCTAssertTrue(app.staticTexts["Cash App Pay"].waitForExistence(timeout: 10))
405+
XCTAssertTrue(app.buttons["Checkout"].isEnabled)
406+
407+
// Open card and cancel, should reset back to Cash App Pay
408+
app.buttons["New card"].waitForExistenceAndTap()
409+
app.buttons["Close"].waitForExistenceAndTap()
410+
XCTAssertTrue(app.staticTexts["Cash App Pay"].waitForExistence(timeout: 10))
411+
XCTAssertTrue(app.buttons["Checkout"].isEnabled)
412+
413+
// Try to fill a card
414+
app.buttons["New card"].waitForExistenceAndTap()
415+
XCTAssertTrue(app.staticTexts["Add new card"].waitForExistence(timeout: 10))
416+
try! fillCardData(app, cardNumber: "5555555555554444", postalEnabled: true)
417+
app.toolbars.buttons["Done"].waitForExistenceAndTap()
418+
app.buttons["Continue"].waitForExistenceAndTap()
419+
XCTAssertTrue(app.staticTexts["Payment method"].waitForExistence(timeout: 10))
420+
XCTAssertEqual(app.staticTexts["Payment method"].label, "•••• 4444")
421+
XCTAssertTrue(app.buttons["Checkout"].isEnabled)
422+
423+
// Tapping on card again should present the form filled out
424+
app.buttons["New card"].waitForExistenceAndTap()
425+
XCTAssertTrue(app.staticTexts["Add new card"].waitForExistence(timeout: 10))
426+
let cardNumberField = app.textFields["Card number"]
427+
XCTAssertEqual(cardNumberField.value as? String, "5555555555554444", "Card number field should contain the entered card number.")
428+
app.buttons["Close"].waitForExistenceAndTap()
429+
XCTAssertTrue(app.staticTexts["Payment method"].waitForExistence(timeout: 10))
430+
XCTAssertEqual(app.staticTexts["Payment method"].label, "•••• 4444")
431+
XCTAssertTrue(app.buttons["Checkout"].isEnabled)
432+
433+
// Select and cancel out a form PM to ensure that the 4242 card is still selected
434+
app.buttons["Klarna"].waitForExistenceAndTap()
435+
app.buttons["Close"].waitForExistenceAndTap()
436+
XCTAssertTrue(app.staticTexts["Payment method"].waitForExistence(timeout: 10))
437+
XCTAssertEqual(app.staticTexts["Payment method"].label, "•••• 4444")
438+
XCTAssertTrue(app.buttons["Checkout"].isEnabled)
439+
440+
// Select a no-form PM such as Cash App Pay
441+
app.buttons["Cash App Pay"].waitForExistenceAndTap()
442+
XCTAssertTrue(app.staticTexts["Cash App Pay"].waitForExistence(timeout: 10))
443+
XCTAssertTrue(app.buttons["Checkout"].isEnabled)
444+
445+
// Fill out US Bank Acct.
446+
app.buttons["US bank account"].waitForExistenceAndTap()
447+
// Fill out name and email fields
448+
let continueButton = app.buttons["Continue"]
449+
XCTAssertFalse(continueButton.isEnabled)
450+
app.textFields["Full name"].tap()
451+
app.typeText("John Doe" + XCUIKeyboardKey.return.rawValue)
452+
app.typeText("test-\(UUID().uuidString)@example.com" + XCUIKeyboardKey.return.rawValue)
453+
XCTAssertTrue(continueButton.isEnabled)
454+
continueButton.tap()
455+
456+
// Go through connections flow
457+
app.buttons["Agree and continue"].waitForExistenceAndTap()
458+
app.staticTexts["Test Institution"].forceTapElement()
459+
// "Success" institution is automatically selected because its the first
460+
app.buttons["connect_accounts_button"].waitForExistenceAndTap(timeout: 10)
461+
462+
let notNowButton = app.buttons["Not now"]
463+
if notNowButton.waitForExistence(timeout: 10) {
464+
app.typeText(XCUIKeyboardKey.return.rawValue) // dismiss keyboard
465+
notNowButton.tap()
466+
}
467+
468+
app.buttons["Continue"].waitForExistenceAndTap()
469+
app.buttons["Continue"].waitForExistenceAndTap()
470+
XCTAssertTrue(app.staticTexts["Payment method"].waitForExistence(timeout: 10))
471+
XCTAssertEqual(app.staticTexts["Payment method"].label, "••••6789")
472+
XCTAssertTrue(app.buttons["Checkout"].isEnabled)
473+
}
296474

297475
func dismissAlertView(alertBody: String, alertTitle: String, buttonToTap: String) {
298476
let alertText = app.staticTexts[alertBody]

StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedFormViewController.swift

+17-11
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import Foundation
1212
@_spi(STP) import StripeUICore
1313
import UIKit
1414

15-
protocol EmbeddedFormViewControllerDelegate: AnyObject {
15+
@MainActor protocol EmbeddedFormViewControllerDelegate: AnyObject {
1616

1717
/// Notifies the delegate to confirm the payment or setup with the provided payment option.
1818
/// This method is called when the user taps the primary button (e.g., "Buy") while `formSheetAction` is set to `.confirm`.
@@ -70,25 +70,29 @@ class EmbeddedFormViewController: UIViewController {
7070
navigationBar.isUserInteractionEnabled = isUserInteractionEnabled
7171
}
7272
}
73+
74+
var collectsUserInput: Bool {
75+
return paymentMethodFormViewController.form.collectsUserInput
76+
}
7377

7478
enum Error: Swift.Error {
7579
case noPaymentOptionOnBuyButtonTap
7680
}
7781
var selectedPaymentOption: PaymentSheet.PaymentOption? {
7882
return paymentMethodFormViewController.paymentOption
7983
}
80-
81-
private let loadResult: PaymentSheetLoader.LoadResult
84+
8285
private let paymentMethodType: PaymentSheet.PaymentMethodType
8386
private let configuration: EmbeddedPaymentElement.Configuration
8487
private let intent: Intent
8588
private let elementsSession: STPElementsSession
89+
private let shouldUseNewCardNewCardHeader: Bool
8690
private let formCache: PaymentMethodFormCache
8791
private let analyticsHelper: PaymentSheetAnalyticsHelper
8892
private var error: Swift.Error?
8993
private var isPaymentInFlight: Bool = false
9094
/// Previous customer input - in the `update` flow, this is the customer input prior to `update`, used so we can restore their state in this VC.
91-
private var previousPaymentOption: PaymentOption?
95+
private(set) var previousPaymentOption: PaymentOption?
9296

9397
// MARK: - UI properties
9498

@@ -128,7 +132,7 @@ class EmbeddedFormViewController: UIViewController {
128132
let headerView = FormHeaderView(
129133
paymentMethodType: paymentMethodType,
130134
// Special case: use "New Card" instead of "Card" if the displayed saved PM is a card
131-
shouldUseNewCardHeader: loadResult.savedPaymentMethods.first?.type == .card,
135+
shouldUseNewCardHeader: shouldUseNewCardNewCardHeader,
132136
appearance: configuration.appearance
133137
)
134138

@@ -154,14 +158,16 @@ class EmbeddedFormViewController: UIViewController {
154158
// MARK: - Initializers
155159

156160
init(configuration: EmbeddedPaymentElement.Configuration,
157-
loadResult: PaymentSheetLoader.LoadResult,
161+
intent: Intent,
162+
elementsSession: STPElementsSession,
163+
shouldUseNewCardNewCardHeader: Bool,
158164
paymentMethodType: PaymentSheet.PaymentMethodType,
159165
previousPaymentOption: PaymentOption? = nil,
160166
analyticsHelper: PaymentSheetAnalyticsHelper,
161-
formCache: PaymentMethodFormCache) {
162-
self.loadResult = loadResult
163-
self.intent = loadResult.intent
164-
self.elementsSession = loadResult.elementsSession
167+
formCache: PaymentMethodFormCache = .init()) {
168+
self.intent = intent
169+
self.elementsSession = elementsSession
170+
self.shouldUseNewCardNewCardHeader = shouldUseNewCardNewCardHeader
165171
self.configuration = configuration
166172
self.previousPaymentOption = previousPaymentOption
167173
self.analyticsHelper = analyticsHelper
@@ -334,7 +340,7 @@ class EmbeddedFormViewController: UIViewController {
334340
UINotificationFeedbackGenerator().notificationOccurred(.success)
335341
#endif
336342
self.primaryButton.update(state: .succeeded, animated: true) {
337-
self.delegate?.embeddedFormViewControllerShouldContinue(self, result: .completed)
343+
self.delegate?.embeddedFormViewControllerShouldContinue(self, result: result)
338344
}
339345
}
340346
}

0 commit comments

Comments
 (0)