diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsElement.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsElement.kt index 566328b983f..c992019e11e 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsElement.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsElement.kt @@ -6,10 +6,11 @@ import com.stripe.android.cards.CardAccountRangeRepository import com.stripe.android.core.strings.ResolvableString import com.stripe.android.model.CardBrand import com.stripe.android.ui.core.cbc.CardBrandChoiceEligibility +import com.stripe.android.ui.core.elements.CardDetailsUtil.getExpiryMonthFormFieldEntry +import com.stripe.android.ui.core.elements.CardDetailsUtil.getExpiryYearFormFieldEntry import com.stripe.android.uicore.elements.IdentifierSpec import com.stripe.android.uicore.elements.SectionFieldErrorController import com.stripe.android.uicore.elements.SectionMultiFieldElement -import com.stripe.android.uicore.elements.convertTo4DigitDate import com.stripe.android.uicore.forms.FormFieldEntry import com.stripe.android.uicore.utils.combineAsStateFlow import com.stripe.android.uicore.utils.mapAsStateFlow @@ -107,31 +108,3 @@ internal class CardDetailsElement( return combineAsStateFlow(flows) { it.toList() } } } - -private fun getExpiryMonthFormFieldEntry(entry: FormFieldEntry): FormFieldEntry { - var month = -1 - entry.value?.let { date -> - val newString = convertTo4DigitDate(date) - if (newString.length == 4) { - month = requireNotNull(newString.take(2).toIntOrNull()) - } - } - - return entry.copy( - value = month.toString().padStart(length = 2, padChar = '0') - ) -} - -private fun getExpiryYearFormFieldEntry(entry: FormFieldEntry): FormFieldEntry { - var year = -1 - entry.value?.let { date -> - val newString = convertTo4DigitDate(date) - if (newString.length == 4) { - year = requireNotNull(newString.takeLast(2).toIntOrNull()) + 2000 - } - } - - return entry.copy( - value = year.toString() - ) -} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsUtil.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsUtil.kt new file mode 100644 index 00000000000..96038b0d2ae --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsUtil.kt @@ -0,0 +1,47 @@ +package com.stripe.android.ui.core.elements + +import androidx.annotation.RestrictTo +import com.stripe.android.uicore.elements.IdentifierSpec +import com.stripe.android.uicore.elements.convertTo4DigitDate +import com.stripe.android.uicore.forms.FormFieldEntry + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +object CardDetailsUtil { + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + fun createExpiryDateFormFieldValues(entry: FormFieldEntry): Map { + return mapOf( + IdentifierSpec.CardExpMonth to getExpiryMonthFormFieldEntry(entry), + IdentifierSpec.CardExpYear to getExpiryYearFormFieldEntry(entry) + ) + } + + @SuppressWarnings("MagicNumber") + internal fun getExpiryMonthFormFieldEntry(entry: FormFieldEntry): FormFieldEntry { + var month = -1 + entry.value?.let { date -> + val newString = convertTo4DigitDate(date) + if (newString.length == 4) { + month = requireNotNull(newString.take(2).toIntOrNull()) + } + } + + return entry.copy( + value = month.toString().padStart(length = 2, padChar = '0') + ) + } + + @SuppressWarnings("MagicNumber") + internal fun getExpiryYearFormFieldEntry(entry: FormFieldEntry): FormFieldEntry { + var year = -1 + entry.value?.let { date -> + val newString = convertTo4DigitDate(date) + if (newString.length == 4) { + year = requireNotNull(newString.takeLast(2).toIntOrNull()) + 2000 + } + } + + return entry.copy( + value = year.toString() + ) + } +} diff --git a/paymentsheet/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt b/paymentsheet/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt index 2c20038e7f4..04f11b8f12a 100644 --- a/paymentsheet/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt +++ b/paymentsheet/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt @@ -19,6 +19,7 @@ import com.stripe.android.link.injection.NativeLinkComponent import com.stripe.android.link.model.AccountStatus import com.stripe.android.link.model.LinkAccount import com.stripe.android.link.ui.LinkAppBarState +import com.stripe.android.paymentelement.confirmation.ConfirmationHandler import com.stripe.android.paymentsheet.R import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -28,8 +29,10 @@ import javax.inject.Inject internal class LinkActivityViewModel @Inject constructor( val activityRetainedComponent: NativeLinkComponent, + confirmationHandlerFactory: ConfirmationHandler.Factory, private val linkAccountManager: LinkAccountManager, ) : ViewModel(), DefaultLifecycleObserver { + val confirmationHandler = confirmationHandlerFactory.create(viewModelScope) private val _linkState = MutableStateFlow( value = LinkAppBarState( navigationIcon = R.drawable.stripe_link_close, diff --git a/paymentsheet/src/main/java/com/stripe/android/link/ui/wallet/WalletUiState.kt b/paymentsheet/src/main/java/com/stripe/android/link/ui/wallet/WalletUiState.kt index b81809155d6..098016a239d 100644 --- a/paymentsheet/src/main/java/com/stripe/android/link/ui/wallet/WalletUiState.kt +++ b/paymentsheet/src/main/java/com/stripe/android/link/ui/wallet/WalletUiState.kt @@ -5,6 +5,7 @@ import com.stripe.android.core.strings.ResolvableString import com.stripe.android.link.ui.PrimaryButtonState import com.stripe.android.model.ConsumerPaymentDetails import com.stripe.android.model.ConsumerPaymentDetails.Card +import com.stripe.android.uicore.forms.FormFieldEntry @Immutable internal data class WalletUiState( @@ -13,8 +14,14 @@ internal data class WalletUiState( val isProcessing: Boolean, val primaryButtonLabel: ResolvableString, val hasCompleted: Boolean, + val errorMessage: ResolvableString? = null, + val expiryDateInput: FormFieldEntry = FormFieldEntry(null), + val cvcInput: FormFieldEntry = FormFieldEntry(null), + val alertMessage: ResolvableString? = null, ) { + val selectedCard: Card? = selectedItem as? Card + val showBankAccountTerms = selectedItem is ConsumerPaymentDetails.BankAccount val primaryButtonState: PrimaryButtonState @@ -23,7 +30,11 @@ internal data class WalletUiState( val isExpired = card?.isExpired ?: false val requiresCvcRecollection = card?.cvcCheck?.requiresRecollection ?: false - val disableButton = isExpired || requiresCvcRecollection + val isMissingExpiryDateInput = (expiryDateInput.isComplete && cvcInput.isComplete).not() + val isMissingCvcInput = cvcInput.isComplete.not() + + val disableButton = (isExpired && isMissingExpiryDateInput) || + (requiresCvcRecollection && isMissingCvcInput) return if (hasCompleted) { PrimaryButtonState.Completed diff --git a/paymentsheet/src/main/java/com/stripe/android/link/ui/wallet/WalletViewModel.kt b/paymentsheet/src/main/java/com/stripe/android/link/ui/wallet/WalletViewModel.kt index 92bfee9ea41..d6a1cca7d86 100644 --- a/paymentsheet/src/main/java/com/stripe/android/link/ui/wallet/WalletViewModel.kt +++ b/paymentsheet/src/main/java/com/stripe/android/link/ui/wallet/WalletViewModel.kt @@ -5,31 +5,46 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory +import com.stripe.android.common.exception.stripeErrorMessage import com.stripe.android.core.Logger import com.stripe.android.core.strings.resolvableString import com.stripe.android.link.LinkActivityResult import com.stripe.android.link.LinkConfiguration import com.stripe.android.link.LinkScreen import com.stripe.android.link.account.LinkAccountManager +import com.stripe.android.link.confirmation.LinkConfirmationHandler import com.stripe.android.link.injection.NativeLinkComponent import com.stripe.android.link.model.LinkAccount import com.stripe.android.link.model.supportedPaymentMethodTypes +import com.stripe.android.model.CardBrand import com.stripe.android.model.ConsumerPaymentDetails +import com.stripe.android.model.ConsumerPaymentDetailsUpdateParams import com.stripe.android.model.PaymentIntent +import com.stripe.android.model.PaymentMethod +import com.stripe.android.model.PaymentMethodCreateParams import com.stripe.android.model.SetupIntent import com.stripe.android.model.StripeIntent import com.stripe.android.ui.core.Amount +import com.stripe.android.ui.core.FieldValuesToParamsMapConverter import com.stripe.android.ui.core.R +import com.stripe.android.ui.core.elements.CardDetailsUtil.createExpiryDateFormFieldValues +import com.stripe.android.ui.core.elements.CvcController +import com.stripe.android.uicore.elements.DateConfig +import com.stripe.android.uicore.elements.SimpleTextFieldController +import com.stripe.android.uicore.utils.mapAsStateFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +import com.stripe.android.link.confirmation.Result as LinkConfirmationResult internal class WalletViewModel @Inject constructor( private val configuration: LinkConfiguration, private val linkAccount: LinkAccount, private val linkAccountManager: LinkAccountManager, + private val linkConfirmationHandler: LinkConfirmationHandler, private val logger: Logger, private val navigate: (route: LinkScreen) -> Unit, private val navigateAndClearStack: (route: LinkScreen) -> Unit, @@ -49,8 +64,33 @@ internal class WalletViewModel @Inject constructor( val uiState: StateFlow = _uiState + val expiryDateController = SimpleTextFieldController( + textFieldConfig = DateConfig() + ) + val cvcController = CvcController( + cardBrandFlow = uiState.mapAsStateFlow { + (it.selectedItem as? ConsumerPaymentDetails.Card)?.brand ?: CardBrand.Unknown + } + ) + init { loadPaymentDetails() + + viewModelScope.launch { + expiryDateController.formFieldValue.collectLatest { input -> + _uiState.update { + it.copy(expiryDateInput = input) + } + } + } + + viewModelScope.launch { + cvcController.formFieldValue.collectLatest { input -> + _uiState.update { + it.copy(cvcInput = input) + } + } + } } private fun loadPaymentDetails() { @@ -85,12 +125,95 @@ internal class WalletViewModel @Inject constructor( fun onItemSelected(item: ConsumerPaymentDetails.PaymentDetails) { if (item == uiState.value.selectedItem) return + expiryDateController.onRawValueChange("") + cvcController.onRawValueChange("") + _uiState.update { it.copy(selectedItem = item) } } - fun onPrimaryButtonClicked() = Unit + fun onPrimaryButtonClicked() { + val paymentDetail = _uiState.value.selectedItem ?: return + _uiState.update { + it.copy(isProcessing = true) + } + + viewModelScope.launch { + performPaymentConfirmation(paymentDetail) + } + } + + private suspend fun performPaymentConfirmation( + selectedPaymentDetails: ConsumerPaymentDetails.PaymentDetails, + ) { + val card = selectedPaymentDetails as? ConsumerPaymentDetails.Card + val isExpired = card != null && card.isExpired + + if (isExpired) { + performPaymentDetailsUpdate(selectedPaymentDetails).fold( + onSuccess = { result -> + val updatedPaymentDetails = result.paymentDetails.single { + it.id == selectedPaymentDetails.id + } + performPaymentConfirmation(updatedPaymentDetails) + }, + onFailure = { error -> + _uiState.update { + it.copy( + alertMessage = error.stripeErrorMessage(), + isProcessing = false + ) + } + } + ) + } else { + // Confirm payment with LinkConfirmationHandler + performPaymentConfirmationWithCvc( + selectedPaymentDetails = selectedPaymentDetails, + cvc = cvcController.formFieldValue.value.takeIf { it.isComplete }?.value + ) + } + } + + private suspend fun performPaymentConfirmationWithCvc( + selectedPaymentDetails: ConsumerPaymentDetails.PaymentDetails, + cvc: String? + ) { + val result = linkConfirmationHandler.confirm( + paymentDetails = selectedPaymentDetails, + linkAccount = linkAccount, + cvc = cvc + ) + when (result) { + LinkConfirmationResult.Canceled -> Unit + is LinkConfirmationResult.Failed -> { + _uiState.update { + it.copy( + errorMessage = result.message, + isProcessing = false + ) + } + } + LinkConfirmationResult.Succeeded -> { + dismissWithResult(LinkActivityResult.Completed) + } + } + } + + private suspend fun performPaymentDetailsUpdate( + selectedPaymentDetails: ConsumerPaymentDetails.PaymentDetails + ): Result { + val paymentMethodCreateParams = uiState.value.toPaymentMethodCreateParams() + + val updateParams = ConsumerPaymentDetailsUpdateParams( + id = selectedPaymentDetails.id, + isDefault = selectedPaymentDetails.isDefault, + cardPaymentMethodCreateParamsMap = paymentMethodCreateParams.toParamMap() + ) + + return linkAccountManager.updatePaymentDetails(updateParams) + } fun onPayAnotherWayClicked() { dismissWithResult(LinkActivityResult.Canceled(LinkActivityResult.Canceled.Reason.PayAnotherWay)) @@ -130,6 +253,9 @@ internal class WalletViewModel @Inject constructor( WalletViewModel( configuration = parentComponent.configuration, linkAccountManager = parentComponent.linkAccountManager, + linkConfirmationHandler = parentComponent.linkConfirmationHandlerFactory.create( + confirmationHandler = parentComponent.viewModel.confirmationHandler + ), logger = parentComponent.logger, linkAccount = linkAccount, navigate = navigate, @@ -141,3 +267,12 @@ internal class WalletViewModel @Inject constructor( } } } + +private fun WalletUiState.toPaymentMethodCreateParams(): PaymentMethodCreateParams { + val expiryDateValues = createExpiryDateFormFieldValues(expiryDateInput) + return FieldValuesToParamsMapConverter.transformToPaymentMethodCreateParams( + fieldValuePairs = expiryDateValues, + code = PaymentMethod.Type.Card.code, + requiresMandate = false + ) +} diff --git a/paymentsheet/src/test/java/com/stripe/android/link/LinkActivityViewModelTest.kt b/paymentsheet/src/test/java/com/stripe/android/link/LinkActivityViewModelTest.kt index 22ec47f3415..fe9ad7f0c13 100644 --- a/paymentsheet/src/test/java/com/stripe/android/link/LinkActivityViewModelTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/link/LinkActivityViewModelTest.kt @@ -20,6 +20,7 @@ import com.google.common.truth.Truth.assertThat import com.stripe.android.link.account.FakeLinkAccountManager import com.stripe.android.link.account.LinkAccountManager import com.stripe.android.link.model.AccountStatus +import com.stripe.android.paymentelement.confirmation.FakeConfirmationHandler import com.stripe.android.testing.CoroutineTestRule import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -317,6 +318,7 @@ internal class LinkActivityViewModelTest { return LinkActivityViewModel( linkAccountManager = linkAccountManager, activityRetainedComponent = mock(), + confirmationHandlerFactory = { FakeConfirmationHandler() } ).apply { this.navController = navController this.dismissWithResult = dismissWithResult diff --git a/paymentsheet/src/test/java/com/stripe/android/link/confirmation/FakeLinkConfirmationHandler.kt b/paymentsheet/src/test/java/com/stripe/android/link/confirmation/FakeLinkConfirmationHandler.kt index ea50e090bee..0cfb2f4dbbd 100644 --- a/paymentsheet/src/test/java/com/stripe/android/link/confirmation/FakeLinkConfirmationHandler.kt +++ b/paymentsheet/src/test/java/com/stripe/android/link/confirmation/FakeLinkConfirmationHandler.kt @@ -5,10 +5,26 @@ import com.stripe.android.model.ConsumerPaymentDetails internal class FakeLinkConfirmationHandler : LinkConfirmationHandler { var confirmResult: Result = Result.Succeeded + val calls = arrayListOf() override suspend fun confirm( paymentDetails: ConsumerPaymentDetails.PaymentDetails, linkAccount: LinkAccount, cvc: String? - ) = confirmResult + ): Result { + calls.add( + element = Call( + paymentDetails = paymentDetails, + linkAccount = linkAccount, + cvc = cvc + ) + ) + return confirmResult + } + + data class Call( + val paymentDetails: ConsumerPaymentDetails.PaymentDetails, + val linkAccount: LinkAccount, + val cvc: String? + ) } diff --git a/paymentsheet/src/test/java/com/stripe/android/link/ui/wallet/WalletScreenTest.kt b/paymentsheet/src/test/java/com/stripe/android/link/ui/wallet/WalletScreenTest.kt index b48396aac48..eff7f976530 100644 --- a/paymentsheet/src/test/java/com/stripe/android/link/ui/wallet/WalletScreenTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/link/ui/wallet/WalletScreenTest.kt @@ -25,6 +25,8 @@ import com.stripe.android.core.strings.resolvableString import com.stripe.android.link.TestFactory import com.stripe.android.link.account.FakeLinkAccountManager import com.stripe.android.link.account.LinkAccountManager +import com.stripe.android.link.confirmation.FakeLinkConfirmationHandler +import com.stripe.android.link.confirmation.LinkConfirmationHandler import com.stripe.android.link.ui.BottomSheetContent import com.stripe.android.link.ui.PrimaryButtonTag import com.stripe.android.model.ConsumerPaymentDetails @@ -349,12 +351,14 @@ internal class WalletScreenTest { } private fun createViewModel( - linkAccountManager: LinkAccountManager = FakeLinkAccountManager() + linkAccountManager: LinkAccountManager = FakeLinkAccountManager(), + linkConfirmationHandler: LinkConfirmationHandler = FakeLinkConfirmationHandler() ): WalletViewModel { return WalletViewModel( configuration = TestFactory.LINK_CONFIGURATION, linkAccount = TestFactory.LINK_ACCOUNT, linkAccountManager = linkAccountManager, + linkConfirmationHandler = linkConfirmationHandler, logger = FakeLogger(), navigate = {}, navigateAndClearStack = {}, diff --git a/paymentsheet/src/test/java/com/stripe/android/link/ui/wallet/WalletUiStateTest.kt b/paymentsheet/src/test/java/com/stripe/android/link/ui/wallet/WalletUiStateTest.kt index 575a1965748..56b8ca0c134 100644 --- a/paymentsheet/src/test/java/com/stripe/android/link/ui/wallet/WalletUiStateTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/link/ui/wallet/WalletUiStateTest.kt @@ -1,22 +1,22 @@ package com.stripe.android.link.ui.wallet import com.google.common.truth.Truth.assertThat +import com.stripe.android.core.strings.ResolvableString import com.stripe.android.link.TestFactory import com.stripe.android.link.TestFactory.LINK_WALLET_PRIMARY_BUTTON_LABEL import com.stripe.android.link.ui.PrimaryButtonState +import com.stripe.android.model.ConsumerPaymentDetails import com.stripe.android.model.CvcCheck +import com.stripe.android.uicore.forms.FormFieldEntry import org.junit.Test class WalletUiStateTest { @Test fun testCompletedButtonState() { - val state = WalletUiState( - paymentDetailsList = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails, + val state = walletUiState( selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails.firstOrNull(), hasCompleted = true, - isProcessing = false, - primaryButtonLabel = LINK_WALLET_PRIMARY_BUTTON_LABEL ) assertThat(state.primaryButtonState).isEqualTo(PrimaryButtonState.Completed) @@ -24,12 +24,9 @@ class WalletUiStateTest { @Test fun testProcessingButtonState() { - val state = WalletUiState( - paymentDetailsList = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails, + val state = walletUiState( selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails.firstOrNull(), - hasCompleted = false, - isProcessing = true, - primaryButtonLabel = LINK_WALLET_PRIMARY_BUTTON_LABEL + isProcessing = true ) assertThat(state.primaryButtonState).isEqualTo(PrimaryButtonState.Processing) @@ -37,15 +34,11 @@ class WalletUiStateTest { @Test fun testDisabledButtonStateForExpiredCard() { - val state = WalletUiState( - paymentDetailsList = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails, + val state = walletUiState( selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD .copy( expiryYear = 1900 ), - hasCompleted = false, - isProcessing = false, - primaryButtonLabel = LINK_WALLET_PRIMARY_BUTTON_LABEL ) assertThat(state.primaryButtonState).isEqualTo(PrimaryButtonState.Disabled) @@ -53,15 +46,11 @@ class WalletUiStateTest { @Test fun testDisabledButtonStateForCvcRecollection() { - val state = WalletUiState( - paymentDetailsList = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails, + val state = walletUiState( selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD .copy( cvcCheck = CvcCheck.Unchecked - ), - hasCompleted = false, - isProcessing = false, - primaryButtonLabel = LINK_WALLET_PRIMARY_BUTTON_LABEL + ) ) assertThat(state.primaryButtonState).isEqualTo(PrimaryButtonState.Disabled) @@ -69,13 +58,8 @@ class WalletUiStateTest { @Test fun testEnabledButtonState() { - val state = WalletUiState( - paymentDetailsList = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails, - selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD - .copy(expiryYear = 2099), - hasCompleted = false, - isProcessing = false, - primaryButtonLabel = LINK_WALLET_PRIMARY_BUTTON_LABEL + val state = walletUiState( + selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD.copy(expiryYear = 2099), ) assertThat(state.primaryButtonState).isEqualTo(PrimaryButtonState.Enabled) @@ -83,12 +67,8 @@ class WalletUiStateTest { @Test fun testShowBankAccountTermsForSelectedBankPaymentMethod() { - val state = WalletUiState( - paymentDetailsList = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails, - selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS_BANK_ACCOUNT, - hasCompleted = false, - isProcessing = false, - primaryButtonLabel = LINK_WALLET_PRIMARY_BUTTON_LABEL + val state = walletUiState( + selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS_BANK_ACCOUNT ) assertThat(state.showBankAccountTerms).isTrue() @@ -96,14 +76,95 @@ class WalletUiStateTest { @Test fun testNoBankAccountTermsForSelectedNonBankPaymentMethod() { - val state = WalletUiState( - paymentDetailsList = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails, - selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD, - hasCompleted = false, - isProcessing = false, - primaryButtonLabel = LINK_WALLET_PRIMARY_BUTTON_LABEL - ) + val state = walletUiState() assertThat(state.showBankAccountTerms).isFalse() } + + @Test + fun testDisabledButtonStateForExpiredCardWithIncompleteExpiryDate() { + val state = walletUiState( + selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD.copy(expiryYear = 1900), + expiryDateInput = FormFieldEntry("", isComplete = false) + ) + + assertThat(state.primaryButtonState).isEqualTo(PrimaryButtonState.Disabled) + } + + @Test + fun testEnabledButtonStateForExpiredCardWithCompleteExpiryDateAndIncompleteCvc() { + val state = walletUiState( + selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD.copy(expiryYear = 1900), + expiryDateInput = FormFieldEntry("12/25", isComplete = true), + ) + + assertThat(state.primaryButtonState).isEqualTo(PrimaryButtonState.Disabled) + } + + @Test + fun testEnabledButtonStateForExpiredCardWithCompleteExpiryDate() { + val state = walletUiState( + selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD.copy(expiryYear = 1900), + expiryDateInput = FormFieldEntry("12/25", isComplete = true), + cvcInput = FormFieldEntry("123", isComplete = true) + ) + + assertThat(state.primaryButtonState).isEqualTo(PrimaryButtonState.Enabled) + } + + @Test + fun testDisabledButtonStateForCardRequiringCvcWithIncompleteCvc() { + val state = walletUiState( + selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD.copy( + cvcCheck = CvcCheck.Unchecked + ), + cvcInput = FormFieldEntry("12", isComplete = false) + ) + + assertThat(state.primaryButtonState).isEqualTo(PrimaryButtonState.Disabled) + } + + @Test + fun testEnabledButtonStateForCardRequiringCvcWithCompleteCvc() { + val state = walletUiState( + selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD.copy( + cvcCheck = CvcCheck.Unchecked + ), + cvcInput = FormFieldEntry("123", isComplete = true) + ) + + assertThat(state.primaryButtonState).isEqualTo(PrimaryButtonState.Enabled) + } + + @Test + fun testEnabledButtonStateForValidCardWithBothInputsComplete() { + val state = walletUiState( + selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD.copy(expiryYear = 2099), + expiryDateInput = FormFieldEntry("12/25", isComplete = true), + cvcInput = FormFieldEntry("123", isComplete = true) + ) + + assertThat(state.primaryButtonState).isEqualTo(PrimaryButtonState.Enabled) + } + + private fun walletUiState( + paymentDetailsList: List = + TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails, + selectedItem: ConsumerPaymentDetails.PaymentDetails? = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD, + hasCompleted: Boolean = false, + isProcessing: Boolean = false, + primaryButtonLabel: ResolvableString = LINK_WALLET_PRIMARY_BUTTON_LABEL, + expiryDateInput: FormFieldEntry = FormFieldEntry(null), + cvcInput: FormFieldEntry = FormFieldEntry(null) + ): WalletUiState { + return WalletUiState( + paymentDetailsList = paymentDetailsList, + selectedItem = selectedItem, + hasCompleted = hasCompleted, + isProcessing = isProcessing, + primaryButtonLabel = primaryButtonLabel, + expiryDateInput = expiryDateInput, + cvcInput = cvcInput + ) + } } diff --git a/paymentsheet/src/test/java/com/stripe/android/link/ui/wallet/WalletViewModelTest.kt b/paymentsheet/src/test/java/com/stripe/android/link/ui/wallet/WalletViewModelTest.kt index ba41d73b8fd..91679ccc549 100644 --- a/paymentsheet/src/test/java/com/stripe/android/link/ui/wallet/WalletViewModelTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/link/ui/wallet/WalletViewModelTest.kt @@ -1,21 +1,29 @@ package com.stripe.android.link.ui.wallet import com.google.common.truth.Truth.assertThat +import com.stripe.android.common.exception.stripeErrorMessage import com.stripe.android.core.Logger +import com.stripe.android.core.strings.resolvableString import com.stripe.android.link.LinkActivityResult import com.stripe.android.link.LinkScreen import com.stripe.android.link.TestFactory import com.stripe.android.link.account.FakeLinkAccountManager -import com.stripe.android.link.account.LinkAccountManager +import com.stripe.android.link.confirmation.FakeLinkConfirmationHandler +import com.stripe.android.link.confirmation.LinkConfirmationHandler import com.stripe.android.model.ConsumerPaymentDetails +import com.stripe.android.model.ConsumerPaymentDetailsUpdateParams import com.stripe.android.testing.CoroutineTestRule import com.stripe.android.testing.FakeLogger +import com.stripe.android.uicore.forms.FormFieldEntry import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import kotlin.Result +import com.stripe.android.link.confirmation.Result as LinkConfirmationResult @RunWith(RobolectricTestRunner::class) class WalletViewModelTest { @@ -26,20 +34,14 @@ class WalletViewModelTest { @Test fun `viewmodel should load payment methods on init`() = runTest(dispatcher) { - val linkAccountManager = object : FakeLinkAccountManager() { - var paymentMethodTypes: Set? = null - override suspend fun listPaymentDetails(paymentMethodTypes: Set): Result { - this.paymentMethodTypes = paymentMethodTypes - return super.listPaymentDetails(paymentMethodTypes) - } - } + val linkAccountManager = WalletLinkAccountManager() val viewModel = createViewModel( linkAccountManager = linkAccountManager ) - assertThat(linkAccountManager.paymentMethodTypes) - .containsExactlyElementsIn(TestFactory.LINK_CONFIGURATION.stripeIntent.paymentMethodTypes) + assertThat(linkAccountManager.listPaymentDetailsCalls) + .containsExactly(TestFactory.LINK_CONFIGURATION.stripeIntent.paymentMethodTypes.toSet()) assertThat(viewModel.uiState.value).isEqualTo( WalletUiState( @@ -47,7 +49,9 @@ class WalletViewModelTest { selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails.firstOrNull(), isProcessing = false, hasCompleted = false, - primaryButtonLabel = TestFactory.LINK_WALLET_PRIMARY_BUTTON_LABEL + primaryButtonLabel = TestFactory.LINK_WALLET_PRIMARY_BUTTON_LABEL, + expiryDateInput = FormFieldEntry(""), + cvcInput = FormFieldEntry("") ) ) } @@ -55,7 +59,7 @@ class WalletViewModelTest { @Test fun `viewmodel should dismiss with failure on load payment method failure`() = runTest(dispatcher) { val error = Throwable("oops") - val linkAccountManager = FakeLinkAccountManager() + val linkAccountManager = WalletLinkAccountManager() linkAccountManager.listPaymentDetailsResult = Result.failure(error) var linkActivityResult: LinkActivityResult? = null @@ -77,7 +81,7 @@ class WalletViewModelTest { @Test fun `viewmodel should open payment method screen when none is available`() = runTest(dispatcher) { - val linkAccountManager = FakeLinkAccountManager() + val linkAccountManager = WalletLinkAccountManager() linkAccountManager.listPaymentDetailsResult = Result.success(ConsumerPaymentDetails(emptyList())) var navScreen: LinkScreen? = null @@ -121,9 +125,244 @@ class WalletViewModelTest { assertThat(navScreen).isEqualTo(LinkScreen.PaymentMethod) } + fun `expiryDateController updates uiState when input changes`() = runTest(dispatcher) { + val viewModel = createViewModel() + + viewModel.expiryDateController.onRawValueChange("12") + advanceUntilIdle() + assertThat(viewModel.uiState.value.expiryDateInput).isEqualTo(FormFieldEntry("12", isComplete = false)) + + viewModel.expiryDateController.onRawValueChange("12/25") + assertThat(viewModel.uiState.value.expiryDateInput).isEqualTo(FormFieldEntry("1225", isComplete = true)) + } + + @Test + fun `cvcController updates uiState when input changes`() = runTest(dispatcher) { + val viewModel = createViewModel() + + viewModel.cvcController.onRawValueChange("12") + advanceUntilIdle() + assertThat(viewModel.uiState.value.cvcInput).isEqualTo(FormFieldEntry("12", isComplete = false)) + + viewModel.cvcController.onRawValueChange("123") + advanceUntilIdle() + assertThat(viewModel.uiState.value.cvcInput).isEqualTo(FormFieldEntry("123", isComplete = true)) + } + + @Test + fun `expiryDateController and cvcController reset when new item is selected`() = runTest(dispatcher) { + val viewModel = createViewModel() + + viewModel.expiryDateController.onRawValueChange("12/25") + viewModel.cvcController.onRawValueChange("123") + + assertThat(viewModel.uiState.value.expiryDateInput).isEqualTo(FormFieldEntry("1225", isComplete = true)) + assertThat(viewModel.uiState.value.cvcInput).isEqualTo(FormFieldEntry("123", isComplete = true)) + + val newCard = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD.copy(id = "new_card_id") + viewModel.onItemSelected(newCard) + + assertThat(viewModel.uiState.value.expiryDateInput).isEqualTo(FormFieldEntry("", isComplete = false)) + assertThat(viewModel.uiState.value.cvcInput).isEqualTo(FormFieldEntry("", isComplete = false)) + } + + @Test + fun `performPaymentConfirmation updates expired card successfully`() = runTest(dispatcher) { + val expiredCard = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD.copy(expiryYear = 1999) + val updatedCard = expiredCard.copy(expiryYear = 2099) + val linkAccountManager = WalletLinkAccountManager() + linkAccountManager.updatePaymentDetailsResult = Result.success( + ConsumerPaymentDetails(paymentDetails = listOf(updatedCard)) + ) + linkAccountManager.listPaymentDetailsResult = Result.success( + ConsumerPaymentDetails(paymentDetails = listOf(expiredCard)) + ) + val linkConfirmationHandler = FakeLinkConfirmationHandler() + + val viewModel = createViewModel( + linkAccountManager = linkAccountManager, + linkConfirmationHandler = linkConfirmationHandler + ) + viewModel.onItemSelected(expiredCard) + viewModel.expiryDateController.onRawValueChange("1299") + viewModel.cvcController.onRawValueChange("123") + + viewModel.onPrimaryButtonClicked() + + assertThat(viewModel.uiState.value.isProcessing).isTrue() + assertThat(viewModel.uiState.value.alertMessage).isNull() + + val updateParamsUsed = linkAccountManager.updatePaymentDetailsCalls.firstOrNull() + val card = updateParamsUsed?.cardPaymentMethodCreateParamsMap + ?.get("card") as? Map<*, *> + + assertThat(updateParamsUsed?.id).isEqualTo(expiredCard.id) + assertThat(card).isEqualTo( + mapOf( + "exp_month" to updatedCard.expiryMonth.toString(), + "exp_year" to updatedCard.expiryYear.toString() + ) + ) + + assertThat(linkConfirmationHandler.calls).containsExactly( + FakeLinkConfirmationHandler.Call( + paymentDetails = updatedCard, + linkAccount = TestFactory.LINK_ACCOUNT, + cvc = "123" + ) + ) + } + + @Test + fun `performPaymentConfirmation handles update failure`() = runTest(dispatcher) { + val error = RuntimeException("Update failed") + val expiredCard = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD.copy(expiryYear = 1999) + val linkAccountManager = WalletLinkAccountManager() + linkAccountManager.listPaymentDetailsResult = Result.success( + ConsumerPaymentDetails(paymentDetails = listOf(expiredCard)) + ) + linkAccountManager.updatePaymentDetailsResult = Result.failure(error) + + val linkConfirmationHandler = FakeLinkConfirmationHandler() + + val viewModel = createViewModel( + linkAccountManager = linkAccountManager, + linkConfirmationHandler = linkConfirmationHandler + ) + viewModel.onItemSelected(expiredCard) + viewModel.expiryDateController.onRawValueChange("1225") + viewModel.cvcController.onRawValueChange("123") + + viewModel.onPrimaryButtonClicked() + + assertThat(viewModel.uiState.value.isProcessing).isFalse() + assertThat(viewModel.uiState.value.alertMessage).isEqualTo(error.stripeErrorMessage()) + + assertThat(linkConfirmationHandler.calls).isEmpty() + } + + @Test + fun `performPaymentConfirmation skips update for non-expired card`() = runTest(dispatcher) { + val validCard = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD.copy(expiryYear = 2099) + val linkAccountManager = WalletLinkAccountManager() + + val linkConfirmationHandler = FakeLinkConfirmationHandler() + + val viewModel = createViewModel( + linkAccountManager = linkAccountManager, + linkConfirmationHandler = linkConfirmationHandler + ) + viewModel.onItemSelected(validCard) + + viewModel.onPrimaryButtonClicked() + + assertThat(linkAccountManager.updatePaymentDetailsCalls).isEmpty() + + assertThat(linkConfirmationHandler.calls).containsExactly( + FakeLinkConfirmationHandler.Call( + paymentDetails = validCard, + linkAccount = TestFactory.LINK_ACCOUNT, + cvc = null + ) + ) + } + + @Test + fun `performPaymentConfirmation dismisses with Completed result on success`() = runTest(dispatcher) { + val validCard = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD.copy(expiryYear = 2099) + val linkAccountManager = WalletLinkAccountManager() + linkAccountManager.listPaymentDetailsResult = Result.success( + value = ConsumerPaymentDetails(paymentDetails = listOf(validCard)) + ) + + val linkConfirmationHandler = FakeLinkConfirmationHandler() + + var result: LinkActivityResult? = null + val viewModel = createViewModel( + linkAccountManager = linkAccountManager, + linkConfirmationHandler = linkConfirmationHandler, + dismissWithResult = { + result = it + } + ) + viewModel.onItemSelected(validCard) + + viewModel.onPrimaryButtonClicked() + + assertThat(linkAccountManager.updatePaymentDetailsCalls).isEmpty() + + assertThat(linkConfirmationHandler.calls).containsExactly( + FakeLinkConfirmationHandler.Call( + paymentDetails = validCard, + cvc = null, + linkAccount = TestFactory.LINK_ACCOUNT + ) + ) + + assertThat(result).isEqualTo(LinkActivityResult.Completed) + } + + @Test + fun `performPaymentConfirmation displays error on failure result`() = runTest(dispatcher) { + val confirmationResult = LinkConfirmationResult.Failed("oops".resolvableString) + val validCard = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD.copy(expiryYear = 2099) + val linkAccountManager = WalletLinkAccountManager() + linkAccountManager.listPaymentDetailsResult = Result.success( + value = ConsumerPaymentDetails(paymentDetails = listOf(validCard)) + ) + + val linkConfirmationHandler = FakeLinkConfirmationHandler() + linkConfirmationHandler.confirmResult = confirmationResult + + var result: LinkActivityResult? = null + val viewModel = createViewModel( + linkAccountManager = linkAccountManager, + linkConfirmationHandler = linkConfirmationHandler, + dismissWithResult = { + result = it + } + ) + viewModel.onItemSelected(validCard) + + viewModel.onPrimaryButtonClicked() + + assertThat(viewModel.uiState.value.errorMessage).isEqualTo(confirmationResult.message) + assertThat(viewModel.uiState.value.isProcessing).isFalse() + assertThat(result).isNull() + } + + @Test + fun `performPaymentConfirmation does nothing on canceled result`() = runTest(dispatcher) { + val confirmationResult = LinkConfirmationResult.Canceled + val validCard = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD.copy(expiryYear = 2099) + val linkAccountManager = WalletLinkAccountManager() + linkAccountManager.listPaymentDetailsResult = Result.success( + value = ConsumerPaymentDetails(paymentDetails = listOf(validCard)) + ) + + val linkConfirmationHandler = FakeLinkConfirmationHandler() + linkConfirmationHandler.confirmResult = confirmationResult + + var result: LinkActivityResult? = null + val viewModel = createViewModel( + linkAccountManager = linkAccountManager, + linkConfirmationHandler = linkConfirmationHandler, + dismissWithResult = { + result = it + } + ) + viewModel.onItemSelected(validCard) + + viewModel.onPrimaryButtonClicked() + + assertThat(viewModel.uiState.value.errorMessage).isNull() + assertThat(result).isNull() + } + private fun createViewModel( - linkAccountManager: LinkAccountManager = FakeLinkAccountManager(), + linkAccountManager: WalletLinkAccountManager = WalletLinkAccountManager(), logger: Logger = FakeLogger(), + linkConfirmationHandler: LinkConfirmationHandler = FakeLinkConfirmationHandler(), navigate: (route: LinkScreen) -> Unit = {}, navigateAndClearStack: (route: LinkScreen) -> Unit = {}, dismissWithResult: (LinkActivityResult) -> Unit = {} @@ -132,6 +371,7 @@ class WalletViewModelTest { configuration = TestFactory.LINK_CONFIGURATION, linkAccount = TestFactory.LINK_ACCOUNT, linkAccountManager = linkAccountManager, + linkConfirmationHandler = linkConfirmationHandler, logger = logger, navigate = navigate, navigateAndClearStack = navigateAndClearStack, @@ -139,3 +379,19 @@ class WalletViewModelTest { ) } } + +private class WalletLinkAccountManager : FakeLinkAccountManager() { + val listPaymentDetailsCalls = arrayListOf>() + val updatePaymentDetailsCalls = arrayListOf() + override suspend fun listPaymentDetails(paymentMethodTypes: Set): Result { + listPaymentDetailsCalls.add(paymentMethodTypes) + return super.listPaymentDetails(paymentMethodTypes) + } + + override suspend fun updatePaymentDetails( + updateParams: ConsumerPaymentDetailsUpdateParams + ): Result { + updatePaymentDetailsCalls.add(updateParams) + return super.updatePaymentDetails(updateParams) + } +}