Skip to content

Commit

Permalink
Merge App Switch Checkout Feature Branch (#1519)
Browse files Browse the repository at this point in the history
* 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

* add app-switch support for one-time-checkout

* Revert "add app-switch support for one-time-checkout"

This reverts commit 26cfe78.

* add app-switch demo app support for one-time checkout flow (#1471)

* update parsing app switch url for 1-time checkout flow (#1500)

* 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

* 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

* pull in main changes

* address pr feedback

* fix spacing

* code cleanup

* more cleanup
  • Loading branch information
agedd authored Feb 20, 2025
1 parent 05f1b03 commit 258ae49
Show file tree
Hide file tree
Showing 10 changed files with 243 additions and 72 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# 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.
* BraintreeApplePay
* Add `BTApplePayCardNonce.isDeviceToken` for MPAN identification

Expand Down
48 changes: 44 additions & 4 deletions Demo/Application/Features/PayPalWebCheckoutViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,20 +89,32 @@ 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,
contactInformationToggle,
payPalCheckoutButton
payPalCheckoutButton,
payPalAppSwitchForCheckoutButton
])
oneTimeCheckoutStackView.spacing = 12

let vaultStackView = buttonsStackView(label: "Vault", views: [
rbaDataToggle,
payPalVaultButton,
payPalAppSwitchButton
payPalAppSwitchForVaultButton
])
vaultStackView.spacing = 12

Expand Down Expand Up @@ -231,8 +243,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

Expand Down
3 changes: 2 additions & 1 deletion Sources/BraintreePayPal/BTPayPalApprovalURLParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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["paymentResource"]["launchPayPalApp"].asBool() ?? false
redirectType = launchPayPalApp ? .payPalApp(url: approvalURL) : .webBrowser(url: approvalURL)
url = approvalURL
} else {
return nil
Expand Down
61 changes: 47 additions & 14 deletions Sources/BraintreePayPal/BTPayPalCheckoutRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -75,23 +74,55 @@ 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: 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: 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

// MARK: - Initializer
/// 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:
Expand All @@ -106,14 +137,16 @@ import BraintreeCore
/// 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,
intent: BTPayPalRequestIntent = .authorize,
userAction: BTPayPalRequestUserAction = .none,
offerPayLater: Bool = false,
currencyCode: String? = nil,
requestBillingAgreement: Bool = false,
shippingCallbackURL: URL? = nil
shippingCallbackURL: URL? = nil,
userAuthenticationEmail: String? = nil
) {
self.amount = amount
self.intent = intent
Expand All @@ -122,8 +155,12 @@ import BraintreeCore
self.currencyCode = currencyCode
self.requestBillingAgreement = requestBillingAgreement
self.shippingCallbackURL = shippingCallbackURL

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
Expand All @@ -135,7 +172,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,
Expand All @@ -147,10 +184,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
Expand Down
18 changes: 10 additions & 8 deletions Sources/BraintreePayPal/BTPayPalClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ import BraintreeDataCollector
request: BTPayPalRequest,
completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void
) {
linkType = (request as? BTPayPalVaultRequest)?.enablePayPalAppSwitch == true ? .universal : .deeplink
linkType = request.enablePayPalAppSwitch == true ? .universal : .deeplink
self.payPalRequest = request

apiClient.sendAnalyticsEvent(
Expand Down Expand Up @@ -396,12 +396,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)
}
Expand All @@ -411,7 +413,6 @@ import BraintreeDataCollector

private func launchPayPalApp(
with payPalAppRedirectURL: URL,
baToken: String,
completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void
) {
apiClient.sendAnalyticsEvent(
Expand All @@ -423,12 +424,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
Expand Down
7 changes: 7 additions & 0 deletions Sources/BraintreePayPal/BTPayPalError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ public enum BTPayPalError: Error, CustomNSError, LocalizedError, Equatable {

/// 13. Missing PayPal Request
case missingPayPalRequest

/// 14. Missing EC Token for App Switch
case missingECToken

public static var errorDomain: String {
"com.braintreepayments.BTPayPalErrorDomain"
Expand Down Expand Up @@ -79,6 +82,8 @@ public enum BTPayPalError: Error, CustomNSError, LocalizedError, Equatable {
return 12
case .missingPayPalRequest:
return 13
case .missingECToken:
return 14
}
}

Expand Down Expand Up @@ -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."
}
}

Expand Down
44 changes: 35 additions & 9 deletions Sources/BraintreePayPal/BTPayPalRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import BraintreeCore
#endif

@objc public enum BTPayPalPaymentType: Int {

/// Checkout
case checkout

Expand All @@ -24,7 +23,6 @@ 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

Expand Down Expand Up @@ -96,14 +94,23 @@ import BraintreeCore
/// :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`.
/// - 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/"
Expand All @@ -124,6 +131,8 @@ import BraintreeCore
billingAgreementDescription: String? = nil,
riskCorrelationId: String? = nil,
userPhoneNumber: BTPayPalPhoneNumber? = nil,
userAuthenticationEmail: String? = nil,
enablePayPalAppSwitch: Bool = false,
shopperSessionID: String? = nil
) {
self.hermesPath = hermesPath
Expand All @@ -139,6 +148,8 @@ import BraintreeCore
self.billingAgreementDescription = billingAgreementDescription
self.riskCorrelationID = riskCorrelationId
self.userPhoneNumber = userPhoneNumber
self.userAuthenticationEmail = userAuthenticationEmail
self.enablePayPalAppSwitch = enablePayPalAppSwitch
self.shopperSessionID = shopperSessionID
}

Expand Down Expand Up @@ -180,19 +191,34 @@ import BraintreeCore
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
}

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,
"os_version": UIDevice.current.systemVersion,
"os_type": UIDevice.current.systemName,
"merchant_app_return_url": universalLink.absoluteString
]

return parameters.merging(appSwitchParameters) { $1 }
}

return parameters
}
}
Loading

0 comments on commit 258ae49

Please sign in to comment.