Skip to content
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
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2026 Adyen N.V.
*
* This file is open source and available under the MIT license. See the LICENSE file for more info.
*
* Created by josephj on 20/3/2026.
*/

package com.adyen.checkout.card.internal.helper

import com.adyen.checkout.core.common.internal.properties.ExpiryDateProperties.EXPIRY_DATE_MAX_LENGTH_NO_SEPARATORS
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

internal object ExpiryDateParser {
private val PARSING_FORMATTER = getFormatter("MMyy")
private val MONTH_FORMATTER = getFormatter("MM")
private val SHORT_YEAR_FORMATTER = getFormatter("yy")
private val FULL_YEAR_FORMATTER = getFormatter("yyyy")

/**
* Parses digit only input into a pair of expiryMonth and expiryYear
* The input must be 4 digits long, 2-digit month followed by 2-digit year (MMYY)
* Automatically adds the year prefix if returnFullYear is true (26 -> 2026)
* Returns null if input is invalid
*/
fun parseToMonthAndYear(expiryDate: String, returnFullYear: Boolean): Pair<String, String>? {
// this manual check is needed because SimpleDateFormat.parse will not enforce the length and will successfully
// parse some strings that are not 4 digits long
if (expiryDate.length != EXPIRY_DATE_MAX_LENGTH_NO_SEPARATORS) {
return null
}
Comment thread
jreij marked this conversation as resolved.

val date = parseToDate(expiryDate)
return date?.let {
getMonthAndYear(date, returnFullYear)
}
}

private fun parseToDate(expiryDate: String): Date? {
return try {
PARSING_FORMATTER.parse(expiryDate)
} catch (_: ParseException) {
null
}
}

private fun getMonthAndYear(date: Date, returnFullYear: Boolean): Pair<String, String> {
val yearFormatter = if (returnFullYear) FULL_YEAR_FORMATTER else SHORT_YEAR_FORMATTER
return MONTH_FORMATTER.format(date) to yearFormatter.format(date)
}

private fun getFormatter(format: String): SimpleDateFormat {
return SimpleDateFormat(format, Locale.ROOT).apply {
Comment thread
OscarSpruit marked this conversation as resolved.
isLenient = false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
package com.adyen.checkout.card.internal.ui.state

import com.adyen.checkout.card.FieldMode
import com.adyen.checkout.card.internal.helper.ExpiryDateParser
import com.adyen.checkout.card.internal.ui.model.CardComponentParams
import com.adyen.checkout.core.common.CardBrand
import com.adyen.checkout.core.common.helper.runCompileOnly
import com.adyen.checkout.core.common.ui.model.ExpiryDate
import com.adyen.checkout.core.components.data.PaymentComponentData
import com.adyen.checkout.core.components.internal.data.provider.SdkDataProvider
import com.adyen.checkout.core.components.internal.ui.state.model.RequirementPolicy
Expand Down Expand Up @@ -74,11 +74,11 @@ private fun CardComponentState.encryptCard(
// TODO - Card. Add isCvcHidden check
val cvc = securityCode.text
if (cvc.isNotEmpty()) unencryptedCardBuilder.setCvc(cvc)
if (expiryDate.text.isNotBlank()) {
val expiryDate = ExpiryDate.from(expiryDate.text)

ExpiryDateParser.parseToMonthAndYear(expiryDate.text, returnFullYear = true)?.let { (expiryMonth, expiryYear) ->
unencryptedCardBuilder.setExpiryDate(
expiryMonth = expiryDate.expiryMonth.toString(),
expiryYear = expiryDate.expiryYear.toString(),
expiryMonth = expiryMonth,
expiryYear = expiryYear,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import androidx.annotation.RestrictTo
import androidx.annotation.VisibleForTesting
import com.adyen.checkout.card.internal.data.model.Brand
import com.adyen.checkout.card.internal.data.model.DetectedCardType
import com.adyen.checkout.card.internal.helper.ExpiryDateParser
import com.adyen.checkout.core.common.helper.CardExpiryDateValidationResult
import com.adyen.checkout.core.common.helper.CardExpiryDateValidator
import com.adyen.checkout.core.common.helper.CardNumberValidationResult
Expand Down Expand Up @@ -71,7 +72,9 @@ internal object CardValidationUtils {
expiryDate: String,
fieldPolicy: Brand.FieldPolicy?,
): CardExpiryDateValidation {
val result = CardExpiryDateValidator.validateExpiryDate(expiryDate)
val (expiryMonth, expiryYear) = ExpiryDateParser.parseToMonthAndYear(expiryDate, returnFullYear = false)
?: return CardExpiryDateValidation.INVALID_OTHER_REASON
val result = CardExpiryDateValidator.validateExpiryDate(expiryMonth = expiryMonth, expiryYear = expiryYear)
return validateExpiryDate(expiryDate, result, fieldPolicy)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ private fun CardComponentPreview() {
text = "5555444433331111",
),
expiryDate = TextInputViewState(
text = "12/34",
text = "1234",
),
securityCode = TextInputViewState(
text = "737",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ package com.adyen.checkout.card.internal.ui.view
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.maxLength
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
Expand All @@ -21,14 +19,16 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.input.KeyboardType
import com.adyen.checkout.card.R
import com.adyen.checkout.card.internal.ui.model.ExpiryDateTrailingIcon
import com.adyen.checkout.card.internal.ui.state.CardIntent
import com.adyen.checkout.core.common.internal.properties.ExpiryDateProperties
import com.adyen.checkout.core.common.localization.CheckoutLocalizationKey
import com.adyen.checkout.core.common.localization.internal.helper.resolveString
import com.adyen.checkout.core.components.internal.ui.state.model.TextInputViewState
import com.adyen.checkout.ui.internal.element.input.CheckoutTextField
import com.adyen.checkout.ui.internal.element.input.SeparatorsOutputTransformation
import com.adyen.checkout.ui.internal.element.input.TextFieldSeparator
import com.adyen.checkout.ui.internal.helper.getThemedIcon
import com.adyen.checkout.ui.internal.theme.CheckoutThemeProvider
import com.adyen.checkout.ui.internal.theme.Dimensions
Expand All @@ -48,6 +48,15 @@ internal fun ExpiryDateField(
""
}

val inputTransformation = remember { ExpiryDateInputTransformation() }
val outputTransformation = remember {
SeparatorsOutputTransformation(
listOf(
TextFieldSeparator(ExpiryDateProperties.EXPIRY_DATE_SEPARATOR, indexInRawString = 2),
),
)
}

CheckoutTextField(
modifier = modifier
.fillMaxWidth()
Expand All @@ -61,9 +70,8 @@ internal fun ExpiryDateField(
onValueChange = { value ->
onIntent(CardIntent.UpdateExpiryDate(value))
},
inputTransformation = ExpiryDateInputTransformation()
.maxLength(maxLength = ExpiryDateInputTransformation.MAX_DIGITS),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
inputTransformation = inputTransformation,
outputTransformation = outputTransformation,
shouldFocus = expiryDateState.isFocused,
trailingIcon = {
ExpiryDateIcon(expiryDateState)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,45 +11,35 @@ package com.adyen.checkout.card.internal.ui.view
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldBuffer
import androidx.compose.foundation.text.input.delete
import androidx.compose.foundation.text.input.insert
import androidx.compose.ui.text.input.KeyboardType
import androidx.core.text.isDigitsOnly
import com.adyen.checkout.core.common.internal.properties.ExpiryDateProperties.EXPIRY_DATE_MAX_LENGTH_NO_SEPARATORS
import com.adyen.checkout.core.common.internal.properties.ExpiryDateProperties.EXPIRY_DATE_SEPARATOR
import com.adyen.checkout.ui.internal.element.input.DigitOnlyTextFieldBufferTransformation

internal class ExpiryDateInputTransformation : InputTransformation {

override val keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)

private val digitOnlyTextFieldBufferTransformation = DigitOnlyTextFieldBufferTransformation(
allowedSeparators = listOf(EXPIRY_DATE_SEPARATOR),
maxLengthWithoutSeparators = EXPIRY_DATE_MAX_LENGTH_NO_SEPARATORS,
)

override fun TextFieldBuffer.transformInput() {
val input = asCharSequence()
val isInputOnlyDigits = input.filter { it != SEPARATOR }.isDigitsOnly()
val hasSeparator = input.filter { it == SEPARATOR }.length > MAX_SEPARATOR_COUNT
if (!isInputOnlyDigits || hasSeparator) {
revertAllChanges()
}
// If first month digit is larger than 1, automatically insert 0 prefix
val shouldAddZeroPrefix = input.length == 1 && input[0].digitToInt() > MAX_FIRST_MONTH_DIGIT_VALUE
val areChangesAccepted = digitOnlyTextFieldBufferTransformation.transformInput(this)
if (!areChangesAccepted) return

val text = asCharSequence()

// If input is one digit larger than 1, automatically insert 0 prefix to correctly format the month
val shouldAddZeroPrefix = text.length == 1 && text[0].digitToInt() > MAX_FIRST_MONTH_DIGIT_VALUE
if (shouldAddZeroPrefix) {
insert(0, PREFIX_ZERO)
}
// If input is 123 after last key stroke, insert separator after month digits
val shouldAddSeparator = input.length == MONTH_LENGTH + 1 && input.last() != SEPARATOR
if (shouldAddSeparator) {
insert(2, SEPARATOR.toString())
}
// If input is 12/ after digit deletion or manually inserting separator
val shouldRemoveSeparator = input.length == MONTH_LENGTH + 1 && input.last() == SEPARATOR
if (shouldRemoveSeparator) {
delete(MONTH_LENGTH, input.length)
}
}

companion object {
// Including separator
const val MAX_DIGITS = 5
private const val MAX_SEPARATOR_COUNT = 1
private const val MONTH_LENGTH = 2
private const val SEPARATOR = '/'
private const val MAX_FIRST_MONTH_DIGIT_VALUE = 1
private const val PREFIX_ZERO = "0"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ private fun SecurityCodeIcon(
AnimatedContent(
targetState = resourceId,
modifier = modifier,
label = "ExpiryDateIcon",
label = "SecurityCodeIcon",
) { targetResourceId ->
val iconSize = remember(isInvalid) {
if (isInvalid) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2026 Adyen N.V.
*
* This file is open source and available under the MIT license. See the LICENSE file for more info.
*
* Created by josephj on 20/3/2026.
*/

package com.adyen.checkout.card.internal.helper

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test

internal class ExpiryDateParserTest {

@Test
fun `when input is valid and returnFullYear is true then return month and full year`() {
val result = ExpiryDateParser.parseToMonthAndYear("0326", true)
assertEquals("03" to "2026", result)
}

@Test
fun `when input is valid and returnFullYear is false then return month and short year`() {
val result = ExpiryDateParser.parseToMonthAndYear("0326", false)
assertEquals("03" to "26", result)
}

@Test
fun `when input length is too short then return null`() {
val result = ExpiryDateParser.parseToMonthAndYear("032", true)
assertNull(result)
}

@Test
fun `when input length is too long then return null`() {
val result = ExpiryDateParser.parseToMonthAndYear("03261", true)
assertNull(result)
}

@Test
fun `when input is empty then return null`() {
val result = ExpiryDateParser.parseToMonthAndYear("", true)
assertNull(result)
}

@Test
fun `when input month is not valid then return null`() {
val result = ExpiryDateParser.parseToMonthAndYear("4520", true)
assertNull(result)
}

@Test
fun `when input has illegal characters then return null`() {
val result = ExpiryDateParser.parseToMonthAndYear("asbg", true)
assertNull(result)
}

@Test
fun `when month is double digits then it is parsed correctly`() {
val result = ExpiryDateParser.parseToMonthAndYear("1212", true)
assertEquals("12" to "2012", result)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ internal class CardComponentStateValidatorTest {
@Test
fun `when expiry date is invalid, then isValid returns false`() {
val state = createValidState().copy(
expiryDate = TextInputComponentState(text = "13/20"),
expiryDate = TextInputComponentState(text = "1320"),
)

val validatedState = validator.validate(state)
Expand Down Expand Up @@ -162,7 +162,7 @@ internal class CardComponentStateValidatorTest {

private fun createValidState() = CardComponentState(
cardNumber = TextInputComponentState(text = "4111111111111111"),
expiryDate = TextInputComponentState(text = "12/30"),
expiryDate = TextInputComponentState(text = "1230"),
securityCode = TextInputComponentState(text = "123"),
holderName = TextInputComponentState(text = "John Doe", requirementPolicy = RequirementPolicy.Hidden),
socialSecurityNumber = TextInputComponentState(
Expand Down
Loading
Loading