Skip to content

Commit

Permalink
Link Cvc & Expiry recollection VM
Browse files Browse the repository at this point in the history
Add confirmation block

Update unit tests

Lint

Update WalletViewModelTest.kt

Update FakeLinkConfirmationHandler.kt
  • Loading branch information
toluo-stripe committed Jan 13, 2025
1 parent 8d226e9 commit 76e4129
Show file tree
Hide file tree
Showing 9 changed files with 577 additions and 5 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 @@ -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
Expand All @@ -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,
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,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.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
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 navigateAndClearStack: (route: LinkScreen) -> Unit,
private val dismissWithResult: (LinkActivityResult) -> Unit
Expand All @@ -48,8 +63,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 +124,92 @@ 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)
}
}
LinkConfirmationResult.Succeeded -> {
dismissWithResult(LinkActivityResult.Completed)
}
}
}

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 @@ -119,6 +239,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,
navigateAndClearStack = navigateAndClearStack,
Expand All @@ -129,3 +252,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 @@ -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
Expand Down Expand Up @@ -317,6 +318,7 @@ internal class LinkActivityViewModelTest {
return LinkActivityViewModel(
linkAccountManager = linkAccountManager,
activityRetainedComponent = mock(),
confirmationHandlerFactory = { FakeConfirmationHandler() }
).apply {
this.navController = navController
this.dismissWithResult = dismissWithResult
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,26 @@ import com.stripe.android.model.ConsumerPaymentDetails

internal class FakeLinkConfirmationHandler : LinkConfirmationHandler {
var confirmResult: Result = Result.Succeeded
val calls = arrayListOf<Call>()

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?
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
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.PrimaryButtonTag
import com.stripe.android.model.ConsumerPaymentDetails
import com.stripe.android.testing.CoroutineTestRule
Expand Down Expand Up @@ -163,12 +165,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(),
navigateAndClearStack = {},
dismissWithResult = {}
Expand Down
Loading

0 comments on commit 76e4129

Please sign in to comment.