Skip to content

Commit 39a45ae

Browse files
committed
Changes for toggling app switch for checkout
1 parent 7b52291 commit 39a45ae

File tree

2 files changed

+166
-59
lines changed

2 files changed

+166
-59
lines changed

Sources/PayPalWebPayments/PayPalWebCheckoutClient.swift

Lines changed: 163 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import AuthenticationServices
44
import CorePayments
55
#endif
66

7+
// swiftlint:disable type_body_length file_length
78
public class PayPalWebCheckoutClient: NSObject {
89

910
let config: CoreConfig
@@ -12,6 +13,8 @@ public class PayPalWebCheckoutClient: NSObject {
1213

1314
private let clientConfigAPI: UpdateClientConfigAPI
1415
private let webAuthenticationSession: WebAuthenticationSession
16+
private let patchCCOAPI: PatchCCOWithAppSwitchEligibility
17+
private let authServiceAPI: AuthenticationSecureTokenServiceAPI
1518
private let networkingClient: NetworkingClient
1619
private var analyticsService: AnalyticsService?
1720

@@ -23,19 +26,25 @@ public class PayPalWebCheckoutClient: NSObject {
2326
self.webAuthenticationSession = WebAuthenticationSession()
2427
self.networkingClient = NetworkingClient(coreConfig: config)
2528
self.clientConfigAPI = UpdateClientConfigAPI(coreConfig: config)
29+
self.authServiceAPI = AuthenticationSecureTokenServiceAPI(coreConfig: config)
30+
self.patchCCOAPI = PatchCCOWithAppSwitchEligibility(coreConfig: config)
2631
}
2732

2833
/// For internal use for testing/mocking purpose
2934
init(
3035
config: CoreConfig,
3136
networkingClient: NetworkingClient,
3237
clientConfigAPI: UpdateClientConfigAPI,
33-
webAuthenticationSession: WebAuthenticationSession
38+
authServiceAPI: AuthenticationSecureTokenServiceAPI,
39+
webAuthenticationSession: WebAuthenticationSession,
40+
patchCCOAPI: PatchCCOWithAppSwitchEligibility
3441
) {
3542
self.config = config
3643
self.webAuthenticationSession = webAuthenticationSession
3744
self.networkingClient = networkingClient
3845
self.clientConfigAPI = clientConfigAPI
46+
self.authServiceAPI = authServiceAPI
47+
self.patchCCOAPI = patchCCOAPI
3948
}
4049

4150
/// Launch the PayPal web flow
@@ -51,65 +60,24 @@ public class PayPalWebCheckoutClient: NSObject {
5160
analyticsService = AnalyticsService(coreConfig: config, orderID: request.orderID)
5261
analyticsService?.sendEvent("paypal-web-payments:checkout:started")
5362

54-
Task {
55-
do {
56-
_ = try await clientConfigAPI.updateClientConfig(
57-
token: request.orderID,
58-
fundingSource: request.fundingSource.rawValue
59-
)
60-
} catch {
61-
print("error in calling graphQL: \(error.localizedDescription)")
62-
}
63-
64-
let baseURLString = config.environment.payPalBaseURL.absoluteString
65-
let payPalCheckoutURLString =
66-
"\(baseURLString)/checkoutnow?token=\(request.orderID)" +
67-
"&fundingSource=\(request.fundingSource.rawValue)"
68-
69-
guard let payPalCheckoutURL = URL(string: payPalCheckoutURLString),
70-
let payPalCheckoutURLComponents = payPalCheckoutReturnURL(payPalCheckoutURL: payPalCheckoutURL)
71-
else {
72-
self.notifyCheckoutFailure(with: PayPalError.payPalURLError, completion: completion)
73-
return
74-
}
75-
76-
webAuthenticationSession.start(
77-
url: payPalCheckoutURLComponents,
78-
context: self,
79-
sessionDidDisplay: { [weak self] didDisplay in
80-
if didDisplay {
81-
self?.analyticsService?.sendEvent("paypal-web-payments:checkout:auth-challenge-presentation:succeeded")
82-
} else {
83-
self?.analyticsService?.sendEvent("paypal-web-payments:checkout:auth-challenge-presentation:failed")
84-
}
85-
},
86-
sessionDidComplete: { url, error in
87-
if let error = error {
88-
switch error {
89-
case ASWebAuthenticationSessionError.canceledLogin:
90-
self.notifyCheckoutCancelWithError(
91-
with: PayPalError.checkoutCanceledError,
92-
completion: completion
93-
)
94-
return
95-
default:
96-
self.notifyCheckoutFailure(with: PayPalError.webSessionError(error), completion: completion)
97-
return
98-
}
99-
}
63+
// Wrap the given completion so it can only be called once across all paths
64+
let completionOnce = makeCompletionOnce(completion)
10065

101-
if let url = url {
102-
guard let orderID = self.getQueryStringParameter(url: url.absoluteString, param: "token"),
103-
let payerID = self.getQueryStringParameter(url: url.absoluteString, param: "PayerID") else {
104-
self.notifyCheckoutFailure(with: PayPalError.malformedResultError, completion: completion)
105-
return
106-
}
107-
108-
let result = PayPalWebCheckoutResult(orderID: orderID, payerID: payerID)
109-
self.notifyCheckoutSuccess(for: result, completion: completion)
110-
}
66+
Task {
67+
// TODO: check device for paypal app
68+
if request.appSwitchIfEligible {
69+
switch await attemptAppSwitchIfEligible(request: request, completionOnce: completionOnce) {
70+
case .launched:
71+
// Do nothing here. Completion will be called when handleReturnURL is invoked.
72+
return
73+
74+
case .fallback(let reason):
75+
// TODO: analytics
76+
startWebCheckoutFlow(request: request, completion: completionOnce)
11177
}
112-
)
78+
} else {
79+
startWebCheckoutFlow(request: request, completion: completionOnce)
80+
}
11381
}
11482
}
11583

@@ -274,6 +242,143 @@ public class PayPalWebCheckoutClient: NSObject {
274242
notifyCheckoutFailure(with: PayPalError.malformedResultError, completion: completion)
275243
}
276244

245+
// MARK: Private functions
246+
247+
private func startWebCheckoutFlow(
248+
request: PayPalWebCheckoutRequest,
249+
completion: @escaping (Result<PayPalWebCheckoutResult, CoreSDKError>) -> Void
250+
) {
251+
Task {
252+
do {
253+
_ = try await self.clientConfigAPI.updateClientConfig(
254+
token: request.orderID,
255+
fundingSource: request.fundingSource.rawValue
256+
)
257+
} catch {
258+
print("updateClientConfig error: \(error.localizedDescription)")
259+
}
260+
261+
let baseURLString = self.config.environment.payPalBaseURL.absoluteString
262+
let payPalCheckoutURLString =
263+
"\(baseURLString)/checkoutnow?token=\(request.orderID)" +
264+
"&fundingSource=\(request.fundingSource.rawValue)"
265+
266+
guard
267+
let payPalCheckoutURL = URL(string: payPalCheckoutURLString),
268+
let authURL = self.payPalCheckoutReturnURL(payPalCheckoutURL: payPalCheckoutURL)
269+
else {
270+
self.notifyCheckoutFailure(with: PayPalError.payPalURLError, completion: completion)
271+
return
272+
}
273+
274+
await MainActor.run {
275+
self.webAuthenticationSession.start(
276+
url: authURL,
277+
context: self,
278+
sessionDidDisplay: { [weak self] didDisplay in
279+
if didDisplay {
280+
self?.analyticsService?.sendEvent("paypal-web-payments:checkout:auth-challenge-presentation:succeeded")
281+
} else {
282+
self?.analyticsService?.sendEvent("paypal-web-payments:checkout:auth-challenge-presentation:failed")
283+
}
284+
},
285+
sessionDidComplete: { [weak self] url, error in
286+
guard let self = self else { return }
287+
288+
if let error = error {
289+
switch error {
290+
case ASWebAuthenticationSessionError.canceledLogin:
291+
self.notifyCheckoutCancelWithError(
292+
with: PayPalError.checkoutCanceledError,
293+
completion: completion
294+
)
295+
default:
296+
self.notifyCheckoutFailure(with: PayPalError.webSessionError(error), completion: completion)
297+
}
298+
return
299+
}
300+
301+
guard
302+
let url = url,
303+
let orderID = self.getQueryStringParameter(url: url.absoluteString, param: "token"),
304+
let payerID = self.getQueryStringParameter(url: url.absoluteString, param: "PayerID")
305+
else {
306+
self.notifyCheckoutFailure(with: PayPalError.malformedResultError, completion: completion)
307+
return
308+
}
309+
310+
let result = PayPalWebCheckoutResult(orderID: orderID, payerID: payerID)
311+
self.notifyCheckoutSuccess(for: result, completion: completion)
312+
}
313+
)
314+
}
315+
}
316+
}
317+
318+
private enum AppSwitchAttempt { case launched, fallback(String) }
319+
320+
private func attemptAppSwitchIfEligible(
321+
request: PayPalWebCheckoutRequest,
322+
completionOnce: @escaping (Result<PayPalWebCheckoutResult, CoreSDKError>) -> Void
323+
) async -> AppSwitchAttempt {
324+
do {
325+
let eligibility = try await patchCCOAPI.patchCCOWithAppSwitchEligibility(
326+
token: request.orderID,
327+
tokenType: "ORDER_ID"
328+
)
329+
330+
guard eligibility.appSwitchEligible == true,
331+
let urlString = eligibility.redirectURL,
332+
let url = URL(string: urlString)
333+
else {
334+
return .fallback(eligibility.ineligibleReason ?? "ineligible")
335+
}
336+
337+
await MainActor.run { [weak self] in
338+
self?.appSwitchCompletion = completionOnce
339+
}
340+
341+
// Try to open the PayPal app. If opening fails, fall back.
342+
let opened: Bool = await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
343+
DispatchQueue.main.async {
344+
UIApplication.shared.open(url, options: [:]) { success in
345+
cont.resume(returning: success)
346+
}
347+
}
348+
}
349+
350+
if opened {
351+
// TODO: analytics
352+
return .launched
353+
} else {
354+
// TODO: analytics
355+
// Attempted to launch but couldn't. Clear the saved completion so a stray return URL can't complete.
356+
await MainActor.run { [weak self] in
357+
self?.appSwitchCompletion = nil
358+
}
359+
return .fallback("cannot_open_url")
360+
}
361+
} catch {
362+
// TODO: analytics
363+
return .fallback("patch_or_lsat_failed")
364+
}
365+
}
366+
367+
// MARK: - Single-shot completion wrapper
368+
369+
private func makeCompletionOnce(
370+
_ completion: @escaping (Result<PayPalWebCheckoutResult, CoreSDKError>) -> Void
371+
) -> (Result<PayPalWebCheckoutResult, CoreSDKError>) -> Void {
372+
let lock = NSLock()
373+
var called = false
374+
return { result in
375+
lock.lock(); defer { lock.unlock() }
376+
guard !called else { return }
377+
called = true
378+
completion(result)
379+
}
380+
}
381+
277382
private func getQueryStringParameter(url: String, param: String) -> String? {
278383
guard let url = URLComponents(string: url) else { return nil }
279384
return url.queryItems?.first { $0.name == param }?.value

Sources/PayPalWebPayments/PayPalWebCheckoutRequest.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ public struct PayPalWebCheckoutRequest {
77
public let orderID: String
88
/// The funding for the order: credit, paylater or default
99
public let fundingSource: PayPalWebCheckoutFundingSource
10+
public let appSwitchIfEligible: Bool
1011

1112
/// Creates an instance of a PayPalRequest.
1213
/// - Parameter orderID: The ID of the order to be approved.
1314
/// - Parameter fundingSource: The funding source for and order. Default value is .paypal
14-
public init(orderID: String, fundingSource: PayPalWebCheckoutFundingSource = .paypal) {
15+
public init(orderID: String, fundingSource: PayPalWebCheckoutFundingSource = .paypal, appSwitchIfEligible: Bool = false) {
1516
self.orderID = orderID
1617
self.fundingSource = fundingSource
18+
self.appSwitchIfEligible = appSwitchIfEligible
1719
}
1820
}

0 commit comments

Comments
 (0)