Skip to content

Commit

Permalink
feat: computing cvc mask based on card brand (#20)
Browse files Browse the repository at this point in the history
  • Loading branch information
jleon15 authored Dec 15, 2022
1 parent a9e7402 commit 86fe06f
Show file tree
Hide file tree
Showing 7 changed files with 108 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class CardFragment : Fragment() {
binding.lifecycleOwner = this
binding.viewModel = viewModel

binding.cvc.cardNumberElement = binding.cardNumber

binding.tokenizeButton.setOnClickListener { tokenize() }
binding.autofillButton.setOnClickListener { autofill() }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package com.basistheory.android.service

import com.basistheory.android.constants.CardBrands

internal class CardBrandEnricher {
class CardBrandEnricher {

object CardMasks {
const val MASK_4_8_12GAPS_19LENGTH = "#### #### #### #######"
Expand All @@ -21,18 +21,21 @@ internal class CardBrandEnricher {
var identifierRanges: List<Pair<String, String?>>,
var validLengths: IntArray,
var cvcMask: String,
var cardMask: String
)

class CardResult(
var cardDetails: CardDetails?,
var cardMask: String,
var cardLength: Int = -1,
var identifierLength: Int = -1,
) {
val complete: Boolean
get() = cardDetails?.validLengths?.contains(cardLength) ?: false
val isComplete: Boolean
get() = validLengths.contains(cardLength)
}

class CardMetadata(
val brand: String?,
val cvcMask: String?,
val cardMask: String?,
val isComplete: Boolean
)

private val cardBrands = listOf(
CardDetails(
CardBrands.VISA.label,
Expand Down Expand Up @@ -205,10 +208,10 @@ internal class CardBrandEnricher {
)
)

fun evaluateCard(number: String?): CardResult {
if (number.isNullOrBlank()) return CardResult(null)
fun evaluateCard(number: String?): CardMetadata? {
if (number.isNullOrBlank()) return null

var bestMatch = CardResult(null)
var bestMatch: CardDetails? = null

cardBrands.forEach { cardDetails ->
cardDetails.identifierRanges.forEach { range ->
Expand All @@ -222,19 +225,32 @@ internal class CardBrandEnricher {
}
}

return bestMatch
return with(bestMatch) {
CardMetadata(
this?.brand,
this?.cvcMask,
this?.cardMask,
this?.isComplete ?: false
)
}
}

private fun chooseBestMatch(
currentBestMatch: CardResult,
currentBestMatch: CardDetails?,
cardDetails: CardDetails,
identifierMatch: String,
number: String
): CardResult =
if (currentBestMatch.identifierLength < identifierMatch.length) CardResult(
cardDetails,
number.length,
identifierMatch.length
)
): CardDetails? =
if ((currentBestMatch?.identifierLength ?: -1) < identifierMatch.length) with(cardDetails) {
CardDetails(
this.brand,
this.identifierRanges,
this.validLengths,
this.cvcMask,
this.cardMask,
number.length,
identifierMatch.length
)
}
else currentBestMatch
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ class CardNumberElement @JvmOverloads constructor(
defStyleAttr: Int = 0
) : TextElement(context, attrs, defStyleAttr) {

var cardMetadata: CardBrandEnricher.CardMetadata? = null
private set

private val cardBrandEnricher: CardBrandEnricher = CardBrandEnricher()

init {
Expand All @@ -26,9 +29,10 @@ class CardNumberElement @JvmOverloads constructor(
}

override fun beforeTextChanged(value: String?): String? {
val cardResult = cardBrandEnricher.evaluateCard(getDigitsOnly(value))
if (cardResult.cardDetails?.cardMask != null)
mask = ElementMask(cardResult.cardDetails!!.cardMask)
cardMetadata = cardBrandEnricher.evaluateCard(getDigitsOnly(value))

if (cardMetadata?.cardMask != null)
mask = ElementMask(cardMetadata!!.cardMask!!)

return value
}
Expand All @@ -39,8 +43,7 @@ class CardNumberElement @JvmOverloads constructor(
isEmpty: Boolean,
isValid: Boolean
): ChangeEvent {
val cardResult = cardBrandEnricher.evaluateCard(getDigitsOnly(value))
val eventDetails = cardResult.cardDetails?.brand?.let { brand ->
val eventDetails = this.cardMetadata?.brand?.let { brand ->
mutableListOf(
EventDetails(
"cardBrand",
Expand All @@ -50,7 +53,7 @@ class CardNumberElement @JvmOverloads constructor(
} ?: mutableListOf()

return ChangeEvent(
cardResult.complete,
cardMetadata?.isComplete ?: false,
isEmpty,
isValid,
eventDetails
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,20 @@ import com.basistheory.android.view.validation.RegexValidator
class CardVerificationCodeElement @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0) : TextElement(context, attrs, defStyleAttr) {
defStyleAttr: Int = 0
) : TextElement(context, attrs, defStyleAttr) {

var cardNumberElement: CardNumberElement? = null
set(value) {
if (value != null && cardNumberElement !== value) {
field = value
super.mask =
cardNumberElement?.cardMetadata?.cvcMask?.let { ElementMask(it) } ?: defaultMask
field?.addChangeEventListener { updateMask() }
} else {
field = value
}
}

init {
super.keyboardType = KeyboardType.NUMBER
Expand All @@ -24,4 +37,9 @@ class CardVerificationCodeElement @JvmOverloads constructor(
listOf(digit, digit, digit)
)
}

private fun updateMask() {
super.mask =
cardNumberElement?.cardMetadata?.cvcMask?.let { ElementMask(it) } ?: defaultMask
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import com.basistheory.android.view.mask.ElementMask
import com.basistheory.android.view.transform.ElementTransform
import com.basistheory.android.view.validation.ElementValidator


open class TextElement @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,40 +37,36 @@ class CardBrandEnricherTests {
expectedBrand: String,
expectedCardMask: String
) {
with(cardBrandEnricher.evaluateCard(cardNumber).cardDetails) {
with(cardBrandEnricher.evaluateCard(cardNumber)) {
expectThat(this?.brand).isEqualTo(expectedBrand)
expectThat(this?.cardMask).isEqualTo(expectedCardMask)
}
}

@Test
fun `should pick best match based on identifier length`() {
expectThat(cardBrandEnricher.evaluateCard("4011784867543859").cardDetails?.brand).isEqualTo(
expectThat(cardBrandEnricher.evaluateCard("4011784867543859")?.brand).isEqualTo(
"elo"
)
}

@Test
fun `should set complete for best match card lengths`() {
fun `should set isComplete for best match card lengths`() {
// discover valid lengths are 16 or 19
val sixteenDigitsDiscoverCardNumber = "6582937163058334"

expectThat(cardBrandEnricher.evaluateCard(sixteenDigitsDiscoverCardNumber).complete).isTrue()
expectThat(cardBrandEnricher.evaluateCard("${sixteenDigitsDiscoverCardNumber}1").complete).isFalse()
expectThat(cardBrandEnricher.evaluateCard("${sixteenDigitsDiscoverCardNumber}12").complete).isFalse()
expectThat(cardBrandEnricher.evaluateCard("${sixteenDigitsDiscoverCardNumber}123").complete).isTrue()
expectThat(cardBrandEnricher.evaluateCard(sixteenDigitsDiscoverCardNumber)?.isComplete).isTrue()
expectThat(cardBrandEnricher.evaluateCard("${sixteenDigitsDiscoverCardNumber}1")?.isComplete).isFalse()
expectThat(cardBrandEnricher.evaluateCard("${sixteenDigitsDiscoverCardNumber}12")?.isComplete).isFalse()
expectThat(cardBrandEnricher.evaluateCard("${sixteenDigitsDiscoverCardNumber}123")?.isComplete).isTrue()
}

@Test
fun `should handle null or empty card number`() {
val nullCard = cardBrandEnricher.evaluateCard(null)

expectThat(nullCard.cardDetails).isNull()
expectThat(nullCard.complete).isFalse()
expectThat(nullCard).isNull()

val emptyCard = cardBrandEnricher.evaluateCard("")

expectThat(emptyCard.cardDetails).isNull()
expectThat(emptyCard.complete).isFalse()
expectThat(emptyCard).isNull()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ package com.basistheory.android.view

import android.app.Activity
import com.basistheory.android.event.ChangeEvent
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.impl.annotations.SpyK
import io.mockk.junit4.MockKRule
import io.mockk.spyk
import io.mockk.verify
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
Expand All @@ -17,10 +23,13 @@ import strikt.assertions.single
class CardVerificationCodeElementTests {
private lateinit var cvcElement: CardVerificationCodeElement

private lateinit var cardNumberElement: CardNumberElement

@Before
fun setUp() {
val activity = Robolectric.buildActivity(Activity::class.java).get()
cvcElement = CardVerificationCodeElement(activity)
cardNumberElement = CardNumberElement(activity)
}

@Test
Expand All @@ -33,11 +42,26 @@ class CardVerificationCodeElementTests {
}

@Test
fun `applies mask when setting the value`() {
fun `applies default mask when setting the value and no card number element is attached`() {
cvcElement.cardNumberElement = null

cvcElement.setText("1a2b3c")
expectThat(cvcElement.getText()).isEqualTo("123")
}

@Test
fun `updates mask depending on card brand`() {
cvcElement.cardNumberElement = cardNumberElement

cardNumberElement.setText("42")
cvcElement.setText("1a2b3c4d5e")
expectThat(cvcElement.getText()).isEqualTo("123")

cardNumberElement.setText("34")
cvcElement.setText("1a2b3c4d5e")
expectThat(cvcElement.getText()).isEqualTo("1234")
}

@Test
fun `ChangeEvent is computed properly for incomplete cvc values`() {
val changeEvents = mutableListOf<ChangeEvent>()
Expand All @@ -63,4 +87,13 @@ class CardVerificationCodeElementTests {
get { isComplete }.isTrue()
}
}

@Test
fun `setting card number element multiple times does not duplicate listeners`() {
val cardNumberElement = spyk(CardNumberElement(Robolectric.buildActivity(Activity::class.java).get()))
cvcElement.cardNumberElement = cardNumberElement
cvcElement.cardNumberElement = cardNumberElement

verify(exactly = 1) { cardNumberElement.addChangeEventListener(any()) }
}
}

0 comments on commit 86fe06f

Please sign in to comment.