Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Merge App Switch Checkout Feature Branch #1519

Merged
merged 14 commits into from
Feb 20, 2025
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