Skip to content

Credit Card Validator — Kotlin source

Validate credit card numbers with the Luhn checksum and detect the issuing brand (Visa, Mastercard, Amex, Discover, Diners, JCB, UnionPay, Maestro). 100% client-side - nothing is transmitted.

This is the Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.

// credit-card-validator — Luhn checksum + brand detection for card numbers.
//
// Language: Kotlin (1.9, standard library only)
// Source:   CosmoDev polyglot showcase port of the Credit Card Validator tool,
//           ported from src/lib/creditCard.ts (the canonical TypeScript lib) and
//           held in lock-step with cli/credit-card-validator/credit-card-validator.go.
// License:  display source — part of CosmoDev's polyglot tool pages.
//
// Pure credit-card validation with no I/O beyond the demo main: sanitize to
// digits, run the Luhn checksum, detect the issuing brand by prefix + length,
// and group the number for display. Never throws on malformed input; mirrors
// the TypeScript contract.
//
// The brand table mirrors the TS regexes with kotlin.text.Regex (stdlib).

/**
 * Full validation result. [brand] is null when the issuer is unknown
 * (the Kotlin equivalent of TypeScript's `string | null`).
 */
data class CardInfo(
    val input: String,
    val sanitized: String,        // digits only
    val valid: Boolean,           // digits present, sane length, and Luhn passes
    val luhnValid: Boolean,       // Luhn checksum passes (requires >= 12 digits)
    val brand: String?,           // issuing brand, or null when unknown
    val formatted: String,        // grouped per brand spacing rules
)

/** Luhn (mod-10) checksum. Empty or non-digit input returns false. */
fun luhnCheck(digits: String): Boolean {
    if (digits.isEmpty() || digits.any { it !in '0'..'9' }) {
        return false // non-digit -> invalid, matching /^[0-9]*$/
    }
    var total = 0
    var shouldDouble = false
    // Walk right-to-left; double every second digit and collapse >9 to its digit-sum
    // (e.g. 7*2=14 -> 14-9=5, identical to summing 1+4).
    for (ch in digits.reversed()) {
        var d = ch - '0'
        if (shouldDouble) {
            d *= 2
            if (d > 9) {
                d -= 9
            }
        }
        total += d
        shouldDouble = !shouldDouble
    }
    return total % 10 == 0
}

/** Brand detection rule: name, prefix pattern, allowed lengths. */
private data class BrandRule(val name: String, val pattern: Regex, val lengths: Set<Int>)

// Brand detection table. ORDER MATTERS: the first prefix+length match wins.
// Maestro is last because its broad "6" / 5x ranges overlap narrower issuers.
private val BRAND_RULES: List<BrandRule> = listOf(
    BrandRule("Visa", Regex("^4"), setOf(13, 16, 19)),
    BrandRule("Mastercard", Regex("^(5[1-5]|2(2[2-9]|[3-6][0-9]|7[01]|720))"), setOf(16)),
    BrandRule("American Express", Regex("^3[47]"), setOf(15)),
    BrandRule(
        "Discover",
        Regex("^(6011|65|64[4-9]|622(12[6-9]|1[3-9][0-9]|[2-8][0-9][0-9]|9([01][0-9]|2[0-5])))"),
        setOf(16, 19),
    ),
    BrandRule("Diners Club", Regex("^(30[0-5]|3095|36|38|39|54|55)"), setOf(14, 16)),
    BrandRule("JCB", Regex("^35(2[89]|[3-8][0-9])"), setOf(16, 17, 18, 19)),
    BrandRule("UnionPay", Regex("^62"), setOf(16, 17, 18, 19)),
    BrandRule("Maestro", Regex("^(50|56|57|58|6)"), (12..19).toSet()),
)

/** Detect the card brand by prefix + length. Returns null when unknown. */
fun detectBrand(digits: String): String? {
    if (digits.isEmpty()) {
        return null
    }
    for (rule in BRAND_RULES) {
        // containsMatchIn with an anchored "^" pattern looks only at the prefix,
        // mirroring Python's pattern.search on these anchored regexes.
        if (rule.pattern.containsMatchIn(digits) && digits.length in rule.lengths) {
            return rule.name
        }
    }
    return null
}

// Slice digits into runs of the given widths, joining with spaces.
private fun group(digits: String, vararg widths: Int): String {
    val parts = mutableListOf<String>()
    var i = 0
    val n = digits.length
    for (w in widths) {
        if (i >= n) break
        parts += digits.substring(i, minOf(i + w, n)) // substring clamps the run at the end
        i += w
    }
    // Any trailing remainder (e.g. a partially typed number) is appended as-is.
    if (i < n) {
        parts += digits.substring(i)
    }
    return parts.joinToString(" ")
}

/** Group the number per brand spacing rules. */
fun formatCard(digits: String, brand: String?): String {
    if (digits.isEmpty()) {
        return ""
    }
    // Amex uses a 4-6-5 grouping; Diners Club's 14-digit variant uses 4-6-4.
    if (brand == "American Express") {
        return group(digits, 4, 6, 5)
    }
    if (brand == "Diners Club" && digits.length == 14) {
        return group(digits, 4, 6, 4)
    }
    // Default: groups of four separated by spaces, no trailing space
    // (equivalent to the TS regex `(.{4})(?=.)` replacement).
    return digits.chunked(4).joinToString(" ")
}

/** Keep ASCII digit characters only. */
private fun sanitizeDigits(input: String): String = input.filter { it in '0'..'9' }

/** Validate a card number. Always returns a CardInfo; never throws. */
fun validateCard(input: String): CardInfo {
    val sanitized = sanitizeDigits(input)
    val brand = detectBrand(sanitized)
    // Luhn is only meaningful once we have a plausible card length (>= 12 digits).
    val luhnValid = sanitized.length >= 12 && luhnCheck(sanitized)
    return CardInfo(
        input = input,
        sanitized = sanitized,
        valid = luhnValid,
        luhnValid = luhnValid,
        brand = brand,
        formatted = formatCard(sanitized, brand),
    )
}

fun main() {
    // Tiny showcase: validate a well-known test number (Visa).
    val info = validateCard("4111 1111 1111 1111")
    println("brand=${info.brand ?: "None"} valid=${info.valid} formatted=\"${info.formatted}\"")
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →