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 #9880

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
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
@@ -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 @@ -23,6 +23,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
Expand All @@ -37,13 +38,24 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.unit.dp
import com.stripe.android.core.strings.resolvableString
import com.stripe.android.link.theme.HorizontalPadding
import com.stripe.android.link.theme.linkColors
import com.stripe.android.link.theme.linkShapes
import com.stripe.android.link.ui.ErrorText
import com.stripe.android.link.ui.PrimaryButton
import com.stripe.android.link.ui.SecondaryButton
import com.stripe.android.model.ConsumerPaymentDetails
import com.stripe.android.paymentsheet.R
import com.stripe.android.ui.core.elements.CvcController
import com.stripe.android.ui.core.elements.CvcElement
import com.stripe.android.uicore.elements.IdentifierSpec
import com.stripe.android.uicore.elements.RowController
import com.stripe.android.uicore.elements.RowElement
import com.stripe.android.uicore.elements.SectionElement
import com.stripe.android.uicore.elements.SectionElementUI
import com.stripe.android.uicore.elements.SimpleTextElement
import com.stripe.android.uicore.elements.TextFieldController
import com.stripe.android.uicore.text.Html
import com.stripe.android.uicore.utils.collectAsState

Expand All @@ -57,6 +69,8 @@ internal fun WalletScreen(
WalletBody(
state = state,
isExpanded = isExpanded,
expiryDateController = viewModel.expiryDateController,
cvcController = viewModel.cvcController,
onItemSelected = viewModel::onItemSelected,
onExpandedChanged = { expanded ->
isExpanded = expanded
Expand All @@ -66,15 +80,19 @@ internal fun WalletScreen(
)
}

@SuppressWarnings("LongMethod")
@Composable
internal fun WalletBody(
state: WalletUiState,
isExpanded: Boolean,
expiryDateController: TextFieldController,
cvcController: CvcController,
onItemSelected: (ConsumerPaymentDetails.PaymentDetails) -> Unit,
onExpandedChanged: (Boolean) -> Unit,
onPrimaryButtonClick: () -> Unit,
onPayAnotherWayClick: () -> Unit,
) {
val context = LocalContext.current
if (state.paymentDetailsList.isEmpty()) {
Box(
modifier = Modifier
Expand Down Expand Up @@ -112,6 +130,30 @@ internal fun WalletBody(
BankAccountTerms()
}

AnimatedVisibility(
visible = state.errorMessage != null
) {
ErrorText(
text = state.errorMessage?.resolve(context).orEmpty(),
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
)
}

state.selectedCard?.let { selectedCard ->
if (selectedCard.requiresCardDetailsRecollection) {
Spacer(modifier = Modifier.height(16.dp))

CardDetailsRecollectionForm(
paymentDetails = selectedCard,
expiryDateController = expiryDateController,
cvcController = cvcController,
isCardExpired = selectedCard.isExpired
)
}
}

Spacer(modifier = Modifier.height(16.dp))

PrimaryButton(
Expand Down Expand Up @@ -361,6 +403,68 @@ private fun BankAccountTerms() {
)
}

@Composable
internal fun CardDetailsRecollectionForm(
paymentDetails: ConsumerPaymentDetails.PaymentDetails,
expiryDateController: TextFieldController,
cvcController: CvcController,
isCardExpired: Boolean,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val rowElement = remember(paymentDetails) {
val rowFields = buildList {
if (isCardExpired) {
add(
element = SimpleTextElement(
identifier = IdentifierSpec.Generic("date"),
controller = expiryDateController
)
)
}

add(
element = CvcElement(
_identifier = IdentifierSpec.CardCvc,
controller = cvcController
)
)
}

RowElement(
_identifier = IdentifierSpec.Generic(paymentDetails.id),
fields = rowFields,
controller = RowController(rowFields)
)
}

val errorTextRes = if (isCardExpired) {
R.string.stripe_wallet_update_expired_card_error
} else {
R.string.stripe_wallet_recollect_cvc_error
}.resolvableString

Column(modifier) {
ErrorText(
text = errorTextRes.resolve(context),
modifier = Modifier
.fillMaxWidth()
.testTag(WALLET_SCREEN_RECOLLECTION_FORM_ERROR)
)

Spacer(modifier = Modifier.height(16.dp))

SectionElementUI(
modifier = Modifier
.testTag(WALLET_SCREEN_RECOLLECTION_FORM_FIELDS),
enabled = true,
element = SectionElement.wrap(rowElement),
hiddenIdentifiers = emptySet(),
lastTextFieldIdentifier = rowElement.fields.last().identifier
)
}
}

private fun String.replaceHyperlinks() = this.replace(
"<terms>",
"<a href=\"https://stripe.com/legal/ach-payments/authorization\">"
Expand All @@ -377,4 +481,6 @@ internal const val WALLET_ADD_PAYMENT_METHOD_ROW = "wallet_add_payment_method_ro
internal const val WALLET_SCREEN_PAYMENT_METHODS_LIST = "wallet_screen_payment_methods_list"
internal const val WALLET_SCREEN_PAY_BUTTON = "wallet_screen_pay_button"
internal const val WALLET_SCREEN_PAY_ANOTHER_WAY_BUTTON = "wallet_screen_pay_another_way_button"
internal const val WALLET_SCREEN_RECOLLECTION_FORM_ERROR = "wallet_screen_recollection_form_error"
internal const val WALLET_SCREEN_RECOLLECTION_FORM_FIELDS = "wallet_screen_recollection_form_fields"
internal const val WALLET_SCREEN_BOX = "wallet_screen_box"
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 should only depend on expiryDateInput.isComplete. Including cvcInput.isComplete in this check is incorrect since it's meant to validate expiry date completeness only. The check should be simplified to:

val isMissingExpiryDateInput = expiryDateInput.isComplete.not()

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,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
Copy link
Contributor Author

Choose a reason for hiding this comment

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

}
}

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
)
}
Comment on lines +224 to +231
Copy link

Choose a reason for hiding this comment

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

The toPaymentMethodCreateParams() function appears incomplete - it transforms the expiry date but omits the CVC value from cvcInput. Both values should be included in the fieldValuePairs map passed to transformToPaymentMethodCreateParams() to properly validate the card details.

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.

cvc is not part of PaymentMethodCreateParams. It will be passed extraParams to the LinkConfirmationHandler` here

Loading
Loading