From e9b5a7e040d2b848181aee14220433954a68771a Mon Sep 17 00:00:00 2001 From: agedd <105314544+agedd@users.noreply.github.com> Date: Fri, 22 Nov 2024 13:29:49 -0600 Subject: [PATCH 01/12] Update `BTPayPalRequest` with App Switch Properties (#1465) * add initial commits for updating `BTPayPalRequest` with app switch properties * add changelog.md entry * address pr feedback * cleanup * cleanup doc string * address pr feedback --- CHANGELOG.md | 3 + .../BTPayPalCheckoutRequest.swift | 57 ++++++++-- Sources/BraintreePayPal/BTPayPalRequest.swift | 104 +++++++++++------- .../BTPayPalVaultBaseRequest.swift | 21 +++- .../BTPayPalVaultRequest.swift | 46 +++----- 5 files changed, 144 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 156f060520..46a37ceb5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## unreleased * BraintreePayPal * Add `BTPayPalRequest.userPhoneNumber` optional property + * Add PayPal App Switch checkout Flow (BETA) + * Add `BTPayPalCheckoutRequest(userAuthenticationEmail:enablePayPalAppSwitch:amount:intent:userAction: offerPayLater:currencyCode:requestBillingAgreement:)` + * **Note:** This feature is currently in beta and may change or be removed in future releases. * BraintreeVenmo * Send `url` in `event_params` for App Switch events to PayPal's analytics service (FPTI) diff --git a/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift b/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift index cec57840b1..60c7d9c899 100644 --- a/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift @@ -81,11 +81,44 @@ import BraintreeCore /// Optional: If set to `true`, this enables the Checkout with Vault flow, where the customer will be prompted to consent to a billing agreement during checkout. Defaults to `false`. public var requestBillingAgreement: 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: - Initializers + + /// Initializes a PayPal Checkout 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. + /// - amount: Required: Used for a one-time payment. Amount must be greater than or equal to zero, may optionally contain exactly 2 decimal places separated by '.' and is limited to 7 digits before the decimal point. + /// - intent: Optional: Payment intent. Defaults to `.authorize`. Only applies to PayPal Checkout. + /// - userAction: Optional: Changes the call-to-action in the PayPal Checkout flow. Defaults to `.none`. + /// - offerPayLater: Optional: Offers PayPal Pay Later if the customer qualifies. Defaults to `false`. Only available with PayPal Checkout. + /// - currencyCode: Optional: A three-character ISO-4217 ISO currency code to use for the transaction. Defaults to merchant currency code if not set. + /// See https://developer.paypal.com/docs/api/reference/currency-codes/ for a list of supported currency codes. + /// - requestBillingAgreement: Optional: If set to `true`, this enables the Checkout with Vault flow, where the customer will be prompted to consent to a billing agreement + /// during checkout. 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, + amount: String, + intent: BTPayPalRequestIntent = .authorize, + userAction: BTPayPalRequestUserAction = .none, + offerPayLater: Bool = false, + currencyCode: String? = nil, + requestBillingAgreement: Bool = false + ) { + self.init( + amount: amount, + intent: intent, + userAction: userAction, + offerPayLater: offerPayLater, + currencyCode: currencyCode, + requestBillingAgreement: requestBillingAgreement, + userAuthenticationEmail: userAuthenticationEmail + ) + super.enablePayPalAppSwitch = enablePayPalAppSwitch + } /// Initializes a PayPal Native Checkout request /// - Parameters: @@ -98,13 +131,15 @@ import BraintreeCore /// See https://developer.paypal.com/docs/api/reference/currency-codes/ for a list of supported currency codes. /// - requestBillingAgreement: Optional: If set to `true`, this enables the Checkout with Vault flow, where the customer will be prompted to consent to a billing agreement /// during checkout. 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( amount: String, intent: BTPayPalRequestIntent = .authorize, userAction: BTPayPalRequestUserAction = .none, offerPayLater: Bool = false, currencyCode: String? = nil, - requestBillingAgreement: Bool = false + requestBillingAgreement: Bool = false, + userAuthenticationEmail: String? = nil ) { self.amount = amount self.intent = intent @@ -112,8 +147,12 @@ import BraintreeCore self.offerPayLater = offerPayLater self.currencyCode = currencyCode self.requestBillingAgreement = requestBillingAgreement - - super.init(hermesPath: "v1/paypal_hermes/create_payment_resource", paymentType: .checkout) + + super.init( + hermesPath: "v1/paypal_hermes/create_payment_resource", + paymentType: .checkout, + userAuthenticationEmail: userAuthenticationEmail + ) } // MARK: Public Methods @@ -137,10 +176,6 @@ import BraintreeCore if currencyCode != nil { checkoutParameters["currency_iso_code"] = currencyCode } - - if let userAuthenticationEmail, !userAuthenticationEmail.isEmpty { - checkoutParameters["payer_email"] = userAuthenticationEmail - } if userAction != .none, var experienceProfile = baseParameters["experience_profile"] as? [String: Any] { experienceProfile["user_action"] = userAction.stringValue diff --git a/Sources/BraintreePayPal/BTPayPalRequest.swift b/Sources/BraintreePayPal/BTPayPalRequest.swift index c7d733d977..2a75a16552 100644 --- a/Sources/BraintreePayPal/BTPayPalRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalRequest.swift @@ -8,7 +8,7 @@ import BraintreeCore /// Checkout case checkout - + /// Vault case vault @@ -24,24 +24,24 @@ import BraintreeCore /// Use this option to specify the PayPal page to display when a user lands on the PayPal site to complete the payment. @objc public enum BTPayPalRequestLandingPageType: Int { - + /// Default case none // Obj-C enums cannot be nil; this default option is used to make `landingPageType` optional for merchants - + /// Login case login - + /// Billing case billing - + var stringValue: String? { switch self { case .login: return "login" - + case .billing: return "billing" - + default: return nil } @@ -51,48 +51,48 @@ import BraintreeCore /// Base options for PayPal Checkout and PayPal Vault flows. /// - Note: Do not instantiate this class directly. Instead, use BTPayPalCheckoutRequest or BTPayPalVaultRequest. @objcMembers open class BTPayPalRequest: NSObject { - + // MARK: - Public Properties - + /// Defaults to false. When set to true, the shipping address selector will be displayed. public var isShippingAddressRequired: Bool - + /// Defaults to false. Set to true to enable user editing of the shipping address. /// - Note: Only applies when `shippingAddressOverride` is set. public var isShippingAddressEditable: Bool - + /// Optional: A locale code to use for the transaction. public var localeCode: BTPayPalLocaleCode - + /// Optional: A valid shipping address to be displayed in the transaction flow. An error will occur if this address is not valid. public var shippingAddressOverride: BTPostalAddress? - + /// Optional: Landing page type. Defaults to `.none`. /// - Note: Setting the BTPayPalRequest's landingPageType changes the PayPal page to display when a user lands on the PayPal site to complete the payment. /// `.login` specifies a PayPal account login page is used. /// `.billing` specifies a non-PayPal account landing page is used. public var landingPageType: BTPayPalRequestLandingPageType - + /// Optional: The merchant name displayed inside of the PayPal flow; defaults to the company name on your Braintree account public var displayName: String? - + /// Optional: A non-default merchant account to use for tokenization. public var merchantAccountID: String? - + /// Optional: The line items for this transaction. It can include up to 249 line items. public var lineItems: [BTPayPalLineItem]? - + /// Optional: Display a custom description to the user for a billing agreement. For Checkout with Vault flows, you must also set /// `requestBillingAgreement` to `true` on your `BTPayPalCheckoutRequest`. public var billingAgreementDescription: String? - + /// Optional: A risk correlation ID created with Set Transaction Context on your server. public var riskCorrelationID: String? - + /// :nodoc: Exposed publicly for use by PayPal Native Checkout module. This property is not covered by semantic versioning. @_documentation(visibility: private) public var hermesPath: String - + /// :nodoc: Exposed publicly for use by PayPal Native Checkout module. This property is not covered by semantic versioning. @_documentation(visibility: private) public var paymentType: BTPayPalPaymentType @@ -100,13 +100,22 @@ import BraintreeCore /// Optional: A user's phone number to initiate a quicker authentication flow in the scenario where the user has a PayPal account /// identified with the same phone number. public var userPhoneNumber: BTPayPalPhoneNumber? - + + /// 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`. + /// - Warning: This property is currently in beta and may change or be removed in future releases. + var enablePayPalAppSwitch: Bool + // MARK: - Static Properties static let callbackURLHostAndPath: String = "onetouch/v1/" - + // MARK: - Initializer - + init( hermesPath: String, paymentType: BTPayPalPaymentType, @@ -120,7 +129,9 @@ import BraintreeCore lineItems: [BTPayPalLineItem]? = nil, billingAgreementDescription: String? = nil, riskCorrelationId: String? = nil, - userPhoneNumber: BTPayPalPhoneNumber? = nil + userPhoneNumber: BTPayPalPhoneNumber? = nil, + userAuthenticationEmail: String? = nil, + enablePayPalAppSwitch: Bool = false ) { self.hermesPath = hermesPath self.paymentType = paymentType @@ -135,10 +146,12 @@ import BraintreeCore self.billingAgreementDescription = billingAgreementDescription self.riskCorrelationID = riskCorrelationId self.userPhoneNumber = userPhoneNumber + self.userAuthenticationEmail = userAuthenticationEmail + self.enablePayPalAppSwitch = enablePayPalAppSwitch } - + // 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 func parameters( @@ -147,43 +160,58 @@ import BraintreeCore isPayPalAppInstalled: Bool = false ) -> [String: Any] { var experienceProfile: [String: Any] = [:] - + experienceProfile["no_shipping"] = !isShippingAddressRequired experienceProfile["brand_name"] = displayName != nil ? displayName : configuration.json?["paypal"]["displayName"].asString() - + if landingPageType.stringValue != nil { experienceProfile["landing_page_type"] = landingPageType.stringValue } - + if localeCode.stringValue != nil { experienceProfile["locale_code"] = localeCode.stringValue } - + experienceProfile["address_override"] = shippingAddressOverride != nil ? !isShippingAddressEditable : false - + var parameters: [String: Any] = [:] - + if merchantAccountID != nil { parameters["merchant_account_id"] = merchantAccountID } - + if riskCorrelationID != nil { parameters["correlation_id"] = riskCorrelationID } - + if let lineItems, !lineItems.isEmpty { let lineItemsArray = lineItems.compactMap { $0.requestParameters() } parameters["line_items"] = lineItemsArray } - if let userPhoneNumberDict = try? userPhoneNumber?.toDictionary() { - parameters["phone_number"] = userPhoneNumberDict + if let userPhoneNumberDictionary = try? userPhoneNumber?.toDictionary() { + parameters["phone_number"] = userPhoneNumberDictionary } - + + if let userAuthenticationEmail, !userAuthenticationEmail.isEmpty { + parameters["payer_email"] = userAuthenticationEmail + } + parameters["return_url"] = BTCoreConstants.callbackURLScheme + "://\(BTPayPalRequest.callbackURLHostAndPath)success" parameters["cancel_url"] = BTCoreConstants.callbackURLScheme + "://\(BTPayPalRequest.callbackURLHostAndPath)cancel" parameters["experience_profile"] = experienceProfile - + + if let universalLink, enablePayPalAppSwitch, isPayPalAppInstalled { + let appSwitchParameters: [String: Any] = [ + "launch_paypal_app": enablePayPalAppSwitch, + "os_version": UIDevice.current.systemVersion, + "os_type": UIDevice.current.systemName, + "merchant_app_return_url": universalLink.absoluteString + ] + + return parameters.merging(appSwitchParameters) { $1 } + } + return parameters } } diff --git a/Sources/BraintreePayPal/BTPayPalVaultBaseRequest.swift b/Sources/BraintreePayPal/BTPayPalVaultBaseRequest.swift index 7db4ac2d38..a837fe259d 100644 --- a/Sources/BraintreePayPal/BTPayPalVaultBaseRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalVaultBaseRequest.swift @@ -13,14 +13,25 @@ import BraintreeCore 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) { + /// - 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`. + public init( + offerCredit: Bool = false, + userAuthenticationEmail: String? = nil, + enablePayPalAppSwitch: Bool = false + ) { self.offerCredit = offerCredit - - super.init(hermesPath: "v1/paypal_hermes/setup_billing_agreement", paymentType: .vault) + + super.init( + hermesPath: "v1/paypal_hermes/setup_billing_agreement", + paymentType: .vault, + userAuthenticationEmail: userAuthenticationEmail, + enablePayPalAppSwitch: enablePayPalAppSwitch + ) } // MARK: Public Methods @@ -32,7 +43,7 @@ import BraintreeCore universalLink: URL? = nil, isPayPalAppInstalled: Bool = false ) -> [String: Any] { - let baseParameters = super.parameters(with: configuration) + let baseParameters = super.parameters(with: configuration, universalLink: universalLink, isPayPalAppInstalled: isPayPalAppInstalled) var vaultParameters: [String: Any] = ["offer_paypal_credit": offerCredit] if let billingAgreementDescription { diff --git a/Sources/BraintreePayPal/BTPayPalVaultRequest.swift b/Sources/BraintreePayPal/BTPayPalVaultRequest.swift index a39cee1cd4..c5358a91ba 100644 --- a/Sources/BraintreePayPal/BTPayPalVaultRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalVaultRequest.swift @@ -6,18 +6,8 @@ import BraintreeCore /// Options for the PayPal Vault flow. @objcMembers public class BTPayPalVaultRequest: BTPayPalVaultBaseRequest { - - // 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`. - /// - Warning: This property is currently in beta and may change or be removed in future releases. - var enablePayPalAppSwitch: Bool = false /// Optional: Recurring billing plan type, or charge pattern. var recurringBillingPlanType: BTPayPalRecurringBillingPlanType? @@ -26,7 +16,7 @@ import BraintreeCore var recurringBillingDetails: BTPayPalRecurringBillingDetails? // 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. @@ -39,10 +29,13 @@ import BraintreeCore enablePayPalAppSwitch: Bool, offerCredit: Bool = false ) { - self.init(offerCredit: offerCredit, userAuthenticationEmail: userAuthenticationEmail) - self.enablePayPalAppSwitch = enablePayPalAppSwitch + self.init( + offerCredit: offerCredit, + userAuthenticationEmail: userAuthenticationEmail + ) + super.enablePayPalAppSwitch = enablePayPalAppSwitch } - + /// Initializes a PayPal Vault request /// - Parameters: /// - offerCredit: Optional: Offers PayPal Credit if the customer qualifies. Defaults to `false`. @@ -57,8 +50,10 @@ import BraintreeCore ) { self.recurringBillingDetails = recurringBillingDetails self.recurringBillingPlanType = recurringBillingPlanType - self.userAuthenticationEmail = userAuthenticationEmail - super.init(offerCredit: offerCredit) + super.init( + offerCredit: offerCredit, + userAuthenticationEmail: userAuthenticationEmail + ) } public override func parameters( @@ -66,22 +61,7 @@ import BraintreeCore universalLink: URL? = nil, isPayPalAppInstalled: Bool = false ) -> [String: Any] { - var baseParameters = super.parameters(with: configuration) - - if let userAuthenticationEmail { - baseParameters["payer_email"] = userAuthenticationEmail - } - - if let universalLink, enablePayPalAppSwitch, isPayPalAppInstalled { - let appSwitchParameters: [String: Any] = [ - "launch_paypal_app": enablePayPalAppSwitch, - "os_version": UIDevice.current.systemVersion, - "os_type": UIDevice.current.systemName, - "merchant_app_return_url": universalLink.absoluteString - ] - - return baseParameters.merging(appSwitchParameters) { $1 } - } + var baseParameters = super.parameters(with: configuration, universalLink: universalLink, isPayPalAppInstalled: isPayPalAppInstalled) if let recurringBillingPlanType { baseParameters["plan_type"] = recurringBillingPlanType.rawValue From 26cfe786220f8e5ce5215c17ad19b05fc3289630 Mon Sep 17 00:00:00 2001 From: ageddam Date: Fri, 22 Nov 2024 14:19:29 -0600 Subject: [PATCH 02/12] add app-switch support for one-time-checkout --- .../PayPalWebCheckoutViewController.swift | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index 91e7158dea..e1777050e1 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -88,18 +88,19 @@ 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 payPalAppSwitchForCheckoutButton = createButton(title: "PayPal App Switch", action: #selector(tappedPayPalAppSwitchForCheckout)) + let payPalAppSwitchForVaultButton = createButton(title: "PayPal App Switch", action: #selector(tappedPayPalAppSwitchForVault)) let oneTimeCheckoutStackView = buttonsStackView(label: "1-Time Checkout", views: [ payLaterToggle, newPayPalCheckoutToggle, - payPalCheckoutButton + payPalAppSwitchForCheckoutButton ]) oneTimeCheckoutStackView.spacing = 12 let vaultStackView = buttonsStackView(label: "Vault", views: [ rbaDataToggle, payPalVaultButton, - payPalAppSwitchButton + payPalAppSwitchForVaultButton ]) vaultStackView.spacing = 12 @@ -221,8 +222,36 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { self.completionBlock(nonce) } } + + @objc func tappedPayPalAppSwitchForCheckout(_ 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 = BTPayPalCheckoutRequest( + userAuthenticationEmail: userEmail, + enablePayPalAppSwitch: true, + amount: "10.00" + ) + + payPalClient.tokenize(request) { nonce, error in + sender.isEnabled = true + + guard let nonce else { + self.progressBlock(error?.localizedDescription) + return + } + + self.completionBlock(nonce) + } + } - @objc func tappedPayPalAppSwitch(_ sender: UIButton) { + @objc func tappedPayPalAppSwitchForVault(_ sender: UIButton) { sender.setTitle("Processing...", for: .disabled) sender.isEnabled = false From 5f68972f01ee48a7042a9cd4df75049b3d842e1d Mon Sep 17 00:00:00 2001 From: ageddam Date: Fri, 22 Nov 2024 14:21:37 -0600 Subject: [PATCH 03/12] Revert "add app-switch support for one-time-checkout" This reverts commit 26cfe786220f8e5ce5215c17ad19b05fc3289630. --- .../PayPalWebCheckoutViewController.swift | 37 ++----------------- 1 file changed, 4 insertions(+), 33 deletions(-) diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index e1777050e1..91e7158dea 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -88,19 +88,18 @@ 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 payPalAppSwitchForCheckoutButton = createButton(title: "PayPal App Switch", action: #selector(tappedPayPalAppSwitchForCheckout)) - let payPalAppSwitchForVaultButton = createButton(title: "PayPal App Switch", action: #selector(tappedPayPalAppSwitchForVault)) + let payPalAppSwitchButton = createButton(title: "PayPal App Switch", action: #selector(tappedPayPalAppSwitch)) let oneTimeCheckoutStackView = buttonsStackView(label: "1-Time Checkout", views: [ payLaterToggle, newPayPalCheckoutToggle, - payPalAppSwitchForCheckoutButton + payPalCheckoutButton ]) oneTimeCheckoutStackView.spacing = 12 let vaultStackView = buttonsStackView(label: "Vault", views: [ rbaDataToggle, payPalVaultButton, - payPalAppSwitchForVaultButton + payPalAppSwitchButton ]) vaultStackView.spacing = 12 @@ -222,36 +221,8 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { self.completionBlock(nonce) } } - - @objc func tappedPayPalAppSwitchForCheckout(_ 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 = BTPayPalCheckoutRequest( - userAuthenticationEmail: userEmail, - enablePayPalAppSwitch: true, - amount: "10.00" - ) - - payPalClient.tokenize(request) { nonce, error in - sender.isEnabled = true - - guard let nonce else { - self.progressBlock(error?.localizedDescription) - return - } - - self.completionBlock(nonce) - } - } - @objc func tappedPayPalAppSwitchForVault(_ sender: UIButton) { + @objc func tappedPayPalAppSwitch(_ sender: UIButton) { sender.setTitle("Processing...", for: .disabled) sender.isEnabled = false From daaebcac693193979a30c7c9d6325e74db71ff4e Mon Sep 17 00:00:00 2001 From: agedd <105314544+agedd@users.noreply.github.com> Date: Mon, 25 Nov 2024 14:42:23 -0600 Subject: [PATCH 04/12] add app-switch demo app support for one-time checkout flow (#1471) --- .../PayPalWebCheckoutViewController.swift | 44 +++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index 91e7158dea..99bff5cedf 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -88,18 +88,26 @@ 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 payPalAppSwitchForCheckoutButton = createButton( + title: "PayPal App Switch - Checkout", + action: #selector(tappedPayPalAppSwitchForCheckout) + ) + let payPalAppSwitchForVaultButton = createButton( + title: "PayPal App Switch - Vault", + action: #selector(tappedPayPalAppSwitchForVault) + ) let oneTimeCheckoutStackView = buttonsStackView(label: "1-Time Checkout", views: [ payLaterToggle, newPayPalCheckoutToggle, - payPalCheckoutButton + payPalCheckoutButton, + payPalAppSwitchForCheckoutButton ]) oneTimeCheckoutStackView.spacing = 12 let vaultStackView = buttonsStackView(label: "Vault", views: [ rbaDataToggle, payPalVaultButton, - payPalAppSwitchButton + payPalAppSwitchForVaultButton ]) vaultStackView.spacing = 12 @@ -221,8 +229,36 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { self.completionBlock(nonce) } } + + @objc func tappedPayPalAppSwitchForCheckout(_ 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 = BTPayPalCheckoutRequest( + userAuthenticationEmail: userEmail, + enablePayPalAppSwitch: true, + amount: "10.00" + ) + + payPalClient.tokenize(request) { nonce, error in + sender.isEnabled = true + + guard let nonce else { + self.progressBlock(error?.localizedDescription) + return + } + + self.completionBlock(nonce) + } + } - @objc func tappedPayPalAppSwitch(_ sender: UIButton) { + @objc func tappedPayPalAppSwitchForVault(_ sender: UIButton) { sender.setTitle("Processing...", for: .disabled) sender.isEnabled = false From a8237eadc332c1bd64be7ab46b620a35b3bd6d72 Mon Sep 17 00:00:00 2001 From: agedd <105314544+agedd@users.noreply.github.com> Date: Tue, 21 Jan 2025 13:02:29 -0600 Subject: [PATCH 05/12] update parsing app switch url for 1-time checkout flow (#1500) --- Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift | 3 ++- Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift b/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift index de2766b5c0..84b1e273d0 100644 --- a/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift +++ b/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift @@ -51,7 +51,8 @@ struct BTPayPalApprovalURLParser { url = payPalAppRedirectURL } else if let approvalURL = body["paymentResource"]["redirectUrl"].asURL() ?? body["agreementSetup"]["approvalUrl"].asURL() { - redirectType = .webBrowser(url: approvalURL) + let launchPayPalApp = body["launchPayPalApp"].asBool() ?? false + redirectType = launchPayPalApp ? .payPalApp(url: approvalURL) : .webBrowser(url: approvalURL) url = approvalURL } else { return nil diff --git a/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift b/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift index 60c7d9c899..4f9b6cb8b3 100644 --- a/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift @@ -164,7 +164,7 @@ import BraintreeCore universalLink: URL? = nil, isPayPalAppInstalled: Bool = false ) -> [String: Any] { - var baseParameters = super.parameters(with: configuration) + var baseParameters = super.parameters(with: configuration, universalLink: universalLink, isPayPalAppInstalled: isPayPalAppInstalled) var checkoutParameters: [String: Any] = [ "intent": intent.stringValue, "amount": amount, From a7424b612db31db93b9263b6bf8ee1d6a3906ebd Mon Sep 17 00:00:00 2001 From: agedd <105314544+agedd@users.noreply.github.com> Date: Mon, 27 Jan 2025 16:02:19 -0600 Subject: [PATCH 06/12] Launch PayPal App For Checkout Flow (#1504) * make changes to launch pp app for checkout flow * address failing unit test * address pr comments * address pr feedback * disable unit test * clean up and address pr feedback * clean up and re-add test/relevant errors * fix warning * address swift lint error --- Sources/BraintreePayPal/BTPayPalClient.swift | 16 +++++++++------- Sources/BraintreePayPal/BTPayPalError.swift | 11 +++++++++-- .../BTPayPalClient_Tests.swift | 12 ++++++------ 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index 0362ed1597..2eb68ac56e 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -366,12 +366,14 @@ import BraintreeDataCollector switch approvalURL.redirectType { case .payPalApp(let url): - guard let baToken = approvalURL.baToken else { - self.notifyFailure(with: BTPayPalError.missingBAToken, completion: completion) + guard (self.isVaultRequest ? approvalURL.baToken : approvalURL.ecToken) != nil else { + self.notifyFailure( + with: self.isVaultRequest ? BTPayPalError.missingBAToken : BTPayPalError.missingECToken, + completion: completion + ) return } - - self.launchPayPalApp(with: url, baToken: baToken, completion: completion) + self.launchPayPalApp(with: url, completion: completion) case .webBrowser(let url): self.handlePayPalRequest(with: url, paymentType: request.paymentType, completion: completion) } @@ -381,7 +383,6 @@ import BraintreeDataCollector private func launchPayPalApp( with payPalAppRedirectURL: URL, - baToken: String, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void ) { apiClient.sendAnalyticsEvent( @@ -392,12 +393,13 @@ import BraintreeDataCollector ) var urlComponents = URLComponents(url: payPalAppRedirectURL, resolvingAgainstBaseURL: true) - urlComponents?.queryItems = [ - URLQueryItem(name: "ba_token", value: baToken), + let additionalQueryItems: [URLQueryItem] = [ URLQueryItem(name: "source", value: "braintree_sdk"), URLQueryItem(name: "switch_initiated_time", value: String(Int(round(Date().timeIntervalSince1970 * 1000)))) ] + urlComponents?.queryItems?.append(contentsOf: additionalQueryItems) + guard let redirectURL = urlComponents?.url else { self.notifyFailure(with: BTPayPalError.invalidURL("Unable to construct PayPal app redirect URL."), completion: completion) return diff --git a/Sources/BraintreePayPal/BTPayPalError.swift b/Sources/BraintreePayPal/BTPayPalError.swift index 3a947c3dd2..23ea735aef 100644 --- a/Sources/BraintreePayPal/BTPayPalError.swift +++ b/Sources/BraintreePayPal/BTPayPalError.swift @@ -38,12 +38,15 @@ public enum BTPayPalError: Error, CustomNSError, LocalizedError, Equatable { /// 11. App Switch could not complete case appSwitchFailed - + /// 12. Missing BA Token for App Switch case missingBAToken - + /// 13. Missing PayPal Request case missingPayPalRequest + + /// 14. Missing EC Token for App Switch + case missingECToken public static var errorDomain: String { "com.braintreepayments.BTPayPalErrorDomain" @@ -79,6 +82,8 @@ public enum BTPayPalError: Error, CustomNSError, LocalizedError, Equatable { return 12 case .missingPayPalRequest: return 13 + case .missingECToken: + return 14 } } @@ -114,6 +119,8 @@ public enum BTPayPalError: Error, CustomNSError, LocalizedError, Equatable { return "Missing BA Token for PayPal App Switch." case .missingPayPalRequest: return "The PayPal Request was missing or invalid." + case .missingECToken: + return "Missing EC Token for PayPal App Switch." } } diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index 7fff4ad489..08f6149f62 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -781,33 +781,33 @@ 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 ) - + 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) } From b67f8f4d4ae4ae84d1e752ec78bdd12e398a2559 Mon Sep 17 00:00:00 2001 From: agedd <105314544+agedd@users.noreply.github.com> Date: Mon, 10 Feb 2025 15:47:13 -0600 Subject: [PATCH 07/12] ECS5 App Switch Feature Bug Fixes (#1512) * add token param * update parsing logic * fix linkType bug * remove token parameter as we expect gw to pass this in redirectURL --- Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift | 2 +- Sources/BraintreePayPal/BTPayPalClient.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift b/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift index 84b1e273d0..41dd5c9260 100644 --- a/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift +++ b/Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift @@ -51,7 +51,7 @@ struct BTPayPalApprovalURLParser { url = payPalAppRedirectURL } else if let approvalURL = body["paymentResource"]["redirectUrl"].asURL() ?? body["agreementSetup"]["approvalUrl"].asURL() { - let launchPayPalApp = body["launchPayPalApp"].asBool() ?? false + let launchPayPalApp = body["paymentResource"]["launchPayPalApp"].asBool() ?? false redirectType = launchPayPalApp ? .payPalApp(url: approvalURL) : .webBrowser(url: approvalURL) url = approvalURL } else { diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index 2eb68ac56e..dd1b4554c5 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -311,7 +311,7 @@ import BraintreeDataCollector request: BTPayPalRequest, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void ) { - linkType = (request as? BTPayPalVaultRequest)?.enablePayPalAppSwitch == true ? .universal : .deeplink + linkType = request.enablePayPalAppSwitch == true ? .universal : .deeplink apiClient.sendAnalyticsEvent(BTPayPalAnalytics.tokenizeStarted, isVaultRequest: isVaultRequest, linkType: linkType) apiClient.fetchOrReturnRemoteConfiguration { configuration, error in From 4167bd63517f324c1ef340ddb03c0c6a30a335f6 Mon Sep 17 00:00:00 2001 From: ageddam Date: Wed, 12 Feb 2025 12:08:04 -0600 Subject: [PATCH 08/12] pull in main changes --- Braintree.podspec | 2 +- Braintree.xcodeproj/project.pbxproj | 24 +++ CHANGELOG.md | 37 ++++- .../PayPalWebCheckoutViewController.swift | 12 +- .../ShopperInsightsViewController.swift | 59 ++++++-- .../Features/VenmoViewController.swift | 18 ++- .../Braintree-Demo-Info.plist | 4 +- .../xcshareddata/xcschemes/Demo.xcscheme | 10 ++ .../ThreeDSecure_UITests_Extensions.swift | 22 +-- .../ThreeDSecure_V2_UITests.swift | 67 +-------- .../Analytics/FPTIBatchData.swift | 28 +++- .../Authorization/BTClientToken.swift | 8 +- Sources/BraintreeCore/BTAPIClient.swift | 14 +- .../BraintreeCore/BTAppContextSwitcher.swift | 17 ++- Sources/BraintreeCore/BTCoreConstants.swift | 2 +- Sources/BraintreeCore/Info.plist | 4 +- .../BTDataCollector.swift | 7 +- .../BTContactInformation.swift | 19 +++ .../BTPayPalCheckoutRequest.swift | 26 +++- Sources/BraintreePayPal/BTPayPalClient.swift | 77 ++++++---- Sources/BraintreePayPal/BTPayPalRequest.swift | 11 +- .../BTButtonOrder.swift | 33 +++++ .../BTButtonType.swift | 15 ++ .../BTExperimentType.swift | 21 +++ .../BraintreeShopperInsights/BTPageType.swift | 45 ++++++ .../BTPresentmentDetails.swift | 27 ++++ .../BTShopperInsightsAnalytics.swift | 8 +- .../BTShopperInsightsClient.swift | 83 ++++++----- .../BTThreeDSecureClient.swift | 11 +- .../BTThreeDSecureRequest.swift | 4 + .../BTThreeDSecureV2Provider.swift | 4 + .../BTVenmoAppSwitchRedirectURL.swift | 16 +- .../BTVenmoAppSwitchReturnURL.swift | 9 +- Sources/BraintreeVenmo/BTVenmoClient.swift | 41 ++++-- .../Analytics/FPTIBatchData_Tests.swift | 14 +- .../BTPayPalCheckoutRequest_Tests.swift | 39 +++++ .../BTPayPalClient_Tests.swift | 27 ++++ .../BTPayPalRequest_Tests.swift | 2 + .../BTShopperInsightsAnalytics_Tests.swift | 6 +- .../BTShopperInsightsClient_Tests.swift | 138 +++++++++++++++--- .../BraintreeTestShared/MockAPIClient.swift | 33 +++-- .../BTThreeDSecureClient_Tests.swift | 22 ++- .../MockCardinalSession.swift | 6 +- .../BTVenmoAppSwitchRedirectURL_Tests.swift | 15 +- .../BTVenmoClient_Tests.swift | 14 ++ 45 files changed, 818 insertions(+), 283 deletions(-) create mode 100644 Sources/BraintreePayPal/BTContactInformation.swift create mode 100644 Sources/BraintreeShopperInsights/BTButtonOrder.swift create mode 100644 Sources/BraintreeShopperInsights/BTButtonType.swift create mode 100644 Sources/BraintreeShopperInsights/BTExperimentType.swift create mode 100644 Sources/BraintreeShopperInsights/BTPageType.swift create mode 100644 Sources/BraintreeShopperInsights/BTPresentmentDetails.swift diff --git a/Braintree.podspec b/Braintree.podspec index f5d273c832..786e101d46 100644 --- a/Braintree.podspec +++ b/Braintree.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "Braintree" - s.version = "6.24.0" + s.version = "6.28.0" s.summary = "Braintree iOS SDK: Helps you accept card and alternative payments in your iOS app." s.description = <<-DESC Braintree is a full-stack payments platform for developers diff --git a/Braintree.xcodeproj/project.pbxproj b/Braintree.xcodeproj/project.pbxproj index 38a990360c..d775c7fd8c 100644 --- a/Braintree.xcodeproj/project.pbxproj +++ b/Braintree.xcodeproj/project.pbxproj @@ -7,6 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 04AA31182D07974D0043ACAB /* BTButtonType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AA31172D0797460043ACAB /* BTButtonType.swift */; }; + 04AA311A2D0797570043ACAB /* BTPresentmentDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AA31192D0797510043ACAB /* BTPresentmentDetails.swift */; }; + 04AA311E2D0798FC0043ACAB /* BTPageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AA311D2D0798F70043ACAB /* BTPageType.swift */; }; + 04AA31202D07990E0043ACAB /* BTButtonOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AA311F2D07990A0043ACAB /* BTButtonOrder.swift */; }; + 04B001102D0CF46E00C0060D /* BTExperimentType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B0010F2D0CF46900C0060D /* BTExperimentType.swift */; }; 0917F6E42A27BDC700ACED2E /* BTVenmoLineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 096C6B2529CCDCEB00912863 /* BTVenmoLineItem.swift */; }; 09357DCB2A2FBEC10096D449 /* BTVenmoLineItem_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09357DCA2A2FBEC10096D449 /* BTVenmoLineItem_Tests.swift */; }; 1FEB89E614CB6BF0B9858EE4 /* Pods_Tests_IntegrationTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 85BD589D380436A0C9D1DEC1 /* Pods_Tests_IntegrationTests.framework */; }; @@ -55,6 +60,7 @@ 42FC237125CE0E110047C49A /* BTPayPalCheckoutRequest_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42FC237025CE0E110047C49A /* BTPayPalCheckoutRequest_Tests.swift */; }; 45227FC52C330FDE00A15018 /* MockURLSessionTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45227FC32C330FDE00A15018 /* MockURLSessionTask.swift */; }; 45227FC72C33104100A15018 /* MockBTHTTPNetworkTiming.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45227FC62C33104100A15018 /* MockBTHTTPNetworkTiming.swift */; }; + 454722AC2CEFB291000DCF4E /* BTContactInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 454722AB2CEFB291000DCF4E /* BTContactInformation.swift */; }; 457D7FC82C29CEC300EF6523 /* RepeatingTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 457D7FC72C29CEC300EF6523 /* RepeatingTimer.swift */; }; 457D7FCA2C2A250E00EF6523 /* RepeatingTimer_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 457D7FC92C2A250E00EF6523 /* RepeatingTimer_Tests.swift */; }; 458570782C34A699009CEF7A /* ConfigurationLoader_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 458570772C34A699009CEF7A /* ConfigurationLoader_Tests.swift */; }; @@ -729,6 +735,11 @@ 035A59D91EA5DE97002960C8 /* BTLocalPaymentClient_UnitTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BTLocalPaymentClient_UnitTests.swift; sourceTree = ""; }; 039A8BD91F9E993500D607E7 /* BTAmericanExpressRewardsBalance_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTAmericanExpressRewardsBalance_Tests.swift; sourceTree = ""; }; 03F921C1200EBB200076CD80 /* BTThreeDSecurePostalAddress_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTThreeDSecurePostalAddress_Tests.swift; sourceTree = ""; }; + 04AA31172D0797460043ACAB /* BTButtonType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTButtonType.swift; sourceTree = ""; }; + 04AA31192D0797510043ACAB /* BTPresentmentDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPresentmentDetails.swift; sourceTree = ""; }; + 04AA311D2D0798F70043ACAB /* BTPageType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPageType.swift; sourceTree = ""; }; + 04AA311F2D07990A0043ACAB /* BTButtonOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTButtonOrder.swift; sourceTree = ""; }; + 04B0010F2D0CF46900C0060D /* BTExperimentType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTExperimentType.swift; sourceTree = ""; }; 09357DCA2A2FBEC10096D449 /* BTVenmoLineItem_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTVenmoLineItem_Tests.swift; sourceTree = ""; }; 096C6B2529CCDCEB00912863 /* BTVenmoLineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTVenmoLineItem.swift; sourceTree = ""; }; 162174E1192D9220008DC35D /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; @@ -790,6 +801,7 @@ 42FC237025CE0E110047C49A /* BTPayPalCheckoutRequest_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTPayPalCheckoutRequest_Tests.swift; sourceTree = ""; }; 45227FC32C330FDE00A15018 /* MockURLSessionTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLSessionTask.swift; sourceTree = ""; }; 45227FC62C33104100A15018 /* MockBTHTTPNetworkTiming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBTHTTPNetworkTiming.swift; sourceTree = ""; }; + 454722AB2CEFB291000DCF4E /* BTContactInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTContactInformation.swift; sourceTree = ""; }; 457D7FC72C29CEC300EF6523 /* RepeatingTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatingTimer.swift; sourceTree = ""; }; 457D7FC92C2A250E00EF6523 /* RepeatingTimer_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatingTimer_Tests.swift; sourceTree = ""; }; 458570772C34A699009CEF7A /* ConfigurationLoader_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationLoader_Tests.swift; sourceTree = ""; }; @@ -1406,6 +1418,7 @@ 62A659A32B98CB23008DFD67 /* PrivacyInfo.xcprivacy */, 807D22F32C29ADA8009FFEA4 /* RecurringBillingMetadata */, 62B811872CC002470024A688 /* BTPayPalPhoneNumber.swift */, + 454722AB2CEFB291000DCF4E /* BTContactInformation.swift */, ); path = BraintreePayPal; sourceTree = ""; @@ -1494,8 +1507,13 @@ 804698292B27C4D70090878E /* BraintreeShopperInsights */ = { isa = PBXGroup; children = ( + 04AA311F2D07990A0043ACAB /* BTButtonOrder.swift */, + 04AA31172D0797460043ACAB /* BTButtonType.swift */, 62EA90482B63071800DD79BC /* BTEligiblePaymentMethods.swift */, 800ED7822B4F5B66007D8A30 /* BTEligiblePaymentsRequest.swift */, + 04B0010F2D0CF46900C0060D /* BTExperimentType.swift */, + 04AA311D2D0798F70043ACAB /* BTPageType.swift */, + 04AA31192D0797510043ACAB /* BTPresentmentDetails.swift */, 8037BFAF2B2CCC130017072C /* BTShopperInsightsAnalytics.swift */, 8064F38E2B1E492F0059C4CB /* BTShopperInsightsClient.swift */, 624B27F62B6AE0C2000AC08A /* BTShopperInsightsError.swift */, @@ -3291,6 +3309,7 @@ 57544F582952298900DEB7B0 /* BTPayPalAccountNonce.swift in Sources */, 8014221C2BAE935B009F9999 /* BTPayPalApprovalURLParser.swift in Sources */, BE349111294B77E100D2CF68 /* BTPayPalVaultRequest.swift in Sources */, + 454722AC2CEFB291000DCF4E /* BTContactInformation.swift in Sources */, 62B811882CC002470024A688 /* BTPayPalPhoneNumber.swift in Sources */, 807D22F52C29ADE2009FFEA4 /* BTPayPalRecurringBillingPlanType.swift in Sources */, 57544820294A2EBE00DEB7B0 /* BTPayPalCreditFinancing.swift in Sources */, @@ -3364,11 +3383,16 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 04AA311E2D0798FC0043ACAB /* BTPageType.swift in Sources */, 804698382B27C53B0090878E /* BTShopperInsightsRequest.swift in Sources */, + 04B001102D0CF46E00C0060D /* BTExperimentType.swift in Sources */, + 04AA31182D07974D0043ACAB /* BTButtonType.swift in Sources */, 624B27F72B6AE0C2000AC08A /* BTShopperInsightsError.swift in Sources */, 804698372B27C5390090878E /* BTShopperInsightsClient.swift in Sources */, 800ED7832B4F5B66007D8A30 /* BTEligiblePaymentsRequest.swift in Sources */, 8037BFB02B2CCC130017072C /* BTShopperInsightsAnalytics.swift in Sources */, + 04AA311A2D0797570043ACAB /* BTPresentmentDetails.swift in Sources */, + 04AA31202D07990E0043ACAB /* BTButtonOrder.swift in Sources */, 62EA90492B63071800DD79BC /* BTEligiblePaymentMethods.swift in Sources */, 804698392B27C53E0090878E /* BTShopperInsightsResult.swift in Sources */, ); diff --git a/CHANGELOG.md b/CHANGELOG.md index 46a37ceb5c..08927304d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,48 @@ # Braintree iOS SDK Release Notes -## unreleased +## 6.28.0 (2025-02-05) +* BraintreeVenmo + * Allow universal links to be set without a return URL scheme (fixes #1505) +* BraintreeCore + * Update to use `NSSecureCoding` protocol (fixes #1508) +* BraintreePayPal + * Add `shopperSessionID` to `BTPayPalCheckoutRequest` and `BTPayPalVaultRequest` +* BraintreeShopperInsights (BETA) + * Add `shopperSessionID` to `BTShopperInsightsClient` initializer + * Add `isPayPalAppInstalled()` and/or `isVenmoAppInstalled()` + * Replace `sendPayPalPresentedEvent()` and `sendPayPalPresentedEvent()` with `sendPresentedEvent(for:presentmentDetails:)` + * Add values to the following parameters to `presentmentDetails`: + * `experimentType` + * `pageType` + * `buttonOrder` + * Replace `sendPayPalSelectedEvent()` and `sendPayPalSelectedEvent()` with `sendSelectedEvent(for:)` + +## 6.27.0 (2025-01-23) +* BraintreePayPal + * Add `BTContactInformation` request object + * Add `BTPayPalCheckoutRequest.contactInformation` optional property + +## 6.26.0 (2025-01-21) +* BraintreePayPal + * Fix bug to ensure that `BTPayPalVaultRequest.userAuthenticationEmail` is not sent as an empty string + * Add `shippingCallbackURL` to `BTPayPalCheckoutRequest` +* BraintreeThreeDSecure + * Return error if no `dfReferenceId` is returned in the 3D Secure flow + +## 6.25.0 (2024-12-11) * BraintreePayPal * Add `BTPayPalRequest.userPhoneNumber` optional property * Add PayPal App Switch checkout Flow (BETA) * Add `BTPayPalCheckoutRequest(userAuthenticationEmail:enablePayPalAppSwitch:amount:intent:userAction: offerPayLater:currencyCode:requestBillingAgreement:)` * **Note:** This feature is currently in beta and may change or be removed in future releases. + * Send `url` in `event_params` for App Switch events to PayPal's analytics service (FPTI) * BraintreeVenmo * Send `url` in `event_params` for App Switch events to PayPal's analytics service (FPTI) + * Add `BTVenmoClient(apiClient:universalLink:)` to use Universal Links when redirecting back from the Venmo flow +* BraintreeCore + * Deprecate `BTAppContextSwitcher.sharedInstance.returnURLScheme` +* BraintreeThreeDSecure + * Add `BTThreeDSecureRequest.requestorAppURL` ## 6.24.0 (2024-10-15) * BraintreePayPal diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index 99bff5cedf..59304f0dac 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -79,9 +79,11 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { let newPayPalCheckoutToggle = Toggle(title: "New PayPal Checkout Experience") let rbaDataToggle = Toggle(title: "Recurring Billing (RBA) Data") + + let contactInformationToggle = Toggle(title: "Add Contact Information") override func viewDidLoad() { - super.heightConstraint = 400 + super.heightConstraint = 500 super.viewDidLoad() } @@ -100,6 +102,7 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { let oneTimeCheckoutStackView = buttonsStackView(label: "1-Time Checkout", views: [ payLaterToggle, newPayPalCheckoutToggle, + contactInformationToggle, payPalCheckoutButton, payPalAppSwitchForCheckoutButton ]) @@ -159,6 +162,13 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { request.offerPayLater = payLaterToggle.isOn request.intent = newPayPalCheckoutToggle.isOn ? .sale : .authorize + if contactInformationToggle.isOn { + request.contactInformation = BTContactInformation( + recipientEmail: "some@email.com", + recipientPhoneNumber: .init(countryCode: "52", nationalNumber: "123456789") + ) + } + payPalClient.tokenize(request) { nonce, error in sender.isEnabled = true diff --git a/Demo/Application/Features/ShopperInsightsViewController.swift b/Demo/Application/Features/ShopperInsightsViewController.swift index 5e6a3787a4..fe3b0c16fb 100644 --- a/Demo/Application/Features/ShopperInsightsViewController.swift +++ b/Demo/Application/Features/ShopperInsightsViewController.swift @@ -6,13 +6,15 @@ import BraintreeShopperInsights class ShopperInsightsViewController: PaymentButtonBaseViewController { - lazy var shopperInsightsClient = BTShopperInsightsClient(apiClient: apiClient) + lazy var shopperInsightsClient = BTShopperInsightsClient(apiClient: apiClient, shopperSessionID: "test-shopper-session-id") lazy var payPalClient = BTPayPalClient(apiClient: apiClient) lazy var venmoClient = BTVenmoClient(apiClient: apiClient) lazy var payPalVaultButton = createButton(title: "PayPal Vault", action: #selector(payPalVaultButtonTapped)) lazy var venmoButton = createButton(title: "Venmo", action: #selector(venmoButtonTapped)) + private var shopperSessionID = "test-shopper-session-id" + lazy var emailView: TextFieldWithLabel = { let view = TextFieldWithLabel() view.label.text = "Email" @@ -93,32 +95,58 @@ class ShopperInsightsViewController: PaymentButtonBaseViewController { let result = try await shopperInsightsClient.getRecommendedPaymentMethods(request: request, experiment: sampleExperiment) // swiftlint:disable:next line_length progressBlock("PayPal Recommended: \(result.isPayPalRecommended)\nVenmo Recommended: \(result.isVenmoRecommended)\nEligible in PayPal Network: \(result.isEligibleInPayPalNetwork)") - payPalVaultButton.isEnabled = result.isPayPalRecommended - venmoButton.isEnabled = result.isVenmoRecommended + + togglePayPalVaultButton(enabled: result.isPayPalRecommended) + toggleVenmoButton(enabled: result.isVenmoRecommended) } catch { progressBlock("Error: \(error.localizedDescription)") } } } + private func togglePayPalVaultButton(enabled: Bool) { + payPalVaultButton.isEnabled = enabled + + guard enabled else { return } + + let presentmentDetails = BTPresentmentDetails( + buttonOrder: .first, + experimentType: .control, + pageType: .about + ) + + shopperInsightsClient.sendPresentedEvent( + for: .payPal, + presentmentDetails: presentmentDetails + ) + } + + private func toggleVenmoButton(enabled: Bool) { + venmoButton.isEnabled = enabled + + guard enabled else { return } + + let presentmentDetails = BTPresentmentDetails( + buttonOrder: .second, + experimentType: .control, + pageType: .about + ) + + shopperInsightsClient.sendPresentedEvent( + for: .venmo, + presentmentDetails: presentmentDetails + ) + } + @objc func payPalVaultButtonTapped(_ button: UIButton) { - let sampleExperiment = - """ - [ - { "experimentName" : "payment ready conversion experiment" }, - { "experimentID" : "a1b2c3" }, - { "treatmentName" : "treatment group 1" } - ] - """ - let paymentMethods = ["Apple Pay", "Card", "PayPal"] - shopperInsightsClient.sendPayPalPresentedEvent(paymentMethodsDisplayed: paymentMethods, experiment: sampleExperiment) progressBlock("Tapped PayPal Vault") - shopperInsightsClient.sendPayPalSelectedEvent() + shopperInsightsClient.sendSelectedEvent(for: .payPal) button.setTitle("Processing...", for: .disabled) button.isEnabled = false let paypalRequest = BTPayPalVaultRequest() + paypalRequest.shopperSessionID = shopperSessionID paypalRequest.userAuthenticationEmail = emailView.textField.text payPalClient.tokenize(paypalRequest) { nonce, error in @@ -128,9 +156,8 @@ class ShopperInsightsViewController: PaymentButtonBaseViewController { } @objc func venmoButtonTapped(_ button: UIButton) { - shopperInsightsClient.sendVenmoPresentedEvent() progressBlock("Tapped Venmo") - shopperInsightsClient.sendVenmoSelectedEvent() + shopperInsightsClient.sendSelectedEvent(for: .venmo) button.setTitle("Processing...", for: .disabled) button.isEnabled = false diff --git a/Demo/Application/Features/VenmoViewController.swift b/Demo/Application/Features/VenmoViewController.swift index 0b6537f168..1668872139 100644 --- a/Demo/Application/Features/VenmoViewController.swift +++ b/Demo/Application/Features/VenmoViewController.swift @@ -2,14 +2,16 @@ import UIKit import BraintreeVenmo class VenmoViewController: PaymentButtonBaseViewController { - + // swiftlint:disable:next implicitly_unwrapped_optional var venmoClient: BTVenmoClient! let webFallbackToggle = Toggle(title: "Enable Web Fallback") let vaultToggle = Toggle(title: "Vault") - + let universalLinkReturnToggle = Toggle(title: "Use Universal Link Return") + override func viewDidLoad() { + super.heightConstraint = 150 super.viewDidLoad() venmoClient = BTVenmoClient(apiClient: apiClient) title = "Custom Venmo Button" @@ -18,7 +20,7 @@ class VenmoViewController: PaymentButtonBaseViewController { override func createPaymentButton() -> UIView { let venmoButton = createButton(title: "Venmo", action: #selector(tappedVenmo)) - let stackView = UIStackView(arrangedSubviews: [webFallbackToggle, vaultToggle, venmoButton]) + let stackView = UIStackView(arrangedSubviews: [webFallbackToggle, vaultToggle, universalLinkReturnToggle, venmoButton]) stackView.axis = .vertical stackView.spacing = 15 stackView.alignment = .fill @@ -40,7 +42,15 @@ class VenmoViewController: PaymentButtonBaseViewController { if vaultToggle.isOn { venmoRequest.vault = true } - + + if universalLinkReturnToggle.isOn { + venmoClient = BTVenmoClient( + apiClient: apiClient, + // swiftlint:disable:next force_unwrapping + universalLink: URL(string: "https://mobile-sdk-demo-site-838cead5d3ab.herokuapp.com/braintree-payments")! + ) + } + Task { do { let venmoAccount = try await venmoClient.tokenize(venmoRequest) diff --git a/Demo/Application/Supporting Files/Braintree-Demo-Info.plist b/Demo/Application/Supporting Files/Braintree-Demo-Info.plist index 03c10f885f..0186c94ba4 100644 --- a/Demo/Application/Supporting Files/Braintree-Demo-Info.plist +++ b/Demo/Application/Supporting Files/Braintree-Demo-Info.plist @@ -41,7 +41,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 6.24.0 + 6.28.0 CFBundleURLTypes @@ -56,7 +56,7 @@ CFBundleVersion - 6.24.0 + 6.28.0 LSApplicationQueriesSchemes com.braintreepayments.Demo.payments diff --git a/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme b/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme index 2f905023b2..fb5e346254 100644 --- a/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme +++ b/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme @@ -262,6 +262,16 @@ ReferencedContainer = "container:../Braintree.xcodeproj"> + + + + CFBundlePackageType FMWK CFBundleShortVersionString - 6.24.0 + 6.28.0 CFBundleSignature ???? CFBundleVersion - 6.24.0 + 6.28.0 NSPrincipalClass diff --git a/Sources/BraintreeDataCollector/BTDataCollector.swift b/Sources/BraintreeDataCollector/BTDataCollector.swift index 097588f2fa..02d6f18cdb 100644 --- a/Sources/BraintreeDataCollector/BTDataCollector.swift +++ b/Sources/BraintreeDataCollector/BTDataCollector.swift @@ -147,15 +147,14 @@ import BraintreeCore var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) if status == errSecSuccess, - let existingItem = item as? [String: Any], - let data = existingItem[kSecValueData as String] as? Data, - let identifier = String(data: data, encoding: String.Encoding.utf8) { + let data = item as? Data, + let identifier = String(data: data, encoding: .utf8) { return identifier } // If not, generate a new one and save it let newIdentifier = UUID().uuidString - query[kSecValueData as String] = newIdentifier + query[kSecValueData as String] = newIdentifier.data(using: .utf8) query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly SecItemAdd(query as CFDictionary, nil) return newIdentifier diff --git a/Sources/BraintreePayPal/BTContactInformation.swift b/Sources/BraintreePayPal/BTContactInformation.swift new file mode 100644 index 0000000000..fff33ebb9f --- /dev/null +++ b/Sources/BraintreePayPal/BTContactInformation.swift @@ -0,0 +1,19 @@ +/// Contact information of the recipient for the order +public struct BTContactInformation { + + // MARK: - Internal Properties + + let recipientEmail: String? + let recipientPhoneNumber: BTPayPalPhoneNumber? + + // MARK: - Initializer + + /// Intialize a BTContactInformation + /// - Parameters: + /// - recipientEmail: Optional: Email address of the recipient. + /// - recipientPhoneNumber: Optional: Phone number of the recipient. + public init(recipientEmail: String? = nil, recipientPhoneNumber: BTPayPalPhoneNumber? = nil) { + self.recipientEmail = recipientEmail + self.recipientPhoneNumber = recipientPhoneNumber + } +} diff --git a/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift b/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift index 4f9b6cb8b3..3276293230 100644 --- a/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift @@ -61,7 +61,6 @@ import BraintreeCore // MARK: - Public Properties /// Used for a one-time payment. - /// /// Amount must be greater than or equal to zero, may optionally contain exactly 2 decimal places separated by '.' and is limited to 7 digits before the decimal point. public var amount: String @@ -75,12 +74,17 @@ import BraintreeCore public var offerPayLater: Bool /// Optional: A three-character ISO-4217 ISO currency code to use for the transaction. Defaults to merchant currency code if not set. - /// /// - Note: See https://developer.paypal.com/docs/api/reference/currency-codes/ for a list of supported currency codes. public var currencyCode: String? /// Optional: If set to `true`, this enables the Checkout with Vault flow, where the customer will be prompted to consent to a billing agreement during checkout. Defaults to `false`. public var requestBillingAgreement: Bool + + /// Optional: Contact information of the recipient for the order + public var contactInformation: BTContactInformation? + + /// Optional: Server side shipping callback URL to be notified when a customer updates their shipping address or options. A callback request will be sent to the merchant server at this URL. + public var shippingCallbackURL: URL? // MARK: - Initializers @@ -131,6 +135,8 @@ import BraintreeCore /// See https://developer.paypal.com/docs/api/reference/currency-codes/ for a list of supported currency codes. /// - requestBillingAgreement: Optional: If set to `true`, this enables the Checkout with Vault flow, where the customer will be prompted to consent to a billing agreement /// during checkout. Defaults to `false`. + /// - shippingCallbackURL: Optional: Server side shipping callback URL to be notified when a customer updates their shipping address or options. + /// A callback request will be sent to the merchant server at this URL. /// - 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( amount: String, @@ -139,6 +145,7 @@ import BraintreeCore offerPayLater: Bool = false, currencyCode: String? = nil, requestBillingAgreement: Bool = false, + shippingCallbackURL: URL? = nil, userAuthenticationEmail: String? = nil ) { self.amount = amount @@ -147,6 +154,7 @@ import BraintreeCore self.offerPayLater = offerPayLater self.currencyCode = currencyCode self.requestBillingAgreement = requestBillingAgreement + self.shippingCallbackURL = shippingCallbackURL super.init( hermesPath: "v1/paypal_hermes/create_payment_resource", @@ -190,6 +198,10 @@ import BraintreeCore } } + if let shippingCallbackURL { + baseParameters["shipping_callback_url"] = shippingCallbackURL.absoluteString + } + if shippingAddressOverride != nil { checkoutParameters["line1"] = shippingAddressOverride?.streetAddress checkoutParameters["line2"] = shippingAddressOverride?.extendedAddress @@ -199,7 +211,15 @@ import BraintreeCore checkoutParameters["country_code"] = shippingAddressOverride?.countryCodeAlpha2 checkoutParameters["recipient_name"] = shippingAddressOverride?.recipientName } - + + if let recipientEmail = contactInformation?.recipientEmail { + checkoutParameters["recipient_email"] = recipientEmail + } + + if let recipientPhoneNumber = try? contactInformation?.recipientPhoneNumber?.toDictionary() { + checkoutParameters["international_phone"] = recipientPhoneNumber + } + return baseParameters.merging(checkoutParameters) { $1 } } } diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index dd1b4554c5..4c25776bae 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -186,6 +186,7 @@ import BraintreeDataCollector // MARK: - Internal Methods + // swiftlint:disable function_body_length func handleReturn( _ url: URL?, paymentType: BTPayPalPaymentType, @@ -196,7 +197,8 @@ import BraintreeDataCollector correlationID: clientMetadataID, isVaultRequest: isVaultRequest, linkType: linkType, - payPalContextID: payPalContextID + payPalContextID: payPalContextID, + shopperSessionID: payPalRequest?.shopperSessionID ) guard let url, BTPayPalReturnURL.isValidURLAction(url: url, linkType: linkType) else { @@ -284,6 +286,29 @@ import BraintreeDataCollector performSwitchRequest(appSwitchURL: url, paymentType: paymentType, completion: completion) } + func invokedOpenURLSuccessfully(_ success: Bool, url: URL, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void) { + if success { + apiClient.sendAnalyticsEvent( + BTPayPalAnalytics.appSwitchSucceeded, + appSwitchURL: url, + isVaultRequest: isVaultRequest, + linkType: linkType, + payPalContextID: payPalContextID + ) + BTPayPalClient.payPalClient = self + appSwitchCompletion = completion + } else { + apiClient.sendAnalyticsEvent( + BTPayPalAnalytics.appSwitchFailed, + appSwitchURL: url, + isVaultRequest: isVaultRequest, + linkType: linkType, + payPalContextID: payPalContextID + ) + notifyFailure(with: BTPayPalError.appSwitchFailed, completion: completion) + } + } + // MARK: - App Switch Methods func handleReturnURL(_ url: URL) { @@ -312,8 +337,14 @@ import BraintreeDataCollector completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void ) { linkType = request.enablePayPalAppSwitch == true ? .universal : .deeplink + self.payPalRequest = request - apiClient.sendAnalyticsEvent(BTPayPalAnalytics.tokenizeStarted, isVaultRequest: isVaultRequest, linkType: linkType) + apiClient.sendAnalyticsEvent( + BTPayPalAnalytics.tokenizeStarted, + isVaultRequest: isVaultRequest, + linkType: linkType, + shopperSessionID: payPalRequest?.shopperSessionID + ) apiClient.fetchOrReturnRemoteConfiguration { configuration, error in if let error { self.notifyFailure(with: error, completion: completion) @@ -332,7 +363,6 @@ import BraintreeDataCollector return } - self.payPalRequest = request self.apiClient.post( request.hermesPath, parameters: request.parameters( @@ -389,7 +419,8 @@ import BraintreeDataCollector BTPayPalAnalytics.appSwitchStarted, isVaultRequest: isVaultRequest, linkType: linkType, - payPalContextID: payPalContextID + payPalContextID: payPalContextID, + shopperSessionID: payPalRequest?.shopperSessionID ) var urlComponents = URLComponents(url: payPalAppRedirectURL, resolvingAgainstBaseURL: true) @@ -406,28 +437,7 @@ import BraintreeDataCollector } application.open(redirectURL) { success in - self.invokedOpenURLSuccessfully(success, completion: completion) - } - } - - private func invokedOpenURLSuccessfully(_ success: Bool, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void) { - if success { - apiClient.sendAnalyticsEvent( - BTPayPalAnalytics.appSwitchSucceeded, - isVaultRequest: isVaultRequest, - linkType: linkType, - payPalContextID: payPalContextID - ) - BTPayPalClient.payPalClient = self - appSwitchCompletion = completion - } else { - apiClient.sendAnalyticsEvent( - BTPayPalAnalytics.appSwitchFailed, - isVaultRequest: isVaultRequest, - linkType: linkType, - payPalContextID: payPalContextID - ) - notifyFailure(with: BTPayPalError.appSwitchFailed, completion: completion) + self.invokedOpenURLSuccessfully(success, url: redirectURL, completion: completion) } } @@ -473,14 +483,16 @@ import BraintreeDataCollector isConfigFromCache: isConfigFromCache, isVaultRequest: isVaultRequest, linkType: linkType, - payPalContextID: payPalContextID + payPalContextID: payPalContextID, + shopperSessionID: payPalRequest?.shopperSessionID ) } else { apiClient.sendAnalyticsEvent( BTPayPalAnalytics.browserPresentationFailed, isVaultRequest: isVaultRequest, linkType: linkType, - payPalContextID: payPalContextID + payPalContextID: payPalContextID, + shopperSessionID: payPalRequest?.shopperSessionID ) } } sessionDidCancel: { [self] in @@ -512,7 +524,8 @@ import BraintreeDataCollector correlationID: clientMetadataID, isVaultRequest: isVaultRequest, linkType: linkType, - payPalContextID: payPalContextID + payPalContextID: payPalContextID, + shopperSessionID: payPalRequest?.shopperSessionID ) completion(result, nil) } @@ -524,7 +537,8 @@ import BraintreeDataCollector errorDescription: error.localizedDescription, isVaultRequest: isVaultRequest, linkType: linkType, - payPalContextID: payPalContextID + payPalContextID: payPalContextID, + shopperSessionID: payPalRequest?.shopperSessionID ) completion(nil, error) } @@ -535,7 +549,8 @@ import BraintreeDataCollector correlationID: clientMetadataID, isVaultRequest: isVaultRequest, linkType: linkType, - payPalContextID: payPalContextID + payPalContextID: payPalContextID, + shopperSessionID: payPalRequest?.shopperSessionID ) completion(nil, BTPayPalError.canceled) } diff --git a/Sources/BraintreePayPal/BTPayPalRequest.swift b/Sources/BraintreePayPal/BTPayPalRequest.swift index 2a75a16552..459a9c6290 100644 --- a/Sources/BraintreePayPal/BTPayPalRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalRequest.swift @@ -110,6 +110,9 @@ import BraintreeCore /// - Warning: This property is currently in beta and may change or be removed in future releases. var enablePayPalAppSwitch: Bool + /// Optional: The shopper session ID returned from your shopper insights server SDK integration. + public var shopperSessionID: String? + // MARK: - Static Properties static let callbackURLHostAndPath: String = "onetouch/v1/" @@ -131,7 +134,8 @@ import BraintreeCore riskCorrelationId: String? = nil, userPhoneNumber: BTPayPalPhoneNumber? = nil, userAuthenticationEmail: String? = nil, - enablePayPalAppSwitch: Bool = false + enablePayPalAppSwitch: Bool = false, + shopperSessionID: String? = nil ) { self.hermesPath = hermesPath self.paymentType = paymentType @@ -148,6 +152,7 @@ import BraintreeCore self.userPhoneNumber = userPhoneNumber self.userAuthenticationEmail = userAuthenticationEmail self.enablePayPalAppSwitch = enablePayPalAppSwitch + self.shopperSessionID = shopperSessionID } // MARK: Public Methods @@ -197,6 +202,10 @@ import BraintreeCore parameters["payer_email"] = userAuthenticationEmail } + if let shopperSessionID { + parameters["shopper_session_id"] = shopperSessionID + } + parameters["return_url"] = BTCoreConstants.callbackURLScheme + "://\(BTPayPalRequest.callbackURLHostAndPath)success" parameters["cancel_url"] = BTCoreConstants.callbackURLScheme + "://\(BTPayPalRequest.callbackURLHostAndPath)cancel" parameters["experience_profile"] = experienceProfile diff --git a/Sources/BraintreeShopperInsights/BTButtonOrder.swift b/Sources/BraintreeShopperInsights/BTButtonOrder.swift new file mode 100644 index 0000000000..0018ecc295 --- /dev/null +++ b/Sources/BraintreeShopperInsights/BTButtonOrder.swift @@ -0,0 +1,33 @@ +import Foundation + +/// The order or ranking in which payment buttons appear. +/// - Warning: This module is in beta. It's public API may change or be removed in future releases. +public enum BTButtonOrder: String { + + /// First place + case first = "1" + + /// Second place + case second = "2" + + /// Third place + case third = "3" + + /// Fourth place + case fourth = "4" + + /// Fifth place + case fifth = "5" + + /// Sixth place + case sixth = "6" + + /// Seventh place + case seventh = "7" + + /// Eighth place + case eighth = "8" + + /// Greater than Eighth place + case other = "other" +} diff --git a/Sources/BraintreeShopperInsights/BTButtonType.swift b/Sources/BraintreeShopperInsights/BTButtonType.swift new file mode 100644 index 0000000000..8341aa7a15 --- /dev/null +++ b/Sources/BraintreeShopperInsights/BTButtonType.swift @@ -0,0 +1,15 @@ +import Foundation + +/// The type of button displayed or presented +/// Warning: This module is in beta. It's public API may change or be removed in future releases. +public enum BTButtonType: String { + + /// PayPal button + case payPal = "PayPal" + + /// Venmo button + case venmo = "Venmo" + + /// All button types other than PayPal or Venmo + case other = "Other" +} diff --git a/Sources/BraintreeShopperInsights/BTExperimentType.swift b/Sources/BraintreeShopperInsights/BTExperimentType.swift new file mode 100644 index 0000000000..60cf4e2e9b --- /dev/null +++ b/Sources/BraintreeShopperInsights/BTExperimentType.swift @@ -0,0 +1,21 @@ +import Foundation + +/// The experiment type that is sent to analytics to help improve the Shopper Insights feature experience. +/// - Warning: This module is in beta. It's public API may change or be removed in future releases. +public enum BTExperimentType: String { + + /// The test experiment + case test + + /// The control experiment + case control + + public var formattedExperiment: String { + """ + [ + { "exp_name" : "PaymentReady" } + { "treatment_name" : "\(self.rawValue)" } + ] + """ + } +} diff --git a/Sources/BraintreeShopperInsights/BTPageType.swift b/Sources/BraintreeShopperInsights/BTPageType.swift new file mode 100644 index 0000000000..a301d93c9a --- /dev/null +++ b/Sources/BraintreeShopperInsights/BTPageType.swift @@ -0,0 +1,45 @@ +import Foundation + +/// The type of page where the payment button is displayed or where an event occured. +/// - Warning: This module is in beta. It's public API may change or be removed in future releases. +public enum BTPageType: String { + + /// A home page is the primary landing page that a visitor will view when they navigate to a website. + case homepage = "homepage" + + /// An About page is a section on a website that provides information about a company, organization, or individual. + case about = "about" + + /// A contact page is a page on a website for visitors to contact the organization or individual providing the website. + case contact = "contact" + + /// An intermediary step that users pass through on their way to a product-listing page that doesn't provide a complete + /// list of products but may showcase a few products and provide links to product subcategories. + case productCategory = "product_category" + + /// A product detail page (PDP) is a web page that outlines everything customers and buyers need to know about a + /// particular product. + case productDetails = "product_details" + + /// The page a user sees after entering a search query. + case search = "search" + + /// A cart is a digital shopping cart that allows buyers to inspect and organize items they plan to buy. + case cart = "cart" + + /// A checkout page is the page related to payment and shipping/billing details on an eCommerce store. + case checkout = "checkout" + + /// An order review page gives the buyer an overview of the goods or services that they have selected and summarizes + /// the order that they are about to place. + case orderReview = "order_review" + + /// The order confirmation page summarizes an order after checkout completes. + case orderConfirmation = "order_confirmation" + + /// Popup cart displayed after “add to cart” click. + case miniCart = "mini_cart" + + /// Any other page available on a merchant’s site. + case other = "other" +} diff --git a/Sources/BraintreeShopperInsights/BTPresentmentDetails.swift b/Sources/BraintreeShopperInsights/BTPresentmentDetails.swift new file mode 100644 index 0000000000..d4c31d7fa1 --- /dev/null +++ b/Sources/BraintreeShopperInsights/BTPresentmentDetails.swift @@ -0,0 +1,27 @@ +import Foundation + +/// `BTPresentmentDetails` Configure detailed information about the presented button. +/// - Warning: This class is in beta. It's public API may change or be removed in future releases. +public class BTPresentmentDetails { + + var buttonOrder: BTButtonOrder + var experimentType: BTExperimentType + var pageType: BTPageType + + /// Detailed information, including button order, experiment type, and page type about the payment button that + /// is sent to analytics to help improve the Shopper Insights feature experience. + /// - Warning: This class is in beta. It's public API may change or be removed in future releases. + /// - Parameters: + /// - buttonOrder: The order or ranking in which payment buttons appear. + /// - experimentType: The experiment type that is sent to analytics to help improve the Shopper Insights feature experience. + /// - pageType: The type of page where the payment button is displayed or where an event occured. + public init( + buttonOrder: BTButtonOrder, + experimentType: BTExperimentType, + pageType: BTPageType + ) { + self.buttonOrder = buttonOrder + self.experimentType = experimentType + self.pageType = pageType + } +} diff --git a/Sources/BraintreeShopperInsights/BTShopperInsightsAnalytics.swift b/Sources/BraintreeShopperInsights/BTShopperInsightsAnalytics.swift index fefbcd5fa1..4ef6d8f585 100644 --- a/Sources/BraintreeShopperInsights/BTShopperInsightsAnalytics.swift +++ b/Sources/BraintreeShopperInsights/BTShopperInsightsAnalytics.swift @@ -4,11 +4,9 @@ enum BTShopperInsightsAnalytics { // MARK: - Merchant Triggered Events - static let payPalPresented = "shopper-insights:paypal-presented" - static let payPalSelected = "shopper-insights:paypal-selected" - static let venmoPresented = "shopper-insights:venmo-presented" - static let venmoSelected = "shopper-insights:venmo-selected" - + static let buttonPresented = "shopper-insights:button-presented" + static let buttonSelected = "shopper-insights:button-selected" + // MARK: - SDK Triggered Events static let recommendedPaymentsStarted = "shopper-insights:get-recommended-payments:started" diff --git a/Sources/BraintreeShopperInsights/BTShopperInsightsClient.swift b/Sources/BraintreeShopperInsights/BTShopperInsightsClient.swift index e5dc83a6cc..b2a7131d06 100644 --- a/Sources/BraintreeShopperInsights/BTShopperInsightsClient.swift +++ b/Sources/BraintreeShopperInsights/BTShopperInsightsClient.swift @@ -17,14 +17,20 @@ public class BTShopperInsightsClient { // MARK: - Private Properties private let apiClient: BTAPIClient + private let shopperSessionID: String? /// Creates a `BTShopperInsightsClient` - /// - Parameter apiClient: A `BTAPIClient` instance. - /// - Warning: This features only works with a client token. - public init(apiClient: BTAPIClient) { + /// - Parameters: + /// - apiClient: A `BTAPIClient` instance. + /// - shopperSessionID: This value should be the shopper session ID returned from your server SDK request + /// - Warning: This init is beta. It's public API may change or be removed in future releases. This feature only works with a client token. + public init(apiClient: BTAPIClient, shopperSessionID: String? = nil) { self.apiClient = apiClient + self.shopperSessionID = shopperSessionID } + // MARK: - Public Methods + /// This method confirms if the customer is a user of PayPal services using their email and phone number. /// - Parameters: /// - request: Required: A `BTShopperInsightsRequest` containing the buyer's user information. @@ -39,7 +45,8 @@ public class BTShopperInsightsClient { ) async throws -> BTShopperInsightsResult { apiClient.sendAnalyticsEvent( BTShopperInsightsAnalytics.recommendedPaymentsStarted, - merchantExperiment: experiment + merchantExperiment: experiment, + shopperSessionID: shopperSessionID ) if apiClient.authorization.type != .clientToken { @@ -82,52 +89,55 @@ public class BTShopperInsightsClient { } } - /// Call this method when the PayPal button has been successfully displayed to the buyer. + /// Call this method when the PayPal or Venmo button has been successfully displayed to the buyer. /// This method sends analytics to help improve the Shopper Insights feature experience. /// - Parameters: - /// - paymentMethodsDisplayed: Optional: The list of available payment methods, rendered in the same order in which they are displayed i.e. ['Apple Pay', 'PayPal'] - /// - experiment: Optional: A `JSONObject` passed in as a string containing details of the merchant experiment. - public func sendPayPalPresentedEvent(paymentMethodsDisplayed: [String?] = [], experiment: String? = nil) { - let paymentMethodsDisplayedString = paymentMethodsDisplayed.compactMap { $0 }.joined(separator: ", ") + /// - buttonType: Type of button presented - PayPal, Venmo, or Other + /// - presentmentDetails: Detailed information, including button order, experiment type, and + /// page type about the payment button that is sent to analytics to help improve the Shopper Insights + /// feature experience. + public func sendPresentedEvent(for buttonType: BTButtonType, presentmentDetails: BTPresentmentDetails) { apiClient.sendAnalyticsEvent( - BTShopperInsightsAnalytics.payPalPresented, - merchantExperiment: experiment, - paymentMethodsDisplayed: paymentMethodsDisplayedString + BTShopperInsightsAnalytics.buttonPresented, + buttonOrder: presentmentDetails.buttonOrder.rawValue, + buttonType: buttonType.rawValue, + merchantExperiment: presentmentDetails.experimentType.formattedExperiment, + pageType: presentmentDetails.pageType.rawValue, + shopperSessionID: shopperSessionID ) } - - /// Call this method when the PayPal button has been selected/tapped by the buyer. - /// This method sends analytics to help improve the Shopper Insights feature experience - public func sendPayPalSelectedEvent() { - apiClient.sendAnalyticsEvent(BTShopperInsightsAnalytics.payPalSelected) - } - - /// Call this method when the Venmo button has been successfully displayed to the buyer. + + /// Call this method when a button has been selected/tapped by the buyer. /// This method sends analytics to help improve the Shopper Insights feature experience. - /// - Parameters: - /// - paymentMethodsDisplayed: Optional: The list of available payment methods, rendered in the same order in which they are displayed. - /// - experiment: Optional: A `JSONObject` passed in as a string containing details of the merchant experiment. - public func sendVenmoPresentedEvent(paymentMethodsDisplayed: [String?] = [], experiment: String? = nil) { - let paymentMethodsDisplayedString = paymentMethodsDisplayed.compactMap { $0 }.joined(separator: ", ") + /// - Parameter buttonType: Type of button presented - PayPal, Venmo, or Other + /// - Warning: This function is in beta. It's public API may change or be removed in future releases. + public func sendSelectedEvent(for buttonType: BTButtonType) { apiClient.sendAnalyticsEvent( - BTShopperInsightsAnalytics.venmoPresented, - merchantExperiment: experiment, - paymentMethodsDisplayed: paymentMethodsDisplayedString + BTShopperInsightsAnalytics.buttonSelected, + buttonType: buttonType.rawValue, + shopperSessionID: shopperSessionID ) } - - /// Call this method when the Venmo button has been selected/tapped by the buyer. - /// This method sends analytics to help improve the Shopper Insights feature experience - public func sendVenmoSelectedEvent() { - apiClient.sendAnalyticsEvent(BTShopperInsightsAnalytics.venmoSelected) + + /// Indicates whether the PayPal App is installed. + /// - Warning: This method is currently in beta and may change or be removed in future releases. + public func isPayPalAppInstalled() -> Bool { + application.isPayPalAppInstalled() } - + + /// Indicates whether the Venmo App is installed. + /// - Warning: This method is currently in beta and may change or be removed in future releases. + public func isVenmoAppInstalled() -> Bool { + application.isVenmoAppInstalled() + } + // MARK: - Analytics Helper Methods private func notifySuccess(with result: BTShopperInsightsResult, for experiment: String?) -> BTShopperInsightsResult { apiClient.sendAnalyticsEvent( BTShopperInsightsAnalytics.recommendedPaymentsSucceeded, - merchantExperiment: experiment + merchantExperiment: experiment, + shopperSessionID: shopperSessionID ) return result } @@ -136,7 +146,8 @@ public class BTShopperInsightsClient { apiClient.sendAnalyticsEvent( BTShopperInsightsAnalytics.recommendedPaymentsFailed, errorDescription: error.localizedDescription, - merchantExperiment: experiment + merchantExperiment: experiment, + shopperSessionID: shopperSessionID ) return error } diff --git a/Sources/BraintreeThreeDSecure/BTThreeDSecureClient.swift b/Sources/BraintreeThreeDSecure/BTThreeDSecureClient.swift index 75c3316b44..8de80b4c1e 100644 --- a/Sources/BraintreeThreeDSecure/BTThreeDSecureClient.swift +++ b/Sources/BraintreeThreeDSecure/BTThreeDSecureClient.swift @@ -250,9 +250,16 @@ import BraintreeCore request: request, cardinalSession: cardinalSession ) { lookupParameters in - if let dfReferenceID = lookupParameters?["dfReferenceId"] { - request.dfReferenceID = dfReferenceID + guard let dfReferenceID = lookupParameters?["dfReferenceId"], !dfReferenceID.isEmpty else { + completion( + BTThreeDSecureError.failedLookup( + [NSLocalizedDescriptionKey: "There was an error retrieving the dfReferenceId."] + ) + ) + return } + + request.dfReferenceID = dfReferenceID completion(nil) } } else { diff --git a/Sources/BraintreeThreeDSecure/BTThreeDSecureRequest.swift b/Sources/BraintreeThreeDSecure/BTThreeDSecureRequest.swift index 43b3274951..d9ff3cc446 100644 --- a/Sources/BraintreeThreeDSecure/BTThreeDSecureRequest.swift +++ b/Sources/BraintreeThreeDSecure/BTThreeDSecureRequest.swift @@ -93,6 +93,10 @@ import BraintreeCore /// When using `BTThreeDSecureUIType.native`, all `BTThreeDSecureRenderType` options except `.html` must be set. public var renderTypes: [BTThreeDSecureRenderType]? + /// Optional. Three DS Requester APP URL Merchant app declaring their URL within the CReq message + /// so that the Authentication app can call the Merchant app after out of band authentication has occurred. + public var requestorAppURL: String? + /// A delegate for receiving information about the ThreeDSecure payment flow. public weak var threeDSecureRequestDelegate: BTThreeDSecureRequestDelegate? diff --git a/Sources/BraintreeThreeDSecure/BTThreeDSecureV2Provider.swift b/Sources/BraintreeThreeDSecure/BTThreeDSecureV2Provider.swift index 89b21f4113..99cd10f9c0 100644 --- a/Sources/BraintreeThreeDSecure/BTThreeDSecureV2Provider.swift +++ b/Sources/BraintreeThreeDSecure/BTThreeDSecureV2Provider.swift @@ -45,6 +45,10 @@ class BTThreeDSecureV2Provider { cardinalConfiguration.renderType = renderTypes.compactMap { $0.cardinalValue } } + if let requestorAppURL = request.requestorAppURL { + cardinalConfiguration.threeDSRequestorAppURL = requestorAppURL + } + guard let cardinalAuthenticationJWT = configuration.cardinalAuthenticationJWT else { completion(nil) return diff --git a/Sources/BraintreeVenmo/BTVenmoAppSwitchRedirectURL.swift b/Sources/BraintreeVenmo/BTVenmoAppSwitchRedirectURL.swift index b9d561e064..862a1c9ea9 100644 --- a/Sources/BraintreeVenmo/BTVenmoAppSwitchRedirectURL.swift +++ b/Sources/BraintreeVenmo/BTVenmoAppSwitchRedirectURL.swift @@ -22,9 +22,10 @@ struct BTVenmoAppSwitchRedirectURL { // MARK: - Initializer init( - returnURLScheme: String, paymentContextID: String, metadata: BTClientMetadata, + returnURLScheme: String?, + universalLink: URL?, forMerchantID merchantID: String?, accessToken: String?, bundleDisplayName: String?, @@ -46,9 +47,6 @@ struct BTVenmoAppSwitchRedirectURL { let base64EncodedBraintreeData = serializedBraintreeData?.base64EncodedString() queryParameters = [ - "x-success": constructRedirectURL(with: returnURLScheme, result: "success"), - "x-error": constructRedirectURL(with: returnURLScheme, result: "error"), - "x-cancel": constructRedirectURL(with: returnURLScheme, result: "cancel"), "x-source": bundleDisplayName, "braintree_merchant_id": merchantID, "braintree_access_token": accessToken, @@ -57,6 +55,16 @@ struct BTVenmoAppSwitchRedirectURL { "braintree_sdk_data": base64EncodedBraintreeData ?? "", "customerClient": "MOBILE_APP" ] + + if let universalLink { + queryParameters["x-success"] = universalLink.appendingPathComponent("success").absoluteString + queryParameters["x-error"] = universalLink.appendingPathComponent("error").absoluteString + queryParameters["x-cancel"] = universalLink.appendingPathComponent("cancel").absoluteString + } else if let returnURLScheme { + queryParameters["x-success"] = constructRedirectURL(with: returnURLScheme, result: "success") + queryParameters["x-error"] = constructRedirectURL(with: returnURLScheme, result: "error") + queryParameters["x-cancel"] = constructRedirectURL(with: returnURLScheme, result: "cancel") + } } // MARK: - Internal Methods diff --git a/Sources/BraintreeVenmo/BTVenmoAppSwitchReturnURL.swift b/Sources/BraintreeVenmo/BTVenmoAppSwitchReturnURL.swift index b1fcd30be4..569054b539 100644 --- a/Sources/BraintreeVenmo/BTVenmoAppSwitchReturnURL.swift +++ b/Sources/BraintreeVenmo/BTVenmoAppSwitchReturnURL.swift @@ -41,7 +41,7 @@ struct BTVenmoAppSwitchReturnURL { init?(url: URL) { let parameters = BTURLUtils.queryParameters(for: url) - if url.path == "/vzero/auth/venmo/success" { + if url.path.contains("success") { if let resourceID = parameters["resource_id"] { state = .succeededWithPaymentContext paymentContextID = resourceID @@ -50,12 +50,12 @@ struct BTVenmoAppSwitchReturnURL { nonce = parameters["paymentMethodNonce"] ?? parameters["payment_method_nonce"] username = parameters["username"] } - } else if url.path == "/vzero/auth/venmo/error" { + } else if url.path.contains("error") { state = .failed let errorMessage: String? = parameters["errorMessage"] ?? parameters["error_message"] let errorCode = Int(parameters["errorCode"] ?? parameters["error_code"] ?? "0") error = BTVenmoAppSwitchError.returnURLError(errorCode ?? 0, errorMessage) - } else if url.path == "/vzero/auth/venmo/cancel" { + } else if url.path.contains("cancel") { state = .canceled } else { state = .unknown @@ -68,6 +68,7 @@ struct BTVenmoAppSwitchReturnURL { /// - Parameter url: an app switch return URL /// - Returns: `true` if the url represents a Venmo Touch app switch return static func isValid(url: URL) -> Bool { - url.host == "x-callback-url" && url.path.hasPrefix("/vzero/auth/venmo/") + (url.scheme == "https" && (url.path.contains("cancel") || url.path.contains("success") || url.path.contains("error"))) + || (url.host == "x-callback-url" && url.path.hasPrefix("/vzero/auth/venmo/")) } } diff --git a/Sources/BraintreeVenmo/BTVenmoClient.swift b/Sources/BraintreeVenmo/BTVenmoClient.swift index f5fa291508..e555423db8 100644 --- a/Sources/BraintreeVenmo/BTVenmoClient.swift +++ b/Sources/BraintreeVenmo/BTVenmoClient.swift @@ -46,9 +46,11 @@ import BraintreeCore /// Used for sending the type of flow, universal vs deeplink to FPTI private var linkType: LinkType? + private var universalLink: URL? + // MARK: - Initializer - /// Creates an Apple Pay client + /// Creates a Venmo client /// - Parameter apiClient: An API client @objc(initWithAPIClient:) public init(apiClient: BTAPIClient) { @@ -56,6 +58,16 @@ import BraintreeCore self.apiClient = apiClient } + /// Initialize a new Venmo client instance. + /// - Parameters: + /// - apiClient: The API Client + /// - universalLink: The URL for the Venmo app to redirect to after user authentication completes. Must be a valid HTTPS URL dedicated to Braintree app switch returns. + @objc(initWithAPIClient:universalLink:) + public convenience init(apiClient: BTAPIClient, universalLink: URL) { + self.init(apiClient: apiClient) + self.universalLink = universalLink + } + // MARK: - Public Methods /// Initiates Venmo login via app switch, which returns a BTVenmoAccountNonce when successful. @@ -69,16 +81,22 @@ import BraintreeCore public func tokenize(_ request: BTVenmoRequest, completion: @escaping (BTVenmoAccountNonce?, Error?) -> Void) { linkType = request.fallbackToWeb ? .universal : .deeplink apiClient.sendAnalyticsEvent(BTVenmoAnalytics.tokenizeStarted, isVaultRequest: shouldVault, linkType: linkType) - let returnURLScheme = BTAppContextSwitcher.sharedInstance.returnURLScheme + let returnURLScheme = BTAppContextSwitcher.sharedInstance._returnURLScheme - if returnURLScheme.isEmpty { + if (universalLink?.absoluteString.isEmpty == true || universalLink?.absoluteString == nil) && returnURLScheme.isEmpty { NSLog( - "%@ Venmo requires a return URL scheme to be configured via [BTAppContextSwitcher setReturnURLScheme:]", + "%@ Venmo requires a return URL scheme or universal link to be configured.", BTLogLevelDescription.string(for: .critical) ) - notifyFailure(with: BTVenmoError.appNotAvailable, completion: completion) + notifyFailure( + with: BTVenmoError.invalidReturnURL("Venmo requires a return URL scheme or universal link to be configured."), + completion: completion + ) return - } else if let bundleIdentifier = bundle.bundleIdentifier, !returnURLScheme.hasPrefix(bundleIdentifier) { + } else if + let bundleIdentifier = bundle.bundleIdentifier, + !returnURLScheme.hasPrefix(bundleIdentifier) + && (universalLink?.absoluteString.isEmpty == true || universalLink?.absoluteString == nil) { NSLog( // swiftlint:disable:next line_length "%@ Venmo requires [BTAppContextSwitcher setReturnURLScheme:] to be configured to begin with your app's bundle ID (%@). Currently, it is set to (%@)", @@ -151,9 +169,10 @@ import BraintreeCore do { let appSwitchURL = try BTVenmoAppSwitchRedirectURL( - returnURLScheme: returnURLScheme, paymentContextID: paymentContextID, metadata: metadata, + returnURLScheme: returnURLScheme, + universalLink: self.universalLink, forMerchantID: merchantProfileID, accessToken: configuration.venmoAccessToken, bundleDisplayName: bundleDisplayName, @@ -393,20 +412,20 @@ import BraintreeCore if success { apiClient.sendAnalyticsEvent( BTVenmoAnalytics.appSwitchSucceeded, + appSwitchURL: appSwitchURL, isVaultRequest: shouldVault, linkType: linkType, - payPalContextID: payPalContextID, - appSwitchURL: appSwitchURL + payPalContextID: payPalContextID ) BTVenmoClient.venmoClient = self self.appSwitchCompletion = completion } else { apiClient.sendAnalyticsEvent( BTVenmoAnalytics.appSwitchFailed, + appSwitchURL: appSwitchURL, isVaultRequest: shouldVault, linkType: linkType, - payPalContextID: payPalContextID, - appSwitchURL: appSwitchURL + payPalContextID: payPalContextID ) notifyFailure(with: BTVenmoError.appSwitchFailed, completion: completion) } diff --git a/UnitTests/BraintreeCoreTests/Analytics/FPTIBatchData_Tests.swift b/UnitTests/BraintreeCoreTests/Analytics/FPTIBatchData_Tests.swift index 72b5f2b5dc..bb8f201c48 100644 --- a/UnitTests/BraintreeCoreTests/Analytics/FPTIBatchData_Tests.swift +++ b/UnitTests/BraintreeCoreTests/Analytics/FPTIBatchData_Tests.swift @@ -3,9 +3,9 @@ import XCTest @testable import BraintreeCore final class FPTIBatchData_Tests: XCTestCase { - + var sut: FPTIBatchData! - + let batchMetadata = FPTIBatchData.Metadata( authorizationFingerprint: "fake-auth", environment: "fake-env", @@ -14,7 +14,7 @@ final class FPTIBatchData_Tests: XCTestCase { sessionID: "fake-session", tokenizationKey: "fake-auth" ) - + let eventParams = [ FPTIBatchData.Event( connectionStartTime: 123, @@ -45,13 +45,13 @@ final class FPTIBatchData_Tests: XCTestCase { startTime: nil ) ] - + override func setUp() { super.setUp() sut = FPTIBatchData(metadata: batchMetadata, events: eventParams) } - + func testInit_formatsJSONBody() throws { let jsonBody = try sut.toDictionary() @@ -59,12 +59,12 @@ final class FPTIBatchData_Tests: XCTestCase { XCTFail("JSON body missing top level `events` key.") return } - + guard let eventParams = events[0]["event_params"] as? [[String: Any]] else { XCTFail("JSON body missing `event_params` key.") return } - + guard let batchParams = events[0]["batch_params"] as? [String: Any] else { XCTFail("JSON body missing `batch_params` key.") return diff --git a/UnitTests/BraintreePayPalTests/BTPayPalCheckoutRequest_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalCheckoutRequest_Tests.swift index e082e120f7..e60433862f 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalCheckoutRequest_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalCheckoutRequest_Tests.swift @@ -176,4 +176,43 @@ class BTPayPalCheckoutRequest_Tests: XCTestCase { XCTAssertNil(parameters["payer_email"]) } + + func testParametersWithConfiguration__withContactInformation_setsRecipientEmailAndPhoneNumber() { + let request = BTPayPalCheckoutRequest(amount: "1") + request.contactInformation = BTContactInformation( + recipientEmail: "some@mail.com", + recipientPhoneNumber: BTPayPalPhoneNumber(countryCode: "US", nationalNumber: "123456789") + ) + + let parameters = request.parameters(with: configuration) + + XCTAssertEqual(parameters["recipient_email"] as? String, "some@mail.com") + let internationalPhone = parameters["international_phone"] as? [String: String] + XCTAssertEqual(internationalPhone, ["country_code": "US", "national_number": "123456789"]) + } + + func testParametersWithConfiguration_whenContactInformationNotSet_doesNotSetPayerEmailAndPhoneNumberInRequest() { + let request = BTPayPalCheckoutRequest(amount: "1") + + let parameters = request.parameters(with: configuration) + + XCTAssertNil(parameters["recipient_email"]) + XCTAssertNil(parameters["international_phone"]) + } + + func testParameters_whenShippingCallbackURLNotSet_returnsParameters() { + let request = BTPayPalCheckoutRequest(amount: "1") + + XCTAssertNil(request.shippingCallbackURL) + let parameters = request.parameters(with: configuration) + XCTAssertNil(parameters["shipping_callback_url"]) + } + + func testParameters_whitShippingCallbackURL_returnsParametersWithShippingCallbackURL() { + let request = BTPayPalCheckoutRequest(amount: "1", shippingCallbackURL: URL(string: "www.some-url.com")) + + XCTAssertNotNil(request.shippingCallbackURL) + let parameters = request.parameters(with: configuration) + XCTAssertNotNil(parameters["shipping_callback_url"]) + } } diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index 08f6149f62..44cb6d4b02 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -982,6 +982,24 @@ class BTPayPalClient_Tests: XCTestCase { XCTAssertNil(lastPostParameters["merchant_app_return_url"] as? String) } + func testInvokedOpenURLSuccessfully_whenSuccess_sendsAppSwitchSucceededWithAppSwitchURL() { + let eventName = BTPayPalAnalytics.appSwitchSucceeded + let fakeURL = URL(string: "some-url")! + payPalClient.invokedOpenURLSuccessfully(true, url: fakeURL) { _, _ in } + + XCTAssertEqual(mockAPIClient.postedAnalyticsEvents.last!, eventName) + XCTAssertEqual(mockAPIClient.postedAppSwitchURL[eventName], fakeURL.absoluteString) + } + + func testInvokedOpenURLSuccessfully_whenFailure_sendsAppSwitchFailedWithAppSwitchURL() { + let eventName = BTPayPalAnalytics.appSwitchFailed + let fakeURL = URL(string: "some-url")! + payPalClient.invokedOpenURLSuccessfully(false, url: fakeURL) { _, _ in } + + XCTAssertEqual(mockAPIClient.postedAnalyticsEvents.first!, eventName) + XCTAssertEqual(mockAPIClient.postedAppSwitchURL[eventName], fakeURL.absoluteString) + } + // MARK: - Analytics func testAPIClientMetadata_hasIntegrationSetToCustom() { @@ -1028,4 +1046,13 @@ class BTPayPalClient_Tests: XCTestCase { XCTAssertFalse(mockAPIClient.postedIsVaultRequest) } + + func testTokenize_whenShopperSessionIDSetOnRequest_includesInAnalytics() async { + let checkoutRequest = BTPayPalCheckoutRequest(amount: "2.00") + checkoutRequest.shopperSessionID = "fake-shopper-session-id" + + let _ = try? await payPalClient.tokenize(checkoutRequest) + + XCTAssertEqual(mockAPIClient.postedShopperSessionID, "fake-shopper-session-id") + } } diff --git a/UnitTests/BraintreePayPalTests/BTPayPalRequest_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalRequest_Tests.swift index 304d349f6c..c9caf02eae 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalRequest_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalRequest_Tests.swift @@ -46,6 +46,7 @@ class BTPayPalRequest_Tests: XCTestCase { request.riskCorrelationID = "123-correlation-id" request.merchantAccountID = "merchant-account-id" request.isShippingAddressEditable = true + request.shopperSessionID = "123456" let lineItem = BTPayPalLineItem(quantity: "1", unitAmount: "1", name: "item", kind: .credit) lineItem.imageURL = URL(string: "http://example/image.jpg") @@ -73,6 +74,7 @@ class BTPayPalRequest_Tests: XCTestCase { XCTAssertEqual(parameters["return_url"] as? String, "sdk.ios.braintree://onetouch/v1/success") XCTAssertEqual(parameters["cancel_url"] as? String, "sdk.ios.braintree://onetouch/v1/cancel") + XCTAssertEqual(parameters["shopper_session_id"] as? String, "123456") } func testParametersWithConfiguration_whenShippingAddressIsRequiredNotSet_returnsNoShippingTrue() { diff --git a/UnitTests/BraintreeShopperInsightsTests/BTShopperInsightsAnalytics_Tests.swift b/UnitTests/BraintreeShopperInsightsTests/BTShopperInsightsAnalytics_Tests.swift index 991a240b45..713762c7c6 100644 --- a/UnitTests/BraintreeShopperInsightsTests/BTShopperInsightsAnalytics_Tests.swift +++ b/UnitTests/BraintreeShopperInsightsTests/BTShopperInsightsAnalytics_Tests.swift @@ -4,10 +4,8 @@ import XCTest final class BTShopperInsightsAnalytics_Tests: XCTestCase { func test_recommendedPaymentAnalyticEvents_sendExpectedEventNames() { - XCTAssertEqual(BTShopperInsightsAnalytics.payPalPresented, "shopper-insights:paypal-presented") - XCTAssertEqual(BTShopperInsightsAnalytics.payPalSelected, "shopper-insights:paypal-selected") - XCTAssertEqual(BTShopperInsightsAnalytics.venmoPresented, "shopper-insights:venmo-presented") - XCTAssertEqual(BTShopperInsightsAnalytics.venmoSelected, "shopper-insights:venmo-selected") + XCTAssertEqual(BTShopperInsightsAnalytics.buttonPresented, "shopper-insights:button-presented") + XCTAssertEqual(BTShopperInsightsAnalytics.buttonSelected, "shopper-insights:button-selected") XCTAssertEqual(BTShopperInsightsAnalytics.recommendedPaymentsStarted, "shopper-insights:get-recommended-payments:started") XCTAssertEqual(BTShopperInsightsAnalytics.recommendedPaymentsSucceeded, "shopper-insights:get-recommended-payments:succeeded") XCTAssertEqual(BTShopperInsightsAnalytics.recommendedPaymentsFailed, "shopper-insights:get-recommended-payments:failed") diff --git a/UnitTests/BraintreeShopperInsightsTests/BTShopperInsightsClient_Tests.swift b/UnitTests/BraintreeShopperInsightsTests/BTShopperInsightsClient_Tests.swift index 168e282134..27ac9c74fb 100644 --- a/UnitTests/BraintreeShopperInsightsTests/BTShopperInsightsClient_Tests.swift +++ b/UnitTests/BraintreeShopperInsightsTests/BTShopperInsightsClient_Tests.swift @@ -28,11 +28,11 @@ class BTShopperInsightsClient_Tests: XCTestCase { } ] """ - + override func setUp() { super.setUp() mockAPIClient = MockAPIClient(authorization: clientToken) - sut = BTShopperInsightsClient(apiClient: mockAPIClient!) + sut = BTShopperInsightsClient(apiClient: mockAPIClient!, shopperSessionID: "fake-shopper-session-id") } // MARK: - getRecommendedPaymentMethods() @@ -78,6 +78,7 @@ class BTShopperInsightsClient_Tests: XCTestCase { XCTAssertEqual(error.domain, "fake-error-domain") XCTAssertEqual(mockAPIClient.postedAnalyticsEvents.last, "shopper-insights:get-recommended-payments:failed") + XCTAssertEqual(mockAPIClient.postedShopperSessionID, "fake-shopper-session-id") } } @@ -101,6 +102,7 @@ class BTShopperInsightsClient_Tests: XCTestCase { XCTAssertTrue(result.isVenmoRecommended) XCTAssertFalse(result.isPayPalRecommended) XCTAssertEqual(mockAPIClient.postedAnalyticsEvents.last, "shopper-insights:get-recommended-payments:succeeded") + XCTAssertEqual(mockAPIClient.postedShopperSessionID, "fake-shopper-session-id") } catch let error as NSError { XCTFail("An error was not expected.") } @@ -127,6 +129,7 @@ class BTShopperInsightsClient_Tests: XCTestCase { XCTAssertTrue(result.isEligibleInPayPalNetwork) XCTAssertEqual(mockAPIClient.postedAnalyticsEvents.last, "shopper-insights:get-recommended-payments:succeeded") XCTAssertEqual(mockAPIClient.postedMerchantExperiment, sampleExperiment) + XCTAssertEqual(mockAPIClient.postedShopperSessionID, "fake-shopper-session-id") } catch { XCTFail("An error was not expected.") } @@ -152,6 +155,7 @@ class BTShopperInsightsClient_Tests: XCTestCase { XCTAssertTrue(result.isVenmoRecommended) XCTAssertTrue(result.isEligibleInPayPalNetwork) XCTAssertEqual(mockAPIClient.postedAnalyticsEvents.last, "shopper-insights:get-recommended-payments:succeeded") + XCTAssertEqual(mockAPIClient.postedShopperSessionID, "fake-shopper-session-id") } catch { XCTFail("An error was not expected.") } @@ -181,13 +185,14 @@ class BTShopperInsightsClient_Tests: XCTestCase { XCTAssertFalse(result.isVenmoRecommended) XCTAssertFalse(result.isEligibleInPayPalNetwork) XCTAssertEqual(mockAPIClient.postedAnalyticsEvents.last, "shopper-insights:get-recommended-payments:succeeded") + XCTAssertEqual(mockAPIClient.postedShopperSessionID, "fake-shopper-session-id") } catch { XCTFail("An error was not expected.") } } func testGetRecommendedPaymentMethods_withTokenizationKey_returnsError() async { - var apiClient = BTAPIClient(authorization: "sandbox_merchant_1234567890abc")! + let apiClient = BTAPIClient(authorization: "sandbox_merchant_1234567890abc")! let shopperInsightsClient = BTShopperInsightsClient(apiClient: apiClient) do { @@ -201,30 +206,123 @@ class BTShopperInsightsClient_Tests: XCTestCase { // MARK: - Analytics - func testSendPayPalPresentedEvent_sendsAnalytic() { - sut.sendPayPalPresentedEvent() - XCTAssertEqual(mockAPIClient.postedAnalyticsEvents.first, "shopper-insights:paypal-presented") + func testSendPayPalPresentedEvent_whenExperimentTypeIsControl_sendsAnalytic() { + let presentmentDetails = BTPresentmentDetails( + buttonOrder: .first, + experimentType: .control, + pageType: .about + ) + sut.sendPresentedEvent(for: .payPal, presentmentDetails: presentmentDetails) + XCTAssertEqual(mockAPIClient.postedAnalyticsEvents.first, "shopper-insights:button-presented") + XCTAssertEqual(mockAPIClient.postedButtonOrder, "1") + XCTAssertEqual(mockAPIClient.postedButtonType, "PayPal") + XCTAssertEqual(mockAPIClient.postedMerchantExperiment, + """ + [ + { "exp_name" : "PaymentReady" } + { "treatment_name" : "control" } + ] + """) + XCTAssertEqual(mockAPIClient.postedPageType, "about") } - - func testSendPayPalPresentedEvent_whenPaymentMethodsDisplayedNotNil_sendsAnalytic() { - let paymentMethods = ["Apple Pay", "Card", "PayPal"] - sut.sendPayPalPresentedEvent(paymentMethodsDisplayed: paymentMethods) - XCTAssertEqual(mockAPIClient.postedPaymentMethodsDisplayed, paymentMethods.joined(separator: ", ")) - XCTAssertEqual(mockAPIClient.postedAnalyticsEvents.first, "shopper-insights:paypal-presented") + + func testSendPayPalPresentedEvent_whenExperimentTypeIsTest_sendsAnalytic() { + let presentmentDetails = BTPresentmentDetails( + buttonOrder: .first, + experimentType: .test, + pageType: .about + ) + sut.sendPresentedEvent(for: .payPal, presentmentDetails: presentmentDetails) + XCTAssertEqual(mockAPIClient.postedMerchantExperiment, + """ + [ + { "exp_name" : "PaymentReady" } + { "treatment_name" : "test" } + ] + """) } - + func testSendPayPalSelectedEvent_sendsAnalytic() { - sut.sendPayPalSelectedEvent() - XCTAssertEqual(mockAPIClient.postedAnalyticsEvents.first, "shopper-insights:paypal-selected") + sut.sendSelectedEvent(for: .payPal) + XCTAssertEqual(mockAPIClient.postedAnalyticsEvents.first, "shopper-insights:button-selected") + XCTAssertEqual(mockAPIClient.postedShopperSessionID, "fake-shopper-session-id") + XCTAssertEqual(mockAPIClient.postedButtonType, "PayPal") } - + func testSendVenmoPresentedEvent_sendsAnalytic() { - sut.sendVenmoPresentedEvent() - XCTAssertEqual(mockAPIClient.postedAnalyticsEvents.first, "shopper-insights:venmo-presented") + let presentmentDetails = BTPresentmentDetails( + buttonOrder: .first, + experimentType: .control, + pageType: .about + ) + sut.sendPresentedEvent(for: .venmo, presentmentDetails: presentmentDetails) + XCTAssertEqual(mockAPIClient.postedAnalyticsEvents.first, "shopper-insights:button-presented") + XCTAssertEqual(mockAPIClient.postedButtonOrder, "1") + XCTAssertEqual(mockAPIClient.postedButtonType, "Venmo") + XCTAssertEqual(mockAPIClient.postedMerchantExperiment, + """ + [ + { "exp_name" : "PaymentReady" } + { "treatment_name" : "control" } + ] + """) + XCTAssertEqual(mockAPIClient.postedPageType, "about") + } + + func testSendVenmoPresentedEvent_whenExperimentTypeIsTest_sendsAnalytic() { + let presentmentDetails = BTPresentmentDetails( + buttonOrder: .first, + experimentType: .test, + pageType: .about + ) + sut.sendPresentedEvent(for: .venmo, presentmentDetails: presentmentDetails) + XCTAssertEqual(mockAPIClient.postedMerchantExperiment, + """ + [ + { "exp_name" : "PaymentReady" } + { "treatment_name" : "test" } + ] + """) } func testSendVenmoSelectedEvent_sendsAnalytic() { - sut.sendVenmoSelectedEvent() - XCTAssertEqual(mockAPIClient.postedAnalyticsEvents.first, "shopper-insights:venmo-selected") + sut.sendSelectedEvent(for: .venmo) + XCTAssertEqual(mockAPIClient.postedAnalyticsEvents.first, "shopper-insights:button-selected") + XCTAssertEqual(mockAPIClient.postedButtonType, "Venmo") + XCTAssertEqual(mockAPIClient.postedShopperSessionID, "fake-shopper-session-id") + } + + // MARK: - App Installed Methods + + func testIsPayPalAppInstalled_whenPayPalAppNotInstalled_returnsFalse() { + let fakeApplication = FakeApplication() + fakeApplication.cannedCanOpenURL = false + + XCTAssertFalse(sut.isPayPalAppInstalled()) + } + + func testIsPayPalAppInstalled_whenPayPalAppIsInstalled_returnsTrue() { + let fakeApplication = FakeApplication() + fakeApplication.cannedCanOpenURL = true + fakeApplication.canOpenURLWhitelist.append(URL(string: "paypal-app-switch-checkout://x-callback-url/path")!) + sut.application = fakeApplication + + XCTAssertTrue(sut.isPayPalAppInstalled()) + } + + func testIsVenmoAppInstalled_whenVenmoAppNotInstalled_returnsFalse() { + let fakeApplication = FakeApplication() + fakeApplication.cannedCanOpenURL = false + + XCTAssertFalse(sut.isVenmoAppInstalled()) + } + + func testIsVenmoAppInstalled_whenVenmoAppIsInstalled_returnsTrue() { + let fakeApplication = FakeApplication() + fakeApplication.cannedCanOpenURL = true + fakeApplication.canOpenURLWhitelist.append(URL(string: "com.venmo.touch.v2://x-callback-url/path")!) + sut.application = fakeApplication + + XCTAssertTrue(sut.isVenmoAppInstalled()) } } diff --git a/UnitTests/BraintreeTestShared/MockAPIClient.swift b/UnitTests/BraintreeTestShared/MockAPIClient.swift index 77ff070ec2..46bbaf5041 100644 --- a/UnitTests/BraintreeTestShared/MockAPIClient.swift +++ b/UnitTests/BraintreeTestShared/MockAPIClient.swift @@ -11,14 +11,17 @@ public class MockAPIClient: BTAPIClient { public var lastGETParameters = [:] as [String: Any]? public var lastGETAPIClientHTTPType: BTAPIClientHTTPService? - public var postedAnalyticsEvents : [String] = [] - public var postedPayPalContextID: String? = nil - public var postedLinkType: LinkType? = nil + public var postedAnalyticsEvents: [String] = [] + public var postedAppSwitchURL: [String: String?] = [:] + public var postedButtonOrder: String? = nil + public var postedButtonType: String? = nil public var postedIsVaultRequest = false + public var postedLinkType: LinkType? = nil public var postedMerchantExperiment: String? = nil - public var postedPaymentMethodsDisplayed: String? = nil - public var postedAppSwitchURL: [String: String?] = [:] - + public var postedPageType: String? = nil + public var postedPayPalContextID: String? = nil + public var postedShopperSessionID: String? = nil + @objc public var cannedConfigurationResponseBody : BTJSON? = nil @objc public var cannedConfigurationResponseError : NSError? = nil @@ -92,24 +95,30 @@ public class MockAPIClient: BTAPIClient { } public override func sendAnalyticsEvent( - _ name: String, + _ eventName: String, + appSwitchURL: URL? = nil, + buttonOrder: String? = nil, + buttonType: String? = nil, correlationID: String? = nil, errorDescription: String? = nil, merchantExperiment experiment: String? = nil, isConfigFromCache: Bool? = nil, isVaultRequest: Bool? = nil, linkType: LinkType? = nil, - paymentMethodsDisplayed: String? = nil, + pageType: String? = nil, payPalContextID: String? = nil, - appSwitchURL: URL? = nil + shopperSessionID: String? = nil ) { + postedButtonType = buttonType + postedButtonOrder = buttonOrder + postedPageType = pageType postedPayPalContextID = payPalContextID postedLinkType = linkType postedIsVaultRequest = isVaultRequest ?? false postedMerchantExperiment = experiment - postedPaymentMethodsDisplayed = paymentMethodsDisplayed - postedAppSwitchURL[name] = appSwitchURL?.absoluteString - postedAnalyticsEvents.append(name) + postedAppSwitchURL[eventName] = appSwitchURL?.absoluteString + postedAnalyticsEvents.append(eventName) + postedShopperSessionID = shopperSessionID } func didFetchPaymentMethods(sorted: Bool) -> Bool { diff --git a/UnitTests/BraintreeThreeDSecureTests/BTThreeDSecureClient_Tests.swift b/UnitTests/BraintreeThreeDSecureTests/BTThreeDSecureClient_Tests.swift index 234fe29429..1f1082f3cb 100644 --- a/UnitTests/BraintreeThreeDSecureTests/BTThreeDSecureClient_Tests.swift +++ b/UnitTests/BraintreeThreeDSecureTests/BTThreeDSecureClient_Tests.swift @@ -10,7 +10,9 @@ class BTThreeDSecureClient_Tests: XCTestCase { var threeDSecureRequest = BTThreeDSecureRequest() var client: BTThreeDSecureClient! var mockThreeDSecureRequestDelegate : MockThreeDSecureRequestDelegate! - + + let mockCardinalSession = MockCardinalSession() + let mockConfiguration = BTJSON(value: [ "threeDSecure": ["cardinalAuthenticationJWT": "FAKE_JWT"], "assetsUrl": "http://assets.example.com" @@ -21,7 +23,7 @@ class BTThreeDSecureClient_Tests: XCTestCase { threeDSecureRequest.amount = 10.0 threeDSecureRequest.nonce = "fake-card-nonce" client = BTThreeDSecureClient(apiClient: mockAPIClient) - client.cardinalSession = MockCardinalSession() + client.cardinalSession = mockCardinalSession mockThreeDSecureRequestDelegate = MockThreeDSecureRequestDelegate() } @@ -689,4 +691,20 @@ class BTThreeDSecureClient_Tests: XCTestCase { waitForExpectations(timeout: 1) } + + func testPrepareLookup_whenDfReferenceIDEmpty_throwsError() { + mockAPIClient.cannedConfigurationResponseBody = mockConfiguration + mockCardinalSession.dfReferenceID = "" + + let expectation = expectation(description: "willCallCompletion") + + threeDSecureRequest.nonce = "fake-card-nonce" + + client.prepareLookup(threeDSecureRequest) { _, error in + XCTAssertEqual(error?.localizedDescription, "There was an error retrieving the dfReferenceId.") + expectation.fulfill() + } + + waitForExpectations(timeout: 1) + } } diff --git a/UnitTests/BraintreeThreeDSecureTests/MockCardinalSession.swift b/UnitTests/BraintreeThreeDSecureTests/MockCardinalSession.swift index fbbb8ec722..24d5047be4 100644 --- a/UnitTests/BraintreeThreeDSecureTests/MockCardinalSession.swift +++ b/UnitTests/BraintreeThreeDSecureTests/MockCardinalSession.swift @@ -3,7 +3,9 @@ import CardinalMobile @testable import BraintreeThreeDSecure class MockCardinalSession: CardinalSessionTestable { - + + var dfReferenceID = "fake-df-reference-id" + func configure(_ sessionConfig: CardinalSessionConfiguration) { // do nothing } @@ -13,7 +15,7 @@ class MockCardinalSession: CardinalSessionTestable { completed didCompleteHandler: @escaping CardinalSessionSetupDidCompleteHandler, validated didValidateHandler: @escaping CardinalSessionSetupDidValidateHandler ) { - didCompleteHandler("fake-df-reference-id") + didCompleteHandler(dfReferenceID) } func continueWith(transactionId: String, payload: String, validationDelegate: CardinalValidationDelegate) { diff --git a/UnitTests/BraintreeVenmoTests/BTVenmoAppSwitchRedirectURL_Tests.swift b/UnitTests/BraintreeVenmoTests/BTVenmoAppSwitchRedirectURL_Tests.swift index da92e9bc00..fef916e212 100644 --- a/UnitTests/BraintreeVenmoTests/BTVenmoAppSwitchRedirectURL_Tests.swift +++ b/UnitTests/BraintreeVenmoTests/BTVenmoAppSwitchRedirectURL_Tests.swift @@ -7,9 +7,10 @@ class BTVenmoAppSwitchRedirectURL_Tests: XCTestCase { func testUrlSchemeURL_whenAllValuesAreInitialized_returnsURLWithPaymentContextID() { do { let requestURL = try BTVenmoAppSwitchRedirectURL( - returnURLScheme: "url-scheme", paymentContextID: "12345", metadata: BTClientMetadata(), + returnURLScheme: "url-scheme", + universalLink: nil, forMerchantID: "merchant-id", accessToken: "access-token", bundleDisplayName: "display-name", @@ -29,9 +30,10 @@ class BTVenmoAppSwitchRedirectURL_Tests: XCTestCase { func testAppSwitchURL_whenMerchantIDNil_throwsError() { do { _ = try BTVenmoAppSwitchRedirectURL( - returnURLScheme: "url-scheme", paymentContextID: "12345", metadata: BTClientMetadata(), + returnURLScheme: "url-scheme", + universalLink: nil, forMerchantID: nil, accessToken: "access-token", bundleDisplayName: "display-name", @@ -47,9 +49,10 @@ class BTVenmoAppSwitchRedirectURL_Tests: XCTestCase { func testUniversalLinkURL_whenAllValuesInitialized_returnsURLWithAllValues() { do { let requestURL = try BTVenmoAppSwitchRedirectURL( - returnURLScheme: "url-scheme", paymentContextID: "12345", metadata: BTClientMetadata(), + returnURLScheme: nil, + universalLink: URL(string: "https://mywebsite.com/braintree-payments"), forMerchantID: "merchant-id", accessToken: "access-token", bundleDisplayName: "display-name", @@ -60,9 +63,9 @@ class BTVenmoAppSwitchRedirectURL_Tests: XCTestCase { let components = URLComponents(string: requestURL.universalLinksURL()!.absoluteString) guard let queryItems = components?.queryItems else { XCTFail(); return } - XCTAssertTrue(queryItems.contains(URLQueryItem(name: "x-success", value: "url-scheme://x-callback-url/vzero/auth/venmo/success"))) - XCTAssertTrue(queryItems.contains(URLQueryItem(name: "x-error", value: "url-scheme://x-callback-url/vzero/auth/venmo/error"))) - XCTAssertTrue(queryItems.contains(URLQueryItem(name: "x-cancel", value: "url-scheme://x-callback-url/vzero/auth/venmo/cancel"))) + XCTAssertTrue(queryItems.contains(URLQueryItem(name: "x-success", value: "https://mywebsite.com/braintree-payments/success"))) + XCTAssertTrue(queryItems.contains(URLQueryItem(name: "x-error", value: "https://mywebsite.com/braintree-payments/error"))) + XCTAssertTrue(queryItems.contains(URLQueryItem(name: "x-cancel", value: "https://mywebsite.com/braintree-payments/cancel"))) XCTAssertTrue(queryItems.contains(URLQueryItem(name: "x-source", value: "display-name"))) XCTAssertTrue(queryItems.contains(URLQueryItem(name: "braintree_merchant_id", value: "merchant-id"))) XCTAssertTrue(queryItems.contains(URLQueryItem(name: "braintree_access_token", value: "access-token"))) diff --git a/UnitTests/BraintreeVenmoTests/BTVenmoClient_Tests.swift b/UnitTests/BraintreeVenmoTests/BTVenmoClient_Tests.swift index e9101a4380..11eb0f6a96 100644 --- a/UnitTests/BraintreeVenmoTests/BTVenmoClient_Tests.swift +++ b/UnitTests/BraintreeVenmoTests/BTVenmoClient_Tests.swift @@ -78,6 +78,20 @@ class BTVenmoClient_Tests: XCTestCase { let venmoClient = BTVenmoClient(apiClient: mockAPIClient) BTAppContextSwitcher.sharedInstance.returnURLScheme = "" + let expectation = expectation(description: "authorization callback") + venmoClient.tokenize(venmoRequest) { venmoAccount, error in + guard let error = error as NSError? else { return } + XCTAssertEqual(error.domain, BTVenmoError.errorDomain) + XCTAssertEqual(error.code, BTVenmoError.invalidReturnURL("").errorCode) + expectation.fulfill() + } + + waitForExpectations(timeout: 2) + } + + func testTokenizeVenmoAccount_whenReturnURLSchemeAndUniversalLinkIsNil_andCallsBackWithError() { + let venmoClient = BTVenmoClient(apiClient: mockAPIClient) + let expectation = expectation(description: "authorization callback") venmoClient.tokenize(venmoRequest) { venmoAccount, error in guard let error = error as NSError? else {return} From e8e4d1158d88ae682bb809f902efb5a4f01a8723 Mon Sep 17 00:00:00 2001 From: ageddam Date: Wed, 12 Feb 2025 15:49:23 -0600 Subject: [PATCH 09/12] address pr feedback --- CHANGELOG.md | 9 ++- .../BTPayPalClient_Tests.swift | 69 ++++++++++++++++++- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08927304d6..17c27b9625 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Braintree iOS SDK Release Notes +## unreleased +* BraintreePayPal + * Add PayPal App Switch checkout Flow (BETA) + * Add `BTPayPalCheckoutRequest(userAuthenticationEmail:enablePayPalAppSwitch:amount:intent:userAction:offerPayLater:currencyCode:requestBillingAgreement:)` + * **Note:** This feature is currently in beta and may change or be removed in future releases. + ## 6.28.0 (2025-02-05) * BraintreeVenmo * Allow universal links to be set without a return URL scheme (fixes #1505) @@ -32,9 +38,6 @@ ## 6.25.0 (2024-12-11) * BraintreePayPal * Add `BTPayPalRequest.userPhoneNumber` optional property - * Add PayPal App Switch checkout Flow (BETA) - * Add `BTPayPalCheckoutRequest(userAuthenticationEmail:enablePayPalAppSwitch:amount:intent:userAction: offerPayLater:currencyCode:requestBillingAgreement:)` - * **Note:** This feature is currently in beta and may change or be removed in future releases. * Send `url` in `event_params` for App Switch events to PayPal's analytics service (FPTI) * BraintreeVenmo * Send `url` in `event_params` for App Switch events to PayPal's analytics service (FPTI) diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index 44cb6d4b02..3dd180264b 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -745,7 +745,7 @@ class BTPayPalClient_Tests: XCTestCase { XCTAssertNil(BTPayPalClient.payPalClient) } - // MARK: - App Switch - tokenize + // MARK: - App Switch - Tokenize func testTokenizeVaultAccount_whenPayPalAppApprovalURLPresent_attemptsAppSwitchWithParameters() async { let fakeApplication = FakeApplication() @@ -858,6 +858,73 @@ class BTPayPalClient_Tests: XCTestCase { waitForExpectations(timeout: 1) } + + func testTokenizeCheckoutAccount_whenPayPalAppApprovalURLPresent_attemptsAppSwitchWithParameters() async { + let fakeApplication = FakeApplication() + payPalClient.application = fakeApplication + + mockAPIClient.cannedResponseBody = BTJSON(value: [ + "paymentResource": [ + "redirectUrl": "https://www.some-url.com/some-path?token=value1", + "launchPayPalApp": true + ] + ]) + + let checkoutRequest = BTPayPalCheckoutRequest( + userAuthenticationEmail: "fake-pp@gmail.com", + enablePayPalAppSwitch: true, + amount: "10.00" + ) + payPalClient.tokenize(checkoutRequest) { _, _ 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, "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(urlTimestamp) + } else { + XCTFail("Expected integer value for query param `switch_initiated_time`") + } + } + + func testTokenizeCheckoutAccount_whenPayPalAppApprovalURLMissingECToken_returnsError() { + let fakeApplication = FakeApplication() + payPalClient.application = fakeApplication + + mockAPIClient.cannedResponseBody = BTJSON(value: [ + "paymentResource": [ + "redirectUrl": "https://www.some-url.com/some-path", + "launchPayPalApp": true + ] + ]) + + let checkoutRequest = BTPayPalCheckoutRequest( + userAuthenticationEmail: "fake-pp@gmail.com", + enablePayPalAppSwitch: true, + amount: "10.00" + ) + + let expectation = expectation(description: "completion block called") + payPalClient.tokenize(checkoutRequest) { nonce, error in + XCTAssertNil(nonce) + + guard let error = error as NSError? else { XCTFail(); return } + XCTAssertEqual(error.code, 14) + XCTAssertEqual(error.localizedDescription, "Missing EC Token for PayPal App Switch.") + XCTAssertEqual(error.domain, "com.braintreepayments.BTPayPalErrorDomain") + expectation.fulfill() + } + + waitForExpectations(timeout: 1) + } func testHandleReturn_whenURLIsUnknown_returnsError() { let request = BTPayPalVaultRequest( From 90a9e96de01c3be5e2363b5a5ae5026e3959819d Mon Sep 17 00:00:00 2001 From: ageddam Date: Thu, 13 Feb 2025 10:25:33 -0600 Subject: [PATCH 10/12] fix spacing --- .../Features/PayPalWebCheckoutViewController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Demo/Application/Features/PayPalWebCheckoutViewController.swift b/Demo/Application/Features/PayPalWebCheckoutViewController.swift index 59304f0dac..abb0471c1d 100644 --- a/Demo/Application/Features/PayPalWebCheckoutViewController.swift +++ b/Demo/Application/Features/PayPalWebCheckoutViewController.swift @@ -89,11 +89,14 @@ 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 payPalAppSwitchForCheckoutButton = createButton( title: "PayPal App Switch - Checkout", action: #selector(tappedPayPalAppSwitchForCheckout) ) + let payPalAppSwitchForVaultButton = createButton( title: "PayPal App Switch - Vault", action: #selector(tappedPayPalAppSwitchForVault) @@ -107,6 +110,7 @@ class PayPalWebCheckoutViewController: PaymentButtonBaseViewController { payPalAppSwitchForCheckoutButton ]) oneTimeCheckoutStackView.spacing = 12 + let vaultStackView = buttonsStackView(label: "Vault", views: [ rbaDataToggle, payPalVaultButton, From 4b22a6d9334ec233ab9addc97f0bdac07fb5cfcd Mon Sep 17 00:00:00 2001 From: ageddam Date: Wed, 19 Feb 2025 12:33:53 -0600 Subject: [PATCH 11/12] code cleanup --- .../BTPayPalCheckoutRequest.swift | 2 +- Sources/BraintreePayPal/BTPayPalError.swift | 4 +- Sources/BraintreePayPal/BTPayPalRequest.swift | 80 +++++++++---------- .../BTPayPalVaultBaseRequest.swift | 2 +- .../BTPayPalVaultRequest.swift | 2 +- .../BTPayPalClient_Tests.swift | 10 +-- 6 files changed, 49 insertions(+), 51 deletions(-) diff --git a/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift b/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift index 3276293230..4a9d1c6011 100644 --- a/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift @@ -82,7 +82,7 @@ import BraintreeCore /// Optional: Contact information of the recipient for the order public var contactInformation: BTContactInformation? - + /// Optional: Server side shipping callback URL to be notified when a customer updates their shipping address or options. A callback request will be sent to the merchant server at this URL. public var shippingCallbackURL: URL? diff --git a/Sources/BraintreePayPal/BTPayPalError.swift b/Sources/BraintreePayPal/BTPayPalError.swift index 23ea735aef..28d4a4d982 100644 --- a/Sources/BraintreePayPal/BTPayPalError.swift +++ b/Sources/BraintreePayPal/BTPayPalError.swift @@ -38,10 +38,10 @@ public enum BTPayPalError: Error, CustomNSError, LocalizedError, Equatable { /// 11. App Switch could not complete case appSwitchFailed - + /// 12. Missing BA Token for App Switch case missingBAToken - + /// 13. Missing PayPal Request case missingPayPalRequest diff --git a/Sources/BraintreePayPal/BTPayPalRequest.swift b/Sources/BraintreePayPal/BTPayPalRequest.swift index 272de438b6..e57ff81763 100644 --- a/Sources/BraintreePayPal/BTPayPalRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalRequest.swift @@ -5,7 +5,6 @@ import BraintreeCore #endif @objc public enum BTPayPalPaymentType: Int { - /// Checkout case checkout @@ -24,24 +23,23 @@ import BraintreeCore /// Use this option to specify the PayPal page to display when a user lands on the PayPal site to complete the payment. @objc public enum BTPayPalRequestLandingPageType: Int { - /// Default case none // Obj-C enums cannot be nil; this default option is used to make `landingPageType` optional for merchants - + /// Login case login - + /// Billing case billing - + var stringValue: String? { switch self { case .login: return "login" - + case .billing: return "billing" - + default: return nil } @@ -51,62 +49,62 @@ import BraintreeCore /// Base options for PayPal Checkout and PayPal Vault flows. /// - Note: Do not instantiate this class directly. Instead, use BTPayPalCheckoutRequest or BTPayPalVaultRequest. @objcMembers open class BTPayPalRequest: NSObject { - + // MARK: - Public Properties - + /// Defaults to false. When set to true, the shipping address selector will be displayed. public var isShippingAddressRequired: Bool - + /// Defaults to false. Set to true to enable user editing of the shipping address. /// - Note: Only applies when `shippingAddressOverride` is set. public var isShippingAddressEditable: Bool - + /// Optional: A locale code to use for the transaction. public var localeCode: BTPayPalLocaleCode - + /// Optional: A valid shipping address to be displayed in the transaction flow. An error will occur if this address is not valid. public var shippingAddressOverride: BTPostalAddress? - + /// Optional: Landing page type. Defaults to `.none`. /// - Note: Setting the BTPayPalRequest's landingPageType changes the PayPal page to display when a user lands on the PayPal site to complete the payment. /// `.login` specifies a PayPal account login page is used. /// `.billing` specifies a non-PayPal account landing page is used. public var landingPageType: BTPayPalRequestLandingPageType - + /// Optional: The merchant name displayed inside of the PayPal flow; defaults to the company name on your Braintree account public var displayName: String? - + /// Optional: A non-default merchant account to use for tokenization. public var merchantAccountID: String? - + /// Optional: The line items for this transaction. It can include up to 249 line items. public var lineItems: [BTPayPalLineItem]? - + /// Optional: Display a custom description to the user for a billing agreement. For Checkout with Vault flows, you must also set /// `requestBillingAgreement` to `true` on your `BTPayPalCheckoutRequest`. public var billingAgreementDescription: String? - + /// Optional: A risk correlation ID created with Set Transaction Context on your server. public var riskCorrelationID: String? - + /// :nodoc: Exposed publicly for use by PayPal Native Checkout module. This property is not covered by semantic versioning. @_documentation(visibility: private) public var hermesPath: String - + /// :nodoc: Exposed publicly for use by PayPal Native Checkout module. This property is not covered by semantic versioning. @_documentation(visibility: private) public var paymentType: BTPayPalPaymentType - + /// Optional: A user's phone number to initiate a quicker authentication flow in the scenario where the user has a PayPal account /// identified with the same phone number. public var userPhoneNumber: BTPayPalPhoneNumber? - + /// 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? - + /// Optional: The shopper session ID returned from your shopper insights server SDK integration. public var shopperSessionID: String? - + // MARK: - Internal Properties /// Optional: Used to determine if the customer will use the PayPal app switch flow. Defaults to `false`. @@ -116,9 +114,9 @@ import BraintreeCore // MARK: - Static Properties static let callbackURLHostAndPath: String = "onetouch/v1/" - + // MARK: - Initializer - + init( hermesPath: String, paymentType: BTPayPalPaymentType, @@ -154,9 +152,9 @@ import BraintreeCore self.enablePayPalAppSwitch = enablePayPalAppSwitch self.shopperSessionID = shopperSessionID } - + // 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 func parameters( @@ -165,51 +163,51 @@ import BraintreeCore isPayPalAppInstalled: Bool = false ) -> [String: Any] { var experienceProfile: [String: Any] = [:] - + experienceProfile["no_shipping"] = !isShippingAddressRequired experienceProfile["brand_name"] = displayName != nil ? displayName : configuration.json?["paypal"]["displayName"].asString() - + if landingPageType.stringValue != nil { experienceProfile["landing_page_type"] = landingPageType.stringValue } - + if localeCode.stringValue != nil { experienceProfile["locale_code"] = localeCode.stringValue } - + experienceProfile["address_override"] = shippingAddressOverride != nil ? !isShippingAddressEditable : false - + var parameters: [String: Any] = [:] - + if merchantAccountID != nil { parameters["merchant_account_id"] = merchantAccountID } - + if riskCorrelationID != nil { parameters["correlation_id"] = riskCorrelationID } - + if let lineItems, !lineItems.isEmpty { let lineItemsArray = lineItems.compactMap { $0.requestParameters() } parameters["line_items"] = lineItemsArray } - + if let userPhoneNumberDictionary = try? userPhoneNumber?.toDictionary() { parameters["phone_number"] = userPhoneNumberDictionary } - + if let userAuthenticationEmail, !userAuthenticationEmail.isEmpty { parameters["payer_email"] = userAuthenticationEmail } - + if let shopperSessionID { parameters["shopper_session_id"] = shopperSessionID } - + parameters["return_url"] = BTCoreConstants.callbackURLScheme + "://\(BTPayPalRequest.callbackURLHostAndPath)success" parameters["cancel_url"] = BTCoreConstants.callbackURLScheme + "://\(BTPayPalRequest.callbackURLHostAndPath)cancel" parameters["experience_profile"] = experienceProfile - + if let universalLink, enablePayPalAppSwitch, isPayPalAppInstalled { let appSwitchParameters: [String: Any] = [ "launch_paypal_app": enablePayPalAppSwitch, diff --git a/Sources/BraintreePayPal/BTPayPalVaultBaseRequest.swift b/Sources/BraintreePayPal/BTPayPalVaultBaseRequest.swift index a837fe259d..9050f667f2 100644 --- a/Sources/BraintreePayPal/BTPayPalVaultBaseRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalVaultBaseRequest.swift @@ -13,7 +13,7 @@ import BraintreeCore 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`. diff --git a/Sources/BraintreePayPal/BTPayPalVaultRequest.swift b/Sources/BraintreePayPal/BTPayPalVaultRequest.swift index 0e68412343..a796a2b617 100644 --- a/Sources/BraintreePayPal/BTPayPalVaultRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalVaultRequest.swift @@ -35,7 +35,7 @@ import BraintreeCore ) super.enablePayPalAppSwitch = enablePayPalAppSwitch } - + /// Initializes a PayPal Vault request /// - Parameters: /// - offerCredit: Optional: Offers PayPal Credit if the customer qualifies. Defaults to `false`. diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index 3dd180264b..149b45289c 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -785,29 +785,29 @@ class BTPayPalClient_Tests: XCTestCase { 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) } From 752050ad292686d99bbb43defe8bcea8d2b99ac3 Mon Sep 17 00:00:00 2001 From: ageddam Date: Wed, 19 Feb 2025 12:46:59 -0600 Subject: [PATCH 12/12] more cleanup --- Sources/BraintreePayPal/BTPayPalRequest.swift | 6 +++--- UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/BraintreePayPal/BTPayPalRequest.swift b/Sources/BraintreePayPal/BTPayPalRequest.swift index e57ff81763..c73a655656 100644 --- a/Sources/BraintreePayPal/BTPayPalRequest.swift +++ b/Sources/BraintreePayPal/BTPayPalRequest.swift @@ -7,7 +7,7 @@ import BraintreeCore @objc public enum BTPayPalPaymentType: Int { /// Checkout case checkout - + /// Vault case vault @@ -163,7 +163,7 @@ import BraintreeCore isPayPalAppInstalled: Bool = false ) -> [String: Any] { var experienceProfile: [String: Any] = [:] - + experienceProfile["no_shipping"] = !isShippingAddressRequired experienceProfile["brand_name"] = displayName != nil ? displayName : configuration.json?["paypal"]["displayName"].asString() @@ -203,7 +203,7 @@ import BraintreeCore if let shopperSessionID { parameters["shopper_session_id"] = shopperSessionID } - + parameters["return_url"] = BTCoreConstants.callbackURLScheme + "://\(BTPayPalRequest.callbackURLHostAndPath)success" parameters["cancel_url"] = BTCoreConstants.callbackURLScheme + "://\(BTPayPalRequest.callbackURLHostAndPath)cancel" parameters["experience_profile"] = experienceProfile diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index 149b45289c..8696cdd39d 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -781,7 +781,7 @@ class BTPayPalClient_Tests: XCTestCase { XCTFail("Expected integer value for query param `switch_initiated_time`") } } - + func testTokenizeVaultAccount_whenPayPalAppApprovalURLMissingBAToken_returnsError() { let fakeApplication = FakeApplication() payPalClient.application = fakeApplication @@ -800,7 +800,7 @@ class BTPayPalClient_Tests: XCTestCase { 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.")