Skip to content

Commit dd8a8a1

Browse files
Link v3: Add support for 2FA Modal and FlowController  (#4227)
## Summary * Show the 2FA modal before showing the list of payment methods. Allow this to be disabled by a server-side flag. * Enable Link in FlowController by bringing back the PayWithNativeLinkController. This can be used separately to confirm a Link payment. * We pass through the AnalyticsHelper from PaymentSheet when using it. ## Motivation Enabling FlowController in Link v3. ## Testing PaymentSheet Example, will add end-to-end tests ## Changelog None yet
1 parent 5ae67cf commit dd8a8a1

27 files changed

+302
-94
lines changed

Example/PaymentSheet Example/PaymentSheetUITest/PaymentSheetUITest.swift

+45-1
Original file line numberDiff line numberDiff line change
@@ -1967,6 +1967,50 @@ class PaymentSheetLinkUITests: PaymentSheetUITestCase {
19671967
XCTAssertTrue(app.staticTexts["Success!"].waitForExistence(timeout: 10.0))
19681968
}
19691969

1970+
// Tests Native Link with a returning user, 2FA prompt shows first
1971+
func testLinkPaymentSheet_native_enabledSPM_noSPMs_returningLinkUser() {
1972+
var settings = PaymentSheetTestPlaygroundSettings.defaultValues()
1973+
settings.customerMode = .new
1974+
settings.apmsEnabled = .on
1975+
settings.linkMode = .link_pm
1976+
settings.useNativeLink = .on
1977+
settings.defaultBillingAddress = .on // the email on the default billings details is signed up for Link
1978+
1979+
loadPlayground(app, settings)
1980+
app.buttons["Present PaymentSheet"].waitForExistenceAndTap()
1981+
let codeField = app.textViews["Code field"]
1982+
_ = codeField.waitForExistence(timeout: 5.0)
1983+
codeField.typeText("000000")
1984+
let pwlController = app.otherElements["Stripe.Link.PayWithLinkViewController"]
1985+
let payButton = pwlController.buttons["Pay $50.99"]
1986+
_ = payButton.waitForExistenceAndTap()
1987+
XCTAssertTrue(app.staticTexts["Success!"].waitForExistence(timeout: 10.0))
1988+
}
1989+
1990+
// Tests Native Link in Flow Controller with a returning user
1991+
func testLinkPaymentSheetFC_native_enabledSPM_noSPMs_returningLinkUser() {
1992+
var settings = PaymentSheetTestPlaygroundSettings.defaultValues()
1993+
settings.customerMode = .new
1994+
settings.apmsEnabled = .on
1995+
settings.linkMode = .link_pm
1996+
settings.uiStyle = .flowController
1997+
settings.useNativeLink = .on
1998+
settings.defaultBillingAddress = .on // the email on the default billings details is signed up for Link
1999+
2000+
loadPlayground(app, settings)
2001+
2002+
app.buttons["Payment method"].waitForExistenceAndTap()
2003+
app.buttons["Link"].waitForExistenceAndTap()
2004+
app.buttons["Confirm"].waitForExistenceAndTap()
2005+
let codeField = app.textViews["Code field"]
2006+
_ = codeField.waitForExistence(timeout: 5.0)
2007+
codeField.typeText("000000")
2008+
let pwlController = app.otherElements["Stripe.Link.PayWithLinkViewController"]
2009+
let payButton = pwlController.buttons["Pay $50.99"]
2010+
_ = payButton.waitForExistenceAndTap()
2011+
XCTAssertTrue(app.staticTexts["Success!"].waitForExistence(timeout: 10.0))
2012+
}
2013+
19702014
// Tests the #5 flow in PaymentSheet where the merchant enables saved payment methods, buyer has SPMs and first time Link user
19712015
func testLinkPaymentSheet_enabledSPM_hasSPMs_firstTimeLinkUser() {
19722016
var settings = PaymentSheetTestPlaygroundSettings.defaultValues()
@@ -2379,7 +2423,7 @@ class PaymentSheetLinkUITests: PaymentSheetUITestCase {
23792423
// // Allow link.com to sign in
23802424
// let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
23812425
// springboard.buttons["Continue"].forceTapWhenHittableInTestCase(self)
2382-
//
2426+
//
23832427
// let emailField = app.webViews.textFields.firstMatch
23842428
// emailField.forceTapWhenHittableInTestCase(self)
23852429
// emailField.typeText("[email protected]")

StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj

+4
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
2E4C37C73AD202C8A3DD2E4E /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51FF291A25EA43D4D100983B /* LoadingViewController.swift */; };
5353
2EC9C94DD8D62E4F4EFC8AB8 /* IntentStatusPollerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 990304EF35A0EE37DCE20D5B /* IntentStatusPollerTest.swift */; };
5454
311AC53D6C76953E9B70148A /* ConsumerSession+PublishableKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D61B52BFA201D25E8F6428 /* ConsumerSession+PublishableKey.swift */; };
55+
313D00C82CD9972F00A8E6B0 /* PayWithNativeLinkController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 313D00C72CD9972F00A8E6B0 /* PayWithNativeLinkController.swift */; };
5556
313F5F832B0BE5FD00BD98A9 /* Docs.docc in Sources */ = {isa = PBXBuildFile; fileRef = 313F5F822B0BE5FD00BD98A9 /* Docs.docc */; };
5657
3147CEBB2CC07E960067B5E4 /* LinkUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEBA2CC07E960067B5E4 /* LinkUtils.swift */; };
5758
3147CEC02CC080570067B5E4 /* LinkInMemoryCookieStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEBD2CC080570067B5E4 /* LinkInMemoryCookieStore.swift */; };
@@ -460,6 +461,7 @@
460461
2DF75FD35820E7556EC34D15 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
461462
2E2B99961C09E31383C9FCE9 /* mt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mt; path = mt.lproj/Localizable.strings; sourceTree = "<group>"; };
462463
2E42F31D392C0AED757D6239 /* StripePaymentSheet.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripePaymentSheet.framework; sourceTree = BUILT_PRODUCTS_DIR; };
464+
313D00C72CD9972F00A8E6B0 /* PayWithNativeLinkController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayWithNativeLinkController.swift; sourceTree = "<group>"; };
463465
313F5F822B0BE5FD00BD98A9 /* Docs.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = Docs.docc; sourceTree = "<group>"; };
464466
3147CEBA2CC07E960067B5E4 /* LinkUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkUtils.swift; sourceTree = "<group>"; };
465467
3147CEBC2CC080570067B5E4 /* LinkCookieStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkCookieStore.swift; sourceTree = "<group>"; };
@@ -1546,6 +1548,7 @@
15461548
children = (
15471549
52185F7315D3C4089D3465BD /* LinkPaymentController.swift */,
15481550
B41560E0599A68A84F5C76D2 /* PaymentSheet-LinkConfirmOption.swift */,
1551+
313D00C72CD9972F00A8E6B0 /* PayWithNativeLinkController.swift */,
15491552
5DCFBC65AF58423E0E8DD04A /* PayWithLinkController.swift */,
15501553
);
15511554
path = Link;
@@ -2141,6 +2144,7 @@
21412144
3147CECB2CC1BF550067B5E4 /* LinkPaymentMethodPicker-CellContentView.swift in Sources */,
21422145
3147CECC2CC1BF550067B5E4 /* LinkPaymentMethodPicker-Cell.swift in Sources */,
21432146
3147CECD2CC1BF550067B5E4 /* LinkPaymentMethodPicker-Header.swift in Sources */,
2147+
313D00C82CD9972F00A8E6B0 /* PayWithNativeLinkController.swift in Sources */,
21442148
3147CECE2CC1BF550067B5E4 /* LinkPaymentMethodPicker-RadioButton.swift in Sources */,
21452149
3147CECF2CC1BF550067B5E4 /* LinkPaymentMethodPicker-AddButton.swift in Sources */,
21462150
FB653AA92B68F73344835A50 /* Intent.swift in Sources */,

StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-SignUpViewModel.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -133,14 +133,14 @@ extension PayWithLinkViewController {
133133

134134
private let accountLookupDebouncer = OperationDebouncer(debounceTime: LinkUI.accountLookupDebounceTime)
135135

136-
private let configuration: PaymentSheet.Configuration
136+
private let configuration: PaymentElementConfiguration
137137

138138
private let country: String?
139139

140140
// MARK: Initializer
141141

142142
init(
143-
configuration: PaymentSheet.Configuration,
143+
configuration: PaymentElementConfiguration,
144144
accountService: LinkAccountServiceProtocol,
145145
linkAccount: PaymentSheetLinkAccount?,
146146
country: String?

StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-UpdatePaymentViewController.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ extension PayWithLinkViewController {
2424
weak var delegate: UpdatePaymentViewControllerDelegate?
2525
let linkAccount: PaymentSheetLinkAccount
2626
let intent: Intent
27-
var configuration: PaymentSheet.Configuration
27+
var configuration: PaymentElementConfiguration
2828
let paymentMethod: ConsumerPaymentDetails
2929

3030
private let titleLabel: UILabel = {

StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController.swift

+3-3
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ final class PayWithLinkViewController: UINavigationController {
6363
final class Context {
6464
let intent: Intent
6565
let elementsSession: STPElementsSession
66-
let configuration: PaymentSheet.Configuration
66+
let configuration: PaymentElementConfiguration
6767
let shouldOfferApplePay: Bool
6868
let shouldFinishOnClose: Bool
6969
let callToAction: ConfirmButton.CallToActionType
@@ -82,7 +82,7 @@ final class PayWithLinkViewController: UINavigationController {
8282
init(
8383
intent: Intent,
8484
elementsSession: STPElementsSession,
85-
configuration: PaymentSheet.Configuration,
85+
configuration: PaymentElementConfiguration,
8686
shouldOfferApplePay: Bool,
8787
shouldFinishOnClose: Bool,
8888
callToAction: ConfirmButton.CallToActionType?,
@@ -119,7 +119,7 @@ final class PayWithLinkViewController: UINavigationController {
119119
convenience init(
120120
intent: Intent,
121121
elementsSession: STPElementsSession,
122-
configuration: PaymentSheet.Configuration,
122+
configuration: PaymentElementConfiguration,
123123
shouldOfferApplePay: Bool = false,
124124
shouldFinishOnClose: Bool = false,
125125
callToAction: ConfirmButton.CallToActionType? = nil,

StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Elements/LinkCardEditElement.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ final class LinkCardEditElement: Element {
4747

4848
let paymentMethod: ConsumerPaymentDetails
4949

50-
let configuration: PaymentSheet.Configuration
50+
let configuration: PaymentElementConfiguration
5151

5252
let theme: ElementsAppearance = LinkUI.appearance.asElementsTheme
5353

@@ -177,7 +177,7 @@ final class LinkCardEditElement: Element {
177177
)
178178
}()
179179

180-
init(paymentMethod: ConsumerPaymentDetails, configuration: PaymentSheet.Configuration) {
180+
init(paymentMethod: ConsumerPaymentDetails, configuration: PaymentElementConfiguration) {
181181
self.paymentMethod = paymentMethod
182182
self.configuration = configuration
183183

StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Extensions/Intent+Link.swift

+8
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ extension STPElementsSession {
3838
linkSettings?.popupWebviewOption ?? .shared
3939
}
4040

41+
func shouldShowLink2FABeforePaymentSheet(for linkAccount: PaymentSheetLinkAccount, configuration: PaymentSheet.Configuration) -> Bool {
42+
return configuration.forceNativeLinkEnabled &&
43+
self.supportsLink &&
44+
linkAccount.sessionState == .requiresVerification &&
45+
!linkAccount.hasStartedSMSVerification &&
46+
self.linkSettings?.suppress2FAModal != true
47+
}
48+
4149
func countryCode(overrideCountry: String?) -> String? {
4250
#if DEBUG
4351
if let overrideCountry {

StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElement+Internal.swift

+14-14
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ extension EmbeddedPaymentElement: EmbeddedPaymentMethodsViewDelegate {
9999
delegate?.embeddedPaymentElementDidUpdatePaymentOption(embeddedPaymentElement: self)
100100
}
101101
}
102-
102+
103103
guard case let .new(paymentMethodType) = embeddedPaymentMethodsView.selection else {
104104
// This can occur when selection is being reset to nothing selected or to a saved payment method, so don't assert.
105105
self.formViewController = nil
@@ -200,7 +200,7 @@ extension EmbeddedPaymentElement: UpdateCardViewControllerDelegate {
200200
accessoryType: accessoryType)
201201
presentingViewController?.dismiss(animated: true)
202202
}
203-
203+
204204
func didDismiss(viewController: UpdateCardViewController) {
205205
presentingViewController?.dismiss(animated: true)
206206
}
@@ -276,32 +276,32 @@ extension EmbeddedPaymentElement: EmbeddedFormViewControllerDelegate {
276276
completion(result, deferredIntentConfirmationType)
277277
}
278278
}
279-
279+
280280
func embeddedFormViewControllerDidCompleteConfirmation(_ embeddedFormViewController: EmbeddedFormViewController, result: PaymentSheetResult) {
281281
embeddedFormViewController.dismiss(animated: true) {
282282
if case let .confirm(completion) = self.configuration.formSheetAction {
283283
completion(result)
284284
}
285285
}
286286
}
287-
287+
288288
func embeddedFormViewControllerDidCancel(_ embeddedFormViewController: EmbeddedFormViewController) {
289289
if embeddedFormViewController.selectedPaymentOption == nil {
290290
self.formViewController = nil
291291
embeddedPaymentMethodsView.resetSelectionToLastSelection()
292292
}
293293
embeddedFormViewController.dismiss(animated: true)
294294
}
295-
295+
296296
func embeddedFormViewControllerShouldClose(_ embeddedFormViewController: EmbeddedFormViewController) {
297297
embeddedFormViewController.dismiss(animated: true)
298298
delegate?.embeddedPaymentElementDidUpdatePaymentOption(embeddedPaymentElement: self)
299299
}
300-
300+
301301
}
302302

303303
extension EmbeddedPaymentElement {
304-
304+
305305
func _confirm() async -> (result: PaymentSheetResult, deferredIntentConfirmationType: STPAnalyticsClient.DeferredIntentConfirmationType?) {
306306
// Wait for the last update to finish and fail if didn't succeed. A failure means the view is out of sync with the intent and could e.g. not be showing a required mandate.
307307
if let latestUpdateTask {
@@ -356,10 +356,11 @@ extension EmbeddedPaymentElement {
356356
elementsSession: elementsSession,
357357
paymentOption: paymentOption,
358358
paymentHandler: paymentHandler,
359-
integrationShape: .embedded
359+
integrationShape: .embedded,
360+
analyticsHelper: analyticsHelper
360361
)
361362
}
362-
363+
363364
func bottomSheetController(with viewController: BottomSheetContentViewController) -> BottomSheetViewController {
364365
return BottomSheetViewController(contentViewController: viewController,
365366
appearance: configuration.appearance,
@@ -370,23 +371,22 @@ extension EmbeddedPaymentElement {
370371
}
371372
}
372373

373-
374374
// TODO(porter) When we use Xcode 16 on CI do this instead of `STPAuthenticationContextWrapper`
375375
// @retroactive is not supported in Xcode 15
376-
//extension UIViewController: @retroactive STPAuthenticationContext {
376+
// extension UIViewController: @retroactive STPAuthenticationContext {
377377
// public func authenticationPresentingViewController() -> UIViewController {
378378
// return self
379379
// }
380-
//}
380+
// }
381381

382382
final class STPAuthenticationContextWrapper: UIViewController {
383383
let _presentingViewController: UIViewController
384-
384+
385385
init(presentingViewController: UIViewController) {
386386
self._presentingViewController = presentingViewController
387387
super.init(nibName: nil, bundle: nil)
388388
}
389-
389+
390390
required init?(coder: NSCoder) {
391391
fatalError("init(coder:) has not been implemented")
392392
}

StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Embedded/EmbeddedPaymentElementConfiguration.swift

+3
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@ extension EmbeddedPaymentElement {
153153
/// Controls whether to filter out wallet payment methods from the saved payment method list.
154154
@_spi(DashboardOnly) public var disableWalletPaymentMethodFiltering: Bool = false
155155

156+
internal var linkPaymentMethodsOnly: Bool = false
157+
@_spi(STP) public var forceNativeLinkEnabled: Bool = false
158+
156159
/// Initializes a Configuration with default values
157160
public init(formSheetAction: FormSheetAction) {
158161
self.formSheetAction = formSheetAction

StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Link/PayWithLinkController.swift

+5-2
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@ final class PayWithLinkController {
2525
let intent: Intent
2626
let elementsSession: STPElementsSession
2727
let configuration: PaymentElementConfiguration
28+
let analyticsHelper: PaymentSheetAnalyticsHelper
2829

29-
init(intent: Intent, elementsSession: STPElementsSession, configuration: PaymentElementConfiguration) {
30+
init(intent: Intent, elementsSession: STPElementsSession, configuration: PaymentElementConfiguration, analyticsHelper: PaymentSheetAnalyticsHelper) {
3031
self.intent = intent
3132
self.elementsSession = elementsSession
3233
self.configuration = configuration
3334
self.paymentHandler = .init(apiClient: configuration.apiClient)
35+
self.analyticsHelper = analyticsHelper
3436
}
3537

3638
func present(
@@ -64,7 +66,8 @@ extension PayWithLinkController: PayWithLinkWebControllerDelegate {
6466
elementsSession: elementsSession,
6567
paymentOption: paymentOption,
6668
paymentHandler: paymentHandler,
67-
integrationShape: .complete
69+
integrationShape: .complete,
70+
analyticsHelper: analyticsHelper
6871
) { result, deferredIntentConfirmationType in
6972
self.completion?(result, deferredIntentConfirmationType)
7073
self.selfRetainer = nil

0 commit comments

Comments
 (0)