@@ -4,6 +4,7 @@ import AuthenticationServices
4
4
import CorePayments
5
5
#endif
6
6
7
+ // swiftlint:disable type_body_length file_length
7
8
public class PayPalWebCheckoutClient : NSObject {
8
9
9
10
let config : CoreConfig
@@ -12,6 +13,8 @@ public class PayPalWebCheckoutClient: NSObject {
12
13
13
14
private let clientConfigAPI : UpdateClientConfigAPI
14
15
private let webAuthenticationSession : WebAuthenticationSession
16
+ private let patchCCOAPI : PatchCCOWithAppSwitchEligibility
17
+ private let authServiceAPI : AuthenticationSecureTokenServiceAPI
15
18
private let networkingClient : NetworkingClient
16
19
private var analyticsService : AnalyticsService ?
17
20
@@ -23,19 +26,25 @@ public class PayPalWebCheckoutClient: NSObject {
23
26
self . webAuthenticationSession = WebAuthenticationSession ( )
24
27
self . networkingClient = NetworkingClient ( coreConfig: config)
25
28
self . clientConfigAPI = UpdateClientConfigAPI ( coreConfig: config)
29
+ self . authServiceAPI = AuthenticationSecureTokenServiceAPI ( coreConfig: config)
30
+ self . patchCCOAPI = PatchCCOWithAppSwitchEligibility ( coreConfig: config)
26
31
}
27
32
28
33
/// For internal use for testing/mocking purpose
29
34
init (
30
35
config: CoreConfig ,
31
36
networkingClient: NetworkingClient ,
32
37
clientConfigAPI: UpdateClientConfigAPI ,
33
- webAuthenticationSession: WebAuthenticationSession
38
+ authServiceAPI: AuthenticationSecureTokenServiceAPI ,
39
+ webAuthenticationSession: WebAuthenticationSession ,
40
+ patchCCOAPI: PatchCCOWithAppSwitchEligibility
34
41
) {
35
42
self . config = config
36
43
self . webAuthenticationSession = webAuthenticationSession
37
44
self . networkingClient = networkingClient
38
45
self . clientConfigAPI = clientConfigAPI
46
+ self . authServiceAPI = authServiceAPI
47
+ self . patchCCOAPI = patchCCOAPI
39
48
}
40
49
41
50
/// Launch the PayPal web flow
@@ -51,65 +60,24 @@ public class PayPalWebCheckoutClient: NSObject {
51
60
analyticsService = AnalyticsService ( coreConfig: config, orderID: request. orderID)
52
61
analyticsService? . sendEvent ( " paypal-web-payments:checkout:started " )
53
62
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)
100
65
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)
111
77
}
112
- )
78
+ } else {
79
+ startWebCheckoutFlow ( request: request, completion: completionOnce)
80
+ }
113
81
}
114
82
}
115
83
@@ -274,6 +242,143 @@ public class PayPalWebCheckoutClient: NSObject {
274
242
notifyCheckoutFailure ( with: PayPalError . malformedResultError, completion: completion)
275
243
}
276
244
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
+
277
382
private func getQueryStringParameter( url: String , param: String ) -> String ? {
278
383
guard let url = URLComponents ( string: url) else { return nil }
279
384
return url. queryItems? . first { $0. name == param } ? . value
0 commit comments