diff --git a/CHANGELOG.md b/CHANGELOG.md index ee312797e49..5c156fd4bbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## XX.XX.XX - 2023-XX-XX +### Payments +* [ADDED][8344](https://github.com/stripe/stripe-android/pull/8344) Added support for `onBehalfOf` to `CardInputWidget`, `CardMultilineWidget`, and `CardFormView`. This parameter may be required when setting a connected account as the merchant of record for a payment. For more information, see the [Connect docs](https://docs.stripe.com/connect/charges#on_behalf_of). + ## 20.41.1 - 2024-04-22 ### PaymentSheet diff --git a/payments-core-testing/src/main/java/com/stripe/android/testing/AbsFakeStripeRepository.kt b/payments-core-testing/src/main/java/com/stripe/android/testing/AbsFakeStripeRepository.kt index 44fdf10b51f..ec626fcba74 100644 --- a/payments-core-testing/src/main/java/com/stripe/android/testing/AbsFakeStripeRepository.kt +++ b/payments-core-testing/src/main/java/com/stripe/android/testing/AbsFakeStripeRepository.kt @@ -431,6 +431,7 @@ abstract class AbsFakeStripeRepository : StripeRepository { override suspend fun retrieveCardElementConfig( requestOptions: ApiRequest.Options, + params: Map? ): Result { TODO("Not yet implemented") } diff --git a/payments-core/api/payments-core.api b/payments-core/api/payments-core.api index 5d658f791c3..b580197f0c9 100644 --- a/payments-core/api/payments-core.api +++ b/payments-core/api/payments-core.api @@ -7412,9 +7412,11 @@ public final class com/stripe/android/view/CardFormView : android/widget/LinearL public synthetic fun (Landroid/content/Context;Landroid/util/AttributeSet;IILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getBrand ()Lcom/stripe/android/model/CardBrand; public final fun getCardParams ()Lcom/stripe/android/model/CardParams; + public final fun getOnBehalfOf ()Ljava/lang/String; public final fun getPaymentMethodCreateParams ()Lcom/stripe/android/model/PaymentMethodCreateParams; public final fun setCardValidCallback (Lcom/stripe/android/view/CardValidCallback;)V public fun setEnabled (Z)V + public final fun setOnBehalfOf (Ljava/lang/String;)V public final fun setPreferredNetworks (Ljava/util/List;)V } @@ -7445,6 +7447,7 @@ public final class com/stripe/android/view/CardInputWidget : android/widget/Line public fun clear ()V public final fun getBrand ()Lcom/stripe/android/model/CardBrand; public fun getCardParams ()Lcom/stripe/android/model/CardParams; + public final fun getOnBehalfOf ()Ljava/lang/String; public fun getPaymentMethodCard ()Lcom/stripe/android/model/PaymentMethodCreateParams$Card; public fun getPaymentMethodCreateParams ()Lcom/stripe/android/model/PaymentMethodCreateParams; public final fun getPostalCodeEnabled ()Z @@ -7463,6 +7466,7 @@ public final class com/stripe/android/view/CardInputWidget : android/widget/Line public fun setEnabled (Z)V public fun setExpiryDate (II)V public fun setExpiryDateTextWatcher (Landroid/text/TextWatcher;)V + public final fun setOnBehalfOf (Ljava/lang/String;)V public final fun setPostalCodeEnabled (Z)V public final fun setPostalCodeRequired (Z)V public fun setPostalCodeTextWatcher (Landroid/text/TextWatcher;)V @@ -7480,6 +7484,7 @@ public final class com/stripe/android/view/CardMultilineWidget : android/widget/ public fun clear ()V public final synthetic fun getBrand ()Lcom/stripe/android/model/CardBrand; public fun getCardParams ()Lcom/stripe/android/model/CardParams; + public final fun getOnBehalfOf ()Ljava/lang/String; public final fun getPaymentMethodBillingDetails ()Lcom/stripe/android/model/PaymentMethod$BillingDetails; public final fun getPaymentMethodBillingDetailsBuilder ()Lcom/stripe/android/model/PaymentMethod$BillingDetails$Builder; public fun getPaymentMethodCard ()Lcom/stripe/android/model/PaymentMethodCreateParams$Card; @@ -7499,6 +7504,7 @@ public final class com/stripe/android/view/CardMultilineWidget : android/widget/ public fun setEnabled (Z)V public fun setExpiryDate (II)V public fun setExpiryDateTextWatcher (Landroid/text/TextWatcher;)V + public final fun setOnBehalfOf (Ljava/lang/String;)V public final fun setPostalCodeRequired (Z)V public fun setPostalCodeTextWatcher (Landroid/text/TextWatcher;)V public final fun setPreferredNetworks (Ljava/util/List;)V diff --git a/payments-core/src/main/java/com/stripe/android/networking/StripeApiRepository.kt b/payments-core/src/main/java/com/stripe/android/networking/StripeApiRepository.kt index bee5a13a82e..9bd3aa7bd74 100644 --- a/payments-core/src/main/java/com/stripe/android/networking/StripeApiRepository.kt +++ b/payments-core/src/main/java/com/stripe/android/networking/StripeApiRepository.kt @@ -1460,12 +1460,13 @@ class StripeApiRepository @JvmOverloads internal constructor( override suspend fun retrieveCardElementConfig( requestOptions: ApiRequest.Options, + params: Map? ): Result { return fetchStripeModelResult( apiRequestFactory.createGet( url = mobileCardElementConfigUrl, options = requestOptions, - params = null, + params = params, ), jsonParser = MobileCardElementConfigParser(), ) diff --git a/payments-core/src/main/java/com/stripe/android/networking/StripeRepository.kt b/payments-core/src/main/java/com/stripe/android/networking/StripeRepository.kt index 2ca9e260687..2ed3ecd9483 100644 --- a/payments-core/src/main/java/com/stripe/android/networking/StripeRepository.kt +++ b/payments-core/src/main/java/com/stripe/android/networking/StripeRepository.kt @@ -394,6 +394,7 @@ interface StripeRepository { @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) suspend fun retrieveCardElementConfig( requestOptions: ApiRequest.Options, + params: Map? = null ): Result @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) diff --git a/payments-core/src/main/java/com/stripe/android/view/CardFormView.kt b/payments-core/src/main/java/com/stripe/android/view/CardFormView.kt index 776e48c99d4..9be7b98c504 100644 --- a/payments-core/src/main/java/com/stripe/android/view/CardFormView.kt +++ b/payments-core/src/main/java/com/stripe/android/view/CardFormView.kt @@ -183,6 +183,23 @@ class CardFormView @JvmOverloads constructor( val paymentMethodCreateParams: PaymentMethodCreateParams? get() = paymentMethodCard?.let { PaymentMethodCreateParams.create(it) } + /** + * The Stripe account ID (if any) which is the business of record. + * See [use cases](https://docs.stripe.com/connect/charges#on_behalf_of) to determine if this option is relevant + * for your integration. This should match the + * [on_behalf_of](https://docs.stripe.com/api/payment_intents/create#create_payment_intent-on_behalf_of) + * provided on the Intent used when confirming payment. + */ + var onBehalfOf: String? = null + set(value) { + if (isAttachedToWindow) { + doWithCardWidgetViewModel(viewModelStoreOwner) { viewModel -> + viewModel.onBehalfOf = value + } + } + field = value + } + init { orientation = VERTICAL diff --git a/payments-core/src/main/java/com/stripe/android/view/CardInputWidget.kt b/payments-core/src/main/java/com/stripe/android/view/CardInputWidget.kt index 9134bb82e8c..976d4f9dc7d 100644 --- a/payments-core/src/main/java/com/stripe/android/view/CardInputWidget.kt +++ b/payments-core/src/main/java/com/stripe/android/view/CardInputWidget.kt @@ -345,6 +345,23 @@ class CardInputWidget @JvmOverloads constructor( updatePostalRequired() } + /** + * The Stripe account ID (if any) which is the business of record. + * See [use cases](https://docs.stripe.com/connect/charges#on_behalf_of) to determine if this option is relevant + * for your integration. This should match the + * [on_behalf_of](https://docs.stripe.com/api/payment_intents/create#create_payment_intent-on_behalf_of) + * provided on the Intent used when confirming payment. + */ + var onBehalfOf: String? = null + set(value) { + if (isAttachedToWindow) { + doWithCardWidgetViewModel(viewModelStoreOwner) { viewModel -> + viewModel.onBehalfOf = value + } + } + field = value + } + private fun updatePostalRequired() { if (isPostalRequired()) { requiredFields.add(postalCodeEditText) diff --git a/payments-core/src/main/java/com/stripe/android/view/CardMultilineWidget.kt b/payments-core/src/main/java/com/stripe/android/view/CardMultilineWidget.kt index d22fb316f71..43a155cc1e5 100644 --- a/payments-core/src/main/java/com/stripe/android/view/CardMultilineWidget.kt +++ b/payments-core/src/main/java/com/stripe/android/view/CardMultilineWidget.kt @@ -211,6 +211,23 @@ class CardMultilineWidget @JvmOverloads constructor( null } + /** + * The Stripe account ID (if any) which is the business of record. + * See [use cases](https://docs.stripe.com/connect/charges#on_behalf_of) to determine if this option is relevant + * for your integration. This should match the + * [on_behalf_of](https://docs.stripe.com/api/payment_intents/create#create_payment_intent-on_behalf_of) + * provided on the Intent used when confirming payment. + */ + var onBehalfOf: String? = null + set(value) { + if (isAttachedToWindow) { + doWithCardWidgetViewModel(viewModelStoreOwner) { viewModel -> + viewModel.onBehalfOf = value + } + } + field = value + } + /** * A [CardParams] representing the card details and postal code if all fields are valid; * otherwise `null` diff --git a/payments-core/src/main/java/com/stripe/android/view/CardWidgetViewModel.kt b/payments-core/src/main/java/com/stripe/android/view/CardWidgetViewModel.kt index bdf1f5b72d1..469c3f454b3 100644 --- a/payments-core/src/main/java/com/stripe/android/view/CardWidgetViewModel.kt +++ b/payments-core/src/main/java/com/stripe/android/view/CardWidgetViewModel.kt @@ -3,9 +3,11 @@ package com.stripe.android.view import android.view.View import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.findViewTreeViewModelStoreOwner import androidx.lifecycle.lifecycleScope @@ -29,13 +31,24 @@ import javax.inject.Provider internal class CardWidgetViewModel( private val paymentConfigProvider: Provider, private val stripeRepository: StripeRepository, - dispatcher: CoroutineDispatcher = Dispatchers.IO, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, + private val handle: SavedStateHandle ) : ViewModel() { private val _isCbcEligible = MutableStateFlow(false) val isCbcEligible: StateFlow = _isCbcEligible + var onBehalfOf: String? = handle[ON_BEHALF_OF] + set(value) { + field = value + handle[ON_BEHALF_OF] = value + getEligibility() + } init { + getEligibility() + } + + private fun getEligibility() { viewModelScope.launch(dispatcher) { _isCbcEligible.value = determineCbcEligibility() } @@ -49,6 +62,9 @@ internal class CardWidgetViewModel( apiKey = paymentConfig.publishableKey, stripeAccount = paymentConfig.stripeAccountId, ), + params = onBehalfOf?.let { + mapOf("on_behalf_of" to it) + } ) val config = response.getOrNull() @@ -69,9 +85,14 @@ internal class CardWidgetViewModel( return CardWidgetViewModel( paymentConfigProvider = { PaymentConfiguration.getInstance(context) }, stripeRepository = stripeRepository, + handle = extras.createSavedStateHandle() ) as T } } + + companion object { + internal const val ON_BEHALF_OF = "on_behalf_of" + } } internal fun View.doWithCardWidgetViewModel( diff --git a/payments-core/src/test/java/com/stripe/android/utils/CardElementTestHelper.kt b/payments-core/src/test/java/com/stripe/android/utils/CardElementTestHelper.kt index 2fe699e972d..b98530ec941 100644 --- a/payments-core/src/test/java/com/stripe/android/utils/CardElementTestHelper.kt +++ b/payments-core/src/test/java/com/stripe/android/utils/CardElementTestHelper.kt @@ -1,5 +1,6 @@ package com.stripe.android.utils +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner import com.stripe.android.ApiKeyFixtures @@ -24,6 +25,7 @@ internal object CardElementTestHelper { stripeRepository = object : AbsFakeStripeRepository() { override suspend fun retrieveCardElementConfig( requestOptions: ApiRequest.Options, + params: Map? ): Result { return Result.success( MobileCardElementConfig( @@ -34,6 +36,7 @@ internal object CardElementTestHelper { ) } }, + handle = SavedStateHandle() ) val viewModelStore = ViewModelStore().apply { diff --git a/payments-core/src/test/java/com/stripe/android/utils/FakeCardElementConfigRepository.kt b/payments-core/src/test/java/com/stripe/android/utils/FakeCardElementConfigRepository.kt index e5d251133d0..1afa0aa8134 100644 --- a/payments-core/src/test/java/com/stripe/android/utils/FakeCardElementConfigRepository.kt +++ b/payments-core/src/test/java/com/stripe/android/utils/FakeCardElementConfigRepository.kt @@ -33,6 +33,7 @@ class FakeCardElementConfigRepository : AbsFakeStripeRepository() { override suspend fun retrieveCardElementConfig( requestOptions: ApiRequest.Options, + params: Map? ): Result { return channel.receive() } diff --git a/payments-core/src/test/java/com/stripe/android/view/CardNumberEditTextTest.kt b/payments-core/src/test/java/com/stripe/android/view/CardNumberEditTextTest.kt index ac7087a256c..e2a908abaf8 100644 --- a/payments-core/src/test/java/com/stripe/android/view/CardNumberEditTextTest.kt +++ b/payments-core/src/test/java/com/stripe/android/view/CardNumberEditTextTest.kt @@ -3,6 +3,7 @@ package com.stripe.android.view import android.text.TextWatcher import android.view.ViewGroup import androidx.appcompat.view.ContextThemeWrapper +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner import androidx.test.core.app.ApplicationProvider @@ -1072,6 +1073,7 @@ internal class CardNumberEditTextTest { paymentConfigProvider = { PaymentConfiguration.getInstance(context) }, stripeRepository = repository, dispatcher = dispatcher, + handle = SavedStateHandle() ) val store = ViewModelStore().apply { diff --git a/payments-core/src/test/java/com/stripe/android/view/CardWidgetViewModelTest.kt b/payments-core/src/test/java/com/stripe/android/view/CardWidgetViewModelTest.kt index f9f80f425cc..d9b29546888 100644 --- a/payments-core/src/test/java/com/stripe/android/view/CardWidgetViewModelTest.kt +++ b/payments-core/src/test/java/com/stripe/android/view/CardWidgetViewModelTest.kt @@ -1,5 +1,6 @@ package com.stripe.android.view +import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import com.stripe.android.PaymentConfiguration @@ -26,6 +27,7 @@ class CardWidgetViewModelTest { paymentConfigProvider = { paymentConfig }, stripeRepository = stripeRepository, dispatcher = testDispatcher, + handle = SavedStateHandle() ) viewModel.isCbcEligible.test { @@ -44,6 +46,7 @@ class CardWidgetViewModelTest { paymentConfigProvider = { paymentConfig }, stripeRepository = stripeRepository, dispatcher = testDispatcher, + handle = SavedStateHandle() ) viewModel.isCbcEligible.test { @@ -61,6 +64,7 @@ class CardWidgetViewModelTest { paymentConfigProvider = { paymentConfig }, stripeRepository = stripeRepository, dispatcher = testDispatcher, + handle = SavedStateHandle() ) viewModel.isCbcEligible.test { @@ -69,4 +73,62 @@ class CardWidgetViewModelTest { expectNoEvents() } } + + @Test + fun `Saves OBO to savedStateHandle`() = runTest(testDispatcher) { + val stripeRepository = FakeCardElementConfigRepository() + val handle = SavedStateHandle() + + val viewModel = CardWidgetViewModel( + paymentConfigProvider = { paymentConfig }, + stripeRepository = stripeRepository, + dispatcher = testDispatcher, + handle = handle + ) + + viewModel.onBehalfOf = "test" + val obo: String? = handle["on_behalf_of"] + assertThat(obo).isEqualTo("test") + } + + @Test + fun `Setting valid OBO re-fetches correct eligibility`() = runTest(testDispatcher) { + val stripeRepository = FakeCardElementConfigRepository() + + val viewModel = CardWidgetViewModel( + paymentConfigProvider = { paymentConfig }, + stripeRepository = stripeRepository, + dispatcher = testDispatcher, + handle = SavedStateHandle() + ) + + viewModel.isCbcEligible.test { + assertThat(awaitItem()).isFalse() + stripeRepository.enqueueEligible() + viewModel.onBehalfOf = "valid_obo" + assertThat(awaitItem()).isTrue() + } + } + + @Test + fun `Setting invalid OBO re-fetches correct eligibility`() = runTest(testDispatcher) { + val stripeRepository = FakeCardElementConfigRepository() + + val viewModel = CardWidgetViewModel( + paymentConfigProvider = { paymentConfig }, + stripeRepository = stripeRepository, + dispatcher = testDispatcher, + handle = SavedStateHandle() + ) + + stripeRepository.enqueueEligible() + + viewModel.isCbcEligible.test { + viewModel.onBehalfOf = "valid_obo" + assertThat(awaitItem()).isTrue() + stripeRepository.enqueueNotEligible() + viewModel.onBehalfOf = "invalid_obo" + assertThat(awaitItem()).isFalse() + } + } }