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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
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
63 changes: 48 additions & 15 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
11 changes: 9 additions & 2 deletions Sources/BraintreePayPal/BTPayPalError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
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
Loading