Validation

Card numbers

Payment card numbers checked before they reach a payment provider: Luhn check digit, brand and length, expiry, and the number formatted or masked for receipts.

Version 1.0.0 · 5 functions · 0 crates

#payments#forms#checkout#validation

Functions

What use cards.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use card_check, card_valid from cards (Quillo 0.6).

card_check(number: str) → any

What a card number is: { valid, brand, luhn, length_ok, last4, cvc_length }. brand is "visa", "mastercard", "amex", "discover", "jcb", "diners", "unionpay", "maestro" or null; valid means the check digit and the length both fit that brand. Spaces and dashes are ignored. It does not say the card exists or has money: only the bank knows.

card_valid(number: str) → bool

Whether number is a valid card number (check digit, length and a known brand).

card_mask(number: str) → stror an error

The number masked for a receipt or a screen, in groups as printed on the card: "•••• •••• •••• 4242" (Amex: "•••• •••••• •1005"). An error when it is not digits.

card_format(number: str) → stror an error

The number in groups as printed on the card, for a form field as it is typed: "4242424242424242" is "4242 4242 4242 4242", Amex in 4-6-5.

card_expiry_ok(month: int, year: int) → bool

Whether a card's expiry month and year (two or four digits) are this month or later, by the server's clock in UTC.

Get it

$ quillo install cards               # cards.qwer into this folder$ quillo install cards --example     # and its example, cards.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/cards.qwer$ curl -O https://quillolang.org/plugins/files/cards.qwe

Then, in your program, all of it or only what you call:

main.qwe
use cards.qweruse card_check, card_valid from cards      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]

Example

cards.qwe
// A checkout form's card field checked before the payment provider is called: brand, check digit, expiry, and a masked number for the receipt.////   quillo run cards.qwe      then open http://localhost:8080/check?number=4242%204242%204242%204242use "cards.qwer"route GET /check ? { number: str } = { card: card_check(number), shown: card_mask(number) ?? null, typed: card_format(number) ?? null }route GET /test-cards = ["4242424242424242", "5555 5555 5555 4444", "3782 822463 10005", "6011111111111117", "4242424242424241"] | map((n) => { number: n, brand: card_check(n).brand, valid: card_valid(n), receipt: card_mask(n)? })route GET /expiry = { old: card_expiry_ok(1, 20), future: card_expiry_ok(12, 2035), bad_month: card_expiry_ok(13, 2030) }
$ quillo run cards.qwe$ curl 'localhost:8080/check?number=4242%204242%204242%204242'$ curl 'localhost:8080/test-cards'$ curl 'localhost:8080/expiry'

Source

