Skip to content

Credit Card Validator — Ruby 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 Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.

# credit-card-validator — Luhn checksum + brand detection for card numbers.
#
# Language: Ruby (3.2, 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 at the bottom:
# sanitize to digits, run the Luhn checksum, detect the issuing brand by
# prefix + length, and group the number for display. Never raises on
# malformed input; mirrors the TypeScript contract.
#
# NOTE on \d: Ruby's Regexp treats \d as ASCII [0-9] by default (unlike
# Python), so the patterns here could use \d — they stick to explicit
# [0-9] ranges to stay byte-for-byte identical to the TypeScript source.

# Full validation result. `brand` is nil when the issuer is unknown.
CardInfo = Struct.new(
  :input,
  :sanitized,   # digits only
  :valid,       # digits present, sane length, and Luhn passes
  :luhn_valid,  # Luhn checksum passes (requires >= 12 digits)
  :brand,       # issuing brand, or nil when unknown
  :formatted,   # grouped per brand spacing rules
  keyword_init: true
)

# Luhn (mod-10) checksum. Empty or non-digit input returns false.
def luhn_check(digits)
  return false if digits.empty? || digits.match?(/[^0-9]/)

  total = 0
  should_double = 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).
  digits.reverse.each_char do |ch|
    d = ch.ord - 48 # ord("0") == 48
    if should_double
      d *= 2
      d -= 9 if d > 9
    end
    total += d
    should_double = !should_double
  end

  total % 10 == 0
end

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

# Detect the card brand by prefix + length. Returns nil when unknown.
def detect_brand(digits)
  return nil if digits.empty?

  BRAND_RULES.each do |name, pattern, lengths|
    return name if digits.match?(pattern) && lengths.include?(digits.length)
  end

  nil
end

# Slice digits into runs of the given widths, joining with spaces.
def group(digits, widths)
  parts = []
  i = 0
  n = digits.length
  widths.each do |w|
    break if i >= n

    parts << digits[i, w] # String#[](start, length) clamps the run at the end
    i += w
  end
  # Any trailing remainder (e.g. a partially typed number) is appended as-is.
  parts << digits[i..] if i < n

  parts.join(' ')
end

# Group the number per brand spacing rules.
def format_card(digits, brand)
  return '' if digits.empty?

  # Amex uses a 4-6-5 grouping; Diners Club's 14-digit variant uses 4-6-4.
  return group(digits, [4, 6, 5]) if brand == 'American Express'
  return group(digits, [4, 6, 4]) if brand == 'Diners Club' && digits.length == 14

  # Default: groups of four separated by spaces; the lookahead (?=.) drops the trailing space.
  digits.gsub(/(.{4})(?=.)/, '\1 ').strip
end

# Validate a card number. Always returns a CardInfo; never raises.
def validate_card(input)
  sanitized = input.gsub(/[^0-9]+/, '')
  brand = detect_brand(sanitized)
  # Luhn is only meaningful once we have a plausible card length (>= 12 digits).
  luhn_valid = sanitized.length >= 12 && luhn_check(sanitized)

  CardInfo.new(
    input: input,
    sanitized: sanitized,
    valid: luhn_valid,
    luhn_valid: luhn_valid,
    brand: brand,
    formatted: format_card(sanitized, brand)
  )
end

if $PROGRAM_NAME == __FILE__
  # Tiny showcase: validate a well-known test number (Visa).
  info = validate_card('4111 1111 1111 1111')
  puts "brand=#{info.brand || 'None'} valid=#{info.valid} formatted=\"#{info.formatted}\""
end

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 →