finanza/card

Payment-card primitives: PAN normalisation, Luhn validation, brand detection by IIN range, masking, BIN/last-four extraction, and expiry parsing.

IIN ranges are a static snapshot of stable card-brand prefixes and lengths and are not a BIN-to-issuer database. See doc/reference/specs/iso-iec-7812-card.md for sources.

Types

Recognised card brands. Unknown is returned when no IIN range matches.

pub type Brand {
  Visa
  Mastercard
  AmericanExpress
  Discover
  Jcb
  DinersClub
  UnionPay
  Unknown
}

Constructors

  • Visa
  • Mastercard
  • AmericanExpress
  • Discover
  • Jcb
  • DinersClub
  • UnionPay
  • Unknown

Options for mask. Build with default_mask and the with_* setters.

pub opaque type MaskOptions

Errors raised by PAN operations.

pub type ValidationError {
  EmptyInput
  InvalidCharacter
  InvalidLength(length: Int)
  InvalidLuhn
  UnknownBrand
  InvalidExpiry
}

Constructors

  • EmptyInput

    Input was empty or only contained whitespace and separators.

  • InvalidCharacter

    Input contained a non-digit character after normalisation.

  • InvalidLength(length: Int)

    The PAN’s length is not valid for any recognised brand.

  • InvalidLuhn

    The PAN failed the Luhn check.

  • UnknownBrand

    The PAN’s prefix did not match any recognised brand IIN range.

  • InvalidExpiry

    Expiry parse received a malformed MM/YY or MM/YYYY value.

Values

pub fn bin(pan pan: String) -> Result(String, ValidationError)

Extract the BIN (first six digits) of a PAN.

pub fn brand_to_string(brand brand: Brand) -> String

Render a Brand as a short upper-case identifier.

pub fn default_mask() -> MaskOptions

Default MaskOptions: keep the first 4 and last 4 digits, mask the rest with *, and group output as 4-digit blocks separated by spaces.

pub fn detect_brand(pan pan: String) -> Brand

Detect the brand of a PAN by inspecting its IIN prefix and length. Returns Unknown when no rule matches.

pub fn expiry_valid(
  expiry expiry: #(Int, Int),
  today today: #(Int, Int),
) -> Bool

Test whether the expiry date (#(month, year)) is on or after today (#(month, year)). The month component of both tuples must be in 1..=12.

Tuples are used (rather than four labelled Int arguments) so that the year/month order cannot be silently swapped at the call site.

pub fn last_four(
  pan pan: String,
) -> Result(String, ValidationError)

Extract the last four digits of a PAN.

pub fn luhn_valid(digits digits: String) -> Bool

Apply the Luhn check to a digit string.

Returns False when digits is empty or contains any non-digit character ("abc", " ", "4242 4242 4242 4242", etc.). The caller can still normalise dynamic input through normalize, but passing un-normalised input is no longer a silent bug — the previous behaviour treated every non-digit as “skip” and returned True for any all-non-digit input because the partial sum landed on zero.

pub fn mask(
  pan pan: String,
  options options: MaskOptions,
) -> Result(String, ValidationError)

Mask a PAN, preserving the configured number of leading and trailing digits and grouping the output.

Grouping is segment-aware: the kept-first block, the mask block, and the kept-last block are grouped independently. This keeps the kept regions intact on irregular-length cards (15-digit AMEX, 14-digit Diners Club) instead of letting their final digit bleed into the next group.

pub fn normalize(pan pan: String) -> String

Strip ASCII whitespace ( , \t, \n, \r, VT, FF) and hyphen-style separators (-, _, .), and fold the three digit-Unicode blocks IMEs commonly produce into their ASCII equivalents:

  • FULLWIDTH DIGIT ZERO..NINE (U+FF10..U+FF19) → "0".."9"
  • ARABIC-INDIC DIGIT ZERO..NINE (U+0660..U+0669) → "0".."9"
  • EXTENDED ARABIC-INDIC DIGIT ZERO..NINE (U+06F0..U+06F9) → "0".."9"

Other Unicode whitespace (NBSP, ideographic space) is not stripped — pre-normalise if needed. The function does not validate that the result is digits-only; downstream luhn_valid and validate continue to enforce that contract.

pub fn parse_expiry(
  input input: String,
) -> Result(#(Int, Int), ValidationError)

Parse a card expiry string into a #(month, year) tuple. Accepts the four common shapes real-world entry forms produce:

  • MM/YY and MM/YYYY (slash) — e.g. "12/26", "12/2026"
  • MM-YY and MM-YYYY (hyphen) — e.g. "12-26", "12-2026"
  • MM.YY and MM.YYYY (dot) — e.g. "12.26", "12.2026"
  • MMYY and MMYYYY (no separator) — e.g. "1226", "122026"

Surrounding whitespace is ignored. Years given as two digits are expanded by prefixing 20 (so 26 becomes 2026). Single-digit months are accepted in the separator forms ("1/26", "1-26", "1.26") but not in the unseparated form — "126" is ambiguous between “January 2026” and “December year 26” so the parser refuses to guess and returns Error(InvalidExpiry).

pub fn parse_expiry_with_window(
  input input: String,
  today today: #(Int, Int),
  window_years window_years: Int,
) -> Result(#(Int, Int), ValidationError)

Parse a card expiry string using a sliding-window interpretation for two-digit years. Accepts the same input shapes as parse_expiry (slash, hyphen, dot, unseparated). The difference is the century rule: a two-digit year YY is read as 20YY if that interpretation lies within window_years of today, and as 19YY otherwise. Four-digit years pass through unchanged.

today is #(month, year) with a four-digit year. window_years is the inclusive upper bound on the future side; PCI DSS and ISO 7813 typically use 50. Use this entry point when the expiry might legitimately predate the current century (e.g. data-archeology use cases) — for routine card capture stay with parse_expiry, which always reads YY as 20YY.

Examples:

  • parse_expiry_with_window(input: "12/26", today: #(5, 2026), window_years: 50)Ok(#(12, 2026))
  • parse_expiry_with_window(input: "12/76", today: #(5, 2026), window_years: 50)Ok(#(12, 2076))
  • parse_expiry_with_window(input: "12/76", today: #(5, 2026), window_years: 20)Ok(#(12, 1976))
pub fn validate(
  pan pan: String,
) -> Result(Brand, ValidationError)

Normalise the input, verify it contains only digits, check length and Luhn, and return the detected Brand.

pub fn with_group_separator(
  options options: MaskOptions,
  separator separator: String,
) -> MaskOptions

Override the separator inserted between groups.

pub fn with_group_size(
  options options: MaskOptions,
  size size: Int,
) -> MaskOptions

Override the grouping size (set to 0 for no grouping).

pub fn with_keep_first(
  options options: MaskOptions,
  count count: Int,
) -> MaskOptions

Override the number of leading digits to preserve.

pub fn with_keep_last(
  options options: MaskOptions,
  count count: Int,
) -> MaskOptions

Override the number of trailing digits to preserve.

pub fn with_mask_char(
  options options: MaskOptions,
  char char: String,
) -> MaskOptions

Override the character used to mask hidden digits.

Search Document