Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Link Cvc & Expiry recollection VM #9882

Merged
merged 6 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
)
}
Original file line number Diff line number Diff line change
@@ -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<IdentifierSpec, FormFieldEntry> {
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()
)
}
}
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()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isMissingExpiryDateInput check appears to have an unintended dependency on cvcInput.isComplete. For validating expiry date input, the condition should be simplified to expiryDateInput.isComplete.not() since the CVC completion state is handled separately by isMissingCvcInput.

Spotted by Graphite Reviewer

Is this helpful? React 👍 or 👎 to let us know.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expired cards requires both updated expiryDate and cvc

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.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,
Expand All @@ -49,8 +64,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 @@ -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
)
}
}
)
Comment on lines +154 to +169
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The onFailure block needs to return early to prevent execution from continuing into payment confirmation. Add return after updating the UI state to ensure the error case fully terminates the flow.

Spotted by Graphite Reviewer

Is this helpful? React 👍 or 👎 to let us know.

} 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
Comment on lines +188 to +189
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isProcessing state should be reset when the confirmation is canceled. Consider updating the case to:

LinkConfirmationResult.Canceled -> _uiState.update { it.copy(isProcessing = false) }

This maintains consistency with the error handling case and prevents the UI from being stuck in a processing state.

Spotted by Graphite Reviewer

Is this helpful? React 👍 or 👎 to let us know.

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<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 @@ -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,
Expand All @@ -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
)
}
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?
)
}
Loading
Loading