#![no_std]
#![forbid(unsafe_code)]
#![doc(html_root_url = "https://docs.rs/credit-card-type/0.1.0")]
extern crate alloc;
use alloc::vec::Vec;
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Code {
pub name: &'static str,
pub size: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CardType {
pub type_: &'static str,
pub nice_type: &'static str,
pub gaps: &'static [u8],
pub lengths: &'static [u8],
pub code: Code,
pub match_strength: Option<usize>,
}
enum Pattern {
Prefix(&'static str),
Range(&'static str, &'static str),
}
struct Def {
type_: &'static str,
nice_type: &'static str,
patterns: &'static [Pattern],
gaps: &'static [u8],
lengths: &'static [u8],
code: Code,
}
impl Def {
fn to_card(&self, match_strength: Option<usize>) -> CardType {
CardType {
type_: self.type_,
nice_type: self.nice_type,
gaps: self.gaps,
lengths: self.lengths,
code: self.code,
match_strength,
}
}
}
pub mod brand {
pub const VISA: &str = "visa";
pub const MASTERCARD: &str = "mastercard";
pub const AMERICAN_EXPRESS: &str = "american-express";
pub const DINERS_CLUB: &str = "diners-club";
pub const DISCOVER: &str = "discover";
pub const JCB: &str = "jcb";
pub const UNIONPAY: &str = "unionpay";
pub const NARANJA: &str = "naranja";
pub const VERVE: &str = "verve";
pub const MAESTRO: &str = "maestro";
pub const ELO: &str = "elo";
pub const MIR: &str = "mir";
pub const HIPER: &str = "hiper";
pub const HIPERCARD: &str = "hipercard";
}
use Pattern::{Prefix as P, Range as R};
static DEFS: &[Def] = &[
Def {
type_: "visa",
nice_type: "Visa",
patterns: &[P("4")],
gaps: &[4, 8, 12],
lengths: &[16, 18, 19],
code: Code {
name: "CVV",
size: 3,
},
},
Def {
type_: "mastercard",
nice_type: "Mastercard",
patterns: &[
R("51", "55"),
R("2221", "2229"),
R("223", "229"),
R("23", "26"),
R("270", "271"),
P("2720"),
],
gaps: &[4, 8, 12],
lengths: &[16],
code: Code {
name: "CVC",
size: 3,
},
},
Def {
type_: "american-express",
nice_type: "American Express",
patterns: &[P("34"), P("37")],
gaps: &[4, 10],
lengths: &[15],
code: Code {
name: "CID",
size: 4,
},
},
Def {
type_: "diners-club",
nice_type: "Diners Club",
patterns: &[R("300", "305"), P("36"), P("38"), P("39")],
gaps: &[4, 10],
lengths: &[14, 16, 19],
code: Code {
name: "CVV",
size: 3,
},
},
Def {
type_: "discover",
nice_type: "Discover",
patterns: &[P("6011"), R("644", "649"), P("65")],
gaps: &[4, 8, 12],
lengths: &[16, 19],
code: Code {
name: "CID",
size: 3,
},
},
Def {
type_: "jcb",
nice_type: "JCB",
patterns: &[P("2131"), P("1800"), R("3528", "3589")],
gaps: &[4, 8, 12],
lengths: &[16, 17, 18, 19],
code: Code {
name: "CVV",
size: 3,
},
},
Def {
type_: "unionpay",
nice_type: "UnionPay",
patterns: &[
P("620"),
R("62100", "62182"),
R("62184", "62187"),
R("62185", "62197"),
R("62200", "62205"),
R("622010", "622999"),
P("622018"),
R("62207", "62209"),
R("623", "626"),
P("6270"),
P("6272"),
P("6276"),
R("627700", "627779"),
R("627781", "627799"),
R("6282", "6289"),
P("6291"),
P("6292"),
P("810"),
R("8110", "8131"),
R("8132", "8151"),
R("8152", "8163"),
R("8164", "8171"),
],
gaps: &[4, 8, 12],
lengths: &[14, 15, 16, 17, 18, 19],
code: Code {
name: "CVN",
size: 3,
},
},
Def {
type_: "naranja",
nice_type: "Naranja",
patterns: &[P("589562"), P("402918"), P("527572")],
gaps: &[4, 8, 12],
lengths: &[16],
code: Code {
name: "CVV",
size: 3,
},
},
Def {
type_: "verve",
nice_type: "Verve",
patterns: &[
R("506099", "506127"),
P("506129"),
R("506133", "506150"),
R("506158", "506163"),
P("506166"),
P("506168"),
P("506170"),
P("506173"),
R("506176", "506180"),
P("506184"),
R("506187", "506188"),
P("506191"),
P("506195"),
P("506197"),
P("507865"),
P("507866"),
R("507868", "507877"),
R("507880", "507888"),
P("507900"),
P("507941"),
],
gaps: &[4, 8, 12],
lengths: &[16, 18, 19],
code: Code {
name: "CVV",
size: 3,
},
},
Def {
type_: "maestro",
nice_type: "Maestro",
patterns: &[
P("493698"),
R("500000", "504174"),
R("504176", "506698"),
R("506779", "508999"),
R("56", "59"),
P("63"),
P("67"),
P("6"),
],
gaps: &[4, 8, 12],
lengths: &[12, 13, 14, 15, 16, 17, 18, 19],
code: Code {
name: "CVC",
size: 3,
},
},
Def {
type_: "elo",
nice_type: "Elo",
patterns: &[
P("401178"),
P("401179"),
P("438935"),
P("457631"),
P("457632"),
P("431274"),
P("451416"),
P("457393"),
P("504175"),
R("506699", "506778"),
R("509000", "509999"),
P("627780"),
P("636297"),
P("636368"),
R("650031", "650033"),
R("650035", "650051"),
R("650405", "650439"),
R("650485", "650538"),
R("650541", "650598"),
R("650700", "650718"),
R("650720", "650727"),
R("650901", "650978"),
R("651652", "651679"),
R("655000", "655019"),
R("655021", "655058"),
],
gaps: &[4, 8, 12],
lengths: &[16],
code: Code {
name: "CVE",
size: 3,
},
},
Def {
type_: "mir",
nice_type: "Mir",
patterns: &[R("2200", "2204")],
gaps: &[4, 8, 12],
lengths: &[16, 17, 18, 19],
code: Code {
name: "CVP2",
size: 3,
},
},
Def {
type_: "hiper",
nice_type: "Hiper",
patterns: &[
P("637095"),
P("63737423"),
P("63743358"),
P("637568"),
P("637599"),
P("637609"),
P("637612"),
],
gaps: &[4, 8, 12],
lengths: &[16],
code: Code {
name: "CVC",
size: 3,
},
},
Def {
type_: "hipercard",
nice_type: "Hipercard",
patterns: &[P("606282")],
gaps: &[4, 8, 12],
lengths: &[16],
code: Code {
name: "CVC",
size: 3,
},
},
];
#[must_use]
pub fn credit_card_type(card_number: &str) -> Vec<CardType> {
if card_number.is_empty() {
return DEFS.iter().map(|d| d.to_card(None)).collect();
}
let mut results: Vec<CardType> = Vec::new();
for def in DEFS {
add_matching(card_number, def, &mut results);
}
if let Some(best) = find_best_match(&results) {
return alloc::vec![best];
}
results
}
#[must_use]
pub fn get_type_info(card_type: &str) -> Option<CardType> {
DEFS.iter()
.find(|d| d.type_ == card_type)
.map(|d| d.to_card(None))
}
fn add_matching(card_number: &str, def: &Def, results: &mut Vec<CardType>) {
for pattern in def.patterns {
if !matches(card_number, pattern) {
continue;
}
let pattern_length = match pattern {
Pattern::Prefix(p) => p.len(),
Pattern::Range(min, _) => min.len(),
};
let match_strength = if card_number.len() >= pattern_length {
Some(pattern_length)
} else {
None
};
results.push(def.to_card(match_strength));
break;
}
}
fn matches(card_number: &str, pattern: &Pattern) -> bool {
match pattern {
Pattern::Prefix(p) => matches_prefix(card_number, p),
Pattern::Range(min, max) => matches_range(card_number, min, max),
}
}
fn matches_prefix(card_number: &str, pattern: &str) -> bool {
let n = card_number.len().min(pattern.len());
card_number.as_bytes()[..n] == pattern.as_bytes()[..n]
}
fn matches_range(card_number: &str, min: &str, max: &str) -> bool {
let sub_len = card_number.len().min(min.len());
let card_prefix = &card_number.as_bytes()[..sub_len];
let (Some(value), Some(lo), Some(hi)) = (
parse_int_prefix(card_prefix),
parse_int_prefix(&min.as_bytes()[..sub_len.min(min.len())]),
parse_int_prefix(&max.as_bytes()[..sub_len.min(max.len())]),
) else {
return false;
};
value >= lo && value <= hi
}
fn parse_int_prefix(bytes: &[u8]) -> Option<u64> {
let digits = &bytes[..bytes.iter().take_while(|b| b.is_ascii_digit()).count()];
if digits.is_empty() {
return None;
}
let mut value: u64 = 0;
for &b in digits {
value = value.wrapping_mul(10).wrapping_add(u64::from(b - b'0'));
}
Some(value)
}
fn find_best_match(results: &[CardType]) -> Option<CardType> {
let with_strength = results
.iter()
.filter(|r| r.match_strength.is_some())
.count();
if with_strength == 0 || with_strength != results.len() {
return None;
}
results.iter().copied().reduce(|best, result| {
if best.match_strength.unwrap_or(0) < result.match_strength.unwrap_or(0) {
result
} else {
best
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn types(number: &str) -> Vec<&'static str> {
credit_card_type(number).iter().map(|c| c.type_).collect()
}
#[test]
fn detects_major_brands() {
assert_eq!(types("4111111111111111"), ["visa"]);
assert_eq!(types("5500005555555559"), ["mastercard"]);
assert_eq!(types("378282246310005"), ["american-express"]);
assert_eq!(types("6011111111111117"), ["discover"]);
assert_eq!(types("3530111333300000"), ["jcb"]);
assert_eq!(types("30569309025904"), ["diners-club"]);
}
#[test]
fn mastercard_2_series() {
assert_eq!(types("2221000000000009"), ["mastercard"]);
assert_eq!(types("2720990000000013"), ["mastercard"]);
}
#[test]
fn partial_visa_is_determinate() {
let cards = credit_card_type("41");
assert_eq!(cards.len(), 1);
assert_eq!(cards[0].type_, "visa");
assert_eq!(cards[0].match_strength, Some(1));
}
#[test]
fn ambiguous_prefix_returns_all_candidates() {
let cards = credit_card_type("6");
let names: Vec<_> = cards.iter().map(|c| c.type_).collect();
assert!(names.contains(&"discover"));
assert!(names.contains(&"maestro"));
assert!(names.len() > 1);
assert!(cards.iter().all(|c| c.match_strength.is_none()));
}
#[test]
fn empty_returns_all_brands() {
assert_eq!(credit_card_type("").len(), 14);
assert!(credit_card_type("")
.iter()
.all(|c| c.match_strength.is_none()));
}
#[test]
fn unknown_prefix_is_empty() {
assert!(credit_card_type("9999").is_empty());
}
#[test]
fn get_type_info_works() {
let amex = get_type_info("american-express").unwrap();
assert_eq!(amex.nice_type, "American Express");
assert_eq!(amex.code.size, 4);
assert_eq!(amex.lengths, &[15]);
assert!(get_type_info("not-a-brand").is_none());
}
#[test]
fn metadata_is_exposed() {
let visa = &credit_card_type("4")[0];
assert_eq!(visa.gaps, &[4, 8, 12]);
assert_eq!(visa.lengths, &[16, 18, 19]);
assert_eq!(
visa.code,
Code {
name: "CVV",
size: 3
}
);
}
}