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
-
EmptyInputInput was empty or only contained whitespace and separators.
-
InvalidCharacterInput contained a non-digit character after normalisation.
-
InvalidLength(length: Int)The PAN’s length is not valid for any recognised brand.
-
InvalidLuhnThe PAN failed the Luhn check.
-
UnknownBrandThe PAN’s prefix did not match any recognised brand IIN range.
-
InvalidExpiryExpiry parse received a malformed
MM/YYorMM/YYYYvalue.
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/YYandMM/YYYY(slash) — e.g."12/26","12/2026"MM-YYandMM-YYYY(hyphen) — e.g."12-26","12-2026"MM.YYandMM.YYYY(dot) — e.g."12.26","12.2026"MMYYandMMYYYY(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.