diff --git a/INTEGRATION_3.X_EXPO.md b/INTEGRATION_3.X_EXPO.md index 366d372..11766da 100644 --- a/INTEGRATION_3.X_EXPO.md +++ b/INTEGRATION_3.X_EXPO.md @@ -18,6 +18,7 @@ In Your `app.config.ts` or `app.config.json` or `app.config.js` please add expo- pathPrefix: "/braintree-payments" // Optional, // Depending on which payment do you really need in the project initialize only required one initialize3DSecure: "true", + initializeGooglePay: "true", addFallbackUrlScheme: "true", appDelegateLanguage?: "swift"; // Optional if you are still using AppDelegate.mm / AppDelegate.m }, @@ -31,6 +32,7 @@ In Your `app.config.ts` or `app.config.json` or `app.config.js` please add expo- `pathPrefix` - Path prefix, in case of you want to separate path only to handle the context switch (Optional) `initialize3DSecure` - Boolean that determines if 3D Secure is used/needed (Values "true" | "false") +`initializeGooglePay` - Boolean that determines if Google Pay is used/needed (Values "true" | "false") `addFallbackUrlScheme` - Boolean that determines if we should add a scheme for a fallback url used in venmo `appDelegateLanguage` - Indicator that tell's the plugin logic if you are still using Objective C file for AppDelegate (Optional) diff --git a/INTEGRATION_3.X_REACT_NATIVE_CLI.md b/INTEGRATION_3.X_REACT_NATIVE_CLI.md index 876b50c..0e5a975 100644 --- a/INTEGRATION_3.X_REACT_NATIVE_CLI.md +++ b/INTEGRATION_3.X_REACT_NATIVE_CLI.md @@ -118,6 +118,30 @@ override fun onCreate() { --- +### C. For: + +- `requestGooglePayPayment` + +Add the following instead: + +```kotlin +import com.expobraintree.ExpoBraintreeModule + +override fun onCreate() { + ... + ExpoBraintreeModule.initGooglePay(this) + ... +} +``` + +--- + +### D. If you use **all methods** + +You must add **all initialization methods**. + +--- + # 3. Update `build.gradle` If you use **3D Secure**, add the following repository to: diff --git a/README.md b/README.md index e7731e0..0ba28b9 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ A high-performance, native implementation of the [Braintree SDK](https://develop | Package Version | Braintree Android | Braintree iOS | Min Android SDK | Min iOS | | :---------------- | :---------------: | :-----------: | :-------------: | :-----: | +| **3.4.0** | v5.19.0 | v6.41.0 | 23 | 15.1 | | **3.3.0** | v5.19.0 | v6.41.0 | 23 | 15.1 | | **3.2.0 - 3.2.2** | v5.19.0 | v6.41.0 | 23 | 15.1 | | **3.1.0** | v5.9.x | v6.31.0 | 23 | 14.0 | @@ -38,6 +39,15 @@ A high-performance, native implementation of the [Braintree SDK](https://develop --- +### Feature List + +| Package Version | Supported Expo SDK | +| :-------------- | :----------------------- | +| **3.4.0** | Google Pay Feature Added | +| **3.3.0** | 3D Secure Feature Added | + +--- + ## 🛠️ Demos ![iOS](assets/ios_demo_with_3d_secure.gif) @@ -143,6 +153,6 @@ You can find implementation details in the [Example App](example/src/App.tsx) or ## Roadmap - [x] Venmo Integration -- [x] 3D-Secure (Alpha) -- [ ] Apple Pay -- [ ] Google Pay +- [x] 3D-Secure (Implemented in 3.3.0) +- [x] Google Pay (Implemented in 3.4.0) +- [ ] Apple Pay (TBD) diff --git a/android/build.gradle b/android/build.gradle index 9a48582..51b4db2 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -104,4 +104,5 @@ dependencies { implementation 'com.braintreepayments.api:card:5.19.0' implementation 'com.braintreepayments.api:venmo:5.19.0' implementation 'com.braintreepayments.api:three-d-secure:5.19.0' + implementation "com.braintreepayments.api:google-pay:5.19.0" } diff --git a/android/src/main/java/com/expobraintree/BrainTreeEnums.kt b/android/src/main/java/com/expobraintree/BrainTreeEnums.kt index 988bf0b..d0af77b 100644 --- a/android/src/main/java/com/expobraintree/BrainTreeEnums.kt +++ b/android/src/main/java/com/expobraintree/BrainTreeEnums.kt @@ -34,3 +34,8 @@ enum class PAYPAL_ERROR_TYPES(val value: String) { enum class VENMO_ERROR_TYPES(val value: String) { VENMO_DISABLED_IN_CONFIGURATION("VENMO_DISABLED_IN_CONFIGURATION_ERROR") } + +enum class GOOGLE_PAY_ERROR_TYPES(val value: String) { + GOOGLE_PAY_NOT_AVAILABLE("GOOGLE_PAY_NOT_AVAILABLE"), + GOOGLE_PAY_FAILED("GOOGLE_PAY_FAILED") +} \ No newline at end of file diff --git a/android/src/main/java/com/expobraintree/CardDataConverter.kt b/android/src/main/java/com/expobraintree/CardDataConverter.kt index 2238e56..6ec7e61 100644 --- a/android/src/main/java/com/expobraintree/CardDataConverter.kt +++ b/android/src/main/java/com/expobraintree/CardDataConverter.kt @@ -15,14 +15,20 @@ class CardDataConverter { companion object { + /** + * Converts a basic CardNonce into a WritableMap for React Native. + */ fun createTokenizeCardDataNonce(cardNonce: CardNonce): WritableMap { val result: WritableMap = Arguments.createMap() result.putString("nonce", cardNonce.string) + + // Handling unknown card types for consistent JS reporting if (cardNonce.cardType == "Unknown") { result.putString("cardNetwork", "") } else { result.putString("cardNetwork", cardNonce.cardType) } + result.putString("lastFour", cardNonce.lastFour) result.putString("lastTwo", cardNonce.lastTwo) result.putString("expirationMonth", cardNonce.expirationMonth) @@ -32,7 +38,7 @@ class CardDataConverter { /** * Converts the 3D Secure result nonce into a WritableMap to be sent back to JavaScript. - * This includes card details and the critical threeDSecureInfo object. + * Includes liability shift details required for security checks. */ fun createThreeDSecureDataNonce(cardNonce: ThreeDSecureNonce): WritableMap { val result: WritableMap = Arguments.createMap() @@ -53,18 +59,20 @@ class CardDataConverter { val infoMap: WritableMap = Arguments.createMap() val info = cardNonce.threeDSecureInfo - if (info != null) { - infoMap.putBoolean("liabilityShifted", info.liabilityShifted) - infoMap.putBoolean("liabilityShiftPossible", info.liabilityShiftPossible) - infoMap.putString("status", info.status) - infoMap.putBoolean("wasVerified", info.wasVerified) - } + // REMOVED: if (info != null) check because threeDSecureInfo is NonNull in SDK v5+ + infoMap.putBoolean("liabilityShifted", info.liabilityShifted) + infoMap.putBoolean("liabilityShiftPossible", info.liabilityShiftPossible) + infoMap.putString("status", info.status) + infoMap.putBoolean("wasVerified", info.wasVerified) result.putMap("threeDSecureInfo", infoMap) return result } + /** + * Creates a Card object from JS options for basic tokenization. + */ fun createTokenizeCardRequest(options: ReadableMap): Card { val card: Card = Card() if (options.hasKey("number")) { @@ -85,10 +93,14 @@ class CardDataConverter { return card } + /** + * Maps JS options to a ThreeDSecureRequest. + * Includes address mapping to satisfy 3DS 2.0 risk assessment requirements. + */ fun create3DSecureRequest(options: ReadableMap): ThreeDSecureRequest { val address = ThreeDSecurePostalAddress() - // Map personal names - Note: Braintree v6 uses givenName and surname + // Personal details mapping if (options.hasKey("givenName")) { address.givenName = options.getString("givenName") } @@ -96,18 +108,15 @@ class CardDataConverter { address.surname = options.getString("surName") } - // Map contact details if (options.hasKey("phoneNumber")) { address.phoneNumber = options.getString("phoneNumber") } - // CRITICAL: countryCodeAlpha2 MUST be provided if 'region' is present. - // This prevents the "The region cannot be provided without a corresponding country code" (422) error. + // Required by Braintree to avoid 422 errors if region is provided if (options.hasKey("countryCodeAlpha2")) { address.countryCodeAlpha2 = options.getString("countryCodeAlpha2") } - // Map geographic location details if (options.hasKey("city")) { address.locality = options.getString("city") } @@ -124,21 +133,18 @@ class CardDataConverter { address.extendedAddress = options.getString("streetAddress2") } - // 3D Secure 2.0 requires additional info for better risk assessment val additionalInformation = ThreeDSecureAdditionalInformation() additionalInformation.shippingAddress = address val threeDSecureRequest = ThreeDSecureRequest() - // Use elvis operator to ensure non-null values for the SDK threeDSecureRequest.nonce = options.getString("nonce") ?: "" threeDSecureRequest.email = options.getString("email") ?: "" threeDSecureRequest.amount = options.getString("amount") ?: "0.00" - // Attach the objects to the main request threeDSecureRequest.billingAddress = address threeDSecureRequest.additionalInformation = additionalInformation return threeDSecureRequest } } -} +} \ No newline at end of file diff --git a/android/src/main/java/com/expobraintree/ExpoBraintreeModule.kt b/android/src/main/java/com/expobraintree/ExpoBraintreeModule.kt index bc1f12c..b7bb553 100644 --- a/android/src/main/java/com/expobraintree/ExpoBraintreeModule.kt +++ b/android/src/main/java/com/expobraintree/ExpoBraintreeModule.kt @@ -9,6 +9,7 @@ import com.braintreepayments.api.card.CardClient import com.braintreepayments.api.card.CardResult import com.braintreepayments.api.datacollector.DataCollector import com.braintreepayments.api.datacollector.DataCollectorResult +import com.braintreepayments.api.googlepay.* import com.braintreepayments.api.paypal.* import com.braintreepayments.api.threedsecure.* import com.braintreepayments.api.venmo.* @@ -33,12 +34,15 @@ class ExpoBraintreeModule(reactContext: ReactApplicationContext) : lateinit var payPalLauncher: PayPalLauncher lateinit var venmoLauncher: VenmoLauncher lateinit var threeDSecureLauncher: ThreeDSecureLauncher + lateinit var googlePayLauncher: GooglePayLauncher + private val moduleHandlers: ExpoBraintreeModuleHandlers = ExpoBraintreeModuleHandlers() private var threeDSecureClientRefInstance: ThreeDSecureClient? = null private var promiseRefInstance: Promise? = null private var payPalClientRefInstance: PayPalClient? = null private var venmoClientRefInstance: VenmoClient? = null + private var googlePayClientRefInstance: GooglePayClient? = null fun init() { initPayPal() @@ -48,9 +52,6 @@ class ExpoBraintreeModule(reactContext: ReactApplicationContext) : fun initPayPal() { payPalLauncher = PayPalLauncher() } fun initVenmo() { venmoLauncher = VenmoLauncher() } - /** - * Initializes the 3D Secure Launcher and registers the callback for verification results. - */ fun initThreeDSecure(activity: FragmentActivity) { threeDSecureLauncher = ThreeDSecureLauncher(activity) { paymentAuthResult -> val client = threeDSecureClientRefInstance @@ -60,13 +61,9 @@ class ExpoBraintreeModule(reactContext: ReactApplicationContext) : client.tokenize(paymentAuthResult) { result -> when (result) { is ThreeDSecureResult.Success -> { - val info = result.nonce.threeDSecureInfo - - // Strict Security: Check if liability shift occurred - if (info.liabilityShifted) { + if (result.nonce.threeDSecureInfo.liabilityShifted) { moduleHandlers.onThreeDSecureSuccessHandler(result.nonce, promise) } else { - // Reject if bank did not take responsibility promise.reject( EXCEPTION_TYPES.TOKENIZE_EXCEPTION.value, THREE_D_SECURE_ERROR_TYPES.D_SECURE_LIABILITY_NOT_SHIFTED.value @@ -74,18 +71,10 @@ class ExpoBraintreeModule(reactContext: ReactApplicationContext) : } } is ThreeDSecureResult.Failure -> { - val msg = result.error.message ?: "Unknown error" - promise.reject( - EXCEPTION_TYPES.TOKENIZE_EXCEPTION.value, - msg, - result.error - ) + promise.reject(EXCEPTION_TYPES.TOKENIZE_EXCEPTION.value, result.error.message ?: "Unknown error") } is ThreeDSecureResult.Cancel -> { - promise.reject( - EXCEPTION_TYPES.USER_CANCEL_EXCEPTION.value, - ERROR_TYPES.USER_CANCEL_TRANSACTION_ERROR.value - ) + promise.reject(EXCEPTION_TYPES.USER_CANCEL_EXCEPTION.value, ERROR_TYPES.USER_CANCEL_TRANSACTION_ERROR.value) } } clearStaticReferences() @@ -94,14 +83,47 @@ class ExpoBraintreeModule(reactContext: ReactApplicationContext) : } } - /** - * Clears all temporary references to prevent memory leaks and session conflicts. - */ + fun initGooglePay(activity: FragmentActivity) { + googlePayLauncher = GooglePayLauncher(activity) { paymentAuthResult -> + val promise = promiseRefInstance ?: return@GooglePayLauncher + val client = googlePayClientRefInstance ?: return@GooglePayLauncher + + client.tokenize(paymentAuthResult) { result -> + when (result) { + is GooglePayResult.Success -> { + // Rzutowanie na GooglePayCardNonce, aby pasowało do handlera + val cardNonce = result.nonce as? GooglePayCardNonce + if (cardNonce != null) { + moduleHandlers.onGooglePaySuccessHandler(cardNonce, promise) + } else { + // Jeśli to inny typ nonca (np. PayPal), wysyłamy ogólnie + promise.resolve(result.nonce.string) + } + } + is GooglePayResult.Failure -> { + promise.reject( + GOOGLE_PAY_ERROR_TYPES.GOOGLE_PAY_FAILED.value, + result.error.message ?: "Google Pay Failed" + ) + } + is GooglePayResult.Cancel -> { + promise.reject( + EXCEPTION_TYPES.USER_CANCEL_EXCEPTION.value, + "User cancelled Google Pay" + ) + } + } + clearStaticReferences() + } + } + } + fun clearStaticReferences() { threeDSecureClientRefInstance = null promiseRefInstance = null payPalClientRefInstance = null venmoClientRefInstance = null + googlePayClientRefInstance = null } } @@ -115,30 +137,57 @@ class ExpoBraintreeModule(reactContext: ReactApplicationContext) : return hasPayPal || hasVenmo } + // --- Google Pay Implementation --- @ReactMethod - fun request3DSecurePaymentCheck(data: ReadableMap, localPromise: Promise) { + fun requestGooglePayPayment(data: ReadableMap, localPromise: Promise) { val activity = fragmentActivity ?: return rejectNoActivity(localPromise) - clearStaticReferences() promiseRefInstance = localPromise try { - val clientToken = data.getString("clientToken") ?: "" - val client = ThreeDSecureClient(activity, clientToken) - threeDSecureClientRefInstance = client + val googlePayClient = GooglePayClient(activity, data.getString("clientToken") ?: "") + googlePayClientRefInstance = googlePayClient + googlePayClient.isReadyToPay(activity) { readinessResult -> + if (readinessResult is GooglePayReadinessResult.ReadyToPay) { + val request = GooglePayDataConverter.createPaymentRequest(data) + googlePayClient.createPaymentAuthRequest(request) { authRequest -> + when (authRequest) { + is GooglePayPaymentAuthRequest.ReadyToLaunch -> googlePayLauncher.launch(authRequest) + is GooglePayPaymentAuthRequest.Failure -> { + localPromise.reject( + GOOGLE_PAY_ERROR_TYPES.GOOGLE_PAY_FAILED.value, + authRequest.error.message + ) + clearStaticReferences() + } + } + } + } else { + localPromise.reject( + GOOGLE_PAY_ERROR_TYPES.GOOGLE_PAY_NOT_AVAILABLE.value, + "Google Pay not available" + ) + clearStaticReferences() + } + } + } catch (ex: Exception) { handleKotlinException(localPromise, ex) } + } + + @ReactMethod + fun request3DSecurePaymentCheck(data: ReadableMap, localPromise: Promise) { + val activity = fragmentActivity ?: return rejectNoActivity(localPromise) + clearStaticReferences() + promiseRefInstance = localPromise + try { + val client = ThreeDSecureClient(activity, data.getString("clientToken") ?: "") + threeDSecureClientRefInstance = client val request = CardDataConverter.create3DSecureRequest(data) - client.createPaymentAuthRequest(activity, request) { response -> when (response) { - is ThreeDSecurePaymentAuthRequest.ReadyToLaunch -> { - threeDSecureLauncher.launch(response) - } + is ThreeDSecurePaymentAuthRequest.ReadyToLaunch -> threeDSecureLauncher.launch(response) is ThreeDSecurePaymentAuthRequest.LaunchNotRequired -> { - // Even in frictionless flow, we must verify liability shift - val info = response.nonce.threeDSecureInfo - - if (info.liabilityShifted) { + if (response.nonce.threeDSecureInfo.liabilityShifted) { moduleHandlers.onThreeDSecureSuccessHandler(response.nonce, localPromise) } else { localPromise.reject( @@ -149,17 +198,12 @@ class ExpoBraintreeModule(reactContext: ReactApplicationContext) : clearStaticReferences() } is ThreeDSecurePaymentAuthRequest.Failure -> { - localPromise.reject( - EXCEPTION_TYPES.TOKENIZE_EXCEPTION.value, - response.error.message ?: THREE_D_SECURE_ERROR_TYPES.PAYMENT_3D_SECURE_FAILED.value - ) + localPromise.reject(EXCEPTION_TYPES.TOKENIZE_EXCEPTION.value, response.error.message ?: "3DS Failed") clearStaticReferences() } } } - } catch (ex: Exception) { - handleKotlinException(localPromise, ex) - } + } catch (ex: Exception) { handleKotlinException(localPromise, ex) } } @ReactMethod @@ -225,13 +269,12 @@ class ExpoBraintreeModule(reactContext: ReactApplicationContext) : clearStaticReferences() promiseRefInstance = localPromise try { - val clientToken = data.getString("clientToken") ?: "" - val cardClient = CardClient(reactContextRef, clientToken) + val cardClient = CardClient(reactContextRef, data.getString("clientToken") ?: "") val cardRequest = CardDataConverter.createTokenizeCardRequest(data) - cardClient.tokenize(cardRequest) { cardResult -> - when (cardResult) { - is CardResult.Success -> moduleHandlers.onCardTokenizeSuccessHandler(cardResult.nonce, localPromise) - is CardResult.Failure -> moduleHandlers.onCardTokenizeFailure(cardResult.error, localPromise) + cardClient.tokenize(cardRequest) { result -> + when (result) { + is CardResult.Success -> moduleHandlers.onCardTokenizeSuccessHandler(result.nonce, localPromise) + is CardResult.Failure -> moduleHandlers.onCardTokenizeFailure(result.error, localPromise) } clearStaticReferences() } diff --git a/android/src/main/java/com/expobraintree/ExpoBraintreeModuleHandlers.kt b/android/src/main/java/com/expobraintree/ExpoBraintreeModuleHandlers.kt index 403df2d..d23107f 100644 --- a/android/src/main/java/com/expobraintree/ExpoBraintreeModuleHandlers.kt +++ b/android/src/main/java/com/expobraintree/ExpoBraintreeModuleHandlers.kt @@ -5,9 +5,11 @@ import com.braintreepayments.api.core.UserCanceledException import com.braintreepayments.api.paypal.PayPalAccountNonce import com.braintreepayments.api.threedsecure.ThreeDSecureNonce import com.braintreepayments.api.venmo.VenmoAccountNonce +import com.braintreepayments.api.googlepay.GooglePayCardNonce import com.facebook.react.bridge.WritableMap import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.Arguments class ExpoBraintreeModuleHandlers { @@ -141,4 +143,32 @@ class ExpoBraintreeModuleHandlers { mPromise.reject(EXCEPTION_TYPES.TOKENIZE_EXCEPTION.value, e.message, e) } } + + fun onGooglePaySuccessHandler(nonce: GooglePayCardNonce, promise: Promise) { + val result: WritableMap = Arguments.createMap() + + // Base nonce information + result.putString("nonce", nonce.string) + result.putString("type", "GooglePayCard") + + // Card details mapping + val details: WritableMap = Arguments.createMap() + details.putString("cardType", nonce.cardType) + details.putString("lastFour", nonce.lastFour) + details.putString("lastTwo", nonce.lastTwo) + result.putMap("details", details) + + // Billing Address mapping (optional) + // Only populates if billingAddressRequired was true and user provided it + nonce.billingAddress?.let { address -> + val billingMap: WritableMap = Arguments.createMap() + billingMap.putString("recipientName", address.recipientName) + billingMap.putString("streetAddress", address.streetAddress) + billingMap.putString("locality", address.locality) // City + billingMap.putString("countryCodeAlpha2", address.countryCodeAlpha2) + result.putMap("billingAddress", billingMap) + } + + promise.resolve(result) + } } diff --git a/android/src/main/java/com/expobraintree/GooglePayDataConverter.kt b/android/src/main/java/com/expobraintree/GooglePayDataConverter.kt new file mode 100644 index 0000000..13baf01 --- /dev/null +++ b/android/src/main/java/com/expobraintree/GooglePayDataConverter.kt @@ -0,0 +1,57 @@ +package com.expobraintree + +import com.braintreepayments.api.googlepay.GooglePayRequest +import com.braintreepayments.api.googlepay.GooglePayTotalPriceStatus +import com.facebook.react.bridge.ReadableMap + +class GooglePayDataConverter { + companion object { + /** + * Maps all possible GooglePayRequest options from React Native to the Braintree SDK. + * Based on: https://braintree.github.io/braintree_android/GooglePay/com.braintreepayments.api/-google-pay-request/index.html + */ + fun createPaymentRequest(data: ReadableMap): GooglePayRequest { + val totalPrice = data.getString("totalPrice") ?: "0.00" + val currencyCode = data.getString("currencyCode") ?: "USD" + + // Map price status: 1 -> ESTIMATED, else -> FINAL + val statusInt = if (data.hasKey("totalPriceStatus")) data.getInt("totalPriceStatus") else 3 + val totalPriceStatus = when (statusInt) { + 1 -> GooglePayTotalPriceStatus.TOTAL_PRICE_STATUS_ESTIMATED + else -> GooglePayTotalPriceStatus.TOTAL_PRICE_STATUS_FINAL + } + + val request = GooglePayRequest( + currencyCode, + totalPrice, + totalPriceStatus + ) + + if (data.hasKey("googleMerchantName")) { + request.googleMerchantName = data.getString("googleMerchantName") + } + // Requirements + if (data.hasKey("billingAddressRequired")) { + request.isBillingAddressRequired = data.getBoolean("billingAddressRequired") + } + if (data.hasKey("emailRequired")) { + request.isEmailRequired = data.getBoolean("emailRequired") + } + if (data.hasKey("phoneNumberRequired")) { + request.isPhoneNumberRequired = data.getBoolean("phoneNumberRequired") + } + if (data.hasKey("shippingAddressRequired")) { + request.isShippingAddressRequired = data.getBoolean("shippingAddressRequired") + } + + // Card Restrictions + if (data.hasKey("allowPrepaidCards")) { + request.allowPrepaidCards = data.getBoolean("allowPrepaidCards") + } + if (data.hasKey("allowCreditCards")) { + request.allowCreditCards = data.getBoolean("allowCreditCards") + } + return request + } + } +} \ No newline at end of file diff --git a/assets/android_demo_with_3ds_secure.gif b/assets/android_demo_with_3ds_secure.gif index e209529..cbadde9 100644 Binary files a/assets/android_demo_with_3ds_secure.gif and b/assets/android_demo_with_3ds_secure.gif differ diff --git a/example-expo-53/App.tsx b/example-expo-53/App.tsx index 152b8ff..0771c6e 100644 --- a/example-expo-53/App.tsx +++ b/example-expo-53/App.tsx @@ -8,6 +8,7 @@ import { View, SafeAreaView, TextInput, + Platform, } from 'react-native'; import { getDeviceDataFromDataCollector, @@ -15,6 +16,8 @@ import { requestOneTimePayment, tokenizeCardData, request3DSecurePaymentCheck, + requestGooglePayPayment, + GOOGLE_PAY_TOTAL_PRICE_STATUS, type ThreeDSecureCheckOptions, } from 'react-native-expo-braintree'; import { LogView, type LogState } from './LogView'; @@ -28,9 +31,7 @@ const T3DS_SCENARIOS = [ { label: '❌ 3DS Failed (Frictionless)', number: '4000000000002925' }, ]; -// --- MAIN APP --- export default function App() { - // Separate states for each section's logs const [log1, setLog1] = React.useState({ loading: false, result: null, @@ -46,6 +47,11 @@ export default function App() { result: null, error: null, }); + const [logGP, setLogGP] = React.useState({ + loading: false, + result: null, + error: null, + }); const [dynamic3DSToken, setDynamic3DSToken] = React.useState(''); @@ -82,7 +88,6 @@ export default function App() { return; } await exec(setLog3, `3DS-${cardNumber.slice(-4)}`, async () => { - // KROK 1: Tokenizacja const tokenized = await tokenizeCardData({ clientToken: dynamic3DSToken.trim(), number: cardNumber, @@ -96,7 +101,6 @@ export default function App() { clientToken: dynamic3DSToken.trim(), amount: '10.00', nonce: tokenized.nonce, - // Dane wymagane do poprawnej walidacji 3DS 2.0 email: 'jill.doe@example.com', givenName: 'Jill', surName: 'Doe', @@ -161,7 +165,7 @@ export default function App() { requestBillingAgreement({ clientToken, merchantAppLink, - billingAgreementDescription: 'Test', + billingAgreementDescription: 'Test Recurring Payment', }) ) } @@ -233,6 +237,39 @@ export default function App() { } /> + + {/* SECTION 4: GOOGLE PAY */} + {Platform.OS === 'android' && ( + + 4. Google Pay + + exec(setLogGP, 'GooglePay', () => + requestGooglePayPayment({ + clientToken, + totalPrice: '199.00', + currencyCode: 'USD', + totalPriceStatus: GOOGLE_PAY_TOTAL_PRICE_STATUS.FINAL, + billingAddressRequired: true, + shippingAddressRequired: true, + emailRequired: true, + allowPrepaidCards: false, + allowCreditCards: true, + }) + ) + } + > + Launch Google Pay + + + setLogGP({ loading: false, result: null, error: null }) + } + /> + + )} ); @@ -288,4 +325,13 @@ const styles = StyleSheet.create({ }, buttonDisabled: { backgroundColor: '#ccc' }, buttonText: { color: 'white', fontWeight: 'bold', fontSize: 13 }, + buttonGooglePay: { + padding: 12, + backgroundColor: '#000', + borderRadius: 6, + marginBottom: 6, + alignItems: 'center', + borderWidth: 1, + borderColor: '#555', + }, }); diff --git a/example-expo-53/app.json b/example-expo-53/app.json index 28ca6a3..54a98d6 100644 --- a/example-expo-53/app.json +++ b/example-expo-53/app.json @@ -33,6 +33,7 @@ { "host": "braintree-example-app.web.app", "initialize3DSecure": "true", + "initializeGooglePay": "true", "addFallbackUrlScheme": "true" } ] diff --git a/example-expo-53/package-lock.json b/example-expo-53/package-lock.json index 7814f7c..b3bf4bd 100644 --- a/example-expo-53/package-lock.json +++ b/example-expo-53/package-lock.json @@ -13,7 +13,7 @@ "expo-status-bar": "~2.2.3", "react": "19.0.0", "react-native": "0.79.6", - "react-native-expo-braintree": "3.3.0-alpha.3" + "react-native-expo-braintree": "3.4.0-alpha.1" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -6629,9 +6629,9 @@ } }, "node_modules/react-native-expo-braintree": { - "version": "3.3.0-alpha.3", - "resolved": "https://registry.npmjs.org/react-native-expo-braintree/-/react-native-expo-braintree-3.3.0-alpha.3.tgz", - "integrity": "sha512-MW+fspImIWZaxYK8Rp40IGi17cWbOuE8VPw4dXsGVwuXQcogbUJVPVhSsH2Oq0Z7RbNJAXzCUHULgRzdP9Sgqg==", + "version": "3.4.0-alpha.1", + "resolved": "https://registry.npmjs.org/react-native-expo-braintree/-/react-native-expo-braintree-3.4.0-alpha.1.tgz", + "integrity": "sha512-QmzBUsdkS5kgSMDZAc49HmWSnH0h132uw5xBoX6gcg3dU1pkxfr9pW/9GrG9OryBh4axh3fVLY9xynRzNi0ocg==", "license": "MIT", "workspaces": [ "example" diff --git a/example-expo-53/package.json b/example-expo-53/package.json index 444be77..2f48a19 100644 --- a/example-expo-53/package.json +++ b/example-expo-53/package.json @@ -13,7 +13,7 @@ "expo-status-bar": "~2.2.3", "react": "19.0.0", "react-native": "0.79.6", - "react-native-expo-braintree": "3.3.0-alpha.3" + "react-native-expo-braintree": "3.4.0-alpha.1" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 69c2e25..c6464ac 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -33,6 +33,5 @@ - \ No newline at end of file diff --git a/example/android/app/src/main/java/com/expobraintreeexample/MainActivity.kt b/example/android/app/src/main/java/com/expobraintreeexample/MainActivity.kt index 458b07b..8f1dcd9 100644 --- a/example/android/app/src/main/java/com/expobraintreeexample/MainActivity.kt +++ b/example/android/app/src/main/java/com/expobraintreeexample/MainActivity.kt @@ -14,6 +14,7 @@ class MainActivity : ReactActivity() { super.onCreate(null) ExpoBraintreeModule.init() ExpoBraintreeModule.initThreeDSecure(this) + ExpoBraintreeModule.initGooglePay(this) } /** diff --git a/example/src/App.tsx b/example/src/App.tsx index a8c1c59..ddd3c32 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -7,6 +7,7 @@ import { View, SafeAreaView, TextInput, + Platform, } from 'react-native'; import { getDeviceDataFromDataCollector, @@ -14,6 +15,8 @@ import { requestOneTimePayment, tokenizeCardData, request3DSecurePaymentCheck, + requestGooglePayPayment, + GOOGLE_PAY_TOTAL_PRICE_STATUS, type ThreeDSecureCheckOptions, } from 'react-native-expo-braintree'; import { LogView, type LogState } from './LogView'; @@ -27,9 +30,7 @@ const T3DS_SCENARIOS = [ { label: '❌ 3DS Failed (Frictionless)', number: '4000000000002925' }, ]; -// --- MAIN APP --- export default function App() { - // Separate states for each section's logs const [log1, setLog1] = React.useState({ loading: false, result: null, @@ -45,6 +46,11 @@ export default function App() { result: null, error: null, }); + const [logGP, setLogGP] = React.useState({ + loading: false, + result: null, + error: null, + }); const [dynamic3DSToken, setDynamic3DSToken] = React.useState(''); @@ -81,7 +87,6 @@ export default function App() { return; } await exec(setLog3, `3DS-${cardNumber.slice(-4)}`, async () => { - // KROK 1: Tokenizacja const tokenized = await tokenizeCardData({ clientToken: dynamic3DSToken.trim(), number: cardNumber, @@ -95,7 +100,6 @@ export default function App() { clientToken: dynamic3DSToken.trim(), amount: '10.00', nonce: tokenized.nonce, - // Dane wymagane do poprawnej walidacji 3DS 2.0 email: 'jill.doe@example.com', givenName: 'Jill', surName: 'Doe', @@ -159,7 +163,7 @@ export default function App() { requestBillingAgreement({ clientToken, merchantAppLink, - billingAgreementDescription: 'Test', + billingAgreementDescription: 'Test Recurring Payment', }) ) } @@ -231,6 +235,39 @@ export default function App() { } /> + + {/* SECTION 4: GOOGLE PAY */} + {Platform.OS === 'android' && ( + + 4. Google Pay + + exec(setLogGP, 'GooglePay', () => + requestGooglePayPayment({ + clientToken, + totalPrice: '199.00', + currencyCode: 'USD', + totalPriceStatus: GOOGLE_PAY_TOTAL_PRICE_STATUS.FINAL, + billingAddressRequired: true, + shippingAddressRequired: true, + emailRequired: true, + allowPrepaidCards: false, + allowCreditCards: true, + }) + ) + } + > + Launch Google Pay + + + setLogGP({ loading: false, result: null, error: null }) + } + /> + + )} ); @@ -286,4 +323,13 @@ const styles = StyleSheet.create({ }, buttonDisabled: { backgroundColor: '#ccc' }, buttonText: { color: 'white', fontWeight: 'bold', fontSize: 13 }, + buttonGooglePay: { + padding: 12, + backgroundColor: '#000', + borderRadius: 6, + marginBottom: 6, + alignItems: 'center', + borderWidth: 1, + borderColor: '#555', + }, }); diff --git a/src/index.tsx b/src/index.tsx index a9028a9..53fc3a6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -13,6 +13,9 @@ import type { BTCardTokenization3DSNonceResult, ThreeDSecureCheckOptions, BTThreeDError, + RequestGooglePayOptions, + BTGooglePayNonceResult, + BTGooglePayError, } from './types'; const LINKING_ERROR = @@ -109,5 +112,19 @@ export async function request3DSecurePaymentCheck( return ex as BTThreeDError; } } +export async function requestGooglePayPayment( + options: RequestGooglePayOptions +): Promise { + try { + if (Platform.OS !== 'android') { + throw new Error('Google Pay is only supported on Android.'); + } + const result: BTGooglePayNonceResult = + await ExpoBraintree.requestGooglePayPayment(options); + return result; + } catch (ex: unknown) { + return ex as BTGooglePayError; + } +} export * from './types'; diff --git a/src/plugin/withExpoBraintree.android.ts b/src/plugin/withExpoBraintree.android.ts index 3c1ab63..a5026b6 100644 --- a/src/plugin/withExpoBraintree.android.ts +++ b/src/plugin/withExpoBraintree.android.ts @@ -22,6 +22,7 @@ interface WithExpoBraintreeAndroidProps { host: string; pathPrefix?: string; initialize3DSecure?: 'true' | 'false'; + initializeGooglePay?: 'true' | 'false'; addFallbackUrlScheme?: 'true' | 'false'; } @@ -31,7 +32,13 @@ export const withExpoBraintreeAndroid: ConfigPlugin< WithExpoBraintreeAndroidProps > = ( expoConfig, - { host, pathPrefix, addFallbackUrlScheme, initialize3DSecure } + { + host, + pathPrefix, + addFallbackUrlScheme, + initialize3DSecure, + initializeGooglePay, + } ) => { let newConfig = withAndroidManifest(expoConfig, (config) => { config.modResults = addBraintreeLinks( @@ -61,6 +68,12 @@ export const withExpoBraintreeAndroid: ConfigPlugin< ` ExpoBraintreeModule.initThreeDSecure(this)${language === 'java' ? ';' : ''}` ); } + + if (initializeGooglePay === 'true') { + newSrc.push( + ` ExpoBraintreeModule.initGooglePay(this)${language === 'java' ? ';' : ''}` + ); + } const withInit = mergeContents({ src: withImports, comment: ' // add BraintreeModule import', diff --git a/src/plugin/withExpoBraintree.ts b/src/plugin/withExpoBraintree.ts index 224b0e3..0f7a426 100644 --- a/src/plugin/withExpoBraintree.ts +++ b/src/plugin/withExpoBraintree.ts @@ -55,6 +55,10 @@ export type ExpoBraintreePluginProps = { * */ initialize3DSecure?: 'true' | 'false'; + /** + * Flag that determines if we should initialize Google Pay + */ + initializeGooglePay?: 'true' | 'false'; }; export const withExpoBraintreePlugin: ConfigPlugin = ( diff --git a/src/types.ts b/src/types.ts index 0e95ee6..1508c03 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ export enum EXCEPTION_TYPES { - SWIFT_EXCEPTION = 'ExpoBraintree:`SwiftException', + KOTLIN_EXCEPTION = 'ExpoBraintree:`KotlinException', USER_CANCEL_EXCEPTION = 'ExpoBraintree:`UserCancelException', TOKENIZE_EXCEPTION = 'ExpoBraintree:`TokenizeException', } @@ -12,6 +12,11 @@ export enum VENMO_EXCEPTION_TYPES { VENMO_DISABLED_IN_CONFIGURATION = 'ExpoBraintree:`VENMO disabled in configuration', } +export enum GOOGLE_PAY_ERROR_TYPES { + GOOGLE_PAY_NOT_AVAILABLE = 'GOOGLE_PAY_NOT_AVAILABLE', + GOOGLE_PAY_FAILED = 'GOOGLE_PAY_FAILED', +} + export enum ERROR_TYPES { API_CLIENT_INITIALIZATION_ERROR = 'API_CLIENT_INITIALIZATION_ERROR', TOKENIZE_VAULT_PAYMENT_ERROR = 'TOKENIZE_VAULT_PAYMENT_ERROR', @@ -212,3 +217,45 @@ export type ThreeDSecureCheckOptions = { region?: string; countryCodeAlpha2?: string; }; + +export enum GOOGLE_PAY_TOTAL_PRICE_STATUS { + /** The total price is an estimated price and might still change (maps to 1 in Kotlin) */ + ESTIMATED = 1, + /** The total price is the final price and will not change (maps to 3/else in Kotlin) */ + FINAL = 3, +} + +export type RequestGooglePayOptions = { + clientToken: string; + totalPrice: string; + currencyCode: string; + totalPriceStatus?: GOOGLE_PAY_TOTAL_PRICE_STATUS; + googleMerchantName?: string; + billingAddressRequired?: boolean; + emailRequired?: boolean; + phoneNumberRequired?: boolean; + shippingAddressRequired?: boolean; + allowPrepaidCards?: boolean; +}; + +export type BTGooglePayNonceResult = { + nonce: string; + type: 'GooglePayCard'; + description: string; + details: { + cardType: string; + lastFour: string; + lastTwo: string; + }; + billingAddress?: { + recipientName?: string; + streetAddress?: string; + locality?: string; + countryCodeAlpha2?: string; + }; +}; + +export type BTGooglePayError = { + code?: EXCEPTION_TYPES | GOOGLE_PAY_ERROR_TYPES; + message?: string; +};