From 0333f854a49a87ea1a88865b5c37ff2a9eb52a9c Mon Sep 17 00:00:00 2001 From: tjclawson-stripe <163896025+tjclawson-stripe@users.noreply.github.com> Date: Thu, 9 Jan 2025 14:50:55 -0500 Subject: [PATCH] Attach Mandate to PaymentOption (#9881) * Attach Mandate to PaymentOption Co-authored-by: jaynewstrom-stripe * Rename UiDefinitionFactoryHelper to NullUiDefinitionFactoryHelper * Add tests * Add google pay test --------- Co-authored-by: jaynewstrom-stripe --- .../embedded/NullUiDefinitionFactoryHelper.kt | 44 +++++++++ .../PaymentOptionDisplayDataFactory.kt | 27 +++++- .../embedded/SharedPaymentElementViewModel.kt | 10 +- .../SharedPaymentElementViewModelTest.kt | 91 +++++++++++++++++++ 4 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/NullUiDefinitionFactoryHelper.kt diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/NullUiDefinitionFactoryHelper.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/NullUiDefinitionFactoryHelper.kt new file mode 100644 index 00000000000..a640c9d3b2b --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/NullUiDefinitionFactoryHelper.kt @@ -0,0 +1,44 @@ +package com.stripe.android.paymentelement.embedded + +import com.stripe.android.cards.CardAccountRangeRepository +import com.stripe.android.cards.CardNumber +import com.stripe.android.lpmfoundations.paymentmethod.UiDefinitionFactory +import com.stripe.android.model.AccountRange +import com.stripe.android.networking.StripeRepository +import com.stripe.android.uicore.utils.stateFlowOf +import kotlinx.coroutines.flow.StateFlow + +internal object NullUiDefinitionFactoryHelper { + val nullEmbeddedUiDefinitionFactory = UiDefinitionFactory.Arguments.Factory.Default( + cardAccountRangeRepositoryFactory = NullCardAccountRangeRepositoryFactory, + linkConfigurationCoordinator = null, + onLinkInlineSignupStateChanged = { + throw IllegalStateException("Not possible.") + }, + ) +} + +private object NullCardAccountRangeRepositoryFactory : CardAccountRangeRepository.Factory { + override fun create(): CardAccountRangeRepository { + return NullCardAccountRangeRepository + } + + override fun createWithStripeRepository( + stripeRepository: StripeRepository, + publishableKey: String + ): CardAccountRangeRepository { + return NullCardAccountRangeRepository + } + + private object NullCardAccountRangeRepository : CardAccountRangeRepository { + override suspend fun getAccountRange(cardNumber: CardNumber.Unvalidated): AccountRange? { + return null + } + + override suspend fun getAccountRanges(cardNumber: CardNumber.Unvalidated): List? { + return null + } + + override val loading: StateFlow = stateFlowOf(false) + } +} diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/PaymentOptionDisplayDataFactory.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/PaymentOptionDisplayDataFactory.kt index cd7cf007a3c..00ffd806070 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/PaymentOptionDisplayDataFactory.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/PaymentOptionDisplayDataFactory.kt @@ -1,6 +1,8 @@ package com.stripe.android.paymentelement.embedded import android.content.Context +import androidx.compose.ui.text.AnnotatedString +import com.stripe.android.lpmfoundations.paymentmethod.PaymentMethodMetadata import com.stripe.android.paymentelement.EmbeddedPaymentElement import com.stripe.android.paymentelement.ExperimentalEmbeddedPaymentElementApi import com.stripe.android.paymentsheet.model.PaymentSelection @@ -16,11 +18,32 @@ internal class PaymentOptionDisplayDataFactory @Inject constructor( private val iconLoader: PaymentSelection.IconLoader, private val context: Context, ) { - fun create(selection: PaymentSelection?): EmbeddedPaymentElement.PaymentOptionDisplayData? { + fun create( + selection: PaymentSelection?, + paymentMethodMetadata: PaymentMethodMetadata, + ): EmbeddedPaymentElement.PaymentOptionDisplayData? { if (selection == null) { return null } + val mandate = when (selection) { + is PaymentSelection.New -> { + paymentMethodMetadata.formElementsForCode( + code = selection.paymentMethodType, + uiDefinitionFactoryArgumentsFactory = NullUiDefinitionFactoryHelper.nullEmbeddedUiDefinitionFactory + )?.firstNotNullOfOrNull { it.mandateText } + } + is PaymentSelection.Saved -> { + selection.mandateText( + paymentMethodMetadata.merchantName, + paymentMethodMetadata.hasIntentToSetup() + ) + } + is PaymentSelection.ExternalPaymentMethod -> null + PaymentSelection.GooglePay -> null + PaymentSelection.Link -> null + } + return EmbeddedPaymentElement.PaymentOptionDisplayData( label = selection.label.resolve(context), imageLoader = { @@ -32,7 +55,7 @@ internal class PaymentOptionDisplayDataFactory @Inject constructor( }, billingDetails = null, paymentMethodType = selection.paymentMethodType, - mandateText = null, + mandateText = if (mandate == null) null else AnnotatedString(mandate.resolve(context)) ) } } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/SharedPaymentElementViewModel.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/SharedPaymentElementViewModel.kt index 8e0a87f30ca..8f5d88643b2 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/SharedPaymentElementViewModel.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/SharedPaymentElementViewModel.kt @@ -99,7 +99,15 @@ internal class SharedPaymentElementViewModel @Inject constructor( init { viewModelScope.launch { selectionHolder.selection.collect { selection -> - _paymentOption.value = paymentOptionDisplayDataFactory.create(selection) + val state = confirmationStateHolder.state + if (state == null) { + _paymentOption.value = null + } else { + _paymentOption.value = paymentOptionDisplayDataFactory.create( + selection = selection, + paymentMethodMetadata = state.paymentMethodMetadata, + ) + } } } } diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/SharedPaymentElementViewModelTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/SharedPaymentElementViewModelTest.kt index bb276801639..05e610e39e1 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/SharedPaymentElementViewModelTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/SharedPaymentElementViewModelTest.kt @@ -9,6 +9,9 @@ import com.stripe.android.common.model.asCommonConfiguration import com.stripe.android.isInstanceOf import com.stripe.android.lpmfoundations.paymentmethod.PaymentMethodMetadataFactory import com.stripe.android.model.PaymentIntentFixtures +import com.stripe.android.model.PaymentMethodCreateParams +import com.stripe.android.model.PaymentMethodFixtures +import com.stripe.android.model.SetupIntentFixtures import com.stripe.android.paymentelement.EmbeddedPaymentElement import com.stripe.android.paymentelement.ExperimentalEmbeddedPaymentElementApi import com.stripe.android.paymentelement.confirmation.FakeConfirmationHandler @@ -303,6 +306,94 @@ internal class SharedPaymentElementViewModelTest { assertThat(embeddedContentHelper.dataLoadedTurbine.awaitItem()).isNotNull() } + @Test + fun `selecting lpm PaymentOption with mandate attaches mandate to paymentMethodMetadata`() { + mandateTest( + paymentSelection = PaymentMethodFixtures.CASHAPP_PAYMENT_SELECTION.copy( + paymentMethodCreateParams = PaymentMethodCreateParams(code = "cashapp", requiresMandate = true) + ), + validate = { + assertThat(it?.mandateText).isNotNull() + } + ) + } + + @Test + fun `selecting saved card does not attach mandate to paymentMethodMetadata`() { + mandateTest( + paymentSelection = PaymentSelection.Saved(PaymentMethodFixtures.CARD_PAYMENT_METHOD), + validate = { + assertThat(it?.mandateText).isNull() + } + ) + } + + @Test + fun `selecting new card does attach mandate to paymentMethodMetadata`() { + mandateTest( + paymentSelection = PaymentMethodFixtures.CARD_PAYMENT_SELECTION, + validate = { + assertThat(it?.mandateText).isNotNull() + } + ) + } + + @Test + fun `selecting google pay does not attach mandate to paymentMethodMetadata`() { + mandateTest( + paymentSelection = PaymentSelection.GooglePay, + validate = { + assertThat(it?.mandateText).isNull() + } + ) + } + + private fun mandateTest( + paymentSelection: PaymentSelection, + validate: (paymentOption: EmbeddedPaymentElement.PaymentOptionDisplayData?) -> Unit + ) = testScenario { + val configuration = EmbeddedPaymentElement.Configuration.Builder("Example, Inc.").build() + configurationHandler.emit( + Result.success( + PaymentElementLoader.State( + config = configuration.asCommonConfiguration(), + customer = null, + linkState = null, + paymentSelection = paymentSelection, + validationError = null, + paymentMethodMetadata = PaymentMethodMetadataFactory.create( + stripeIntent = SetupIntentFixtures.SI_SUCCEEDED.copy( + paymentMethodTypes = listOf("card", "cashapp"), + ), + billingDetailsCollectionConfiguration = configuration + .billingDetailsCollectionConfiguration, + allowsDelayedPaymentMethods = configuration.allowsDelayedPaymentMethods, + allowsPaymentMethodsRequiringShippingAddress = configuration + .allowsPaymentMethodsRequiringShippingAddress, + isGooglePayReady = true, + cbcEligibility = CardBrandChoiceEligibility.Ineligible, + ), + ) + ) + ) + + assertThat(selectionHolder.selection.value?.paymentMethodType).isNull() + viewModel.paymentOption.test { + assertThat(awaitItem()).isNull() + + assertThat( + viewModel.configure( + PaymentSheet.IntentConfiguration( + PaymentSheet.IntentConfiguration.Mode.Setup("USD"), + ), + configuration = configuration, + ) + ).isInstanceOf() + validate.invoke(awaitItem()) + } + assertThat(embeddedContentHelper.dataLoadedTurbine.awaitItem()).isNotNull() + } + private fun testScenario( block: suspend Scenario.() -> Unit, ) = runTest {