BTPayPalDriver.m 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679
  1. #import "BTPayPalDriver_Internal.h"
  2. #import "BTPayPalAccountNonce_Internal.h"
  3. #import "BTPayPalCreditFinancing_Internal.h"
  4. #import "BTPayPalCreditFinancingAmount_Internal.h"
  5. #import "BTPayPalRequest_Internal.h"
  6. #import "BTPayPalCheckoutRequest_Internal.h"
  7. #if __has_include(<Braintree/BraintreePayPal.h>) // CocoaPods
  8. #import <Braintree/BraintreeCore.h>
  9. #import <Braintree/BTAPIClient_Internal.h>
  10. #import <Braintree/BTPaymentMethodNonceParser.h>
  11. #import <Braintree/BTLogger_Internal.h>
  12. #import <Braintree/BTConfiguration+PayPal.h>
  13. #import <Braintree/BTPayPalLineItem.h>
  14. #elif SWIFT_PACKAGE // SPM
  15. #import <BraintreeCore/BraintreeCore.h>
  16. #import "../BraintreeCore/BTAPIClient_Internal.h"
  17. #import "../BraintreeCore/BTPaymentMethodNonceParser.h"
  18. #import "../BraintreeCore/BTLogger_Internal.h"
  19. #import <BraintreePayPal/BTConfiguration+PayPal.h>
  20. #import <BraintreePayPal/BTPayPalLineItem.h>
  21. #else // Carthage
  22. #import <BraintreeCore/BraintreeCore.h>
  23. #import <BraintreeCore/BTAPIClient_Internal.h>
  24. #import <BraintreeCore/BTPaymentMethodNonceParser.h>
  25. #import <BraintreeCore/BTLogger_Internal.h>
  26. #import <BraintreePayPal/BTConfiguration+PayPal.h>
  27. #import <BraintreePayPal/BTPayPalLineItem.h>
  28. #endif
  29. #if __has_include(<Braintree/Braintree-Swift.h>) // CocoaPods
  30. #import <Braintree/Braintree-Swift.h>
  31. #elif SWIFT_PACKAGE // SPM
  32. /* Use @import for SPM support
  33. * See https://forums.swift.org/t/using-a-swift-package-in-a-mixed-swift-and-objective-c-project/27348
  34. */
  35. @import PayPalDataCollector;
  36. #elif __has_include("Braintree-Swift.h") // CocoaPods for ReactNative
  37. /* Use quoted style when importing Swift headers for ReactNative support
  38. * See https://github.com/braintree/braintree_ios/issues/671
  39. */
  40. #import "Braintree-Swift.h"
  41. #else // Carthage
  42. #import <PayPalDataCollector/PayPalDataCollector-Swift.h>
  43. #endif
  44. NSString *const BTPayPalDriverErrorDomain = @"com.braintreepayments.BTPayPalDriverErrorDomain";
  45. /**
  46. This environment MUST be used for App Store submissions.
  47. */
  48. NSString * _Nonnull const PayPalEnvironmentProduction = @"live";
  49. /**
  50. Sandbox: Uses the PayPal sandbox for transactions. Useful for development.
  51. */
  52. NSString * _Nonnull const PayPalEnvironmentSandbox = @"sandbox";
  53. /**
  54. Mock: Mock mode. Does not submit transactions to PayPal. Fakes successful responses. Useful for unit tests.
  55. */
  56. NSString * _Nonnull const PayPalEnvironmentMock = @"mock";
  57. @interface BTPayPalDriver () <ASWebAuthenticationPresentationContextProviding>
  58. @property (nonatomic, assign) BOOL returnedToAppAfterPermissionAlert;
  59. @end
  60. @implementation BTPayPalDriver
  61. + (void)load {
  62. if (self == [BTPayPalDriver class]) {
  63. [[BTPaymentMethodNonceParser sharedParser] registerType:@"PayPalAccount" withParsingBlock:^BTPaymentMethodNonce * _Nullable(BTJSON * _Nonnull payPalAccount) {
  64. return [self payPalAccountFromJSON:payPalAccount];
  65. }];
  66. }
  67. }
  68. - (instancetype)initWithAPIClient:(BTAPIClient *)apiClient {
  69. if (self = [super init]) {
  70. _apiClient = apiClient;
  71. [NSNotificationCenter.defaultCenter addObserver:self
  72. selector:@selector(applicationDidBecomeActive:)
  73. name:UIApplicationDidBecomeActiveNotification
  74. object:nil];
  75. }
  76. return self;
  77. }
  78. - (instancetype)init {
  79. return nil;
  80. }
  81. - (void)applicationDidBecomeActive:(__unused NSNotification *)notification {
  82. if (self.isAuthenticationSessionStarted) {
  83. self.returnedToAppAfterPermissionAlert = YES;
  84. }
  85. }
  86. - (void)dealloc {
  87. [NSNotificationCenter.defaultCenter removeObserver:self];
  88. }
  89. #pragma mark - Billing Agreement (Vault)
  90. - (void)requestBillingAgreement:(BTPayPalVaultRequest *)request
  91. completion:(void (^)(BTPayPalAccountNonce *tokenizedCheckout, NSError *error))completionBlock {
  92. [self tokenizePayPalAccountWithPayPalRequest:request completion:completionBlock];
  93. }
  94. #pragma mark - One-Time Payment (Checkout)
  95. - (void)requestOneTimePayment:(BTPayPalCheckoutRequest *)request
  96. completion:(void (^)(BTPayPalAccountNonce *tokenizedCheckout, NSError *error))completionBlock {
  97. [self tokenizePayPalAccountWithPayPalRequest:request completion:completionBlock];
  98. }
  99. #pragma mark - Helpers
  100. - (void)tokenizePayPalAccountWithPayPalRequest:(BTPayPalRequest *)request completion:(void (^)(BTPayPalAccountNonce *, NSError *))completionBlock {
  101. if (!self.apiClient) {
  102. NSError *error = [NSError errorWithDomain:BTPayPalDriverErrorDomain
  103. code:BTPayPalDriverErrorTypeIntegration
  104. userInfo:@{NSLocalizedDescriptionKey: @"BTPayPalDriver failed because BTAPIClient is nil."}];
  105. completionBlock(nil, error);
  106. return;
  107. }
  108. if (!request) {
  109. completionBlock(nil, [NSError errorWithDomain:BTPayPalDriverErrorDomain code:BTPayPalDriverErrorTypeInvalidRequest userInfo:nil]);
  110. return;
  111. }
  112. if (!([request isKindOfClass:BTPayPalCheckoutRequest.class] || [request isKindOfClass:BTPayPalVaultRequest.class])) {
  113. NSError *error = [NSError errorWithDomain:BTPayPalDriverErrorDomain
  114. code:BTPayPalDriverErrorTypeIntegration
  115. userInfo:@{NSLocalizedDescriptionKey: @"BTPayPalDriver failed because request is not of type BTPayPalCheckoutRequest or BTPayPalVaultRequest."}];
  116. completionBlock(nil, error);
  117. return;
  118. }
  119. [self.apiClient fetchOrReturnRemoteConfiguration:^(BTConfiguration *configuration, NSError *error) {
  120. if (error) {
  121. if (completionBlock) {
  122. completionBlock(nil, error);
  123. }
  124. return;
  125. }
  126. if (![self verifyAppSwitchWithRemoteConfiguration:configuration.json error:&error]) {
  127. if (completionBlock) {
  128. completionBlock(nil, error);
  129. }
  130. return;
  131. }
  132. self.payPalRequest = request;
  133. [self.apiClient POST:request.hermesPath
  134. parameters:[request parametersWithConfiguration:configuration]
  135. completion:^(BTJSON *body, __unused NSHTTPURLResponse *response, NSError *error) {
  136. if (error) {
  137. if (error.code == NETWORK_CONNECTION_LOST_CODE) {
  138. [self.apiClient sendAnalyticsEvent:@"ios.paypal.tokenize.network-connection.failure"];
  139. }
  140. NSString *errorDetailsIssue = ((BTJSON *)error.userInfo[BTHTTPJSONResponseBodyKey][@"paymentResource"][@"errorDetails"][0][@"issue"]).asString;
  141. if (error.userInfo[NSLocalizedDescriptionKey] == nil && errorDetailsIssue != nil) {
  142. NSMutableDictionary *dictionary = [error.userInfo mutableCopy];
  143. dictionary[NSLocalizedDescriptionKey] = errorDetailsIssue;
  144. error = [NSError errorWithDomain:error.domain code:error.code userInfo:dictionary];
  145. }
  146. if (completionBlock) {
  147. completionBlock(nil, error);
  148. }
  149. return;
  150. }
  151. NSURL *approvalUrl = [body[@"paymentResource"][@"redirectUrl"] asURL];
  152. if (approvalUrl == nil) {
  153. approvalUrl = [body[@"agreementSetup"][@"approvalUrl"] asURL];
  154. }
  155. approvalUrl = [self decorateApprovalURL:approvalUrl forRequest:request];
  156. NSString *pairingID = [self.class tokenFromApprovalURL:approvalUrl];
  157. self.clientMetadataID = self.payPalRequest.riskCorrelationId ? self.payPalRequest.riskCorrelationId : [PPDataCollector clientMetadataID:pairingID isSandbox:[configuration.environment isEqualToString:@"sandbox"]];
  158. BOOL analyticsSuccess = error ? NO : YES;
  159. [self sendAnalyticsEventForInitiatingOneTouchForPaymentType:request.paymentType withSuccess:analyticsSuccess];
  160. [self handlePayPalRequestWithURL:approvalUrl
  161. error:error
  162. paymentType:request.paymentType
  163. completion:completionBlock];
  164. }];
  165. }];
  166. }
  167. - (NSDictionary *)dictionaryFromResponseURL:(NSURL *)url {
  168. if ([[self.class actionFromURLAction: url] isEqualToString:@"cancel"]) {
  169. return nil;
  170. }
  171. NSDictionary *resultDictionary = @{
  172. @"client": @{
  173. @"platform": @"iOS",
  174. @"product_name": @"PayPal",
  175. @"paypal_sdk_version": @"version"
  176. },
  177. @"response": @{
  178. @"webURL": url.absoluteString
  179. },
  180. @"response_type": @"web"
  181. };
  182. return resultDictionary;
  183. }
  184. - (void)handlePayPalRequestWithURL:(NSURL *)url
  185. error:(NSError *)error
  186. paymentType:(BTPayPalPaymentType)paymentType
  187. completion:(void (^)(BTPayPalAccountNonce *, NSError *))completionBlock {
  188. if (!error) {
  189. // Defensive programming in case PayPal One Touch returns a non-HTTP URL so that ASWebAuthenticationSession doesn't crash
  190. if (![url.scheme.lowercaseString hasPrefix:@"http"]) {
  191. NSError *urlError = [NSError errorWithDomain:BTPayPalDriverErrorDomain
  192. code:BTPayPalDriverErrorTypeUnknown
  193. userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Attempted to open an invalid URL in ASWebAuthenticationSession: %@://", url.scheme],
  194. NSLocalizedRecoverySuggestionErrorKey: @"Try again or contact Braintree Support." }];
  195. NSString *eventName = [NSString stringWithFormat:@"ios.%@.webswitch.error.safariviewcontrollerbadscheme.%@", [self.class eventStringForPaymentType:paymentType], url.scheme];
  196. [self.apiClient sendAnalyticsEvent:eventName];
  197. if (completionBlock) {
  198. completionBlock(nil, urlError);
  199. }
  200. return;
  201. }
  202. [self performSwitchRequest:url paymentType:paymentType completion:completionBlock];
  203. } else if (completionBlock) {
  204. completionBlock(nil, error);
  205. }
  206. }
  207. - (void)performSwitchRequest:(NSURL *)appSwitchURL paymentType:(BTPayPalPaymentType)paymentType completion:(void (^)(BTPayPalAccountNonce *, NSError *))completionBlock {
  208. self.approvalUrl = appSwitchURL; // exposed for testing
  209. self.authenticationSession = [[ASWebAuthenticationSession alloc] initWithURL:appSwitchURL
  210. callbackURLScheme:BTPayPalCallbackURLScheme
  211. completionHandler:^(NSURL * _Nullable callbackURL, NSError * _Nullable error) {
  212. // Required to avoid memory leak for BTPayPalDriver
  213. self.authenticationSession = nil;
  214. if (error) {
  215. if (error.domain == ASWebAuthenticationSessionErrorDomain && error.code == ASWebAuthenticationSessionErrorCodeCanceledLogin) {
  216. if (self.returnedToAppAfterPermissionAlert) {
  217. // User tapped system cancel button in browser
  218. NSString *eventName = [NSString stringWithFormat:@"ios.%@.authsession.browser.cancel", [self.class eventStringForPaymentType:paymentType]];
  219. [self.apiClient sendAnalyticsEvent:eventName];
  220. } else {
  221. // User tapped system cancel button on permission alert
  222. NSString *eventName = [NSString stringWithFormat:@"ios.%@.authsession.alert.cancel", [self.class eventStringForPaymentType:paymentType]];
  223. [self.apiClient sendAnalyticsEvent:eventName];
  224. }
  225. }
  226. // User canceled by breaking out of the PayPal browser switch flow
  227. // (e.g. System "Cancel" button on permission alert or browser during ASWebAuthenticationSession)
  228. NSError *err = [NSError errorWithDomain:BTPayPalDriverErrorDomain
  229. code:BTPayPalDriverErrorTypeCanceled
  230. userInfo:@{NSLocalizedDescriptionKey: @"PayPal flow was canceled by the user."}];
  231. if (completionBlock) {
  232. completionBlock(nil, err);
  233. }
  234. return;
  235. }
  236. [self handleBrowserSwitchReturnURL:callbackURL
  237. paymentType:paymentType
  238. completion:completionBlock];
  239. }];
  240. if (@available(iOS 13, *)) {
  241. self.authenticationSession.presentationContextProvider = self;
  242. }
  243. self.returnedToAppAfterPermissionAlert = NO;
  244. self.isAuthenticationSessionStarted = [self.authenticationSession start];
  245. if (self.isAuthenticationSessionStarted) {
  246. NSString *eventName = [NSString stringWithFormat:@"ios.%@.authsession.start.succeeded", [self.class eventStringForPaymentType:paymentType]];
  247. [self.apiClient sendAnalyticsEvent:eventName];
  248. } else {
  249. NSString *eventName = [NSString stringWithFormat:@"ios.%@.authsession.start.failed", [self.class eventStringForPaymentType:paymentType]];
  250. [self.apiClient sendAnalyticsEvent:eventName];
  251. }
  252. }
  253. - (BTClientMetadata *)clientMetadata {
  254. BTMutableClientMetadata *metadata = [self.apiClient.metadata mutableCopy];
  255. metadata.source = BTClientMetadataSourcePayPalBrowser;
  256. return [metadata copy];
  257. }
  258. + (BTPostalAddress *)accountAddressFromJSON:(BTJSON *)addressJSON {
  259. if (!addressJSON.isObject) {
  260. return nil;
  261. }
  262. BTPostalAddress *address = [[BTPostalAddress alloc] init];
  263. address.recipientName = [addressJSON[@"recipientName"] asString]; // Likely to be nil
  264. address.streetAddress = [addressJSON[@"street1"] asString];
  265. address.extendedAddress = [addressJSON[@"street2"] asString];
  266. address.locality = [addressJSON[@"city"] asString];
  267. address.region = [addressJSON[@"state"] asString];
  268. address.postalCode = [addressJSON[@"postalCode"] asString];
  269. address.countryCodeAlpha2 = [addressJSON[@"country"] asString];
  270. return address;
  271. }
  272. + (BTPostalAddress *)shippingOrBillingAddressFromJSON:(BTJSON *)addressJSON {
  273. if (!addressJSON.isObject) {
  274. return nil;
  275. }
  276. BTPostalAddress *address = [[BTPostalAddress alloc] init];
  277. address.recipientName = [addressJSON[@"recipientName"] asString]; // Likely to be nil
  278. address.streetAddress = [addressJSON[@"line1"] asString];
  279. address.extendedAddress = [addressJSON[@"line2"] asString];
  280. address.locality = [addressJSON[@"city"] asString];
  281. address.region = [addressJSON[@"state"] asString];
  282. address.postalCode = [addressJSON[@"postalCode"] asString];
  283. address.countryCodeAlpha2 = [addressJSON[@"countryCode"] asString];
  284. return address;
  285. }
  286. + (BTPayPalCreditFinancingAmount *)creditFinancingAmountFromJSON:(BTJSON *)amountJSON {
  287. if (!amountJSON.isObject) {
  288. return nil;
  289. }
  290. NSString *currency = [amountJSON[@"currency"] asString];
  291. NSString *value = [amountJSON[@"value"] asString];
  292. return [[BTPayPalCreditFinancingAmount alloc] initWithCurrency:currency value:value];
  293. }
  294. + (BTPayPalCreditFinancing *)creditFinancingFromJSON:(BTJSON *)creditFinancingOfferedJSON {
  295. if (!creditFinancingOfferedJSON.isObject) {
  296. return nil;
  297. }
  298. BOOL isCardAmountImmutable = [creditFinancingOfferedJSON[@"cardAmountImmutable"] isTrue];
  299. BTPayPalCreditFinancingAmount *monthlyPayment = [self.class creditFinancingAmountFromJSON:creditFinancingOfferedJSON[@"monthlyPayment"]];
  300. BOOL payerAcceptance = [creditFinancingOfferedJSON[@"payerAcceptance"] isTrue];
  301. NSInteger term = [creditFinancingOfferedJSON[@"term"] asIntegerOrZero];
  302. BTPayPalCreditFinancingAmount *totalCost = [self.class creditFinancingAmountFromJSON:creditFinancingOfferedJSON[@"totalCost"]];
  303. BTPayPalCreditFinancingAmount *totalInterest = [self.class creditFinancingAmountFromJSON:creditFinancingOfferedJSON[@"totalInterest"]];
  304. return [[BTPayPalCreditFinancing alloc] initWithCardAmountImmutable:isCardAmountImmutable
  305. monthlyPayment:monthlyPayment
  306. payerAcceptance:payerAcceptance
  307. term:term
  308. totalCost:totalCost
  309. totalInterest:totalInterest];
  310. }
  311. + (BTPayPalAccountNonce *)payPalAccountFromJSON:(BTJSON *)payPalAccount {
  312. NSString *nonce = [payPalAccount[@"nonce"] asString];
  313. BTJSON *details = payPalAccount[@"details"];
  314. NSString *email = [details[@"email"] asString];
  315. NSString *clientMetadataID = [details[@"correlationId"] asString];
  316. // Allow email to be under payerInfo
  317. if ([details[@"payerInfo"][@"email"] isString]) {
  318. email = [details[@"payerInfo"][@"email"] asString];
  319. }
  320. NSString *firstName = [details[@"payerInfo"][@"firstName"] asString];
  321. NSString *lastName = [details[@"payerInfo"][@"lastName"] asString];
  322. NSString *phone = [details[@"payerInfo"][@"phone"] asString];
  323. NSString *payerID = [details[@"payerInfo"][@"payerId"] asString];
  324. BOOL isDefault = [payPalAccount[@"default"] isTrue];
  325. BTPostalAddress *shippingAddress = [self.class shippingOrBillingAddressFromJSON:details[@"payerInfo"][@"shippingAddress"]];
  326. BTPostalAddress *billingAddress = [self.class shippingOrBillingAddressFromJSON:details[@"payerInfo"][@"billingAddress"]];
  327. if (!shippingAddress) {
  328. shippingAddress = [self.class accountAddressFromJSON:details[@"payerInfo"][@"accountAddress"]];
  329. }
  330. BTPayPalCreditFinancing *creditFinancing = [self.class creditFinancingFromJSON:details[@"creditFinancingOffered"]];
  331. BTPayPalAccountNonce *tokenizedPayPalAccount = [[BTPayPalAccountNonce alloc] initWithNonce:nonce
  332. email:email
  333. firstName:firstName
  334. lastName:lastName
  335. phone:phone
  336. billingAddress:billingAddress
  337. shippingAddress:shippingAddress
  338. clientMetadataID:clientMetadataID
  339. payerID:payerID
  340. isDefault:isDefault
  341. creditFinancing:creditFinancing];
  342. return tokenizedPayPalAccount;
  343. }
  344. #pragma mark - ASWebAuthenticationPresentationContextProviding protocol
  345. - (ASPresentationAnchor)presentationAnchorForWebAuthenticationSession:(ASWebAuthenticationSession *)session API_AVAILABLE(ios(13)) NS_EXTENSION_UNAVAILABLE("Uses APIs (i.e UIApplication.sharedApplication) not available for use in App Extensions.") {
  346. if (self.payPalRequest.activeWindow) {
  347. return self.payPalRequest.activeWindow;
  348. }
  349. for (UIScene* scene in UIApplication.sharedApplication.connectedScenes) {
  350. if (scene.activationState == UISceneActivationStateForegroundActive) {
  351. UIWindowScene *windowScene = (UIWindowScene *)scene;
  352. return windowScene.windows.firstObject;
  353. }
  354. }
  355. if (@available(iOS 15, *)) {
  356. return ((UIWindowScene *)UIApplication.sharedApplication.connectedScenes.allObjects.firstObject).windows.firstObject;
  357. } else {
  358. return UIApplication.sharedApplication.windows.firstObject;
  359. }
  360. }
  361. #pragma mark - Preflight check
  362. - (BOOL)verifyAppSwitchWithRemoteConfiguration:(BTJSON *)configuration error:(NSError * __autoreleasing *)error {
  363. if (![configuration[@"paypalEnabled"] isTrue]) {
  364. [self.apiClient sendAnalyticsEvent:@"ios.paypal-otc.preflight.disabled"];
  365. if (error != NULL) {
  366. *error = [NSError errorWithDomain:BTPayPalDriverErrorDomain
  367. code:BTPayPalDriverErrorTypeDisabled
  368. userInfo:@{ NSLocalizedDescriptionKey: @"PayPal is not enabled for this merchant",
  369. NSLocalizedRecoverySuggestionErrorKey: @"Enable PayPal for this merchant in the Braintree Control Panel" }];
  370. }
  371. return NO;
  372. }
  373. return YES;
  374. }
  375. #pragma mark - Analytics Helpers
  376. + (NSString *)eventStringForPaymentType:(BTPayPalPaymentType)paymentType {
  377. switch (paymentType) {
  378. case BTPayPalPaymentTypeVault:
  379. return @"paypal-ba";
  380. case BTPayPalPaymentTypeCheckout:
  381. return @"paypal-single-payment";
  382. default:
  383. return nil;
  384. }
  385. }
  386. - (void)sendAnalyticsEventForInitiatingOneTouchForPaymentType:(BTPayPalPaymentType)paymentType
  387. withSuccess:(BOOL)success {
  388. NSString *eventName = [NSString stringWithFormat:@"ios.%@.webswitch.initiate.%@", [self.class eventStringForPaymentType:paymentType], success ? @"started" : @"failed"];
  389. [self.apiClient sendAnalyticsEvent:eventName];
  390. if ([self.payPalRequest isKindOfClass:BTPayPalCheckoutRequest.class] && ((BTPayPalCheckoutRequest *)self.payPalRequest).offerPayLater) {
  391. NSString *eventName = [NSString stringWithFormat:@"ios.%@.webswitch.paylater.offered.%@", [self.class eventStringForPaymentType:paymentType], success ? @"started" : @"failed"];
  392. [self.apiClient sendAnalyticsEvent:eventName];
  393. }
  394. if ([self.payPalRequest isKindOfClass:BTPayPalVaultRequest.class] && ((BTPayPalVaultRequest *)self.payPalRequest).offerCredit) {
  395. NSString *eventName = [NSString stringWithFormat:@"ios.%@.webswitch.credit.offered.%@", [self.class eventStringForPaymentType:paymentType], success ? @"started" : @"failed"];
  396. [self.apiClient sendAnalyticsEvent:eventName];
  397. }
  398. }
  399. - (void)sendAnalyticsEventIfCreditFinancingInNonce:(BTPayPalAccountNonce *)payPalAccountNonce forPaymentType:(BTPayPalPaymentType)paymentType {
  400. if (payPalAccountNonce.creditFinancing) {
  401. NSString *eventName = [NSString stringWithFormat:@"ios.%@.credit.accepted", [self.class eventStringForPaymentType:paymentType]];
  402. [self.apiClient sendAnalyticsEvent:eventName];
  403. }
  404. }
  405. - (void)sendAnalyticsEventForTokenizationSuccessForPaymentType:(BTPayPalPaymentType)paymentType {
  406. NSString *eventName = [NSString stringWithFormat:@"ios.%@.tokenize.succeeded", [self.class eventStringForPaymentType:paymentType]];
  407. [self.apiClient sendAnalyticsEvent:eventName];
  408. }
  409. - (void)sendAnalyticsEventForTokenizationFailureForPaymentType:(BTPayPalPaymentType)paymentType {
  410. NSString *eventName = [NSString stringWithFormat:@"ios.%@.tokenize.failed", [self.class eventStringForPaymentType:paymentType]];
  411. [self.apiClient sendAnalyticsEvent:eventName];
  412. }
  413. #pragma mark - Internal
  414. - (NSURL *)decorateApprovalURL:(NSURL*)approvalURL forRequest:(BTPayPalRequest *)paypalRequest {
  415. if (approvalURL != nil && [paypalRequest isKindOfClass:BTPayPalCheckoutRequest.class]) {
  416. NSURLComponents* approvalURLComponents = [[NSURLComponents alloc] initWithURL:approvalURL resolvingAgainstBaseURL:NO];
  417. if (approvalURLComponents != nil) {
  418. NSString *userActionValue = ((BTPayPalCheckoutRequest *)paypalRequest).userActionAsString;
  419. if (userActionValue.length > 0) {
  420. NSURLQueryItem *userActionQueryItem = [[NSURLQueryItem alloc] initWithName:@"useraction" value:userActionValue];
  421. NSArray<NSURLQueryItem *> *queryItems = approvalURLComponents.queryItems ?: @[];
  422. approvalURLComponents.queryItems = [queryItems arrayByAddingObject:userActionQueryItem];
  423. }
  424. return approvalURLComponents.URL;
  425. }
  426. }
  427. return approvalURL;
  428. }
  429. #pragma mark - Browser Switch handling
  430. - (void)handleBrowserSwitchReturnURL:(NSURL *)url
  431. paymentType:(BTPayPalPaymentType)paymentType
  432. completion:(void (^)(BTPayPalAccountNonce *tokenizedCheckout, NSError *error))completionBlock {
  433. if (![self.class isValidURLAction: url]) {
  434. NSError *responseError = [NSError errorWithDomain:BTPayPalDriverErrorDomain
  435. code:BTPayPalDriverErrorTypeUnknown
  436. userInfo:@{ NSLocalizedDescriptionKey: @"Unexpected response" }];
  437. if (completionBlock) {
  438. completionBlock(nil, responseError);
  439. }
  440. return;
  441. }
  442. NSDictionary *response = [self dictionaryFromResponseURL:url];
  443. if (!response) {
  444. if (completionBlock) {
  445. // If there's no response, the user canceled out of the flow using the cancel link
  446. // on the PayPal website
  447. NSError *err = [NSError errorWithDomain:BTPayPalDriverErrorDomain
  448. code:BTPayPalDriverErrorTypeCanceled
  449. userInfo:@{NSLocalizedDescriptionKey: @"PayPal flow was canceled by the user."}];
  450. completionBlock(nil, err);
  451. }
  452. return;
  453. }
  454. NSMutableDictionary *parameters = [NSMutableDictionary dictionary];
  455. parameters[@"paypal_account"] = [response mutableCopy];
  456. if (paymentType == BTPayPalPaymentTypeCheckout) {
  457. parameters[@"paypal_account"][@"options"] = @{ @"validate": @NO };
  458. if ([self.payPalRequest isKindOfClass:BTPayPalCheckoutRequest.class] && ((BTPayPalCheckoutRequest *) self.payPalRequest).intentAsString) {
  459. parameters[@"paypal_account"][@"intent"] = ((BTPayPalCheckoutRequest *) self.payPalRequest).intentAsString;
  460. }
  461. }
  462. if (self.clientMetadataID) {
  463. parameters[@"paypal_account"][@"correlation_id"] = self.clientMetadataID;
  464. }
  465. if (self.payPalRequest != nil && self.payPalRequest.merchantAccountID != nil) {
  466. parameters[@"merchant_account_id"] = self.payPalRequest.merchantAccountID;
  467. }
  468. BTClientMetadata *metadata = [self clientMetadata];
  469. parameters[@"_meta"] = @{
  470. @"source" : metadata.sourceString,
  471. @"integration" : metadata.integrationString,
  472. @"sessionId" : metadata.sessionID,
  473. };
  474. [self.apiClient POST:@"/v1/payment_methods/paypal_accounts"
  475. parameters:parameters
  476. completion:^(BTJSON *body, __unused NSHTTPURLResponse *response, NSError *error) {
  477. if (error) {
  478. if (error.code == NETWORK_CONNECTION_LOST_CODE) {
  479. [self.apiClient sendAnalyticsEvent:@"ios.paypal.handle-browser-switch.network-connection.failure"];
  480. }
  481. [self sendAnalyticsEventForTokenizationFailureForPaymentType:paymentType];
  482. if (completionBlock) {
  483. completionBlock(nil, error);
  484. }
  485. return;
  486. }
  487. [self sendAnalyticsEventForTokenizationSuccessForPaymentType:paymentType];
  488. BTJSON *payPalAccount = body[@"paypalAccounts"][0];
  489. BTPayPalAccountNonce *tokenizedAccount = [self.class payPalAccountFromJSON:payPalAccount];
  490. [self sendAnalyticsEventIfCreditFinancingInNonce:tokenizedAccount forPaymentType:paymentType];
  491. if (completionBlock) {
  492. completionBlock(tokenizedAccount, nil);
  493. }
  494. }];
  495. }
  496. #pragma mark - Class Methods
  497. + (NSString *)tokenFromApprovalURL:(NSURL *)approvalURL {
  498. NSDictionary *queryDictionary = [self parseQueryString:[approvalURL query]];
  499. return queryDictionary[@"token"] ?: queryDictionary[@"ba_token"];
  500. }
  501. + (NSDictionary *)parseQueryString:(NSString *)query {
  502. NSMutableDictionary *dict = [[NSMutableDictionary alloc] initWithCapacity:6];
  503. NSArray *pairs = [query componentsSeparatedByString:@"&"];
  504. for (NSString *pair in pairs) {
  505. NSArray *elements = [pair componentsSeparatedByString:@"="];
  506. if (elements.count > 1) {
  507. NSString *key = [[elements objectAtIndex:0] stringByRemovingPercentEncoding];
  508. NSString *val = [[elements objectAtIndex:1] stringByRemovingPercentEncoding];
  509. if (key.length && val.length) {
  510. dict[key] = val;
  511. }
  512. }
  513. }
  514. return dict;
  515. }
  516. + (BOOL)isValidURLAction:(NSURL *)url {
  517. NSString *scheme = url.scheme;
  518. if (!scheme.length) {
  519. return NO;
  520. }
  521. NSString *hostAndPath = [url.host stringByAppendingString:url.path];
  522. NSMutableArray *pathComponents = [[hostAndPath componentsSeparatedByString:@"/"] mutableCopy];
  523. [pathComponents removeLastObject]; // remove the action (`success`, `cancel`, etc)
  524. hostAndPath = [pathComponents componentsJoinedByString:@"/"];
  525. if ([hostAndPath length]) {
  526. hostAndPath = [hostAndPath stringByAppendingString:@"/"];
  527. }
  528. if (![hostAndPath isEqualToString:BTPayPalCallbackURLHostAndPath]) {
  529. return NO;
  530. }
  531. NSString *action = [self actionFromURLAction:url];
  532. if (!action.length) {
  533. return NO;
  534. }
  535. NSArray *validActions = @[@"success", @"cancel", @"authenticate"];
  536. if (![validActions containsObject:action]) {
  537. return NO;
  538. }
  539. NSString *query = [url query];
  540. if (!query.length) {
  541. // should always have at least a payload or else a Hermes token (even if the action is "cancel")
  542. return NO;
  543. }
  544. return YES;
  545. }
  546. + (NSString *)actionFromURLAction:(NSURL *)url {
  547. NSString *action = [url.lastPathComponent componentsSeparatedByString:@"?"][0];
  548. if (![action length]) {
  549. action = url.host;
  550. }
  551. return action;
  552. }
  553. @end