cards.qwer
//! Payment card numbers checked before they reach a payment provider: the Luhn check digit, the brand (Visa, Mastercard, Amex...), the right length, and the number masked for receipts.//! version: 1.0.0//!//! [dependencies]use serde_json::{json, Value};fn digits(number: &str) -> Option<String> {    let d: String = number.chars().filter(|c| !c.is_whitespace() && *c != '-').collect();    if d.is_empty() || d.len() > 25 || !d.chars().all(|c| c.is_ascii_digit()) { None } else { Some(d) }}fn luhn_ok(d: &str) -> bool {    let sum: u32 = d.chars().rev().enumerate().map(|(i, c)| {        let n = c.to_digit(10).unwrap_or(0);        if i % 2 == 1 { let x = n * 2; if x > 9 { x - 9 } else { x } } else { n }    }).sum();    d.len() >= 2 && sum % 10 == 0}/// (brand, lengths, CVC length) by the number's first digits (its IIN range).fn brand(d: &str) -> Option<(&'static str, &'static [usize], usize)> {    let p = |n: usize| d.get(..n).and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);    let r = |n: usize, lo: u32, hi: u32| (lo..=hi).contains(&p(n));    Some(if r(2, 34, 34) || r(2, 37, 37) {        ("amex", &[15], 4)    } else if r(4, 2221, 2720) || r(2, 51, 55) {        ("mastercard", &[16], 3)    } else if r(4, 6011, 6011) || r(2, 65, 65) || r(3, 644, 649) || r(6, 622126, 622925) {        ("discover", &[16, 17, 18, 19], 3)    } else if r(4, 3528, 3589) {        ("jcb", &[16, 17, 18, 19], 3)    } else if r(2, 36, 36) || r(2, 38, 39) || r(3, 300, 305) {        ("diners", &[14, 15, 16, 17, 18, 19], 3)    } else if r(2, 62, 62) || r(2, 81, 81) {        ("unionpay", &[16, 17, 18, 19], 3)    } else if r(2, 50, 50) || r(2, 56, 58) || r(1, 6, 6) {        ("maestro", &[12, 13, 14, 15, 16, 17, 18, 19], 3)    } else if r(1, 4, 4) {        ("visa", &[13, 16, 19], 3)    } else {        return None;    })}/// What a card number is: `{ valid, brand, luhn, length_ok, last4, cvc_length }`. `brand` is "visa", "mastercard", "amex", "discover", "jcb", "diners", "unionpay", "maestro" or null; `valid` means the check digit and the length both fit that brand. Spaces and dashes are ignored. It does not say the card exists or has money: only the bank knows.pub fn card_check(number: &str) -> Value {    let Some(d) = digits(number) else {        return json!({ "valid": false, "brand": null, "luhn": false, "length_ok": false, "last4": null, "cvc_length": null });    };    let b = brand(&d);    let luhn = luhn_ok(&d);    let length_ok = b.map_or(d.len() >= 12 && d.len() <= 19, |(_, l, _)| l.contains(&d.len()));    json!({        "valid": b.is_some() && luhn && length_ok,        "brand": b.map(|x| x.0),        "luhn": luhn,        "length_ok": length_ok,        "last4": d.get(d.len().saturating_sub(4)..),        "cvc_length": b.map(|x| x.2),    })}/// Whether `number` is a valid card number (check digit, length and a known brand).pub fn card_valid(number: &str) -> bool {    card_check(number)["valid"].as_bool().unwrap_or(false)}/// The number masked for a receipt or a screen, in groups as printed on the card: "•••• •••• •••• 4242" (Amex: "•••• •••••• •1005"). An error when it is not digits.pub fn card_mask(number: &str) -> Result<String, String> {    let d = digits(number).ok_or("a card number is digits, spaces and dashes")?;    let keep = d.len().saturating_sub(4);    let masked: Vec<char> = d.chars().enumerate().map(|(i, c)| if i < keep { '•' } else { c }).collect();    let groups: &[usize] = if brand(&d).map(|b| b.0) == Some("amex") { &[4, 6, 5] } else { &[4, 4, 4, 4, 4] };    let mut out = String::new();    let mut i = 0;    for &g in groups {        if i >= masked.len() {            break;        }        if !out.is_empty() {            out.push(' ');        }        out.extend(&masked[i..(i + g).min(masked.len())]);        i += g;    }    if i < masked.len() {        out.push(' ');        out.extend(&masked[i..]);    }    Ok(out)}/// The number in groups as printed on the card, for a form field as it is typed: "4242424242424242" is "4242 4242 4242 4242", Amex in 4-6-5.pub fn card_format(number: &str) -> Result<String, String> {    let d = digits(number).ok_or("a card number is digits, spaces and dashes")?;    let groups: &[usize] = if brand(&d).map(|b| b.0) == Some("amex") { &[4, 6, 5] } else { &[4, 4, 4, 4, 3] };    let mut out = Vec::new();    let mut i = 0;    for &g in groups {        if i >= d.len() {            break;        }        out.push(&d[i..(i + g).min(d.len())]);        i += g;    }    if i < d.len() {        out.push(&d[i..]);    }    Ok(out.join(" "))}/// Whether a card's expiry month and year (two or four digits) are this month or later, by the server's clock in UTC.pub fn card_expiry_ok(month: i64, year: i64) -> bool {    if !(1..=12).contains(&month) {        return false;    }    let year = if year < 100 { 2000 + year } else { year };    let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64);    // Days since 1970 to a civil year and month (Howard Hinnant's algorithm).    let z = secs / 86400 + 719468;    let era = z.div_euclid(146097);    let doe = z - era * 146097;    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);    let mp = (5 * doy + 2) / 153;    let m = if mp < 10 { mp + 3 } else { mp - 9 };    let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 };    (year, month) >= (y, m)}
Thread
77Useful? Like it, or add to its thread below.

Thread 0 posts

On Card numbers: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.