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/README.md b/README.md index 754561b336..c39a70e401 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Get started with our [📚 integration guides](https://stripe.com/docs/payments/ **Native UI**: We provide native screens and elements to securely collect payment details on Android and iOS. -**PaymentSheet**: [Learn how to integrate](https://stripe.com/docs/payments/accept-a-payment) PaymentSheet, our new pre-built payments UI for mobile apps. PaymentSheet lets you accept cards, Apple Pay, Google Pay, and much more out of the box and also supports saving & reusing payment methods. PaymentSheet currently accepts the following payment methods: Card, Apple Pay, Google Pay, SEPA Debit, Bancontact, Billie, iDEAL, EPS, P24, Afterpay/Clearpay, Klarna, Giropay, and ACH. +**PaymentSheet**: [Learn how to integrate](https://stripe.com/docs/payments/accept-a-payment) PaymentSheet, our new pre-built payments UI for mobile apps. PaymentSheet lets you accept cards, Apple Pay, Google Pay, and much more out of the box and also supports saving & reusing payment methods. PaymentSheet currently accepts the following payment methods: Card, Apple Pay, Google Pay, SEPA Debit, Bancontact, Billie, iDEAL, EPS, P24, Afterpay/Clearpay, Klarna, and ACH. #### Recommended usage 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 e29ae9141d..1e4ea5a2f8 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=22.1.+ +StripeSdk_stripeVersion=22.2.+ diff --git a/android/src/androidTest/java/com/reactnativestripesdk/PaymentSheetAppearanceTest.kt b/android/src/androidTest/java/com/reactnativestripesdk/PaymentSheetAppearanceTest.kt index 98835eaa83..b6ee0b42ff 100644 --- a/android/src/androidTest/java/com/reactnativestripesdk/PaymentSheetAppearanceTest.kt +++ b/android/src/androidTest/java/com/reactnativestripesdk/PaymentSheetAppearanceTest.kt @@ -78,18 +78,8 @@ class PaymentSheetAppearanceTest { // Build expected appearance manually val testColor = Color.parseColor("#123456") - val colorsBuilder = PaymentSheet.Colors.Builder() - colorsBuilder.primary(testColor) - colorsBuilder.surface(testColor) - colorsBuilder.component(testColor) - colorsBuilder.componentBorder(testColor) - colorsBuilder.componentDivider(testColor) - colorsBuilder.onComponent(testColor) - colorsBuilder.onSurface(testColor) - colorsBuilder.subtitle(testColor) - colorsBuilder.placeholderText(testColor) - colorsBuilder.appBarIcon(testColor) - colorsBuilder.error(testColor) + val colorsBuilderLight = paymentSheetColorsBuilderFull(PaymentSheet.Colors.Builder.light(), testColor) + val colorsBuilderDark = paymentSheetColorsBuilderFull(PaymentSheet.Colors.Builder.dark(), testColor) val shapesBuilder = PaymentSheet.Shapes.Builder() shapesBuilder.cornerRadiusDp(42.0f) @@ -98,17 +88,15 @@ class PaymentSheetAppearanceTest { val typographyBuilder = PaymentSheet.Typography.Builder() typographyBuilder.sizeScaleFactor(42.0f) - val primaryButtonColorsBuilder = PaymentSheet.PrimaryButtonColors.Builder() - primaryButtonColorsBuilder.background(testColor) - primaryButtonColorsBuilder.onBackground(testColor) - primaryButtonColorsBuilder.border(testColor) - primaryButtonColorsBuilder.successBackgroundColor(testColor) - primaryButtonColorsBuilder.onSuccessBackgroundColor(testColor) + val primaryButtonColorsBuilderLight = + paymentSheetPrimaryButtonColorsBuilderFull(PaymentSheet.PrimaryButtonColors.Builder.light(), testColor) + val primaryButtonColorsBuilderDark = + paymentSheetPrimaryButtonColorsBuilderFull(PaymentSheet.PrimaryButtonColors.Builder.dark(), testColor) val primaryButton = PaymentSheet.PrimaryButton( - colorsLight = primaryButtonColorsBuilder.buildLight(), - colorsDark = primaryButtonColorsBuilder.buildDark(), + colorsLight = primaryButtonColorsBuilderLight.build(), + colorsDark = primaryButtonColorsBuilderDark.build(), shape = PaymentSheet.PrimaryButtonShape( cornerRadiusDp = 42.0f, @@ -120,8 +108,8 @@ class PaymentSheetAppearanceTest { val appearanceBuilder = PaymentSheet.Appearance.Builder() appearanceBuilder.typography(typographyBuilder.build()) - appearanceBuilder.colorsLight(colorsBuilder.buildLight()) - appearanceBuilder.colorsDark(colorsBuilder.buildDark()) + appearanceBuilder.colorsLight(colorsBuilderLight.build()) + appearanceBuilder.colorsDark(colorsBuilderDark.build()) appearanceBuilder.shapes(shapesBuilder.build()) appearanceBuilder.primaryButton(primaryButton) appearanceBuilder.formInsetValues( @@ -173,25 +161,21 @@ class PaymentSheetAppearanceTest { // Build expected appearance manually (only setting the properties that are in JSON) val testColor = Color.parseColor("#123456") - val colorsBuilder = PaymentSheet.Colors.Builder() - colorsBuilder.primary(testColor) - colorsBuilder.component(testColor) - colorsBuilder.componentDivider(testColor) - colorsBuilder.onSurface(testColor) - colorsBuilder.placeholderText(testColor) - colorsBuilder.error(testColor) + val lightColorsBuilder = paymentSheetColorsBuilderPartial(PaymentSheet.Colors.Builder.light(), testColor) + val darkColorsBuilder = paymentSheetColorsBuilderPartial(PaymentSheet.Colors.Builder.dark(), testColor) val shapesBuilder = PaymentSheet.Shapes.Builder() shapesBuilder.cornerRadiusDp(42.0f) - val primaryButtonColorsBuilder = PaymentSheet.PrimaryButtonColors.Builder() - primaryButtonColorsBuilder.background(testColor) - primaryButtonColorsBuilder.border(testColor) + val lightPrimaryButtonColorsBuilder = + paymentSheetPrimaryButtonColorsBuilderPartial(PaymentSheet.PrimaryButtonColors.Builder.light(), testColor) + val darkPrimaryButtonColorsBuilder = + paymentSheetPrimaryButtonColorsBuilderPartial(PaymentSheet.PrimaryButtonColors.Builder.dark(), testColor) val primaryButton = PaymentSheet.PrimaryButton( - colorsLight = primaryButtonColorsBuilder.buildLight(), - colorsDark = primaryButtonColorsBuilder.buildDark(), + colorsLight = lightPrimaryButtonColorsBuilder.build(), + colorsDark = darkPrimaryButtonColorsBuilder.build(), shape = PaymentSheet.PrimaryButtonShape( cornerRadiusDp = 42.0f, @@ -203,8 +187,8 @@ class PaymentSheetAppearanceTest { val appearanceBuilder = PaymentSheet.Appearance.Builder() appearanceBuilder.typography(PaymentSheet.Typography.Builder().build()) - appearanceBuilder.colorsLight(colorsBuilder.buildLight()) - appearanceBuilder.colorsDark(colorsBuilder.buildDark()) + appearanceBuilder.colorsLight(lightColorsBuilder.build()) + appearanceBuilder.colorsDark(darkColorsBuilder.build()) appearanceBuilder.shapes(shapesBuilder.build()) appearanceBuilder.primaryButton(primaryButton) @@ -246,12 +230,18 @@ class PaymentSheetAppearanceTest { val testColor = Color.parseColor("#123456") - val flatRadioColorsBuilder = - PaymentSheet.Appearance.Embedded.RowStyle.FlatWithRadio.Colors - .Builder() - flatRadioColorsBuilder.separatorColor(testColor) - flatRadioColorsBuilder.selectedColor(testColor) - flatRadioColorsBuilder.unselectedColor(testColor) + val lightFlatRadioColorsBuilder = + paymentSheetFlatRadioColorsBuilderFull( + PaymentSheet.Appearance.Embedded.RowStyle.FlatWithRadio.Colors.Builder + .light(), + testColor, + ) + val darkFlatRadioColorsBuilder = + paymentSheetFlatRadioColorsBuilderFull( + PaymentSheet.Appearance.Embedded.RowStyle.FlatWithRadio.Colors.Builder + .dark(), + testColor, + ) val flatRadioBuilder = PaymentSheet.Appearance.Embedded.RowStyle.FlatWithRadio @@ -262,8 +252,8 @@ class PaymentSheetAppearanceTest { flatRadioBuilder.topSeparatorEnabled(true) flatRadioBuilder.bottomSeparatorEnabled(true) flatRadioBuilder.additionalVerticalInsetsDp(42.0f) - flatRadioBuilder.colorsLight(flatRadioColorsBuilder.buildLight()) - flatRadioBuilder.colorsDark(flatRadioColorsBuilder.buildDark()) + flatRadioBuilder.colorsLight(lightFlatRadioColorsBuilder.build()) + flatRadioBuilder.colorsDark(darkFlatRadioColorsBuilder.build()) val embeddedBuilder = PaymentSheet.Appearance.Embedded.Builder() embeddedBuilder.rowStyle(flatRadioBuilder.build()) @@ -302,19 +292,26 @@ class PaymentSheetAppearanceTest { val testColor = Color.parseColor("#123456") - val flatRadioColorsBuilder = - PaymentSheet.Appearance.Embedded.RowStyle.FlatWithRadio.Colors - .Builder() - flatRadioColorsBuilder.separatorColor(testColor) - flatRadioColorsBuilder.selectedColor(testColor) + val lightFlatRadioColorsBuilder = + paymentSheetFlatRadioColorsBuilderPartial( + PaymentSheet.Appearance.Embedded.RowStyle.FlatWithRadio.Colors.Builder + .light(), + testColor, + ) + val darkFlatRadioColorsBuilder = + paymentSheetFlatRadioColorsBuilderPartial( + PaymentSheet.Appearance.Embedded.RowStyle.FlatWithRadio.Colors.Builder + .dark(), + testColor, + ) val flatRadioBuilder = PaymentSheet.Appearance.Embedded.RowStyle.FlatWithRadio .Builder() flatRadioBuilder.separatorThicknessDp(42.0f) flatRadioBuilder.topSeparatorEnabled(false) - flatRadioBuilder.colorsLight(flatRadioColorsBuilder.buildLight()) - flatRadioBuilder.colorsDark(flatRadioColorsBuilder.buildDark()) + flatRadioBuilder.colorsLight(lightFlatRadioColorsBuilder.build()) + flatRadioBuilder.colorsDark(darkFlatRadioColorsBuilder.build()) val embeddedBuilder = PaymentSheet.Appearance.Embedded.Builder() embeddedBuilder.rowStyle(flatRadioBuilder.build()) @@ -360,11 +357,18 @@ class PaymentSheetAppearanceTest { val testColor = Color.parseColor("#123456") - val flatCheckmarkColorsBuilder = - PaymentSheet.Appearance.Embedded.RowStyle.FlatWithCheckmark.Colors - .Builder() - flatCheckmarkColorsBuilder.separatorColor(testColor) - flatCheckmarkColorsBuilder.checkmarkColor(testColor) + val lightFlatCheckmarkColorsBuilder = + paymentSheetFlatCheckmarkColorsBuilder( + PaymentSheet.Appearance.Embedded.RowStyle.FlatWithCheckmark.Colors.Builder + .light(), + testColor, + ) + val darkFlatCheckmarkColorsBuilder = + paymentSheetFlatCheckmarkColorsBuilder( + PaymentSheet.Appearance.Embedded.RowStyle.FlatWithCheckmark.Colors.Builder + .dark(), + testColor, + ) val flatCheckmarkBuilder = PaymentSheet.Appearance.Embedded.RowStyle.FlatWithCheckmark @@ -376,8 +380,8 @@ class PaymentSheetAppearanceTest { flatCheckmarkBuilder.bottomSeparatorEnabled(true) flatCheckmarkBuilder.checkmarkInsetDp(42.0f) flatCheckmarkBuilder.additionalVerticalInsetsDp(42.0f) - flatCheckmarkBuilder.colorsLight(flatCheckmarkColorsBuilder.buildLight()) - flatCheckmarkBuilder.colorsDark(flatCheckmarkColorsBuilder.buildDark()) + flatCheckmarkBuilder.colorsLight(lightFlatCheckmarkColorsBuilder.build()) + flatCheckmarkBuilder.colorsDark(darkFlatCheckmarkColorsBuilder.build()) val embeddedBuilder = PaymentSheet.Appearance.Embedded.Builder() embeddedBuilder.rowStyle(flatCheckmarkBuilder.build()) @@ -416,19 +420,26 @@ class PaymentSheetAppearanceTest { val testColor = Color.parseColor("#123456") - val flatCheckmarkColorsBuilder = - PaymentSheet.Appearance.Embedded.RowStyle.FlatWithCheckmark.Colors - .Builder() - flatCheckmarkColorsBuilder.separatorColor(testColor) - flatCheckmarkColorsBuilder.checkmarkColor(testColor) + val lightFlatCheckmarkColorsBuilder = + paymentSheetFlatCheckmarkColorsBuilder( + PaymentSheet.Appearance.Embedded.RowStyle.FlatWithCheckmark.Colors.Builder + .light(), + testColor, + ) + val darkFlatCheckmarkColorsBuilder = + paymentSheetFlatCheckmarkColorsBuilder( + PaymentSheet.Appearance.Embedded.RowStyle.FlatWithCheckmark.Colors.Builder + .dark(), + testColor, + ) val flatCheckmarkBuilder = PaymentSheet.Appearance.Embedded.RowStyle.FlatWithCheckmark .Builder() flatCheckmarkBuilder.separatorThicknessDp(42.0f) flatCheckmarkBuilder.bottomSeparatorEnabled(false) - flatCheckmarkBuilder.colorsLight(flatCheckmarkColorsBuilder.buildLight()) - flatCheckmarkBuilder.colorsDark(flatCheckmarkColorsBuilder.buildDark()) + flatCheckmarkBuilder.colorsLight(lightFlatCheckmarkColorsBuilder.build()) + flatCheckmarkBuilder.colorsDark(darkFlatCheckmarkColorsBuilder.build()) val embeddedBuilder = PaymentSheet.Appearance.Embedded.Builder() embeddedBuilder.rowStyle(flatCheckmarkBuilder.build()) @@ -473,11 +484,18 @@ class PaymentSheetAppearanceTest { val testColor = Color.parseColor("#123456") - val flatDisclosureColorsBuilder = - PaymentSheet.Appearance.Embedded.RowStyle.FlatWithDisclosure.Colors - .Builder() - flatDisclosureColorsBuilder.separatorColor(testColor) - flatDisclosureColorsBuilder.disclosureColor(testColor) + val lightFlatDisclosureColorsBuilder = + paymentSheetFlatDisclosureColorsBuilder( + PaymentSheet.Appearance.Embedded.RowStyle.FlatWithDisclosure.Colors.Builder + .light(), + testColor, + ) + val darkFlatDisclosureColorsBuilder = + paymentSheetFlatDisclosureColorsBuilder( + PaymentSheet.Appearance.Embedded.RowStyle.FlatWithDisclosure.Colors.Builder + .dark(), + testColor, + ) val flatDisclosureBuilder = PaymentSheet.Appearance.Embedded.RowStyle.FlatWithDisclosure @@ -488,8 +506,8 @@ class PaymentSheetAppearanceTest { flatDisclosureBuilder.topSeparatorEnabled(true) flatDisclosureBuilder.bottomSeparatorEnabled(true) flatDisclosureBuilder.additionalVerticalInsetsDp(42.0f) - flatDisclosureBuilder.colorsLight(flatDisclosureColorsBuilder.buildLight()) - flatDisclosureBuilder.colorsDark(flatDisclosureColorsBuilder.buildDark()) + flatDisclosureBuilder.colorsLight(lightFlatDisclosureColorsBuilder.build()) + flatDisclosureBuilder.colorsDark(darkFlatDisclosureColorsBuilder.build()) val embeddedBuilder = PaymentSheet.Appearance.Embedded.Builder() embeddedBuilder.rowStyle(flatDisclosureBuilder.build()) @@ -527,18 +545,24 @@ class PaymentSheetAppearanceTest { val testColor = Color.parseColor("#123456") - val flatDisclosureColorsBuilder = - PaymentSheet.Appearance.Embedded.RowStyle.FlatWithDisclosure.Colors - .Builder() - flatDisclosureColorsBuilder.separatorColor(testColor) - flatDisclosureColorsBuilder.disclosureColor(testColor) - + val lightFlatDisclosureColorsBuilder = + paymentSheetFlatDisclosureColorsBuilder( + PaymentSheet.Appearance.Embedded.RowStyle.FlatWithDisclosure.Colors.Builder + .light(), + testColor, + ) + val darkFlatDisclosureColorsBuilder = + paymentSheetFlatDisclosureColorsBuilder( + PaymentSheet.Appearance.Embedded.RowStyle.FlatWithDisclosure.Colors.Builder + .dark(), + testColor, + ) val flatDisclosureBuilder = PaymentSheet.Appearance.Embedded.RowStyle.FlatWithDisclosure .Builder() flatDisclosureBuilder.separatorThicknessDp(42.0f) - flatDisclosureBuilder.colorsLight(flatDisclosureColorsBuilder.buildLight()) - flatDisclosureBuilder.colorsDark(flatDisclosureColorsBuilder.buildDark()) + flatDisclosureBuilder.colorsLight(lightFlatDisclosureColorsBuilder.build()) + flatDisclosureBuilder.colorsDark(darkFlatDisclosureColorsBuilder.build()) val embeddedBuilder = PaymentSheet.Appearance.Embedded.Builder() embeddedBuilder.rowStyle(flatDisclosureBuilder.build()) @@ -646,4 +670,101 @@ class PaymentSheetAppearanceTest { } return map } + + private fun paymentSheetColorsBuilderFull( + builder: PaymentSheet.Colors.Builder, + testColor: Int, + ): PaymentSheet.Colors.Builder { + builder.primary(testColor) + builder.surface(testColor) + builder.component(testColor) + builder.componentBorder(testColor) + builder.componentDivider(testColor) + builder.onComponent(testColor) + builder.onSurface(testColor) + builder.subtitle(testColor) + builder.placeholderText(testColor) + builder.appBarIcon(testColor) + builder.error(testColor) + + return builder + } + + private fun paymentSheetColorsBuilderPartial( + builder: PaymentSheet.Colors.Builder, + testColor: Int, + ): PaymentSheet.Colors.Builder { + builder.primary(testColor) + builder.component(testColor) + builder.componentDivider(testColor) + builder.onSurface(testColor) + builder.placeholderText(testColor) + builder.error(testColor) + + return builder + } + + private fun paymentSheetPrimaryButtonColorsBuilderFull( + builder: PaymentSheet.PrimaryButtonColors.Builder, + testColor: Int, + ): PaymentSheet.PrimaryButtonColors.Builder { + builder.background(testColor) + builder.onBackground(testColor) + builder.border(testColor) + builder.successBackgroundColor(testColor) + builder.onSuccessBackgroundColor(testColor) + + return builder + } + + private fun paymentSheetPrimaryButtonColorsBuilderPartial( + builder: PaymentSheet.PrimaryButtonColors.Builder, + testColor: Int, + ): PaymentSheet.PrimaryButtonColors.Builder { + builder.background(testColor) + builder.border(testColor) + + return builder + } + + private fun paymentSheetFlatRadioColorsBuilderFull( + builder: PaymentSheet.Appearance.Embedded.RowStyle.FlatWithRadio.Colors.Builder, + testColor: Int, + ): PaymentSheet.Appearance.Embedded.RowStyle.FlatWithRadio.Colors.Builder { + builder.separatorColor(testColor) + builder.selectedColor(testColor) + builder.unselectedColor(testColor) + + return builder + } + + private fun paymentSheetFlatRadioColorsBuilderPartial( + builder: PaymentSheet.Appearance.Embedded.RowStyle.FlatWithRadio.Colors.Builder, + testColor: Int, + ): PaymentSheet.Appearance.Embedded.RowStyle.FlatWithRadio.Colors.Builder { + builder.separatorColor(testColor) + builder.selectedColor(testColor) + + return builder + } + + private fun paymentSheetFlatCheckmarkColorsBuilder( + builder: PaymentSheet.Appearance.Embedded.RowStyle.FlatWithCheckmark.Colors.Builder, + testColor: Int, + ): PaymentSheet.Appearance.Embedded.RowStyle.FlatWithCheckmark.Colors.Builder { + builder.separatorColor(testColor) + builder.checkmarkColor(testColor) + + return builder + } + + private fun paymentSheetFlatDisclosureColorsBuilder( + builder: PaymentSheet.Appearance.Embedded.RowStyle.FlatWithDisclosure.Colors.Builder, + testColor: Int, + ): PaymentSheet.Appearance.Embedded.RowStyle.FlatWithDisclosure.Colors.Builder { + builder.separatorColor(testColor) + builder.disclosureColor(testColor) + + return builder + } } 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..08bef0ba64 --- /dev/null +++ b/android/src/main/java/com/reactnativestripesdk/FakeOnrampSdkModule.kt @@ -0,0 +1,154 @@ +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 presentKycInfoVerification( + updatedAddress: ReadableMap?, + 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() + } + + @ReactMethod + override fun authenticateUserWithToken( + token: String, + 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/PaymentLauncherManager.kt b/android/src/main/java/com/reactnativestripesdk/PaymentLauncherManager.kt index e8803e92ef..c30fb1b9f9 100644 --- a/android/src/main/java/com/reactnativestripesdk/PaymentLauncherManager.kt +++ b/android/src/main/java/com/reactnativestripesdk/PaymentLauncherManager.kt @@ -342,6 +342,7 @@ class PaymentLauncherManager( StripeIntent.NextActionType.DisplayOxxoDetails, StripeIntent.NextActionType.DisplayBoletoDetails, StripeIntent.NextActionType.DisplayKonbiniDetails, + StripeIntent.NextActionType.DisplayPayNowDetails, StripeIntent.NextActionType.VerifyWithMicrodeposits, StripeIntent.NextActionType.DisplayMultibancoDetails, StripeIntent.NextActionType.DisplayPayNowDetails, diff --git a/android/src/main/java/com/reactnativestripesdk/PaymentSheetAppearance.kt b/android/src/main/java/com/reactnativestripesdk/PaymentSheetAppearance.kt index 38dbeaf926..c0ebc00ce1 100644 --- a/android/src/main/java/com/reactnativestripesdk/PaymentSheetAppearance.kt +++ b/android/src/main/java/com/reactnativestripesdk/PaymentSheetAppearance.kt @@ -34,8 +34,8 @@ fun buildPaymentSheetAppearance( val builder = PaymentSheet.Appearance.Builder() builder.typography(buildTypography(userParams?.getMap(PaymentSheetAppearanceKeys.FONT), context)) - builder.colorsLight(buildColorsBuilder(lightColorParams).buildLight()) - builder.colorsDark(buildColorsBuilder(darkColorParams).buildDark()) + builder.colorsLight(buildColorsBuilder(PaymentSheet.Colors.Builder.light(), lightColorParams).build()) + builder.colorsDark(buildColorsBuilder(PaymentSheet.Colors.Builder.dark(), darkColorParams).build()) builder.shapes(buildShapes(userParams?.getMap(PaymentSheetAppearanceKeys.SHAPES))) builder.primaryButton( buildPrimaryButton( @@ -82,9 +82,10 @@ private fun colorFromHex(hexString: String?): Int? = } } -private fun buildColorsBuilder(colorParams: ReadableMap?): PaymentSheet.Colors.Builder { - val builder = PaymentSheet.Colors.Builder() - +private fun buildColorsBuilder( + builder: PaymentSheet.Colors.Builder, + colorParams: ReadableMap?, +): PaymentSheet.Colors.Builder { colorFromHex(colorParams?.getString(PaymentSheetAppearanceKeys.PRIMARY))?.let { builder.primary(it) } @@ -162,9 +163,9 @@ private fun buildPrimaryButton( return PaymentSheet.PrimaryButton( colorsLight = - buildPrimaryButtonColors(lightColorParams, context).buildLight(), + buildPrimaryButtonColors(PaymentSheet.PrimaryButtonColors.Builder.light(), lightColorParams, context).build(), colorsDark = - buildPrimaryButtonColors(darkColorParams, context).buildDark(), + buildPrimaryButtonColors(PaymentSheet.PrimaryButtonColors.Builder.dark(), darkColorParams, context).build(), shape = PaymentSheet.PrimaryButtonShape( cornerRadiusDp = @@ -183,11 +184,10 @@ private fun buildPrimaryButton( @Throws(PaymentSheetAppearanceException::class) private fun buildPrimaryButtonColors( + builder: PaymentSheet.PrimaryButtonColors.Builder, colorParams: ReadableMap, context: Context, ): PaymentSheet.PrimaryButtonColors.Builder { - val builder = PaymentSheet.PrimaryButtonColors.Builder() - // TODO: Why is background a string but successBackgroundColor a "dynamic" color? // https://stripe.dev/stripe-react-native/api-reference/types/PaymentSheet.PrimaryButtonColorConfig.html colorFromHex(colorParams.getString(PaymentSheetAppearanceKeys.BACKGROUND))?.let { @@ -236,16 +236,22 @@ private fun buildEmbeddedAppearance( val separatorInsetsParams = flatParams?.getMap(PaymentSheetAppearanceKeys.SEPARATOR_INSETS) - val flatRadioColorsBuilder = + val flatRadioColorsBuilderLight = PaymentSheet.Appearance.Embedded.RowStyle.FlatWithRadio.Colors - .Builder() + .Builder + .light() + val flatRadioColorsBuilderDark = + PaymentSheet.Appearance.Embedded.RowStyle.FlatWithRadio.Colors + .Builder + .dark() dynamicColorFromParams( context, flatParams, PaymentSheetAppearanceKeys.SEPARATOR_COLOR, )?.let { - flatRadioColorsBuilder.separatorColor(it) + flatRadioColorsBuilderLight.separatorColor(it) + flatRadioColorsBuilderDark.separatorColor(it) } dynamicColorFromParams( @@ -253,7 +259,8 @@ private fun buildEmbeddedAppearance( radioParams, PaymentSheetAppearanceKeys.SELECTED_COLOR, )?.let { - flatRadioColorsBuilder.selectedColor(it) + flatRadioColorsBuilderLight.selectedColor(it) + flatRadioColorsBuilderDark.selectedColor(it) } dynamicColorFromParams( @@ -261,7 +268,8 @@ private fun buildEmbeddedAppearance( radioParams, PaymentSheetAppearanceKeys.UNSELECTED_COLOR, )?.let { - flatRadioColorsBuilder.unselectedColor(it) + flatRadioColorsBuilderLight.unselectedColor(it) + flatRadioColorsBuilderDark.unselectedColor(it) } val rowStyleBuilder = @@ -292,8 +300,8 @@ private fun buildEmbeddedAppearance( rowStyleBuilder.additionalVerticalInsetsDp(it) } - rowStyleBuilder.colorsLight(flatRadioColorsBuilder.buildLight()) - rowStyleBuilder.colorsDark(flatRadioColorsBuilder.buildDark()) + rowStyleBuilder.colorsLight(flatRadioColorsBuilderLight.build()) + rowStyleBuilder.colorsDark(flatRadioColorsBuilderDark.build()) embeddedBuilder.rowStyle(rowStyleBuilder.build()) } @@ -304,20 +312,28 @@ private fun buildEmbeddedAppearance( val separatorInsetsParams = flatParams?.getMap(PaymentSheetAppearanceKeys.SEPARATOR_INSETS) - val flatCheckmarkColorsBuilder = + val flatCheckmarkColorsBuilderLight = PaymentSheet.Appearance.Embedded.RowStyle.FlatWithCheckmark.Colors - .Builder() + .Builder + .light() + + val flatCheckmarkColorsBuilderDark = + PaymentSheet.Appearance.Embedded.RowStyle.FlatWithCheckmark.Colors + .Builder + .dark() dynamicColorFromParams( context, flatParams, PaymentSheetAppearanceKeys.SEPARATOR_COLOR, )?.let { - flatCheckmarkColorsBuilder.separatorColor(it) + flatCheckmarkColorsBuilderLight.separatorColor(it) + flatCheckmarkColorsBuilderDark.separatorColor(it) } dynamicColorFromParams(context, checkmarkParams, PaymentSheetAppearanceKeys.COLOR)?.let { - flatCheckmarkColorsBuilder.checkmarkColor(it) + flatCheckmarkColorsBuilderLight.checkmarkColor(it) + flatCheckmarkColorsBuilderDark.checkmarkColor(it) } val rowStyleBuilder = @@ -353,8 +369,8 @@ private fun buildEmbeddedAppearance( } // TODO: The theme is so crazy long, why does each Color thing has the same redundant Theme... - rowStyleBuilder.colorsLight(flatCheckmarkColorsBuilder.buildLight()) - rowStyleBuilder.colorsDark(flatCheckmarkColorsBuilder.buildDark()) + rowStyleBuilder.colorsLight(flatCheckmarkColorsBuilderLight.build()) + rowStyleBuilder.colorsDark(flatCheckmarkColorsBuilderDark.build()) embeddedBuilder.rowStyle(rowStyleBuilder.build()) } @@ -364,20 +380,28 @@ private fun buildEmbeddedAppearance( val separatorInsetsParams = flatParams?.getMap(PaymentSheetAppearanceKeys.SEPARATOR_INSETS) - val flatDisclosureColorsBuilder = + val flatDisclosureColorsBuilderLight = PaymentSheet.Appearance.Embedded.RowStyle.FlatWithDisclosure.Colors - .Builder() + .Builder + .light() + + val flatDisclosureColorsBuilderDark = + PaymentSheet.Appearance.Embedded.RowStyle.FlatWithDisclosure.Colors + .Builder + .dark() dynamicColorFromParams( context, flatParams, PaymentSheetAppearanceKeys.SEPARATOR_COLOR, )?.let { - flatDisclosureColorsBuilder.separatorColor(it) + flatDisclosureColorsBuilderLight.separatorColor(it) + flatDisclosureColorsBuilderDark.separatorColor(it) } dynamicColorFromParams(context, disclosureParams, PaymentSheetAppearanceKeys.COLOR)?.let { - flatDisclosureColorsBuilder.disclosureColor(it) + flatDisclosureColorsBuilderLight.disclosureColor(it) + flatDisclosureColorsBuilderDark.disclosureColor(it) } val rowStyleBuilder = @@ -408,8 +432,8 @@ private fun buildEmbeddedAppearance( rowStyleBuilder.additionalVerticalInsetsDp(it) } - rowStyleBuilder.colorsLight(flatDisclosureColorsBuilder.buildLight()) - rowStyleBuilder.colorsDark(flatDisclosureColorsBuilder.buildDark()) + rowStyleBuilder.colorsLight(flatDisclosureColorsBuilderLight.build()) + rowStyleBuilder.colorsDark(flatDisclosureColorsBuilderDark.build()) embeddedBuilder.rowStyle(rowStyleBuilder.build()) } diff --git a/android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt b/android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt index 43e9b233a1..7a2b14641b 100644 --- a/android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt +++ b/android/src/main/java/com/reactnativestripesdk/StripeSdkModule.kt @@ -72,6 +72,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.json.JSONObject +@SuppressLint("RestrictedApi") @ReactModule(name = StripeSdkModule.NAME) @OptIn(ReactNativeSdkInternal::class) class StripeSdkModule( @@ -1436,11 +1437,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 aad60c0c06..7f874ac8ae 100644 --- a/android/src/main/java/com/reactnativestripesdk/utils/Mappers.kt +++ b/android/src/main/java/com/reactnativestripesdk/utils/Mappers.kt @@ -29,6 +29,8 @@ import com.stripe.android.paymentelement.ExperimentalCustomPaymentMethodsApi import com.stripe.android.paymentsheet.PaymentSheet import java.lang.IllegalArgumentException +internal fun createEmptyResult(): WritableMap = WritableNativeMap() + internal fun createResult( key: String, value: WritableMap, @@ -589,6 +591,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 } @@ -654,6 +662,21 @@ internal fun mapToAddress( return address.build() } +internal fun mapToPaymentSheetAddress(addressMap: ReadableMap?): PaymentSheet.Address? { + if (addressMap == null) { + return null + } + + return PaymentSheet.Address( + city = addressMap.getString("city"), + country = addressMap.getString("country"), + line1 = addressMap.getString("line1"), + line2 = addressMap.getString("line2"), + postalCode = addressMap.getString("postalCode"), + state = addressMap.getString("state"), + ) +} + internal fun mapToBillingDetails( billingDetails: ReadableMap?, cardAddress: Address?, 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..0003fe7ad4 --- /dev/null +++ b/android/src/oldarch/java/com/reactnativestripesdk/NativeOnrampSdkModuleSpec.java @@ -0,0 +1,118 @@ + +/** + * 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 authenticateUserWithToken(String token, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void presentKycInfoVerification(ReadableMap updatedAddress, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void logout(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..aa2f344bdf --- /dev/null +++ b/android/src/onramp/java/com/reactnativestripesdk/OnrampSdkModule.kt @@ -0,0 +1,917 @@ +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.reactnativestripesdk.utils.mapToPaymentSheetAddress +import com.stripe.android.crypto.onramp.OnrampCoordinator +import com.stripe.android.crypto.onramp.model.CryptoNetwork +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.OnrampTokenAuthenticationResult +import com.stripe.android.crypto.onramp.model.OnrampUpdatePhoneNumberResult +import com.stripe.android.crypto.onramp.model.OnrampVerifyIdentityResult +import com.stripe.android.crypto.onramp.model.OnrampVerifyKycInfoResult +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.model.DateOfBirth +import com.stripe.android.paymentsheet.PaymentSheet +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@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 verifyKycPromise: 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!!) + }, + verifyKycCallback = { result -> + handleOnrampKycVerificationResult(result, verifyKycPromise!!) + }, + ) + + 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 = mapToPaymentSheetAddress(addressMap) ?: 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 presentKycInfoVerification( + updatedAddress: ReadableMap?, + promise: Promise, + ) { + val presenter = + onrampPresenter ?: run { + promise.resolve(createOnrampNotConfiguredError()) + return + } + + val address = mapToPaymentSheetAddress(updatedAddress) + + verifyKycPromise = promise + presenter.verifyKycInfo(address) + } + + @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) + } + } + } + + @ReactMethod + override fun authenticateUserWithToken( + token: String, + promise: Promise, + ) { + val coordinator = + onrampCoordinator ?: run { + promise.resolve(createOnrampNotConfiguredError()) + return + } + + CoroutineScope(Dispatchers.IO).launch { + val result = coordinator.authenticateUserWithToken(token) + + withContext(Dispatchers.Main) { + handleAuthenticateUserWithTokenResult(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 handleOnrampKycVerificationResult( + result: OnrampVerifyKycInfoResult, + promise: Promise, + ) { + when (result) { + is OnrampVerifyKycInfoResult.Confirmed -> { + promise.resolve( + WritableNativeMap().apply { putString("status", "Confirmed") }, + ) + } + is OnrampVerifyKycInfoResult.UpdateAddress -> { + promise.resolve( + WritableNativeMap().apply { putString("status", "UpdateAddress") }, + ) + } + is OnrampVerifyKycInfoResult.Cancelled -> { + promise.resolve(createCanceledError("KYC verification was cancelled")) + } + is OnrampVerifyKycInfoResult.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 handleAuthenticateUserWithTokenResult( + result: OnrampTokenAuthenticationResult, + promise: Promise, + ) { + when (result) { + is OnrampTokenAuthenticationResult.Completed -> { + promise.resolveVoid() + } + is OnrampTokenAuthenticationResult.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 ae906aea0a..fe20dea70c 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1618,6 +1618,27 @@ PODS: - React-Core - React-jsi - ReactTestApp-Resources (1.0.0-dev) + - RNCAsyncStorage (2.2.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 + - Yoga - RNCPicker (2.11.0): - DoubleConversion - glog @@ -1691,7 +1712,7 @@ PODS: - StripePayments (= 25.0.1) - StripePaymentsUI (= 25.0.1) - StripeUICore (= 25.0.1) - - stripe-react-native (0.56.0): + - stripe-react-native (0.57.0): - DoubleConversion - glog - hermes-engine @@ -1711,15 +1732,10 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - Stripe (~> 25.0.1) - - stripe-react-native/NewArch (= 0.56.0) - - StripeApplePay (~> 25.0.1) - - StripeFinancialConnections (~> 25.0.1) - - StripePayments (~> 25.0.1) - - StripePaymentSheet (~> 25.0.1) - - StripePaymentsUI (~> 25.0.1) + - stripe-react-native/Core (= 0.57.0) + - stripe-react-native/NewArch (= 0.57.0) - Yoga - - stripe-react-native/NewArch (0.56.0): + - stripe-react-native/Core (0.57.0): - DoubleConversion - glog - hermes-engine @@ -1746,7 +1762,51 @@ PODS: - StripePaymentSheet (~> 25.0.1) - StripePaymentsUI (~> 25.0.1) - Yoga - - stripe-react-native/Tests (0.56.0): + - stripe-react-native/NewArch (0.57.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 + - Yoga + - stripe-react-native/Onramp (0.57.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 (~> 25.0.1) + - Yoga + - stripe-react-native/Tests (0.57.0): - DoubleConversion - glog - hermes-engine @@ -1766,19 +1826,27 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - Stripe (~> 25.0.1) - - StripeApplePay (~> 25.0.1) - - StripeFinancialConnections (~> 25.0.1) - - StripePayments (~> 25.0.1) - - StripePaymentSheet (~> 25.0.1) - - StripePaymentsUI (~> 25.0.1) - Yoga - StripeApplePay (25.0.1): - StripeCore (= 25.0.1) + - StripeCameraCore (25.0.1): + - StripeCore (= 25.0.1) - StripeCore (25.0.1) + - StripeCryptoOnramp (25.0.1): + - StripeApplePay (= 25.0.1) + - StripeCore (= 25.0.1) + - StripeIdentity (= 25.0.1) + - StripePayments (= 25.0.1) + - StripePaymentSheet (= 25.0.1) + - StripePaymentsUI (= 25.0.1) + - StripeUICore (= 25.0.1) - StripeFinancialConnections (25.0.1): - StripeCore (= 25.0.1) - StripeUICore (= 25.0.1) + - StripeIdentity (25.0.1): + - StripeCameraCore (= 25.0.1) + - StripeCore (= 25.0.1) + - StripeUICore (= 25.0.1) - StripePayments (25.0.1): - StripeCore (= 25.0.1) - StripePayments/Stripe3DS2 (= 25.0.1) @@ -1869,9 +1937,11 @@ DEPENDENCIES: - "ReactNativeHost (from `../node_modules/@rnx-kit/react-native-host`)" - ReactTestApp-DevSupport (from `../node_modules/react-native-test-app`) - ReactTestApp-Resources (from `..`) + - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - "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 +1950,11 @@ SPEC REPOS: - SocketRocket - Stripe - StripeApplePay + - StripeCameraCore - StripeCore + - StripeCryptoOnramp - StripeFinancialConnections + - StripeIdentity - StripePayments - StripePaymentSheet - StripePaymentsUI @@ -2027,6 +2100,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-test-app" ReactTestApp-Resources: :path: ".." + RNCAsyncStorage: + :path: "../node_modules/@react-native-async-storage/async-storage" RNCPicker: :path: "../node_modules/@react-native-picker/picker" RNScreens: @@ -2106,20 +2181,24 @@ SPEC CHECKSUMS: ReactNativeHost: 2503667b2e20fff53f55c4f85d9c7e66e0f3d702 ReactTestApp-DevSupport: 881bdd4c9f703cf611e46715b8a5f27b2ff15e5d ReactTestApp-Resources: 49fb7249af8203b9c74fd85de8c6f5bbb77a41df + RNCAsyncStorage: 79cfba789fca205b204f3ce7948b874500129eb4 RNCPicker: cfb51a08c6e10357d9a65832e791825b0747b483 RNScreens: 0d4cb9afe052607ad0aa71f645a88bb7c7f2e64c SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Stripe: 4728e3e0dd8df134e4a420ab504e929a93a815f0 - stripe-react-native: e2dac3be3c2668207a7b7700663b852646a0e7d8 + stripe-react-native: 1f557650e84614835a1980740ad6839bd69814d2 StripeApplePay: 43997281ace138a1c75a8f2d7be11925ea28644c + StripeCameraCore: e61d13cf270c450e699b12857dc75edc55a8455c StripeCore: 457c30e2fd3a7c4b274a5ad53d1ff03661eef2a0 + StripeCryptoOnramp: 5ccd75c8b4acf68261a1d9e19f85fe6ad840cbf4 StripeFinancialConnections: 8c2e326f767fb014b53174b3a5f8592c0a45fa56 + StripeIdentity: b6950bd769a564f33fa409f502128619befbcc16 StripePayments: 6955de4298a5265e66f02cffcc7954475ac7f6c8 StripePaymentSheet: 3f93ce6ea84afde770d3c7e18a9b8f99aed63896 StripePaymentsUI: 626726a01255a6458c35436f7f6431dacee82684 StripeUICore: 30f8352fd7a5cf1541b7777a57b3ad1133bf6763 Yoga: afd04ff05ebe0121a00c468a8a3c8080221cb14c -PODFILE CHECKSUM: a2ed964678852d4cc306ff4add3e4fa90be77ea6 +PODFILE CHECKSUM: 0e8d697b2e2384b55c224afb61b755b153a2962a COCOAPODS: 1.16.2 diff --git a/example/package.json b/example/package.json index 32ef7f89df..2d11f5f0ef 100644 --- a/example/package.json +++ b/example/package.json @@ -17,6 +17,7 @@ "postinstall": "patch-package" }, "dependencies": { + "@react-native-async-storage/async-storage": "^2.1.0", "@react-native-picker/picker": "^2.11.0", "@react-navigation/native": "^7.0.14", "@react-navigation/native-stack": "^7.2.0", @@ -31,9 +32,9 @@ "@babel/core": "^7.26.9", "@babel/preset-env": "^7.26.9", "@babel/runtime": "^7.26.9", + "@react-native-community/cli": "15.0.1", "@react-native-community/cli-platform-android": "15.0.1", "@react-native-community/cli-platform-ios": "15.0.1", - "@react-native-community/cli": "15.0.1", "@react-native/babel-preset": "0.78.0", "@react-native/metro-config": "0.78.0", "@react-native/typescript-config": "0.78.0", diff --git a/example/server/onrampBackend.ts b/example/server/onrampBackend.ts new file mode 100644 index 0000000000..2d8fb1aadf --- /dev/null +++ b/example/server/onrampBackend.ts @@ -0,0 +1,498 @@ +interface CreateAuthIntentRequest { + oauth_scopes: string; +} + +interface CreateAuthIntentResponse { + authIntentId: string; + existing: boolean; + state: string; + token: string; +} + +interface CreateLinkAuthTokenResponse { + link_auth_token_client_secret: string; + expires_in: number; +} + +interface SaveUserRequest { + crypto_customer_id: string; +} + +interface SaveUserResponse { + success: boolean; +} + +interface SignupRequest { + email: string; + password: string; + livemode: boolean; +} + +interface SignupUser { + user_id: number; + email: string; + created_at: string; +} + +interface SignupResponse { + token: string; + user: SignupUser; +} + +interface LoginRequest { + email: string; + password: string; + livemode: boolean; +} + +interface LoginResponse { + token: string; + user: SignupUser; +} + +interface GetCryptoCustomerResponse { + crypto_customer_id: 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; + method?: 'GET' | 'POST'; + } = {} + ): Promise> { + const { + useTimeout = false, + authToken, + transformResponse, + method = 'POST', + } = 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 fetchOptions: RequestInit & { headers: Record } = { + method, + headers, + ...(controller && { signal: controller.signal }), + } as any; + + if (method !== 'GET') { + (fetchOptions as any).body = JSON.stringify(requestBody); + } + + const response = await fetch(`${this.baseUrl}${endpoint}`, fetchOptions); + + 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 using an existing user session token + * @param authToken Bearer auth token from signup/login + * @param oauthScopes OAuth scopes + */ + async createAuthIntent( + authToken: string, + oauthScopes: string = 'kyc.status:read,crypto:ramp,auth.persist_login:read' + ): Promise> { + const requestBody: CreateAuthIntentRequest = { + oauth_scopes: oauthScopes, + }; + + return this.makeRequest( + '/v1/auth/create', + requestBody, + { authToken } + ); + } + + /** + * Creates a Link Auth Token client secret for token-based Link authentication + * @param authToken Bearer auth token from signup/login + */ + async createLinkAuthToken( + authToken: string + ): Promise> { + return this.makeRequest( + '/v1/auth/create_link_auth_token', + {}, + { + authToken, + transformResponse: (data) => ({ + link_auth_token_client_secret: data.link_auth_token_client_secret, + expires_in: data.expires_in, + }), + } + ); + } + + /** + * Signs up a new user for the demo backend + * @param email User email address + * @param password User password + * @param livemode Whether to use livemode (defaults to false) + */ + async signup( + email: string, + password: string, + livemode: boolean = false + ): Promise> { + const requestBody: SignupRequest = { + email, + password, + livemode, + }; + + return this.makeRequest('/v1/auth/signup', requestBody, { + transformResponse: (data) => ({ + token: data.token, + user: data.user, + }), + }); + } + + /** + * Logs in an existing user + * @param email User email address + * @param password User password + * @param livemode Whether to use livemode (defaults to false) + */ + async login( + email: string, + password: string, + livemode: boolean = false + ): Promise> { + const requestBody: LoginRequest = { + email, + password, + livemode, + }; + + return this.makeRequest('/v1/auth/login', requestBody, { + transformResponse: (data) => ({ + token: data.token, + user: data.user, + }), + }); + } + + /** + * Saves the authenticated user with their crypto customer id on the demo backend + * @param cryptoCustomerId The crypto customer id to associate with the user + * @param authToken Bearer auth token from signup/login + */ + async saveUser( + cryptoCustomerId: string, + authToken: string + ): Promise> { + const requestBody: SaveUserRequest = { + crypto_customer_id: cryptoCustomerId, + }; + + return this.makeRequest( + '/v1/auth/save_user', + requestBody, + { + authToken, + transformResponse: (data) => ({ success: !!data.success }), + } + ); + } + + /** + * 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( + '/v1/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( + '/v1/checkout', + requestBody, + { + useTimeout: true, + authToken, + transformResponse: (data) => ({ + id: data.id, + client_secret: data.client_secret, + }), + } + ); + } + + /** + * Retrieves the crypto customer id for the currently authenticated user + * @param authToken Authorization token + */ + async getCryptoCustomerId( + authToken: string + ): Promise> { + return this.makeRequest( + '/v1/auth/crypto_customer', + null, + { + authToken, + method: 'GET', + transformResponse: (data) => ({ + crypto_customer_id: data.crypto_customer_id, + }), + } + ); + } +} + +const defaultClient = new OnrampBackend(); + +export const createAuthIntent = async ( + authToken: string, + oauthScopes?: string +): Promise> => { + return defaultClient.createAuthIntent(authToken, 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 createLinkAuthToken = async ( + authToken: string +): Promise> => { + return defaultClient.createLinkAuthToken(authToken); +}; + +export const checkout = async ( + cosId: string, + authToken: string +): Promise> => { + return defaultClient.checkout(cosId, authToken); +}; + +export const getCryptoCustomerId = async ( + authToken: string +): Promise> => { + return defaultClient.getCryptoCustomerId(authToken); +}; + +export const saveUser = async ( + cryptoCustomerId: string, + authToken: string +): Promise> => { + return defaultClient.saveUser(cryptoCustomerId, authToken); +}; + +export const signup = async ( + email: string, + password: string, + livemode?: boolean +): Promise> => { + return defaultClient.signup(email, password, livemode); +}; + +export const login = async ( + email: string, + password: string, + livemode?: boolean +): Promise> => { + return defaultClient.login(email, password, livemode); +}; + +export type { + CreateAuthIntentRequest, + CreateAuthIntentResponse, + CreateLinkAuthTokenResponse, + GetCryptoCustomerResponse, + SaveUserRequest, + SaveUserResponse, + SignupRequest, + SignupResponse, + SignupUser, + LoginRequest, + LoginResponse, + CreateOnrampSessionRequest, + OnrampSessionResponse, + CheckoutRequest, + ApiResult, +}; + +export default OnrampBackend; diff --git a/example/src/App.tsx b/example/src/App.tsx index ea3c563307..4b3948637a 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'; import CustomerSheetScreenCustomerSession from './screens/CustomerSheetScreenCustomerSession'; const Stack = createNativeStackNavigator(); @@ -102,6 +104,8 @@ export type RootStackParamList = { CustomerSheetScreenCustomerSession: undefined; RevolutPayScreen: undefined; PaymentSheetWithPmoSfuScreen: undefined; + CryptoOnrampFlow: undefined; + RegisterCryptoUserScreen: undefined; }; declare global { @@ -277,6 +281,11 @@ export default function App() { name="PaymentSheetWithPmoSfuScreen" component={PaymentSheetWithPmoSfuScreen} /> + + = 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 983488b104..f776ed8357 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 ( @@ -457,6 +496,39 @@ export default function HomeScreen() { + + + <> + +