Skip to content

Commit

Permalink
Link Cvc & Expiry recollection VM
Browse files Browse the repository at this point in the history
  • Loading branch information
toluo-stripe committed Jan 13, 2025
1 parent 562ff98 commit d012bd4
Show file tree
Hide file tree
Showing 5 changed files with 333 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.stripe.android.ui.core.elements

import androidx.annotation.RestrictTo
import com.stripe.android.CardBrandFilter
import com.stripe.android.DefaultCardBrandFilter
import com.stripe.android.cards.CardAccountRangeRepository
Expand Down Expand Up @@ -108,6 +109,14 @@ internal class CardDetailsElement(
}
}

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
fun createExpiryDateFormFieldValues(entry: FormFieldEntry): Map<IdentifierSpec, FormFieldEntry> {
return mapOf(
IdentifierSpec.CardExpMonth to getExpiryMonthFormFieldEntry(entry),
IdentifierSpec.CardExpYear to getExpiryYearFormFieldEntry(entry)
)
}

private fun getExpiryMonthFormFieldEntry(entry: FormFieldEntry): FormFieldEntry {
var month = -1
entry.value?.let { date ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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
Expand All @@ -14,14 +15,25 @@ import com.stripe.android.link.account.LinkAccountManager
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.CvcController
import com.stripe.android.ui.core.elements.createExpiryDateFormFieldValues
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
Expand All @@ -48,8 +60,33 @@ internal class WalletViewModel @Inject constructor(

val uiState: StateFlow<WalletUiState> = _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() {
Expand Down Expand Up @@ -84,12 +121,66 @@ 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
}
}

private suspend fun performPaymentDetailsUpdate(
selectedPaymentDetails: ConsumerPaymentDetails.PaymentDetails
): Result<ConsumerPaymentDetails> {
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))
Expand Down Expand Up @@ -129,3 +220,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
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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.CvcCheck
import com.stripe.android.uicore.forms.FormFieldEntry
import org.junit.Test

class WalletUiStateTest {
Expand Down Expand Up @@ -106,4 +107,94 @@ class WalletUiStateTest {

assertThat(state.showBankAccountTerms).isFalse()
}

@Test
fun testDisabledButtonStateForExpiredCardWithIncompleteExpiryDate() {
val state = WalletUiState(
paymentDetailsList = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails,
selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD.copy(expiryYear = 1900),
hasCompleted = false,
isProcessing = false,
primaryButtonLabel = LINK_WALLET_PRIMARY_BUTTON_LABEL,
expiryDateInput = FormFieldEntry("", isComplete = false)
)

assertThat(state.primaryButtonState).isEqualTo(PrimaryButtonState.Disabled)
}

@Test
fun testEnabledButtonStateForExpiredCardWithCompleteExpiryDateAndIncompleteCvc() {
val state = WalletUiState(
paymentDetailsList = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails,
selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD.copy(expiryYear = 1900),
hasCompleted = false,
isProcessing = false,
primaryButtonLabel = LINK_WALLET_PRIMARY_BUTTON_LABEL,
expiryDateInput = FormFieldEntry("12/25", isComplete = true),
)

assertThat(state.primaryButtonState).isEqualTo(PrimaryButtonState.Disabled)
}

@Test
fun testEnabledButtonStateForExpiredCardWithCompleteExpiryDate() {
val state = WalletUiState(
paymentDetailsList = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails,
selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD.copy(expiryYear = 1900),
hasCompleted = false,
isProcessing = false,
primaryButtonLabel = LINK_WALLET_PRIMARY_BUTTON_LABEL,
expiryDateInput = FormFieldEntry("12/25", isComplete = true),
cvcInput = FormFieldEntry("123", isComplete = true)
)

assertThat(state.primaryButtonState).isEqualTo(PrimaryButtonState.Enabled)
}

@Test
fun testDisabledButtonStateForCardRequiringCvcWithIncompleteCvc() {
val state = WalletUiState(
paymentDetailsList = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails,
selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD.copy(
cvcCheck = CvcCheck.Unchecked
),
hasCompleted = false,
isProcessing = false,
primaryButtonLabel = LINK_WALLET_PRIMARY_BUTTON_LABEL,
cvcInput = FormFieldEntry("12", isComplete = false)
)

assertThat(state.primaryButtonState).isEqualTo(PrimaryButtonState.Disabled)
}

@Test
fun testEnabledButtonStateForCardRequiringCvcWithCompleteCvc() {
val state = WalletUiState(
paymentDetailsList = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails,
selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD.copy(
cvcCheck = CvcCheck.Unchecked
),
hasCompleted = false,
isProcessing = false,
primaryButtonLabel = LINK_WALLET_PRIMARY_BUTTON_LABEL,
cvcInput = FormFieldEntry("123", isComplete = true)
)

assertThat(state.primaryButtonState).isEqualTo(PrimaryButtonState.Enabled)
}

@Test
fun testEnabledButtonStateForValidCardWithBothInputsComplete() {
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,
expiryDateInput = FormFieldEntry("12/25", isComplete = true),
cvcInput = FormFieldEntry("123", isComplete = true)
)

assertThat(state.primaryButtonState).isEqualTo(PrimaryButtonState.Enabled)
}
}
Loading

0 comments on commit d012bd4

Please sign in to comment.