Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Connect] Using FinancialConnections SDK in Connect #4159

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
41E9A1C12C7CE30000EDE131 /* AppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41E9A1C02C7CE30000EDE131 /* AppSettingsView.swift */; };
E62B3CD72C99EA020098B607 /* StripeCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E62B3CD62C99EA020098B607 /* StripeCore.framework */; };
E62B3CD82C99EA020098B607 /* StripeCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E62B3CD62C99EA020098B607 /* StripeCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
E640C9D92CC1970F009D0C6E /* StripeFinancialConnections.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E640C9D82CC1970F009D0C6E /* StripeFinancialConnections.framework */; };
E640C9DA2CC1970F009D0C6E /* StripeFinancialConnections.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E640C9D82CC1970F009D0C6E /* StripeFinancialConnections.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
E6C5E52B2CA76B0F0021444D /* PresentationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6C5E52A2CA76B030021444D /* PresentationSettingsView.swift */; };
E6C5E52D2CA771730021444D /* PresentationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6C5E52C2CA771700021444D /* PresentationSettings.swift */; };
/* End PBXBuildFile section */
Expand All @@ -64,6 +66,7 @@
E62B3CD82C99EA020098B607 /* StripeCore.framework in Embed Frameworks */,
416E9ECC2C76BE0C00A0B917 /* StripeConnect.framework in Embed Frameworks */,
4161C28A2CA1B438005BD67C /* StripeUICore.framework in Embed Frameworks */,
E640C9DA2CC1970F009D0C6E /* StripeFinancialConnections.framework in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
Expand Down Expand Up @@ -104,6 +107,7 @@
41E9A1BE2C7CD3C700EDE131 /* UIViewController+NavigationEmbed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+NavigationEmbed.swift"; sourceTree = "<group>"; };
41E9A1C02C7CE30000EDE131 /* AppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsView.swift; sourceTree = "<group>"; };
E62B3CD62C99EA020098B607 /* StripeCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = StripeCore.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E640C9D82CC1970F009D0C6E /* StripeFinancialConnections.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = StripeFinancialConnections.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E6C5E52A2CA76B030021444D /* PresentationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationSettingsView.swift; sourceTree = "<group>"; };
E6C5E52C2CA771700021444D /* PresentationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationSettings.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
Expand All @@ -116,6 +120,7 @@
E62B3CD72C99EA020098B607 /* StripeCore.framework in Frameworks */,
416E9ECB2C76BE0C00A0B917 /* StripeConnect.framework in Frameworks */,
4161C2892CA1B438005BD67C /* StripeUICore.framework in Frameworks */,
E640C9D92CC1970F009D0C6E /* StripeFinancialConnections.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -227,6 +232,7 @@
416E9EC62C76BDFE00A0B917 /* Frameworks */ = {
isa = PBXGroup;
children = (
E640C9D82CC1970F009D0C6E /* StripeFinancialConnections.framework */,
4161C2882CA1B438005BD67C /* StripeUICore.framework */,
E62B3CD62C99EA020098B607 /* StripeCore.framework */,
416E9EC72C76BDFE00A0B917 /* StripeConnect.framework */,
Expand Down
40 changes: 40 additions & 0 deletions StripeConnect/StripeConnect.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// FinancialConnectionsPresenter.swift
// StripeConnect
//
// Created by Mel Ludowise on 10/18/24.
//

@_spi(STP) import StripeCore
import StripeFinancialConnections
import UIKit

/// Wraps `FinancialConnectionsSheet` for easy dependency injection in tests
class FinancialConnectionsPresenter {
@MainActor
func presentForToken(
apiClient: STPAPIClient,
clientSecret: String,
connectedAccountId: String,
from presentingViewController: UIViewController
) async -> FinancialConnectionsSheet.TokenResult {
let financialConnectionsSheet = FinancialConnectionsSheet(
financialConnectionsSessionClientSecret: clientSecret
)
// FC needs the connected account ID to be configured on the API Client
// Make a copy before modifying so we don't unexpectedly modify the shared API client
financialConnectionsSheet.apiClient = apiClient.makeCopy()
financialConnectionsSheet.apiClient.stripeAccount = connectedAccountId
return await withCheckedContinuation { continuation in
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wanted to check to see if we have weighed the pros and cons of checked vs unchecked continuation here. If we stick with checked then this will cause a crash if resume is called more than once. It shouldn't get called more than once in this instance, but maybe we can add some extra protections here to ensure we don't call resume more than once and instead of crashing we log an error.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't spend too much time thinking about it in this instance since it shouldn't be possible for it to get called more than once. I don't think we have a strong opinion as to which to use the SDK.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok that's ok, I don't think this should be a blocker or anything. Just wanted to call out that we have been bitten by this before. Especially within an SDK it would be ideal to not crash on a programmer error like this.

financialConnectionsSheet.presentForToken(from: presentingViewController) { result in
continuation.resume(returning: result)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

@_spi(STP) import StripeCore
import StripeFinancialConnections
@_spi(STP) import StripeUICore
import UIKit
import WebKit
Expand All @@ -28,6 +29,9 @@ class ConnectComponentWebViewController: ConnectWebViewController {
/// Manages authenticated web views
private let authenticatedWebViewManager: AuthenticatedWebViewManager

/// Presents the FinancialConnectionsSheet
private let financialConnectionsPresenter: FinancialConnectionsPresenter

private lazy var setterMessageHandler: OnSetterFunctionCalledMessageHandler = .init(analyticsClient: analyticsClient)

private var didFailLoadWithError: (Error) -> Void
Expand All @@ -49,13 +53,15 @@ class ConnectComponentWebViewController: ConnectWebViewController {
// Should only be overridden for tests
notificationCenter: NotificationCenter = NotificationCenter.default,
webLocale: Locale = Locale.autoupdatingCurrent,
authenticatedWebViewManager: AuthenticatedWebViewManager = .init()
authenticatedWebViewManager: AuthenticatedWebViewManager = .init(),
financialConnectionsPresenter: FinancialConnectionsPresenter = .init()
Comment on lines +56 to +57
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm starting to second guess whether these should have default values or not. I'm worried that one day we will do a handoff to a new ConnectComponentWebViewController instance and not provide the existing managers.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we end up having that future case, then I agree we should stop configuring default values here since it could lead to issues. But given that we don't have that case today and these are only explicitly set for test injection, I'm not too concerned about it right now.

) {
self.componentManager = componentManager
self.notificationCenter = notificationCenter
self.webLocale = webLocale
self.authenticatedWebViewManager = authenticatedWebViewManager
self.didFailLoadWithError = didFailLoadWithError
self.financialConnectionsPresenter = financialConnectionsPresenter

let config = WKWebViewConfiguration()

Expand All @@ -66,7 +72,7 @@ class ConnectComponentWebViewController: ConnectWebViewController {
// embedded in the web view instead of full screen. Also works for
// embedded YouTube videos.
config.allowsInlineMediaPlayback = true
let allowedHosts = (StripeConnectConstants.allowedHosts + [self.componentManager.baseURL.host]).compactMap({$0})
let allowedHosts = (StripeConnectConstants.allowedHosts + [self.componentManager.baseURL.host]).compactMap({ $0 })
let analyticsClient = analyticsClientFactory(.init(
params: ConnectJSURLParams(component: componentType, apiClient: componentManager.apiClient, publicKeyOverride: componentManager.publicKeyOverride)))
super.init(
Expand Down Expand Up @@ -115,7 +121,8 @@ class ConnectComponentWebViewController: ConnectWebViewController {
// Should only be overridden for tests
notificationCenter: NotificationCenter = NotificationCenter.default,
webLocale: Locale = Locale.autoupdatingCurrent,
authenticatedWebViewManager: AuthenticatedWebViewManager = .init()) {
authenticatedWebViewManager: AuthenticatedWebViewManager = .init(),
financialConnectionsPresenter: FinancialConnectionsPresenter = .init()) {
self.init(componentManager: componentManager,
componentType: componentType,
loadContent: loadContent,
Expand All @@ -124,7 +131,8 @@ class ConnectComponentWebViewController: ConnectWebViewController {
didFailLoadWithError: didFailLoadWithError,
notificationCenter: notificationCenter,
webLocale: webLocale,
authenticatedWebViewManager: authenticatedWebViewManager)
authenticatedWebViewManager: authenticatedWebViewManager,
financialConnectionsPresenter: financialConnectionsPresenter)
}

required init?(coder: NSCoder) {
Expand Down Expand Up @@ -262,6 +270,9 @@ private extension ConnectComponentWebViewController {
addMessageHandler(OpenAuthenticatedWebViewMessageHandler(analyticsClient: analyticsClient) { [weak self] payload in
self?.openAuthenticatedWebView(payload)
})
addMessageHandler(OpenFinancialConnectionsMessageHandler(analyticsClient: analyticsClient) { [weak self] payload in
self?.openFinancialConnections(payload)
})
}

/// Adds NotificationCenter observers
Expand Down Expand Up @@ -304,4 +315,33 @@ private extension ConnectComponentWebViewController {
}
}
}

func openFinancialConnections(_ args: OpenFinancialConnectionsMessageHandler.Payload) {
Task { @MainActor in
let result = await financialConnectionsPresenter.presentForToken(
apiClient: componentManager.apiClient,
clientSecret: args.clientSecret,
connectedAccountId: args.connectedAccountId,
from: self
)

var token: String?
// TODO: MXMOBILE-2491 Log these as errors instead of printing to console

switch result {
case .completed(result: (_, let returnedToken)):
token = returnedToken?.id
if returnedToken == nil {
debugPrint("Error using FinancialConnections: no bank token returned")
}
case .failed(let error):
debugPrint("Error using FinancialConnections: \(error)")
case .canceled:
// No-op
break
}

sendMessage(ReturnedFromFinancialConnectionsSender(payload: .init(bankToken: token, id: args.id)))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// OpenFinancialConnectionsMessageHandler.swift
// StripeConnect
//
// Created by Mel Ludowise on 10/17/24.
//

/// Indicates to open the FinancialConnections flow
class OpenFinancialConnectionsMessageHandler: ScriptMessageHandler<OpenFinancialConnectionsMessageHandler.Payload> {
struct Payload: Codable, Equatable {
/// The Financial Connections Session client secret used to open the FinancialConnectionsSheet
let clientSecret: String
/// Unique identifier (UUID) returned to the web view with the FinancialConnections
/// result in `returnedFromFinancialConnections` message
let id: String
// The id of the Connected Account that requested the Financial Connections Session client secret
let connectedAccountId: String
}
init(analyticsClient: ComponentAnalyticsClient,
didReceiveMessage: @escaping (Payload) -> Void) {
super.init(name: "openFinancialConnections",
analyticsClient: analyticsClient,
didReceiveMessage: didReceiveMessage)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// ReturnedFromFinancialConnectionsSender.swift
// StripeConnect
//
// Created by Mel Ludowise on 10/17/24.
//

/// Notifies that the user finished the FinancialConnections flow
struct ReturnedFromFinancialConnectionsSender: MessageSender {
struct Payload: Codable, Equatable {
/// The linked bank account token.
/// This value will be nil if the user canceled the flow or an error occurred.
let bankToken: String?
/// Unique identifier (UUID) originally passed from the web layer in `openFinancialConnections`
let id: String
}
let name: String = "returnedFromFinancialConnections"
let payload: Payload
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Foundation
import SafariServices
@_spi(DashboardOnly) @_spi(PrivateBetaConnect) @testable import StripeConnect
@_spi(STP) import StripeCore
@testable import StripeFinancialConnections
@_spi(STP) import StripeUICore
import WebKit
import XCTest
Expand Down Expand Up @@ -415,7 +416,7 @@ class ConnectComponentWebViewControllerTests: XCTestCase {
let event = try analyticsClient.lastEvent(ofType: UnrecognizedSetterEvent.self)
XCTAssertEqual(event.metadata.setter, "unknownSetter")
}

@MainActor
func testAllowedHosts() async throws {
let componentManager = componentManagerAssertingOnFetch()
Expand All @@ -426,7 +427,7 @@ class ConnectComponentWebViewControllerTests: XCTestCase {
didFailLoadWithError: { _ in })
XCTAssertEqual(webVC.allowedHosts, StripeConnectConstants.allowedHosts + ["connect-js.stripe.com"])
}

@MainActor
func testAllowedHostsWithModifiedBaseURL() async throws {
let componentManager = componentManagerAssertingOnFetch()
Expand Down Expand Up @@ -455,6 +456,86 @@ class ConnectComponentWebViewControllerTests: XCTestCase {
let event = try analyticsClient.lastEvent(ofType: UnexpectedNavigationEvent.self)
XCTAssertEqual(event.metadata.url, "https://stripe.com")
}

// MARK: - openFinancialConnections

func testOpenFinancialConnections_success() throws {
let componentManager = componentManagerAssertingOnFetch()
let financialConnectionsPresenter = MockFinancialConnectionsPresenter { apiClient, secret, connectedAccountId, vc in
XCTAssert(apiClient === componentManager.apiClient)
XCTAssertEqual(secret, "client_secret_123")
XCTAssertEqual(connectedAccountId, "acct_1234")
XCTAssert(vc is ConnectComponentWebViewController)

return .completed(result: (
session: StripeAPI.FinancialConnectionsSession(clientSecret: "", id: "", accounts: .init(data: [], hasMore: false), livemode: false, paymentAccount: nil, bankAccountToken: nil, status: nil, statusDetails: nil),
token: StripeAPI.BankAccountToken(id: "bank_token", bankAccount: nil, clientIp: nil, livemode: false, used: false)
))
}
let webVC = ConnectComponentWebViewController(componentManager: componentManager,
componentType: .payouts,
loadContent: false,
analyticsClientFactory: MockComponentAnalyticsClient.init,
didFailLoadWithError: { _ in },
financialConnectionsPresenter: financialConnectionsPresenter)

let expectation = try webVC.webView.expectationForMessageReceived(
sender: ReturnedFromFinancialConnectionsSender(payload: .init(
bankToken: "bank_token",
id: "5678"
))
)

webVC.webView.evaluateOpenFinancialConnectionsWebView(clientSecret: "client_secret_123", id: "5678", connectedAccountId: "acct_1234")

wait(for: [expectation], timeout: TestHelpers.defaultTimeout)
}

func testOpenFinancialConnections_canceled() throws {
let componentManager = componentManagerAssertingOnFetch()
let financialConnectionsPresenter = MockFinancialConnectionsPresenter { _, _, _, _ in
return .canceled
}
let webVC = ConnectComponentWebViewController(componentManager: componentManager,
componentType: .payouts,
loadContent: false,
analyticsClientFactory: MockComponentAnalyticsClient.init,
didFailLoadWithError: { _ in },
financialConnectionsPresenter: financialConnectionsPresenter)
let expectation = try webVC.webView.expectationForMessageReceived(
sender: ReturnedFromFinancialConnectionsSender(payload: .init(
bankToken: nil,
id: "5678"
))
)

webVC.webView.evaluateOpenFinancialConnectionsWebView(clientSecret: "client_secret_123", id: "5678", connectedAccountId: "acct_1234")

wait(for: [expectation], timeout: TestHelpers.defaultTimeout)
}

func testOpenFinancialConnections_error() throws {
let componentManager = componentManagerAssertingOnFetch()
let financialConnectionsPresenter = MockFinancialConnectionsPresenter { _, _, _, _ in
return .failed(error: NSError(domain: "mock_error", code: 0))
}
let webVC = ConnectComponentWebViewController(componentManager: componentManager,
componentType: .payouts,
loadContent: false,
analyticsClientFactory: MockComponentAnalyticsClient.init,
didFailLoadWithError: { _ in },
financialConnectionsPresenter: financialConnectionsPresenter)
let expectation = try webVC.webView.expectationForMessageReceived(
sender: ReturnedFromFinancialConnectionsSender(payload: .init(
bankToken: nil,
id: "5678"
))
)

webVC.webView.evaluateOpenFinancialConnectionsWebView(clientSecret: "client_secret_123", id: "5678", connectedAccountId: "acct_1234")

wait(for: [expectation], timeout: TestHelpers.defaultTimeout)
}
}

// MARK: - Helpers
Expand Down Expand Up @@ -484,3 +565,30 @@ private class MockAuthenticatedWebViewManager: AuthenticatedWebViewManager {
try await overridePresent(url, view)
}
}

private class MockFinancialConnectionsPresenter: FinancialConnectionsPresenter {
var overridePresentForToken: (
_ apiClient: STPAPIClient,
_ clientSecret: String,
_ connectedAccountId: String,
_ presentingViewController: UIViewController
) async -> FinancialConnectionsSheet.TokenResult

init(overridePresentForToken: @escaping (
_ apiClient: STPAPIClient,
_ clientSecret: String,
_ connectedAccountId: String,
_ presentingViewController: UIViewController
) -> FinancialConnectionsSheet.TokenResult) {
self.overridePresentForToken = overridePresentForToken
}

override func presentForToken(
apiClient: STPAPIClient,
clientSecret: String,
connectedAccountId: String,
from presentingViewController: UIViewController
) async -> FinancialConnectionsSheet.TokenResult {
await overridePresentForToken(apiClient, clientSecret, connectedAccountId, presentingViewController)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// OpenFinancialConnectionsMessageHandlerTests.swift
// StripeConnect
//
// Created by Mel Ludowise on 10/18/24.
//

@testable import StripeConnect
import XCTest

class OpenFinancialConnectionsMessageHandlerTests: ScriptWebTestBase {
func testMessageSend() {
let expectation = self.expectation(description: "Message received")
webView.addMessageHandler(messageHandler: OpenFinancialConnectionsMessageHandler(analyticsClient: MockComponentAnalyticsClient(commonFields: .mock)) { payload in
XCTAssertEqual(payload, .init(clientSecret: "secret_123", id: "1234", connectedAccountId: "acct_1234"))
expectation.fulfill()
})

webView.evaluateOpenFinancialConnectionsWebView(clientSecret: "secret_123", id: "1234", connectedAccountId: "acct_1234")

waitForExpectations(timeout: TestHelpers.defaultTimeout, handler: nil)
}
}
Loading
Loading