diff --git a/Braintree.xcodeproj/project.pbxproj b/Braintree.xcodeproj/project.pbxproj index 9cc3e4b56e..ba2d6a6196 100644 --- a/Braintree.xcodeproj/project.pbxproj +++ b/Braintree.xcodeproj/project.pbxproj @@ -88,6 +88,7 @@ 800E78C429E0DD5300D1B0FC /* FPTIBatchData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800E78C329E0DD5300D1B0FC /* FPTIBatchData.swift */; }; 800ED7832B4F5B66007D8A30 /* BTEligiblePaymentsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800ED7822B4F5B66007D8A30 /* BTEligiblePaymentsRequest.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 */; }; 8037BFB02B2CCC130017072C /* BTShopperInsightsAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8037BFAF2B2CCC130017072C /* BTShopperInsightsAnalytics.swift */; }; 804326BF2B1A5C5B0044E90B /* BTApplePaymentTokensRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 804326BE2B1A5C5B0044E90B /* BTApplePaymentTokensRequest.swift */; }; 804698372B27C5390090878E /* BTShopperInsightsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8064F38E2B1E492F0059C4CB /* BTShopperInsightsClient.swift */; }; @@ -230,6 +231,8 @@ BE349111294B77E100D2CF68 /* BTPayPalVaultRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE349110294B77E100D2CF68 /* BTPayPalVaultRequest.swift */; }; BE349113294B798300D2CF68 /* BTPayPalRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE349112294B798300D2CF68 /* BTPayPalRequest.swift */; }; BE48CE4829D5DDA600F0825C /* BTThreeDSecureV2TextBoxCustomization.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE82E74029C4A1330059FE97 /* BTThreeDSecureV2TextBoxCustomization.swift */; }; + BE549F112BF5445F00B6F441 /* BTPayPalReturnURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6BC22D2BA9CFFC00C3E321 /* BTPayPalReturnURL.swift */; }; + BE549F122BF5449E00B6F441 /* BTPayPalVaultBaseRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6BC22B2BA9C67600C3E321 /* BTPayPalVaultBaseRequest.swift */; }; BE549F142BF6576300B6F441 /* BTAnalyticsEventsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE549F132BF6576300B6F441 /* BTAnalyticsEventsStorage.swift */; }; BE54C0332912B68E009C6CEE /* BTHTTP_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE54C0322912B68E009C6CEE /* BTHTTP_Tests.swift */; }; BE54C0352912B6BC009C6CEE /* BTHTTPTestProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE54C0342912B6BC009C6CEE /* BTHTTPTestProtocol.swift */; }; @@ -297,6 +300,7 @@ BEA0F9282B23741900C21EFA /* BTPayPalMessagingRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEA0F9272B23741900C21EFA /* BTPayPalMessagingRequest.swift */; }; BEA0F92A2B23768600C21EFA /* BTPayPalMessagingError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEA0F9292B23768600C21EFA /* BTPayPalMessagingError.swift */; }; BEB9BF532A26872B00A3673E /* BTWebAuthenticationSessionClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEB9BF522A26872B00A3673E /* BTWebAuthenticationSessionClient.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, ); }; }; @@ -810,6 +814,7 @@ 800E78C329E0DD5300D1B0FC /* FPTIBatchData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FPTIBatchData.swift; sourceTree = ""; }; 800ED7822B4F5B66007D8A30 /* BTEligiblePaymentsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTEligiblePaymentsRequest.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 = ""; }; 8037BFAF2B2CCC130017072C /* BTShopperInsightsAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTShopperInsightsAnalytics.swift; sourceTree = ""; }; 804326BE2B1A5C5B0044E90B /* BTApplePaymentTokensRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTApplePaymentTokensRequest.swift; sourceTree = ""; }; 804698302B27C5340090878E /* BraintreeShopperInsights.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = BraintreeShopperInsights.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -975,6 +980,8 @@ BE698EA328AD2C10001D9B10 /* BTCoreConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTCoreConstants.swift; sourceTree = ""; }; BE698EA528B3CDAD001D9B10 /* ConfigurationCache_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationCache_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 /* 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 = ""; }; @@ -1014,6 +1021,7 @@ BEA0F9272B23741900C21EFA /* BTPayPalMessagingRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPayPalMessagingRequest.swift; sourceTree = ""; }; BEA0F9292B23768600C21EFA /* BTPayPalMessagingError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPayPalMessagingError.swift; sourceTree = ""; }; BEB9BF522A26872B00A3673E /* BTWebAuthenticationSessionClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTWebAuthenticationSessionClient.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 = ""; }; @@ -1351,6 +1359,8 @@ 57544F5B295254A500DEB7B0 /* BTJSON+PayPal.swift */, 57544F572952298900DEB7B0 /* BTPayPalAccountNonce.swift */, 3B7A261029C0CAA40087059D /* BTPayPalAnalytics.swift */, + 8014221B2BAE935B009F9999 /* BTPayPalApprovalURLParser.swift */, + BE6BC22D2BA9CFFC00C3E321 /* BTPayPalReturnURL.swift */, BE8E5CEE294B6937001BF017 /* BTPayPalCheckoutRequest.swift */, 57544F5929524E4D00DEB7B0 /* BTPayPalClient.swift */, 5754481F294A2EBE00DEB7B0 /* BTPayPalCreditFinancing.swift */, @@ -1359,6 +1369,7 @@ BEF5D2E5294A18B300FFD56D /* BTPayPalLineItem.swift */, 57D9436D2968A8080079EAB1 /* BTPayPalLocaleCode.swift */, BE349112294B798300D2CF68 /* BTPayPalRequest.swift */, + BE6BC22B2BA9C67600C3E321 /* BTPayPalVaultBaseRequest.swift */, BE349110294B77E100D2CF68 /* BTPayPalVaultRequest.swift */, 62A659A32B98CB23008DFD67 /* PrivacyInfo.xcprivacy */, ); @@ -1925,6 +1936,7 @@ 427F32DF25D1D62D00435294 /* BTPayPalClient_Tests.swift */, BECB10C52B5999EE008D398E /* BTPayPalLineItem_Tests.swift */, 42FC218A25CDE0290047C49A /* BTPayPalRequest_Tests.swift */, + BEBA590E2BB1B5B9005FA8A2 /* BTPayPalReturnURL_Tests.swift */, 427F328F25D1A7B900435294 /* BTPayPalVaultRequest_Tests.swift */, A9E5C1E424FD665D00EE691F /* Info.plist */, ); @@ -3175,12 +3187,15 @@ 57544F5E295258AC00DEB7B0 /* BTPayPalError.swift in Sources */, BEF5D2E6294A18B300FFD56D /* BTPayPalLineItem.swift in Sources */, BE349113294B798300D2CF68 /* BTPayPalRequest.swift in Sources */, + BE549F112BF5445F00B6F441 /* BTPayPalReturnURL.swift in Sources */, 57544F5C295254A500DEB7B0 /* BTJSON+PayPal.swift in Sources */, + BE549F122BF5449E00B6F441 /* BTPayPalVaultBaseRequest.swift in Sources */, 3B7A261129C0CAA40087059D /* BTPayPalAnalytics.swift in Sources */, BE8E5CEF294B6937001BF017 /* BTPayPalCheckoutRequest.swift in Sources */, 5754481E294A2A1D00DEB7B0 /* BTPayPalCreditFinancingAmount.swift in Sources */, 57D9436E2968A8080079EAB1 /* BTPayPalLocaleCode.swift in Sources */, 57544F582952298900DEB7B0 /* BTPayPalAccountNonce.swift in Sources */, + 8014221C2BAE935B009F9999 /* BTPayPalApprovalURLParser.swift in Sources */, BE349111294B77E100D2CF68 /* BTPayPalVaultRequest.swift in Sources */, 57544820294A2EBE00DEB7B0 /* BTPayPalCreditFinancing.swift in Sources */, 57544F5A29524E4D00DEB7B0 /* BTPayPalClient.swift in Sources */, @@ -3526,6 +3541,7 @@ BECB10C62B5999EE008D398E /* BTPayPalLineItem_Tests.swift in Sources */, 3B7A261429C35BD00087059D /* BTPayPalAnalytics_Tests.swift in Sources */, A95229C724FD949D006F7D25 /* BTConfiguration+PayPal_Tests.swift in Sources */, + BEBA590F2BB1B5B9005FA8A2 /* BTPayPalReturnURL_Tests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f0dfaaa19..32d7fdfaae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Braintree iOS SDK Release Notes +## unreleased +* BraintreePayPal + * 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 * BraintreeShopperInsights (BETA) @@ -13,10 +21,6 @@ * Batch analytics events to FPTI * Send `start_time`, `end_time`, and `endpoint` to FPTI for tracking API request latency * Send `isVaultRequest` to FPTI for tracking in Venmo and PayPal flows -* BraintreePayPalMessaging (BETA) - * Add `BTPayPalMessagingRequest`, `BTPayPalMessagingColor`, `BTPayPalMessagingLogoType`, `BTPayPalMessagingOfferType`, `BTPayPalMessagingPageType`, `BTPayPalMessagingTextAlignment`, and `BTPayPalMessagingDelegate` - * Add `BTPayPalMessagingView` to display PayPal messages to promote offers such as Pay Later and PayPal Credit to customers. - * To get started create a `BTPayPalMessagingView` and call `start(_:)` with a `BTPayPalMessagingRequest` ## 6.18.2 (2024-05-15) * BraintreePayPal diff --git a/Demo/Application/Base/AppDelegate.swift b/Demo/Application/Base/AppDelegate.swift index 2881da263e..097d1b26da 100644 --- a/Demo/Application/Base/AppDelegate.swift +++ b/Demo/Application/Base/AppDelegate.swift @@ -11,12 +11,12 @@ import BraintreeCore registerDefaultsFromSettings() persistDemoSettings() BTAppContextSwitcher.sharedInstance.returnURLScheme = returnURLScheme - + userDefaults.setValue(true, forKey: "magnes.debug.mode") 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..b70118fa9a 100644 --- a/Demo/Application/Base/SceneDelegate.swift +++ b/Demo/Application/Base/SceneDelegate.swift @@ -21,8 +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, 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 a667111eb7..84e97cd839 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -1,10 +1,14 @@ import Foundation import UIKit import BraintreePayPal +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 emailLabel: UILabel = { let label = UILabel() @@ -40,6 +44,7 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { override func createPaymentButton() -> UIView { let payPalCheckoutButton = createButton(title: "PayPal Checkout", action: #selector(tappedPayPalCheckout)) let payPalVaultButton = createButton(title: "PayPal Vault", action: #selector(tappedPayPalVault)) + let payPalAppSwitchButton = createButton(title: "PayPal App Switch", action: #selector(tappedPayPalAppSwitch)) let stackView = UIStackView(arrangedSubviews: [ UIStackView(arrangedSubviews: [emailLabel, emailTextField]), @@ -48,7 +53,7 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { UIStackView(arrangedSubviews: [newPayPalCheckoutToggleLabel, newPayPalCheckoutToggle]), payPalCheckoutButton ]), - buttonsStackView(label: "Vault",views: [payPalVaultButton]) + buttonsStackView(label: "Vault",views: [payPalVaultButton, payPalAppSwitchButton]) ]) stackView.axis = .vertical @@ -110,13 +115,40 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { self.completionBlock(nonce) } } + + @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") + sender.isEnabled = true + return + } + + let request = BTPayPalVaultRequest( + userAuthenticationEmail: userEmail, + enablePayPalAppSwitch: true + ) + + payPalClient.tokenize(request) { nonce, error in + sender.isEnabled = true + + guard let nonce else { + self.progressBlock(error?.localizedDescription) + return + + } + + self.completionBlock(nonce) + } + } // MARK: - Helpers private func buttonsStackView(label: String, views: [UIView]) -> UIStackView { let titleLabel = UILabel() titleLabel.text = label - titleLabel.font = .preferredFont(forTextStyle: .title3) let buttonsStackView = UIStackView(arrangedSubviews: [titleLabel] + views) buttonsStackView.axis = .vertical diff --git a/Demo/Application/Supporting Files/Braintree-Demo-Info.plist b/Demo/Application/Supporting Files/Braintree-Demo-Info.plist index ddd0a24505..ea680cbbdb 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-app-switch-checkout LSRequiresIPhoneOS 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/BraintreeCore/Analytics/FPTIBatchData.swift b/Sources/BraintreeCore/Analytics/FPTIBatchData.swift index 76acf4afbd..02a7d41ae6 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? @@ -39,10 +40,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: 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" @@ -51,6 +53,7 @@ struct FPTIBatchData: Codable { case isVaultRequest = "is_vault" case linkType = "link_type" case payPalContextID = "paypal_context_id" + case payPalInstalled = "paypal_installed" case timestamp = "t" case tenantName = "tenant_name" case startTime = "start_time" @@ -143,11 +146,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/BTAppContextSwitcher.swift b/Sources/BraintreeCore/BTAppContextSwitcher.swift index c8bb5129e5..913665c4b4 100644 --- a/Sources/BraintreeCore/BTAppContextSwitcher.swift +++ b/Sources/BraintreeCore/BTAppContextSwitcher.swift @@ -9,11 +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 = "" - + // MARK: - Private Properties private var appContextSwitchClients = [BTAppContextSwitchClient.Type]() @@ -24,7 +26,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 +34,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/Sources/BraintreeCore/BTCoreConstants.swift b/Sources/BraintreeCore/BTCoreConstants.swift index b5bbe814be..86ce16c00e 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/BTPayPalAnalytics.swift b/Sources/BraintreePayPal/BTPayPalAnalytics.swift index 686b53f84a..1df2153a45 100644 --- a/Sources/BraintreePayPal/BTPayPalAnalytics.swift +++ b/Sources/BraintreePayPal/BTPayPalAnalytics.swift @@ -19,10 +19,16 @@ 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: - Additional Conversion events static let handleReturnStarted = "paypal:tokenize:handle-return:started" + + // 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/BTPayPalApprovalURLParser.swift b/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift new file mode 100644 index 0000000000..cf0c28a820 --- /dev/null +++ b/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift @@ -0,0 +1,60 @@ +import Foundation + +#if canImport(BraintreeCore) +import BraintreeCore +#endif + +/// The type of PayPal authentication flow to occur +enum PayPalRedirectType: Equatable { + + /// 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 { + + let redirectType: PayPalRedirectType + + 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/BTPayPalCheckoutRequest.swift b/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift index ca6d364792..adeb355c9b 100644 --- a/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift @@ -120,7 +120,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 75b4a949a4..76681ecb31 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -15,7 +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 @@ -29,11 +33,26 @@ 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 } + + /// Exposed for testing to check if the PayPal app is installed + var payPalAppInstalled: Bool = false + /// True if `tokenize()` was called with a Vault request object type var isVaultRequest: Bool = false + // 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 + 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 @@ -42,12 +61,17 @@ 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 + // MARK: - Initializer /// Initialize a new PayPal client instance. /// - Parameter apiClient: The API Client @objc(initWithAPIClient:) public init(apiClient: BTAPIClient) { + BTAppContextSwitcher.sharedInstance.register(BTPayPalClient.self) + self.apiClient = apiClient self.webAuthenticationSession = BTWebAuthenticationSession() @@ -60,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. 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) { + self.init(apiClient: apiClient) + self.universalLink = universalLink + } + // MARK: - Public Methods /// Tokenize a PayPal request to be used with the PayPal Vault flow. @@ -147,10 +182,10 @@ import BraintreeDataCollector } } } - + // MARK: - Internal Methods - func handleBrowserSwitchReturn( + func handleReturn( _ url: URL?, paymentType: BTPayPalPaymentType, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void @@ -159,19 +194,33 @@ import BraintreeDataCollector BTPayPalAnalytics.handleReturnStarted, correlationID: clientMetadataID, isVaultRequest: isVaultRequest, + linkType: linkType, payPalContextID: payPalContextID ) - 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] @@ -232,13 +281,32 @@ import BraintreeDataCollector performSwitchRequest(appSwitchURL: url, paymentType: paymentType, completion: completion) } + // MARK: - App Switch Methods + + func handleReturnURL(_ url: URL) { + guard let returnURL = BTPayPalReturnURL(.payPalApp(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 private func tokenize( request: BTPayPalRequest, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void ) { - apiClient.sendAnalyticsEvent(BTPayPalAnalytics.tokenizeStarted, isVaultRequest: isVaultRequest) + payPalAppInstalled = application.isPayPalAppInstalled() + linkType = (request as? BTPayPalVaultRequest)?.enablePayPalAppSwitch == true && payPalAppInstalled ? "universal" : "deeplink" + + apiClient.sendAnalyticsEvent(BTPayPalAnalytics.tokenizeStarted, isVaultRequest: isVaultRequest, linkType: linkType) apiClient.fetchOrReturnRemoteConfiguration { configuration, error in if let error { self.notifyFailure(with: error, completion: completion) @@ -255,8 +323,15 @@ import BraintreeDataCollector return } + 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 + 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) @@ -269,27 +344,75 @@ 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, linkType: self.linkType) else { + self.notifyFailure(with: BTPayPalError.invalidURL("Missing approval URL in gateway response."), completion: completion) return } + + self.payPalContextID = approvalURL.baToken ?? approvalURL.ecToken + + let dataCollector = BTDataCollector(apiClient: self.apiClient) + self.clientMetadataID = self.payPalRequest?.riskCorrelationID ?? dataCollector.clientMetadataID(self.payPalContextID) - let pairingID = self.token(from: approvalURL) + switch approvalURL.redirectType { + case .payPalApp(let url): + guard let baToken = approvalURL.baToken else { + self.notifyFailure(with: BTPayPalError.missingBAToken, completion: completion) + return + } - if !pairingID.isEmpty { - self.payPalContextID = pairingID + self.launchPayPalApp(with: url, baToken: baToken, completion: completion) + case .webBrowser(let url): + self.handlePayPalRequest(with: url, paymentType: request.paymentType, completion: completion) } - - let dataCollector = BTDataCollector(apiClient: self.apiClient) - self.clientMetadataID = self.payPalRequest?.riskCorrelationID ?? dataCollector.clientMetadataID(pairingID) - self.handlePayPalRequest(with: approvalURL, paymentType: request.paymentType, completion: completion) } } } - + + private func launchPayPalApp(with payPalAppRedirectURL: URL, baToken: String, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void) { + apiClient.sendAnalyticsEvent( + BTPayPalAnalytics.appSwitchStarted, + linkType: linkType, + payPalContextID: payPalContextID + ) + + var urlComponents = URLComponents(url: payPalAppRedirectURL, resolvingAgainstBaseURL: true) + urlComponents?.queryItems = [ + URLQueryItem(name: "ba_token", value: baToken), + 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 + self.invokedOpenURLSuccessfully(success, completion: completion) + } + } + + private func invokedOpenURLSuccessfully(_ success: Bool, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void) { + if success { + apiClient.sendAnalyticsEvent( + BTPayPalAnalytics.appSwitchSucceeded, + linkType: linkType, + payPalContextID: payPalContextID + ) + BTPayPalClient.payPalClient = self + appSwitchCompletion = completion + } else { + apiClient.sendAnalyticsEvent( + BTPayPalAnalytics.appSwitchFailed, + linkType: linkType, + payPalContextID: payPalContextID + ) + notifyFailure(with: BTPayPalError.appSwitchFailed, completion: completion) + } + } + private func performSwitchRequest( appSwitchURL: URL, paymentType: BTPayPalPaymentType, @@ -309,18 +432,30 @@ import BraintreeDataCollector return } - handleBrowserSwitchReturn(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( BTPayPalAnalytics.browserPresentationSucceeded, isVaultRequest: isVaultRequest, + linkType: linkType, payPalContextID: payPalContextID ) } else { apiClient.sendAnalyticsEvent( BTPayPalAnalytics.browserPresentationFailed, isVaultRequest: isVaultRequest, + linkType: linkType, payPalContextID: payPalContextID ) } @@ -330,6 +465,7 @@ import BraintreeDataCollector apiClient.sendAnalyticsEvent( BTPayPalAnalytics.browserLoginAlertCanceled, isVaultRequest: isVaultRequest, + linkType: linkType, payPalContextID: payPalContextID ) } @@ -340,89 +476,6 @@ import BraintreeDataCollector return } } - - private func token(from approvalURL: URL) -> String { - guard let query = approvalURL.query else { return "" } - let queryDictionary = parse(queryString: query) - - return queryDictionary["ba_token"] ?? queryDictionary["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 - } - - 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 { - 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 @@ -434,6 +487,7 @@ import BraintreeDataCollector BTPayPalAnalytics.tokenizeSucceeded, correlationID: clientMetadataID, isVaultRequest: isVaultRequest, + linkType: linkType, payPalContextID: payPalContextID ) completion(result, nil) @@ -445,6 +499,7 @@ import BraintreeDataCollector correlationID: clientMetadataID, errorDescription: error.localizedDescription, isVaultRequest: isVaultRequest, + linkType: linkType, payPalContextID: payPalContextID ) completion(nil, error) @@ -455,8 +510,24 @@ import BraintreeDataCollector BTPayPalAnalytics.browserLoginCanceled, correlationID: clientMetadataID, isVaultRequest: isVaultRequest, + linkType: linkType, payPalContextID: payPalContextID ) 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 { + BTPayPalReturnURL.isValid(url) + } +} diff --git a/Sources/BraintreePayPal/BTPayPalError.swift b/Sources/BraintreePayPal/BTPayPalError.swift index b1c81a6f3e..a35eea1e73 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, PayPal native app approval URL is invalid + case invalidURL(String) /// 5. The ASWebAuthenticationSession URL is invalid case asWebAuthenticationSessionURLInvalid(String) @@ -32,7 +32,16 @@ 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 + + /// 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" } @@ -59,6 +68,12 @@ public enum BTPayPalError: Error, CustomNSError, LocalizedError, Equatable { return 8 case .deallocated: return 9 + case .appSwitchReturnURLPathInvalid: + return 10 + case .appSwitchFailed: + return 11 + case .missingBAToken: + return 12 } } @@ -72,8 +87,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 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 +99,12 @@ 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." + case .appSwitchFailed: + return "UIApplication failed to perform app switch to PayPal." + case .missingBAToken: + return "Missing BA Token for PayPal App Switch." } } 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/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/Sources/BraintreePayPal/BTPayPalVaultBaseRequest.swift b/Sources/BraintreePayPal/BTPayPalVaultBaseRequest.swift new file mode 100644 index 0000000000..7d2fbe7743 --- /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, universalLink: URL? = nil) -> [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..ae7d8657bf 100644 --- a/Sources/BraintreePayPal/BTPayPalVaultRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalVaultRequest.swift @@ -1,61 +1,68 @@ -import Foundation +import UIKit #if canImport(BraintreeCore) 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: 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 + // MARK: - Internal Properties - /// 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 - self.userAuthenticationEmail = userAuthenticationEmail + /// Optional: Used to determine if the customer will use the PayPal app switch flow. + /// Defaults to `false`. + /// - Warning: This property is currently in beta and may change or be removed in future releases. + var enablePayPalAppSwitch: Bool = false + + // MARK: - Initializers - super.init(hermesPath: "v1/paypal_hermes/setup_billing_agreement", paymentType: .vault) + /// 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. + /// - 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, + offerCredit: Bool = false + ) { + self.init(offerCredit: offerCredit, userAuthenticationEmail: userAuthenticationEmail) + self.enablePayPalAppSwitch = enablePayPalAppSwitch } - // MARK: Public Methods + /// 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. + public init(offerCredit: Bool = false, userAuthenticationEmail: String? = nil) { + self.userAuthenticationEmail = userAuthenticationEmail + super.init(offerCredit: offerCredit) + } - /// :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] + public override func parameters(with configuration: BTConfiguration, universalLink: URL? = nil) -> [String: Any] { + var baseParameters = super.parameters(with: configuration) - if let billingAgreementDescription { - vaultParameters["description"] = billingAgreementDescription - } - if let userAuthenticationEmail { - vaultParameters["payer_email"] = userAuthenticationEmail + baseParameters["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 + + 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": universalLink.absoluteString ] - - vaultParameters["shipping_address"] = shippingAddressParameters + return baseParameters.merging(appSwitchParameters) { $1 } } - 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/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/BTAppContextSwitcher_Tests.swift b/UnitTests/BraintreeCoreTests/BTAppContextSwitcher_Tests.swift index e6c7ed529a..153be1fd82 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) } @@ -80,13 +80,12 @@ 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) } - } @objcMembers class MockAppContextSwitchClient: BTAppContextSwitchClient { diff --git a/UnitTests/BraintreePayPalTests/BTPayPalAnalytics_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalAnalytics_Tests.swift index d07f5cf156..d1b4f83a76 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalAnalytics_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalAnalytics_Tests.swift @@ -11,5 +11,8 @@ final class BTPayPalAnalytics_Tests: XCTestCase { XCTAssertEqual(BTPayPalAnalytics.browserPresentationFailed, "paypal:tokenize:browser-presentation:failed") XCTAssertEqual(BTPayPalAnalytics.browserLoginAlertCanceled, "paypal:tokenize:browser-login:alert-canceled") XCTAssertEqual(BTPayPalAnalytics.handleReturnStarted, "paypal:tokenize:handle-return:started") + 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 1617662dc2..ce27dacd37 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -6,6 +6,7 @@ import XCTest class BTPayPalClient_Tests: XCTestCase { var mockAPIClient: MockAPIClient! var payPalClient: BTPayPalClient! + var mockWebAuthenticationSession: MockWebAuthenticationSession! override func setUp() { super.setUp() @@ -18,8 +19,9 @@ class BTPayPalClient_Tests: XCTestCase { mockAPIClient.cannedResponseBody = BTJSON(value: [ "paymentResource": ["redirectUrl": "http://fakeURL.com"] ]) - payPalClient = BTPayPalClient(apiClient: mockAPIClient) - payPalClient.webAuthenticationSession = MockWebAuthenticationSession() + payPalClient = BTPayPalClient(apiClient: mockAPIClient, universalLink: URL(string: "https://www.paypal.com")!) + mockWebAuthenticationSession = MockWebAuthenticationSession() + payPalClient.webAuthenticationSession = mockWebAuthenticationSession } func testTokenizePayPalAccount_whenRemoteConfigurationFetchFails_callsBackWithConfigurationError() { @@ -188,10 +190,11 @@ class BTPayPalClient_Tests: XCTestCase { waitForExpectations(timeout: 1.0) } - func testTokenizePayPalAccount_whenApprovalUrlIsInvalid_returnsError() { + func testTokenizePayPalAccount_whenAllApprovalURLsInvalid_returnsError() { mockAPIClient.cannedResponseBody = BTJSON(value: [ - "paymentResource": [ - "redirectUrl": "" + "agreementSetup": [ + "approvalUrl": "", + "paypalAppApprovalUrl": "" ] ]) @@ -202,8 +205,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 occurred with retrieving a PayPal URL: Missing approval URL in gateway response.") expectation.fulfill() } @@ -217,10 +220,40 @@ class BTPayPalClient_Tests: XCTestCase { ] ]) + mockWebAuthenticationSession.cannedResponseURL = URL(string: "https://www.paypal.com/checkout/success") + let request = BTPayPalCheckoutRequest(amount: "1") payPalClient.tokenize(request) { _, _ in } XCTAssertEqual(mockAPIClient.postedPayPalContextID, "EC-Random-Value") + XCTAssertTrue(mockAPIClient.postedAnalyticsEvents.contains("paypal:tokenize:handle-return:started")) + } + + 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 + ) + + mockAPIClient.cannedResponseBody = BTJSON(value: [ + "agreementSetup": [ + "paypalAppApprovalUrl": "https://www.paypal.com?ba_token=BA-Random-Value" + ] + ]) + + payPalClient.tokenize(vaultRequest) { _, _ 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, "BA-Random-Value") + XCTAssertEqual(mockAPIClient.postedLinkType, "universal") + XCTAssertNotNil(payPalClient.clientMetadataID) } func testTokenize_whenApprovalURLDoesNotContainPayPalContextID_doesNotSendPayPalContextIDInAnalytics() { @@ -243,10 +276,33 @@ class BTPayPalClient_Tests: XCTestCase { ] ]) + mockWebAuthenticationSession.cannedResponseURL = URL(string: "https://www.paypal.com/checkout/success") + let request = BTPayPalCheckoutRequest(amount: "1") payPalClient.tokenize(request) { _, _ in } XCTAssertEqual(mockAPIClient.postedPayPalContextID, "BA-Random-Value") + XCTAssertEqual(mockAPIClient.postedLinkType, "deeplink") + 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") + XCTAssertTrue(mockAPIClient.postedAnalyticsEvents.contains("paypal:tokenize:handle-return:started")) } // MARK: - Browser switch @@ -311,7 +367,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) @@ -328,7 +384,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) @@ -343,7 +399,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") @@ -360,7 +416,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") @@ -378,7 +434,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! @@ -401,7 +457,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")) } @@ -412,7 +468,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) @@ -431,7 +487,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) @@ -455,7 +511,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 { @@ -526,7 +582,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") @@ -594,7 +650,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") @@ -622,7 +678,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") } } @@ -631,7 +687,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! @@ -641,6 +697,280 @@ class BTPayPalClient_Tests: XCTestCase { XCTAssertEqual(metaParameters["sessionId"] as? String, mockAPIClient.metadata.sessionID) } + // MARK: - App Switch - canHandleReturnURL + + 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: - App Switch - tokenize + + func testTokenizeVaultAccount_whenPayPalAppApprovalURLPresent_attemptsAppSwitchWithParameters() async { + let fakeApplication = FakeApplication() + payPalClient.application = fakeApplication + + mockAPIClient.cannedResponseBody = BTJSON(value: [ + "agreementSetup": [ + "paypalAppApprovalUrl": "https://www.some-url.com/some-path?ba_token=value1" + ] + ]) + + 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, "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`") + } + } + + 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 + ) + + 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 + payPalClient.application = fakeApplication + + let vaultRequest = BTPayPalVaultRequest( + userAuthenticationEmail: "fake@gmail.com", + enablePayPalAppSwitch: true + ) + + mockAPIClient.cannedResponseBody = BTJSON(value: [ + "agreementSetup": [ + "paypalAppApprovalUrl": "https://www.some-url.com/some-path?ba_token=value1" + ] + ]) + + 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", + 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") + + 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 + ) + 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 + ) + 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) + } + + func testIsiOSAppSwitchAvailable_whenApplicationCanOpenPayPalInAppURL_returnsTrueAndSendsAnalytics() { + let fakeApplication = FakeApplication() + payPalClient.application = fakeApplication + payPalClient.payPalAppInstalled = true + + let vaultRequest = BTPayPalVaultRequest( + userAuthenticationEmail: "fake@gmail.com", + enablePayPalAppSwitch: true + ) + + mockAPIClient.cannedResponseBody = BTJSON(value: [ + "agreementSetup": [ + "paypalAppApprovalUrl": "https://www.some-url.com/some-path?token=value1" + ] + ]) + + 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://www.paypal.com") + } + + func testIsiOSAppSwitchAvailable_whenApplicationCantOpenPayPalInAppURL_returnsFalseAndSendsAnalytics() { + let fakeApplication = FakeApplication() + fakeApplication.cannedCanOpenURL = false + payPalClient.application = fakeApplication + + let vaultRequest = BTPayPalVaultRequest( + userAuthenticationEmail: "fake@gmail.com", + enablePayPalAppSwitch: true + ) + + mockAPIClient.cannedResponseBody = BTJSON(value: [ + "agreementSetup": [ + "paypalAppApprovalUrl": "https://www.some-url.com/some-path?token=value1" + ] + ]) + + 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() { @@ -667,7 +997,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")) } diff --git a/UnitTests/BraintreePayPalTests/BTPayPalRequest_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalRequest_Tests.swift index c8538902aa..304d349f6c 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalRequest_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalRequest_Tests.swift @@ -93,4 +93,16 @@ 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_whenInitialized_setsAllRequiredValues() { + let request = BTPayPalVaultRequest( + userAuthenticationEmail: "fake@gmail.com", + enablePayPalAppSwitch: true + ) + + XCTAssertEqual(request.userAuthenticationEmail, "fake@gmail.com") + XCTAssertTrue(request.enablePayPalAppSwitch) + } } 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) + } +} diff --git a/UnitTests/BraintreePayPalTests/BTPayPalVaultRequest_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalVaultRequest_Tests.swift index c2baf2407a..2036a75bb0 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,18 @@ class BTPayPalVaultRequest_Tests: XCTestCase { XCTAssertEqual(shippingParams["country_code"], "US") XCTAssertEqual(shippingParams["recipient_name"], "Recipient") } + + func testParameters_withEnablePayPalAppSwitchTrue_returnsAllParams() { + let request = BTPayPalVaultRequest( + userAuthenticationEmail: "sally@gmail.com", + enablePayPalAppSwitch: true + ) + + 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+")) + XCTAssertTrue((parameters["os_type"] as! String).matches("iOS|iPadOS")) + XCTAssertEqual(parameters["merchant_app_return_url"] as? String, "some-url") + } } 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 + } }