Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for ACHv2 on Android #879

Merged
merged 13 commits into from
Apr 15, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# CHANGELOG

- [#879](https://github.com/stripe/stripe-react-native/pull/879) Feat: Add support for ACHv2 payments on Android (already existed on iOS).
- [#879](https://github.com/stripe/stripe-react-native/pull/879) Chore: Upgraded `stripe-android` from v19.3.+ to v20.0.+
- [#837](https://github.com/stripe/stripe-react-native/pull/837) BREAKING CHANGE: Mostly fixes and changes to types, but some method's now accept slightly different parameters:
- Removed `setUrlSchemeOnAndroid` in favor of `setReturnUrlSchemeOnAndroid`. `setReturnUrlSchemeOnAndroid` functions exactly the same, this is just a rename.
- Removed `handleCardAction` in favor of `handleNextAction`. `handleNextAction` functions exactly the same, this is just a rename.
Expand Down
3 changes: 2 additions & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ dependencies {
api 'com.facebook.react:react-native:+'
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.3.1"
implementation 'com.stripe:stripe-android:19.3.+'
implementation 'com.stripe:stripe-android:20.0.+'
implementation 'com.stripe:connections:20.0.+'
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Undecided on if this is included by default or we require users to add it

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We require our users to add it

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We require it for merchants that support ACH, but it's not mandatory for everyone. If you don't want to include the entire library on android for everyone, you can do compileOnly instead, and mention in the docs that users need to add it to their android/build.gradle file. CollectBankAccountLauncher will fail at runtime if the connections code is not available on the classpath.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep- my question here is if the added size of the connections library is large enough to make users add this to their build.gradle manually. Even small additional steps like that can really cause issues for RN users. And then they're in charge of keeping that version up-to-date with the version of com.stripe:stripe-android we use is stripe-react-native internally. So I lean towards including it in our own library so users don't have to think about it.

implementation 'com.google.android.material:material:1.3.0'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.reactnativestripesdk

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import com.facebook.react.bridge.Promise
import com.stripe.android.model.PaymentIntent
import com.stripe.android.model.SetupIntent
import com.stripe.android.model.StripeIntent
import com.stripe.android.payments.bankaccount.CollectBankAccountConfiguration
import com.stripe.android.payments.bankaccount.CollectBankAccountLauncher
import com.stripe.android.payments.bankaccount.navigation.CollectBankAccountResult

class CollectBankAccountLauncherFragment(
private val activity: AppCompatActivity,
private val publishableKey: String,
private val clientSecret: String,
private val isPaymentIntent: Boolean,
private val collectParams: CollectBankAccountConfiguration.USBankAccount,
private val promise: Promise
) : Fragment() {
private lateinit var collectBankAccountLauncher: CollectBankAccountLauncher

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View {
collectBankAccountLauncher = createBankAccountLauncher()

return FrameLayout(requireActivity()).also {
it.visibility = View.GONE
}
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(
view,
savedInstanceState)

if (isPaymentIntent) {
collectBankAccountLauncher.presentWithPaymentIntent(
publishableKey,
clientSecret,
collectParams
)
} else {
collectBankAccountLauncher.presentWithSetupIntent(
publishableKey,
clientSecret,
collectParams
)
}
}

private fun createBankAccountLauncher(): CollectBankAccountLauncher {
return CollectBankAccountLauncher.create(this) { result ->
when (result) {
is CollectBankAccountResult.Completed -> {
val intent = result.response.intent
if (intent.status === StripeIntent.Status.RequiresPaymentMethod) {
promise.resolve(createError(ErrorType.Canceled.toString(), "Bank account collection was canceled."))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this customer facing? If it is, should this be translated?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is developer facing

} else if (intent.status === StripeIntent.Status.RequiresConfirmation) {
promise.resolve(
if (isPaymentIntent)
createResult("paymentIntent", mapFromPaymentIntentResult(intent as PaymentIntent))
else
createResult("setupIntent", mapFromSetupIntentResult(intent as SetupIntent))
)
}
}
is CollectBankAccountResult.Cancelled -> {
promise.resolve(createError(ErrorType.Canceled.toString(), "Bank account collection was canceled."))
}
is CollectBankAccountResult.Failed -> {
promise.resolve(createError(ErrorType.Failed.toString(), result.error))
}
}
activity.supportFragmentManager.beginTransaction().remove(this).commit()
}
}
}
12 changes: 6 additions & 6 deletions android/src/main/java/com/reactnativestripesdk/Errors.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ package com.reactnativestripesdk
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.WritableNativeMap
import com.stripe.android.core.exception.APIException
import com.stripe.android.core.exception.AuthenticationException
import com.stripe.android.core.exception.InvalidRequestException
import com.stripe.android.exception.AuthenticationException
import com.stripe.android.exception.CardException
import com.stripe.android.model.PaymentIntent
import com.stripe.android.model.SetupIntent

enum class ErrorType {
Failed, Canceled, Unknown
}

enum class ConfirmPaymentErrorType {
Failed, Canceled, Unknown
}
Expand All @@ -17,12 +21,8 @@ enum class CreateTokenErrorType {
Failed
}

enum class NextPaymentActionErrorType {
Failed, Canceled, Unknown
}

enum class ConfirmSetupIntentErrorType {
Failed, Canceled
Failed, Canceled, Unknown
}

enum class RetrievePaymentIntentErrorType {
Expand Down
97 changes: 81 additions & 16 deletions android/src/main/java/com/reactnativestripesdk/Mappers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ internal fun mapPaymentMethodType(type: PaymentMethod.Type?): String {
PaymentMethod.Type.Upi -> "Upi"
PaymentMethod.Type.WeChatPay -> "WeChatPay"
PaymentMethod.Type.Klarna -> "Klarna"
PaymentMethod.Type.USBankAccount -> "USBankAccount"
else -> "Unknown"
}
}
Expand All @@ -132,6 +133,7 @@ internal fun mapToPaymentMethodType(type: String?): PaymentMethod.Type? {
"Upi" -> PaymentMethod.Type.Upi
"WeChatPay" -> PaymentMethod.Type.WeChatPay
"Klarna" -> PaymentMethod.Type.Klarna
"USBankAccount" -> PaymentMethod.Type.USBankAccount
else -> null
}
}
Expand Down Expand Up @@ -213,6 +215,38 @@ internal fun mapFromBankAccount(bankAccount: BankAccount?): WritableMap? {
return bankAccountMap
}

internal fun mapToUSBankAccountHolderType(type: String?): PaymentMethod.USBankAccount.USBankAccountHolderType {
return when (type) {
"Company" -> PaymentMethod.USBankAccount.USBankAccountHolderType.COMPANY
"Individual" -> PaymentMethod.USBankAccount.USBankAccountHolderType.INDIVIDUAL
else -> PaymentMethod.USBankAccount.USBankAccountHolderType.INDIVIDUAL
}
}

internal fun mapFromUSBankAccountHolderType(type: PaymentMethod.USBankAccount.USBankAccountHolderType): String {
return when (type) {
PaymentMethod.USBankAccount.USBankAccountHolderType.COMPANY -> "Company"
PaymentMethod.USBankAccount.USBankAccountHolderType.INDIVIDUAL -> "Individual"
else -> "Unknown"
}
}

internal fun mapToUSBankAccountType(type: String?): PaymentMethod.USBankAccount.USBankAccountType {
return when (type) {
"Savings" -> PaymentMethod.USBankAccount.USBankAccountType.SAVINGS
"Checking" -> PaymentMethod.USBankAccount.USBankAccountType.CHECKING
else -> PaymentMethod.USBankAccount.USBankAccountType.CHECKING
}
}

internal fun mapFromUSBankAccountType(type: PaymentMethod.USBankAccount.USBankAccountType): String {
return when (type) {
PaymentMethod.USBankAccount.USBankAccountType.CHECKING -> "Checking"
PaymentMethod.USBankAccount.USBankAccountType.SAVINGS -> "Savings"
else -> "Unknown"
}
}

internal fun mapFromCard(card: Card?): WritableMap? {
val cardMap: WritableMap = WritableNativeMap()

Expand Down Expand Up @@ -279,6 +313,7 @@ internal fun mapFromPaymentMethod(paymentMethod: PaymentMethod): WritableMap {
val ideal: WritableMap = WritableNativeMap()
val fpx: WritableMap = WritableNativeMap()
val upi: WritableMap = WritableNativeMap()
val usBankAccount: WritableMap = WritableNativeMap()

card.putString("brand", mapCardBrand(paymentMethod.card?.brand))
card.putString("country", paymentMethod.card?.country)
Expand Down Expand Up @@ -315,6 +350,28 @@ internal fun mapFromPaymentMethod(paymentMethod: PaymentMethod): WritableMap {

upi.putString("vpa", paymentMethod.upi?.vpa)

// TODO: Remove reflection once USBankAccount is public payment method in stripe-android
try {
PaymentMethod::class.java.getDeclaredField("usBankAccount").let {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reflection for now for testing, won't need this once feature is public

it.isAccessible = true
val bankAccountPM = it.get(paymentMethod) as PaymentMethod.USBankAccount
usBankAccount.putString("routingNumber", bankAccountPM.routingNumber)
usBankAccount.putString("accountHolderType", mapFromUSBankAccountHolderType(bankAccountPM.accountHolderType))
usBankAccount.putString("accountType", mapFromUSBankAccountType(bankAccountPM.accountType))
usBankAccount.putString("last4", bankAccountPM.last4)
usBankAccount.putString("bankName", bankAccountPM.bankName)
usBankAccount.putString("linkedAccount", bankAccountPM.linkedAccount)
usBankAccount.putString("fingerprint", bankAccountPM.fingerprint)
usBankAccount.putString("fingerprint", bankAccountPM.fingerprint)
usBankAccount.putString("preferredNetworks", bankAccountPM.networks?.preferred)
usBankAccount.putArray("supportedNetworks", bankAccountPM.networks?.supported as? ReadableArray)
}
} catch (e: java.lang.Exception) {
Log.w(
"StripeReactNative",
"Unable to find USBankAccount method:" + e.message)
}

pm.putString("id", paymentMethod.id)
pm.putString("type", mapPaymentMethodType(paymentMethod.type))
pm.putBoolean("livemode", paymentMethod.liveMode)
Expand All @@ -328,6 +385,7 @@ internal fun mapFromPaymentMethod(paymentMethod: PaymentMethod): WritableMap {
pm.putMap("Ideal", ideal)
pm.putMap("Fpx", fpx)
pm.putMap("Upi", upi)
pm.putMap("USBankAccount", usBankAccount)

return pm
}
Expand Down Expand Up @@ -377,6 +435,14 @@ internal fun mapFromPaymentIntentResult(paymentIntent: PaymentIntent): WritableM
return map
}

internal fun mapFromMicrodepositType(type: MicrodepositType): String {
return when (type) {
MicrodepositType.AMOUNTS -> "amounts"
MicrodepositType.DESCRIPTOR_CODE -> "descriptorCode"
else -> "unknown"
}
}

internal fun mapNextAction(type: NextActionType?, data: NextActionData?): WritableNativeMap? {
val nextActionMap = WritableNativeMap()
when (type) {
Expand All @@ -386,15 +452,14 @@ internal fun mapNextAction(type: NextActionType?, data: NextActionData?): Writab
nextActionMap.putString("redirectUrl", it.url.toString())
}
}
// TODO: This is currently private. Uncomment when ACHv2 is available on Android.
// NextActionType.VerifyWithMicrodeposits -> {
// (data as? NextActionData.VerifyWithMicrodeposits)?.let {
// nextActionMap.putString("type", "verifyWithMicrodeposits")
// nextActionMap.putString("arrivalDate", it.arrivalDate.toString())
// nextActionMap.putString("redirectUrl", it.hostedVerificationUrl)
// nextActionMap.putString("microdepositType", it.microdepositType.toString())
// }
// }
NextActionType.VerifyWithMicrodeposits -> {
(data as? NextActionData.VerifyWithMicrodeposits)?.let {
nextActionMap.putString("type", "verifyWithMicrodeposits")
nextActionMap.putString("arrivalDate", it.arrivalDate.toString())
nextActionMap.putString("redirectUrl", it.hostedVerificationUrl)
nextActionMap.putString("microdepositType", mapFromMicrodepositType(it.microdepositType))
}
}
NextActionType.DisplayOxxoDetails -> {
(data as? NextActionData.DisplayOxxoDetails)?.let {
nextActionMap.putString("type", "oxxoVoucher")
Expand Down Expand Up @@ -461,14 +526,14 @@ internal fun mapToAddress(addressMap: ReadableMap?, cardAddress: Address?): Addr
.setLine2(getValOr(addressMap, "line2"))
.setState(getValOr(addressMap, "state"))

cardAddress?.let { ca ->
ca.postalCode?.let {
address.setPostalCode(it)
}
ca.country?.let {
address.setCountry(it)
}
cardAddress?.let { ca ->
ca.postalCode?.let {
address.setPostalCode(it)
}
ca.country?.let {
address.setCountry(it)
}
}

return address.build()
}
Expand Down
Loading