From 517ed421583db1cfa6995501e1e10b7968c135f0 Mon Sep 17 00:00:00 2001 From: Vasyl Nahuliak Date: Tue, 8 Jun 2021 11:37:53 +0300 Subject: [PATCH] build: move files from private repo --- .gitignore | 1 + README.md | 162 ++++++++- android/build.gradle | 11 + android/src/main/AndroidManifest.xml | 12 + .../RNBraintreeModule.java | 322 +++++++++++++++++- .../RNBraintreePackage.java | 1 + index.d.ts | 56 +++ index.js | 9 +- ios/RNBraintree.m | 196 ++++++++++- ios/RNBraintreeApplePay.h | 5 + ios/RNBraintreeApplePay.m | 127 +++++++ package.json | 3 +- react-native-braintree.podspec | 7 +- 13 files changed, 889 insertions(+), 23 deletions(-) create mode 100644 index.d.ts create mode 100644 ios/RNBraintreeApplePay.h create mode 100644 ios/RNBraintreeApplePay.m diff --git a/.gitignore b/.gitignore index c5464d9..bab1edf 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ project.xcworkspace build/ .idea .gradle +gradle/ local.properties *.iml diff --git a/README.md b/README.md index 96cfce2..11df9d3 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,165 @@ -# react-native-braintree +# @ekreative/react-native-braintree ## Getting started -`$ npm install react-native-braintree --save` +## Android Specific +Add this to your `build.gradle` -### Mostly automatic installation +```groovy + repositories { + maven { + url "https://cardinalcommerce.bintray.com/android" + credentials { + username 'braintree-team-sdk@cardinalcommerce' + password '220cc9476025679c4e5c843666c27d97cfb0f951' + } + } + } +``` + +In Your `AndroidManifest.xml`, `android:allowBackup="false"` can be replaced `android:allowBackup="true"`, it is responsible for app backup. + +## iOS Specific +```bash +cd ios +pod install +``` +###### Configure a new URL scheme +Add a bundle url scheme {BUNDLE_IDENTIFIER}.payments in your app Info via XCode or manually in the Info.plist. In your Info.plist, you should have something like: + +```xml +CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.myapp + CFBundleURLSchemes + + com.myapp.payments + + + +``` +###### Update your code +In your `AppDelegate.m`: + +```objective-c +#import "BraintreeCore.h" + +... +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions +{ + ... + [BTAppSwitch setReturnURLScheme:self.paymentsURLScheme]; +} + +- (BOOL)application:(UIApplication *)application + openURL:(NSURL *)url + options:(NSDictionary *)options { + + if ([url.scheme localizedCaseInsensitiveCompare:self.paymentsURLScheme] == NSOrderedSame) { + return [BTAppSwitch handleOpenURL:url options:options]; + } + + return [RCTLinkingManager application:application openURL:url options:options]; +} + +- (NSString *)paymentsURLScheme { + NSString *bundleIdentifier = [[NSBundle mainBundle] bundleIdentifier]; + return [NSString stringWithFormat:@"%@.%@", bundleIdentifier, @"payments"]; +} +``` -`$ react-native link react-native-braintree` ## Usage + +##### Show PayPall module + ```javascript -import RNBraintree from 'react-native-braintree'; +import RNBraintree from '@ekreative/react-native-braintree'; + +RNBraintree.showPayPalModule({ + clientToken: 'CLIENT_TOKEN_GENERATED_ON_SERVER_SIDE', + amount: '1.0', + currencyCode: 'EUR' + }) + .then(result => console.log(result)) + .catch((error) => console.log(error)); + -// TODO: What to do with the module? -RNBraintree; ``` + +##### Card tokenization +```javascript +import RNBraintree from '@ekreative/react-native-braintree'; + +RNBraintree.tokenizeCard({ + clientToken: 'CLIENT_TOKEN_GENERATED_ON_SERVER_SIDE', + number: '1111222233334444', + expirationMonth: '11', + expirationYear: '24', + cvv: '123', + postalCode: '', + }) + .then(result => console.log(result)) + .catch((error) => console.log(error)); + +``` +##### Make Payment +```javascript +import RNBraintree from '@ekreative/react-native-braintree'; + +RNBraintree.run3DSecureCheck({ + clientToken: 'CLIENT_TOKEN_GENERATED_ON_SERVER_SIDE', + nonce: 'CARD_NONCE', + amount: '122.00', + email: 'email@mail.com', + firstname: '', + lastname: '', + phoneNumber: '', + streetAddress: '', + streetAddress2: '', + city: '', + region: '', + postalCode: '', + countryCode: '' + }) + .then(result => console.log(result)) + .catch((error) => console.log(error)); + +``` +### iOS +##### Get if Apple Pay available +```javascript +import RNBraintree from '@ekreative/react-native-braintree'; + +console.log(RNBraintree.isApplePayAvailable()) +``` +##### Make payment using Apple Pay +```javascript +import RNBraintree from '@ekreative/react-native-braintree'; + +RNBraintree.runApplePay({ + clientToken: 'CLIENT_TOKEN_GENERATED_ON_SERVER_SIDE', + companyName: 'Company', + amount: '100.0', + currencyCode: 'EUR' + }) + .then(result => console.log(result)) + .catch((error) => console.log(error)); +``` +### Android +##### Make payment using Google Pay +```javascript +import RNBraintree from '@ekreative/react-native-braintree'; + +RNBraintree.runGooglePay({ + clientToken: 'CLIENT_TOKEN_GENERATED_ON_SERVER_SIDE', + amount: '100.0', + currencyCode: 'EUR' + }) + .then(result => console.log(result)) + .catch((error) => console.log(error)); +``` \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index fa45ded..eab6cb9 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -32,6 +32,7 @@ buildscript { if (project == rootProject) { repositories { google() + jcenter() } dependencies { // This should reflect the Gradle plugin version used by @@ -66,12 +67,22 @@ repositories { // Android JSC is installed from npm url "$rootDir/../node_modules/jsc-android/dist" } + maven { + url "https://cardinalcommerce.bintray.com/android" + credentials { + username 'braintree-team-sdk@cardinalcommerce' + password '220cc9476025679c4e5c843666c27d97cfb0f951' + } + } google() + jcenter() } dependencies { //noinspection GradleDynamicVersion implementation 'com.facebook.react:react-native:+' // From node_modules + implementation 'com.braintreepayments.api:drop-in:4.6.0' + implementation 'com.google.android.gms:play-services-wallet:18.1.2' } def configureReactNativePom(def pom) { diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index aa2e057..6d7f97c 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -3,4 +3,16 @@ + + + + + + + + + + + diff --git a/android/src/main/java/com/ekreative/reactnativebraintree/RNBraintreeModule.java b/android/src/main/java/com/ekreative/reactnativebraintree/RNBraintreeModule.java index 011bd8a..19e217c 100644 --- a/android/src/main/java/com/ekreative/reactnativebraintree/RNBraintreeModule.java +++ b/android/src/main/java/com/ekreative/reactnativebraintree/RNBraintreeModule.java @@ -2,18 +2,85 @@ package com.ekreative.reactnativebraintree; +import android.app.Activity; +import android.content.Intent; +import android.util.Log; + +import androidx.appcompat.app.AppCompatActivity; + +import com.braintreepayments.api.BraintreeFragment; +import com.braintreepayments.api.Card; +import com.braintreepayments.api.DataCollector; +import com.braintreepayments.api.GooglePayment; +import com.braintreepayments.api.PayPal; +import com.braintreepayments.api.ThreeDSecure; +import com.braintreepayments.api.dropin.DropInResult; +import com.braintreepayments.api.exceptions.InvalidArgumentException; +import com.braintreepayments.api.interfaces.BraintreeCancelListener; +import com.braintreepayments.api.interfaces.BraintreeErrorListener; +import com.braintreepayments.api.interfaces.BraintreeResponseListener; +import com.braintreepayments.api.interfaces.ConfigurationListener; +import com.braintreepayments.api.interfaces.PaymentMethodNonceCreatedListener; +import com.braintreepayments.api.interfaces.ThreeDSecureLookupListener; +import com.braintreepayments.api.models.CardBuilder; +import com.braintreepayments.api.models.CardNonce; +import com.braintreepayments.api.models.Configuration; +import com.braintreepayments.api.models.GooglePaymentRequest; +import com.braintreepayments.api.models.PayPalAccountNonce; +import com.braintreepayments.api.models.PayPalRequest; +import com.braintreepayments.api.models.PaymentMethodNonce; +import com.braintreepayments.api.models.ThreeDSecureAdditionalInformation; +import com.braintreepayments.api.models.ThreeDSecureLookup; +import com.braintreepayments.api.models.ThreeDSecurePostalAddress; +import com.braintreepayments.api.models.ThreeDSecureRequest; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; -import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.BaseActivityEventListener; +import com.facebook.react.bridge.WritableMap; +import com.google.android.gms.wallet.PaymentData; +import com.google.android.gms.wallet.TransactionInfo; +import com.google.android.gms.wallet.WalletConstants; + +import static android.app.Activity.RESULT_OK; +import static android.app.Activity.RESULT_CANCELED; public class RNBraintreeModule extends ReactContextBaseJavaModule { - private final ReactApplicationContext reactContext; + private Promise mPromise; + private BraintreeFragment mBraintreeFragment; + private String mDeviceData; + private static final int GOOGLE_PAYMENT_REQUEST_CODE = 79129; public RNBraintreeModule(ReactApplicationContext reactContext) { super(reactContext); - this.reactContext = reactContext; + + reactContext.addActivityEventListener(new BaseActivityEventListener() { + @Override + public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) { + super.onActivityResult(activity, requestCode, resultCode, data); + if (requestCode == GOOGLE_PAYMENT_REQUEST_CODE && resultCode == RESULT_OK) { + GooglePayment.tokenize(mBraintreeFragment, PaymentData.getFromIntent(data)); + } + + if (requestCode == GOOGLE_PAYMENT_REQUEST_CODE && resultCode == RESULT_CANCELED) { + mPromise.reject("USER_CANCELLATION", "The user cancelled"); + } + + // FIXME: PaymentMethodNonceCreatedListener doesn't call when using ThreeDSecureRequest.VERSION_2 + // SOURCE: https://github.com/braintree/braintree_android/issues/268#issuecomment-567268780 + + final int unmaskedRequestCode = requestCode & 0x0000ffff; + boolean isBraintreeFragment = mBraintreeFragment != null && mBraintreeFragment.getTag().indexOf("Braintree") != -1; + + if (isBraintreeFragment) { + mBraintreeFragment.onActivityResult(unmaskedRequestCode, resultCode, data); + } + } + }); } @Override @@ -22,8 +89,251 @@ public String getName() { } @ReactMethod - public void sampleMethod(String stringArgument, int numberArgument, Callback callback) { - // TODO: Implement some actually useful functionality - callback.invoke("Received numberArgument: " + numberArgument + " stringArgument: " + stringArgument); + public void showPayPalModule(final ReadableMap parameters, final Promise promise) { + mPromise = promise; + + if (!parameters.hasKey("clientToken")) { + promise.reject("You must provide a clientToken"); + } else { + setup(parameters.getString("clientToken")); + + String currency = "USD"; + if (!parameters.hasKey("amount")) { + promise.reject("You must provide a amount"); + } + if (parameters.hasKey("currencyCode")) { + currency = parameters.getString("currencyCode"); + } + + try { + PayPalRequest request = new PayPalRequest(parameters.getString("amount")) + .currencyCode(currency) + .intent(PayPalRequest.INTENT_AUTHORIZE); + PayPal.requestOneTimePayment(mBraintreeFragment, request); + } catch (Exception e) { + promise.reject(e.getMessage()); + } + } + } + + @ReactMethod + public void requestPayPalBillingAgreement(final ReadableMap parameters, final Promise promise) { + mPromise = promise; + + if (!parameters.hasKey("clientToken")) { + promise.reject("MISSING_CLIENT_TOKEN", "You must provide a clientToken"); + } + + setup(parameters.getString("clientToken")); + + try { + String description = parameters.hasKey("description") ? parameters.getString("description") : ""; + String localeCode = parameters.hasKey("localeCode") ? parameters.getString("localeCode") : "US"; + + PayPalRequest request = new PayPalRequest() + .localeCode(localeCode) + .billingAgreementDescription(description); + + PayPal.requestBillingAgreement(mBraintreeFragment, request); + } catch (Exception e) { + promise.reject("REQUEST_BILLING_AGREEMENT_FAILED", e.getMessage()); + } + + } + + @ReactMethod + public void runGooglePay(final ReadableMap parameters, final Promise promise) { + mPromise = promise; + + if (!parameters.hasKey("clientToken")) { + promise.reject("You must provide a clientToken"); + } else { + setup(parameters.getString("clientToken")); + + String currency = "USD"; + if (!parameters.hasKey("amount")) { + promise.reject("You must provide a amount"); + } + if (parameters.hasKey("currencyCode")) { + currency = parameters.getString("currencyCode"); + } + + try { + GooglePaymentRequest googlePaymentRequest = new GooglePaymentRequest() + .transactionInfo(TransactionInfo.newBuilder() + .setCurrencyCode(currency) + .setTotalPrice(parameters.getString("amount")) + .setTotalPriceStatus(WalletConstants.TOTAL_PRICE_STATUS_FINAL) + .build()) + .billingAddressRequired(true); + + if (parameters.hasKey("merchantId")) { + String merchantId = parameters.getString("merchantId"); + googlePaymentRequest.googleMerchantId(merchantId); + String env = "test".equals(merchantId) ? "TEST" : "PRODUCTION"; + googlePaymentRequest.environment(env); + } + + GooglePayment.requestPayment(mBraintreeFragment, googlePaymentRequest); + } catch (Exception e) { + promise.reject(e.getMessage()); + } + } + } + + @ReactMethod + public void tokenizeCard(final ReadableMap parameters, final Promise promise) { + mPromise = promise; + + if (!parameters.hasKey("clientToken")) { + promise.reject("You must provide a clientToken"); + } else { + setup(parameters.getString("clientToken")); + + try { + CardBuilder cardBuilder = new CardBuilder().validate(false); + + if (parameters.hasKey("number")) { + cardBuilder.cardNumber(parameters.getString("number")); + } + + if (parameters.hasKey("expirationMonth")) { + cardBuilder.expirationMonth(parameters.getString("expirationMonth")); + } + + if (parameters.hasKey("expirationYear")) { + cardBuilder.expirationYear(parameters.getString("expirationYear")); + } + + if (parameters.hasKey("cvv")) { + cardBuilder.cvv(parameters.getString("cvv")); + } + + if (parameters.hasKey("postalCode")) { + cardBuilder.postalCode(parameters.getString("postalCode")); + } + + Card.tokenize(mBraintreeFragment, cardBuilder); + } catch (Exception e) { + promise.reject(e.getMessage()); + } + } + } + + @ReactMethod + public void run3DSecureCheck(final ReadableMap parameters, final Promise promise) { + mPromise = promise; + + if (!parameters.hasKey("clientToken")) { + promise.reject("You must provide a clientToken"); + } + + try { + setup(parameters.getString("clientToken")); + + ThreeDSecurePostalAddress address = new ThreeDSecurePostalAddress(); + + if (parameters.hasKey("firstname")) { + address.givenName(parameters.getString("firstname")); + } + + if (parameters.hasKey("lastname")) { + address.surname(parameters.getString("lastname")); + } + + if (parameters.hasKey("phoneNumber")) { + address.phoneNumber(parameters.getString("phoneNumber")); + } + + if (parameters.hasKey("countryCode")) { + address.countryCodeAlpha2(parameters.getString("countryCode")); + } + + if (parameters.hasKey("city")) { + address.locality(parameters.getString("city")); + } + + if (parameters.hasKey("postalCode")) { + address.postalCode(parameters.getString("postalCode")); + } + + if (parameters.hasKey("region")) { + address.region(parameters.getString("region")); + } + + if (parameters.hasKey("streetAddress")) { + address.streetAddress(parameters.getString("streetAddress")); + } + + if (parameters.hasKey("streetAddress2")) { + address.extendedAddress(parameters.getString("streetAddress2")); + } + + // For best results, provide as many additional elements as possible. + ThreeDSecureAdditionalInformation additionalInformation = new ThreeDSecureAdditionalInformation() + .shippingAddress(address); + + ThreeDSecureRequest threeDSecureRequest = new ThreeDSecureRequest() + .nonce(parameters.getString("nonce")) + .email(parameters.getString("email")) + .billingAddress(address) + .versionRequested(ThreeDSecureRequest.VERSION_2) + .additionalInformation(additionalInformation) + .amount(parameters.getString("amount")); + + ThreeDSecure.performVerification(mBraintreeFragment, threeDSecureRequest, new ThreeDSecureLookupListener() { + @Override + public void onLookupComplete(ThreeDSecureRequest request, ThreeDSecureLookup lookup) { + // Optionally inspect the lookup result and prepare UI if a challenge is required + ThreeDSecure.continuePerformVerification(mBraintreeFragment, request, lookup); + } + }); + } catch (Exception e) { + promise.reject(e.getMessage()); + } + } + + public void setup(final String token) { + if (mBraintreeFragment == null) { + try { + mBraintreeFragment = BraintreeFragment.newInstance((AppCompatActivity) getCurrentActivity(), token); + mBraintreeFragment.addListener(new BraintreeCancelListener() { + @Override + public void onCancel(int requestCode) { + mPromise.reject("USER_CANCELLATION", "The user cancelled"); + } + }); + mBraintreeFragment.addListener(new PaymentMethodNonceCreatedListener() { + @Override + public void onPaymentMethodNonceCreated(PaymentMethodNonce paymentMethodNonce) { + + WritableMap map = Arguments.createMap(); + if (paymentMethodNonce instanceof PayPalAccountNonce) { + PayPalAccountNonce payPalAccountNonce = (PayPalAccountNonce) paymentMethodNonce; + + // Access additional information + String email = payPalAccountNonce.getEmail(); + map.putString("email", email); + + } + map.putString("nonce", paymentMethodNonce.getNonce()); + sendResult(map); + } + }); + DataCollector.collectDeviceData(mBraintreeFragment, new BraintreeResponseListener() { + @Override + public void onResponse(String deviceData) { + mDeviceData = deviceData; + } + }); + } catch (Exception e) { + mPromise.reject(e.getMessage()); + } + } + } + + public void sendResult(final WritableMap result) { + result.putString("deviceData", mDeviceData); + mPromise.resolve(result); } } diff --git a/android/src/main/java/com/ekreative/reactnativebraintree/RNBraintreePackage.java b/android/src/main/java/com/ekreative/reactnativebraintree/RNBraintreePackage.java index 4737232..0346f50 100644 --- a/android/src/main/java/com/ekreative/reactnativebraintree/RNBraintreePackage.java +++ b/android/src/main/java/com/ekreative/reactnativebraintree/RNBraintreePackage.java @@ -10,6 +10,7 @@ import com.facebook.react.bridge.NativeModule; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.uimanager.ViewManager; +import com.facebook.react.bridge.JavaScriptModule; public class RNBraintreePackage implements ReactPackage { @Override diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..2ab2a69 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,56 @@ +declare module '@ekreative/react-native-braintree' { + interface BraintreeResponse { + nonce: string; + deviceData: string; + } + + interface BraintreeOptions { + clientToken: string; + amount: string; + currencyCode: string; + } + + interface Run3DSecureCheckOptions + extends Omit { + nonce: string; + email: string; + firstname: string; + lastname: string; + phoneNumber: string; + streetAddress: string; + streetAddress2?: string; + city: string; + region?: string; + postalCode: string; + countryCode: string; + } + + interface TokenizeCardOptions { + clientToken: string; + number: string; + expirationMonth: string; + expirationYear: string; + cvv: string; + postalCode?: string; + } + + interface RunApplePayOptions extends BraintreeOptions { + companyName: string; + } + + // Export + + interface RNBraintreeModule { + showPayPalModule(options: BraintreeOptions): Promise; + runGooglePay(options: BraintreeOptions): Promise; + run3DSecureCheck( + options: Run3DSecureCheckOptions, + ): Promise; + tokenizeCard(options: TokenizeCardOptions): Promise; + runApplePay(options: RunApplePayOptions): Promise; + } + + const RNBraintree: RNBraintreeModule; + + export default RNBraintree; +} diff --git a/index.js b/index.js index 9bcf729..57b0cc0 100644 --- a/index.js +++ b/index.js @@ -3,5 +3,12 @@ import { NativeModules } from 'react-native'; const { RNBraintree } = NativeModules; +const { RNBraintreeApplePay } = NativeModules; -export default RNBraintree; +export default { + showPayPalModule: RNBraintree.showPayPalModule, + runGooglePay: RNBraintree.runGooglePay, + run3DSecureCheck: RNBraintree.run3DSecureCheck, + tokenizeCard: RNBraintree.tokenizeCard, + runApplePay: RNBraintreeApplePay && RNBraintreeApplePay.runApplePay, +} diff --git a/ios/RNBraintree.m b/ios/RNBraintree.m index c362701..14e80c3 100644 --- a/ios/RNBraintree.m +++ b/ios/RNBraintree.m @@ -1,16 +1,200 @@ -// RNBraintree.m - #import "RNBraintree.h" +#import "BraintreeCore.h" +#import "BTCardClient.h" +#import "BraintreePayPal.h" +#import "BTDataCollector.h" +#import "BraintreePaymentFlow.h" +@interface RNBraintree() +@property (nonatomic, strong) BTAPIClient *apiClient; +@property (nonatomic, strong) BTDataCollector *dataCollector; +@end @implementation RNBraintree RCT_EXPORT_MODULE() -RCT_EXPORT_METHOD(sampleMethod:(NSString *)stringArgument numberParameter:(nonnull NSNumber *)numberArgument callback:(RCTResponseSenderBlock)callback) -{ - // TODO: Implement some actually useful functionality - callback(@[[NSString stringWithFormat: @"numberArgument: %@ stringArgument: %@", numberArgument, stringArgument]]); +RCT_EXPORT_METHOD(showPayPalModule: (NSDictionary *)options + resolver: (RCTPromiseResolveBlock)resolve + rejecter: (RCTPromiseRejectBlock)reject) { + NSString *clientToken = options[@"clientToken"]; + NSString *amount = options[@"amount"]; + NSString *currencyCode = options[@"currencyCode"]; + + self.apiClient = [[BTAPIClient alloc] initWithAuthorization: clientToken]; + self.dataCollector = [[BTDataCollector alloc] initWithAPIClient:self.apiClient]; + BTPayPalDriver *payPalDriver = [[BTPayPalDriver alloc] initWithAPIClient: self.apiClient]; + payPalDriver.viewControllerPresentingDelegate = self; + + BTPayPalRequest *request= [[BTPayPalRequest alloc] initWithAmount:amount]; + request.currencyCode = currencyCode; + [payPalDriver requestOneTimePayment:request completion:^(BTPayPalAccountNonce * _Nullable tokenizedPayPalAccount, NSError * _Nullable error) { + if (error) { + reject(@"ONE_TIME_PAYMENT_FAILED", error.localizedDescription, nil); + return; + } + if (!tokenizedPayPalAccount) { + reject(@"ONE_TIME_PAYMENT_CANCELLED", @"Payment has been cancelled", nil); + return; + } + [self.dataCollector collectDeviceData:^(NSString * _Nonnull deviceData) { + resolve(@{@"deviceData": deviceData, + @"email": tokenizedPayPalAccount.email, + @"nonce": tokenizedPayPalAccount.nonce,}); + }]; + }]; +} + +RCT_EXPORT_METHOD(requestPayPalBillingAgreement: (NSDictionary *)options + resolver: (RCTPromiseResolveBlock)resolve + rejecter: (RCTPromiseRejectBlock)reject) { + NSString *clientToken = options[@"clientToken"]; + NSString *description = options[@"description"]; + + self.apiClient = [[BTAPIClient alloc] initWithAuthorization: clientToken]; + self.dataCollector = [[BTDataCollector alloc] initWithAPIClient:self.apiClient]; + BTPayPalDriver *payPalDriver = [[BTPayPalDriver alloc] initWithAPIClient: self.apiClient]; + payPalDriver.viewControllerPresentingDelegate = self; + + BTPayPalRequest *request= [[BTPayPalRequest alloc] init]; + if (description) { + request.billingAgreementDescription = description; + } + [payPalDriver requestBillingAgreement:request completion:^(BTPayPalAccountNonce * _Nullable tokenizedPayPalAccount, NSError * _Nullable error) { + if (error) { + reject(@"REQUEST_BILLING_AGREEMENT_FAILED", error.localizedDescription, nil); + return; + } + if (!tokenizedPayPalAccount) { + reject(@"REQUEST_BILLING_AGREEMENT_CANCELLED", @"Request billing agreement has been cancelled", nil); + return; + } + [self.dataCollector collectDeviceData:^(NSString * _Nonnull deviceData) { + resolve(@{@"deviceData": deviceData, + @"nonce": tokenizedPayPalAccount.nonce,}); + }]; + }]; +} + +RCT_EXPORT_METHOD(tokenizeCard: (NSDictionary *)parameters + resolver: (RCTPromiseResolveBlock)resolve + rejecter: (RCTPromiseRejectBlock)reject) { + + NSString *clientToken = parameters[@"clientToken"]; + self.apiClient = [[BTAPIClient alloc] initWithAuthorization: clientToken]; + self.dataCollector = [[BTDataCollector alloc] initWithAPIClient:self.apiClient]; + BTCardClient *cardClient = [[BTCardClient alloc] initWithAPIClient: self.apiClient]; + + BTCard *card = [[BTCard alloc] initWithNumber:parameters[@"number"] + expirationMonth:parameters[@"expirationMonth"] + expirationYear:parameters[@"expirationYear"] + cvv:parameters[@"cvv"]]; + + card.postalCode = parameters[@"postalCode"]; + card.shouldValidate = NO; + + [cardClient tokenizeCard:card completion:^(BTCardNonce * _Nullable tokenizedCard, NSError * _Nullable error) { + if (error) { + reject(@"TOKENIZE_FAILED", error.localizedDescription, nil); + return; + } + [self.dataCollector collectDeviceData:^(NSString * _Nonnull deviceData) { + resolve(@{@"deviceData": deviceData, + @"nonce": tokenizedCard.nonce,}); + }]; + }]; +} + +RCT_EXPORT_METHOD(run3DSecureCheck: (NSDictionary *)parameters + resolver: (RCTPromiseResolveBlock)resolve + rejecter: (RCTPromiseRejectBlock)reject) { + [self startPaymentFlow:parameters + resolver:resolve + rejecter:reject]; +} + +#pragma mark - 3D Secure +- (void)startPaymentFlow: (NSDictionary *)parameters + resolver: (RCTPromiseResolveBlock)resolve + rejecter: (RCTPromiseRejectBlock)reject { + NSString *clientToken = parameters[@"clientToken"]; + self.apiClient = [[BTAPIClient alloc] initWithAuthorization: clientToken]; + self.dataCollector = [[BTDataCollector alloc] initWithAPIClient:self.apiClient]; + + BTThreeDSecureRequest *threeDSecureRequest = [[BTThreeDSecureRequest alloc] init]; + threeDSecureRequest.amount = [NSDecimalNumber decimalNumberWithString: parameters[@"amount"]]; + threeDSecureRequest.nonce = parameters[@"nonce"]; + threeDSecureRequest.threeDSecureRequestDelegate = self; + threeDSecureRequest.email = parameters[@"email"]; + threeDSecureRequest.versionRequested = BTThreeDSecureVersion2; + + BTThreeDSecurePostalAddress *address = [BTThreeDSecurePostalAddress new]; + address.givenName = parameters[@"firstname"]; // ASCII-printable characters required, else will throw a validation error + address.surname = parameters[@"lastname"]; // ASCII-printable characters required, else will throw a validation error + address.phoneNumber = parameters[@"phoneNumber"]; + address.streetAddress = parameters[@"streetAddress"]; + address.extendedAddress = parameters[@"streetAddress2"]; + address.locality = parameters[@"city"]; + address.region = parameters[@"region"]; + address.postalCode = parameters[@"postalCode"]; + address.countryCodeAlpha2 = parameters[@"countryCode"]; + + BTThreeDSecureAdditionalInformation *additionalInformation = [BTThreeDSecureAdditionalInformation new]; + additionalInformation.shippingAddress = address; + + threeDSecureRequest.additionalInformation = additionalInformation; + threeDSecureRequest.billingAddress = address; + + BTPaymentFlowDriver *paymentFlowDriver = [[BTPaymentFlowDriver alloc] initWithAPIClient:self.apiClient]; + paymentFlowDriver.viewControllerPresentingDelegate = self; + + [paymentFlowDriver startPaymentFlow:threeDSecureRequest completion:^(BTPaymentFlowResult * _Nonnull result, NSError * _Nonnull error) { + if (error) { + reject(@"PAYMENT_FAILED", error.localizedDescription, nil); + return; + } + BTThreeDSecureResult *threeDSecureResult = (BTThreeDSecureResult *)result; + if (!threeDSecureResult.tokenizedCard.threeDSecureInfo.liabilityShiftPossible && threeDSecureResult.tokenizedCard.threeDSecureInfo.wasVerified) { + reject(@"3DSECURE_NOT_ABLE_TO_SHIFT_LIABILITY", @"3D Secure liability cannot be shifted", nil); + return; + } + if (!threeDSecureResult.tokenizedCard.threeDSecureInfo.liabilityShifted && threeDSecureResult.tokenizedCard.threeDSecureInfo.wasVerified) { + reject(@"3DSECURE_LIABILITY_NOT_SHIFTED", @"3D Secure liability was not shifted", nil); + } + if (!threeDSecureResult.tokenizedCard.nonce) { + reject(@"PAYMENT_3D_SECURE_FAILED", @"Something went wrong", nil); + return; + } + [self.dataCollector collectDeviceData:^(NSString * _Nonnull deviceData) { + resolve(@{@"deviceData": deviceData, + @"nonce": threeDSecureResult.tokenizedCard.nonce}); + }]; + return; + }]; +} + +#pragma mark - BTViewControllerPresentingDelegate +- (void)paymentDriver:(nonnull id)driver requestsPresentationOfViewController:(nonnull UIViewController *)viewController { + [self.reactRoot presentViewController:viewController animated:YES completion:nil]; +} + +- (void)paymentDriver:(nonnull id)driver requestsDismissalOfViewController:(nonnull UIViewController *)viewController { + [viewController dismissViewControllerAnimated:YES completion:nil]; +} + +#pragma mark - BTThreeDSecureRequestDelegate + +- (void)onLookupComplete:(BTThreeDSecureRequest *)request result:(BTThreeDSecureLookup *)result next:(void (^)(void))next { + next(); +} + +#pragma mark - RootController +- (UIViewController*)reactRoot { + UIViewController *topViewController = [UIApplication sharedApplication].keyWindow.rootViewController; + if (topViewController.presentedViewController) { + topViewController = topViewController.presentedViewController; + } + return topViewController; } @end diff --git a/ios/RNBraintreeApplePay.h b/ios/RNBraintreeApplePay.h new file mode 100644 index 0000000..26b10d8 --- /dev/null +++ b/ios/RNBraintreeApplePay.h @@ -0,0 +1,5 @@ +#import + +@interface RNBraintreeApplePay : NSObject + +@end diff --git a/ios/RNBraintreeApplePay.m b/ios/RNBraintreeApplePay.m new file mode 100644 index 0000000..3753b7f --- /dev/null +++ b/ios/RNBraintreeApplePay.m @@ -0,0 +1,127 @@ +@import PassKit; +#import "RNBraintreeApplePay.h" +#import "BraintreeCore.h" +#import "BTDataCollector.h" +#import "BraintreePaymentFlow.h" +#import "BraintreeApplePay.h" + +@interface RNBraintreeApplePay() + +@property (nonatomic, strong) BTAPIClient *apiClient; +@property (nonatomic, strong) BTDataCollector *dataCollector; +@property (nonatomic, strong) RCTPromiseResolveBlock resolve; +@property (nonatomic, strong) RCTPromiseRejectBlock reject; +@property (nonatomic, assign) BOOL isApplePaymentAuthorized; + +@end + +@implementation RNBraintreeApplePay + +RCT_EXPORT_MODULE() + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(isApplePayAvailable) { + BOOL canMakePayments = [PKPaymentAuthorizationViewController canMakePaymentsUsingNetworks: + @[PKPaymentNetworkVisa, PKPaymentNetworkMasterCard, PKPaymentNetworkAmex, PKPaymentNetworkDiscover]]; + return [NSNumber numberWithBool:canMakePayments]; +} + +RCT_EXPORT_METHOD(runApplePay: (NSDictionary *)options + resolver: (RCTPromiseResolveBlock)resolve + rejecter: (RCTPromiseRejectBlock)reject) { + NSString *companyName = options[@"companyName"]; + NSString *amount = options[@"amount"]; + NSString *clientToken = options[@"clientToken"]; + NSString *currencyCode = options[@"currencyCode"]; + if (!companyName) { + reject(@"NO_COMPANY_NAME", @"You must provide a `companyName`", nil); + return; + } + if (!amount) { + reject(@"NO_TOTAL_PRICE", @"You must provide a `amount`", nil); + return; + } + + self.apiClient = [[BTAPIClient alloc] initWithAuthorization: clientToken]; + self.dataCollector = [[BTDataCollector alloc] initWithAPIClient:self.apiClient]; + + BTApplePayClient *applePayClient = [[BTApplePayClient alloc] initWithAPIClient: self.apiClient]; + + [applePayClient paymentRequest:^(PKPaymentRequest * _Nullable paymentRequest, NSError * _Nullable error) { + if (error) { + reject(@"APPLE_PAY_PAYMENT_REQUEST_FAILED", error.localizedDescription, nil); + return; + } + + if (@available(iOS 11.0, *)) { + paymentRequest.requiredBillingContactFields = [NSSet setWithObject:PKContactFieldPostalAddress]; + } + paymentRequest.merchantCapabilities = PKMerchantCapability3DS; + paymentRequest.paymentSummaryItems = @[ + [PKPaymentSummaryItem summaryItemWithLabel:companyName amount:[NSDecimalNumber decimalNumberWithString:amount]] + ]; + paymentRequest.currencyCode = currencyCode; + self.resolve = resolve; + self.reject = reject; + [self setIsApplePaymentAuthorized:NO]; + PKPaymentAuthorizationViewController *paymentController = [[PKPaymentAuthorizationViewController alloc] initWithPaymentRequest:paymentRequest]; + paymentController.delegate = self; + [[self reactRoot] presentViewController:paymentController animated:YES completion:NULL]; + }]; +} + +- (void)handleTokenizationResult: (BTApplePayCardNonce *)tokenizedApplePayPayment + error: (NSError *)error + completion: (void (^)(PKPaymentAuthorizationStatus))completion{ + if (!tokenizedApplePayPayment) { + self.reject(error.localizedDescription, error.localizedDescription, error); + completion(PKPaymentAuthorizationStatusFailure); + [self resetPaymentResolvers]; + return; + } + [self.dataCollector collectDeviceData:^(NSString * _Nonnull deviceData) { + self.resolve(@{@"deviceData": deviceData, + @"nonce": tokenizedApplePayPayment.nonce}); + completion(PKPaymentAuthorizationStatusSuccess); + [self resetPaymentResolvers]; + }]; +} + +- (void)resetPaymentResolvers { + self.resolve = NULL; + self.reject = NULL; +} + +#pragma mark - PKPaymentAuthorizationViewControllerDelegate +- (void)paymentAuthorizationViewController:(PKPaymentAuthorizationViewController *)controller + didAuthorizePayment:(PKPayment *)payment + completion:(void (^)(PKPaymentAuthorizationStatus))completion { + [self setIsApplePaymentAuthorized: YES]; + BTApplePayClient *applePayClient = [[BTApplePayClient alloc] initWithAPIClient:self.apiClient]; + [applePayClient tokenizeApplePayPayment:payment + completion:^(BTApplePayCardNonce *tokenizedApplePayPayment, NSError *error) { + [self handleTokenizationResult:tokenizedApplePayPayment error:error completion:completion]; + }]; +} + +- (void)paymentAuthorizationViewControllerDidFinish:(nonnull PKPaymentAuthorizationViewController *)controller { + [controller dismissViewControllerAnimated:YES completion:NULL]; + if (self.reject && [self isApplePaymentAuthorized]) { + self.reject(@"APPLE_PAY_FAILED", @"Apple Pay failed", nil); + } + if (self.isApplePaymentAuthorized == NO) { + self.reject(@"USER_CANCELLATION", @"The user cancelled", nil); + } + [self resetPaymentResolvers]; + self.isApplePaymentAuthorized = NULL; +} + +#pragma mark - RootController +- (UIViewController*)reactRoot { + UIViewController *topViewController = [UIApplication sharedApplication].keyWindow.rootViewController; + if (topViewController.presentedViewController) { + topViewController = topViewController.presentedViewController; + } + return topViewController; +} + +@end diff --git a/package.json b/package.json index 706b845..91e06ba 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "react-native-braintree", + "name": "@ekreative/react-native-braintree", "title": "React Native Braintree", "version": "1.0.0", "description": "TODO", @@ -8,6 +8,7 @@ "README.md", "android", "index.js", + "index.d.ts", "ios", "react-native-braintree.podspec" ], diff --git a/react-native-braintree.podspec b/react-native-braintree.podspec index 8432bb0..d54347c 100644 --- a/react-native-braintree.podspec +++ b/react-native-braintree.podspec @@ -24,7 +24,10 @@ Pod::Spec.new do |s| s.requires_arc = true s.dependency "React" - # ... - # s.dependency "..." + s.dependency "Braintree" + s.dependency "Braintree/DataCollector" + s.dependency "Braintree/PaymentFlow" + s.dependency "Braintree/Apple-Pay" + end