From 06e5ea1fbec97ae9ee727e3c2297f3884047f720 Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Fri, 8 Mar 2024 16:07:04 -0600 Subject: [PATCH 01/32] Universal Links Demo (#1205) * Add "Universal Link Flow" button to PayPal Web demo app - this button with open a page that will allow us to return to the app on tapping "open" * This PR is a proof of concept that our demo app can be set up for universal linking, follow up PRs to this feature branch will build on this work --- Demo/Application/Base/AppDelegate.swift | 2 +- Demo/Application/Base/SceneDelegate.swift | 7 +++++++ .../Features/PayPalWebCheckoutViewController.swift | 7 ++++++- Demo/Demo.entitlements | 4 ++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Demo/Application/Base/AppDelegate.swift b/Demo/Application/Base/AppDelegate.swift index ea3f9a92ce..63da543807 100644 --- a/Demo/Application/Base/AppDelegate.swift +++ b/Demo/Application/Base/AppDelegate.swift @@ -16,7 +16,7 @@ import BraintreeCore return true } - + func registerDefaultsFromSettings() { if processInfoArgs.contains("-EnvironmentSandbox") { userDefaults.set(BraintreeDemoEnvironment.sandbox.rawValue, forKey: BraintreeDemoSettings.EnvironmentDefaultsKey) diff --git a/Demo/Application/Base/SceneDelegate.swift b/Demo/Application/Base/SceneDelegate.swift index 92ed1ce2de..d999b496aa 100644 --- a/Demo/Application/Base/SceneDelegate.swift +++ b/Demo/Application/Base/SceneDelegate.swift @@ -25,4 +25,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } } } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + if let returnURL = userActivity.webpageURL { + // TODO: implementation - pass full URL to BT SDK + print("Returned to Demo app via universal link: \(returnURL)") + } + } } diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index cfa0b98a17..345f68f848 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -10,8 +10,9 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { let payPalCheckoutButton = createButton(title: "PayPal Checkout", action: #selector(tappedPayPalCheckout)) let payPalVaultButton = createButton(title: "PayPal Vault", action: #selector(tappedPayPalVault)) let payPalPayLaterButton = createButton(title: "PayPal with Pay Later Offered", action: #selector(tappedPayPalPayLater)) + let universalLinkButton = createButton(title: "Universal Link Flow", action: #selector(universalLinkFlow)) - let buttons = [payPalCheckoutButton, payPalVaultButton, payPalPayLaterButton] + let buttons = [payPalCheckoutButton, payPalVaultButton, payPalPayLaterButton, universalLinkButton] let stackView = UIStackView(arrangedSubviews: buttons) stackView.axis = .vertical stackView.alignment = .center @@ -83,4 +84,8 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { self.completionBlock(nonce) } } + + @objc func universalLinkFlow(_ sender: UIButton) { + UIApplication.shared.open(URL(string: "https://braintree-ios-demo.fly.dev")!) + } } diff --git a/Demo/Demo.entitlements b/Demo/Demo.entitlements index 2cdb95f604..d74d92e869 100644 --- a/Demo/Demo.entitlements +++ b/Demo/Demo.entitlements @@ -2,6 +2,10 @@ + com.apple.developer.associated-domains + + applinks:braintree-ios-demo.fly.dev + com.apple.developer.in-app-payments merchant.com.braintreepayments.apple-pay-demo.Braintree-Demo From b59f2bd0d18c9e76396979487adeee8e37cf3141 Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Thu, 14 Mar 2024 10:48:04 -0500 Subject: [PATCH 02/32] [QL] Add `BTAppContextSwitcher.universalLink` (#1214) * Add BTAppContextSwitcher.sharedInstance.universalLink property * Parse path in return URL SceneDelegate method --- CHANGELOG.md | 4 ++++ Demo/Application/Base/AppDelegate.swift | 4 +++- Demo/Application/Base/SceneDelegate.swift | 6 +++--- .../Features/PayPalWebCheckoutViewController.swift | 2 +- Sources/BraintreeCore/BTAppContextSwitcher.swift | 11 ++++++++--- .../BTAppContextSwitcher_Tests.swift | 8 +++++++- 6 files changed, 26 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75dd3182d4..423c210a4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Braintree iOS SDK Release Notes +## unreleased +* BraintreeCore + * Add property `BTAppContextSwitcher.sharedInstance.universalLink` for the PayPal app switch flow + ## 6.13.0 (2024-03-12) * BraintreeVenmo * Add `isFinalAmount` to `BTVenmoRequest` diff --git a/Demo/Application/Base/AppDelegate.swift b/Demo/Application/Base/AppDelegate.swift index 63da543807..273bb03d19 100644 --- a/Demo/Application/Base/AppDelegate.swift +++ b/Demo/Application/Base/AppDelegate.swift @@ -4,6 +4,7 @@ import BraintreeCore @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { private let returnURLScheme = "com.braintreepayments.Demo.payments" + private let universalLinkURL = "https://braintree-ios-demo.fly.dev/braintree-payments" private let processInfoArgs = ProcessInfo.processInfo.arguments private let userDefaults = UserDefaults.standard @@ -11,7 +12,8 @@ import BraintreeCore registerDefaultsFromSettings() persistDemoSettings() BTAppContextSwitcher.sharedInstance.returnURLScheme = returnURLScheme - + BTAppContextSwitcher.sharedInstance.universalLink = universalLinkURL + userDefaults.setValue(true, forKey: "magnes.debug.mode") return true diff --git a/Demo/Application/Base/SceneDelegate.swift b/Demo/Application/Base/SceneDelegate.swift index d999b496aa..76e8c4392a 100644 --- a/Demo/Application/Base/SceneDelegate.swift +++ b/Demo/Application/Base/SceneDelegate.swift @@ -21,15 +21,15 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { URLContexts.forEach { urlContext in let url = urlContext.url if url.scheme?.localizedCaseInsensitiveCompare("com.braintreepayments.Demo.payments") == .orderedSame { - _ = BTAppContextSwitcher.sharedInstance.handleOpenURL(context: urlContext) + BTAppContextSwitcher.sharedInstance.handleOpenURL(context: urlContext) } } } func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { - if let returnURL = userActivity.webpageURL { - // TODO: implementation - pass full URL to BT SDK + if let returnURL = userActivity.webpageURL, returnURL.path == "/braintree-payments" { print("Returned to Demo app via universal link: \(returnURL)") + BTAppContextSwitcher.sharedInstance.handleOpen(returnURL) } } } diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index 345f68f848..6b30d4cfde 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -86,6 +86,6 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { } @objc func universalLinkFlow(_ sender: UIButton) { - UIApplication.shared.open(URL(string: "https://braintree-ios-demo.fly.dev")!) + UIApplication.shared.open(URL(string: "https://braintree-ios-demo.fly.dev/braintree-payments")!) } } diff --git a/Sources/BraintreeCore/BTAppContextSwitcher.swift b/Sources/BraintreeCore/BTAppContextSwitcher.swift index c8bb5129e5..20c301c1bc 100644 --- a/Sources/BraintreeCore/BTAppContextSwitcher.swift +++ b/Sources/BraintreeCore/BTAppContextSwitcher.swift @@ -12,8 +12,13 @@ import UIKit /// The URL scheme to return to this app after switching to another app or opening a SFSafariViewController. /// This URL scheme must be registered as a URL Type in the app's info.plist, and it must start with the app's bundle ID. + /// - Note: This property should only be used for the Venmo flow. public var returnURLScheme: String = "" - + + /// The URL to use for the PayPal app switch flow. Must be a valid HTTPS URL dedicated to Braintree app switch returns. + /// - Note: This property should only be used for the PayPal app switch flow. + public var universalLink: String = "" + // MARK: - Private Properties private var appContextSwitchClients = [BTAppContextSwitchClient.Type]() @@ -24,7 +29,7 @@ import UIKit /// - Parameters: url the URL you receive in `scene:openURLContexts:` (or `application:openURL:options:` if not using SceneDelegate) when returning to your app /// - Returns: `true` when the SDK can process the return URL @objc(handleOpenURLContext:) - public func handleOpenURL(context: UIOpenURLContext) -> Bool { + @discardableResult public func handleOpenURL(context: UIOpenURLContext) -> Bool { handleOpen(context.url) } @@ -32,7 +37,7 @@ import UIKit /// - Parameter url: The URL you receive in `scene:openURLContexts:` (or `application:openURL:options:` if not using SceneDelegate) /// - Returns: `true` when the SDK has handled the URL successfully @objc(handleOpenURL:) - public func handleOpen(_ url: URL) -> Bool { + @discardableResult public func handleOpen(_ url: URL) -> Bool { for appContextSwitchClient in appContextSwitchClients { if appContextSwitchClient.canHandleReturnURL(url) { appContextSwitchClient.handleReturnURL(url) diff --git a/UnitTests/BraintreeCoreTests/BTAppContextSwitcher_Tests.swift b/UnitTests/BraintreeCoreTests/BTAppContextSwitcher_Tests.swift index e6c7ed529a..1551de3e68 100644 --- a/UnitTests/BraintreeCoreTests/BTAppContextSwitcher_Tests.swift +++ b/UnitTests/BraintreeCoreTests/BTAppContextSwitcher_Tests.swift @@ -80,13 +80,19 @@ class BTAppContextSwitcher_Tests: XCTestCase { XCTAssertFalse(handled) XCTAssertNil(MockAppContextSwitchClient.lastHandleReturnURL) } - + func testHandleOpenURLContext_withNoAppSwitching_returnsFalse() { let mockURLContext = BTMockOpenURLContext(url: URL(string: "fake://url")!).mock let handled = BTAppContextSwitcher.sharedInstance.handleOpenURL(context: mockURLContext) XCTAssertFalse(handled) } + // MARK: - universalLink Tests + + func testSetUniversalLink() { + BTAppContextSwitcher.sharedInstance.universalLink = "https://fake.com" + XCTAssertEqual(appSwitch.universalLink, "https://fake.com") + } } @objcMembers class MockAppContextSwitchClient: BTAppContextSwitchClient { From 2bff949e74ed2d790106ea78e8e5aecbf1a77f33 Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Tue, 19 Mar 2024 09:41:51 -0500 Subject: [PATCH 03/32] [QL] Conform `BTPayPalClient` to `BTAppContextSwitchClient` Protocol (#1218) * Conform BTPayPalClient to BTAppContextSwitchClient * Add BTPayPalAppSwitchReturnURL struct * Add Unit Tests --- Braintree.xcodeproj/project.pbxproj | 4 ++ Demo/Application/Base/SceneDelegate.swift | 2 +- .../PayPalWebCheckoutViewController.swift | 4 +- .../BTPayPalAppSwitchReturnURL.swift | 28 ++++++++++++++ Sources/BraintreePayPal/BTPayPalClient.swift | 29 +++++++++++++++ .../BTAppContextSwitcher_Tests.swift | 2 +- .../BTPayPalClient_Tests.swift | 37 +++++++++++++++++++ 7 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 Sources/BraintreePayPal/BTPayPalAppSwitchReturnURL.swift diff --git a/Braintree.xcodeproj/project.pbxproj b/Braintree.xcodeproj/project.pbxproj index 258008b42c..8bc4d7dd0d 100644 --- a/Braintree.xcodeproj/project.pbxproj +++ b/Braintree.xcodeproj/project.pbxproj @@ -228,6 +228,7 @@ BE698EA228AA8EEA001D9B10 /* BTCacheDateValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE698EA128AA8EEA001D9B10 /* BTCacheDateValidator.swift */; }; BE698EA428AD2C10001D9B10 /* BTCoreConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE698EA328AD2C10001D9B10 /* BTCoreConstants.swift */; }; BE698EA628B3CDAD001D9B10 /* BTCacheDateValidator_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE698EA528B3CDAD001D9B10 /* BTCacheDateValidator_Tests.swift */; }; + BE6BC22E2BA9CFFC00C3E321 /* BTPayPalAppSwitchReturnURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6BC22D2BA9CFFC00C3E321 /* BTPayPalAppSwitchReturnURL.swift */; }; BE70A963284FA3F000F6D3F7 /* BTDataCollectorError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE70A962284FA3F000F6D3F7 /* BTDataCollectorError.swift */; }; BE70A965284FA9DE00F6D3F7 /* MockBTDataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE70A964284FA9DE00F6D3F7 /* MockBTDataCollector.swift */; }; BE70A983284FC07C00F6D3F7 /* BraintreeDataCollector.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A76D7C001BB1CAB00000FA6A /* BraintreeDataCollector.framework */; }; @@ -864,6 +865,7 @@ BE698EA328AD2C10001D9B10 /* BTCoreConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTCoreConstants.swift; sourceTree = ""; }; BE698EA528B3CDAD001D9B10 /* BTCacheDateValidator_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTCacheDateValidator_Tests.swift; sourceTree = ""; }; BE698EAA28B50F41001D9B10 /* BTClientToken_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTClientToken_Tests.swift; sourceTree = ""; }; + BE6BC22D2BA9CFFC00C3E321 /* BTPayPalAppSwitchReturnURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPayPalAppSwitchReturnURL.swift; sourceTree = ""; }; BE70A962284FA3F000F6D3F7 /* BTDataCollectorError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTDataCollectorError.swift; sourceTree = ""; }; BE70A964284FA9DE00F6D3F7 /* MockBTDataCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBTDataCollector.swift; sourceTree = ""; }; BE7A9643299FC5DE009AB920 /* BTConfiguration+ApplePay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BTConfiguration+ApplePay.swift"; sourceTree = ""; }; @@ -1188,6 +1190,7 @@ 57544F5B295254A500DEB7B0 /* BTJSON+PayPal.swift */, 57544F572952298900DEB7B0 /* BTPayPalAccountNonce.swift */, 3B7A261029C0CAA40087059D /* BTPayPalAnalytics.swift */, + BE6BC22D2BA9CFFC00C3E321 /* BTPayPalAppSwitchReturnURL.swift */, BE8E5CEE294B6937001BF017 /* BTPayPalCheckoutRequest.swift */, 57544F5929524E4D00DEB7B0 /* BTPayPalClient.swift */, 5754481F294A2EBE00DEB7B0 /* BTPayPalCreditFinancing.swift */, @@ -2782,6 +2785,7 @@ BE349113294B798300D2CF68 /* BTPayPalRequest.swift in Sources */, 57544F5C295254A500DEB7B0 /* BTJSON+PayPal.swift in Sources */, 3B7A261129C0CAA40087059D /* BTPayPalAnalytics.swift in Sources */, + BE6BC22E2BA9CFFC00C3E321 /* BTPayPalAppSwitchReturnURL.swift in Sources */, BE8E5CEF294B6937001BF017 /* BTPayPalCheckoutRequest.swift in Sources */, 5754481E294A2A1D00DEB7B0 /* BTPayPalCreditFinancingAmount.swift in Sources */, 57D9436E2968A8080079EAB1 /* BTPayPalLocaleCode.swift in Sources */, diff --git a/Demo/Application/Base/SceneDelegate.swift b/Demo/Application/Base/SceneDelegate.swift index 76e8c4392a..b70118fa9a 100644 --- a/Demo/Application/Base/SceneDelegate.swift +++ b/Demo/Application/Base/SceneDelegate.swift @@ -27,7 +27,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { - if let returnURL = userActivity.webpageURL, returnURL.path == "/braintree-payments" { + if let returnURL = userActivity.webpageURL, returnURL.path.contains("braintree-payments") { print("Returned to Demo app via universal link: \(returnURL)") BTAppContextSwitcher.sharedInstance.handleOpen(returnURL) } diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index 6b30d4cfde..df63bfdcf2 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -86,6 +86,8 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { } @objc func universalLinkFlow(_ sender: UIButton) { - UIApplication.shared.open(URL(string: "https://braintree-ios-demo.fly.dev/braintree-payments")!) + // TODO: implement in a future PR - used here so we don't have to remove lazy instantiation + payPalClient.tokenize(BTPayPalVaultRequest()) { _, _ in } + UIApplication.shared.open(URL(string: "https://braintree-ios-demo.fly.dev/braintree-payments/success")!) } } diff --git a/Sources/BraintreePayPal/BTPayPalAppSwitchReturnURL.swift b/Sources/BraintreePayPal/BTPayPalAppSwitchReturnURL.swift new file mode 100644 index 0000000000..e6ca706eaa --- /dev/null +++ b/Sources/BraintreePayPal/BTPayPalAppSwitchReturnURL.swift @@ -0,0 +1,28 @@ +import Foundation + +enum BTPayPalAppSwitchReturnURLState { + case unknown + case succeeded + case canceled +} + +/// This class interprets URLs received from the PayPal app via app switch returns. +/// +/// PayPal app switch authorization requests should result in success or user-initiated cancelation. These states are communicated in the url. +struct BTPayPalAppSwitchReturnURL { + + /// Initializes a new `BTPayPalAppSwitchReturnURL` + /// - Parameter url: an incoming app switch url + init?(url: URL) { + // TODO: implement init based on return URL + } + + // MARK: - Static Methods + + /// Evaluates whether the url represents a valid PayPal return URL. + /// - Parameter url: an app switch return URL + /// - Returns: `true` if the url represents a valid PayPal app switch return + static func isValid(_ url: URL) -> Bool { + url.scheme == "https" && (url.path.contains("cancel") || url.path.contains("success")) + } +} diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index 24bafae622..c1a045e236 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -29,6 +29,12 @@ import BraintreeDataCollector /// Exposed for testing, the ASWebAuthenticationSession instance used for the PayPal flow var webAuthenticationSession: BTWebAuthenticationSession + // MARK: - Static Properties + + /// This static instance of `BTPayPalClient` is used during the app switch process. + /// We require a static reference of the client to call `handleReturnURL` and return to the app. + static var payPalClient: BTPayPalClient? = nil + // MARK: - Private Properties /// Indicates if the user returned back to the merchant app from the `BTWebAuthenticationSession` @@ -45,6 +51,8 @@ import BraintreeDataCollector /// - Parameter apiClient: The API Client @objc(initWithAPIClient:) public init(apiClient: BTAPIClient) { + BTAppContextSwitcher.sharedInstance.register(BTPayPalClient.self) + self.apiClient = apiClient self.webAuthenticationSession = BTWebAuthenticationSession() @@ -221,6 +229,12 @@ import BraintreeDataCollector performSwitchRequest(appSwitchURL: url, paymentType: paymentType, completion: completion) } + // MARK: - App Switch Methods + + func handleReturnURL(_ url: URL) { + // TODO: implement handling return URL in a follow up PR + } + // MARK: - Private Methods private func tokenize( @@ -431,3 +445,18 @@ import BraintreeDataCollector completion(nil, BTPayPalError.canceled) } } + +extension BTPayPalClient: BTAppContextSwitchClient { + /// :nodoc: + @_documentation(visibility: private) + @objc public static func handleReturnURL(_ url: URL) { + payPalClient?.handleReturnURL(url) + BTPayPalClient.payPalClient = nil + } + + /// :nodoc: + @_documentation(visibility: private) + @objc public static func canHandleReturnURL(_ url: URL) -> Bool { + BTPayPalAppSwitchReturnURL.isValid(url) + } +} diff --git a/UnitTests/BraintreeCoreTests/BTAppContextSwitcher_Tests.swift b/UnitTests/BraintreeCoreTests/BTAppContextSwitcher_Tests.swift index 1551de3e68..86db5764e3 100644 --- a/UnitTests/BraintreeCoreTests/BTAppContextSwitcher_Tests.swift +++ b/UnitTests/BraintreeCoreTests/BTAppContextSwitcher_Tests.swift @@ -25,7 +25,7 @@ class BTAppContextSwitcher_Tests: XCTestCase { appSwitch.register(MockAppContextSwitchClient.self) let expectedURL = URL(string: "fake://url")! - BTAppContextSwitcher.sharedInstance.handleOpen(expectedURL) + _ = BTAppContextSwitcher.sharedInstance.handleOpen(expectedURL) XCTAssertEqual(MockAppContextSwitchClient.lastCanHandleURL!, expectedURL) } diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index fc4956c662..9f7956ed1d 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -634,6 +634,43 @@ class BTPayPalClient_Tests: XCTestCase { XCTAssertEqual(metaParameters["sessionId"] as? String, mockAPIClient.metadata.sessionID) } + // MARK: - App Switch + + func testCanHandleReturnURL_whenHostIsURLScheme_returnsFalse() { + let url = URL(string: "fake-scheme://success")! + XCTAssertFalse(BTPayPalClient.canHandleReturnURL(url)) + } + + func testCanHandleReturnURL_whenPathIsInvalid_returnsFalse() { + let url = URL(string: "https://mycoolwebsite.com/junkpath")! + XCTAssertFalse(BTPayPalClient.canHandleReturnURL(url)) + } + + func testCanHandleReturnURL_whenSchemeIsHTTP_returnsFalse() { + let url = URL(string: "http://mycoolwebsite.com/success")! + XCTAssertFalse(BTPayPalClient.canHandleReturnURL(url)) + } + + func testCanHandleReturnURL_whenPathIsValidSuccess_returnsTrue() { + let url = URL(string: "https://mycoolwebsite.com/braintree-payments/success")! + XCTAssertTrue(BTPayPalClient.canHandleReturnURL(url)) + } + + func testCanHandleReturnURL_whenPathIsValidCancel_returnsTrue() { + let url = URL(string: "https://mycoolwebsite.com/braintree-payments/cancel")! + XCTAssertTrue(BTPayPalClient.canHandleReturnURL(url)) + } + + func testCanHandleReturnURL_whenPathIsValidWithQueryParameters_returnsTrue() { + let url = URL(string: "https://mycoolwebsite.com/braintree-payments/success?token=112233")! + XCTAssertTrue(BTPayPalClient.canHandleReturnURL(url)) + } + + func testHandleReturnURL_whenURLIsValid_setsBTPayPalClientToNil() { + BTPayPalClient.handleReturnURL(URL(string: "https://mycoolwebsite.com/braintree-payments/success")!) + XCTAssertNil(BTPayPalClient.payPalClient) + } + // MARK: - Analytics func testAPIClientMetadata_hasIntegrationSetToCustom() { From 0aab1477f5c2dacd749c0a352c5ed72591e520a6 Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Wed, 20 Mar 2024 15:03:23 -0500 Subject: [PATCH 04/32] [QL] Add `BTPayPalVault.enablePayPalAppSwitch` property (#1227) * Add BTPayPalVault.enablePayPalAppSwitch property * Add new BTPayPalVaultBaseRequest - this allows us to only use the new property in the PayPal Web flow and cannot be accessed in the PayPal Native Checkout module --- Braintree.xcodeproj/project.pbxproj | 4 ++ CHANGELOG.md | 3 ++ .../PayPalWebCheckoutViewController.swift | 3 +- .../BTPayPalVaultBaseRequest.swift | 54 +++++++++++++++++++ .../BTPayPalVaultRequest.swift | 54 +++++++------------ .../BTPayPalNativeVaultRequest.swift | 2 +- .../BTPayPalRequest_Tests.swift | 7 +++ 7 files changed, 89 insertions(+), 38 deletions(-) create mode 100644 Sources/BraintreePayPal/BTPayPalVaultBaseRequest.swift diff --git a/Braintree.xcodeproj/project.pbxproj b/Braintree.xcodeproj/project.pbxproj index 8bc4d7dd0d..7d8bc1a9f8 100644 --- a/Braintree.xcodeproj/project.pbxproj +++ b/Braintree.xcodeproj/project.pbxproj @@ -228,6 +228,7 @@ BE698EA228AA8EEA001D9B10 /* BTCacheDateValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE698EA128AA8EEA001D9B10 /* BTCacheDateValidator.swift */; }; BE698EA428AD2C10001D9B10 /* BTCoreConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE698EA328AD2C10001D9B10 /* BTCoreConstants.swift */; }; BE698EA628B3CDAD001D9B10 /* BTCacheDateValidator_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE698EA528B3CDAD001D9B10 /* BTCacheDateValidator_Tests.swift */; }; + BE6BC22C2BA9C67600C3E321 /* BTPayPalVaultBaseRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6BC22B2BA9C67600C3E321 /* BTPayPalVaultBaseRequest.swift */; }; BE6BC22E2BA9CFFC00C3E321 /* BTPayPalAppSwitchReturnURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6BC22D2BA9CFFC00C3E321 /* BTPayPalAppSwitchReturnURL.swift */; }; BE70A963284FA3F000F6D3F7 /* BTDataCollectorError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE70A962284FA3F000F6D3F7 /* BTDataCollectorError.swift */; }; BE70A965284FA9DE00F6D3F7 /* MockBTDataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE70A964284FA9DE00F6D3F7 /* MockBTDataCollector.swift */; }; @@ -865,6 +866,7 @@ BE698EA328AD2C10001D9B10 /* BTCoreConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTCoreConstants.swift; sourceTree = ""; }; BE698EA528B3CDAD001D9B10 /* BTCacheDateValidator_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTCacheDateValidator_Tests.swift; sourceTree = ""; }; BE698EAA28B50F41001D9B10 /* BTClientToken_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTClientToken_Tests.swift; sourceTree = ""; }; + BE6BC22B2BA9C67600C3E321 /* BTPayPalVaultBaseRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPayPalVaultBaseRequest.swift; sourceTree = ""; }; BE6BC22D2BA9CFFC00C3E321 /* BTPayPalAppSwitchReturnURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPayPalAppSwitchReturnURL.swift; sourceTree = ""; }; BE70A962284FA3F000F6D3F7 /* BTDataCollectorError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTDataCollectorError.swift; sourceTree = ""; }; BE70A964284FA9DE00F6D3F7 /* MockBTDataCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBTDataCollector.swift; sourceTree = ""; }; @@ -1199,6 +1201,7 @@ BEF5D2E5294A18B300FFD56D /* BTPayPalLineItem.swift */, 57D9436D2968A8080079EAB1 /* BTPayPalLocaleCode.swift */, BE349112294B798300D2CF68 /* BTPayPalRequest.swift */, + BE6BC22B2BA9C67600C3E321 /* BTPayPalVaultBaseRequest.swift */, BE349110294B77E100D2CF68 /* BTPayPalVaultRequest.swift */, 62A659A32B98CB23008DFD67 /* PrivacyInfo.xcprivacy */, ); @@ -2790,6 +2793,7 @@ 5754481E294A2A1D00DEB7B0 /* BTPayPalCreditFinancingAmount.swift in Sources */, 57D9436E2968A8080079EAB1 /* BTPayPalLocaleCode.swift in Sources */, 57544F582952298900DEB7B0 /* BTPayPalAccountNonce.swift in Sources */, + BE6BC22C2BA9C67600C3E321 /* BTPayPalVaultBaseRequest.swift in Sources */, BE349111294B77E100D2CF68 /* BTPayPalVaultRequest.swift in Sources */, 57544820294A2EBE00DEB7B0 /* BTPayPalCreditFinancing.swift in Sources */, 57544F5A29524E4D00DEB7B0 /* BTPayPalClient.swift in Sources */, diff --git a/CHANGELOG.md b/CHANGELOG.md index f11b06f8d1..1ab8c00592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## unreleased * BraintreeCore * Add property `BTAppContextSwitcher.sharedInstance.universalLink` for the PayPal app switch flow +* BraintreePayPal + * Add `BTPayPalVault.enablePayPalAppSwitch` + * If set to `true` we will attempt to use the PayPal App Switch flow ## 6.16.0 (2024-03-19) * Add `BTPayPalVaultRequest.userAuthenticationEmail` optional property diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index df63bfdcf2..a9c363b68b 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -87,7 +87,8 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { @objc func universalLinkFlow(_ sender: UIButton) { // TODO: implement in a future PR - used here so we don't have to remove lazy instantiation - payPalClient.tokenize(BTPayPalVaultRequest()) { _, _ in } + let request = BTPayPalVaultRequest(enablePayPalAppSwitch: true) + payPalClient.tokenize(request) { _, _ in } UIApplication.shared.open(URL(string: "https://braintree-ios-demo.fly.dev/braintree-payments/success")!) } } diff --git a/Sources/BraintreePayPal/BTPayPalVaultBaseRequest.swift b/Sources/BraintreePayPal/BTPayPalVaultBaseRequest.swift new file mode 100644 index 0000000000..77609770ff --- /dev/null +++ b/Sources/BraintreePayPal/BTPayPalVaultBaseRequest.swift @@ -0,0 +1,54 @@ +import Foundation + +#if canImport(BraintreeCore) +import BraintreeCore +#endif + +/// Options for the PayPal Vault flow. +@objcMembers open class BTPayPalVaultBaseRequest: BTPayPalRequest { + + // MARK: - Public Properties + + /// Optional: Offers PayPal Credit if the customer qualifies. Defaults to `false`. + public var offerCredit: Bool + + // MARK: - Initializer + + /// Initializes a PayPal Native Vault request + /// - Parameters: + /// - offerCredit: Optional: Offers PayPal Credit if the customer qualifies. Defaults to `false`. + public init(offerCredit: Bool = false) { + self.offerCredit = offerCredit + + super.init(hermesPath: "v1/paypal_hermes/setup_billing_agreement", paymentType: .vault) + } + + // MARK: Public Methods + + /// :nodoc: Exposed publicly for use by PayPal Native Checkout module. This method is not covered by semantic versioning. + @_documentation(visibility: private) + public override func parameters(with configuration: BTConfiguration) -> [String: Any] { + let baseParameters = super.parameters(with: configuration) + var vaultParameters: [String: Any] = ["offer_paypal_credit": offerCredit] + + if let billingAgreementDescription { + vaultParameters["description"] = billingAgreementDescription + } + + if let shippingAddressOverride { + let shippingAddressParameters: [String: String?] = [ + "line1": shippingAddressOverride.streetAddress, + "line2": shippingAddressOverride.extendedAddress, + "city": shippingAddressOverride.locality, + "state": shippingAddressOverride.region, + "postal_code": shippingAddressOverride.postalCode, + "country_code": shippingAddressOverride.countryCodeAlpha2, + "recipient_name": shippingAddressOverride.recipientName + ] + + vaultParameters["shipping_address"] = shippingAddressParameters + } + + return baseParameters.merging(vaultParameters) { $1 } + } +} diff --git a/Sources/BraintreePayPal/BTPayPalVaultRequest.swift b/Sources/BraintreePayPal/BTPayPalVaultRequest.swift index d6f4461742..d59cdd3b40 100644 --- a/Sources/BraintreePayPal/BTPayPalVaultRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalVaultRequest.swift @@ -5,57 +5,39 @@ import BraintreeCore #endif /// Options for the PayPal Vault flow. -@objcMembers open class BTPayPalVaultRequest: BTPayPalRequest { +@objcMembers public class BTPayPalVaultRequest: BTPayPalVaultBaseRequest { // MARK: - Public Properties - /// Optional: Offers PayPal Credit if the customer qualifies. Defaults to `false`. - public var offerCredit: Bool - + /// Optional: Used to determine if the customer will use the PayPal app switch flow. + /// Defaults to `false`. + /// - Note: This property is currently in beta and may change or be removed in future releases. + public var enablePayPalAppSwitch: Bool + /// Optional: User email to initiate a quicker authentication flow in cases where the user has a PayPal Account with the same email. public var userAuthenticationEmail: String? // MARK: - Initializer - /// Initializes a PayPal Native Vault request - /// - Parameter offerCredit: Optional: Offers PayPal Credit if the customer qualifies. Defaults to `false`. - public init(offerCredit: Bool = false, userAuthenticationEmail: String? = nil) { - self.offerCredit = offerCredit + /// Initializes a PayPal Vault request + /// - Parameters: + /// - offerCredit: Optional: Offers PayPal Credit if the customer qualifies. Defaults to `false`. + /// - userAuthenticationEmail: Optional: User email to initiate a quicker authentication flow in cases where the user has a PayPal Account with the same email. + /// - enablePayPalAppSwitch: Optional: Used to determine if the customer will use the PayPal app switch flow. Defaults to `false`. + /// This property is currently in beta and may change or be removed in future releases. + public init(offerCredit: Bool = false, userAuthenticationEmail: String? = nil, enablePayPalAppSwitch: Bool = false) { + self.enablePayPalAppSwitch = enablePayPalAppSwitch self.userAuthenticationEmail = userAuthenticationEmail - - super.init(hermesPath: "v1/paypal_hermes/setup_billing_agreement", paymentType: .vault) + super.init(offerCredit: offerCredit) } - // MARK: Public Methods - - /// :nodoc: Exposed publicly for use by PayPal Native Checkout module. This method is not covered by semantic versioning. - @_documentation(visibility: private) public override func parameters(with configuration: BTConfiguration) -> [String: Any] { - let baseParameters = super.parameters(with: configuration) - var vaultParameters: [String: Any] = ["offer_paypal_credit": offerCredit] + var baseParameters = super.parameters(with: configuration) - if let billingAgreementDescription { - vaultParameters["description"] = billingAgreementDescription - } - if let userAuthenticationEmail { - vaultParameters["payer_email"] = userAuthenticationEmail - } - - if let shippingAddressOverride { - let shippingAddressParameters: [String: String?] = [ - "line1": shippingAddressOverride.streetAddress, - "line2": shippingAddressOverride.extendedAddress, - "city": shippingAddressOverride.locality, - "state": shippingAddressOverride.region, - "postal_code": shippingAddressOverride.postalCode, - "country_code": shippingAddressOverride.countryCodeAlpha2, - "recipient_name": shippingAddressOverride.recipientName - ] - - vaultParameters["shipping_address"] = shippingAddressParameters + baseParameters["payer_email"] = userAuthenticationEmail } - return baseParameters.merging(vaultParameters) { $1 } + return baseParameters } } diff --git a/Sources/BraintreePayPalNativeCheckout/BTPayPalNativeVaultRequest.swift b/Sources/BraintreePayPalNativeCheckout/BTPayPalNativeVaultRequest.swift index cd9f7a7ce6..6fa44883b7 100644 --- a/Sources/BraintreePayPalNativeCheckout/BTPayPalNativeVaultRequest.swift +++ b/Sources/BraintreePayPalNativeCheckout/BTPayPalNativeVaultRequest.swift @@ -9,7 +9,7 @@ import BraintreePayPal #endif /// Options for the PayPal Vault flow. -@objcMembers public class BTPayPalNativeVaultRequest: BTPayPalVaultRequest { +@objcMembers public class BTPayPalNativeVaultRequest: BTPayPalVaultBaseRequest { // MARK: - Initializer diff --git a/UnitTests/BraintreePayPalTests/BTPayPalRequest_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalRequest_Tests.swift index c8538902aa..0b236a852e 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalRequest_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalRequest_Tests.swift @@ -93,4 +93,11 @@ class BTPayPalRequest_Tests: XCTestCase { guard let experienceProfile = parameters["experience_profile"] as? [String:Any] else { XCTFail(); return } XCTAssertEqual(experienceProfile["no_shipping"] as? Bool, false) } + + // MARK: - enablePayPalAppSwitch + + func testEnablePayPalAppSwitch_whenNotPassed_defaultsValueAsFalse() { + let request = BTPayPalVaultRequest() + XCTAssertFalse(request.enablePayPalAppSwitch) + } } From 657456bc8f488c402a3db671178f4736ca598543 Mon Sep 17 00:00:00 2001 From: scannillo <35243507+scannillo@users.noreply.github.com> Date: Thu, 21 Mar 2024 16:03:14 -0500 Subject: [PATCH 05/32] [QL] Add additional POST params to `/setup_billing_agreement` API call (#1228) --- .../BraintreePayPal/BTPayPalVaultRequest.swift | 12 +++++++++++- .../BTPayPalVaultRequest_Tests.swift | 18 +++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Sources/BraintreePayPal/BTPayPalVaultRequest.swift b/Sources/BraintreePayPal/BTPayPalVaultRequest.swift index d59cdd3b40..eb03598643 100644 --- a/Sources/BraintreePayPal/BTPayPalVaultRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalVaultRequest.swift @@ -1,4 +1,4 @@ -import Foundation +import UIKit #if canImport(BraintreeCore) import BraintreeCore @@ -37,6 +37,16 @@ import BraintreeCore if let userAuthenticationEmail { baseParameters["payer_email"] = userAuthenticationEmail } + + if enablePayPalAppSwitch { + let appSwitchParameters: [String: Any] = [ + "launch_paypal_app": enablePayPalAppSwitch, + "os_version": UIDevice.current.systemVersion, + "os_type": UIDevice.current.systemName, + "merchant_app_return_url": BTAppContextSwitcher.sharedInstance.universalLink + ] + return baseParameters.merging(appSwitchParameters) { $1 } + } return baseParameters } diff --git a/UnitTests/BraintreePayPalTests/BTPayPalVaultRequest_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalVaultRequest_Tests.swift index c2baf2407a..45568d73bc 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalVaultRequest_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalVaultRequest_Tests.swift @@ -54,7 +54,11 @@ class BTPayPalVaultRequest_Tests: XCTestCase { XCTAssertEqual(parameters["description"] as? String, "desc") XCTAssertEqual(parameters["offer_paypal_credit"] as? Bool, true) XCTAssertEqual(parameters["payer_email"] as? String, "fake@email.com") - + XCTAssertNil(parameters["launch_paypal_app"]) + XCTAssertNil(parameters["os_version"]) + XCTAssertNil(parameters["os_type"]) + XCTAssertNil(parameters["merchant_app_return_url"]) + guard let shippingParams = parameters["shipping_address"] as? [String:String] else { XCTFail(); return } XCTAssertEqual(shippingParams["line1"], "123 Main") @@ -65,4 +69,16 @@ class BTPayPalVaultRequest_Tests: XCTestCase { XCTAssertEqual(shippingParams["country_code"], "US") XCTAssertEqual(shippingParams["recipient_name"], "Recipient") } + + func testParameters_withEnablePayPalAppSwitchTrue_returnsAllParams() { + BTAppContextSwitcher.sharedInstance.universalLink = "some-url" + let request = BTPayPalVaultRequest(enablePayPalAppSwitch: true) + + let parameters = request.parameters(with: configuration) + + XCTAssertEqual(parameters["launch_paypal_app"] as? Bool, true) + XCTAssertTrue((parameters["os_version"] as! String).matches("\\d+\\.\\d+")) + XCTAssertTrue((parameters["os_type"] as! String).matches("iOS|iPadOS")) + XCTAssertEqual(parameters["merchant_app_return_url"] as? String, "some-url") + } } From e74952736cb664996a34bc5b13040b408e2cd03c Mon Sep 17 00:00:00 2001 From: scannillo <35243507+scannillo@users.noreply.github.com> Date: Tue, 26 Mar 2024 10:25:38 -0500 Subject: [PATCH 06/32] Open PayPal App URL if present in BT GW response (#1234) --- Braintree.xcodeproj/project.pbxproj | 4 ++ Demo/Application/Base/AppDelegate.swift | 3 +- .../BTPayPalApprovalURLParser.swift | 44 ++++++++++++ Sources/BraintreePayPal/BTPayPalClient.swift | 71 +++++++++---------- Sources/BraintreePayPal/BTPayPalError.swift | 8 +-- .../BTPayPalClient_Tests.swift | 63 ++++++++++++++-- 6 files changed, 146 insertions(+), 47 deletions(-) create mode 100644 Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift diff --git a/Braintree.xcodeproj/project.pbxproj b/Braintree.xcodeproj/project.pbxproj index 7d8bc1a9f8..753e562fd2 100644 --- a/Braintree.xcodeproj/project.pbxproj +++ b/Braintree.xcodeproj/project.pbxproj @@ -83,6 +83,7 @@ 62DE8FBF2B9656BF00F08F53 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 62DE8FBE2B9656BF00F08F53 /* PrivacyInfo.xcprivacy */; }; 800E78C429E0DD5300D1B0FC /* FPTIBatchData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800E78C329E0DD5300D1B0FC /* FPTIBatchData.swift */; }; 800FC544257FDC5100DEE132 /* BTApplePayCardNonce_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800FC543257FDC5100DEE132 /* BTApplePayCardNonce_Tests.swift */; }; + 8014221C2BAE935B009F9999 /* BTPayPalApprovalURLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8014221B2BAE935B009F9999 /* BTPayPalApprovalURLParser.swift */; }; 804326BF2B1A5C5B0044E90B /* BTApplePaymentTokensRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 804326BE2B1A5C5B0044E90B /* BTApplePaymentTokensRequest.swift */; }; 80482F8029D39A1D007E5F50 /* BTThreeDSecureRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80482F7F29D39A1D007E5F50 /* BTThreeDSecureRequest.swift */; }; 80482F8229D39BF5007E5F50 /* BTThreeDSecureRequestDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80482F8129D39BF5007E5F50 /* BTThreeDSecureRequestDelegate.swift */; }; @@ -721,6 +722,7 @@ 800E23DC22206A8300C5D22E /* BTThreeDSecureAuthenticateJWT_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTThreeDSecureAuthenticateJWT_Tests.swift; sourceTree = ""; }; 800E78C329E0DD5300D1B0FC /* FPTIBatchData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FPTIBatchData.swift; sourceTree = ""; }; 800FC543257FDC5100DEE132 /* BTApplePayCardNonce_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTApplePayCardNonce_Tests.swift; sourceTree = ""; }; + 8014221B2BAE935B009F9999 /* BTPayPalApprovalURLParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPayPalApprovalURLParser.swift; sourceTree = ""; }; 804326BE2B1A5C5B0044E90B /* BTApplePaymentTokensRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTApplePaymentTokensRequest.swift; sourceTree = ""; }; 80482F7F29D39A1D007E5F50 /* BTThreeDSecureRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTThreeDSecureRequest.swift; sourceTree = ""; }; 80482F8129D39BF5007E5F50 /* BTThreeDSecureRequestDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTThreeDSecureRequestDelegate.swift; sourceTree = ""; }; @@ -1192,6 +1194,7 @@ 57544F5B295254A500DEB7B0 /* BTJSON+PayPal.swift */, 57544F572952298900DEB7B0 /* BTPayPalAccountNonce.swift */, 3B7A261029C0CAA40087059D /* BTPayPalAnalytics.swift */, + 8014221B2BAE935B009F9999 /* BTPayPalApprovalURLParser.swift */, BE6BC22D2BA9CFFC00C3E321 /* BTPayPalAppSwitchReturnURL.swift */, BE8E5CEE294B6937001BF017 /* BTPayPalCheckoutRequest.swift */, 57544F5929524E4D00DEB7B0 /* BTPayPalClient.swift */, @@ -2794,6 +2797,7 @@ 57D9436E2968A8080079EAB1 /* BTPayPalLocaleCode.swift in Sources */, 57544F582952298900DEB7B0 /* BTPayPalAccountNonce.swift in Sources */, BE6BC22C2BA9C67600C3E321 /* BTPayPalVaultBaseRequest.swift in Sources */, + 8014221C2BAE935B009F9999 /* BTPayPalApprovalURLParser.swift in Sources */, BE349111294B77E100D2CF68 /* BTPayPalVaultRequest.swift in Sources */, 57544820294A2EBE00DEB7B0 /* BTPayPalCreditFinancing.swift in Sources */, 57544F5A29524E4D00DEB7B0 /* BTPayPalClient.swift in Sources */, diff --git a/Demo/Application/Base/AppDelegate.swift b/Demo/Application/Base/AppDelegate.swift index 273bb03d19..00eb01502d 100644 --- a/Demo/Application/Base/AppDelegate.swift +++ b/Demo/Application/Base/AppDelegate.swift @@ -4,7 +4,8 @@ import BraintreeCore @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { private let returnURLScheme = "com.braintreepayments.Demo.payments" - private let universalLinkURL = "https://braintree-ios-demo.fly.dev/braintree-payments" + // TODO: - Replace with Demo app URL of "https://braintree-ios-demo.fly.dev/braintree-payments" once BT GW allowlists all URLs. + private let universalLinkURL = "https://paypal.com" private let processInfoArgs = ProcessInfo.processInfo.arguments private let userDefaults = UserDefaults.standard diff --git a/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift b/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift new file mode 100644 index 0000000000..6b6398a919 --- /dev/null +++ b/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift @@ -0,0 +1,44 @@ +import Foundation + +#if canImport(BraintreeCore) +import BraintreeCore +#endif + +/// The type of PayPal authentication flow to occur +enum PayPalRedirectType { + + /// The in-app browser (ASWebAuthenticationSession) web checkout flow + case webBrowser(url: URL) + + /// The universal link flow, switching out of the merchant app into the native PayPal app + case payPalApp(url: URL) +} + +/// Parses response body from `/v1/paypal_hermes/*` POST requests to determine the `PayPalRedirectType` +struct BTPayPalApprovalURLParser { + + var redirectType: PayPalRedirectType + + var pairingID: String? { + switch redirectType { + case .webBrowser(let url), .payPalApp(let url): + let url = URLComponents(url: url, resolvingAgainstBaseURL: true) + if let token = url?.queryItems?.first(where: { $0.name == "token" || $0.name == "ba_token" })?.value, + !token.isEmpty { + return token + } + return nil + } + } + + init?(body: BTJSON) { + if let payPalAppRedirectURL = body["paymentResource"]["paypalAppApprovalUrl"].asURL() { + redirectType = .payPalApp(url: payPalAppRedirectURL) + } else if let approvalURL = body["paymentResource"]["redirectUrl"].asURL() ?? + body["agreementSetup"]["approvalUrl"].asURL() { + redirectType = .webBrowser(url: approvalURL) + } else { + return nil + } + } +} diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index dd5455f70f..7311f37d11 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -16,6 +16,10 @@ import BraintreeDataCollector /// Exposed for testing to get the instance of BTAPIClient var apiClient: BTAPIClient + /// Defaults to `UIApplication.shared`, but exposed for unit tests to inject test doubles + /// to prevent calls to openURL. Subclassing UIApplication is not possible, since it enforces that only one instance can ever exist. + var application: URLOpener = UIApplication.shared + /// Exposed for testing the approvalURL construction var approvalURL: URL? = nil @@ -272,27 +276,44 @@ import BraintreeDataCollector self.notifyFailure(with: BTPayPalError.httpPostRequestError(dictionary), completion: completion) return } - - guard let body, - let approvalURL = body["paymentResource"]["redirectUrl"].asURL() ?? - body["agreementSetup"]["approvalUrl"].asURL() else { - self.notifyFailure(with: BTPayPalError.invalidURL, completion: completion) + + guard let body, let approvalURL = BTPayPalApprovalURLParser(body: body) else { + self.notifyFailure(with: BTPayPalError.invalidURL("Missing approval URL in gateway response."), completion: completion) return } - - let pairingID = self.token(from: approvalURL) - - if !pairingID.isEmpty { - self.payPalContextID = pairingID - } + + self.payPalContextID = approvalURL.pairingID let dataCollector = BTDataCollector(apiClient: self.apiClient) - self.clientMetadataID = self.payPalRequest?.riskCorrelationID ?? dataCollector.clientMetadataID(pairingID) - self.handlePayPalRequest(with: approvalURL, paymentType: request.paymentType, completion: completion) + self.clientMetadataID = self.payPalRequest?.riskCorrelationID ?? dataCollector.clientMetadataID(approvalURL.pairingID) + + switch approvalURL.redirectType { + case .payPalApp(let url): + self.launchPayPalApp(with: url, completion: completion) + case .webBrowser(let url): + self.handlePayPalRequest(with: url, paymentType: request.paymentType, completion: completion) + } } } } + private func launchPayPalApp(with payPalAppRedirectURL: URL, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void) { + var urlComponents = URLComponents(url: payPalAppRedirectURL, resolvingAgainstBaseURL: true) + urlComponents?.queryItems = [ + URLQueryItem(name: "source", value: "braintree_sdk"), + URLQueryItem(name: "switch_initiated_time", value: String(Int(round(Date().timeIntervalSince1970 * 1000)))) + ] + + guard let redirectURL = urlComponents?.url else { + self.notifyFailure(with: BTPayPalError.invalidURL("Unable to construct PayPal app redirect URL."), completion: completion) + return + } + + application.open(redirectURL, options: [:]) { success in + // TODO: - Handle success or fail of opening app + } + } + private func performSwitchRequest( appSwitchURL: URL, paymentType: BTPayPalPaymentType, @@ -332,30 +353,6 @@ import BraintreeDataCollector } } - private func token(from approvalURL: URL) -> String { - guard let query = approvalURL.query else { return "" } - let queryDictionary = parse(queryString: query) - - return queryDictionary["token"] ?? queryDictionary["ba_token"] ?? "" - } - - private func parse(queryString query: String) -> [String: String] { - var dict = [String: String]() - let pairs = query.components(separatedBy: "&") - - for pair in pairs { - let elements = pair.components(separatedBy: "=") - if elements.count > 1, - let key = elements[0].removingPercentEncoding, - let value = elements[1].removingPercentEncoding, - !key.isEmpty, - !value.isEmpty { - dict[key] = value - } - } - return dict - } - private func isValidURLAction(url: URL) -> Bool { guard let host = url.host, let scheme = url.scheme, !scheme.isEmpty else { return false diff --git a/Sources/BraintreePayPal/BTPayPalError.swift b/Sources/BraintreePayPal/BTPayPalError.swift index b1c81a6f3e..063ee69000 100644 --- a/Sources/BraintreePayPal/BTPayPalError.swift +++ b/Sources/BraintreePayPal/BTPayPalError.swift @@ -15,8 +15,8 @@ public enum BTPayPalError: Error, CustomNSError, LocalizedError, Equatable { /// 3. HTTP POST request returned an error case httpPostRequestError([String: Any]) - /// 4. The approval or redirect URL is invalid - case invalidURL + /// 4. The web approval URL, web redirect URL, or PayPal native app approval URL is invalid + case invalidURL(String) /// 5. The ASWebAuthenticationSession URL is invalid case asWebAuthenticationSessionURLInvalid(String) @@ -72,8 +72,8 @@ public enum BTPayPalError: Error, CustomNSError, LocalizedError, Equatable { return "Failed to fetch Braintree configuration." case .httpPostRequestError(let error): return "HTTP POST request failed with \(error)." - case .invalidURL: - return "The approval and/or return URL contained an invalid URL. Try again or contact Braintree Support." + case .invalidURL(let error): + return "An error occured with retrieving a PayPal authentication URL: \(error)" case .asWebAuthenticationSessionURLInvalid(let scheme): return "Attempted to open an invalid URL in ASWebAuthenticationSession: \(scheme)://. Try again or contact Braintree Support." case .invalidURLAction: diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index 9f7956ed1d..363bd5022b 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -188,10 +188,12 @@ class BTPayPalClient_Tests: XCTestCase { waitForExpectations(timeout: 1.0) } - func testTokenizePayPalAccount_whenApprovalUrlIsInvalid_returnsError() { + func testTokenizePayPalAccount_whenAllApprovalURLsInvalid_returnsError() { mockAPIClient.cannedResponseBody = BTJSON(value: [ "paymentResource": [ - "redirectUrl": "" + "redirectUrl": "", + "approvalUrl": "", + "paypalAppApprovalUrl": "" ] ]) @@ -202,8 +204,8 @@ class BTPayPalClient_Tests: XCTestCase { guard let error = error as NSError? else { XCTFail(); return } XCTAssertNil(nonce) XCTAssertEqual(error.domain, BTPayPalError.errorDomain) - XCTAssertEqual(error.code, BTPayPalError.invalidURL.errorCode) - XCTAssertEqual(error.localizedDescription, BTPayPalError.invalidURL.errorDescription) + XCTAssertEqual(error.code, BTPayPalError.invalidURL("").errorCode) + XCTAssertEqual(error.localizedDescription, "An error occured with retrieving a PayPal authentication URL: Missing approval URL in gateway response.") expectation.fulfill() } @@ -224,6 +226,23 @@ class BTPayPalClient_Tests: XCTestCase { XCTAssertEqual(mockAPIClient.postedPayPalContextID, "EC-Random-Value") } + + // TODO: - Un-pend test once app switch flow sends analytics + func pendTokenize_whenPayPalAppApprovalURLContainsPayPalContextID_sendsPayPalContextIDInAnalytics() { + mockAPIClient.cannedResponseBody = BTJSON(value: [ + "paymentResource": [ + "paypalAppApprovalUrl": "https://www.fake.com?ba_token=123" + ] + ]) + + payPalClient.webAuthenticationSession = MockWebAuthenticationSession() + + let request = BTPayPalCheckoutRequest(amount: "1") + payPalClient.tokenize(request) { _, _ in } + + XCTAssertEqual(mockAPIClient.postedPayPalContextID, "123") + XCTAssertNotNil(payPalClient.clientMetadataID) + } func testTokenize_whenApprovalURLDoesNotContainPayPalContextID_doesNotSendPayPalContextIDInAnalytics() { mockAPIClient.cannedResponseBody = BTJSON(value: [ @@ -634,7 +653,7 @@ class BTPayPalClient_Tests: XCTestCase { XCTAssertEqual(metaParameters["sessionId"] as? String, mockAPIClient.metadata.sessionID) } - // MARK: - App Switch + // MARK: - App Switch - canHandleReturnURL func testCanHandleReturnURL_whenHostIsURLScheme_returnsFalse() { let url = URL(string: "fake-scheme://success")! @@ -670,6 +689,40 @@ class BTPayPalClient_Tests: XCTestCase { BTPayPalClient.handleReturnURL(URL(string: "https://mycoolwebsite.com/braintree-payments/success")!) XCTAssertNil(BTPayPalClient.payPalClient) } + + // MARK: - App Switch - tokenize + + func testTokenizeVaultAccount_whenPayPalAppApprovalURLPresent_attemptsAppSwitchWithParameters() async { + let fakeApplication = FakeApplication() + payPalClient.application = fakeApplication + + mockAPIClient.cannedResponseBody = BTJSON(value: [ + "paymentResource": [ + "paypalAppApprovalUrl": "https://www.some-url.com/some-path?token=value1", + "redirectUrl": "https://www.other-url.com/" + ] + ]) + + let vaultRequest = BTPayPalVaultRequest(userAuthenticationEmail: "fake@gmail.com", enablePayPalAppSwitch: true) + + payPalClient.tokenize(vaultRequest) { _, _ in } + + XCTAssertTrue(fakeApplication.openURLWasCalled) + + let urlComponents = URLComponents(url: fakeApplication.lastOpenURL!, resolvingAgainstBaseURL: true) + XCTAssertEqual(urlComponents?.host, "www.some-url.com") + XCTAssertEqual(urlComponents?.path, "/some-path") + + XCTAssertEqual(urlComponents?.queryItems?[0].name, "source") + XCTAssertEqual(urlComponents?.queryItems?[0].value, "braintree_sdk") + XCTAssertEqual(urlComponents?.queryItems?[1].name, "switch_initiated_time") + if let urlTimestamp = urlComponents?.queryItems?[1].value { + XCTAssertNotNil(Int(urlTimestamp)) + } else { + XCTFail("Expected integer value for query param `switch_initiated_time`") + } + } + // MARK: - Analytics From 6720c734db1d8c5763770dc2fd7d1dc411b22403 Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Tue, 26 Mar 2024 14:27:46 -0500 Subject: [PATCH 07/32] [QL] Update `BTAppContextSwitcher.sharedInstance.universalLink` to URL Type (#1232) * Replace BTAppContextSwitcher.sharedInstance.universalLink with init: BTPayPalVaultRequest(userAuthenticationEmail:enablePayPalAppSwitch:universalLink:offerCredit:) --- CHANGELOG.md | 6 +-- Demo/Application/Base/AppDelegate.swift | 3 -- .../PayPalWebCheckoutViewController.swift | 7 +++- .../BraintreeCore/BTAppContextSwitcher.swift | 7 +--- .../BTPayPalVaultRequest.swift | 40 ++++++++++++++----- .../BTAppContextSwitcher_Tests.swift | 7 ---- .../BTPayPalClient_Tests.swift | 8 +++- .../BTPayPalRequest_Tests.swift | 13 ++++-- .../BTPayPalVaultRequest_Tests.swift | 7 +++- 9 files changed, 61 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 179d23b19f..d4238494c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,9 @@ ## unreleased * Require Xcode 15.0+ (per [App Store requirements](https://developer.apple.com/news/?id=khzvxn8a)) -* BraintreeCore - * Add property `BTAppContextSwitcher.sharedInstance.universalLink` for the PayPal app switch flow * BraintreePayPal - * Add `BTPayPalVault.enablePayPalAppSwitch` - * If set to `true` we will attempt to use the PayPal App Switch flow + * Add `BTPayPalVaultRequest(userAuthenticationEmail:enablePayPalAppSwitch:universalLink:offerCredit:)` + * This init should be used for the PayPal App Switch flow ## 6.16.0 (2024-03-19) * Add `BTPayPalVaultRequest.userAuthenticationEmail` optional property diff --git a/Demo/Application/Base/AppDelegate.swift b/Demo/Application/Base/AppDelegate.swift index 00eb01502d..59fbe210fb 100644 --- a/Demo/Application/Base/AppDelegate.swift +++ b/Demo/Application/Base/AppDelegate.swift @@ -4,8 +4,6 @@ import BraintreeCore @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { private let returnURLScheme = "com.braintreepayments.Demo.payments" - // TODO: - Replace with Demo app URL of "https://braintree-ios-demo.fly.dev/braintree-payments" once BT GW allowlists all URLs. - private let universalLinkURL = "https://paypal.com" private let processInfoArgs = ProcessInfo.processInfo.arguments private let userDefaults = UserDefaults.standard @@ -13,7 +11,6 @@ import BraintreeCore registerDefaultsFromSettings() persistDemoSettings() BTAppContextSwitcher.sharedInstance.returnURLScheme = returnURLScheme - BTAppContextSwitcher.sharedInstance.universalLink = universalLinkURL userDefaults.setValue(true, forKey: "magnes.debug.mode") diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index a9c363b68b..1190687cf1 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -87,7 +87,12 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { @objc func universalLinkFlow(_ sender: UIButton) { // TODO: implement in a future PR - used here so we don't have to remove lazy instantiation - let request = BTPayPalVaultRequest(enablePayPalAppSwitch: true) + // TODO: replace URL with https://braintree-ios-demo.fly.dev/braintree-payments + let request = BTPayPalVaultRequest( + userAuthenticationEmail: "sally@gmail.com", + enablePayPalAppSwitch: true, + universalLink: URL(string: "https://paypal.com")! + ) payPalClient.tokenize(request) { _, _ in } UIApplication.shared.open(URL(string: "https://braintree-ios-demo.fly.dev/braintree-payments/success")!) } diff --git a/Sources/BraintreeCore/BTAppContextSwitcher.swift b/Sources/BraintreeCore/BTAppContextSwitcher.swift index 20c301c1bc..913665c4b4 100644 --- a/Sources/BraintreeCore/BTAppContextSwitcher.swift +++ b/Sources/BraintreeCore/BTAppContextSwitcher.swift @@ -9,16 +9,13 @@ import UIKit /// Singleton for shared instance of `BTAppContextSwitcher` public static let sharedInstance = BTAppContextSwitcher() - + + // NEXT_MAJOR_VERSION: move this property into the feature client request where it is used /// The URL scheme to return to this app after switching to another app or opening a SFSafariViewController. /// This URL scheme must be registered as a URL Type in the app's info.plist, and it must start with the app's bundle ID. /// - Note: This property should only be used for the Venmo flow. public var returnURLScheme: String = "" - /// The URL to use for the PayPal app switch flow. Must be a valid HTTPS URL dedicated to Braintree app switch returns. - /// - Note: This property should only be used for the PayPal app switch flow. - public var universalLink: String = "" - // MARK: - Private Properties private var appContextSwitchClients = [BTAppContextSwitchClient.Type]() diff --git a/Sources/BraintreePayPal/BTPayPalVaultRequest.swift b/Sources/BraintreePayPal/BTPayPalVaultRequest.swift index eb03598643..6f343429a9 100644 --- a/Sources/BraintreePayPal/BTPayPalVaultRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalVaultRequest.swift @@ -9,24 +9,44 @@ import BraintreeCore // MARK: - Public Properties + /// Optional: User email to initiate a quicker authentication flow in cases where the user has a PayPal Account with the same email. + public var userAuthenticationEmail: String? + + // MARK: - Internal Properties + /// Optional: Used to determine if the customer will use the PayPal app switch flow. /// Defaults to `false`. /// - Note: This property is currently in beta and may change or be removed in future releases. - public var enablePayPalAppSwitch: Bool + var enablePayPalAppSwitch: Bool = false - /// Optional: User email to initiate a quicker authentication flow in cases where the user has a PayPal Account with the same email. - public var userAuthenticationEmail: String? + /// The URL to use for the PayPal app switch flow. Must be a valid HTTPS URL dedicated to Braintree app switch returns. + var universalLink: URL? - // MARK: - Initializer + // MARK: - Initializers + + /// Initializes a PayPal Vault request for the PayPal App Switch flow + /// - Parameters: + /// - userAuthenticationEmail: Required: User email to initiate a quicker authentication flow in cases where the user has a PayPal Account with the same email. + /// - enablePayPalAppSwitch: Required: Used to determine if the customer will use the PayPal app switch flow. + /// - universalLink: Required: The URL to use for the PayPal app switch flow. Must be a valid HTTPS URL dedicated to Braintree app switch returns. + /// - offerCredit: Optional: Offers PayPal Credit if the customer qualifies. Defaults to `false`. + /// - Note: This initializer should be used for merchants using the PayPal App Switch flow. This feature is currently in beta and may change or be removed in future releases. + public convenience init( + userAuthenticationEmail: String, + enablePayPalAppSwitch: Bool, + universalLink: URL, + offerCredit: Bool = false + ) { + self.init(offerCredit: offerCredit, userAuthenticationEmail: userAuthenticationEmail) + self.universalLink = universalLink + self.enablePayPalAppSwitch = enablePayPalAppSwitch + } /// Initializes a PayPal Vault request /// - Parameters: /// - offerCredit: Optional: Offers PayPal Credit if the customer qualifies. Defaults to `false`. /// - userAuthenticationEmail: Optional: User email to initiate a quicker authentication flow in cases where the user has a PayPal Account with the same email. - /// - enablePayPalAppSwitch: Optional: Used to determine if the customer will use the PayPal app switch flow. Defaults to `false`. - /// This property is currently in beta and may change or be removed in future releases. - public init(offerCredit: Bool = false, userAuthenticationEmail: String? = nil, enablePayPalAppSwitch: Bool = false) { - self.enablePayPalAppSwitch = enablePayPalAppSwitch + public init(offerCredit: Bool = false, userAuthenticationEmail: String? = nil) { self.userAuthenticationEmail = userAuthenticationEmail super.init(offerCredit: offerCredit) } @@ -38,12 +58,12 @@ import BraintreeCore baseParameters["payer_email"] = userAuthenticationEmail } - if enablePayPalAppSwitch { + if enablePayPalAppSwitch, let universalLink { let appSwitchParameters: [String: Any] = [ "launch_paypal_app": enablePayPalAppSwitch, "os_version": UIDevice.current.systemVersion, "os_type": UIDevice.current.systemName, - "merchant_app_return_url": BTAppContextSwitcher.sharedInstance.universalLink + "merchant_app_return_url": universalLink.absoluteString ] return baseParameters.merging(appSwitchParameters) { $1 } } diff --git a/UnitTests/BraintreeCoreTests/BTAppContextSwitcher_Tests.swift b/UnitTests/BraintreeCoreTests/BTAppContextSwitcher_Tests.swift index 86db5764e3..153be1fd82 100644 --- a/UnitTests/BraintreeCoreTests/BTAppContextSwitcher_Tests.swift +++ b/UnitTests/BraintreeCoreTests/BTAppContextSwitcher_Tests.swift @@ -86,13 +86,6 @@ class BTAppContextSwitcher_Tests: XCTestCase { let handled = BTAppContextSwitcher.sharedInstance.handleOpenURL(context: mockURLContext) XCTAssertFalse(handled) } - - // MARK: - universalLink Tests - - func testSetUniversalLink() { - BTAppContextSwitcher.sharedInstance.universalLink = "https://fake.com" - XCTAssertEqual(appSwitch.universalLink, "https://fake.com") - } } @objcMembers class MockAppContextSwitchClient: BTAppContextSwitchClient { diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index 363bd5022b..06da542b36 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -703,8 +703,12 @@ class BTPayPalClient_Tests: XCTestCase { ] ]) - let vaultRequest = BTPayPalVaultRequest(userAuthenticationEmail: "fake@gmail.com", enablePayPalAppSwitch: true) - + let vaultRequest = BTPayPalVaultRequest( + userAuthenticationEmail: "fake@gmail.com", + enablePayPalAppSwitch: true, + universalLink: URL(string: "https://paypal.com")! + ) + payPalClient.tokenize(vaultRequest) { _, _ in } XCTAssertTrue(fakeApplication.openURLWasCalled) diff --git a/UnitTests/BraintreePayPalTests/BTPayPalRequest_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalRequest_Tests.swift index 0b236a852e..164862cbd1 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalRequest_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalRequest_Tests.swift @@ -96,8 +96,15 @@ class BTPayPalRequest_Tests: XCTestCase { // MARK: - enablePayPalAppSwitch - func testEnablePayPalAppSwitch_whenNotPassed_defaultsValueAsFalse() { - let request = BTPayPalVaultRequest() - XCTAssertFalse(request.enablePayPalAppSwitch) + func testEnablePayPalAppSwitch_whenInitialized_setsAllRequiredValues() { + let request = BTPayPalVaultRequest( + userAuthenticationEmail: "fake@gmail.com", + enablePayPalAppSwitch: true, + universalLink: URL(string: "my-website-is-cool.com")! + ) + + XCTAssertEqual(request.userAuthenticationEmail, "fake@gmail.com") + XCTAssertTrue(request.enablePayPalAppSwitch) + XCTAssertEqual(request.universalLink?.absoluteString, "my-website-is-cool.com") } } diff --git a/UnitTests/BraintreePayPalTests/BTPayPalVaultRequest_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalVaultRequest_Tests.swift index 45568d73bc..4c5ddbe4f6 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalVaultRequest_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalVaultRequest_Tests.swift @@ -71,8 +71,11 @@ class BTPayPalVaultRequest_Tests: XCTestCase { } func testParameters_withEnablePayPalAppSwitchTrue_returnsAllParams() { - BTAppContextSwitcher.sharedInstance.universalLink = "some-url" - let request = BTPayPalVaultRequest(enablePayPalAppSwitch: true) + let request = BTPayPalVaultRequest( + userAuthenticationEmail: "sally@gmail.com", + enablePayPalAppSwitch: true, + universalLink: URL(string: "some-url")! + ) let parameters = request.parameters(with: configuration) From b692e4176668ff17ece8536bd0d0c1cdd30f396d Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Wed, 3 Apr 2024 10:11:01 -0500 Subject: [PATCH 08/32] [QL] Parse App Switch Return URL (#1237) * Add implementation to BTPayPalAppSwitchReturnURL * Rename BTPayPalClient.handleBrowserSwitchReturn to handleReturn since it will be used for both app switch and ASWeb returns * Add implementation for handleReturnURL for App Switch flows * Add new BTPayPalError.unknownAppSwitchError and update typo on invalidURL error * Add BTPayPalAppSwitchReturnURL_Tests * Add new BTPayPalClient_Tests for new code --- Braintree.xcodeproj/project.pbxproj | 4 + .../BraintreePayPal_IntegrationTests.swift | 8 +- .../BTPayPalAppSwitchReturnURL.swift | 17 ++- Sources/BraintreePayPal/BTPayPalClient.swift | 22 +++- Sources/BraintreePayPal/BTPayPalError.swift | 13 +- .../BTPayPalAppSwitchReturnURL_Tests.swift | 25 ++++ .../BTPayPalClient_Tests.swift | 123 +++++++++++++++--- 7 files changed, 184 insertions(+), 28 deletions(-) create mode 100644 UnitTests/BraintreePayPalTests/BTPayPalAppSwitchReturnURL_Tests.swift diff --git a/Braintree.xcodeproj/project.pbxproj b/Braintree.xcodeproj/project.pbxproj index 753e562fd2..1e8a2c24da 100644 --- a/Braintree.xcodeproj/project.pbxproj +++ b/Braintree.xcodeproj/project.pbxproj @@ -265,6 +265,7 @@ BE9FB82B2898324C00D6FE2F /* BTPaymentMethodNonce.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE9FB82A2898324C00D6FE2F /* BTPaymentMethodNonce.swift */; }; BE9FB82D28984ADE00D6FE2F /* BTPaymentMethodNonceParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE9FB82C28984ADE00D6FE2F /* BTPaymentMethodNonceParser.swift */; }; BEB9BF532A26872B00A3673E /* BTWebAuthenticationSessionClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEB9BF522A26872B00A3673E /* BTWebAuthenticationSessionClient.swift */; }; + BEBA590F2BB1B5B9005FA8A2 /* BTPayPalAppSwitchReturnURL_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEBA590E2BB1B5B9005FA8A2 /* BTPayPalAppSwitchReturnURL_Tests.swift */; }; BEBC222728D25BB400D83186 /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80DBE69423A931A600373230 /* Helpers.swift */; }; BEBC6E4B29258FD4004E25A0 /* BraintreeCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 570B93AC285397520041BAFE /* BraintreeCore.framework */; }; BEBC6E5E2927CF59004E25A0 /* Braintree.h in Headers */ = {isa = PBXBuildFile; fileRef = BEBC6E5D2927CF59004E25A0 /* Braintree.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -901,6 +902,7 @@ BE9FB82A2898324C00D6FE2F /* BTPaymentMethodNonce.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPaymentMethodNonce.swift; sourceTree = ""; }; BE9FB82C28984ADE00D6FE2F /* BTPaymentMethodNonceParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPaymentMethodNonceParser.swift; sourceTree = ""; }; BEB9BF522A26872B00A3673E /* BTWebAuthenticationSessionClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTWebAuthenticationSessionClient.swift; sourceTree = ""; }; + BEBA590E2BB1B5B9005FA8A2 /* BTPayPalAppSwitchReturnURL_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPayPalAppSwitchReturnURL_Tests.swift; sourceTree = ""; }; BEBC6E5D2927CF59004E25A0 /* Braintree.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Braintree.h; sourceTree = ""; }; BEBC6F252937A510004E25A0 /* BTClientMetadata_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTClientMetadata_Tests.swift; sourceTree = ""; }; BEBC6F272937BD1F004E25A0 /* BTGraphQLHTTP_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTGraphQLHTTP_Tests.swift; sourceTree = ""; }; @@ -1717,6 +1719,7 @@ 42FC218A25CDE0290047C49A /* BTPayPalRequest_Tests.swift */, 427F328F25D1A7B900435294 /* BTPayPalVaultRequest_Tests.swift */, A9E5C1E424FD665D00EE691F /* Info.plist */, + BEBA590E2BB1B5B9005FA8A2 /* BTPayPalAppSwitchReturnURL_Tests.swift */, ); path = BraintreePayPalTests; sourceTree = ""; @@ -3115,6 +3118,7 @@ BECB10C62B5999EE008D398E /* BTPayPalLineItem_Tests.swift in Sources */, 3B7A261429C35BD00087059D /* BTPayPalAnalytics_Tests.swift in Sources */, A95229C724FD949D006F7D25 /* BTConfiguration+PayPal_Tests.swift in Sources */, + BEBA590F2BB1B5B9005FA8A2 /* BTPayPalAppSwitchReturnURL_Tests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/IntegrationTests/BraintreePayPal_IntegrationTests.swift b/IntegrationTests/BraintreePayPal_IntegrationTests.swift index bf65fca73f..8fc8f5c533 100644 --- a/IntegrationTests/BraintreePayPal_IntegrationTests.swift +++ b/IntegrationTests/BraintreePayPal_IntegrationTests.swift @@ -18,7 +18,7 @@ class BraintreePayPal_IntegrationTests: XCTestCase { let tokenizationExpectation = expectation(description: "Tokenize one-time payment") let returnURL = URL(string: oneTouchCoreAppSwitchSuccessURLFixture) - payPalClient.handleBrowserSwitchReturn(returnURL, paymentType: .checkout) { tokenizedPayPalAccount, error in + payPalClient.handleReturn(returnURL, paymentType: .checkout) { tokenizedPayPalAccount, error in guard let nonce = tokenizedPayPalAccount?.nonce else { XCTFail("Failed to tokenize account.") return @@ -42,7 +42,7 @@ class BraintreePayPal_IntegrationTests: XCTestCase { let tokenizationExpectation = expectation(description: "Tokenize one-time payment") let returnURL = URL(string: oneTouchCoreAppSwitchSuccessURLFixture) - payPalClient.handleBrowserSwitchReturn(returnURL,paymentType: .checkout) { tokenizedPayPalAccount, error in + payPalClient.handleReturn(returnURL,paymentType: .checkout) { tokenizedPayPalAccount, error in guard let nonce = tokenizedPayPalAccount?.nonce else { XCTFail("Failed to tokenize account.") return @@ -68,7 +68,7 @@ class BraintreePayPal_IntegrationTests: XCTestCase { let tokenizationExpectation = expectation(description: "Tokenize billing agreement payment") let returnURL = URL(string: oneTouchCoreAppSwitchSuccessURLFixture) - payPalClient.handleBrowserSwitchReturn(returnURL, paymentType: .vault) { tokenizedPayPalAccount, error in + payPalClient.handleReturn(returnURL, paymentType: .vault) { tokenizedPayPalAccount, error in guard let nonce = tokenizedPayPalAccount?.nonce else { XCTFail("Failed to tokenize account.") return @@ -92,7 +92,7 @@ class BraintreePayPal_IntegrationTests: XCTestCase { let tokenizationExpectation = expectation(description: "Tokenize billing agreement payment") let returnURL = URL(string: oneTouchCoreAppSwitchSuccessURLFixture) - payPalClient.handleBrowserSwitchReturn(returnURL, paymentType: .vault) { tokenizedPayPalAccount, error in + payPalClient.handleReturn(returnURL, paymentType: .vault) { tokenizedPayPalAccount, error in guard let nonce = tokenizedPayPalAccount?.nonce else { XCTFail("Failed to tokenize account.") return diff --git a/Sources/BraintreePayPal/BTPayPalAppSwitchReturnURL.swift b/Sources/BraintreePayPal/BTPayPalAppSwitchReturnURL.swift index e6ca706eaa..c6a3064bd0 100644 --- a/Sources/BraintreePayPal/BTPayPalAppSwitchReturnURL.swift +++ b/Sources/BraintreePayPal/BTPayPalAppSwitchReturnURL.swift @@ -1,7 +1,11 @@ import Foundation +#if canImport(BraintreeCore) +import BraintreeCore +#endif + enum BTPayPalAppSwitchReturnURLState { - case unknown + case unknownPath case succeeded case canceled } @@ -11,10 +15,19 @@ enum BTPayPalAppSwitchReturnURLState { /// PayPal app switch authorization requests should result in success or user-initiated cancelation. These states are communicated in the url. struct BTPayPalAppSwitchReturnURL { + /// The overall status of the app switch - success, cancelation, or an unknown path + var state: BTPayPalAppSwitchReturnURLState = .unknownPath + /// Initializes a new `BTPayPalAppSwitchReturnURL` /// - Parameter url: an incoming app switch url init?(url: URL) { - // TODO: implement init based on return URL + if url.path.contains("success") { + state = .succeeded + } else if url.path.contains("cancel") { + state = .canceled + } else { + state = .unknownPath + } } // MARK: - Static Methods diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index 7311f37d11..a7af694686 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -33,6 +33,10 @@ import BraintreeDataCollector /// Exposed for testing, the ASWebAuthenticationSession instance used for the PayPal flow var webAuthenticationSession: BTWebAuthenticationSession + /// Used internally as a holder for the completion in methods that do not pass a completion such as `handleOpen`. + /// This allows us to set and return a completion in our methods that otherwise cannot require a completion. + var appSwitchCompletion: (BTPayPalAccountNonce?, Error?) -> Void = { _, _ in } + // MARK: - Static Properties /// This static instance of `BTPayPalClient` is used during the app switch process. @@ -157,7 +161,7 @@ import BraintreeDataCollector // MARK: - Internal Methods - func handleBrowserSwitchReturn( + func handleReturn( _ url: URL?, paymentType: BTPayPalPaymentType, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void @@ -236,7 +240,17 @@ import BraintreeDataCollector // MARK: - App Switch Methods func handleReturnURL(_ url: URL) { - // TODO: implement handling return URL in a follow up PR + guard let returnURL = BTPayPalAppSwitchReturnURL(url: url) else { + notifyFailure(with: BTPayPalError.invalidURL("App Switch return URL cannot be nil"), completion: appSwitchCompletion) + return + } + + switch returnURL.state { + case .succeeded, .canceled: + handleReturn(url, paymentType: .vault, completion: appSwitchCompletion) + case .unknownPath: + notifyFailure(with: BTPayPalError.appSwitchReturnURLPathInvalid, completion: appSwitchCompletion) + } } // MARK: - Private Methods @@ -333,7 +347,7 @@ import BraintreeDataCollector return } - handleBrowserSwitchReturn(url, paymentType: paymentType, completion: completion) + handleReturn(url, paymentType: paymentType, completion: completion) } sessionDidAppear: { [self] didAppear in if didAppear { apiClient.sendAnalyticsEvent(BTPayPalAnalytics.browserPresentationSucceeded, payPalContextID: payPalContextID) @@ -368,7 +382,7 @@ import BraintreeDataCollector hostAndPath.append("/") } - if hostAndPath != BTPayPalRequest.callbackURLHostAndPath { + if hostAndPath != BTPayPalRequest.callbackURLHostAndPath && (payPalRequest as? BTPayPalVaultRequest)?.universalLink == nil { return false } diff --git a/Sources/BraintreePayPal/BTPayPalError.swift b/Sources/BraintreePayPal/BTPayPalError.swift index 063ee69000..23122ca6fd 100644 --- a/Sources/BraintreePayPal/BTPayPalError.swift +++ b/Sources/BraintreePayPal/BTPayPalError.swift @@ -15,7 +15,7 @@ public enum BTPayPalError: Error, CustomNSError, LocalizedError, Equatable { /// 3. HTTP POST request returned an error case httpPostRequestError([String: Any]) - /// 4. The web approval URL, web redirect URL, or PayPal native app approval URL is invalid + /// 4. The web approval URL, web redirect URL, PayPal native app approval URL is invalid case invalidURL(String) /// 5. The ASWebAuthenticationSession URL is invalid @@ -32,7 +32,10 @@ public enum BTPayPalError: Error, CustomNSError, LocalizedError, Equatable { /// 9. Deallocated BTPayPalClient case deallocated - + + /// 10. The App Switch return URL did not contain the cancel or success path. + case appSwitchReturnURLPathInvalid + public static var errorDomain: String { "com.braintreepayments.BTPayPalErrorDomain" } @@ -59,6 +62,8 @@ public enum BTPayPalError: Error, CustomNSError, LocalizedError, Equatable { return 8 case .deallocated: return 9 + case .appSwitchReturnURLPathInvalid: + return 10 } } @@ -73,7 +78,7 @@ public enum BTPayPalError: Error, CustomNSError, LocalizedError, Equatable { case .httpPostRequestError(let error): return "HTTP POST request failed with \(error)." case .invalidURL(let error): - return "An error occured with retrieving a PayPal authentication URL: \(error)" + return "An error occurred with retrieving a PayPal URL: \(error)" case .asWebAuthenticationSessionURLInvalid(let scheme): return "Attempted to open an invalid URL in ASWebAuthenticationSession: \(scheme)://. Try again or contact Braintree Support." case .invalidURLAction: @@ -84,6 +89,8 @@ public enum BTPayPalError: Error, CustomNSError, LocalizedError, Equatable { return "ASWebAuthenticationSession failed with \(error.localizedDescription)" case .deallocated: return "BTPayPalClient has been deallocated." + case .appSwitchReturnURLPathInvalid: + return "The App Switch return URL did not contain the cancel or success path." } } diff --git a/UnitTests/BraintreePayPalTests/BTPayPalAppSwitchReturnURL_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalAppSwitchReturnURL_Tests.swift new file mode 100644 index 0000000000..472b9b1ac0 --- /dev/null +++ b/UnitTests/BraintreePayPalTests/BTPayPalAppSwitchReturnURL_Tests.swift @@ -0,0 +1,25 @@ +import XCTest +@testable import BraintreePayPal + +final class BTPayPalAppSwitchReturnURL_Tests: XCTestCase { + + func testInitWithURL_whenSuccessReturnURL_createsValuesAndSetsSuccessState() { + let returnURL = BTPayPalAppSwitchReturnURL(url: URL(string: "https://www.merchant-app.com/merchant-path/success?token=A_FAKE_EC_TOKEN&ba_token=A_FAKE_BA_TOKEN&switch_initiated_time=1234567890")!) + XCTAssertEqual(returnURL?.state, .succeeded) + } + + func testInitWithURL_whenSuccessReturnURLWithoutToken_createsValuesAndSetsSuccessState() { + let returnURL = BTPayPalAppSwitchReturnURL(url: URL(string: "https://www.merchant-app.com/merchant-path/success?ba_token=A_FAKE_BA_TOKEN&switch_initiated_time=1234567890")!) + XCTAssertEqual(returnURL?.state, .succeeded) + } + + func testInitWithURL_whenCancelURLWithoutToken_setsCancelState() { + let returnURL = BTPayPalAppSwitchReturnURL(url: URL(string: "https://www.merchant-app.com/merchant-path/cancel?ba_token=A_FAKE_BA_TOKEN&switch_initiated_time=1234567890")!) + XCTAssertEqual(returnURL?.state, .canceled) + } + + func testInitWithURL_whenUnknownURLWithoutToken_setsUnknownState() { + let returnURL = BTPayPalAppSwitchReturnURL(url: URL(string: "https://www.merchant-app.com/merchant-path/garbage-url")!) + XCTAssertEqual(returnURL?.state, .unknownPath) + } +} diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index 98e9df2064..0d0902b67e 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -205,7 +205,7 @@ class BTPayPalClient_Tests: XCTestCase { XCTAssertNil(nonce) XCTAssertEqual(error.domain, BTPayPalError.errorDomain) XCTAssertEqual(error.code, BTPayPalError.invalidURL("").errorCode) - XCTAssertEqual(error.localizedDescription, "An error occured with retrieving a PayPal authentication URL: Missing approval URL in gateway response.") + XCTAssertEqual(error.localizedDescription, "An error occurred with retrieving a PayPal URL: Missing approval URL in gateway response.") expectation.fulfill() } @@ -338,7 +338,7 @@ class BTPayPalClient_Tests: XCTestCase { let expectation = expectation(description: "completion block called") - payPalClient.handleBrowserSwitchReturn(returnURL, paymentType: .checkout) { nonce, error in + payPalClient.handleReturn(returnURL, paymentType: .checkout) { nonce, error in guard let error = error as NSError? else { XCTFail(); return } XCTAssertNil(nonce) XCTAssertEqual(error.domain, BTPayPalError.errorDomain) @@ -355,7 +355,7 @@ class BTPayPalClient_Tests: XCTestCase { let continuationExpectation = expectation(description: "Continuation called") - payPalClient.handleBrowserSwitchReturn(returnURL, paymentType: .checkout) { nonce, error in + payPalClient.handleReturn(returnURL, paymentType: .checkout) { nonce, error in guard let error = error as NSError? else { XCTFail(); return } XCTAssertNil(nonce) XCTAssertNotNil(error) @@ -370,7 +370,7 @@ class BTPayPalClient_Tests: XCTestCase { func testHandleBrowserSwitchReturn_whenBrowserSwitchSucceeds_tokenizesPayPalCheckout() { let returnURL = URL(string: "bar://onetouch/v1/success?token=hermes_token")! - payPalClient.handleBrowserSwitchReturn(returnURL, paymentType: .checkout) { _, _ in } + payPalClient.handleReturn(returnURL, paymentType: .checkout) { _, _ in } XCTAssertEqual(mockAPIClient.lastPOSTPath, "/v1/payment_methods/paypal_accounts") @@ -387,7 +387,7 @@ class BTPayPalClient_Tests: XCTestCase { payPalClient.payPalRequest = payPalRequest let returnURL = URL(string: "bar://onetouch/v1/success?token=hermes_token")! - payPalClient.handleBrowserSwitchReturn(returnURL, paymentType: .checkout) { _, _ in } + payPalClient.handleReturn(returnURL, paymentType: .checkout) { _, _ in } XCTAssertEqual(mockAPIClient.lastPOSTPath, "/v1/payment_methods/paypal_accounts") @@ -405,7 +405,7 @@ class BTPayPalClient_Tests: XCTestCase { payPalClient.payPalRequest?.merchantAccountID = merchantAccountID let returnURL = URL(string: "bar://onetouch/v1/success?token=hermes_token")! - payPalClient.handleBrowserSwitchReturn(returnURL, paymentType: .checkout) { _, _ in } + payPalClient.handleReturn(returnURL, paymentType: .checkout) { _, _ in } XCTAssertEqual(mockAPIClient.lastPOSTPath, "/v1/payment_methods/paypal_accounts") let lastPostParameters = mockAPIClient.lastPOSTParameters! @@ -428,7 +428,7 @@ class BTPayPalClient_Tests: XCTestCase { payPalClient.payPalRequest = BTPayPalCheckoutRequest(amount: "1.34") let returnURL = URL(string: "bar://onetouch/v1/success?token=hermes_token")! - payPalClient.handleBrowserSwitchReturn(returnURL, paymentType: .checkout) { _, _ in } + payPalClient.handleReturn(returnURL, paymentType: .checkout) { _, _ in } XCTAssertFalse(mockAPIClient.postedAnalyticsEvents.contains("ios.paypal-single-payment.credit.accepted")) } @@ -439,7 +439,7 @@ class BTPayPalClient_Tests: XCTestCase { let returnURL = URL(string: "bar://onetouch/v1/success?token=hermes_token")! let expectation = expectation(description: "Returns an error") - payPalClient.handleBrowserSwitchReturn(returnURL, paymentType: .checkout) { _, error in + payPalClient.handleReturn(returnURL, paymentType: .checkout) { _, error in guard let error = error as NSError? else { XCTFail(); return } XCTAssertNotNil(error) XCTAssertEqual(error.domain, BTPayPalError.errorDomain) @@ -458,7 +458,7 @@ class BTPayPalClient_Tests: XCTestCase { payPalClient.clientMetadataID = "a-fake-cmid" let returnURL = URL(string: "bar://onetouch/v1/success?token=hermes_token")! - payPalClient.handleBrowserSwitchReturn(returnURL, paymentType: .checkout) { _, _ in } + payPalClient.handleReturn(returnURL, paymentType: .checkout) { _, _ in } let lastPostParameters = mockAPIClient.lastPOSTParameters! XCTAssertEqual(lastPostParameters["merchant_account_id"] as? String, merchantAccountID) @@ -482,7 +482,7 @@ class BTPayPalClient_Tests: XCTestCase { func testHandleBrowserSwitchReturn_whenBrowserSwitchSucceeds_sendsCorrectParametersForTokenization() { let returnURL = URL(string: "bar://onetouch/v1/success?token=hermes_token")! - payPalClient.handleBrowserSwitchReturn(returnURL, paymentType: .vault) { _, _ in } + payPalClient.handleReturn(returnURL, paymentType: .vault) { _, _ in } XCTAssertEqual(mockAPIClient.lastPOSTPath, "/v1/payment_methods/paypal_accounts") guard let lastPostParameters = mockAPIClient.lastPOSTParameters else { @@ -553,7 +553,7 @@ class BTPayPalClient_Tests: XCTestCase { mockAPIClient.cannedResponseBody = BTJSON(value: checkoutResponse as [String : AnyObject]) let returnURL = URL(string: "bar://onetouch/v1/success?token=hermes_token")! - payPalClient.handleBrowserSwitchReturn(returnURL, paymentType: .checkout) { (tokenizedPayPalAccount, error) in + payPalClient.handleReturn(returnURL, paymentType: .checkout) { (tokenizedPayPalAccount, error) in XCTAssertEqual(tokenizedPayPalAccount!.nonce, "a-nonce") XCTAssertEqual(tokenizedPayPalAccount!.firstName, "Some") XCTAssertEqual(tokenizedPayPalAccount!.lastName, "Dude") @@ -621,7 +621,7 @@ class BTPayPalClient_Tests: XCTestCase { mockAPIClient.cannedResponseBody = BTJSON(value: checkoutResponse as [String : AnyObject]) let returnURL = URL(string: "bar://onetouch/v1/success?token=hermes_token")! - payPalClient.handleBrowserSwitchReturn(returnURL, paymentType: .checkout) { (tokenizedPayPalAccount, error) in + payPalClient.handleReturn(returnURL, paymentType: .checkout) { (tokenizedPayPalAccount, error) in let shippingAddress = tokenizedPayPalAccount!.shippingAddress! XCTAssertEqual(shippingAddress.recipientName, "Grace Hopper") @@ -649,7 +649,7 @@ class BTPayPalClient_Tests: XCTestCase { mockAPIClient.cannedResponseBody = BTJSON(value: checkoutResponse as [String : AnyObject]) let returnURL = URL(string: "bar://onetouch/v1/success?token=hermes_token")! - payPalClient.handleBrowserSwitchReturn(returnURL, paymentType: .checkout) { tokenizedPayPalAccount, error in + payPalClient.handleReturn(returnURL, paymentType: .checkout) { tokenizedPayPalAccount, error in XCTAssertEqual(tokenizedPayPalAccount!.email, "hello@world.com") } } @@ -658,7 +658,7 @@ class BTPayPalClient_Tests: XCTestCase { func testMetadata_whenCheckoutBrowserSwitchIsSuccessful_isPOSTedToServer() { let returnURL = URL(string: "bar://onetouch/v1/success?token=hermes_token")! - payPalClient.handleBrowserSwitchReturn(returnURL, paymentType: .checkout) { _, _ in } + payPalClient.handleReturn(returnURL, paymentType: .checkout) { _, _ in } XCTAssertEqual(mockAPIClient.lastPOSTPath, "/v1/payment_methods/paypal_accounts") let lastPostParameters = mockAPIClient.lastPOSTParameters! @@ -743,6 +743,99 @@ class BTPayPalClient_Tests: XCTestCase { } + func testHandleReturn_whenURLIsCancel_returnsCancel() { + let request = BTPayPalVaultRequest( + userAuthenticationEmail: "sally@gmail.com", + enablePayPalAppSwitch: true, + universalLink: URL(string: "https://merchant-app.com/merchant-path")! + ) + let returnURL = URL(string: "https://www.merchant-app.com/merchant-path/cancel?ba_token=A_FAKE_BA_TOKEN&switch_initiated_time=1234567890")! + let expectation = expectation(description: "completion block called") + + payPalClient.payPalRequest = request + payPalClient.handleReturn(returnURL, paymentType: .vault) { nonce, error in + guard let error = error as NSError? else { XCTFail(); return } + XCTAssertNil(nonce) + XCTAssertEqual(error.domain, BTPayPalError.errorDomain) + XCTAssertEqual(error.code, BTPayPalError.canceled.errorCode) + XCTAssertEqual(error.localizedDescription, BTPayPalError.canceled.errorDescription) + expectation.fulfill() + } + + waitForExpectations(timeout: 1) + } + + func testHandleReturn_whenURLIsUnknown_returnsError() { + let request = BTPayPalVaultRequest( + userAuthenticationEmail: "sally@gmail.com", + enablePayPalAppSwitch: true, + universalLink: URL(string: "https://merchant-app.com/merchant-path")! + ) + let returnURL = URL(string: "https://www.merchant-app.com/merchant-path/garbage-url")! + let expectation = expectation(description: "completion block called") + + payPalClient.payPalRequest = request + payPalClient.handleReturn(returnURL, paymentType: .vault) { nonce, error in + guard let error = error as NSError? else { XCTFail(); return } + XCTAssertNil(nonce) + XCTAssertEqual(error.domain, BTPayPalError.errorDomain) + XCTAssertEqual(error.code, BTPayPalError.invalidURLAction.errorCode) + XCTAssertEqual(error.localizedDescription, BTPayPalError.invalidURLAction.errorDescription) + expectation.fulfill() + } + + waitForExpectations(timeout: 1) + } + + func testHandleReturn_whenURLIsSuccess_returnsTokenization() { + let request = BTPayPalVaultRequest( + userAuthenticationEmail: "sally@gmail.com", + enablePayPalAppSwitch: true, + universalLink: URL(string: "https://merchant-app.com/merchant-path")! + ) + mockAPIClient.cannedResponseBody = BTJSON(value: [ + "paypalAccounts": + [ + [ + "description": "jane.doe@example.com", + "details": [ + "email": "jane.doe@example.com", + ], + "nonce": "a-nonce", + "type": "PayPalAccount", + ] as [String: Any] + ] + ]) + + let returnURL = URL(string: "https://www.merchant-app.com/merchant-path/success?token=A_FAKE_EC_TOKEN&ba_token=A_FAKE_BA_TOKEN&switch_initiated_time=1234567890.1234") + let expectation = expectation(description: "completion block called") + + payPalClient.payPalRequest = request + payPalClient.handleReturn(returnURL, paymentType: .vault) { nonce, error in + XCTAssertNil(error) + XCTAssertNotNil(nonce) + XCTAssertEqual(nonce?.nonce, "a-nonce") + expectation.fulfill() + } + + waitForExpectations(timeout: 1) + } + + func testHandleReturnURL_whenReturnURLIsInvalid_returnsError() { + let expectation = expectation(description: "completion block called") + payPalClient.appSwitchCompletion = { nonce, error in + guard let error = error as NSError? else { XCTFail(); return } + XCTAssertNil(nonce) + XCTAssertEqual(error.domain, BTPayPalError.errorDomain) + XCTAssertEqual(error.code, BTPayPalError.appSwitchReturnURLPathInvalid.errorCode) + XCTAssertEqual(error.localizedDescription, "The App Switch return URL did not contain the cancel or success path.") + expectation.fulfill() + } + + payPalClient.handleReturnURL(URL(string: "https://merchant-app.com/merchant-path/garbage")!) + waitForExpectations(timeout: 1) + } + // MARK: - Analytics func testAPIClientMetadata_hasIntegrationSetToCustom() { @@ -769,7 +862,7 @@ class BTPayPalClient_Tests: XCTestCase { payPalClient.payPalRequest = BTPayPalVaultRequest() let returnURL = URL(string: "bar://hello/world")! - payPalClient.handleBrowserSwitchReturn(returnURL, paymentType: .vault) { _, _ in } + payPalClient.handleReturn(returnURL, paymentType: .vault) { _, _ in } XCTAssertFalse(mockAPIClient.postedAnalyticsEvents.contains("ios.paypal-ba.credit.accepted")) } From a2d3c0d9d85ced18501ed0fb59852b8361e85314 Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Wed, 3 Apr 2024 10:55:37 -0500 Subject: [PATCH 09/32] [QL] Handle Success or Failure of `application.open` (#1246) * Handle Success or Failure of application.open via invokedOpenURLSuccessfully * Add TODOs for future analytics PR * Add new BTPayPalError.appSwitchFailed --- Sources/BraintreePayPal/BTPayPalClient.swift | 15 +++++++++++-- Sources/BraintreePayPal/BTPayPalError.swift | 7 +++++++ .../BTPayPalClient_Tests.swift | 21 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index a7af694686..63b2e08f7d 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -324,10 +324,21 @@ import BraintreeDataCollector } application.open(redirectURL, options: [:]) { success in - // TODO: - Handle success or fail of opening app + self.invokedOpenURLSuccessfully(success, completion: completion) } } - + + private func invokedOpenURLSuccessfully(_ success: Bool, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void) { + if success { + // TODO: send appSwitchSucceeded analytics with payPalContextID and linkType + BTPayPalClient.payPalClient = self + appSwitchCompletion = completion + } else { + // TODO: send appSwitchFailed analytics with payPalContextID and linkType + notifyFailure(with: BTPayPalError.appSwitchFailed, completion: completion) + } + } + private func performSwitchRequest( appSwitchURL: URL, paymentType: BTPayPalPaymentType, diff --git a/Sources/BraintreePayPal/BTPayPalError.swift b/Sources/BraintreePayPal/BTPayPalError.swift index 23122ca6fd..a2abebd429 100644 --- a/Sources/BraintreePayPal/BTPayPalError.swift +++ b/Sources/BraintreePayPal/BTPayPalError.swift @@ -36,6 +36,9 @@ public enum BTPayPalError: Error, CustomNSError, LocalizedError, Equatable { /// 10. The App Switch return URL did not contain the cancel or success path. case appSwitchReturnURLPathInvalid + /// 11. App Switch could not complete + case appSwitchFailed + public static var errorDomain: String { "com.braintreepayments.BTPayPalErrorDomain" } @@ -64,6 +67,8 @@ public enum BTPayPalError: Error, CustomNSError, LocalizedError, Equatable { return 9 case .appSwitchReturnURLPathInvalid: return 10 + case .appSwitchFailed: + return 11 } } @@ -91,6 +96,8 @@ public enum BTPayPalError: Error, CustomNSError, LocalizedError, Equatable { return "BTPayPalClient has been deallocated." case .appSwitchReturnURLPathInvalid: return "The App Switch return URL did not contain the cancel or success path." + case .appSwitchFailed: + return "UIApplication failed to perform app switch to PayPal." } } diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index 0d0902b67e..8c59654567 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -742,7 +742,28 @@ class BTPayPalClient_Tests: XCTestCase { } } + func testTokenizeVaultAccount_whenOpenURLReturnsFalse_returnsError() { + let fakeApplication = FakeApplication() + fakeApplication.cannedOpenURLSuccess = false + payPalClient.application = fakeApplication + let vaultRequest = BTPayPalVaultRequest( + userAuthenticationEmail: "fake@gmail.com", + enablePayPalAppSwitch: true, + universalLink: URL(string: "https://paypal.com")! + ) + + payPalClient.tokenize(vaultRequest) { nonce, error in + XCTAssertNil(nonce) + + if let error = error as NSError? { + XCTAssertEqual(error.code, 11) + XCTAssertEqual(error.localizedDescription, "UIApplication failed to perform app switch to PayPal.") + XCTAssertEqual(error.domain, "com.braintreepayments.BTPayPalErrorDomain") + } + } + } + func testHandleReturn_whenURLIsCancel_returnsCancel() { let request = BTPayPalVaultRequest( userAuthenticationEmail: "sally@gmail.com", From 75a084f6c02056a6f474d32d0947a6bb626185a6 Mon Sep 17 00:00:00 2001 From: Stephanie <127455800+stechiu@users.noreply.github.com> Date: Thu, 4 Apr 2024 09:14:39 -0700 Subject: [PATCH 10/32] App installed check (#1233) * Add appInstalled check if enablePayPalAppSwitch=true, fallback to existing tokenize() flow if not Co-authored-by: Jax DesMarais-Leder --- Sources/BraintreePayPal/BTPayPalClient.swift | 28 +++++++-- .../BTPayPalClient_Tests.swift | 58 +++++++++++++++++++ 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index 63b2e08f7d..e7c5c8793d 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -15,11 +15,11 @@ import BraintreeDataCollector /// Exposed for testing to get the instance of BTAPIClient var apiClient: BTAPIClient - + /// Defaults to `UIApplication.shared`, but exposed for unit tests to inject test doubles /// to prevent calls to openURL. Subclassing UIApplication is not possible, since it enforces that only one instance can ever exist. var application: URLOpener = UIApplication.shared - + /// Exposed for testing the approvalURL construction var approvalURL: URL? = nil @@ -37,6 +37,9 @@ import BraintreeDataCollector /// This allows us to set and return a completion in our methods that otherwise cannot require a completion. var appSwitchCompletion: (BTPayPalAccountNonce?, Error?) -> Void = { _, _ in } + /// Exposed for testing to check if the PayPal app is installed + var payPalAppInstalled: Bool = false + // MARK: - Static Properties /// This static instance of `BTPayPalClient` is used during the app switch process. @@ -53,6 +56,9 @@ import BraintreeDataCollector /// In the PayPal flow this will be either an EC token or a Billing Agreement token private var payPalContextID: String? = nil + /// URL Scheme for PayPal In-App Checkout + private let payPalInAppScheme: String = "paypal-in-app-checkout://" + // MARK: - Initializer /// Initialize a new PayPal client instance. @@ -158,9 +164,8 @@ import BraintreeDataCollector } } } - + // MARK: - Internal Methods - func handleReturn( _ url: URL?, paymentType: BTPayPalPaymentType, @@ -276,6 +281,12 @@ import BraintreeDataCollector return } + self.payPalAppInstalled = self.isPayPalAppInstalled() + + if !self.payPalAppInstalled { + (request as? BTPayPalVaultRequest)?.enablePayPalAppSwitch = false + } + self.payPalRequest = request self.apiClient.post(request.hermesPath, parameters: request.parameters(with: configuration)) { body, response, error in if let error = error as? NSError { @@ -310,7 +321,14 @@ import BraintreeDataCollector } } } - + + private func isPayPalAppInstalled() -> Bool { + guard let paypalURL = URL(string: payPalInAppScheme) else { + return false + } + return application.canOpenURL(paypalURL) + } + private func launchPayPalApp(with payPalAppRedirectURL: URL, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void) { var urlComponents = URLComponents(url: payPalAppRedirectURL, resolvingAgainstBaseURL: true) urlComponents?.queryItems = [ diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index 8c59654567..ff3f78c928 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -857,6 +857,64 @@ class BTPayPalClient_Tests: XCTestCase { waitForExpectations(timeout: 1) } + func testIsiOSAppSwitchAvailable_whenApplicationCanOpenPayPalInAppURL_returnsTrue() { + let fakeApplication = FakeApplication() + payPalClient.application = fakeApplication + payPalClient.payPalAppInstalled = true + + let vaultRequest = BTPayPalVaultRequest( + userAuthenticationEmail: "fake@gmail.com", + enablePayPalAppSwitch: true, + universalLink: URL(string: "https://paypal.com")! + ) + + mockAPIClient.cannedResponseBody = BTJSON(value: [ + "paymentResource": [ + "paypalAppApprovalUrl": "https://www.some-url.com/some-path?token=value1", + "redirectUrl": "https://www.other-url.com/" + ] + ]) + + payPalClient.tokenize(vaultRequest) { _, _ in } + + XCTAssertEqual("v1/paypal_hermes/setup_billing_agreement", mockAPIClient.lastPOSTPath) + guard let lastPostParameters = mockAPIClient.lastPOSTParameters else { XCTFail(); return } + + XCTAssertEqual(lastPostParameters["launch_paypal_app"] as? Bool, true) + XCTAssertTrue((lastPostParameters["os_version"] as! String).matches("\\d+\\.\\d+")) + XCTAssertTrue((lastPostParameters["os_type"] as! String).matches("iOS|iPadOS")) + XCTAssertEqual(lastPostParameters["merchant_app_return_url"] as? String, "https://paypal.com") + } + + func testIsiOSAppSwitchAvailable_whenApplicationCantOpenPayPalInAppURL_returnsFalse() { + let fakeApplication = FakeApplication() + fakeApplication.cannedCanOpenURL = false + payPalClient.application = fakeApplication + + let vaultRequest = BTPayPalVaultRequest( + userAuthenticationEmail: "fake@gmail.com", + enablePayPalAppSwitch: true, + universalLink: URL(string: "https://paypal.com")! + ) + + mockAPIClient.cannedResponseBody = BTJSON(value: [ + "paymentResource": [ + "paypalAppApprovalUrl": "https://www.some-url.com/some-path?token=value1", + "redirectUrl": "https://www.other-url.com/" + ] + ]) + + payPalClient.tokenize(vaultRequest) { _, _ in } + + XCTAssertEqual("v1/paypal_hermes/setup_billing_agreement", mockAPIClient.lastPOSTPath) + guard let lastPostParameters = mockAPIClient.lastPOSTParameters else { XCTFail(); return } + + XCTAssertNil(lastPostParameters["launch_paypal_app"] as? Bool) + XCTAssertNil(lastPostParameters["os_version"] as? String) + XCTAssertNil(lastPostParameters["os_type"] as? String) + XCTAssertNil(lastPostParameters["merchant_app_return_url"] as? String) + } + // MARK: - Analytics func testAPIClientMetadata_hasIntegrationSetToCustom() { From 13b6804acf620bd2b4562a55d8d4e5c3088ab986 Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Fri, 5 Apr 2024 09:52:17 -0500 Subject: [PATCH 11/32] [QL] Replace Universal Link in Demo App + Update `paypalAppApprovalUrl` Parsing (#1250) * Our demo app URL for universal linking of https://braintree-ios-demo.fly.dev/braintree-payments has been allowlisted so we can update PayPalWebCheckoutViewController with this * In BTPayPalApprovalURLParser we were parsing paypalAppApprovalUrl from paymentResource (Checkout) when it should be parsed from agreementSetup (Vault) - verified via breakpoints and our contracts that this nesting should be updated * Updated tests with this correction --- .../PayPalWebCheckoutViewController.swift | 3 +-- .../BTPayPalApprovalURLParser.swift | 2 +- .../BTPayPalClient_Tests.swift | 20 ++++++++----------- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index 1190687cf1..17da54b287 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -87,11 +87,10 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { @objc func universalLinkFlow(_ sender: UIButton) { // TODO: implement in a future PR - used here so we don't have to remove lazy instantiation - // TODO: replace URL with https://braintree-ios-demo.fly.dev/braintree-payments let request = BTPayPalVaultRequest( userAuthenticationEmail: "sally@gmail.com", enablePayPalAppSwitch: true, - universalLink: URL(string: "https://paypal.com")! + universalLink: URL(string: "https://braintree-ios-demo.fly.dev/braintree-payments")! ) payPalClient.tokenize(request) { _, _ in } UIApplication.shared.open(URL(string: "https://braintree-ios-demo.fly.dev/braintree-payments/success")!) diff --git a/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift b/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift index 6e0e4aaf9d..beb3f5518a 100644 --- a/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift +++ b/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift @@ -37,7 +37,7 @@ struct BTPayPalApprovalURLParser { } init?(body: BTJSON) { - if let payPalAppRedirectURL = body["paymentResource"]["paypalAppApprovalUrl"].asURL() { + if let payPalAppRedirectURL = body["agreementSetup"]["paypalAppApprovalUrl"].asURL() { redirectType = .payPalApp(url: payPalAppRedirectURL) } else if let approvalURL = body["paymentResource"]["redirectUrl"].asURL() ?? body["agreementSetup"]["approvalUrl"].asURL() { diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index ff3f78c928..720027b0af 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -190,8 +190,7 @@ class BTPayPalClient_Tests: XCTestCase { func testTokenizePayPalAccount_whenAllApprovalURLsInvalid_returnsError() { mockAPIClient.cannedResponseBody = BTJSON(value: [ - "paymentResource": [ - "redirectUrl": "", + "agreementSetup": [ "approvalUrl": "", "paypalAppApprovalUrl": "" ] @@ -230,7 +229,7 @@ class BTPayPalClient_Tests: XCTestCase { // TODO: - Un-pend test once app switch flow sends analytics func pendTokenize_whenPayPalAppApprovalURLContainsPayPalContextID_sendsPayPalContextIDInAnalytics() { mockAPIClient.cannedResponseBody = BTJSON(value: [ - "paymentResource": [ + "agreementSetup": [ "paypalAppApprovalUrl": "https://www.fake.com?ba_token=123" ] ]) @@ -712,9 +711,8 @@ class BTPayPalClient_Tests: XCTestCase { payPalClient.application = fakeApplication mockAPIClient.cannedResponseBody = BTJSON(value: [ - "paymentResource": [ - "paypalAppApprovalUrl": "https://www.some-url.com/some-path?token=value1", - "redirectUrl": "https://www.other-url.com/" + "agreementSetup": [ + "paypalAppApprovalUrl": "https://www.some-url.com/some-path?token=value1" ] ]) @@ -869,9 +867,8 @@ class BTPayPalClient_Tests: XCTestCase { ) mockAPIClient.cannedResponseBody = BTJSON(value: [ - "paymentResource": [ - "paypalAppApprovalUrl": "https://www.some-url.com/some-path?token=value1", - "redirectUrl": "https://www.other-url.com/" + "agreementSetup": [ + "paypalAppApprovalUrl": "https://www.some-url.com/some-path?token=value1" ] ]) @@ -898,9 +895,8 @@ class BTPayPalClient_Tests: XCTestCase { ) mockAPIClient.cannedResponseBody = BTJSON(value: [ - "paymentResource": [ - "paypalAppApprovalUrl": "https://www.some-url.com/some-path?token=value1", - "redirectUrl": "https://www.other-url.com/" + "agreementSetup": [ + "paypalAppApprovalUrl": "https://www.some-url.com/some-path?token=value1" ] ]) From 22ac026ed9407f790b5ff5b3c53c98ed54b453cf Mon Sep 17 00:00:00 2001 From: Stephanie <127455800+stechiu@users.noreply.github.com> Date: Mon, 8 Apr 2024 10:18:39 -0700 Subject: [PATCH 12/32] Change tokenization key (#1253) * Updated tokenization key * Added `payPalClient` to `universalLinkFlow` method --- Demo/Application/Features/PayPalWebCheckoutViewController.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index 17da54b287..d5fbf67ed6 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -1,6 +1,7 @@ import Foundation import UIKit import BraintreePayPal +import BraintreeCore class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { @@ -87,6 +88,7 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { @objc func universalLinkFlow(_ sender: UIButton) { // TODO: implement in a future PR - used here so we don't have to remove lazy instantiation + let payPalClient = BTPayPalClient(apiClient: BTAPIClient(authorization: "sandbox_jy4fvpfg_v7x2rb226dx4pr7b")!) let request = BTPayPalVaultRequest( userAuthenticationEmail: "sally@gmail.com", enablePayPalAppSwitch: true, From 5d77d1284d68d810f178fb25dc80dae9670e3fbb Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Mon, 8 Apr 2024 13:03:14 -0500 Subject: [PATCH 13/32] [QL] App Switch Analytics (#1254) * Send link_type and paypal_installed in event_params when available to FPTI for the PayPal flow --- CHANGELOG.md | 1 + .../Analytics/BTAnalyticsService.swift | 10 ++- .../Analytics/FPTIBatchData.swift | 2 + Sources/BraintreeCore/BTAPIClient.swift | 6 +- .../BraintreePayPal/BTPayPalAnalytics.swift | 8 ++- Sources/BraintreePayPal/BTPayPalClient.swift | 69 ++++++++++++++++--- .../Analytics/FPTIBatchData_Tests.swift | 4 ++ .../Analytics/FakeAnalyticsService.swift | 3 +- .../BTPayPalAnalytics_Tests.swift | 3 + .../BTPayPalClient_Tests.swift | 36 +++++++--- .../BraintreeTestShared/MockAPIClient.swift | 5 +- 11 files changed, 118 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4238494c2..fb036572bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * BraintreePayPal * Add `BTPayPalVaultRequest(userAuthenticationEmail:enablePayPalAppSwitch:universalLink:offerCredit:)` * This init should be used for the PayPal App Switch flow + * Send `link_type` and `paypal_installed` in `event_params` when available to PayPal's analytics service (FPTI) ## 6.16.0 (2024-03-19) * Add `BTPayPalVaultRequest.userAuthenticationEmail` optional property diff --git a/Sources/BraintreeCore/Analytics/BTAnalyticsService.swift b/Sources/BraintreeCore/Analytics/BTAnalyticsService.swift index 4dfc4f4dcb..64555fe7f1 100644 --- a/Sources/BraintreeCore/Analytics/BTAnalyticsService.swift +++ b/Sources/BraintreeCore/Analytics/BTAnalyticsService.swift @@ -32,7 +32,8 @@ class BTAnalyticsService: Equatable { correlationID: String? = nil, errorDescription: String? = nil, linkType: String? = nil, - payPalContextID: String? = nil + payPalContextID: String? = nil, + payPalInstalled: String? = nil ) { Task(priority: .background) { await performEventRequest( @@ -40,7 +41,8 @@ class BTAnalyticsService: Equatable { correlationID: correlationID, errorDescription: errorDescription, linkType: linkType, - payPalContextID: payPalContextID + payPalContextID: payPalContextID, + payPalInstalled: payPalInstalled ) } } @@ -51,7 +53,8 @@ class BTAnalyticsService: Equatable { correlationID: String? = nil, errorDescription: String? = nil, linkType: String? = nil, - payPalContextID: String? = nil + payPalContextID: String? = nil, + payPalInstalled: String? = nil ) async { let timestampInMilliseconds = UInt64(Date().timeIntervalSince1970 * 1000) let event = FPTIBatchData.Event( @@ -60,6 +63,7 @@ class BTAnalyticsService: Equatable { eventName: eventName, linkType: linkType, payPalContextID: payPalContextID, + payPalInstalled: payPalInstalled, timestamp: String(timestampInMilliseconds) ) diff --git a/Sources/BraintreeCore/Analytics/FPTIBatchData.swift b/Sources/BraintreeCore/Analytics/FPTIBatchData.swift index 4e3e2ca38f..168655027a 100644 --- a/Sources/BraintreeCore/Analytics/FPTIBatchData.swift +++ b/Sources/BraintreeCore/Analytics/FPTIBatchData.swift @@ -35,6 +35,7 @@ struct FPTIBatchData: Codable { /// Used for linking events from the client to server side request /// This value will be PayPal Order ID, Payment Token, EC token, Billing Agreement, or Venmo Context ID depending on the flow let payPalContextID: String? + let payPalInstalled: String? let timestamp: String let tenantName: String = "Braintree" @@ -44,6 +45,7 @@ struct FPTIBatchData: Codable { case eventName = "event_name" case linkType = "link_type" case payPalContextID = "paypal_context_id" + case payPalInstalled = "paypal_installed" case timestamp = "t" case tenantName = "tenant_name" } diff --git a/Sources/BraintreeCore/BTAPIClient.swift b/Sources/BraintreeCore/BTAPIClient.swift index f1e20c50f5..e77c903e58 100644 --- a/Sources/BraintreeCore/BTAPIClient.swift +++ b/Sources/BraintreeCore/BTAPIClient.swift @@ -344,14 +344,16 @@ import Foundation correlationID: String? = nil, errorDescription: String? = nil, linkType: String? = nil, - payPalContextID: String? = nil + payPalContextID: String? = nil, + payPalInstalled: Bool? = nil ) { analyticsService?.sendAnalyticsEvent( eventName, correlationID: correlationID, errorDescription: errorDescription, linkType: linkType, - payPalContextID: payPalContextID + payPalContextID: payPalContextID, + payPalInstalled: payPalInstalled?.description ) } diff --git a/Sources/BraintreePayPal/BTPayPalAnalytics.swift b/Sources/BraintreePayPal/BTPayPalAnalytics.swift index c3ba312c27..7ba21da866 100644 --- a/Sources/BraintreePayPal/BTPayPalAnalytics.swift +++ b/Sources/BraintreePayPal/BTPayPalAnalytics.swift @@ -19,6 +19,12 @@ enum BTPayPalAnalytics { // general cancel used in conversion rates static let browserLoginCanceled = "paypal:tokenize:browser-login:canceled" - // specific cancel from permisison alert + // specific cancel from permission alert static let browserLoginAlertCanceled = "paypal:tokenize:browser-login:alert-canceled" + + // MARK: - App Switch events + + static let appSwitchStarted = "paypal:tokenize:app-switch:started" + static let appSwitchSucceeded = "paypal:tokenize:app-switch:succeeded" + static let appSwitchFailed = "paypal:tokenize:app-switch:failed" } diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index e7c5c8793d..ef0f445d5b 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -56,6 +56,9 @@ import BraintreeDataCollector /// In the PayPal flow this will be either an EC token or a Billing Agreement token private var payPalContextID: String? = nil + /// Used for sending the type of flow, universal vs deeplink to FPTI + private var linkType: String? = nil + /// URL Scheme for PayPal In-App Checkout private let payPalInAppScheme: String = "paypal-in-app-checkout://" @@ -166,6 +169,7 @@ import BraintreeDataCollector } // MARK: - Internal Methods + func handleReturn( _ url: URL?, paymentType: BTPayPalPaymentType, @@ -264,7 +268,10 @@ import BraintreeDataCollector request: BTPayPalRequest, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void ) { - self.apiClient.sendAnalyticsEvent(BTPayPalAnalytics.tokenizeStarted) + payPalAppInstalled = isPayPalAppInstalled() + linkType = (request as? BTPayPalVaultRequest)?.enablePayPalAppSwitch == true && payPalAppInstalled ? "universal" : "deeplink" + + apiClient.sendAnalyticsEvent(BTPayPalAnalytics.tokenizeStarted, linkType: linkType, payPalInstalled: payPalAppInstalled) apiClient.fetchOrReturnRemoteConfiguration { configuration, error in if let error { self.notifyFailure(with: error, completion: completion) @@ -281,8 +288,6 @@ import BraintreeDataCollector return } - self.payPalAppInstalled = self.isPayPalAppInstalled() - if !self.payPalAppInstalled { (request as? BTPayPalVaultRequest)?.enablePayPalAppSwitch = false } @@ -330,6 +335,13 @@ import BraintreeDataCollector } private func launchPayPalApp(with payPalAppRedirectURL: URL, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void) { + apiClient.sendAnalyticsEvent( + BTPayPalAnalytics.appSwitchStarted, + linkType: linkType, + payPalContextID: payPalContextID, + payPalInstalled: payPalAppInstalled + ) + var urlComponents = URLComponents(url: payPalAppRedirectURL, resolvingAgainstBaseURL: true) urlComponents?.queryItems = [ URLQueryItem(name: "source", value: "braintree_sdk"), @@ -348,11 +360,21 @@ import BraintreeDataCollector private func invokedOpenURLSuccessfully(_ success: Bool, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void) { if success { - // TODO: send appSwitchSucceeded analytics with payPalContextID and linkType + apiClient.sendAnalyticsEvent( + BTPayPalAnalytics.appSwitchSucceeded, + linkType: linkType, + payPalContextID: payPalContextID, + payPalInstalled: payPalAppInstalled + ) BTPayPalClient.payPalClient = self appSwitchCompletion = completion } else { - // TODO: send appSwitchFailed analytics with payPalContextID and linkType + apiClient.sendAnalyticsEvent( + BTPayPalAnalytics.appSwitchFailed, + linkType: linkType, + payPalContextID: payPalContextID, + payPalInstalled: payPalAppInstalled + ) notifyFailure(with: BTPayPalError.appSwitchFailed, completion: completion) } } @@ -379,14 +401,29 @@ import BraintreeDataCollector handleReturn(url, paymentType: paymentType, completion: completion) } sessionDidAppear: { [self] didAppear in if didAppear { - apiClient.sendAnalyticsEvent(BTPayPalAnalytics.browserPresentationSucceeded, payPalContextID: payPalContextID) + apiClient.sendAnalyticsEvent( + BTPayPalAnalytics.browserPresentationSucceeded, + linkType: linkType, + payPalContextID: payPalContextID, + payPalInstalled: payPalAppInstalled + ) } else { - apiClient.sendAnalyticsEvent(BTPayPalAnalytics.browserPresentationFailed, payPalContextID: payPalContextID) + apiClient.sendAnalyticsEvent( + BTPayPalAnalytics.browserPresentationFailed, + linkType: linkType, + payPalContextID: payPalContextID, + payPalInstalled: payPalAppInstalled + ) } } sessionDidCancel: { [self] in if !webSessionReturned { // User tapped system cancel button on permission alert - apiClient.sendAnalyticsEvent(BTPayPalAnalytics.browserLoginAlertCanceled, payPalContextID: payPalContextID) + apiClient.sendAnalyticsEvent( + BTPayPalAnalytics.browserLoginAlertCanceled, + linkType: linkType, + payPalContextID: payPalContextID, + payPalInstalled: payPalAppInstalled + ) } // User canceled by breaking out of the PayPal browser switch flow @@ -461,7 +498,13 @@ import BraintreeDataCollector with result: BTPayPalAccountNonce, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void ) { - apiClient.sendAnalyticsEvent(BTPayPalAnalytics.tokenizeSucceeded, correlationID: clientMetadataID, payPalContextID: payPalContextID) + apiClient.sendAnalyticsEvent( + BTPayPalAnalytics.tokenizeSucceeded, + correlationID: clientMetadataID, + linkType: linkType, + payPalContextID: payPalContextID, + payPalInstalled: payPalAppInstalled + ) completion(result, nil) } @@ -470,7 +513,9 @@ import BraintreeDataCollector BTPayPalAnalytics.tokenizeFailed, correlationID: clientMetadataID, errorDescription: error.localizedDescription, - payPalContextID: payPalContextID + linkType: linkType, + payPalContextID: payPalContextID, + payPalInstalled: payPalAppInstalled ) completion(nil, error) } @@ -479,7 +524,9 @@ import BraintreeDataCollector self.apiClient.sendAnalyticsEvent( BTPayPalAnalytics.browserLoginCanceled, correlationID: clientMetadataID, - payPalContextID: payPalContextID + linkType: linkType, + payPalContextID: payPalContextID, + payPalInstalled: payPalAppInstalled ) completion(nil, BTPayPalError.canceled) } diff --git a/UnitTests/BraintreeCoreTests/Analytics/FPTIBatchData_Tests.swift b/UnitTests/BraintreeCoreTests/Analytics/FPTIBatchData_Tests.swift index 37e5938782..94a47dfad9 100644 --- a/UnitTests/BraintreeCoreTests/Analytics/FPTIBatchData_Tests.swift +++ b/UnitTests/BraintreeCoreTests/Analytics/FPTIBatchData_Tests.swift @@ -22,6 +22,7 @@ final class FPTIBatchData_Tests: XCTestCase { eventName: "fake-event-1", linkType: "universal", payPalContextID: "fake-order-id", + payPalInstalled: "true", timestamp: "fake-time-1" ), FPTIBatchData.Event( @@ -30,6 +31,7 @@ final class FPTIBatchData_Tests: XCTestCase { eventName: "fake-event-2", linkType: nil, payPalContextID: "fake-order-id-2", + payPalInstalled: nil, timestamp: "fake-time-2" ) ] @@ -89,6 +91,8 @@ final class FPTIBatchData_Tests: XCTestCase { XCTAssertNil(eventParams[1]["link_type"]) XCTAssertEqual(eventParams[0]["paypal_context_id"] as! String, "fake-order-id") XCTAssertEqual(eventParams[1]["paypal_context_id"] as! String, "fake-order-id-2") + XCTAssertEqual(eventParams[0]["paypal_installed"] as? String, "true") + XCTAssertNil(eventParams[1]["paypal_installed"]) XCTAssertEqual(eventParams[0]["error_desc"] as? String, "fake-error-description-1") XCTAssertNil(eventParams[1]["error_desc"]) XCTAssertEqual(eventParams[0]["correlation_id"] as? String, "fake-correlation-id-1") diff --git a/UnitTests/BraintreeCoreTests/Analytics/FakeAnalyticsService.swift b/UnitTests/BraintreeCoreTests/Analytics/FakeAnalyticsService.swift index eb87e6ab5c..79cdaee749 100644 --- a/UnitTests/BraintreeCoreTests/Analytics/FakeAnalyticsService.swift +++ b/UnitTests/BraintreeCoreTests/Analytics/FakeAnalyticsService.swift @@ -9,7 +9,8 @@ class FakeAnalyticsService: BTAnalyticsService { correlationID: String? = nil, errorDescription: String? = nil, linkType: String? = nil, - payPalContextID: String? = nil + payPalContextID: String? = nil, + payPalInstalled: String? = nil ) { self.lastEvent = eventName } diff --git a/UnitTests/BraintreePayPalTests/BTPayPalAnalytics_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalAnalytics_Tests.swift index a025ddc1e7..f772891268 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalAnalytics_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalAnalytics_Tests.swift @@ -10,5 +10,8 @@ final class BTPayPalAnalytics_Tests: XCTestCase { XCTAssertEqual(BTPayPalAnalytics.browserPresentationSucceeded, "paypal:tokenize:browser-presentation:succeeded") XCTAssertEqual(BTPayPalAnalytics.browserPresentationFailed, "paypal:tokenize:browser-presentation:failed") XCTAssertEqual(BTPayPalAnalytics.browserLoginAlertCanceled, "paypal:tokenize:browser-login:alert-canceled") + XCTAssertEqual(BTPayPalAnalytics.appSwitchStarted, "paypal:tokenize:app-switch:started") + XCTAssertEqual(BTPayPalAnalytics.appSwitchSucceeded, "paypal:tokenize:app-switch:succeeded") + XCTAssertEqual(BTPayPalAnalytics.appSwitchFailed, "paypal:tokenize:app-switch:failed") } } diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index 720027b0af..bf50947b04 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -226,20 +226,32 @@ class BTPayPalClient_Tests: XCTestCase { XCTAssertEqual(mockAPIClient.postedPayPalContextID, "EC-Random-Value") } - // TODO: - Un-pend test once app switch flow sends analytics - func pendTokenize_whenPayPalAppApprovalURLContainsPayPalContextID_sendsPayPalContextIDInAnalytics() { + func testTokenize_whenPayPalAppApprovalURLContainsPayPalContextID_sendsPayPalContextIDAndLinkTypeInAnalytics() { + let fakeApplication = FakeApplication() + payPalClient.application = fakeApplication + payPalClient.payPalAppInstalled = true + payPalClient.webAuthenticationSession = MockWebAuthenticationSession() + + let vaultRequest = BTPayPalVaultRequest( + userAuthenticationEmail: "fake@gmail.com", + enablePayPalAppSwitch: true, + universalLink: URL(string: "https://www.paypal.com")! + ) + mockAPIClient.cannedResponseBody = BTJSON(value: [ "agreementSetup": [ - "paypalAppApprovalUrl": "https://www.fake.com?ba_token=123" + "paypalAppApprovalUrl": "https://www.paypal.com?ba_token=BA-Random-Value" ] ]) - payPalClient.webAuthenticationSession = MockWebAuthenticationSession() + payPalClient.tokenize(vaultRequest) { _, _ in } - let request = BTPayPalCheckoutRequest(amount: "1") - payPalClient.tokenize(request) { _, _ in } + let returnURL = URL(string: "https://www.merchant-app.com/merchant-path/success?ba_token=A_FAKE_BA_TOKEN&switch_initiated_time=1234567890")! + payPalClient.handleReturnURL(returnURL) - XCTAssertEqual(mockAPIClient.postedPayPalContextID, "123") + XCTAssertEqual(mockAPIClient.postedPayPalContextID, "BA-Random-Value") + XCTAssertEqual(mockAPIClient.postedLinkType, "universal") + XCTAssertEqual(mockAPIClient.postedPayPalAppInstalled, "true") XCTAssertNotNil(payPalClient.clientMetadataID) } @@ -271,6 +283,8 @@ class BTPayPalClient_Tests: XCTestCase { payPalClient.tokenize(request) { _, _ in } XCTAssertEqual(mockAPIClient.postedPayPalContextID, "BA-Random-Value") + XCTAssertEqual(mockAPIClient.postedLinkType, "deeplink") + XCTAssertEqual(mockAPIClient.postedPayPalAppInstalled, "false") } // MARK: - Browser switch @@ -709,7 +723,7 @@ class BTPayPalClient_Tests: XCTestCase { func testTokenizeVaultAccount_whenPayPalAppApprovalURLPresent_attemptsAppSwitchWithParameters() async { let fakeApplication = FakeApplication() payPalClient.application = fakeApplication - + mockAPIClient.cannedResponseBody = BTJSON(value: [ "agreementSetup": [ "paypalAppApprovalUrl": "https://www.some-url.com/some-path?token=value1" @@ -855,7 +869,7 @@ class BTPayPalClient_Tests: XCTestCase { waitForExpectations(timeout: 1) } - func testIsiOSAppSwitchAvailable_whenApplicationCanOpenPayPalInAppURL_returnsTrue() { + func testIsiOSAppSwitchAvailable_whenApplicationCanOpenPayPalInAppURL_returnsTrueAndSendsAnalytics() { let fakeApplication = FakeApplication() payPalClient.application = fakeApplication payPalClient.payPalAppInstalled = true @@ -875,6 +889,7 @@ class BTPayPalClient_Tests: XCTestCase { payPalClient.tokenize(vaultRequest) { _, _ in } XCTAssertEqual("v1/paypal_hermes/setup_billing_agreement", mockAPIClient.lastPOSTPath) + XCTAssertEqual(mockAPIClient.postedPayPalAppInstalled, "true") guard let lastPostParameters = mockAPIClient.lastPOSTParameters else { XCTFail(); return } XCTAssertEqual(lastPostParameters["launch_paypal_app"] as? Bool, true) @@ -883,7 +898,7 @@ class BTPayPalClient_Tests: XCTestCase { XCTAssertEqual(lastPostParameters["merchant_app_return_url"] as? String, "https://paypal.com") } - func testIsiOSAppSwitchAvailable_whenApplicationCantOpenPayPalInAppURL_returnsFalse() { + func testIsiOSAppSwitchAvailable_whenApplicationCantOpenPayPalInAppURL_returnsFalseAndSendsAnalytics() { let fakeApplication = FakeApplication() fakeApplication.cannedCanOpenURL = false payPalClient.application = fakeApplication @@ -903,6 +918,7 @@ class BTPayPalClient_Tests: XCTestCase { payPalClient.tokenize(vaultRequest) { _, _ in } XCTAssertEqual("v1/paypal_hermes/setup_billing_agreement", mockAPIClient.lastPOSTPath) + XCTAssertEqual(mockAPIClient.postedPayPalAppInstalled, "false") guard let lastPostParameters = mockAPIClient.lastPOSTParameters else { XCTFail(); return } XCTAssertNil(lastPostParameters["launch_paypal_app"] as? Bool) diff --git a/UnitTests/BraintreeTestShared/MockAPIClient.swift b/UnitTests/BraintreeTestShared/MockAPIClient.swift index 1af0a4a6c5..dbebe8afc0 100644 --- a/UnitTests/BraintreeTestShared/MockAPIClient.swift +++ b/UnitTests/BraintreeTestShared/MockAPIClient.swift @@ -13,6 +13,7 @@ public class MockAPIClient: BTAPIClient { public var postedAnalyticsEvents : [String] = [] public var postedPayPalContextID: String? = nil public var postedLinkType: String? = nil + public var postedPayPalAppInstalled: String? = nil @objc public var cannedConfigurationResponseBody : BTJSON? = nil @objc public var cannedConfigurationResponseError : NSError? = nil @@ -87,10 +88,12 @@ public class MockAPIClient: BTAPIClient { correlationID: String? = nil, errorDescription: String? = nil, linkType: String? = nil, - payPalContextID: String? = nil + payPalContextID: String? = nil, + payPalInstalled: Bool? = nil ) { postedPayPalContextID = payPalContextID postedLinkType = linkType + postedPayPalAppInstalled = payPalInstalled?.description postedAnalyticsEvents.append(name) } From cbb664ee96a344a7018213b9514a37beb00c1655 Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Mon, 8 Apr 2024 13:25:11 -0500 Subject: [PATCH 14/32] handle nonce in ViewController for app switch flow; update button and method names (#1261) --- .../PayPalWebCheckoutViewController.swift | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index d5fbf67ed6..949f313f77 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -11,9 +11,9 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { let payPalCheckoutButton = createButton(title: "PayPal Checkout", action: #selector(tappedPayPalCheckout)) let payPalVaultButton = createButton(title: "PayPal Vault", action: #selector(tappedPayPalVault)) let payPalPayLaterButton = createButton(title: "PayPal with Pay Later Offered", action: #selector(tappedPayPalPayLater)) - let universalLinkButton = createButton(title: "Universal Link Flow", action: #selector(universalLinkFlow)) + let payPalAppSwitchButton = createButton(title: "PayPal App Switch Flow", action: #selector(tappedPayPalAppSwitchFlow)) - let buttons = [payPalCheckoutButton, payPalVaultButton, payPalPayLaterButton, universalLinkButton] + let buttons = [payPalCheckoutButton, payPalVaultButton, payPalPayLaterButton, payPalAppSwitchButton] let stackView = UIStackView(arrangedSubviews: buttons) stackView.axis = .vertical stackView.alignment = .center @@ -86,15 +86,22 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { } } - @objc func universalLinkFlow(_ sender: UIButton) { - // TODO: implement in a future PR - used here so we don't have to remove lazy instantiation + @objc func tappedPayPalAppSwitchFlow(_ sender: UIButton) { let payPalClient = BTPayPalClient(apiClient: BTAPIClient(authorization: "sandbox_jy4fvpfg_v7x2rb226dx4pr7b")!) let request = BTPayPalVaultRequest( userAuthenticationEmail: "sally@gmail.com", enablePayPalAppSwitch: true, universalLink: URL(string: "https://braintree-ios-demo.fly.dev/braintree-payments")! ) - payPalClient.tokenize(request) { _, _ in } - UIApplication.shared.open(URL(string: "https://braintree-ios-demo.fly.dev/braintree-payments/success")!) + payPalClient.tokenize(request) { nonce, error in + sender.isEnabled = true + + guard let nonce else { + self.progressBlock(error?.localizedDescription) + return + } + + self.completionBlock(nonce) + } } } From f0aa6cd082a893246beb8307409c348fbca607f0 Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Tue, 9 Apr 2024 10:07:00 -0500 Subject: [PATCH 15/32] update note annotations to warnings in docstrings (#1263) --- Sources/BraintreePayPal/BTPayPalVaultRequest.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/BraintreePayPal/BTPayPalVaultRequest.swift b/Sources/BraintreePayPal/BTPayPalVaultRequest.swift index 6f343429a9..b2048cc71d 100644 --- a/Sources/BraintreePayPal/BTPayPalVaultRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalVaultRequest.swift @@ -16,7 +16,7 @@ import BraintreeCore /// Optional: Used to determine if the customer will use the PayPal app switch flow. /// Defaults to `false`. - /// - Note: This property is currently in beta and may change or be removed in future releases. + /// - Warning: This property is currently in beta and may change or be removed in future releases. var enablePayPalAppSwitch: Bool = false /// The URL to use for the PayPal app switch flow. Must be a valid HTTPS URL dedicated to Braintree app switch returns. @@ -30,7 +30,7 @@ import BraintreeCore /// - enablePayPalAppSwitch: Required: Used to determine if the customer will use the PayPal app switch flow. /// - universalLink: Required: The URL to use for the PayPal app switch flow. Must be a valid HTTPS URL dedicated to Braintree app switch returns. /// - offerCredit: Optional: Offers PayPal Credit if the customer qualifies. Defaults to `false`. - /// - Note: This initializer should be used for merchants using the PayPal App Switch flow. This feature is currently in beta and may change or be removed in future releases. + /// - Warning: This initializer should be used for merchants using the PayPal App Switch flow. This feature is currently in beta and may change or be removed in future releases. public convenience init( userAuthenticationEmail: String, enablePayPalAppSwitch: Bool, From e1932c6b2d8465fb1e53d7b48eba193a78cc538f Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Tue, 9 Apr 2024 10:08:04 -0500 Subject: [PATCH 16/32] [QL] Add Missing BA Token to Query Parameters (#1265) * add missing BA token to query parameters for app switch flow * update test --- Sources/BraintreePayPal/BTPayPalClient.swift | 1 + .../BraintreePayPalTests/BTPayPalClient_Tests.swift | 12 +++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index ca2ffa2fca..df51d8c707 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -351,6 +351,7 @@ import BraintreeDataCollector var urlComponents = URLComponents(url: payPalAppRedirectURL, resolvingAgainstBaseURL: true) urlComponents?.queryItems = [ + URLQueryItem(name: "ba_token", value: payPalContextID), URLQueryItem(name: "source", value: "braintree_sdk"), URLQueryItem(name: "switch_initiated_time", value: String(Int(round(Date().timeIntervalSince1970 * 1000)))) ] diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index 9a175600f3..0b1d3a48f3 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -727,7 +727,7 @@ class BTPayPalClient_Tests: XCTestCase { mockAPIClient.cannedResponseBody = BTJSON(value: [ "agreementSetup": [ - "paypalAppApprovalUrl": "https://www.some-url.com/some-path?token=value1" + "paypalAppApprovalUrl": "https://www.some-url.com/some-path?ba_token=value1" ] ]) @@ -745,10 +745,12 @@ class BTPayPalClient_Tests: XCTestCase { XCTAssertEqual(urlComponents?.host, "www.some-url.com") XCTAssertEqual(urlComponents?.path, "/some-path") - XCTAssertEqual(urlComponents?.queryItems?[0].name, "source") - XCTAssertEqual(urlComponents?.queryItems?[0].value, "braintree_sdk") - XCTAssertEqual(urlComponents?.queryItems?[1].name, "switch_initiated_time") - if let urlTimestamp = urlComponents?.queryItems?[1].value { + XCTAssertEqual(urlComponents?.queryItems?[0].name, "ba_token") + XCTAssertEqual(urlComponents?.queryItems?[0].value, "value1") + XCTAssertEqual(urlComponents?.queryItems?[1].name, "source") + XCTAssertEqual(urlComponents?.queryItems?[1].value, "braintree_sdk") + XCTAssertEqual(urlComponents?.queryItems?[2].name, "switch_initiated_time") + if let urlTimestamp = urlComponents?.queryItems?[2].value { XCTAssertNotNil(Int(urlTimestamp)) } else { XCTFail("Expected integer value for query param `switch_initiated_time`") From e9a710d55858c43fa3c7a875456ebf4541cc4756 Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Wed, 10 Apr 2024 09:43:55 -0500 Subject: [PATCH 17/32] add copyable UILabel for debugging App Switch issues (#1266) --- .../PaymentButtonBaseViewController.swift | 2 +- .../PayPalWebCheckoutViewController.swift | 35 ++++++++++++++++++- Sources/BraintreePayPal/BTPayPalClient.swift | 3 ++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/Demo/Application/Base/PaymentButtonBaseViewController.swift b/Demo/Application/Base/PaymentButtonBaseViewController.swift index ba48e1a77b..2e01be1527 100644 --- a/Demo/Application/Base/PaymentButtonBaseViewController.swift +++ b/Demo/Application/Base/PaymentButtonBaseViewController.swift @@ -29,7 +29,7 @@ class PaymentButtonBaseViewController: BaseViewController { paymentButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), paymentButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), paymentButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), - paymentButton.heightAnchor.constraint(equalToConstant: 100) + paymentButton.heightAnchor.constraint(equalToConstant: 150) ]) } diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index 949f313f77..e584878944 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -7,13 +7,22 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { lazy var payPalClient = BTPayPalClient(apiClient: apiClient) + // TODO: remove UILabel before merging into main DTBTSDK-3766 + let baTokenLabel = UILabel() + override func createPaymentButton() -> UIView { let payPalCheckoutButton = createButton(title: "PayPal Checkout", action: #selector(tappedPayPalCheckout)) let payPalVaultButton = createButton(title: "PayPal Vault", action: #selector(tappedPayPalVault)) let payPalPayLaterButton = createButton(title: "PayPal with Pay Later Offered", action: #selector(tappedPayPalPayLater)) let payPalAppSwitchButton = createButton(title: "PayPal App Switch Flow", action: #selector(tappedPayPalAppSwitchFlow)) - let buttons = [payPalCheckoutButton, payPalVaultButton, payPalPayLaterButton, payPalAppSwitchButton] + // TODO: remove tapGesture before merging into main DTBTSDK-3766 + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(labelTapped)) + baTokenLabel.isUserInteractionEnabled = true + baTokenLabel.addGestureRecognizer(tapGesture) + baTokenLabel.textColor = .systemPink + + let buttons = [payPalCheckoutButton, payPalVaultButton, payPalPayLaterButton, payPalAppSwitchButton, baTokenLabel] let stackView = UIStackView(arrangedSubviews: buttons) stackView.axis = .vertical stackView.alignment = .center @@ -93,6 +102,15 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { enablePayPalAppSwitch: true, universalLink: URL(string: "https://braintree-ios-demo.fly.dev/braintree-payments")! ) + + // TODO: remove NotificationCenter before merging into main DTBTSDK-3766 + NotificationCenter.default.addObserver( + self, + selector: #selector(receivedNotification), + name: Notification.Name("BAToken"), + object: nil + ) + payPalClient.tokenize(request) { nonce, error in sender.isEnabled = true @@ -104,4 +122,19 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { self.completionBlock(nonce) } } + + // TODO: remove labelTapped and receivedNotification before merging into main DTBTSDK-3766 + + @objc func labelTapped(sender: UITapGestureRecognizer) { + UIPasteboard.general.string = baTokenLabel.text + } + + @objc func receivedNotification(_ notification: Notification) { + guard let baToken = notification.object else { + baTokenLabel.text = "No token returned" + return + } + + baTokenLabel.text = "\(baToken)" + } } diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index df51d8c707..6fb30ecc36 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -321,6 +321,9 @@ import BraintreeDataCollector self.payPalContextID = approvalURL.pairingID + // TODO: remove NotificationCenter before merging into main DTBTSDK-3766 + NotificationCenter.default.post(name: Notification.Name("BAToken"), object: self.payPalContextID) + let dataCollector = BTDataCollector(apiClient: self.apiClient) self.clientMetadataID = self.payPalRequest?.riskCorrelationID ?? dataCollector.clientMetadataID(approvalURL.pairingID) From 3121388a2a60fa4abbdde40236eaa5419af6378d Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Thu, 11 Apr 2024 12:55:31 -0500 Subject: [PATCH 18/32] [QL] Update plist and Handle Fallback Properly (#1270) * Update Braintree-Demo-Info.plist with proper scheme name - currently our check for isPayPalAppInstalled was always returning false because we need the scheme added to the plist for the demo app * If we got a paypalAppApprovalUrl back we were always app switching, this would cause issues if the isPayPalAppInstalled check returned false and the app truly wasn't installed, I tested this by removing the app and ensuring we fell back to web with the new logic - it can also be tested by updating the BTPayPalClient.payPalInAppScheme to something like "badscheme" --- .../Supporting Files/Braintree-Demo-Info.plist | 2 +- Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift | 4 ++-- Sources/BraintreePayPal/BTPayPalClient.swift | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Demo/Application/Supporting Files/Braintree-Demo-Info.plist b/Demo/Application/Supporting Files/Braintree-Demo-Info.plist index 3f2f3761f9..37e15f70b4 100644 --- a/Demo/Application/Supporting Files/Braintree-Demo-Info.plist +++ b/Demo/Application/Supporting Files/Braintree-Demo-Info.plist @@ -61,7 +61,7 @@ com.braintreepayments.Demo.payments com.venmo.touch.v2 - paypal + paypal-in-app-checkout LSRequiresIPhoneOS diff --git a/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift b/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift index beb3f5518a..9e8e187277 100644 --- a/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift +++ b/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift @@ -36,8 +36,8 @@ struct BTPayPalApprovalURLParser { } } - init?(body: BTJSON) { - if let payPalAppRedirectURL = body["agreementSetup"]["paypalAppApprovalUrl"].asURL() { + init?(body: BTJSON, linkType: String?) { + if linkType == "universal", let payPalAppRedirectURL = body["agreementSetup"]["paypalAppApprovalUrl"].asURL() { redirectType = .payPalApp(url: payPalAppRedirectURL) } else if let approvalURL = body["paymentResource"]["redirectUrl"].asURL() ?? body["agreementSetup"]["approvalUrl"].asURL() { diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index 6fb30ecc36..178407692f 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -48,6 +48,9 @@ import BraintreeDataCollector // MARK: - Private Properties + /// URL Scheme for PayPal In-App Checkout + private let payPalInAppScheme: String = "paypal-in-app-checkout://" + /// Indicates if the user returned back to the merchant app from the `BTWebAuthenticationSession` /// Will only be `true` if the user proceed through the `UIAlertController` private var webSessionReturned: Bool = false @@ -59,9 +62,6 @@ import BraintreeDataCollector /// Used for sending the type of flow, universal vs deeplink to FPTI private var linkType: String? = nil - /// URL Scheme for PayPal In-App Checkout - private let payPalInAppScheme: String = "paypal-in-app-checkout://" - // MARK: - Initializer /// Initialize a new PayPal client instance. @@ -314,7 +314,7 @@ import BraintreeDataCollector return } - guard let body, let approvalURL = BTPayPalApprovalURLParser(body: body) else { + guard let body, let approvalURL = BTPayPalApprovalURLParser(body: body, linkType: self.linkType) else { self.notifyFailure(with: BTPayPalError.invalidURL("Missing approval URL in gateway response."), completion: completion) return } From 346e4e9da58fec55a693371c281657b2b82c6bbe Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Thu, 11 Apr 2024 15:03:17 -0500 Subject: [PATCH 19/32] [QL] Consolidate Return URL Logic (#1271) * updates to move ReturnURL logic to one place * update fallback logic to handle properly * update plist for app installed check * add webBrowser based BTPayPalReturnURL tests * PR feedback: update docstrings and enum name --- Braintree.xcodeproj/project.pbxproj | 16 ++-- .../BTPayPalAppSwitchReturnURL.swift | 41 -------- Sources/BraintreePayPal/BTPayPalClient.swift | 96 ++++++------------- .../BraintreePayPal/BTPayPalReturnURL.swift | 84 ++++++++++++++++ .../BTPayPalAppSwitchReturnURL_Tests.swift | 25 ----- .../BTPayPalClient_Tests.swift | 9 +- .../BTPayPalReturnURL_Tests.swift | 40 ++++++++ 7 files changed, 169 insertions(+), 142 deletions(-) delete mode 100644 Sources/BraintreePayPal/BTPayPalAppSwitchReturnURL.swift create mode 100644 Sources/BraintreePayPal/BTPayPalReturnURL.swift delete mode 100644 UnitTests/BraintreePayPalTests/BTPayPalAppSwitchReturnURL_Tests.swift create mode 100644 UnitTests/BraintreePayPalTests/BTPayPalReturnURL_Tests.swift diff --git a/Braintree.xcodeproj/project.pbxproj b/Braintree.xcodeproj/project.pbxproj index 4c34b32ab0..34feb8f4a4 100644 --- a/Braintree.xcodeproj/project.pbxproj +++ b/Braintree.xcodeproj/project.pbxproj @@ -230,7 +230,7 @@ BE698EA428AD2C10001D9B10 /* BTCoreConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE698EA328AD2C10001D9B10 /* BTCoreConstants.swift */; }; BE698EA628B3CDAD001D9B10 /* BTCacheDateValidator_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE698EA528B3CDAD001D9B10 /* BTCacheDateValidator_Tests.swift */; }; BE6BC22C2BA9C67600C3E321 /* BTPayPalVaultBaseRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6BC22B2BA9C67600C3E321 /* BTPayPalVaultBaseRequest.swift */; }; - BE6BC22E2BA9CFFC00C3E321 /* BTPayPalAppSwitchReturnURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6BC22D2BA9CFFC00C3E321 /* BTPayPalAppSwitchReturnURL.swift */; }; + BE6BC22E2BA9CFFC00C3E321 /* BTPayPalReturnURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6BC22D2BA9CFFC00C3E321 /* BTPayPalReturnURL.swift */; }; BE70A963284FA3F000F6D3F7 /* BTDataCollectorError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE70A962284FA3F000F6D3F7 /* BTDataCollectorError.swift */; }; BE70A965284FA9DE00F6D3F7 /* MockBTDataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE70A964284FA9DE00F6D3F7 /* MockBTDataCollector.swift */; }; BE70A983284FC07C00F6D3F7 /* BraintreeDataCollector.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A76D7C001BB1CAB00000FA6A /* BraintreeDataCollector.framework */; }; @@ -265,7 +265,7 @@ BE9FB82B2898324C00D6FE2F /* BTPaymentMethodNonce.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE9FB82A2898324C00D6FE2F /* BTPaymentMethodNonce.swift */; }; BE9FB82D28984ADE00D6FE2F /* BTPaymentMethodNonceParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE9FB82C28984ADE00D6FE2F /* BTPaymentMethodNonceParser.swift */; }; BEB9BF532A26872B00A3673E /* BTWebAuthenticationSessionClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEB9BF522A26872B00A3673E /* BTWebAuthenticationSessionClient.swift */; }; - BEBA590F2BB1B5B9005FA8A2 /* BTPayPalAppSwitchReturnURL_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEBA590E2BB1B5B9005FA8A2 /* BTPayPalAppSwitchReturnURL_Tests.swift */; }; + BEBA590F2BB1B5B9005FA8A2 /* BTPayPalReturnURL_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEBA590E2BB1B5B9005FA8A2 /* BTPayPalReturnURL_Tests.swift */; }; BEBC222728D25BB400D83186 /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80DBE69423A931A600373230 /* Helpers.swift */; }; BEBC6E4B29258FD4004E25A0 /* BraintreeCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 570B93AC285397520041BAFE /* BraintreeCore.framework */; }; BEBC6E5E2927CF59004E25A0 /* Braintree.h in Headers */ = {isa = PBXBuildFile; fileRef = BEBC6E5D2927CF59004E25A0 /* Braintree.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -870,7 +870,7 @@ BE698EA528B3CDAD001D9B10 /* BTCacheDateValidator_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTCacheDateValidator_Tests.swift; sourceTree = ""; }; BE698EAA28B50F41001D9B10 /* BTClientToken_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTClientToken_Tests.swift; sourceTree = ""; }; BE6BC22B2BA9C67600C3E321 /* BTPayPalVaultBaseRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPayPalVaultBaseRequest.swift; sourceTree = ""; }; - BE6BC22D2BA9CFFC00C3E321 /* BTPayPalAppSwitchReturnURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPayPalAppSwitchReturnURL.swift; sourceTree = ""; }; + BE6BC22D2BA9CFFC00C3E321 /* BTPayPalReturnURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPayPalReturnURL.swift; sourceTree = ""; }; BE70A962284FA3F000F6D3F7 /* BTDataCollectorError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTDataCollectorError.swift; sourceTree = ""; }; BE70A964284FA9DE00F6D3F7 /* MockBTDataCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBTDataCollector.swift; sourceTree = ""; }; BE7A9643299FC5DE009AB920 /* BTConfiguration+ApplePay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BTConfiguration+ApplePay.swift"; sourceTree = ""; }; @@ -902,7 +902,7 @@ BE9FB82A2898324C00D6FE2F /* BTPaymentMethodNonce.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPaymentMethodNonce.swift; sourceTree = ""; }; BE9FB82C28984ADE00D6FE2F /* BTPaymentMethodNonceParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPaymentMethodNonceParser.swift; sourceTree = ""; }; BEB9BF522A26872B00A3673E /* BTWebAuthenticationSessionClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTWebAuthenticationSessionClient.swift; sourceTree = ""; }; - BEBA590E2BB1B5B9005FA8A2 /* BTPayPalAppSwitchReturnURL_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPayPalAppSwitchReturnURL_Tests.swift; sourceTree = ""; }; + BEBA590E2BB1B5B9005FA8A2 /* BTPayPalReturnURL_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPayPalReturnURL_Tests.swift; sourceTree = ""; }; BEBC6E5D2927CF59004E25A0 /* Braintree.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Braintree.h; sourceTree = ""; }; BEBC6F252937A510004E25A0 /* BTClientMetadata_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTClientMetadata_Tests.swift; sourceTree = ""; }; BEBC6F272937BD1F004E25A0 /* BTGraphQLHTTP_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTGraphQLHTTP_Tests.swift; sourceTree = ""; }; @@ -1197,7 +1197,7 @@ 57544F572952298900DEB7B0 /* BTPayPalAccountNonce.swift */, 3B7A261029C0CAA40087059D /* BTPayPalAnalytics.swift */, 8014221B2BAE935B009F9999 /* BTPayPalApprovalURLParser.swift */, - BE6BC22D2BA9CFFC00C3E321 /* BTPayPalAppSwitchReturnURL.swift */, + BE6BC22D2BA9CFFC00C3E321 /* BTPayPalReturnURL.swift */, BE8E5CEE294B6937001BF017 /* BTPayPalCheckoutRequest.swift */, 57544F5929524E4D00DEB7B0 /* BTPayPalClient.swift */, 5754481F294A2EBE00DEB7B0 /* BTPayPalCreditFinancing.swift */, @@ -1713,13 +1713,13 @@ A95229C624FD949D006F7D25 /* BTConfiguration+PayPal_Tests.swift */, BEDEAF102AC1D049004EA970 /* BTPayPalAccountNonce_Tests.swift */, 3B7A261229C35B670087059D /* BTPayPalAnalytics_Tests.swift */, + BEBA590E2BB1B5B9005FA8A2 /* BTPayPalReturnURL_Tests.swift */, 42FC237025CE0E110047C49A /* BTPayPalCheckoutRequest_Tests.swift */, 427F32DF25D1D62D00435294 /* BTPayPalClient_Tests.swift */, BECB10C52B5999EE008D398E /* BTPayPalLineItem_Tests.swift */, 42FC218A25CDE0290047C49A /* BTPayPalRequest_Tests.swift */, 427F328F25D1A7B900435294 /* BTPayPalVaultRequest_Tests.swift */, A9E5C1E424FD665D00EE691F /* Info.plist */, - BEBA590E2BB1B5B9005FA8A2 /* BTPayPalAppSwitchReturnURL_Tests.swift */, ); path = BraintreePayPalTests; sourceTree = ""; @@ -2794,7 +2794,7 @@ BE349113294B798300D2CF68 /* BTPayPalRequest.swift in Sources */, 57544F5C295254A500DEB7B0 /* BTJSON+PayPal.swift in Sources */, 3B7A261129C0CAA40087059D /* BTPayPalAnalytics.swift in Sources */, - BE6BC22E2BA9CFFC00C3E321 /* BTPayPalAppSwitchReturnURL.swift in Sources */, + BE6BC22E2BA9CFFC00C3E321 /* BTPayPalReturnURL.swift in Sources */, BE8E5CEF294B6937001BF017 /* BTPayPalCheckoutRequest.swift in Sources */, 5754481E294A2A1D00DEB7B0 /* BTPayPalCreditFinancingAmount.swift in Sources */, 57D9436E2968A8080079EAB1 /* BTPayPalLocaleCode.swift in Sources */, @@ -3118,7 +3118,7 @@ BECB10C62B5999EE008D398E /* BTPayPalLineItem_Tests.swift in Sources */, 3B7A261429C35BD00087059D /* BTPayPalAnalytics_Tests.swift in Sources */, A95229C724FD949D006F7D25 /* BTConfiguration+PayPal_Tests.swift in Sources */, - BEBA590F2BB1B5B9005FA8A2 /* BTPayPalAppSwitchReturnURL_Tests.swift in Sources */, + BEBA590F2BB1B5B9005FA8A2 /* BTPayPalReturnURL_Tests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/BraintreePayPal/BTPayPalAppSwitchReturnURL.swift b/Sources/BraintreePayPal/BTPayPalAppSwitchReturnURL.swift deleted file mode 100644 index c6a3064bd0..0000000000 --- a/Sources/BraintreePayPal/BTPayPalAppSwitchReturnURL.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation - -#if canImport(BraintreeCore) -import BraintreeCore -#endif - -enum BTPayPalAppSwitchReturnURLState { - case unknownPath - case succeeded - case canceled -} - -/// This class interprets URLs received from the PayPal app via app switch returns. -/// -/// PayPal app switch authorization requests should result in success or user-initiated cancelation. These states are communicated in the url. -struct BTPayPalAppSwitchReturnURL { - - /// The overall status of the app switch - success, cancelation, or an unknown path - var state: BTPayPalAppSwitchReturnURLState = .unknownPath - - /// Initializes a new `BTPayPalAppSwitchReturnURL` - /// - Parameter url: an incoming app switch url - init?(url: URL) { - if url.path.contains("success") { - state = .succeeded - } else if url.path.contains("cancel") { - state = .canceled - } else { - state = .unknownPath - } - } - - // MARK: - Static Methods - - /// Evaluates whether the url represents a valid PayPal return URL. - /// - Parameter url: an app switch return URL - /// - Returns: `true` if the url represents a valid PayPal app switch return - static func isValid(_ url: URL) -> Bool { - url.scheme == "https" && (url.path.contains("cancel") || url.path.contains("success")) - } -} diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index 178407692f..000560e782 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -182,17 +182,30 @@ import BraintreeDataCollector payPalContextID: payPalContextID, payPalInstalled: payPalAppInstalled ) - guard let url, isValidURLAction(url: url) else { + + guard let url, BTPayPalReturnURL.isValidURLAction(url: url, linkType: linkType) else { notifyFailure(with: BTPayPalError.invalidURLAction, completion: completion) return } - guard let response = responseDictionary(from: url) else { + guard let action = BTPayPalReturnURL.action(from: url), action != "cancel" else { notifyCancel(completion: completion) return } - - var account: [String: Any] = response + + let clientDictionary: [String: String] = [ + "platform": "iOS", + "product_name": "PayPal", + "paypal_sdk_version": "version" + ] + + let responseDictionary: [String: String] = ["webURL": url.absoluteString] + + var account: [String: Any] = [ + "client": clientDictionary, + "response": responseDictionary, + "response_type": "web" + ] if paymentType == .checkout { account["options"] = ["validate": false] @@ -256,7 +269,7 @@ import BraintreeDataCollector // MARK: - App Switch Methods func handleReturnURL(_ url: URL) { - guard let returnURL = BTPayPalAppSwitchReturnURL(url: url) else { + guard let returnURL = BTPayPalReturnURL(.payPalApp(url: url)) else { notifyFailure(with: BTPayPalError.invalidURL("App Switch return URL cannot be nil"), completion: appSwitchCompletion) return } @@ -409,7 +422,17 @@ import BraintreeDataCollector return } - handleReturn(url, paymentType: paymentType, completion: completion) + guard let url, let returnURL = BTPayPalReturnURL(.webBrowser(url: url)) else { + notifyFailure(with: BTPayPalError.invalidURL("ASWebAuthenticationSession return URL cannot be nil"), completion: completion) + return + } + + switch returnURL.state { + case .succeeded, .canceled: + handleReturn(url, paymentType: .vault, completion: completion) + case .unknownPath: + notifyFailure(with: BTPayPalError.asWebAuthenticationSessionURLInvalid(url.absoluteString), completion: completion) + } } sessionDidAppear: { [self] didAppear in if didAppear { apiClient.sendAnalyticsEvent( @@ -443,65 +466,6 @@ import BraintreeDataCollector return } } - - private func isValidURLAction(url: URL) -> Bool { - guard let host = url.host, let scheme = url.scheme, !scheme.isEmpty else { - return false - } - - var hostAndPath = host - .appending(url.path) - .components(separatedBy: "/") - .dropLast(1) // remove the action (`success`, `cancel`, etc) - .joined(separator: "/") - - if hostAndPath.count > 0 { - hostAndPath.append("/") - } - - if hostAndPath != BTPayPalRequest.callbackURLHostAndPath && (payPalRequest as? BTPayPalVaultRequest)?.universalLink == nil { - return false - } - - guard let action = action(from: url), - let query = url.query, - query.count > 0, - action.count >= 0, - ["success", "cancel", "authenticate"].contains(action) else { - return false - } - - return true - } - - private func responseDictionary(from url: URL) -> [String : Any]? { - if let action = action(from: url), action == "cancel" { - return nil - } - - let clientDictionary: [String: String] = [ - "platform": "iOS", - "product_name": "PayPal", - "paypal_sdk_version": "version" - ] - - let responseDictionary: [String: String] = ["webURL": url.absoluteString] - - return [ - "client": clientDictionary, - "response": responseDictionary, - "response_type": "web" - ] - } - - private func action(from url: URL) -> String? { - guard let action = url.lastPathComponent.components(separatedBy: "?").first, - !action.isEmpty else { - return url.host - } - - return action - } // MARK: - Analytics Helper Methods @@ -554,6 +518,6 @@ extension BTPayPalClient: BTAppContextSwitchClient { /// :nodoc: @_documentation(visibility: private) @objc public static func canHandleReturnURL(_ url: URL) -> Bool { - BTPayPalAppSwitchReturnURL.isValid(url) + BTPayPalReturnURL.isValid(url) } } diff --git a/Sources/BraintreePayPal/BTPayPalReturnURL.swift b/Sources/BraintreePayPal/BTPayPalReturnURL.swift new file mode 100644 index 0000000000..ab4e964133 --- /dev/null +++ b/Sources/BraintreePayPal/BTPayPalReturnURL.swift @@ -0,0 +1,84 @@ +import Foundation + +#if canImport(BraintreeCore) +import BraintreeCore +#endif + +enum BTPayPalReturnURLState { + case unknownPath + case succeeded + case canceled +} + +/// This class interprets URLs received from the PayPal app via app switch returns and web returns via ASWebAuthenticationSession. +/// +/// PayPal app switch and ASWebAuthenticationSession authorization requests should result in success or user-initiated cancelation. These states are communicated in the url. +struct BTPayPalReturnURL { + + /// The overall status of the app switch - success, cancelation, or an unknown path + var state: BTPayPalReturnURLState = .unknownPath + + /// Initializes a new `BTPayPalReturnURL` + /// - Parameter url: an incoming app switch or ASWebAuthenticationSession url + init?(_ redirectType: PayPalRedirectType) { + switch redirectType { + case .payPalApp(let url), .webBrowser(let url): + if url.path.contains("success") { + state = .succeeded + } else if url.path.contains("cancel") { + state = .canceled + } else { + state = .unknownPath + } + } + } + + // MARK: - Static Methods + + /// Evaluates whether the url represents a valid PayPal return URL. + /// - Parameter url: an app switch or ASWebAuthenticationSession return URL + /// - Returns: `true` if the url represents a valid PayPal app switch return + static func isValid(_ url: URL) -> Bool { + url.scheme == "https" && (url.path.contains("cancel") || url.path.contains("success")) + } + + static func isValidURLAction(url: URL, linkType: String?) -> Bool { + guard let host = url.host, let scheme = url.scheme, !scheme.isEmpty else { + return false + } + + var hostAndPath = host + .appending(url.path) + .components(separatedBy: "/") + .dropLast(1) // remove the action (`success`, `cancel`, etc) + .joined(separator: "/") + + if hostAndPath.count > 0 { + hostAndPath.append("/") + } + + /// If we are using the deeplink/ASWeb based PayPal flow we want to check that the host and path matches + /// the static callbackURLHostAndPath. For the universal link flow we do not care about this check. + if hostAndPath != BTPayPalRequest.callbackURLHostAndPath && linkType == "deeplink" { + return false + } + + guard let action = action(from: url), + let query = url.query, + query.count > 0, + action.count >= 0, + ["success", "cancel", "authenticate"].contains(action) else { + return false + } + + return true + } + + static func action(from url: URL) -> String? { + guard let action = url.lastPathComponent.components(separatedBy: "?").first, !action.isEmpty else { + return url.host + } + + return action + } +} diff --git a/UnitTests/BraintreePayPalTests/BTPayPalAppSwitchReturnURL_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalAppSwitchReturnURL_Tests.swift deleted file mode 100644 index 472b9b1ac0..0000000000 --- a/UnitTests/BraintreePayPalTests/BTPayPalAppSwitchReturnURL_Tests.swift +++ /dev/null @@ -1,25 +0,0 @@ -import XCTest -@testable import BraintreePayPal - -final class BTPayPalAppSwitchReturnURL_Tests: XCTestCase { - - func testInitWithURL_whenSuccessReturnURL_createsValuesAndSetsSuccessState() { - let returnURL = BTPayPalAppSwitchReturnURL(url: URL(string: "https://www.merchant-app.com/merchant-path/success?token=A_FAKE_EC_TOKEN&ba_token=A_FAKE_BA_TOKEN&switch_initiated_time=1234567890")!) - XCTAssertEqual(returnURL?.state, .succeeded) - } - - func testInitWithURL_whenSuccessReturnURLWithoutToken_createsValuesAndSetsSuccessState() { - let returnURL = BTPayPalAppSwitchReturnURL(url: URL(string: "https://www.merchant-app.com/merchant-path/success?ba_token=A_FAKE_BA_TOKEN&switch_initiated_time=1234567890")!) - XCTAssertEqual(returnURL?.state, .succeeded) - } - - func testInitWithURL_whenCancelURLWithoutToken_setsCancelState() { - let returnURL = BTPayPalAppSwitchReturnURL(url: URL(string: "https://www.merchant-app.com/merchant-path/cancel?ba_token=A_FAKE_BA_TOKEN&switch_initiated_time=1234567890")!) - XCTAssertEqual(returnURL?.state, .canceled) - } - - func testInitWithURL_whenUnknownURLWithoutToken_setsUnknownState() { - let returnURL = BTPayPalAppSwitchReturnURL(url: URL(string: "https://www.merchant-app.com/merchant-path/garbage-url")!) - XCTAssertEqual(returnURL?.state, .unknownPath) - } -} diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index 0b1d3a48f3..a2dd4ea608 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -218,7 +218,9 @@ class BTPayPalClient_Tests: XCTestCase { ] ]) - payPalClient.webAuthenticationSession = MockWebAuthenticationSession() + let mockWebAuthenticationSession = MockWebAuthenticationSession() + mockWebAuthenticationSession.cannedResponseURL = URL(string: "https://www.paypal.com/checkout/success") + payPalClient.webAuthenticationSession = mockWebAuthenticationSession let request = BTPayPalCheckoutRequest(amount: "1") payPalClient.tokenize(request) { _, _ in } @@ -278,7 +280,9 @@ class BTPayPalClient_Tests: XCTestCase { ] ]) - payPalClient.webAuthenticationSession = MockWebAuthenticationSession() + let mockWebAuthenticationSession = MockWebAuthenticationSession() + mockWebAuthenticationSession.cannedResponseURL = URL(string: "https://www.paypal.com/checkout/success") + payPalClient.webAuthenticationSession = mockWebAuthenticationSession let request = BTPayPalCheckoutRequest(amount: "1") payPalClient.tokenize(request) { _, _ in } @@ -286,6 +290,7 @@ class BTPayPalClient_Tests: XCTestCase { XCTAssertEqual(mockAPIClient.postedPayPalContextID, "BA-Random-Value") XCTAssertEqual(mockAPIClient.postedLinkType, "deeplink") XCTAssertEqual(mockAPIClient.postedPayPalAppInstalled, "false") + XCTAssertTrue(mockAPIClient.postedAnalyticsEvents.contains("paypal:tokenize:handle-return:started")) } // MARK: - Browser switch diff --git a/UnitTests/BraintreePayPalTests/BTPayPalReturnURL_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalReturnURL_Tests.swift new file mode 100644 index 0000000000..1e5bf9fcf6 --- /dev/null +++ b/UnitTests/BraintreePayPalTests/BTPayPalReturnURL_Tests.swift @@ -0,0 +1,40 @@ +import XCTest +@testable import BraintreePayPal + +final class BTPayPalReturnURL_Tests: XCTestCase { + + func testInitWithURL_whenSuccessReturnURL_setsSuccessState() { + let returnURL = BTPayPalReturnURL(.payPalApp(url: URL(string: "https://www.merchant-app.com/merchant-path/success?token=A_FAKE_EC_TOKEN&ba_token=A_FAKE_BA_TOKEN&switch_initiated_time=1234567890")!)) + XCTAssertEqual(returnURL?.state, .succeeded) + } + + func testInitWithURL_whenSuccessReturnURLWithoutToken_setsSuccessState() { + let returnURL = BTPayPalReturnURL(.payPalApp(url: URL(string: "https://www.merchant-app.com/merchant-path/success?ba_token=A_FAKE_BA_TOKEN&switch_initiated_time=1234567890")!)) + XCTAssertEqual(returnURL?.state, .succeeded) + } + + func testInitWithURL_whenCancelURLWithoutToken_setsCancelState() { + let returnURL = BTPayPalReturnURL(.payPalApp(url: URL(string: "https://www.merchant-app.com/merchant-path/cancel?ba_token=A_FAKE_BA_TOKEN&switch_initiated_time=1234567890")!)) + XCTAssertEqual(returnURL?.state, .canceled) + } + + func testInitWithURL_whenUnknownURLWithoutToken_setsUnknownState() { + let returnURL = BTPayPalReturnURL(.payPalApp(url: URL(string: "https://www.merchant-app.com/merchant-path/garbage-url")!)) + XCTAssertEqual(returnURL?.state, .unknownPath) + } + + func testInitWithSchemeURL_whenSuccessReturnURL_setsSuccessState() { + let returnURL = BTPayPalReturnURL(.payPalApp(url: URL(string: "bar://onetouch/v1/success?token=hermes_token")!)) + XCTAssertEqual(returnURL?.state, .succeeded) + } + + func testInitWithSchemeURL_whenCancelURLWithoutToken_setsCancelState() { + let returnURL = BTPayPalReturnURL(.payPalApp(url: URL(string: "bar://onetouch/v1/cancel?token=hermes_token")!)) + XCTAssertEqual(returnURL?.state, .canceled) + } + + func testInitWithSchemeURL_whenUnknownURLWithoutToken_setsUnknownState() { + let returnURL = BTPayPalReturnURL(.payPalApp(url: URL(string: "bar://onetouch/v1/invalid")!)) + XCTAssertEqual(returnURL?.state, .unknownPath) + } +} From 761fdce14760455129c864a7970d48989a80226c Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Thu, 11 Apr 2024 16:15:16 -0500 Subject: [PATCH 20/32] [QL] Allow Nonce to be Copied for E2E Testing (#1272) * print nonce to status bar and allow to be copied for E2E testing * clear nonce between sessions after copied --- Demo/Application/Base/BaseViewController.swift | 1 + .../Base/ContainmentViewController.swift | 18 ++++++++++++++++++ .../PayPalWebCheckoutViewController.swift | 5 ++++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Demo/Application/Base/BaseViewController.swift b/Demo/Application/Base/BaseViewController.swift index 7d7df4287d..df5b727794 100644 --- a/Demo/Application/Base/BaseViewController.swift +++ b/Demo/Application/Base/BaseViewController.swift @@ -5,6 +5,7 @@ class BaseViewController: UIViewController { var progressBlock: ((String?) -> Void) = { _ in } var completionBlock: ((BTPaymentMethodNonce?) -> Void) = { _ in } + var nonceCompletionBlock: ((BTPaymentMethodNonce?) -> Void) = { _ in } init(authorization: String) { super.init(nibName: nil, bundle: nil) diff --git a/Demo/Application/Base/ContainmentViewController.swift b/Demo/Application/Base/ContainmentViewController.swift index 716519e6a4..4bbebbddea 100644 --- a/Demo/Application/Base/ContainmentViewController.swift +++ b/Demo/Application/Base/ContainmentViewController.swift @@ -18,6 +18,7 @@ class ContainmentViewController: UIViewController { updateStatus("Presenting \(type(of: currentViewController))") currentViewController.progressBlock = progressBlock currentViewController.completionBlock = completionBlock + currentViewController.nonceCompletionBlock = nonceCompletionBlock appendViewController(currentViewController) title = currentViewController.title @@ -30,6 +31,12 @@ class ContainmentViewController: UIViewController { } } + private var copiedNonce: BTPaymentMethodNonce? { + didSet { + statusItem?.isEnabled = (copiedNonce != nil) + } + } + // MARK: - Progress and Completion Blocks func progressBlock(_ status: String?) { @@ -42,6 +49,11 @@ class ContainmentViewController: UIViewController { updateStatus("Got a nonce. Tap to make a transaction.") } + func nonceCompletionBlock(_ nonce: BTPaymentMethodNonce?) { + copiedNonce = nonce + updateStatus(copiedNonce?.nonce ?? "no nonce returned") + } + override func viewDidLoad() { super.viewDidLoad() @@ -107,6 +119,12 @@ class ContainmentViewController: UIViewController { @objc private func tappedStatus() { print("Tapped status!") + if let copiedNonce { + UIPasteboard.general.string = copiedNonce.nonce + self.copiedNonce = nil + return + } + if let currentPaymentMethodNonce { let nonce = currentPaymentMethodNonce.nonce updateStatus("Creating Transaction…") diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index e584878944..a162c196c0 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -96,6 +96,9 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { } @objc func tappedPayPalAppSwitchFlow(_ sender: UIButton) { + sender.setTitle("Processing...", for: .disabled) + sender.isEnabled = false + let payPalClient = BTPayPalClient(apiClient: BTAPIClient(authorization: "sandbox_jy4fvpfg_v7x2rb226dx4pr7b")!) let request = BTPayPalVaultRequest( userAuthenticationEmail: "sally@gmail.com", @@ -119,7 +122,7 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { return } - self.completionBlock(nonce) + self.nonceCompletionBlock(nonce) } } From 2f15804fe77a3e60aecc38e23416f5cb98f6f991 Mon Sep 17 00:00:00 2001 From: scannillo <35243507+scannillo@users.noreply.github.com> Date: Mon, 15 Apr 2024 10:46:51 -0500 Subject: [PATCH 21/32] [QL] Add email text field to PP Web Demo (#1273) --- .../PaymentButtonBaseViewController.swift | 2 +- .../PayPalWebCheckoutViewController.swift | 69 ++++++++++++++----- 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/Demo/Application/Base/PaymentButtonBaseViewController.swift b/Demo/Application/Base/PaymentButtonBaseViewController.swift index 2e01be1527..3c3dcc167a 100644 --- a/Demo/Application/Base/PaymentButtonBaseViewController.swift +++ b/Demo/Application/Base/PaymentButtonBaseViewController.swift @@ -29,7 +29,7 @@ class PaymentButtonBaseViewController: BaseViewController { paymentButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), paymentButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), paymentButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), - paymentButton.heightAnchor.constraint(equalToConstant: 150) + paymentButton.heightAnchor.constraint(equalToConstant: 300) ]) } diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index a162c196c0..adeb2229e8 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -6,6 +6,13 @@ import BraintreeCore class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { lazy var payPalClient = BTPayPalClient(apiClient: apiClient) + + lazy var emailTextField: UITextField = { + let textField = UITextField() + textField.placeholder = "placeholder@email.com" + textField.backgroundColor = .systemBackground + return textField + }() // TODO: remove UILabel before merging into main DTBTSDK-3766 let baTokenLabel = UILabel() @@ -14,7 +21,7 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { let payPalCheckoutButton = createButton(title: "PayPal Checkout", action: #selector(tappedPayPalCheckout)) let payPalVaultButton = createButton(title: "PayPal Vault", action: #selector(tappedPayPalVault)) let payPalPayLaterButton = createButton(title: "PayPal with Pay Later Offered", action: #selector(tappedPayPalPayLater)) - let payPalAppSwitchButton = createButton(title: "PayPal App Switch Flow", action: #selector(tappedPayPalAppSwitchFlow)) + let payPalAppSwitchButton = createButton(title: "PayPal App Switch", action: #selector(tappedPayPalAppSwitch)) // TODO: remove tapGesture before merging into main DTBTSDK-3766 let tapGesture = UITapGestureRecognizer(target: self, action: #selector(labelTapped)) @@ -22,15 +29,19 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { baTokenLabel.addGestureRecognizer(tapGesture) baTokenLabel.textColor = .systemPink - let buttons = [payPalCheckoutButton, payPalVaultButton, payPalPayLaterButton, payPalAppSwitchButton, baTokenLabel] - let stackView = UIStackView(arrangedSubviews: buttons) + let stackView = UIStackView(arrangedSubviews: [ + buttonsStackView(label: "1-Time Checkout Flows", views: [payPalCheckoutButton, payPalPayLaterButton]), + buttonsStackView(label: "Vault Flows",views: [emailTextField, payPalVaultButton, payPalAppSwitchButton]) + ]) + stackView.axis = .vertical - stackView.alignment = .center - stackView.distribution = .fillEqually + stackView.distribution = .fillProportionally + stackView.spacing = 25 stackView.translatesAutoresizingMaskIntoConstraints = false - return stackView } + + // MARK: - 1-Time Checkout Flows @objc func tappedPayPalCheckout(_ sender: UIButton) { progressBlock("Tapped PayPal - Checkout using BTPayPalClient") @@ -55,13 +66,14 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { self.completionBlock(nonce) } } - - @objc func tappedPayPalVault(_ sender: UIButton) { - progressBlock("Tapped PayPal - Vault using BTPayPalClient") + + @objc func tappedPayPalPayLater(_ sender: UIButton) { + progressBlock("Tapped PayPal - initiating with Pay Later offered") sender.setTitle("Processing...", for: .disabled) sender.isEnabled = false - let request = BTPayPalVaultRequest() + let request = BTPayPalCheckoutRequest(amount: "4.30") + request.offerPayLater = true payPalClient.tokenize(request) { nonce, error in sender.isEnabled = true @@ -74,14 +86,16 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { self.completionBlock(nonce) } } - - @objc func tappedPayPalPayLater(_ sender: UIButton) { - progressBlock("Tapped PayPal - initiating with Pay Later offered") + + // MARK: - Vault Flows + + @objc func tappedPayPalVault(_ sender: UIButton) { + progressBlock("Tapped PayPal - Vault using BTPayPalClient") sender.setTitle("Processing...", for: .disabled) sender.isEnabled = false - let request = BTPayPalCheckoutRequest(amount: "4.30") - request.offerPayLater = true + let request = BTPayPalVaultRequest() + request.userAuthenticationEmail = emailTextField.text payPalClient.tokenize(request) { nonce, error in sender.isEnabled = true @@ -95,13 +109,18 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { } } - @objc func tappedPayPalAppSwitchFlow(_ sender: UIButton) { + @objc func tappedPayPalAppSwitch(_ sender: UIButton) { sender.setTitle("Processing...", for: .disabled) sender.isEnabled = false + guard let userEmail = emailTextField.text, !userEmail.isEmpty else { + self.progressBlock("Email cannot be nil for App Switch flow") + return + } + let payPalClient = BTPayPalClient(apiClient: BTAPIClient(authorization: "sandbox_jy4fvpfg_v7x2rb226dx4pr7b")!) let request = BTPayPalVaultRequest( - userAuthenticationEmail: "sally@gmail.com", + userAuthenticationEmail: userEmail, enablePayPalAppSwitch: true, universalLink: URL(string: "https://braintree-ios-demo.fly.dev/braintree-payments")! ) @@ -125,6 +144,22 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { self.nonceCompletionBlock(nonce) } } + + // MARK: - Helpers + + private func buttonsStackView(label: String, views: [UIView]) -> UIStackView { + let titleLabel = UILabel() + titleLabel.text = label + + let buttonsStackView = UIStackView(arrangedSubviews: [titleLabel] + views) + buttonsStackView.axis = .vertical + buttonsStackView.distribution = .fillProportionally + buttonsStackView.backgroundColor = .systemGray6 + buttonsStackView.layoutMargins = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) + buttonsStackView.isLayoutMarginsRelativeArrangement = true + + return buttonsStackView + } // TODO: remove labelTapped and receivedNotification before merging into main DTBTSDK-3766 From fbad77422188ee0460af914136f677f7142f9012 Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Wed, 17 Apr 2024 10:58:14 -0500 Subject: [PATCH 22/32] add BA token back to ViewController (#1275) --- .../Features/PayPalWebCheckoutViewController.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index adeb2229e8..6f04630dcc 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -31,7 +31,8 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { let stackView = UIStackView(arrangedSubviews: [ buttonsStackView(label: "1-Time Checkout Flows", views: [payPalCheckoutButton, payPalPayLaterButton]), - buttonsStackView(label: "Vault Flows",views: [emailTextField, payPalVaultButton, payPalAppSwitchButton]) + buttonsStackView(label: "Vault Flows",views: [emailTextField, payPalVaultButton, payPalAppSwitchButton]), + baTokenLabel ]) stackView.axis = .vertical @@ -115,6 +116,7 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { guard let userEmail = emailTextField.text, !userEmail.isEmpty else { self.progressBlock("Email cannot be nil for App Switch flow") + sender.isEnabled = true return } From dc8ffbaef6755f2a04d4dee910ec889558bb9573 Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Mon, 22 Apr 2024 12:51:08 -0500 Subject: [PATCH 23/32] update app switch scheme in BTPayPalClient and Info.plist (#1278) --- Demo/Application/Supporting Files/Braintree-Demo-Info.plist | 2 +- Sources/BraintreePayPal/BTPayPalClient.swift | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Demo/Application/Supporting Files/Braintree-Demo-Info.plist b/Demo/Application/Supporting Files/Braintree-Demo-Info.plist index 37e15f70b4..4c239927f4 100644 --- a/Demo/Application/Supporting Files/Braintree-Demo-Info.plist +++ b/Demo/Application/Supporting Files/Braintree-Demo-Info.plist @@ -61,7 +61,7 @@ com.braintreepayments.Demo.payments com.venmo.touch.v2 - paypal-in-app-checkout + paypal-app-switch-checkout LSRequiresIPhoneOS diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index 000560e782..defc28d4d1 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -48,8 +48,8 @@ import BraintreeDataCollector // MARK: - Private Properties - /// URL Scheme for PayPal In-App Checkout - private let payPalInAppScheme: String = "paypal-in-app-checkout://" + /// URL Scheme for PayPal App Switch Checkout + private let payPalAppSwitchScheme: String = "paypal-app-switch-checkout://" /// Indicates if the user returned back to the merchant app from the `BTWebAuthenticationSession` /// Will only be `true` if the user proceed through the `UIAlertController` @@ -351,7 +351,7 @@ import BraintreeDataCollector } private func isPayPalAppInstalled() -> Bool { - guard let paypalURL = URL(string: payPalInAppScheme) else { + guard let paypalURL = URL(string: payPalAppSwitchScheme) else { return false } return application.canOpenURL(paypalURL) From e50ac38847374dc28d5166104c9925fc1c63080b Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Wed, 24 Apr 2024 11:21:06 -0500 Subject: [PATCH 24/32] [QL] Stricter Logic For BA Token for App Switch (#1274) * Previous PR comment: https://github.com/braintree/braintree_ios/pull/1265/files#r1562730080 * For the PayPal App Switch flow we only ever want a BA token currently, separate out logic to check only for BA token in this flow * Check that BA Token is present before attempting to start the App Switch flow, if not throw an error * Add new BTPayPalError.missingBAToken * Add unit test for this updated logic --- Braintree.xcodeproj/project.pbxproj | 2 +- .../BTPayPalApprovalURLParser.swift | 45 ++++++++++------ Sources/BraintreePayPal/BTPayPalClient.swift | 17 +++--- Sources/BraintreePayPal/BTPayPalError.swift | 7 +++ .../BTPayPalClient_Tests.swift | 52 ++++++++++++++++++- 5 files changed, 98 insertions(+), 25 deletions(-) diff --git a/Braintree.xcodeproj/project.pbxproj b/Braintree.xcodeproj/project.pbxproj index 34feb8f4a4..6ee5c07597 100644 --- a/Braintree.xcodeproj/project.pbxproj +++ b/Braintree.xcodeproj/project.pbxproj @@ -1713,11 +1713,11 @@ A95229C624FD949D006F7D25 /* BTConfiguration+PayPal_Tests.swift */, BEDEAF102AC1D049004EA970 /* BTPayPalAccountNonce_Tests.swift */, 3B7A261229C35B670087059D /* BTPayPalAnalytics_Tests.swift */, - BEBA590E2BB1B5B9005FA8A2 /* BTPayPalReturnURL_Tests.swift */, 42FC237025CE0E110047C49A /* BTPayPalCheckoutRequest_Tests.swift */, 427F32DF25D1D62D00435294 /* BTPayPalClient_Tests.swift */, BECB10C52B5999EE008D398E /* BTPayPalLineItem_Tests.swift */, 42FC218A25CDE0290047C49A /* BTPayPalRequest_Tests.swift */, + BEBA590E2BB1B5B9005FA8A2 /* BTPayPalReturnURL_Tests.swift */, 427F328F25D1A7B900435294 /* BTPayPalVaultRequest_Tests.swift */, A9E5C1E424FD665D00EE691F /* Info.plist */, ); diff --git a/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift b/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift index 9e8e187277..cf0c28a820 100644 --- a/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift +++ b/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift @@ -5,7 +5,7 @@ import BraintreeCore #endif /// The type of PayPal authentication flow to occur -enum PayPalRedirectType { +enum PayPalRedirectType: Equatable { /// The in-app browser (ASWebAuthenticationSession) web checkout flow case webBrowser(url: URL) @@ -17,31 +17,42 @@ enum PayPalRedirectType { /// Parses response body from `/v1/paypal_hermes/*` POST requests to determine the `PayPalRedirectType` struct BTPayPalApprovalURLParser { - var redirectType: PayPalRedirectType - - var pairingID: String? { - switch redirectType { - case .webBrowser(let url), .payPalApp(let url): - let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: true)? - .queryItems? - .compactMap { $0 } - - if let baToken = queryItems?.filter({ $0.name == "ba_token" }).first?.value, !baToken.isEmpty { - return baToken - } else if let ecToken = queryItems?.filter({ $0.name == "token" }).first?.value, !ecToken.isEmpty { - return ecToken - } + let redirectType: PayPalRedirectType - return nil + private let url: URL + + var ecToken: String? { + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: true)? + .queryItems? + .compactMap { $0 } + + if let ecToken = queryItems?.filter({ $0.name == "token" }).first?.value, !ecToken.isEmpty { + return ecToken } + + return nil } - + + var baToken: String? { + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: true)? + .queryItems? + .compactMap { $0 } + + if let baToken = queryItems?.filter({ $0.name == "ba_token" }).first?.value, !baToken.isEmpty { + return baToken + } + + return nil + } + init?(body: BTJSON, linkType: String?) { if linkType == "universal", let payPalAppRedirectURL = body["agreementSetup"]["paypalAppApprovalUrl"].asURL() { redirectType = .payPalApp(url: payPalAppRedirectURL) + url = payPalAppRedirectURL } else if let approvalURL = body["paymentResource"]["redirectUrl"].asURL() ?? body["agreementSetup"]["approvalUrl"].asURL() { redirectType = .webBrowser(url: approvalURL) + url = approvalURL } else { return nil } diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index defc28d4d1..5af34c8f63 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -332,17 +332,22 @@ import BraintreeDataCollector return } - self.payPalContextID = approvalURL.pairingID + self.payPalContextID = approvalURL.baToken ?? approvalURL.ecToken // TODO: remove NotificationCenter before merging into main DTBTSDK-3766 NotificationCenter.default.post(name: Notification.Name("BAToken"), object: self.payPalContextID) let dataCollector = BTDataCollector(apiClient: self.apiClient) - self.clientMetadataID = self.payPalRequest?.riskCorrelationID ?? dataCollector.clientMetadataID(approvalURL.pairingID) - + self.clientMetadataID = self.payPalRequest?.riskCorrelationID ?? dataCollector.clientMetadataID(self.payPalContextID) + switch approvalURL.redirectType { case .payPalApp(let url): - self.launchPayPalApp(with: url, completion: completion) + guard let baToken = approvalURL.baToken else { + self.notifyFailure(with: BTPayPalError.missingBAToken, completion: completion) + return + } + + self.launchPayPalApp(with: url, baToken: baToken, completion: completion) case .webBrowser(let url): self.handlePayPalRequest(with: url, paymentType: request.paymentType, completion: completion) } @@ -357,7 +362,7 @@ import BraintreeDataCollector return application.canOpenURL(paypalURL) } - private func launchPayPalApp(with payPalAppRedirectURL: URL, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void) { + private func launchPayPalApp(with payPalAppRedirectURL: URL, baToken: String, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void) { apiClient.sendAnalyticsEvent( BTPayPalAnalytics.appSwitchStarted, linkType: linkType, @@ -367,7 +372,7 @@ import BraintreeDataCollector var urlComponents = URLComponents(url: payPalAppRedirectURL, resolvingAgainstBaseURL: true) urlComponents?.queryItems = [ - URLQueryItem(name: "ba_token", value: payPalContextID), + URLQueryItem(name: "ba_token", value: baToken), URLQueryItem(name: "source", value: "braintree_sdk"), URLQueryItem(name: "switch_initiated_time", value: String(Int(round(Date().timeIntervalSince1970 * 1000)))) ] diff --git a/Sources/BraintreePayPal/BTPayPalError.swift b/Sources/BraintreePayPal/BTPayPalError.swift index a2abebd429..a35eea1e73 100644 --- a/Sources/BraintreePayPal/BTPayPalError.swift +++ b/Sources/BraintreePayPal/BTPayPalError.swift @@ -39,6 +39,9 @@ public enum BTPayPalError: Error, CustomNSError, LocalizedError, Equatable { /// 11. App Switch could not complete case appSwitchFailed + /// 12. Missing BA Token for App Switch + case missingBAToken + public static var errorDomain: String { "com.braintreepayments.BTPayPalErrorDomain" } @@ -69,6 +72,8 @@ public enum BTPayPalError: Error, CustomNSError, LocalizedError, Equatable { return 10 case .appSwitchFailed: return 11 + case .missingBAToken: + return 12 } } @@ -98,6 +103,8 @@ public enum BTPayPalError: Error, CustomNSError, LocalizedError, Equatable { return "The App Switch return URL did not contain the cancel or success path." case .appSwitchFailed: return "UIApplication failed to perform app switch to PayPal." + case .missingBAToken: + return "Missing BA Token for PayPal App Switch." } } diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index a2dd4ea608..2e5bf5c583 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -293,6 +293,26 @@ class BTPayPalClient_Tests: XCTestCase { XCTAssertTrue(mockAPIClient.postedAnalyticsEvents.contains("paypal:tokenize:handle-return:started")) } + func testTokenize_whenApprovalUrlContainsBAToken_sendsBATokenAsPayPalContextIDInAnalytics() { + mockAPIClient.cannedResponseBody = BTJSON(value: [ + "agreementSetup": [ + "approvalUrl": "https://www.paypal.com/agreements/approve?ba_token=A_FAKE_BA_TOKEN" + ] + ]) + + let mockWebAuthenticationSession = MockWebAuthenticationSession() + mockWebAuthenticationSession.cannedResponseURL = URL(string: "sdk.ios.braintree://onetouch/v1/success") + payPalClient.webAuthenticationSession = mockWebAuthenticationSession + + let request = BTPayPalVaultRequest() + payPalClient.tokenize(request) { _, _ in } + + XCTAssertEqual(mockAPIClient.postedPayPalContextID, "A_FAKE_BA_TOKEN") + XCTAssertEqual(mockAPIClient.postedLinkType, "deeplink") + XCTAssertEqual(mockAPIClient.postedPayPalAppInstalled, "false") + XCTAssertTrue(mockAPIClient.postedAnalyticsEvents.contains("paypal:tokenize:handle-return:started")) + } + // MARK: - Browser switch func testTokenizePayPalAccount_whenPayPalPayLaterOffered_performsSwitchCorrectly() { @@ -761,7 +781,37 @@ class BTPayPalClient_Tests: XCTestCase { XCTFail("Expected integer value for query param `switch_initiated_time`") } } - + + func testTokenizeVaultAccount_whenPayPalAppApprovalURLMissingBAToken_returnsError() { + let fakeApplication = FakeApplication() + payPalClient.application = fakeApplication + + mockAPIClient.cannedResponseBody = BTJSON(value: [ + "agreementSetup": [ + "paypalAppApprovalUrl": "https://www.some-url.com/some-path?token=value1" + ] + ]) + + let vaultRequest = BTPayPalVaultRequest( + userAuthenticationEmail: "fake@gmail.com", + enablePayPalAppSwitch: true, + universalLink: URL(string: "https://paypal.com")! + ) + + let expectation = expectation(description: "completion block called") + payPalClient.tokenize(vaultRequest) { nonce, error in + XCTAssertNil(nonce) + + guard let error = error as NSError? else { XCTFail(); return } + XCTAssertEqual(error.code, 12) + XCTAssertEqual(error.localizedDescription, "Missing BA Token for PayPal App Switch.") + XCTAssertEqual(error.domain, "com.braintreepayments.BTPayPalErrorDomain") + expectation.fulfill() + } + + waitForExpectations(timeout: 1) + } + func testTokenizeVaultAccount_whenOpenURLReturnsFalse_returnsError() { let fakeApplication = FakeApplication() fakeApplication.cannedOpenURLSuccess = false From 51fb5a4d42086fa5c3e9d05e615b703cbca4081b Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Mon, 20 May 2024 08:48:16 -0500 Subject: [PATCH 25/32] add missing comma in FakeAnalyticsService --- .../BraintreeCoreTests/Analytics/FakeAnalyticsService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnitTests/BraintreeCoreTests/Analytics/FakeAnalyticsService.swift b/UnitTests/BraintreeCoreTests/Analytics/FakeAnalyticsService.swift index b4a8a7a624..381d0d9efd 100644 --- a/UnitTests/BraintreeCoreTests/Analytics/FakeAnalyticsService.swift +++ b/UnitTests/BraintreeCoreTests/Analytics/FakeAnalyticsService.swift @@ -13,7 +13,7 @@ class FakeAnalyticsService: BTAnalyticsService { errorDescription: String? = nil, linkType: String? = nil, payPalContextID: String? = nil, - payPalInstalled: String? = nil + payPalInstalled: String? = nil, startTime: Int? = nil ) { self.lastEvent = eventName From 7801595d72337bdd6b220007f0e90f4cbaff1f7f Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Mon, 20 May 2024 09:18:15 -0500 Subject: [PATCH 26/32] [QL] Replace App Switch Universal Link URL (#1309) * replace universal link URL with heroku URL * remove old URL from Demo.entitlements --- Demo/Application/Features/PayPalWebCheckoutViewController.swift | 2 +- Demo/Demo.entitlements | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index 6f04630dcc..d171e83b75 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -124,7 +124,7 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { let request = BTPayPalVaultRequest( userAuthenticationEmail: userEmail, enablePayPalAppSwitch: true, - universalLink: URL(string: "https://braintree-ios-demo.fly.dev/braintree-payments")! + universalLink: URL(string: "https://mobile-sdk-demo-site-838cead5d3ab.herokuapp.com/braintree-payments")! ) // TODO: remove NotificationCenter before merging into main DTBTSDK-3766 diff --git a/Demo/Demo.entitlements b/Demo/Demo.entitlements index 10197140a7..56880ad6c0 100644 --- a/Demo/Demo.entitlements +++ b/Demo/Demo.entitlements @@ -4,7 +4,6 @@ com.apple.developer.associated-domains - applinks:braintree-ios-demo.fly.dev applinks:mobile-sdk-demo-site-838cead5d3ab.herokuapp.com com.apple.developer.in-app-payments From 0e667bc0e2675fbcc5afdbd15d1a51d98a7ba275 Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Mon, 20 May 2024 13:17:30 -0500 Subject: [PATCH 27/32] [QL] Enable App Switch Sandbox/Production Testing (#1310) * Add ability to test app switch in stage, production, and sandbox * Add "App Switch Staging Env" toggle for testing in the staging environment * Sandbox and Production are being rolled out today/tomorrow and we want the ability to continue to test stage as well as Sandbox/Production concurrently --- .../PayPalWebCheckoutViewController.swift | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index d171e83b75..0ecd22d27c 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -14,6 +14,15 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { return textField }() + lazy var appSwitchStageToggleLabel: UILabel = { + let label = UILabel() + label.text = "App Switch Staging Env" + label.font = .preferredFont(forTextStyle: .footnote) + return label + }() + + let appSwitchStageToggle = UISwitch() + // TODO: remove UILabel before merging into main DTBTSDK-3766 let baTokenLabel = UILabel() @@ -31,7 +40,15 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { let stackView = UIStackView(arrangedSubviews: [ buttonsStackView(label: "1-Time Checkout Flows", views: [payPalCheckoutButton, payPalPayLaterButton]), - buttonsStackView(label: "Vault Flows",views: [emailTextField, payPalVaultButton, payPalAppSwitchButton]), + buttonsStackView( + label: "Vault Flows", + views: [ + emailTextField, + payPalVaultButton, + payPalAppSwitchButton, + UIStackView(arrangedSubviews: [appSwitchStageToggleLabel, appSwitchStageToggle]) + ] + ), baTokenLabel ]) @@ -119,8 +136,7 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { sender.isEnabled = true return } - - let payPalClient = BTPayPalClient(apiClient: BTAPIClient(authorization: "sandbox_jy4fvpfg_v7x2rb226dx4pr7b")!) + let request = BTPayPalVaultRequest( userAuthenticationEmail: userEmail, enablePayPalAppSwitch: true, @@ -135,15 +151,30 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { object: nil ) - payPalClient.tokenize(request) { nonce, error in - sender.isEnabled = true + if appSwitchStageToggle.isOn { + let stagePayPalClient = BTPayPalClient(apiClient: BTAPIClient(authorization: "sandbox_jy4fvpfg_v7x2rb226dx4pr7b")!) - guard let nonce else { - self.progressBlock(error?.localizedDescription) - return + stagePayPalClient.tokenize(request) { nonce, error in + sender.isEnabled = true + + guard let nonce else { + self.progressBlock(error?.localizedDescription) + return + } + + self.nonceCompletionBlock(nonce) } + } else { + payPalClient.tokenize(request) { nonce, error in + sender.isEnabled = true + + guard let nonce else { + self.progressBlock(error?.localizedDescription) + return + } - self.nonceCompletionBlock(nonce) + self.completionBlock(nonce) + } } } From 9df01f00756f4ffdc70fe3b0b80778b3bd01185e Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Wed, 22 May 2024 11:06:11 -0500 Subject: [PATCH 28/32] [QL] Update PayPal App Installed Check (#1313) * update logic for payPalInstalled to align with venmo; update tests * move app installed checks into a UIApplication extension --- .../Analytics/BTAnalyticsService.swift | 4 -- .../Analytics/FPTIBatchData.swift | 16 +++---- Sources/BraintreeCore/BTAPIClient.swift | 6 +-- Sources/BraintreeCore/BTCoreConstants.swift | 6 ++- .../UIApplication+URLOpener.swift | 27 +++++++++++- Sources/BraintreePayPal/BTPayPalClient.swift | 44 +++++-------------- .../BTVenmoAppSwitchRedirectURL.swift | 2 +- .../Analytics/FPTIBatchData_Tests.swift | 4 -- .../Analytics/FakeAnalyticsService.swift | 1 - .../BTPayPalClient_Tests.swift | 5 --- .../BraintreeTestShared/FakeApplication.swift | 8 ++++ .../BraintreeTestShared/MockAPIClient.swift | 5 +-- 12 files changed, 59 insertions(+), 69 deletions(-) diff --git a/Sources/BraintreeCore/Analytics/BTAnalyticsService.swift b/Sources/BraintreeCore/Analytics/BTAnalyticsService.swift index b040e41b1d..f4011269ed 100644 --- a/Sources/BraintreeCore/Analytics/BTAnalyticsService.swift +++ b/Sources/BraintreeCore/Analytics/BTAnalyticsService.swift @@ -38,7 +38,6 @@ class BTAnalyticsService: Equatable { errorDescription: String? = nil, linkType: String? = nil, payPalContextID: String? = nil, - payPalInstalled: String? = nil, startTime: Int? = nil ) { Task(priority: .background) { @@ -50,7 +49,6 @@ class BTAnalyticsService: Equatable { errorDescription: errorDescription, linkType: linkType, payPalContextID: payPalContextID, - payPalInstalled: payPalInstalled, startTime: startTime ) } @@ -65,7 +63,6 @@ class BTAnalyticsService: Equatable { errorDescription: String? = nil, linkType: String? = nil, payPalContextID: String? = nil, - payPalInstalled: String? = nil, startTime: Int? = nil ) async { let timestampInMilliseconds = Date().utcTimestampMilliseconds @@ -77,7 +74,6 @@ class BTAnalyticsService: Equatable { eventName: eventName, linkType: linkType, payPalContextID: payPalContextID, - payPalInstalled: payPalInstalled, startTime: startTime, timestamp: String(timestampInMilliseconds) ) diff --git a/Sources/BraintreeCore/Analytics/FPTIBatchData.swift b/Sources/BraintreeCore/Analytics/FPTIBatchData.swift index 672dac22ae..af0038a634 100644 --- a/Sources/BraintreeCore/Analytics/FPTIBatchData.swift +++ b/Sources/BraintreeCore/Analytics/FPTIBatchData.swift @@ -1,4 +1,3 @@ -import Foundation import UIKit /// The POST body for a batch upload of FPTI events @@ -26,7 +25,9 @@ struct FPTIBatchData: Codable { /// Encapsulates a single event by it's name and timestamp. struct Event: Codable { - + + static var application: URLOpener = UIApplication.shared + let correlationID: String? let endpoint: String? let endTime: Int? @@ -37,11 +38,11 @@ struct FPTIBatchData: Codable { /// Used for linking events from the client to server side request /// This value will be PayPal Order ID, Payment Token, EC token, Billing Agreement, or Venmo Context ID depending on the flow let payPalContextID: String? - let payPalInstalled: String? + let payPalInstalled: Bool = application.isPayPalAppInstalled() let startTime: Int? let timestamp: String let tenantName: String = "Braintree" - let venmoInstalled: Bool = isVenmoAppInstalled() + let venmoInstalled: Bool = application.isVenmoAppInstalled() enum CodingKeys: String, CodingKey { case correlationID = "correlation_id" @@ -142,11 +143,4 @@ struct FPTIBatchData: Codable { case tokenizationKey = "tokenization_key" } } - - private static func isVenmoAppInstalled() -> Bool { - guard let venmoURL = URL(string: "\(BTCoreConstants.venmoScheme)://") else { - return false - } - return UIApplication.shared.canOpenURL(venmoURL) - } } diff --git a/Sources/BraintreeCore/BTAPIClient.swift b/Sources/BraintreeCore/BTAPIClient.swift index 1508ba96fd..b7be417ac2 100644 --- a/Sources/BraintreeCore/BTAPIClient.swift +++ b/Sources/BraintreeCore/BTAPIClient.swift @@ -323,16 +323,14 @@ import Foundation correlationID: String? = nil, errorDescription: String? = nil, linkType: String? = nil, - payPalContextID: String? = nil, - payPalInstalled: Bool? = nil + payPalContextID: String? = nil ) { analyticsService?.sendAnalyticsEvent( eventName, correlationID: correlationID, errorDescription: errorDescription, linkType: linkType, - payPalContextID: payPalContextID, - payPalInstalled: payPalInstalled?.description + payPalContextID: payPalContextID ) } diff --git a/Sources/BraintreeCore/BTCoreConstants.swift b/Sources/BraintreeCore/BTCoreConstants.swift index d748cacea0..0739d46b26 100644 --- a/Sources/BraintreeCore/BTCoreConstants.swift +++ b/Sources/BraintreeCore/BTCoreConstants.swift @@ -10,7 +10,11 @@ import Foundation /// :nodoc: This property is exposed for internal Braintree use only. Do not use. It is not covered by Semantic Versioning and may change or be removed at any time. public static let callbackURLScheme: String = "sdk.ios.braintree" - public static let venmoScheme: String = "com.venmo.touch.v2" + /// URL Scheme for Venmo App + public static let venmoURLScheme: String = "com.venmo.touch.v2" + + /// URL Scheme for PayPal App + public static let payPalURLScheme: String = "paypal-app-switch-checkout" static let apiVersion: String = "2016-10-07" diff --git a/Sources/BraintreeCore/UIApplication+URLOpener.swift b/Sources/BraintreeCore/UIApplication+URLOpener.swift index bd56ae3eaa..30efc970ec 100644 --- a/Sources/BraintreeCore/UIApplication+URLOpener.swift +++ b/Sources/BraintreeCore/UIApplication+URLOpener.swift @@ -5,9 +5,32 @@ import UIKit /// Used to mock `UIApplication`. @_documentation(visibility: private) public protocol URLOpener { - + func canOpenURL(_ url: URL) -> Bool func open(_ url: URL, options: [UIApplication.OpenExternalURLOptionsKey: Any], completionHandler completion: ((Bool) -> Void)?) + func isPayPalAppInstalled() -> Bool + func isVenmoAppInstalled() -> Bool } -extension UIApplication: URLOpener { } +extension UIApplication: URLOpener { + + /// :nodoc: This method is exposed for internal Braintree use only. Do not use. It is not covered by Semantic Versioning and may change or be removed at any time. + /// Indicates whether the Venmo App is installed. + @_documentation(visibility: private) + public func isVenmoAppInstalled() -> Bool { + guard let venmoURL = URL(string: "\(BTCoreConstants.venmoURLScheme)://") else { + return false + } + return canOpenURL(venmoURL) + } + + /// :nodoc: This method is exposed for internal Braintree use only. Do not use. It is not covered by Semantic Versioning and may change or be removed at any time. + /// Indicates whether the PayPal App is installed. + @_documentation(visibility: private) + public func isPayPalAppInstalled() -> Bool { + guard let payPalURL = URL(string: "\(BTCoreConstants.payPalURLScheme)://") else { + return false + } + return canOpenURL(payPalURL) + } +} diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index 7c5f42c4ed..d5cca78a0f 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -48,9 +48,6 @@ import BraintreeDataCollector // MARK: - Private Properties - /// URL Scheme for PayPal App Switch Checkout - private let payPalAppSwitchScheme: String = "paypal-app-switch-checkout://" - /// Indicates if the user returned back to the merchant app from the `BTWebAuthenticationSession` /// Will only be `true` if the user proceed through the `UIAlertController` private var webSessionReturned: Bool = false @@ -180,8 +177,7 @@ import BraintreeDataCollector BTPayPalAnalytics.handleReturnStarted, correlationID: clientMetadataID, linkType: linkType, - payPalContextID: payPalContextID, - payPalInstalled: payPalAppInstalled + payPalContextID: payPalContextID ) guard let url, BTPayPalReturnURL.isValidURLAction(url: url, linkType: linkType) else { @@ -289,10 +285,10 @@ import BraintreeDataCollector request: BTPayPalRequest, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void ) { - payPalAppInstalled = isPayPalAppInstalled() + payPalAppInstalled = application.isPayPalAppInstalled() linkType = (request as? BTPayPalVaultRequest)?.enablePayPalAppSwitch == true && payPalAppInstalled ? "universal" : "deeplink" - apiClient.sendAnalyticsEvent(BTPayPalAnalytics.tokenizeStarted, linkType: linkType, payPalInstalled: payPalAppInstalled) + apiClient.sendAnalyticsEvent(BTPayPalAnalytics.tokenizeStarted, linkType: linkType) apiClient.fetchOrReturnRemoteConfiguration { configuration, error in if let error { self.notifyFailure(with: error, completion: completion) @@ -356,19 +352,11 @@ import BraintreeDataCollector } } - private func isPayPalAppInstalled() -> Bool { - guard let paypalURL = URL(string: payPalAppSwitchScheme) else { - return false - } - return application.canOpenURL(paypalURL) - } - private func launchPayPalApp(with payPalAppRedirectURL: URL, baToken: String, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void) { apiClient.sendAnalyticsEvent( BTPayPalAnalytics.appSwitchStarted, linkType: linkType, - payPalContextID: payPalContextID, - payPalInstalled: payPalAppInstalled + payPalContextID: payPalContextID ) var urlComponents = URLComponents(url: payPalAppRedirectURL, resolvingAgainstBaseURL: true) @@ -393,8 +381,7 @@ import BraintreeDataCollector apiClient.sendAnalyticsEvent( BTPayPalAnalytics.appSwitchSucceeded, linkType: linkType, - payPalContextID: payPalContextID, - payPalInstalled: payPalAppInstalled + payPalContextID: payPalContextID ) BTPayPalClient.payPalClient = self appSwitchCompletion = completion @@ -402,8 +389,7 @@ import BraintreeDataCollector apiClient.sendAnalyticsEvent( BTPayPalAnalytics.appSwitchFailed, linkType: linkType, - payPalContextID: payPalContextID, - payPalInstalled: payPalAppInstalled + payPalContextID: payPalContextID ) notifyFailure(with: BTPayPalError.appSwitchFailed, completion: completion) } @@ -444,15 +430,13 @@ import BraintreeDataCollector apiClient.sendAnalyticsEvent( BTPayPalAnalytics.browserPresentationSucceeded, linkType: linkType, - payPalContextID: payPalContextID, - payPalInstalled: payPalAppInstalled + payPalContextID: payPalContextID ) } else { apiClient.sendAnalyticsEvent( BTPayPalAnalytics.browserPresentationFailed, linkType: linkType, - payPalContextID: payPalContextID, - payPalInstalled: payPalAppInstalled + payPalContextID: payPalContextID ) } } sessionDidCancel: { [self] in @@ -461,8 +445,7 @@ import BraintreeDataCollector apiClient.sendAnalyticsEvent( BTPayPalAnalytics.browserLoginAlertCanceled, linkType: linkType, - payPalContextID: payPalContextID, - payPalInstalled: payPalAppInstalled + payPalContextID: payPalContextID ) } @@ -483,8 +466,7 @@ import BraintreeDataCollector BTPayPalAnalytics.tokenizeSucceeded, correlationID: clientMetadataID, linkType: linkType, - payPalContextID: payPalContextID, - payPalInstalled: payPalAppInstalled + payPalContextID: payPalContextID ) completion(result, nil) } @@ -495,8 +477,7 @@ import BraintreeDataCollector correlationID: clientMetadataID, errorDescription: error.localizedDescription, linkType: linkType, - payPalContextID: payPalContextID, - payPalInstalled: payPalAppInstalled + payPalContextID: payPalContextID ) completion(nil, error) } @@ -506,8 +487,7 @@ import BraintreeDataCollector BTPayPalAnalytics.browserLoginCanceled, correlationID: clientMetadataID, linkType: linkType, - payPalContextID: payPalContextID, - payPalInstalled: payPalAppInstalled + payPalContextID: payPalContextID ) completion(nil, BTPayPalError.canceled) } diff --git a/Sources/BraintreeVenmo/BTVenmoAppSwitchRedirectURL.swift b/Sources/BraintreeVenmo/BTVenmoAppSwitchRedirectURL.swift index 8f58b6b68e..d86a9afb80 100644 --- a/Sources/BraintreeVenmo/BTVenmoAppSwitchRedirectURL.swift +++ b/Sources/BraintreeVenmo/BTVenmoAppSwitchRedirectURL.swift @@ -88,7 +88,7 @@ struct BTVenmoAppSwitchRedirectURL { private static func appSwitchBaseURLComponents() -> URLComponents { var components: URLComponents = URLComponents(string: xCallbackTemplate) ?? URLComponents() - components.scheme = BTCoreConstants.venmoScheme + components.scheme = BTCoreConstants.venmoURLScheme components.percentEncodedPath = "/vzero/auth" return components } diff --git a/UnitTests/BraintreeCoreTests/Analytics/FPTIBatchData_Tests.swift b/UnitTests/BraintreeCoreTests/Analytics/FPTIBatchData_Tests.swift index 5c30b6fd72..aaee7467e6 100644 --- a/UnitTests/BraintreeCoreTests/Analytics/FPTIBatchData_Tests.swift +++ b/UnitTests/BraintreeCoreTests/Analytics/FPTIBatchData_Tests.swift @@ -24,7 +24,6 @@ final class FPTIBatchData_Tests: XCTestCase { eventName: "fake-event-1", linkType: "universal", payPalContextID: "fake-order-id", - payPalInstalled: "true", startTime: 999888777666, timestamp: "fake-time-1" ), @@ -36,7 +35,6 @@ final class FPTIBatchData_Tests: XCTestCase { eventName: "fake-event-2", linkType: nil, payPalContextID: "fake-order-id-2", - payPalInstalled: nil, startTime: nil, timestamp: "fake-time-2" ) @@ -97,8 +95,6 @@ final class FPTIBatchData_Tests: XCTestCase { XCTAssertNil(eventParams[1]["link_type"]) XCTAssertEqual(eventParams[0]["paypal_context_id"] as! String, "fake-order-id") XCTAssertEqual(eventParams[1]["paypal_context_id"] as! String, "fake-order-id-2") - XCTAssertEqual(eventParams[0]["paypal_installed"] as? String, "true") - XCTAssertNil(eventParams[1]["paypal_installed"]) XCTAssertEqual(eventParams[0]["error_desc"] as? String, "fake-error-description-1") XCTAssertNil(eventParams[1]["error_desc"]) XCTAssertEqual(eventParams[0]["correlation_id"] as? String, "fake-correlation-id-1") diff --git a/UnitTests/BraintreeCoreTests/Analytics/FakeAnalyticsService.swift b/UnitTests/BraintreeCoreTests/Analytics/FakeAnalyticsService.swift index 381d0d9efd..24869d4e1a 100644 --- a/UnitTests/BraintreeCoreTests/Analytics/FakeAnalyticsService.swift +++ b/UnitTests/BraintreeCoreTests/Analytics/FakeAnalyticsService.swift @@ -13,7 +13,6 @@ class FakeAnalyticsService: BTAnalyticsService { errorDescription: String? = nil, linkType: String? = nil, payPalContextID: String? = nil, - payPalInstalled: String? = nil, startTime: Int? = nil ) { self.lastEvent = eventName diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index 61508a576f..d3e45b4f51 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -257,7 +257,6 @@ class BTPayPalClient_Tests: XCTestCase { XCTAssertEqual(mockAPIClient.postedPayPalContextID, "BA-Random-Value") XCTAssertEqual(mockAPIClient.postedLinkType, "universal") - XCTAssertEqual(mockAPIClient.postedPayPalAppInstalled, "true") XCTAssertNotNil(payPalClient.clientMetadataID) } @@ -292,7 +291,6 @@ class BTPayPalClient_Tests: XCTestCase { XCTAssertEqual(mockAPIClient.postedPayPalContextID, "BA-Random-Value") XCTAssertEqual(mockAPIClient.postedLinkType, "deeplink") - XCTAssertEqual(mockAPIClient.postedPayPalAppInstalled, "false") XCTAssertTrue(mockAPIClient.postedAnalyticsEvents.contains("paypal:tokenize:handle-return:started")) } @@ -312,7 +310,6 @@ class BTPayPalClient_Tests: XCTestCase { XCTAssertEqual(mockAPIClient.postedPayPalContextID, "A_FAKE_BA_TOKEN") XCTAssertEqual(mockAPIClient.postedLinkType, "deeplink") - XCTAssertEqual(mockAPIClient.postedPayPalAppInstalled, "false") XCTAssertTrue(mockAPIClient.postedAnalyticsEvents.contains("paypal:tokenize:handle-return:started")) } @@ -950,7 +947,6 @@ class BTPayPalClient_Tests: XCTestCase { payPalClient.tokenize(vaultRequest) { _, _ in } XCTAssertEqual("v1/paypal_hermes/setup_billing_agreement", mockAPIClient.lastPOSTPath) - XCTAssertEqual(mockAPIClient.postedPayPalAppInstalled, "true") guard let lastPostParameters = mockAPIClient.lastPOSTParameters else { XCTFail(); return } XCTAssertEqual(lastPostParameters["launch_paypal_app"] as? Bool, true) @@ -979,7 +975,6 @@ class BTPayPalClient_Tests: XCTestCase { payPalClient.tokenize(vaultRequest) { _, _ in } XCTAssertEqual("v1/paypal_hermes/setup_billing_agreement", mockAPIClient.lastPOSTPath) - XCTAssertEqual(mockAPIClient.postedPayPalAppInstalled, "false") guard let lastPostParameters = mockAPIClient.lastPOSTParameters else { XCTFail(); return } XCTAssertNil(lastPostParameters["launch_paypal_app"] as? Bool) diff --git a/UnitTests/BraintreeTestShared/FakeApplication.swift b/UnitTests/BraintreeTestShared/FakeApplication.swift index 7102757d0a..bf7b6683d5 100644 --- a/UnitTests/BraintreeTestShared/FakeApplication.swift +++ b/UnitTests/BraintreeTestShared/FakeApplication.swift @@ -23,4 +23,12 @@ public class FakeApplication: URLOpener { } return cannedCanOpenURL } + + public func isPayPalAppInstalled() -> Bool { + cannedCanOpenURL + } + + public func isVenmoAppInstalled() -> Bool { + cannedCanOpenURL + } } diff --git a/UnitTests/BraintreeTestShared/MockAPIClient.swift b/UnitTests/BraintreeTestShared/MockAPIClient.swift index dbebe8afc0..1af0a4a6c5 100644 --- a/UnitTests/BraintreeTestShared/MockAPIClient.swift +++ b/UnitTests/BraintreeTestShared/MockAPIClient.swift @@ -13,7 +13,6 @@ public class MockAPIClient: BTAPIClient { public var postedAnalyticsEvents : [String] = [] public var postedPayPalContextID: String? = nil public var postedLinkType: String? = nil - public var postedPayPalAppInstalled: String? = nil @objc public var cannedConfigurationResponseBody : BTJSON? = nil @objc public var cannedConfigurationResponseError : NSError? = nil @@ -88,12 +87,10 @@ public class MockAPIClient: BTAPIClient { correlationID: String? = nil, errorDescription: String? = nil, linkType: String? = nil, - payPalContextID: String? = nil, - payPalInstalled: Bool? = nil + payPalContextID: String? = nil ) { postedPayPalContextID = payPalContextID postedLinkType = linkType - postedPayPalAppInstalled = payPalInstalled?.description postedAnalyticsEvents.append(name) } From 3ff16cfe842933954eb199b10050b84786cb67e3 Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Tue, 28 May 2024 09:51:07 -0500 Subject: [PATCH 29/32] remove notification center for BA Token and nonce (#1318) --- .../Application/Base/BaseViewController.swift | 1 - .../Base/ContainmentViewController.swift | 18 --------- .../PayPalWebCheckoutViewController.swift | 37 +------------------ Sources/BraintreePayPal/BTPayPalClient.swift | 3 -- 4 files changed, 2 insertions(+), 57 deletions(-) diff --git a/Demo/Application/Base/BaseViewController.swift b/Demo/Application/Base/BaseViewController.swift index df5b727794..7d7df4287d 100644 --- a/Demo/Application/Base/BaseViewController.swift +++ b/Demo/Application/Base/BaseViewController.swift @@ -5,7 +5,6 @@ class BaseViewController: UIViewController { var progressBlock: ((String?) -> Void) = { _ in } var completionBlock: ((BTPaymentMethodNonce?) -> Void) = { _ in } - var nonceCompletionBlock: ((BTPaymentMethodNonce?) -> Void) = { _ in } init(authorization: String) { super.init(nibName: nil, bundle: nil) diff --git a/Demo/Application/Base/ContainmentViewController.swift b/Demo/Application/Base/ContainmentViewController.swift index 4bbebbddea..716519e6a4 100644 --- a/Demo/Application/Base/ContainmentViewController.swift +++ b/Demo/Application/Base/ContainmentViewController.swift @@ -18,7 +18,6 @@ class ContainmentViewController: UIViewController { updateStatus("Presenting \(type(of: currentViewController))") currentViewController.progressBlock = progressBlock currentViewController.completionBlock = completionBlock - currentViewController.nonceCompletionBlock = nonceCompletionBlock appendViewController(currentViewController) title = currentViewController.title @@ -31,12 +30,6 @@ class ContainmentViewController: UIViewController { } } - private var copiedNonce: BTPaymentMethodNonce? { - didSet { - statusItem?.isEnabled = (copiedNonce != nil) - } - } - // MARK: - Progress and Completion Blocks func progressBlock(_ status: String?) { @@ -49,11 +42,6 @@ class ContainmentViewController: UIViewController { updateStatus("Got a nonce. Tap to make a transaction.") } - func nonceCompletionBlock(_ nonce: BTPaymentMethodNonce?) { - copiedNonce = nonce - updateStatus(copiedNonce?.nonce ?? "no nonce returned") - } - override func viewDidLoad() { super.viewDidLoad() @@ -119,12 +107,6 @@ class ContainmentViewController: UIViewController { @objc private func tappedStatus() { print("Tapped status!") - if let copiedNonce { - UIPasteboard.general.string = copiedNonce.nonce - self.copiedNonce = nil - return - } - if let currentPaymentMethodNonce { let nonce = currentPaymentMethodNonce.nonce updateStatus("Creating Transaction…") diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index 0ecd22d27c..4d2b542e96 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -23,21 +23,12 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { let appSwitchStageToggle = UISwitch() - // TODO: remove UILabel before merging into main DTBTSDK-3766 - let baTokenLabel = UILabel() - override func createPaymentButton() -> UIView { let payPalCheckoutButton = createButton(title: "PayPal Checkout", action: #selector(tappedPayPalCheckout)) let payPalVaultButton = createButton(title: "PayPal Vault", action: #selector(tappedPayPalVault)) let payPalPayLaterButton = createButton(title: "PayPal with Pay Later Offered", action: #selector(tappedPayPalPayLater)) let payPalAppSwitchButton = createButton(title: "PayPal App Switch", action: #selector(tappedPayPalAppSwitch)) - // TODO: remove tapGesture before merging into main DTBTSDK-3766 - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(labelTapped)) - baTokenLabel.isUserInteractionEnabled = true - baTokenLabel.addGestureRecognizer(tapGesture) - baTokenLabel.textColor = .systemPink - let stackView = UIStackView(arrangedSubviews: [ buttonsStackView(label: "1-Time Checkout Flows", views: [payPalCheckoutButton, payPalPayLaterButton]), buttonsStackView( @@ -48,8 +39,7 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { payPalAppSwitchButton, UIStackView(arrangedSubviews: [appSwitchStageToggleLabel, appSwitchStageToggle]) ] - ), - baTokenLabel + ) ]) stackView.axis = .vertical @@ -143,14 +133,6 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { universalLink: URL(string: "https://mobile-sdk-demo-site-838cead5d3ab.herokuapp.com/braintree-payments")! ) - // TODO: remove NotificationCenter before merging into main DTBTSDK-3766 - NotificationCenter.default.addObserver( - self, - selector: #selector(receivedNotification), - name: Notification.Name("BAToken"), - object: nil - ) - if appSwitchStageToggle.isOn { let stagePayPalClient = BTPayPalClient(apiClient: BTAPIClient(authorization: "sandbox_jy4fvpfg_v7x2rb226dx4pr7b")!) @@ -162,7 +144,7 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { return } - self.nonceCompletionBlock(nonce) + self.completionBlock(nonce) } } else { payPalClient.tokenize(request) { nonce, error in @@ -193,19 +175,4 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { return buttonsStackView } - - // TODO: remove labelTapped and receivedNotification before merging into main DTBTSDK-3766 - - @objc func labelTapped(sender: UITapGestureRecognizer) { - UIPasteboard.general.string = baTokenLabel.text - } - - @objc func receivedNotification(_ notification: Notification) { - guard let baToken = notification.object else { - baTokenLabel.text = "No token returned" - return - } - - baTokenLabel.text = "\(baToken)" - } } diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index d5cca78a0f..d8be3e2417 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -331,9 +331,6 @@ import BraintreeDataCollector self.payPalContextID = approvalURL.baToken ?? approvalURL.ecToken - // TODO: remove NotificationCenter before merging into main DTBTSDK-3766 - NotificationCenter.default.post(name: Notification.Name("BAToken"), object: self.payPalContextID) - let dataCollector = BTDataCollector(apiClient: self.apiClient) self.clientMetadataID = self.payPalRequest?.riskCorrelationID ?? dataCollector.clientMetadataID(self.payPalContextID) From f3b14bfdeba6dcc8917041fee1d441d1aef7f8a2 Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Tue, 28 May 2024 10:44:54 -0500 Subject: [PATCH 30/32] [QL] Remove Stage Logic from `PayPalWebCheckoutViewController` (#1320) * remove stage logic from PayPalWebCheckoutViewController --- .../PayPalWebCheckoutViewController.swift | 49 ++++--------------- 1 file changed, 9 insertions(+), 40 deletions(-) diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index 4d2b542e96..69868a13a2 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -14,15 +14,6 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { return textField }() - lazy var appSwitchStageToggleLabel: UILabel = { - let label = UILabel() - label.text = "App Switch Staging Env" - label.font = .preferredFont(forTextStyle: .footnote) - return label - }() - - let appSwitchStageToggle = UISwitch() - override func createPaymentButton() -> UIView { let payPalCheckoutButton = createButton(title: "PayPal Checkout", action: #selector(tappedPayPalCheckout)) let payPalVaultButton = createButton(title: "PayPal Vault", action: #selector(tappedPayPalVault)) @@ -31,15 +22,7 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { let stackView = UIStackView(arrangedSubviews: [ buttonsStackView(label: "1-Time Checkout Flows", views: [payPalCheckoutButton, payPalPayLaterButton]), - buttonsStackView( - label: "Vault Flows", - views: [ - emailTextField, - payPalVaultButton, - payPalAppSwitchButton, - UIStackView(arrangedSubviews: [appSwitchStageToggleLabel, appSwitchStageToggle]) - ] - ) + buttonsStackView(label: "Vault Flows", views: [emailTextField, payPalVaultButton, payPalAppSwitchButton]) ]) stackView.axis = .vertical @@ -133,30 +116,16 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { universalLink: URL(string: "https://mobile-sdk-demo-site-838cead5d3ab.herokuapp.com/braintree-payments")! ) - if appSwitchStageToggle.isOn { - let stagePayPalClient = BTPayPalClient(apiClient: BTAPIClient(authorization: "sandbox_jy4fvpfg_v7x2rb226dx4pr7b")!) - - stagePayPalClient.tokenize(request) { nonce, error in - sender.isEnabled = true - - guard let nonce else { - self.progressBlock(error?.localizedDescription) - return - } - - self.completionBlock(nonce) - } - } else { - payPalClient.tokenize(request) { nonce, error in - sender.isEnabled = true - - guard let nonce else { - self.progressBlock(error?.localizedDescription) - return - } + payPalClient.tokenize(request) { nonce, error in + sender.isEnabled = true + + guard let nonce else { + self.progressBlock(error?.localizedDescription) + return - self.completionBlock(nonce) } + + self.completionBlock(nonce) } } From 42884eac424a852ffa2ab7d8b67d8eb42c124d5e Mon Sep 17 00:00:00 2001 From: Jax DesMarais-Leder Date: Tue, 4 Jun 2024 11:26:38 -0500 Subject: [PATCH 31/32] Move `universalLink` to `BTPayPalClient` (#1330) * move universalLink into BTPayPalClient init * update PayPalWebCheckoutViewController * update UnitTests * update CHANGELOG --- CHANGELOG.md | 4 ++- .../PayPalWebCheckoutViewController.swift | 10 +++--- .../BTPayPalCheckoutRequest.swift | 2 +- Sources/BraintreePayPal/BTPayPalClient.swift | 18 ++++++++++- Sources/BraintreePayPal/BTPayPalRequest.swift | 2 +- .../BTPayPalVaultBaseRequest.swift | 2 +- .../BTPayPalVaultRequest.swift | 8 +---- .../BTPayPalClient_Tests.swift | 31 +++++++------------ .../BTPayPalRequest_Tests.swift | 4 +-- .../BTPayPalVaultRequest_Tests.swift | 5 ++- 10 files changed, 44 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f086158508..4ad36ed0f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ ## unreleased * BraintreePayPal - * Add `BTPayPalVaultRequest(userAuthenticationEmail:enablePayPalAppSwitch:universalLink:offerCredit:)` + * Add `BTPayPalVaultRequest(userAuthenticationEmail:enablePayPalAppSwitch:offerCredit:)` + * This init should be used for the PayPal App Switch flow + * Add `BTPayPalClient(apiClient:universalLink:)` * This init should be used for the PayPal App Switch flow * Send `link_type` and `paypal_installed` in `event_params` when available to PayPal's analytics service (FPTI) * **Note:** This feature is currently in beta and may change or be removed in future releases. diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index 69868a13a2..14ff61629b 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -5,8 +5,11 @@ import BraintreeCore class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { - lazy var payPalClient = BTPayPalClient(apiClient: apiClient) - + lazy var payPalClient = BTPayPalClient( + apiClient: apiClient, + universalLink: URL(string: "https://mobile-sdk-demo-site-838cead5d3ab.herokuapp.com/braintree-payments")! + ) + lazy var emailTextField: UITextField = { let textField = UITextField() textField.placeholder = "placeholder@email.com" @@ -112,8 +115,7 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { let request = BTPayPalVaultRequest( userAuthenticationEmail: userEmail, - enablePayPalAppSwitch: true, - universalLink: URL(string: "https://mobile-sdk-demo-site-838cead5d3ab.herokuapp.com/braintree-payments")! + enablePayPalAppSwitch: true ) payPalClient.tokenize(request) { nonce, error in diff --git a/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift b/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift index 53b01c46f6..c8c47c3dbd 100644 --- a/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift @@ -117,7 +117,7 @@ import BraintreeCore /// :nodoc: Exposed publicly for use by PayPal Native Checkout module. This method is not covered by semantic versioning. @_documentation(visibility: private) - public override func parameters(with configuration: BTConfiguration) -> [String: Any] { + public override func parameters(with configuration: BTConfiguration, universalLink: URL? = nil) -> [String: Any] { var baseParameters = super.parameters(with: configuration) var checkoutParameters: [String: Any] = [ "intent": intent.stringValue, diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index bfe17bddcc..a347416ff5 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -51,6 +51,8 @@ import BraintreeDataCollector // MARK: - Private Properties + private var universalLink: URL? + /// Indicates if the user returned back to the merchant app from the `BTWebAuthenticationSession` /// Will only be `true` if the user proceed through the `UIAlertController` private var webSessionReturned: Bool = false @@ -82,6 +84,17 @@ import BraintreeDataCollector ) } + /// Initialize a new PayPal client instance for the PayPal App Switch flow. + /// - Parameters: + /// - apiClient: The API Client + /// - universalLink: The URL to use for the PayPal app switch flow. Must be a valid HTTPS URL dedicated to Braintree app switch returns. + /// - Warning: This initializer should be used for merchants using the PayPal App Switch flow. This feature is currently in beta and may change or be removed in future releases. + @objc(initWithAPIClient:universalLink:) + public convenience init(apiClient: BTAPIClient, universalLink: URL) { + self.init(apiClient: apiClient) + self.universalLink = universalLink + } + // MARK: - Public Methods /// Tokenize a PayPal request to be used with the PayPal Vault flow. @@ -315,7 +328,10 @@ import BraintreeDataCollector } self.payPalRequest = request - self.apiClient.post(request.hermesPath, parameters: request.parameters(with: configuration)) { body, response, error in + self.apiClient.post( + request.hermesPath, + parameters: request.parameters(with: configuration, universalLink: self.universalLink) + ) { body, response, error in if let error = error as? NSError { guard let jsonResponseBody = error.userInfo[BTCoreConstants.jsonResponseBodyKey] as? BTJSON else { self.notifyFailure(with: error, completion: completion) diff --git a/Sources/BraintreePayPal/BTPayPalRequest.swift b/Sources/BraintreePayPal/BTPayPalRequest.swift index 1959c25004..2ec43edfdb 100644 --- a/Sources/BraintreePayPal/BTPayPalRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalRequest.swift @@ -135,7 +135,7 @@ import BraintreeCore /// :nodoc: Exposed publicly for use by PayPal Native Checkout module. This method is not covered by semantic versioning. @_documentation(visibility: private) - public func parameters(with configuration: BTConfiguration) -> [String: Any] { + public func parameters(with configuration: BTConfiguration, universalLink: URL? = nil) -> [String: Any] { var experienceProfile: [String: Any] = [:] experienceProfile["no_shipping"] = !isShippingAddressRequired diff --git a/Sources/BraintreePayPal/BTPayPalVaultBaseRequest.swift b/Sources/BraintreePayPal/BTPayPalVaultBaseRequest.swift index 77609770ff..7d2fbe7743 100644 --- a/Sources/BraintreePayPal/BTPayPalVaultBaseRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalVaultBaseRequest.swift @@ -27,7 +27,7 @@ import BraintreeCore /// :nodoc: Exposed publicly for use by PayPal Native Checkout module. This method is not covered by semantic versioning. @_documentation(visibility: private) - public override func parameters(with configuration: BTConfiguration) -> [String: Any] { + public override func parameters(with configuration: BTConfiguration, universalLink: URL? = nil) -> [String: Any] { let baseParameters = super.parameters(with: configuration) var vaultParameters: [String: Any] = ["offer_paypal_credit": offerCredit] diff --git a/Sources/BraintreePayPal/BTPayPalVaultRequest.swift b/Sources/BraintreePayPal/BTPayPalVaultRequest.swift index b2048cc71d..a665e06e5e 100644 --- a/Sources/BraintreePayPal/BTPayPalVaultRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalVaultRequest.swift @@ -19,26 +19,20 @@ import BraintreeCore /// - Warning: This property is currently in beta and may change or be removed in future releases. var enablePayPalAppSwitch: Bool = false - /// The URL to use for the PayPal app switch flow. Must be a valid HTTPS URL dedicated to Braintree app switch returns. - var universalLink: URL? - // MARK: - Initializers /// Initializes a PayPal Vault request for the PayPal App Switch flow /// - Parameters: /// - userAuthenticationEmail: Required: User email to initiate a quicker authentication flow in cases where the user has a PayPal Account with the same email. /// - enablePayPalAppSwitch: Required: Used to determine if the customer will use the PayPal app switch flow. - /// - universalLink: Required: The URL to use for the PayPal app switch flow. Must be a valid HTTPS URL dedicated to Braintree app switch returns. /// - offerCredit: Optional: Offers PayPal Credit if the customer qualifies. Defaults to `false`. /// - Warning: This initializer should be used for merchants using the PayPal App Switch flow. This feature is currently in beta and may change or be removed in future releases. public convenience init( userAuthenticationEmail: String, enablePayPalAppSwitch: Bool, - universalLink: URL, offerCredit: Bool = false ) { self.init(offerCredit: offerCredit, userAuthenticationEmail: userAuthenticationEmail) - self.universalLink = universalLink self.enablePayPalAppSwitch = enablePayPalAppSwitch } @@ -51,7 +45,7 @@ import BraintreeCore super.init(offerCredit: offerCredit) } - public override func parameters(with configuration: BTConfiguration) -> [String: Any] { + public override func parameters(with configuration: BTConfiguration, universalLink: URL? = nil) -> [String: Any] { var baseParameters = super.parameters(with: configuration) if let userAuthenticationEmail { diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index 7dc155c984..ce27dacd37 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -19,7 +19,7 @@ class BTPayPalClient_Tests: XCTestCase { mockAPIClient.cannedResponseBody = BTJSON(value: [ "paymentResource": ["redirectUrl": "http://fakeURL.com"] ]) - payPalClient = BTPayPalClient(apiClient: mockAPIClient) + payPalClient = BTPayPalClient(apiClient: mockAPIClient, universalLink: URL(string: "https://www.paypal.com")!) mockWebAuthenticationSession = MockWebAuthenticationSession() payPalClient.webAuthenticationSession = mockWebAuthenticationSession } @@ -237,8 +237,7 @@ class BTPayPalClient_Tests: XCTestCase { let vaultRequest = BTPayPalVaultRequest( userAuthenticationEmail: "fake@gmail.com", - enablePayPalAppSwitch: true, - universalLink: URL(string: "https://www.paypal.com")! + enablePayPalAppSwitch: true ) mockAPIClient.cannedResponseBody = BTJSON(value: [ @@ -749,8 +748,7 @@ class BTPayPalClient_Tests: XCTestCase { let vaultRequest = BTPayPalVaultRequest( userAuthenticationEmail: "fake@gmail.com", - enablePayPalAppSwitch: true, - universalLink: URL(string: "https://paypal.com")! + enablePayPalAppSwitch: true ) payPalClient.tokenize(vaultRequest) { _, _ in } @@ -785,8 +783,7 @@ class BTPayPalClient_Tests: XCTestCase { let vaultRequest = BTPayPalVaultRequest( userAuthenticationEmail: "fake@gmail.com", - enablePayPalAppSwitch: true, - universalLink: URL(string: "https://paypal.com")! + enablePayPalAppSwitch: true ) let expectation = expectation(description: "completion block called") @@ -810,8 +807,7 @@ class BTPayPalClient_Tests: XCTestCase { let vaultRequest = BTPayPalVaultRequest( userAuthenticationEmail: "fake@gmail.com", - enablePayPalAppSwitch: true, - universalLink: URL(string: "https://paypal.com")! + enablePayPalAppSwitch: true ) mockAPIClient.cannedResponseBody = BTJSON(value: [ @@ -834,8 +830,7 @@ class BTPayPalClient_Tests: XCTestCase { func testHandleReturn_whenURLIsCancel_returnsCancel() { let request = BTPayPalVaultRequest( userAuthenticationEmail: "sally@gmail.com", - enablePayPalAppSwitch: true, - universalLink: URL(string: "https://merchant-app.com/merchant-path")! + enablePayPalAppSwitch: true ) let returnURL = URL(string: "https://www.merchant-app.com/merchant-path/cancel?ba_token=A_FAKE_BA_TOKEN&switch_initiated_time=1234567890")! let expectation = expectation(description: "completion block called") @@ -856,8 +851,7 @@ class BTPayPalClient_Tests: XCTestCase { func testHandleReturn_whenURLIsUnknown_returnsError() { let request = BTPayPalVaultRequest( userAuthenticationEmail: "sally@gmail.com", - enablePayPalAppSwitch: true, - universalLink: URL(string: "https://merchant-app.com/merchant-path")! + enablePayPalAppSwitch: true ) let returnURL = URL(string: "https://www.merchant-app.com/merchant-path/garbage-url")! let expectation = expectation(description: "completion block called") @@ -878,8 +872,7 @@ class BTPayPalClient_Tests: XCTestCase { func testHandleReturn_whenURLIsSuccess_returnsTokenization() { let request = BTPayPalVaultRequest( userAuthenticationEmail: "sally@gmail.com", - enablePayPalAppSwitch: true, - universalLink: URL(string: "https://merchant-app.com/merchant-path")! + enablePayPalAppSwitch: true ) mockAPIClient.cannedResponseBody = BTJSON(value: [ "paypalAccounts": @@ -931,8 +924,7 @@ class BTPayPalClient_Tests: XCTestCase { let vaultRequest = BTPayPalVaultRequest( userAuthenticationEmail: "fake@gmail.com", - enablePayPalAppSwitch: true, - universalLink: URL(string: "https://paypal.com")! + enablePayPalAppSwitch: true ) mockAPIClient.cannedResponseBody = BTJSON(value: [ @@ -949,7 +941,7 @@ class BTPayPalClient_Tests: XCTestCase { XCTAssertEqual(lastPostParameters["launch_paypal_app"] as? Bool, true) XCTAssertTrue((lastPostParameters["os_version"] as! String).matches("\\d+\\.\\d+")) XCTAssertTrue((lastPostParameters["os_type"] as! String).matches("iOS|iPadOS")) - XCTAssertEqual(lastPostParameters["merchant_app_return_url"] as? String, "https://paypal.com") + XCTAssertEqual(lastPostParameters["merchant_app_return_url"] as? String, "https://www.paypal.com") } func testIsiOSAppSwitchAvailable_whenApplicationCantOpenPayPalInAppURL_returnsFalseAndSendsAnalytics() { @@ -959,8 +951,7 @@ class BTPayPalClient_Tests: XCTestCase { let vaultRequest = BTPayPalVaultRequest( userAuthenticationEmail: "fake@gmail.com", - enablePayPalAppSwitch: true, - universalLink: URL(string: "https://paypal.com")! + enablePayPalAppSwitch: true ) mockAPIClient.cannedResponseBody = BTJSON(value: [ diff --git a/UnitTests/BraintreePayPalTests/BTPayPalRequest_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalRequest_Tests.swift index 164862cbd1..304d349f6c 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalRequest_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalRequest_Tests.swift @@ -99,12 +99,10 @@ class BTPayPalRequest_Tests: XCTestCase { func testEnablePayPalAppSwitch_whenInitialized_setsAllRequiredValues() { let request = BTPayPalVaultRequest( userAuthenticationEmail: "fake@gmail.com", - enablePayPalAppSwitch: true, - universalLink: URL(string: "my-website-is-cool.com")! + enablePayPalAppSwitch: true ) XCTAssertEqual(request.userAuthenticationEmail, "fake@gmail.com") XCTAssertTrue(request.enablePayPalAppSwitch) - XCTAssertEqual(request.universalLink?.absoluteString, "my-website-is-cool.com") } } diff --git a/UnitTests/BraintreePayPalTests/BTPayPalVaultRequest_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalVaultRequest_Tests.swift index 4c5ddbe4f6..2036a75bb0 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalVaultRequest_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalVaultRequest_Tests.swift @@ -73,11 +73,10 @@ class BTPayPalVaultRequest_Tests: XCTestCase { func testParameters_withEnablePayPalAppSwitchTrue_returnsAllParams() { let request = BTPayPalVaultRequest( userAuthenticationEmail: "sally@gmail.com", - enablePayPalAppSwitch: true, - universalLink: URL(string: "some-url")! + enablePayPalAppSwitch: true ) - let parameters = request.parameters(with: configuration) + let parameters = request.parameters(with: configuration, universalLink: URL(string: "some-url")!) XCTAssertEqual(parameters["launch_paypal_app"] as? Bool, true) XCTAssertTrue((parameters["os_version"] as! String).matches("\\d+\\.\\d+")) From ec3488a72c332087048470f408268314077d78dc Mon Sep 17 00:00:00 2001 From: scannillo <35243507+scannillo@users.noreply.github.com> Date: Wed, 12 Jun 2024 13:08:43 -0500 Subject: [PATCH 32/32] [QL] PP App Switch - cleanup docstrings for release (#1334) --- CHANGELOG.md | 11 +++++------ Sources/BraintreePayPal/BTPayPalClient.swift | 2 +- Sources/BraintreePayPal/BTPayPalVaultRequest.swift | 1 + 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eaed9d0b97..32d7fdfaae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,11 @@ ## unreleased * BraintreePayPal - * Add `BTPayPalVaultRequest(userAuthenticationEmail:enablePayPalAppSwitch:offerCredit:)` - * This init should be used for the PayPal App Switch flow - * Add `BTPayPalClient(apiClient:universalLink:)` - * This init should be used for the PayPal App Switch flow - * Send `link_type` and `paypal_installed` in `event_params` when available to PayPal's analytics service (FPTI) - * **Note:** This feature is currently in beta and may change or be removed in future releases. + * Add PayPal App Switch vault flow (BETA) + * Add `BTPayPalVaultRequest(userAuthenticationEmail:enablePayPalAppSwitch:offerCredit:)` + * Add `BTPayPalClient(apiClient:universalLink:)` + * Send `link_type` and `paypal_installed` in `event_params` when available to PayPal's analytics service (FPTI) + * **Note:** This feature is currently in beta and may change or be removed in future releases. ## 6.20.0 (2024-06-06) * Re-use existing URLSession instance for `v1/configuration` and subsequent BT GW API calls diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index a347416ff5..76681ecb31 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -87,7 +87,7 @@ import BraintreeDataCollector /// Initialize a new PayPal client instance for the PayPal App Switch flow. /// - Parameters: /// - apiClient: The API Client - /// - universalLink: The URL to use for the PayPal app switch flow. Must be a valid HTTPS URL dedicated to Braintree app switch returns. + /// - universalLink: The URL to use for the PayPal app switch flow. Must be a valid HTTPS URL dedicated to Braintree app switch returns. This URL must be allow-listed in your Braintree Control Panel. /// - Warning: This initializer should be used for merchants using the PayPal App Switch flow. This feature is currently in beta and may change or be removed in future releases. @objc(initWithAPIClient:universalLink:) public convenience init(apiClient: BTAPIClient, universalLink: URL) { diff --git a/Sources/BraintreePayPal/BTPayPalVaultRequest.swift b/Sources/BraintreePayPal/BTPayPalVaultRequest.swift index a665e06e5e..ae7d8657bf 100644 --- a/Sources/BraintreePayPal/BTPayPalVaultRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalVaultRequest.swift @@ -27,6 +27,7 @@ import BraintreeCore /// - enablePayPalAppSwitch: Required: Used to determine if the customer will use the PayPal app switch flow. /// - offerCredit: Optional: Offers PayPal Credit if the customer qualifies. Defaults to `false`. /// - Warning: This initializer should be used for merchants using the PayPal App Switch flow. This feature is currently in beta and may change or be removed in future releases. + /// - Note: The PayPal App Switch flow currently only supports the production environment. public convenience init( userAuthenticationEmail: String, enablePayPalAppSwitch: Bool,