diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000000..c0077d3ce0 --- /dev/null +++ b/.npmignore @@ -0,0 +1,24 @@ +example/ +e2e-tests/ +docs/ +scripts/ +.tmp/ +.github/ +lib/typescript/example/ +**/__tests__ +**/__fixtures__ +**/__mocks__ + +android/src/androidTest/ +android/src/test/ +android/build/ +android/bin/ + +ios/Tests/ + +yarn-error.log +CODEOWNERS +CONTRIBUTING.md +babel.config.js +tsconfig.json +yarn.lock diff --git a/android/build.gradle b/android/build.gradle index fbf6bed308..3386743035 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -44,6 +44,11 @@ def isNewArchitectureEnabled() { return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true" } +def isOnrampIncluded() { + return rootProject.ext.has("StripeSdk_includeOnramp") && + rootProject.ext.StripeSdk_includeOnramp.toString().toLowerCase() == "true" +} + def reactNativeArchitectures() { def value = project.getProperties().get("reactNativeArchitectures") return value ? value.split(",") : [ @@ -98,6 +103,7 @@ android { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles 'proguard-rules.txt' buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() + buildConfigField "boolean", "IS_ONRAMP_INCLUDED", isOnrampIncluded().toString() ndk { abiFilters(*reactNativeArchitectures()) @@ -129,6 +135,9 @@ android { if (!isNewArchitectureEnabled()) { srcDirs += ["src/oldarch/java"] } + if (isOnrampIncluded()) { + srcDirs += ["src/onramp/java"] + } } } } @@ -214,6 +223,11 @@ dependencies { implementation("com.stripe:stripe-android:$stripe_version") { exclude group: 'androidx.emoji2', module: 'emoji2' } + if (isOnrampIncluded()) { + implementation("com.stripe:crypto-onramp:$stripe_version") { + exclude group: 'androidx.emoji2', module: 'emoji2' + } + } implementation("com.stripe:financial-connections:$stripe_version") { exclude group: 'androidx.emoji2', module: 'emoji2' } diff --git a/android/gradle.properties b/android/gradle.properties index 0d05e3d4db..aba0c88629 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -3,4 +3,4 @@ StripeSdk_compileSdkVersion=30 StripeSdk_targetSdkVersion=28 StripeSdk_minSdkVersion=21 # Keep StripeSdk_stripeVersion in sync with https://github.com/stripe/stripe-identity-react-native/blob/main/android/gradle.properties -StripeSdk_stripeVersion=21.23.+ +StripeSdk_stripeVersion=21.27.0 diff --git a/android/src/main/java/com/reactnativestripesdk/FakeOnrampSdkModule.kt b/android/src/main/java/com/reactnativestripesdk/FakeOnrampSdkModule.kt new file mode 100644 index 0000000000..b44b6362b7 --- /dev/null +++ b/android/src/main/java/com/reactnativestripesdk/FakeOnrampSdkModule.kt @@ -0,0 +1,138 @@ +package com.reactnativestripesdk + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.module.annotations.ReactModule +import com.reactnativestripesdk.utils.createFailedError + +@ReactModule(name = NativeOnrampSdkModuleSpec.NAME) +class FakeOnrampSdkModule( + reactContext: ReactApplicationContext, +) : NativeOnrampSdkModuleSpec(reactContext) { + @ReactMethod + override fun initialise( + params: ReadableMap?, + promise: Promise?, + ) { + promise?.resolve(null) + } + + @ReactMethod + override fun configureOnramp( + config: ReadableMap?, + promise: Promise?, + ) { + promise?.resolveNotImplemented() + } + + @ReactMethod + override fun hasLinkAccount( + email: String?, + promise: Promise?, + ) { + promise?.resolveNotImplemented() + } + + @ReactMethod + override fun registerLinkUser( + info: ReadableMap?, + promise: Promise?, + ) { + promise?.resolveNotImplemented() + } + + @ReactMethod + override fun registerWalletAddress( + walletAddress: String?, + network: String?, + promise: Promise?, + ) { + promise?.resolveNotImplemented() + } + + @ReactMethod + override fun attachKycInfo( + kycInfo: ReadableMap?, + promise: Promise?, + ) { + promise?.resolveNotImplemented() + } + + @ReactMethod + override fun updatePhoneNumber( + phone: String?, + promise: Promise?, + ) { + promise?.resolveNotImplemented() + } + + @ReactMethod + override fun authenticateUser(promise: Promise?) { + promise?.resolveNotImplemented() + } + + @ReactMethod + override fun verifyIdentity(promise: Promise?) { + promise?.resolveNotImplemented() + } + + @ReactMethod + override fun collectPaymentMethod( + paymentMethod: String?, + platformPayParams: ReadableMap?, + promise: Promise?, + ) { + promise?.resolveNotImplemented() + } + + @ReactMethod + override fun provideCheckoutClientSecret(clientSecret: String?) { + // No-op + } + + @ReactMethod + override fun createCryptoPaymentToken(promise: Promise?) { + promise?.resolveNotImplemented() + } + + @ReactMethod + override fun performCheckout( + onrampSessionId: String?, + promise: Promise?, + ) { + promise?.resolveNotImplemented() + } + + @ReactMethod + override fun onrampAuthorize( + linkAuthIntentId: String?, + promise: Promise?, + ) { + promise?.resolveNotImplemented() + } + + @ReactMethod + override fun logout(promise: Promise?) { + promise?.resolveNotImplemented() + } + + @ReactMethod + override fun getCryptoTokenDisplayData( + token: ReadableMap, + promise: Promise, + ) { + promise?.resolveNotImplemented() + } + + private fun Promise.resolveNotImplemented() { + this.resolve( + createFailedError( + NotImplementedError( + "To enable Onramp, add 'StripeSdk_includeOnramp=true' to gradle.properties.", + ), + ), + ) + } +} diff --git a/android/src/main/java/com/reactnativestripesdk/PaymentLauncherFragment.kt b/android/src/main/java/com/reactnativestripesdk/PaymentLauncherFragment.kt index f9a5007635..42386517c7 100644 --- a/android/src/main/java/com/reactnativestripesdk/PaymentLauncherFragment.kt +++ b/android/src/main/java/com/reactnativestripesdk/PaymentLauncherFragment.kt @@ -381,6 +381,7 @@ class PaymentLauncherFragment : StripeFragment() { StripeIntent.NextActionType.DisplayOxxoDetails, StripeIntent.NextActionType.DisplayBoletoDetails, StripeIntent.NextActionType.DisplayKonbiniDetails, + StripeIntent.NextActionType.DisplayPayNowDetails, StripeIntent.NextActionType.VerifyWithMicrodeposits, StripeIntent.NextActionType.DisplayMultibancoDetails, -> true diff --git a/android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt b/android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt index 381b703127..ac3aa44b9f 100644 --- a/android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt +++ b/android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt @@ -71,6 +71,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.json.JSONObject +@SuppressLint("RestrictedApi") @ReactModule(name = StripeSdkModule.NAME) class StripeSdkModule( reactContext: ReactApplicationContext, @@ -242,7 +243,10 @@ class StripeSdkModule( val customPaymentMethodConfig = params.getMap("customPaymentMethodConfiguration") if (customPaymentMethodConfig != null) { // Store the original ReadableMap for custom payment methods - bundle.putSerializable("customPaymentMethodConfigurationReadableMap", customPaymentMethodConfig.toHashMap()) + bundle.putSerializable( + "customPaymentMethodConfigurationReadableMap", + customPaymentMethodConfig.toHashMap(), + ) } paymentSheetFragment = @@ -1405,11 +1409,14 @@ class StripeSdkModule( private fun setupComposeCompatView() { UiThreadUtil.runOnUiThread { - composeCompatView = composeCompatView ?: StripeAbstractComposeView.CompatView(context = reactApplicationContext).also { - currentActivity?.findViewById(android.R.id.content)?.addView( - it, - ) - } + composeCompatView = + composeCompatView ?: StripeAbstractComposeView + .CompatView(context = reactApplicationContext) + .also { + currentActivity?.findViewById(android.R.id.content)?.addView( + it, + ) + } } } diff --git a/android/src/main/java/com/reactnativestripesdk/StripeSdkPackage.kt b/android/src/main/java/com/reactnativestripesdk/StripeSdkPackage.kt index 35b8ed0988..fe9541664a 100644 --- a/android/src/main/java/com/reactnativestripesdk/StripeSdkPackage.kt +++ b/android/src/main/java/com/reactnativestripesdk/StripeSdkPackage.kt @@ -19,11 +19,20 @@ class StripeSdkPackage : BaseReactPackage() { ): NativeModule? = when (name) { StripeSdkModule.NAME -> StripeSdkModule(reactContext) + NativeOnrampSdkModuleSpec.NAME -> { + val onrampModuleClass = getOnrampModuleClass() + val constructor = onrampModuleClass.getConstructor(ReactApplicationContext::class.java) + constructor.newInstance(reactContext) as NativeModule + } else -> null } override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { - val moduleList: Array> = arrayOf(StripeSdkModule::class.java) + val moduleList: Array> = + arrayOf( + StripeSdkModule::class.java, + getOnrampModuleClass(), + ) val reactModuleInfoMap: MutableMap = HashMap() for (moduleClass in moduleList) { val reactModule = moduleClass.getAnnotation(ReactModule::class.java) ?: continue @@ -51,4 +60,13 @@ class StripeSdkPackage : BaseReactPackage() { AddressSheetViewManager(), EmbeddedPaymentElementViewManager(), ) + + private fun getOnrampModuleClass(): Class { + if (BuildConfig.IS_ONRAMP_INCLUDED) { + @Suppress("UNCHECKED_CAST") + return Class.forName("com.reactnativestripesdk.OnrampSdkModule") as Class + } else { + return FakeOnrampSdkModule::class.java + } + } } diff --git a/android/src/main/java/com/reactnativestripesdk/utils/Errors.kt b/android/src/main/java/com/reactnativestripesdk/utils/Errors.kt index 40d48a6890..4de126973a 100644 --- a/android/src/main/java/com/reactnativestripesdk/utils/Errors.kt +++ b/android/src/main/java/com/reactnativestripesdk/utils/Errors.kt @@ -178,8 +178,18 @@ internal fun createError( return mapError(code, error.message, error.localizedMessage, null, null, null) } +internal fun createCanceledError(message: String? = null): WritableMap = createError(ErrorType.Canceled.toString(), message) + +internal fun createFailedError(error: Throwable): WritableMap = createError(ErrorType.Failed.toString(), error) + internal fun createMissingInitError(): WritableMap = createError( ErrorType.Failed.toString(), "Stripe has not been initialized. Initialize Stripe in your app with the StripeProvider component or the initStripe method.", ) + +internal fun createOnrampNotConfiguredError(): WritableMap = + createError( + ErrorType.Failed.toString(), + "Onramp is not configured.", + ) diff --git a/android/src/main/java/com/reactnativestripesdk/utils/Mappers.kt b/android/src/main/java/com/reactnativestripesdk/utils/Mappers.kt index b74d924aeb..788538f871 100644 --- a/android/src/main/java/com/reactnativestripesdk/utils/Mappers.kt +++ b/android/src/main/java/com/reactnativestripesdk/utils/Mappers.kt @@ -30,6 +30,8 @@ import com.stripe.android.model.Token import com.stripe.android.paymentelement.ExperimentalCustomPaymentMethodsApi import com.stripe.android.paymentsheet.PaymentSheet +internal fun createEmptyResult(): WritableMap = WritableNativeMap() + internal fun createResult( key: String, value: WritableMap, @@ -586,6 +588,12 @@ internal fun mapNextAction( nextActionMap.putString("voucherURL", it.hostedVoucherUrl) } } + NextActionType.DisplayPayNowDetails -> { + (data as? NextActionData.DisplayPayNowDetails)?.let { + nextActionMap.putString("type", "paynow") + nextActionMap.putString("qrCodeUrl", it.qrCodeUrl) + } + } } return nextActionMap } @@ -1073,7 +1081,8 @@ internal fun parseCustomPaymentMethods(customPaymentMethodConfig: Bundle?): List return emptyList() } - val configHashMap = customPaymentMethodConfig.getSerializable("customPaymentMethodConfigurationReadableMap") as? HashMap + val configHashMap = + customPaymentMethodConfig.getSerializable("customPaymentMethodConfigurationReadableMap") as? HashMap if (configHashMap != null) { val customPaymentMethods = configHashMap["customPaymentMethods"] as? List> if (customPaymentMethods != null) { @@ -1083,7 +1092,8 @@ internal fun parseCustomPaymentMethods(customPaymentMethodConfig: Bundle?): List val id = customPaymentMethodMap["id"] as? String if (id != null) { val subtitle = customPaymentMethodMap["subtitle"] as? String - val disableBillingDetailCollection = customPaymentMethodMap["disableBillingDetailCollection"] as? Boolean ?: false + val disableBillingDetailCollection = + customPaymentMethodMap["disableBillingDetailCollection"] as? Boolean ?: false result.add( PaymentSheet.CustomPaymentMethod( id = id, diff --git a/android/src/oldarch/java/com/facebook/react/viewmanagers/ApplePayButtonManagerDelegate.java b/android/src/oldarch/java/com/facebook/react/viewmanagers/ApplePayButtonManagerDelegate.java index a34f4d90f5..fc294e4b0d 100644 --- a/android/src/oldarch/java/com/facebook/react/viewmanagers/ApplePayButtonManagerDelegate.java +++ b/android/src/oldarch/java/com/facebook/react/viewmanagers/ApplePayButtonManagerDelegate.java @@ -34,6 +34,18 @@ public void setProperty(T view, String propName, @Nullable Object value) { case "borderRadius": mViewManager.setBorderRadius(view, value == null ? 4 : ((Double) value).intValue()); break; + case "hasShippingMethodCallback": + mViewManager.setHasShippingMethodCallback(view, value == null ? false : (boolean) value); + break; + case "hasShippingContactCallback": + mViewManager.setHasShippingContactCallback(view, value == null ? false : (boolean) value); + break; + case "hasCouponCodeCallback": + mViewManager.setHasCouponCodeCallback(view, value == null ? false : (boolean) value); + break; + case "hasOrderTrackingCallback": + mViewManager.setHasOrderTrackingCallback(view, value == null ? false : (boolean) value); + break; default: super.setProperty(view, propName, value); } diff --git a/android/src/oldarch/java/com/facebook/react/viewmanagers/ApplePayButtonManagerInterface.java b/android/src/oldarch/java/com/facebook/react/viewmanagers/ApplePayButtonManagerInterface.java index 9c54affd76..04bc830052 100644 --- a/android/src/oldarch/java/com/facebook/react/viewmanagers/ApplePayButtonManagerInterface.java +++ b/android/src/oldarch/java/com/facebook/react/viewmanagers/ApplePayButtonManagerInterface.java @@ -16,4 +16,8 @@ public interface ApplePayButtonManagerInterface { void setType(T view, int value); void setButtonStyle(T view, int value); void setBorderRadius(T view, int value); + void setHasShippingMethodCallback(T view, boolean value); + void setHasShippingContactCallback(T view, boolean value); + void setHasCouponCodeCallback(T view, boolean value); + void setHasOrderTrackingCallback(T view, boolean value); } diff --git a/android/src/oldarch/java/com/reactnativestripesdk/NativeOnrampSdkModuleSpec.java b/android/src/oldarch/java/com/reactnativestripesdk/NativeOnrampSdkModuleSpec.java new file mode 100644 index 0000000000..49cd0a0fd0 --- /dev/null +++ b/android/src/oldarch/java/com/reactnativestripesdk/NativeOnrampSdkModuleSpec.java @@ -0,0 +1,110 @@ + +/** + * This code was generated by [react-native-codegen](https://www.npmjs.com/package/react-native-codegen). + * + * Do not edit this file as changes may cause incorrect behavior and will be lost + * once the code is regenerated. + * + * @generated by codegen project: GenerateModuleJavaSpec.js + * + * @nolint + */ + +package com.reactnativestripesdk; + +import com.facebook.proguard.annotations.DoNotStrip; +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.ReadableMap; +import com.facebook.react.modules.core.DeviceEventManagerModule; +import com.facebook.react.turbomodule.core.interfaces.TurboModule; +import javax.annotation.Nonnull; + +public abstract class NativeOnrampSdkModuleSpec extends ReactContextBaseJavaModule implements TurboModule { + public static final String NAME = "OnrampSdk"; + + public NativeOnrampSdkModuleSpec(ReactApplicationContext reactContext) { + super(reactContext); + } + + @Override + public @Nonnull String getName() { + return NAME; + } + + private void invoke(String eventName, Object params) { + getReactApplicationContext() + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit(eventName, params); + } + + protected final void emitOnCheckoutClientSecretRequested(ReadableMap value) { + invoke("onCheckoutClientSecretRequested", value); + } + + @ReactMethod + @DoNotStrip + public abstract void initialise(ReadableMap params, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void configureOnramp(ReadableMap config, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void hasLinkAccount(String email, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void registerLinkUser(ReadableMap info, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void registerWalletAddress(String walletAddress, String network, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void attachKycInfo(ReadableMap kycInfo, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void updatePhoneNumber(String phone, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void authenticateUser(Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void verifyIdentity(Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void collectPaymentMethod(String paymentMethod, ReadableMap platformPayParams, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void provideCheckoutClientSecret(String clientSecret); + + @ReactMethod + @DoNotStrip + public abstract void createCryptoPaymentToken(Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void performCheckout(String onrampSessionId, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void onrampAuthorize(String linkAuthIntentId, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void getCryptoTokenDisplayData(ReadableMap token, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void logout(Promise promise); +} diff --git a/android/src/oldarch/java/com/reactnativestripesdk/NativeStripeSdkModuleSpec.java b/android/src/oldarch/java/com/reactnativestripesdk/NativeStripeSdkModuleSpec.java index d0ecd2b31b..64f8203b64 100644 --- a/android/src/oldarch/java/com/reactnativestripesdk/NativeStripeSdkModuleSpec.java +++ b/android/src/oldarch/java/com/reactnativestripesdk/NativeStripeSdkModuleSpec.java @@ -118,6 +118,10 @@ protected final void emitOnCustomPaymentMethodConfirmHandlerCallback(ReadableMap invoke("onCustomPaymentMethodConfirmHandlerCallback", value); } + protected final void emitOnCheckoutClientSecretRequested(ReadableMap value) { + invoke("onCheckoutClientSecretRequested", value); + } + @ReactMethod @DoNotStrip public abstract void initialise(ReadableMap params, Promise promise); diff --git a/android/src/onramp/java/com/reactnativestripesdk/OnrampSdkModule.kt b/android/src/onramp/java/com/reactnativestripesdk/OnrampSdkModule.kt new file mode 100644 index 0000000000..4ea8ab3692 --- /dev/null +++ b/android/src/onramp/java/com/reactnativestripesdk/OnrampSdkModule.kt @@ -0,0 +1,844 @@ +package com.reactnativestripesdk + +import android.annotation.SuppressLint +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.ui.graphics.Color +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.SavedStateHandle +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableNativeMap +import com.facebook.react.module.annotations.ReactModule +import com.reactnativestripesdk.utils.ErrorType +import com.reactnativestripesdk.utils.createCanceledError +import com.reactnativestripesdk.utils.createEmptyResult +import com.reactnativestripesdk.utils.createError +import com.reactnativestripesdk.utils.createFailedError +import com.reactnativestripesdk.utils.createMissingActivityError +import com.reactnativestripesdk.utils.createMissingInitError +import com.reactnativestripesdk.utils.createOnrampNotConfiguredError +import com.reactnativestripesdk.utils.createResult +import com.reactnativestripesdk.utils.getValOr +import com.stripe.android.crypto.onramp.OnrampCoordinator +import com.stripe.android.crypto.onramp.model.CryptoNetwork +import com.stripe.android.crypto.onramp.model.DateOfBirth +import com.stripe.android.crypto.onramp.model.KycInfo +import com.stripe.android.crypto.onramp.model.LinkUserInfo +import com.stripe.android.crypto.onramp.model.OnrampAttachKycInfoResult +import com.stripe.android.crypto.onramp.model.OnrampAuthenticateResult +import com.stripe.android.crypto.onramp.model.OnrampAuthorizeResult +import com.stripe.android.crypto.onramp.model.OnrampCallbacks +import com.stripe.android.crypto.onramp.model.OnrampCheckoutResult +import com.stripe.android.crypto.onramp.model.OnrampCollectPaymentMethodResult +import com.stripe.android.crypto.onramp.model.OnrampConfiguration +import com.stripe.android.crypto.onramp.model.OnrampConfigurationResult +import com.stripe.android.crypto.onramp.model.OnrampCreateCryptoPaymentTokenResult +import com.stripe.android.crypto.onramp.model.OnrampHasLinkAccountResult +import com.stripe.android.crypto.onramp.model.OnrampLogOutResult +import com.stripe.android.crypto.onramp.model.OnrampRegisterLinkUserResult +import com.stripe.android.crypto.onramp.model.OnrampRegisterWalletAddressResult +import com.stripe.android.crypto.onramp.model.OnrampUpdatePhoneNumberResult +import com.stripe.android.crypto.onramp.model.OnrampVerifyIdentityResult +import com.stripe.android.crypto.onramp.model.PaymentMethodType +import com.stripe.android.link.LinkAppearance +import com.stripe.android.link.LinkAppearance.Colors +import com.stripe.android.link.LinkAppearance.PrimaryButton +import com.stripe.android.link.LinkAppearance.Style +import com.stripe.android.link.LinkController.PaymentMethodPreview +import com.stripe.android.link.PaymentMethodPreviewDetails +import com.stripe.android.model.CardBrand +import com.stripe.android.paymentsheet.PaymentSheet +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@SuppressLint("RestrictedApi") +@ReactModule(name = NativeOnrampSdkModuleSpec.NAME) +class OnrampSdkModule( + reactContext: ReactApplicationContext, +) : NativeOnrampSdkModuleSpec(reactContext) { + private lateinit var publishableKey: String + private var stripeAccountId: String? = null + + private var onrampCoordinator: OnrampCoordinator? = null + private var onrampPresenter: OnrampCoordinator.Presenter? = null + + private var authenticateUserPromise: Promise? = null + private var identityVerificationPromise: Promise? = null + private var collectPaymentPromise: Promise? = null + private var authorizePromise: Promise? = null + private var checkoutPromise: Promise? = null + + private var checkoutClientSecretDeferred: CompletableDeferred? = null + + @ReactMethod + override fun initialise( + params: ReadableMap, + promise: Promise, + ) { + // Note: This method depends on `StripeSdkModule#initialise()` being called as well. + val publishableKey = getValOr(params, "publishableKey", null) as String + this.stripeAccountId = getValOr(params, "stripeAccountId", null) + this.publishableKey = publishableKey + + promise.resolve(null) + } + + /** + * Safely get and cast the current activity as an AppCompatActivity. If that fails, the promise + * provided will be resolved with an error message instructing the user to retry the method. + */ + private fun getCurrentActivityOrResolveWithError(promise: Promise?): FragmentActivity? { + (currentActivity as? FragmentActivity)?.let { + return it + } + promise?.resolve(createMissingActivityError()) + return null + } + + @ReactMethod + override fun configureOnramp( + config: ReadableMap, + promise: Promise, + ) { + val application = + currentActivity?.application ?: (reactApplicationContext.applicationContext as? Application) + if (application == null) { + promise.resolve(createMissingActivityError()) + return + } + + val coordinator = + onrampCoordinator ?: OnrampCoordinator + .Builder() + .build(application, SavedStateHandle()) + .also { this.onrampCoordinator = it } + + CoroutineScope(Dispatchers.IO).launch { + val appearanceMap = config.getMap("appearance") + val appearance = + if (appearanceMap != null) { + mapAppearance(appearanceMap) + } else { + LinkAppearance(style = Style.AUTOMATIC) + } + + val displayName = config.getString("merchantDisplayName") ?: "" + + val cryptoCustomerId = config.getString("cryptoCustomerId") + + val configuration = + OnrampConfiguration( + merchantDisplayName = displayName, + publishableKey = publishableKey, + appearance = appearance, + cryptoCustomerId = cryptoCustomerId, + ) + + val configureResult = coordinator.configure(configuration) + + CoroutineScope(Dispatchers.Main).launch { + when (configureResult) { + is OnrampConfigurationResult.Completed -> { + createOnrampPresenter(promise) + } + is OnrampConfigurationResult.Failed -> { + promise.resolve(createError(ErrorType.Failed.toString(), configureResult.error)) + } + } + } + } + } + + @ReactMethod + private fun createOnrampPresenter(promise: Promise) { + val activity = getCurrentActivityOrResolveWithError(promise) as? ComponentActivity + if (activity == null) { + promise.resolve(createMissingActivityError()) + return + } + if (onrampCoordinator == null) { + promise.resolve(createMissingInitError()) + return + } + if (onrampPresenter != null) { + promise.resolveVoid() + return + } + + val onrampCallbacks = + OnrampCallbacks( + authenticateUserCallback = { result -> + handleOnrampAuthenticationResult(result, authenticateUserPromise!!) + }, + verifyIdentityCallback = { result -> + handleOnrampIdentityVerificationResult(result, identityVerificationPromise!!) + }, + collectPaymentCallback = { result -> + handleOnrampCollectPaymentResult(result, collectPaymentPromise!!) + }, + authorizeCallback = { result -> + handleOnrampAuthorizationResult(result, authorizePromise!!) + }, + checkoutCallback = { result -> + handleOnrampCheckoutResult(result, checkoutPromise!!) + }, + ) + + try { + onrampPresenter = onrampCoordinator!!.createPresenter(activity, onrampCallbacks) + promise.resolveVoid() + } catch (e: Exception) { + promise.resolve(createFailedError(e)) + } + } + + @ReactMethod + override fun hasLinkAccount( + email: String, + promise: Promise, + ) { + val coordinator = + onrampCoordinator ?: run { + promise.resolve(createOnrampNotConfiguredError()) + return + } + CoroutineScope(Dispatchers.IO).launch { + when (val result = coordinator.hasLinkAccount(email)) { + is OnrampHasLinkAccountResult.Completed -> { + promise.resolveBoolean("hasLinkAccount", result.hasLinkAccount) + } + is OnrampHasLinkAccountResult.Failed -> { + promise.resolve(createFailedError(result.error)) + } + } + } + } + + @ReactMethod + override fun registerLinkUser( + info: ReadableMap, + promise: Promise, + ) { + val coordinator = + onrampCoordinator ?: run { + promise.resolve(createOnrampNotConfiguredError()) + return + } + CoroutineScope(Dispatchers.IO).launch { + val linkUserInfo = + LinkUserInfo( + email = info.getString("email") ?: "", + phone = info.getString("phone") ?: "", + country = info.getString("country") ?: "", + fullName = info.getString("fullName"), + ) + + val result = coordinator.registerLinkUser(linkUserInfo) + when (result) { + is OnrampRegisterLinkUserResult.Completed -> { + promise.resolveString("customerId", result.customerId) + } + is OnrampRegisterLinkUserResult.Failed -> { + promise.resolve(createFailedError(result.error)) + } + } + } + } + + @ReactMethod + override fun registerWalletAddress( + walletAddress: String, + network: String, + promise: Promise, + ) { + val coordinator = + onrampCoordinator ?: run { + promise.resolve(createOnrampNotConfiguredError()) + return + } + CoroutineScope(Dispatchers.IO).launch { + val cryptoNetwork = enumValues().firstOrNull { it.value == network } + if (cryptoNetwork == null) { + promise.resolve(createError(ErrorType.Unknown.toString(), "Invalid network: $network")) + return@launch + } + + when (val result = coordinator.registerWalletAddress(walletAddress, cryptoNetwork)) { + is OnrampRegisterWalletAddressResult.Completed -> { + promise.resolveVoid() + } + is OnrampRegisterWalletAddressResult.Failed -> { + promise.resolve(createFailedError(result.error)) + } + } + } + } + + @ReactMethod + override fun attachKycInfo( + kycInfo: ReadableMap, + promise: Promise, + ) { + val coordinator = + onrampCoordinator ?: run { + promise.resolve(createOnrampNotConfiguredError()) + return + } + CoroutineScope(Dispatchers.IO).launch { + val firstName = kycInfo.getString("firstName") + if (firstName.isNullOrEmpty()) { + promise.resolve( + createError( + ErrorType.Unknown.toString(), + "Missing required field: firstName", + ), + ) + return@launch + } + val lastName = kycInfo.getString("lastName") + if (lastName.isNullOrEmpty()) { + promise.resolve( + createError( + ErrorType.Unknown.toString(), + "Missing required field: lastName", + ), + ) + return@launch + } + val idNumber = kycInfo.getString("idNumber") + if (idNumber.isNullOrEmpty()) { + promise.resolve( + createError( + ErrorType.Unknown.toString(), + "Missing required field: idNumber", + ), + ) + return@launch + } + + val dateOfBirthMap = kycInfo.getMap("dateOfBirth") + val dob = + if ( + dateOfBirthMap != null && + dateOfBirthMap.hasKey("day") && + dateOfBirthMap.hasKey("month") && + dateOfBirthMap.hasKey("year") + ) { + DateOfBirth( + day = dateOfBirthMap.getInt("day"), + month = dateOfBirthMap.getInt("month"), + year = dateOfBirthMap.getInt("year"), + ) + } else { + promise.resolve( + createError( + ErrorType.Unknown.toString(), + "Missing required field: dateOfBirth", + ), + ) + return@launch + } + + val addressMap = kycInfo.getMap("address") + val addressObj = + addressMap?.let { + PaymentSheet.Address( + city = it.getString("city"), + country = it.getString("country"), + line1 = it.getString("line1"), + line2 = it.getString("line2"), + postalCode = it.getString("postalCode"), + state = it.getString("state"), + ) + } ?: PaymentSheet.Address() + + val kycInfoObj = + KycInfo( + firstName = firstName, + lastName = lastName, + idNumber = idNumber, + dateOfBirth = dob, + address = addressObj, + ) + + when (val result = coordinator.attachKycInfo(kycInfoObj)) { + is OnrampAttachKycInfoResult.Completed -> { + promise.resolveVoid() + } + is OnrampAttachKycInfoResult.Failed -> { + promise.resolve(createFailedError(result.error)) + } + } + } + } + + @ReactMethod + override fun updatePhoneNumber( + phone: String, + promise: Promise, + ) { + val coordinator = + onrampCoordinator ?: run { + promise.resolve(createOnrampNotConfiguredError()) + return + } + CoroutineScope(Dispatchers.IO).launch { + when (val result = coordinator.updatePhoneNumber(phone)) { + OnrampUpdatePhoneNumberResult.Completed -> { + promise.resolveVoid() + } + is OnrampUpdatePhoneNumberResult.Failed -> { + promise.resolve(createFailedError(result.error)) + } + } + } + } + + @ReactMethod + override fun authenticateUser(promise: Promise) { + val presenter = + onrampPresenter ?: run { + promise.resolve(createOnrampNotConfiguredError()) + return + } + + authenticateUserPromise = promise + + presenter.authenticateUser() + } + + @ReactMethod + override fun verifyIdentity(promise: Promise) { + val presenter = + onrampPresenter ?: run { + promise.resolve(createOnrampNotConfiguredError()) + return + } + + identityVerificationPromise = promise + + presenter.verifyIdentity() + } + + @ReactMethod + override fun collectPaymentMethod( + paymentMethod: String, + platformPayParams: ReadableMap, + promise: Promise, + ) { + val presenter = + onrampPresenter ?: run { + promise.resolve(createOnrampNotConfiguredError()) + return + } + + val method = + when (paymentMethod) { + "Card" -> PaymentMethodType.Card + "BankAccount" -> PaymentMethodType.BankAccount + else -> { + promise.resolve( + createFailedError( + IllegalArgumentException("Unsupported payment method: $paymentMethod"), + ), + ) + return + } + } + + collectPaymentPromise = promise + + presenter.collectPaymentMethod(method) + } + + @ReactMethod + override fun createCryptoPaymentToken(promise: Promise) { + val coordinator = + onrampCoordinator ?: run { + promise.resolve(createOnrampNotConfiguredError()) + return + } + + CoroutineScope(Dispatchers.IO).launch { + val result = coordinator.createCryptoPaymentToken() + CoroutineScope(Dispatchers.Main).launch { + handleOnrampCreateCryptoPaymentTokenResult(result, promise) + } + } + } + + @ReactMethod + override fun performCheckout( + onrampSessionId: String, + promise: Promise, + ) { + val presenter = + onrampPresenter ?: run { + promise.resolve(createOnrampNotConfiguredError()) + return + } + + val checkoutHandler: suspend () -> String = { + checkoutClientSecretDeferred = CompletableDeferred() + + val params = Arguments.createMap() + params.putString("onrampSessionId", onrampSessionId) + + emitOnCheckoutClientSecretRequested(params) + + checkoutClientSecretDeferred!!.await() + } + + checkoutPromise = promise + + presenter.performCheckout(onrampSessionId, checkoutHandler) + } + + @ReactMethod + override fun provideCheckoutClientSecret(clientSecret: String?) { + if (clientSecret != null) { + checkoutClientSecretDeferred?.complete(clientSecret) + } else { + checkoutClientSecretDeferred?.completeExceptionally( + RuntimeException("Failed to provide checkout client secret"), + ) + } + checkoutClientSecretDeferred = null + } + + @ReactMethod + override fun onrampAuthorize( + linkAuthIntentId: String, + promise: Promise, + ) { + val presenter = + onrampPresenter ?: run { + promise.resolve(createOnrampNotConfiguredError()) + return + } + + authorizePromise = promise + + presenter.authorize(linkAuthIntentId) + } + + @ReactMethod + override fun getCryptoTokenDisplayData( + token: ReadableMap, + promise: Promise, + ) { + val context = reactApplicationContext + + val paymentDetails: PaymentMethodPreview? = + when { + token.hasKey("card") -> { + val cardMap = token.getMap("card") + if (cardMap != null) { + val brand = cardMap.getString("brand") ?: "" + val funding = cardMap.getString("funding") ?: "" + val last4 = cardMap.getString("last4") ?: "" + val cardBrand = CardBrand.fromCode(brand) + + PaymentMethodPreview.create( + context = context, + details = + PaymentMethodPreviewDetails.Card( + brand = cardBrand, + funding = funding, + last4 = last4, + ), + ) + } else { + null + } + } + token.hasKey("us_bank_account") -> { + val bankMap = token.getMap("us_bank_account") + if (bankMap != null) { + val bankName = bankMap.getString("bank_name") + val last4 = bankMap.getString("last4") ?: "" + PaymentMethodPreview.create( + context = context, + details = + PaymentMethodPreviewDetails.BankAccount( + bankIconCode = null, + bankName = bankName, + last4 = last4, + ), + ) + } else { + null + } + } + else -> null + } + + if (paymentDetails == null) { + promise.resolve( + createFailedError( + IllegalArgumentException("Unsupported payment method"), + ), + ) + return + } + + val icon = + currentActivity + ?.let { ContextCompat.getDrawable(it, paymentDetails.iconRes) } + ?.let { "data:image/png;base64," + getBase64FromBitmap(getBitmapFromDrawable(it)) } + + val displayData = Arguments.createMap() + + displayData.putString("icon", icon) + displayData.putString("label", paymentDetails.label) + displayData.putString("sublabel", paymentDetails.sublabel) + + promise.resolve(createResult("displayData", displayData)) + } + + @ReactMethod + override fun logout(promise: Promise) { + val coordinator = + onrampCoordinator ?: run { + promise.resolve(createOnrampNotConfiguredError()) + return + } + + CoroutineScope(Dispatchers.IO).launch { + val result = coordinator.logOut() + CoroutineScope(Dispatchers.Main).launch { + handleLogOutResult(result, promise) + } + } + } + + private fun mapAppearance(appearanceMap: ReadableMap): LinkAppearance { + val lightColorsMap = appearanceMap.getMap("lightColors") + val darkColorsMap = appearanceMap.getMap("darkColors") + val styleStr = appearanceMap.getString("style") + val primaryButtonMap = appearanceMap.getMap("primaryButton") + + val lightColors = + if (lightColorsMap != null) { + val primaryColorStr = lightColorsMap.getString("primary") + val contentColorStr = lightColorsMap.getString("contentOnPrimary") + val borderSelectedColorStr = lightColorsMap.getString("borderSelected") + + Colors( + primary = Color(android.graphics.Color.parseColor(primaryColorStr)), + contentOnPrimary = Color(android.graphics.Color.parseColor(contentColorStr)), + borderSelected = Color(android.graphics.Color.parseColor(borderSelectedColorStr)), + ) + } else { + null + } + + val darkColors = + if (darkColorsMap != null) { + val primaryColorStr = darkColorsMap.getString("primary") + val contentColorStr = darkColorsMap.getString("contentOnPrimary") + val borderSelectedColorStr = darkColorsMap.getString("borderSelected") + + Colors( + primary = Color(android.graphics.Color.parseColor(primaryColorStr)), + contentOnPrimary = Color(android.graphics.Color.parseColor(contentColorStr)), + borderSelected = Color(android.graphics.Color.parseColor(borderSelectedColorStr)), + ) + } else { + null + } + + val style = + when (styleStr) { + "ALWAYS_LIGHT" -> Style.ALWAYS_LIGHT + "ALWAYS_DARK" -> Style.ALWAYS_DARK + else -> Style.AUTOMATIC + } + + val primaryButton = + if (primaryButtonMap != null) { + PrimaryButton( + cornerRadiusDp = + if (primaryButtonMap.hasKey("cornerRadius")) { + primaryButtonMap.getDouble("cornerRadius").toFloat() + } else { + null + }, + heightDp = + if (primaryButtonMap.hasKey("height")) { + primaryButtonMap.getDouble("height").toFloat() + } else { + null + }, + ) + } else { + null + } + + val default = LinkAppearance(style = Style.AUTOMATIC) + return LinkAppearance( + lightColors = lightColors ?: default.lightColors, + darkColors = darkColors ?: default.darkColors, + style = style, + primaryButton = primaryButton ?: default.primaryButton, + ) + } + + private fun handleOnrampAuthenticationResult( + result: OnrampAuthenticateResult, + promise: Promise, + ) { + when (result) { + is OnrampAuthenticateResult.Completed -> { + promise.resolveString("customerId", result.customerId) + } + is OnrampAuthenticateResult.Cancelled -> { + promise.resolve(createCanceledError("Authentication was cancelled")) + } + is OnrampAuthenticateResult.Failed -> { + promise.resolve(createFailedError(result.error)) + } + } + } + + private fun handleOnrampIdentityVerificationResult( + result: OnrampVerifyIdentityResult, + promise: Promise, + ) { + when (result) { + is OnrampVerifyIdentityResult.Completed -> { + promise.resolveVoid() + } + is OnrampVerifyIdentityResult.Cancelled -> { + promise.resolve(createCanceledError("Identity verification was cancelled")) + } + is OnrampVerifyIdentityResult.Failed -> { + promise.resolve(createFailedError(result.error)) + } + } + } + + private fun handleOnrampCollectPaymentResult( + result: OnrampCollectPaymentMethodResult, + promise: Promise, + ) { + when (result) { + is OnrampCollectPaymentMethodResult.Completed -> { + val displayData = Arguments.createMap() + val icon = + currentActivity + ?.let { ContextCompat.getDrawable(it, result.displayData.iconRes) } + ?.let { "data:image/png;base64," + getBase64FromBitmap(getBitmapFromDrawable(it)) } + displayData.putString("icon", icon) + displayData.putString("label", result.displayData.label) + result.displayData.sublabel?.let { displayData.putString("sublabel", it) } + promise.resolve(createResult("displayData", displayData)) + } + is OnrampCollectPaymentMethodResult.Cancelled -> { + promise.resolve(createCanceledError("Payment collection was cancelled")) + } + is OnrampCollectPaymentMethodResult.Failed -> { + promise.resolve(createFailedError(result.error)) + } + } + } + + private fun handleOnrampAuthorizationResult( + result: OnrampAuthorizeResult, + promise: Promise, + ) { + when (result) { + is OnrampAuthorizeResult.Consented -> { + promise.resolve( + WritableNativeMap().apply { + putString("status", "Consented") + putString("customerId", result.customerId) + }, + ) + } + is OnrampAuthorizeResult.Denied -> { + promise.resolve( + WritableNativeMap().apply { + putString("status", "Denied") + }, + ) + } + is OnrampAuthorizeResult.Canceled -> { + promise.resolve(createCanceledError("Authorization was cancelled")) + } + is OnrampAuthorizeResult.Failed -> { + promise.resolve(createFailedError(result.error)) + } + } + } + + private fun handleOnrampCheckoutResult( + result: OnrampCheckoutResult, + promise: Promise, + ) { + when (result) { + is OnrampCheckoutResult.Completed -> { + promise.resolveVoid() + } + is OnrampCheckoutResult.Canceled -> { + promise.resolve(createCanceledError("Checkout was cancelled")) + } + is OnrampCheckoutResult.Failed -> { + promise.resolve(createFailedError(result.error)) + } + } + } + + private fun handleOnrampCreateCryptoPaymentTokenResult( + result: OnrampCreateCryptoPaymentTokenResult, + promise: Promise, + ) { + when (result) { + is OnrampCreateCryptoPaymentTokenResult.Completed -> { + promise.resolveString("cryptoPaymentToken", result.cryptoPaymentToken) + } + is OnrampCreateCryptoPaymentTokenResult.Failed -> { + promise.resolve(createFailedError(result.error)) + } + } + } + + private fun handleLogOutResult( + result: OnrampLogOutResult, + promise: Promise, + ) { + when (result) { + is OnrampLogOutResult.Completed -> { + promise.resolveVoid() + } + is OnrampLogOutResult.Failed -> { + promise.resolve(createFailedError(result.error)) + } + } + } + + private fun Promise.resolveVoid() { + resolve(createEmptyResult()) + } + + private fun Promise.resolveString( + key: String, + value: String, + ) { + resolve(WritableNativeMap().apply { putString(key, value) }) + } + + private fun Promise.resolveBoolean( + key: String, + value: Boolean, + ) { + resolve(WritableNativeMap().apply { putBoolean(key, value) }) + } +} diff --git a/example/android/gradle.properties b/example/android/gradle.properties index 5ae7a6f5b3..584d299671 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -51,3 +51,6 @@ bridgelessEnabled=true # Version of Kotlin to build against. #KOTLIN_VERSION=1.8.22 + +# Enable Onramp functionality for Android. +StripeSdk_includeOnramp=true diff --git a/example/ios/Podfile b/example/ios/Podfile index 0f57ca6987..0892ff212d 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -16,4 +16,5 @@ options = { use_test_app! options do |target| pod 'stripe-react-native', path: '../node_modules/@stripe/stripe-react-native', testspecs: ['Tests'] + pod 'stripe-react-native/Onramp', path: '../node_modules/@stripe/stripe-react-native' end diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index e432e5f583..36cce954c9 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1685,12 +1685,12 @@ PODS: - ReactCommon/turbomodule/core - Yoga - SocketRocket (0.7.1) - - Stripe (24.23.0): - - StripeApplePay (= 24.23.0) - - StripeCore (= 24.23.0) - - StripePayments (= 24.23.0) - - StripePaymentsUI (= 24.23.0) - - StripeUICore (= 24.23.0) + - Stripe (24.24.0): + - StripeApplePay (= 24.24.0) + - StripeCore (= 24.24.0) + - StripePayments (= 24.24.0) + - StripePaymentsUI (= 24.24.0) + - StripeUICore (= 24.24.0) - stripe-react-native (0.51.0): - DoubleConversion - glog @@ -1711,13 +1711,35 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - Stripe (~> 24.23.0) + - stripe-react-native/Core (= 0.51.0) - stripe-react-native/NewArch (= 0.51.0) - - StripeApplePay (~> 24.23.0) - - StripeFinancialConnections (~> 24.23.0) - - StripePayments (~> 24.23.0) - - StripePaymentSheet (~> 24.23.0) - - StripePaymentsUI (~> 24.23.0) + - Yoga + - stripe-react-native/Core (0.51.0): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Stripe (~> 24.24.0) + - StripeApplePay (~> 24.24.0) + - StripeFinancialConnections (~> 24.24.0) + - StripePayments (~> 24.24.0) + - StripePaymentSheet (~> 24.24.0) + - StripePaymentsUI (~> 24.24.0) - Yoga - stripe-react-native/NewArch (0.51.0): - DoubleConversion @@ -1739,12 +1761,29 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - Stripe (~> 24.23.0) - - StripeApplePay (~> 24.23.0) - - StripeFinancialConnections (~> 24.23.0) - - StripePayments (~> 24.23.0) - - StripePaymentSheet (~> 24.23.0) - - StripePaymentsUI (~> 24.23.0) + - Yoga + - stripe-react-native/Onramp (0.51.0): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - stripe-react-native/Core + - StripeCryptoOnramp (~> 24.24.0) - Yoga - stripe-react-native/Tests (0.51.0): - DoubleConversion @@ -1766,35 +1805,43 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - Stripe (~> 24.23.0) - - StripeApplePay (~> 24.23.0) - - StripeFinancialConnections (~> 24.23.0) - - StripePayments (~> 24.23.0) - - StripePaymentSheet (~> 24.23.0) - - StripePaymentsUI (~> 24.23.0) - Yoga - - StripeApplePay (24.23.0): - - StripeCore (= 24.23.0) - - StripeCore (24.23.0) - - StripeFinancialConnections (24.23.0): - - StripeCore (= 24.23.0) - - StripeUICore (= 24.23.0) - - StripePayments (24.23.0): - - StripeCore (= 24.23.0) - - StripePayments/Stripe3DS2 (= 24.23.0) - - StripePayments/Stripe3DS2 (24.23.0): - - StripeCore (= 24.23.0) - - StripePaymentSheet (24.23.0): - - StripeApplePay (= 24.23.0) - - StripeCore (= 24.23.0) - - StripePayments (= 24.23.0) - - StripePaymentsUI (= 24.23.0) - - StripePaymentsUI (24.23.0): - - StripeCore (= 24.23.0) - - StripePayments (= 24.23.0) - - StripeUICore (= 24.23.0) - - StripeUICore (24.23.0): - - StripeCore (= 24.23.0) + - StripeApplePay (24.24.0): + - StripeCore (= 24.24.0) + - StripeCameraCore (24.24.0): + - StripeCore (= 24.24.0) + - StripeCore (24.24.0) + - StripeCryptoOnramp (24.24.0): + - StripeApplePay (= 24.24.0) + - StripeCore (= 24.24.0) + - StripeIdentity (= 24.24.0) + - StripePayments (= 24.24.0) + - StripePaymentSheet (= 24.24.0) + - StripePaymentsUI (= 24.24.0) + - StripeUICore (= 24.24.0) + - StripeFinancialConnections (24.24.0): + - StripeCore (= 24.24.0) + - StripeUICore (= 24.24.0) + - StripeIdentity (24.24.0): + - StripeCameraCore (= 24.24.0) + - StripeCore (= 24.24.0) + - StripeUICore (= 24.24.0) + - StripePayments (24.24.0): + - StripeCore (= 24.24.0) + - StripePayments/Stripe3DS2 (= 24.24.0) + - StripePayments/Stripe3DS2 (24.24.0): + - StripeCore (= 24.24.0) + - StripePaymentSheet (24.24.0): + - StripeApplePay (= 24.24.0) + - StripeCore (= 24.24.0) + - StripePayments (= 24.24.0) + - StripePaymentsUI (= 24.24.0) + - StripePaymentsUI (24.24.0): + - StripeCore (= 24.24.0) + - StripePayments (= 24.24.0) + - StripeUICore (= 24.24.0) + - StripeUICore (24.24.0): + - StripeCore (= 24.24.0) - Yoga (0.0.0) DEPENDENCIES: @@ -1872,6 +1919,7 @@ DEPENDENCIES: - "RNCPicker (from `../node_modules/@react-native-picker/picker`)" - RNScreens (from `../node_modules/react-native-screens`) - "stripe-react-native (from `../node_modules/@stripe/stripe-react-native`)" + - "stripe-react-native/Onramp (from `../node_modules/@stripe/stripe-react-native`)" - "stripe-react-native/Tests (from `../node_modules/@stripe/stripe-react-native`)" - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) @@ -1880,8 +1928,11 @@ SPEC REPOS: - SocketRocket - Stripe - StripeApplePay + - StripeCameraCore - StripeCore + - StripeCryptoOnramp - StripeFinancialConnections + - StripeIdentity - StripePayments - StripePaymentSheet - StripePaymentsUI @@ -2109,17 +2160,20 @@ SPEC CHECKSUMS: RNCPicker: cfb51a08c6e10357d9a65832e791825b0747b483 RNScreens: 0d4cb9afe052607ad0aa71f645a88bb7c7f2e64c SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - Stripe: 2306d32be47f9632942f3385e9c2bad31d6ddcab - stripe-react-native: d95f5aa4e5a2d4a0edc8b3ea06e3a55d33ad31eb - StripeApplePay: edf515972406df57bd860d5f00416a552be6a450 - StripeCore: ff6173a175acc7c4c25acc7cfabbade717dbc4de - StripeFinancialConnections: 34a90401657135130fe5bfd93db5ac27c001ec65 - StripePayments: 9efe7bd14cae1821dba13997e12e070dfb38610b - StripePaymentSheet: 1c04531a7eff2c721736fc7d433ee342a59fae0e - StripePaymentsUI: 1cd35149b88de69c3364b4d1d4bb28c653c7d626 - StripeUICore: 539e667170d9c5c86c02a9c63f320d132c7c1b73 - Yoga: 9b7fb56e7b08cde60e2153344fa6afbd88e5d99f + Stripe: 64849211528185870444d8ca696eeab40bdcacce + stripe-react-native: 7e5254793501cbd2e11cd38a9f9da12208f31446 + StripeApplePay: 1e65bbf41e4844a02b0dff60cb56c8d37261e53d + StripeCameraCore: e0b7d463d4d55b730675821c5c90ef1f3f7a528b + StripeCore: 63f2337bceb55db0095c5e29a61cfad33a61963c + StripeCryptoOnramp: e640a4f337bf6fccaa474e6d9ac5f340b341fbf9 + StripeFinancialConnections: 2b2b2c9c0ed71ad22a038b3a4caf73ca5128dae2 + StripeIdentity: adbca8ff3e2027b48f1d61453aca8e302b932b32 + StripePayments: 7a994f28bedd16bc0e4eb9091d2811425adaa6af + StripePaymentSheet: 82477c8b000ba6446d2294106e3f4f96fc043ea8 + StripePaymentsUI: 8302dfe8094abb32cd2e0998a5b601164216d9db + StripeUICore: defd03b91fd5fb2838c59284e697b8da9cef0db0 + Yoga: afd04ff05ebe0121a00c468a8a3c8080221cb14c -PODFILE CHECKSUM: a2ed964678852d4cc306ff4add3e4fa90be77ea6 +PODFILE CHECKSUM: 0e8d697b2e2384b55c224afb61b755b153a2962a COCOAPODS: 1.16.2 diff --git a/example/server/onrampBackend.ts b/example/server/onrampBackend.ts new file mode 100644 index 0000000000..073f8c6280 --- /dev/null +++ b/example/server/onrampBackend.ts @@ -0,0 +1,286 @@ +interface CreateAuthIntentRequest { + email: string; + oauth_scopes: string; +} + +interface AuthIntentData { + id: string; + expiresAt: number; // Unix timestamp +} + +interface CreateAuthIntentResponse { + data: AuthIntentData; + token: string; +} + +interface CreateOnrampSessionRequest { + ui_mode: string; + payment_token: string; + source_amount: number; + source_currency: string; + destination_currency: string; + destination_network: string; + wallet_address: string; + crypto_customer_id: string; + customer_ip_address: string; +} + +interface OnrampSessionResponse { + id: string; + client_secret: string; +} + +interface CheckoutRequest { + cos_id: string; +} + +type ApiResult = + | { success: true; data: T } + | { success: false; error: { code: string; message: string } }; + +export class OnrampBackend { + private baseUrl: string; + + constructor( + baseUrl: string = 'https://crypto-onramp-example.stripedemos.com' + ) { + this.baseUrl = baseUrl; + } + + private async makeRequest( + endpoint: string, + requestBody: any, + options: { + useTimeout?: boolean; + authToken?: string; + transformResponse?: (data: any) => T; + } = {} + ): Promise> { + const { useTimeout = false, authToken, transformResponse } = options; + + try { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (authToken) { + headers.Authorization = `Bearer ${authToken}`; + } + + let controller: AbortController | undefined; + let timeoutId: ReturnType | undefined; + + if (useTimeout) { + controller = new AbortController(); + timeoutId = setTimeout(() => controller!.abort(), 60000); // 60 seconds timeout + } + + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method: 'POST', + headers, + body: JSON.stringify(requestBody), + ...(controller && { signal: controller.signal }), + }); + + if (timeoutId) { + clearTimeout(timeoutId); + } + + const responseData = await response.json(); + + if (!response.ok) { + console.error( + `[OnrampBackend] Request to ${endpoint} failed:`, + response.status, + responseData + ); + return { + success: false, + error: { + code: `HTTP_${response.status}`, + message: + responseData.error || + `HTTP ${response.status}: ${response.statusText}`, + }, + }; + } + + const transformedData = transformResponse + ? transformResponse(responseData) + : responseData; + + return { + success: true, + data: transformedData, + }; + } catch (error) { + console.error( + `[OnrampBackend] Error making request to ${endpoint}:`, + error + ); + + if (error instanceof Error && error.name === 'AbortError') { + return { + success: false, + error: { + code: 'TIMEOUT_ERROR', + message: 'Request timed out after 60 seconds', + }, + }; + } + + return { + success: false, + error: { + code: 'NETWORK_ERROR', + message: + error instanceof Error ? error.message : 'Network error occurred', + }, + }; + } + } + + /** + * Creates an auth intent for the given email and OAuth scopes + * @param email User email address + * @param oauthScopes OAuth scopes + */ + async createAuthIntent( + email: string, + oauthScopes: string = 'kyc.status:read,crypto:ramp' + ): Promise> { + const requestBody: CreateAuthIntentRequest = { + email, + oauth_scopes: oauthScopes, + }; + + return this.makeRequest( + '/auth_intent/create', + requestBody + ); + } + + /** + * Creates an onramp session for crypto purchase + * @param paymentToken Payment token from collectPaymentMethod + * @param walletAddress Destination wallet address + * @param cryptoCustomerId Customer ID from authentication + * @param authToken Authorization token + * @param destinationNetwork Destination network (e.g., "ethereum", "solana", "bitcoin") + * @param sourceAmount Source amount in USD + * @param sourceCurrency Source currency (e.g., "usd") + * @param destinationCurrency Destination currency (e.g., "eth", "sol", "btc") + * @param customerIpAddress Customer IP address + */ + async createOnrampSession( + paymentToken: string, + walletAddress: string, + cryptoCustomerId: string, + authToken: string, + destinationNetwork: string, + sourceAmount: number, + sourceCurrency: string, + destinationCurrency: string, + customerIpAddress: string + ): Promise> { + const requestBody: CreateOnrampSessionRequest = { + ui_mode: 'headless', + payment_token: paymentToken, + source_amount: sourceAmount, + source_currency: sourceCurrency, + destination_currency: destinationCurrency, + destination_network: destinationNetwork, + wallet_address: walletAddress, + crypto_customer_id: cryptoCustomerId, + customer_ip_address: customerIpAddress, + }; + + return this.makeRequest( + '/create_onramp_session', + requestBody, + { + useTimeout: true, + authToken, + transformResponse: (data) => ({ + id: data.id, + client_secret: data.client_secret, + }), + } + ); + } + + /** + * Performs checkout for an existing onramp session + * @param cosId Crypto onramp session ID + * @param authToken Authorization token + */ + async checkout( + cosId: string, + authToken: string + ): Promise> { + const requestBody: CheckoutRequest = { + cos_id: cosId, + }; + + return this.makeRequest('/checkout', requestBody, { + useTimeout: true, + authToken, + transformResponse: (data) => ({ + id: data.id, + client_secret: data.client_secret, + }), + }); + } +} + +const defaultClient = new OnrampBackend(); + +export const createAuthIntent = async ( + email: string, + oauthScopes?: string +): Promise> => { + return defaultClient.createAuthIntent(email, oauthScopes); +}; + +export const createOnrampSession = async ( + paymentToken: string, + walletAddress: string, + cryptoCustomerId: string, + authToken: string, + destinationNetwork: string, + sourceAmount: number, + sourceCurrency: string, + destinationCurrency: string, + customerIpAddress: string +): Promise> => { + return defaultClient.createOnrampSession( + paymentToken, + walletAddress, + cryptoCustomerId, + authToken, + destinationNetwork, + sourceAmount, + sourceCurrency, + destinationCurrency, + customerIpAddress + ); +}; + +export const checkout = async ( + cosId: string, + authToken: string +): Promise> => { + return defaultClient.checkout(cosId, authToken); +}; + +export type { + CreateAuthIntentRequest, + CreateAuthIntentResponse, + AuthIntentData, + CreateOnrampSessionRequest, + OnrampSessionResponse, + CheckoutRequest, + ApiResult, +}; + +export default OnrampBackend; diff --git a/example/src/App.tsx b/example/src/App.tsx index 556eb00677..7d04705f7f 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -49,6 +49,8 @@ import CustomerSheetScreen from './screens/CustomerSheetScreen'; import RevolutPayScreen from './screens/RevolutPayScreen'; import type { EmbeddedPaymentElementResult } from '@stripe/stripe-react-native'; import PaymentSheetWithPmoSfuScreen from './screens/PaymentSheetWithPmoSfuScreen'; +import CryptoOnrampFlow from './screens/Onramp/CryptoOnrampFlow'; +import RegisterCryptoUserScreen from './screens/Onramp/RegisterCryptoUserScreen'; const Stack = createNativeStackNavigator(); @@ -100,6 +102,8 @@ export type RootStackParamList = { CustomerSheetScreen: undefined; RevolutPayScreen: undefined; PaymentSheetWithPmoSfuScreen: undefined; + CryptoOnrampFlow: undefined; + RegisterCryptoUserScreen: undefined; }; declare global { @@ -275,6 +279,11 @@ export default function App() { name="PaymentSheetWithPmoSfuScreen" component={PaymentSheetWithPmoSfuScreen} /> + + diff --git a/example/src/components/Collapse.tsx b/example/src/components/Collapse.tsx index 4901e11ff7..b33c7a057f 100644 --- a/example/src/components/Collapse.tsx +++ b/example/src/components/Collapse.tsx @@ -5,24 +5,27 @@ import { colors } from '../colors'; interface Props { title: string; children?: React.ReactNode; + initialExpanded?: boolean; } -export const Collapse: React.FC = React.memo(({ children, title }) => { - const [expanded, setExpanded] = React.useState(false); +export const Collapse: React.FC = React.memo( + ({ children, title, initialExpanded = false }) => { + const [expanded, setExpanded] = React.useState(initialExpanded); - return ( - - setExpanded(!expanded)} - > - {title} - + return ( + + setExpanded(!expanded)} + > + {title} + - {expanded && {children}} - - ); -}); + {expanded && {children}} + + ); + } +); const styles = StyleSheet.create({ container: { diff --git a/example/src/screens/HomeScreen.tsx b/example/src/screens/HomeScreen.tsx index b1cb76c5db..cc2819ebe3 100644 --- a/example/src/screens/HomeScreen.tsx +++ b/example/src/screens/HomeScreen.tsx @@ -1,6 +1,10 @@ import React, { useCallback, useEffect } from 'react'; import { useNavigation } from '@react-navigation/native'; -import { useStripe } from '@stripe/stripe-react-native'; +import { + StripeProvider, + useStripe, + useOnramp, +} from '@stripe/stripe-react-native'; import { Linking, StyleSheet, @@ -13,10 +17,12 @@ import { import { colors } from '../colors'; import Button from '../components/Button'; import { Collapse } from '../components/Collapse'; +import { Onramp } from '@stripe/stripe-react-native'; export default function HomeScreen() { const navigation = useNavigation(); const { handleURLCallback } = useStripe(); + const { configure } = useOnramp(); const handleDeepLink = useCallback( async (url: string | null) => { @@ -48,6 +54,39 @@ export default function HomeScreen() { return () => deepLinkListener.remove(); }, [handleDeepLink]); + const handleConfigureOnramp = useCallback(() => { + const config: Onramp.Configuration = { + merchantDisplayName: 'Onramp RN Example', + appearance: { + lightColors: { + primary: '#2d22a1', + contentOnPrimary: '#ffffff', + borderSelected: '#07b8b8', + }, + darkColors: { + primary: '#800080', + contentOnPrimary: '#ffffff', + borderSelected: '#526f3e', + }, + style: 'ALWAYS_DARK', + primaryButton: { + cornerRadius: 8, + height: 48, + }, + }, + }; + + configure(config).then((result) => { + if (result?.error) { + console.error('Error configuring Onramp:', result.error.message); + Alert.alert('Onramp Configuration Error', result.error.message); + } else { + console.log('Onramp configured successfully.'); + Alert.alert('Success', 'Onramp configured successfully.'); + } + }); + }, [configure]); + return ( @@ -450,6 +489,39 @@ export default function HomeScreen() { + + + <> + +