-module(finanza@card).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/finanza/card.gleam").
-export([normalize/1, luhn_valid/1, brand_to_string/1, detect_brand/1, validate/1, default_mask/0, with_keep_first/2, with_keep_last/2, with_mask_char/2, with_group_size/2, with_group_separator/2, mask/2, last_four/1, bin/1, expiry_valid/2, parse_expiry/1]).
-export_type([brand/0, validation_error/0, mask_options/0]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" Payment-card primitives: PAN normalisation, Luhn validation,\n"
" brand detection by IIN range, masking, BIN/last-four extraction,\n"
" and expiry parsing.\n"
"\n"
" IIN ranges are a static snapshot of stable card-brand prefixes\n"
" and lengths and are *not* a BIN-to-issuer database. See\n"
" `doc/reference/specs/iso-iec-7812-card.md` for sources.\n"
).
-type brand() :: visa |
mastercard |
american_express |
discover |
jcb |
diners_club |
union_pay |
unknown.
-type validation_error() :: empty_input |
invalid_character |
{invalid_length, integer()} |
invalid_luhn |
unknown_brand |
invalid_expiry.
-opaque mask_options() :: {mask_options,
integer(),
integer(),
binary(),
integer(),
binary()}.
-file("src/finanza/card.gleam", 67).
-spec is_pan_char(binary()) -> boolean().
is_pan_char(Grapheme) ->
case Grapheme of
<<" "/utf8>> ->
false;
<<"-"/utf8>> ->
false;
<<"_"/utf8>> ->
false;
<<"."/utf8>> ->
false;
_ ->
true
end.
-file("src/finanza/card.gleam", 60).
?DOC(
" Strip ASCII whitespace and hyphen-style separators (`-`, ` `).\n"
" Does not validate that the result is digits-only.\n"
).
-spec normalize(binary()) -> binary().
normalize(Pan) ->
_pipe = Pan,
_pipe@1 = gleam@string:to_graphemes(_pipe),
_pipe@2 = gleam@list:filter(_pipe@1, fun is_pan_char/1),
erlang:list_to_binary(_pipe@2).
-file("src/finanza/card.gleam", 98).
-spec luhn_contribution(integer(), integer()) -> integer().
luhn_contribution(Value, Index) ->
gleam@bool:guard(
(Index rem 2) =:= 0,
Value,
fun() ->
Doubled = Value * 2,
gleam@bool:guard(Doubled > 9, Doubled - 9, fun() -> Doubled end)
end
).
-file("src/finanza/card.gleam", 105).
-spec digit_value(binary()) -> {ok, integer()} | {error, nil}.
digit_value(Char) ->
case Char of
<<"0"/utf8>> ->
{ok, 0};
<<"1"/utf8>> ->
{ok, 1};
<<"2"/utf8>> ->
{ok, 2};
<<"3"/utf8>> ->
{ok, 3};
<<"4"/utf8>> ->
{ok, 4};
<<"5"/utf8>> ->
{ok, 5};
<<"6"/utf8>> ->
{ok, 6};
<<"7"/utf8>> ->
{ok, 7};
<<"8"/utf8>> ->
{ok, 8};
<<"9"/utf8>> ->
{ok, 9};
_ ->
{error, nil}
end.
-file("src/finanza/card.gleam", 87).
-spec luhn_sum(list(binary())) -> integer().
luhn_sum(Chars) ->
_pipe = Chars,
_pipe@1 = lists:reverse(_pipe),
gleam@list:index_fold(
_pipe@1,
0,
fun(Acc, Char, Index) -> case digit_value(Char) of
{ok, V} ->
Acc + luhn_contribution(V, Index);
{error, nil} ->
Acc
end end
).
-file("src/finanza/card.gleam", 79).
?DOC(
" Apply the Luhn check to a digit string. The caller is responsible\n"
" for passing a normalised, all-digit string (use\n"
" [`normalize`](#normalize) and check the format first).\n"
).
-spec luhn_valid(binary()) -> boolean().
luhn_valid(Digits) ->
Chars = gleam@string:to_graphemes(Digits),
case gleam@list:is_empty(Chars) of
true ->
false;
false ->
(luhn_sum(Chars) rem 10) =:= 0
end.
-file("src/finanza/card.gleam", 205).
-spec starts_with(binary(), binary()) -> boolean().
starts_with(Pan, Prefix) ->
gleam_stdlib:string_starts_with(Pan, Prefix).
-file("src/finanza/card.gleam", 162).
-spec visa_matches(binary(), integer()) -> boolean().
visa_matches(Pan, Length) ->
starts_with(Pan, <<"4"/utf8>>) andalso (((Length =:= 13) orelse (Length =:= 16))
orelse (Length =:= 19)).
-file("src/finanza/card.gleam", 172).
-spec amex_matches(binary(), integer()) -> boolean().
amex_matches(Pan, Length) ->
(Length =:= 15) andalso (starts_with(Pan, <<"34"/utf8>>) orelse starts_with(
Pan,
<<"37"/utf8>>
)).
-file("src/finanza/card.gleam", 201).
-spec unionpay_matches(binary(), integer()) -> boolean().
unionpay_matches(Pan, Length) ->
(starts_with(Pan, <<"62"/utf8>>) andalso (Length >= 16)) andalso (Length =< 19).
-file("src/finanza/card.gleam", 209).
-spec prefix_in_range(binary(), integer(), integer(), integer()) -> boolean().
prefix_in_range(Pan, Prefix_len, Low, High) ->
Prefix = gleam@string:slice(Pan, 0, Prefix_len),
case gleam_stdlib:parse_int(Prefix) of
{ok, Value} ->
(Value >= Low) andalso (Value =< High);
{error, nil} ->
false
end.
-file("src/finanza/card.gleam", 166).
-spec mastercard_matches(binary(), integer()) -> boolean().
mastercard_matches(Pan, Length) ->
gleam@bool:guard(
Length /= 16,
false,
fun() ->
prefix_in_range(Pan, 2, 51, 55) orelse prefix_in_range(
Pan,
4,
2221,
2720
)
end
).
-file("src/finanza/card.gleam", 176).
-spec discover_matches(binary(), integer()) -> boolean().
discover_matches(Pan, Length) ->
gleam@bool:guard(
(Length < 16) orelse (Length > 19),
false,
fun() ->
((starts_with(Pan, <<"6011"/utf8>>) orelse starts_with(
Pan,
<<"65"/utf8>>
))
orelse prefix_in_range(Pan, 3, 644, 649))
orelse prefix_in_range(Pan, 6, 622126, 622925)
end
).
-file("src/finanza/card.gleam", 184).
-spec jcb_matches(binary(), integer()) -> boolean().
jcb_matches(Pan, Length) ->
gleam@bool:guard(
(Length < 16) orelse (Length > 19),
false,
fun() -> prefix_in_range(Pan, 4, 3528, 3589) end
).
-file("src/finanza/card.gleam", 189).
-spec diners_matches(binary(), integer()) -> boolean().
diners_matches(Pan, Length) ->
gleam@bool:guard(
(Length < 14) orelse (Length > 19),
false,
fun() ->
((prefix_in_range(Pan, 3, 300, 305) orelse starts_with(
Pan,
<<"36"/utf8>>
))
orelse starts_with(Pan, <<"38"/utf8>>))
orelse starts_with(Pan, <<"39"/utf8>>)
end
).
-file("src/finanza/card.gleam", 138).
-spec classify_brand(binary()) -> brand().
classify_brand(Pan) ->
Length = string:length(Pan),
gleam@bool:lazy_guard(
amex_matches(Pan, Length),
fun() -> american_express end,
fun() ->
gleam@bool:lazy_guard(
diners_matches(Pan, Length),
fun() -> diners_club end,
fun() ->
gleam@bool:lazy_guard(
jcb_matches(Pan, Length),
fun() -> jcb end,
fun() ->
gleam@bool:lazy_guard(
mastercard_matches(Pan, Length),
fun() -> mastercard end,
fun() ->
gleam@bool:lazy_guard(
visa_matches(Pan, Length),
fun() -> visa end,
fun() ->
gleam@bool:lazy_guard(
unionpay_matches(Pan, Length),
fun() -> union_pay end,
fun() ->
gleam@bool:lazy_guard(
discover_matches(
Pan,
Length
),
fun() -> discover end,
fun() -> unknown end
)
end
)
end
)
end
)
end
)
end
)
end
).
-file("src/finanza/card.gleam", 223).
?DOC(" Render a [`Brand`](#Brand) as a short upper-case identifier.\n").
-spec brand_to_string(brand()) -> binary().
brand_to_string(Brand) ->
case Brand of
visa ->
<<"VISA"/utf8>>;
mastercard ->
<<"MASTERCARD"/utf8>>;
american_express ->
<<"AMEX"/utf8>>;
discover ->
<<"DISCOVER"/utf8>>;
jcb ->
<<"JCB"/utf8>>;
diners_club ->
<<"DINERS"/utf8>>;
union_pay ->
<<"UNIONPAY"/utf8>>;
unknown ->
<<"UNKNOWN"/utf8>>
end.
-file("src/finanza/card.gleam", 259).
-spec digits_only(binary()) -> {ok, nil} | {error, validation_error()}.
digits_only(Pan) ->
Bad = begin
_pipe = Pan,
_pipe@1 = gleam@string:to_graphemes(_pipe),
gleam@list:any(_pipe@1, fun(C) -> case digit_value(C) of
{ok, _} ->
false;
{error, nil} ->
true
end end)
end,
case Bad of
true ->
{error, invalid_character};
false ->
{ok, nil}
end.
-file("src/finanza/card.gleam", 125).
?DOC(
" Detect the brand of a PAN by inspecting its IIN prefix and length.\n"
" Returns [`Unknown`](#Brand) when no rule matches.\n"
).
-spec detect_brand(binary()) -> brand().
detect_brand(Pan) ->
Normalised = normalize(Pan),
case digits_only(Normalised) of
{ok, _} ->
classify_brand(Normalised);
{error, invalid_character} ->
unknown;
{error, empty_input} ->
unknown;
{error, {invalid_length, _}} ->
unknown;
{error, invalid_luhn} ->
unknown;
{error, unknown_brand} ->
unknown;
{error, invalid_expiry} ->
unknown
end.
-file("src/finanza/card.gleam", 240).
?DOC(
" Normalise the input, verify it contains only digits, check length\n"
" and Luhn, and return the detected [`Brand`](#Brand).\n"
).
-spec validate(binary()) -> {ok, brand()} | {error, validation_error()}.
validate(Pan) ->
Normalised = normalize(Pan),
gleam@bool:guard(
Normalised =:= <<""/utf8>>,
{error, empty_input},
fun() ->
gleam@result:'try'(
digits_only(Normalised),
fun(_) ->
Length = string:length(Normalised),
gleam@bool:guard(
(Length < 12) orelse (Length > 19),
{error, {invalid_length, Length}},
fun() ->
gleam@bool:guard(
not luhn_valid(Normalised),
{error, invalid_luhn},
fun() -> case classify_brand(Normalised) of
unknown ->
{error, unknown_brand};
Brand ->
{ok, Brand}
end end
)
end
)
end
)
end
).
-file("src/finanza/card.gleam", 280).
?DOC(
" Default [`MaskOptions`](#MaskOptions): keep the first 4 and last 4\n"
" digits, mask the rest with `*`, and group output as 4-digit blocks\n"
" separated by spaces.\n"
).
-spec default_mask() -> mask_options().
default_mask() ->
{mask_options, 4, 4, <<"*"/utf8>>, 4, <<" "/utf8>>}.
-file("src/finanza/card.gleam", 291).
?DOC(" Override the number of leading digits to preserve.\n").
-spec with_keep_first(mask_options(), integer()) -> mask_options().
with_keep_first(Options, Count) ->
{mask_options,
Count,
erlang:element(3, Options),
erlang:element(4, Options),
erlang:element(5, Options),
erlang:element(6, Options)}.
-file("src/finanza/card.gleam", 299).
?DOC(" Override the number of trailing digits to preserve.\n").
-spec with_keep_last(mask_options(), integer()) -> mask_options().
with_keep_last(Options, Count) ->
{mask_options,
erlang:element(2, Options),
Count,
erlang:element(4, Options),
erlang:element(5, Options),
erlang:element(6, Options)}.
-file("src/finanza/card.gleam", 307).
?DOC(" Override the character used to mask hidden digits.\n").
-spec with_mask_char(mask_options(), binary()) -> mask_options().
with_mask_char(Options, Char) ->
{mask_options,
erlang:element(2, Options),
erlang:element(3, Options),
Char,
erlang:element(5, Options),
erlang:element(6, Options)}.
-file("src/finanza/card.gleam", 315).
?DOC(" Override the grouping size (set to `0` for no grouping).\n").
-spec with_group_size(mask_options(), integer()) -> mask_options().
with_group_size(Options, Size) ->
{mask_options,
erlang:element(2, Options),
erlang:element(3, Options),
erlang:element(4, Options),
Size,
erlang:element(6, Options)}.
-file("src/finanza/card.gleam", 323).
?DOC(" Override the separator inserted between groups.\n").
-spec with_group_separator(mask_options(), binary()) -> mask_options().
with_group_separator(Options, Separator) ->
{mask_options,
erlang:element(2, Options),
erlang:element(3, Options),
erlang:element(4, Options),
erlang:element(5, Options),
Separator}.
-file("src/finanza/card.gleam", 369).
-spec clamp(integer(), integer(), integer()) -> integer().
clamp(Value, Low, High) ->
gleam@bool:guard(
Value < Low,
Low,
fun() -> gleam@bool:guard(Value > High, High, fun() -> Value end) end
).
-file("src/finanza/card.gleam", 383).
-spec chunks_loop(list(binary()), integer(), list(list(binary()))) -> list(list(binary())).
chunks_loop(Chars, Size, Acc) ->
case Chars of
[] ->
Acc;
_ ->
Head = gleam@list:take(Chars, Size),
Tail = gleam@list:drop(Chars, Size),
chunks_loop(Tail, Size, [Head | Acc])
end.
-file("src/finanza/card.gleam", 375).
-spec chunks(binary(), integer()) -> list(binary()).
chunks(Value, Size) ->
gleam@bool:guard(
Size =< 0,
[Value],
fun() ->
Chars = gleam@string:to_graphemes(Value),
_pipe = chunks_loop(Chars, Size, []),
_pipe@1 = lists:reverse(_pipe),
gleam@list:map(_pipe@1, fun erlang:list_to_binary/1)
end
).
-file("src/finanza/card.gleam", 338).
?DOC(
" Mask a PAN, preserving the configured number of leading and\n"
" trailing digits and grouping the output.\n"
"\n"
" Grouping is segment-aware: the kept-first block, the mask block,\n"
" and the kept-last block are grouped independently. This keeps the\n"
" kept regions intact on irregular-length cards (15-digit AMEX,\n"
" 14-digit Diners Club) instead of letting their final digit bleed\n"
" into the next group.\n"
).
-spec mask(binary(), mask_options()) -> {ok, binary()} |
{error, validation_error()}.
mask(Pan, Options) ->
Normalised = normalize(Pan),
gleam@bool:guard(
Normalised =:= <<""/utf8>>,
{error, empty_input},
fun() ->
gleam@result:'try'(
digits_only(Normalised),
fun(_) ->
Length = string:length(Normalised),
Keep_first = clamp(erlang:element(2, Options), 0, Length),
Keep_last = clamp(
erlang:element(3, Options),
0,
Length - Keep_first
),
Mask_count = (Length - Keep_first) - Keep_last,
First_block = gleam@string:slice(Normalised, 0, Keep_first),
Mask_block = gleam@string:repeat(
erlang:element(4, Options),
Mask_count
),
Last_block = gleam@string:slice(
Normalised,
Length - Keep_last,
Keep_last
),
Separator = case erlang:element(5, Options) =< 0 of
true ->
<<""/utf8>>;
false ->
erlang:element(6, Options)
end,
Segments = begin
_pipe = [First_block, Mask_block, Last_block],
_pipe@1 = gleam@list:filter(
_pipe,
fun(S) -> S /= <<""/utf8>> end
),
gleam@list:flat_map(
_pipe@1,
fun(Seg) ->
chunks(Seg, erlang:element(5, Options))
end
)
end,
{ok, gleam@string:join(Segments, Separator)}
end
)
end
).
-file("src/finanza/card.gleam", 399).
?DOC(" Extract the last four digits of a PAN.\n").
-spec last_four(binary()) -> {ok, binary()} | {error, validation_error()}.
last_four(Pan) ->
Normalised = normalize(Pan),
gleam@bool:guard(
Normalised =:= <<""/utf8>>,
{error, empty_input},
fun() ->
gleam@result:'try'(
digits_only(Normalised),
fun(_) ->
Length = string:length(Normalised),
gleam@bool:guard(
Length < 4,
{error, {invalid_length, Length}},
fun() ->
{ok, gleam@string:slice(Normalised, Length - 4, 4)}
end
)
end
)
end
).
-file("src/finanza/card.gleam", 412).
?DOC(" Extract the BIN (first six digits) of a PAN.\n").
-spec bin(binary()) -> {ok, binary()} | {error, validation_error()}.
bin(Pan) ->
Normalised = normalize(Pan),
gleam@bool:guard(
Normalised =:= <<""/utf8>>,
{error, empty_input},
fun() ->
gleam@result:'try'(
digits_only(Normalised),
fun(_) ->
Length = string:length(Normalised),
gleam@bool:guard(
Length < 6,
{error, {invalid_length, Length}},
fun() -> {ok, gleam@string:slice(Normalised, 0, 6)} end
)
end
)
end
).
-file("src/finanza/card.gleam", 432).
?DOC(
" Test whether the `expiry` date (`#(month, year)`) is on or after\n"
" `today` (`#(month, year)`). The month component of both tuples\n"
" must be in `1..=12`.\n"
"\n"
" Tuples are used (rather than four labelled `Int` arguments) so that\n"
" the year/month order cannot be silently swapped at the call site.\n"
).
-spec expiry_valid({integer(), integer()}, {integer(), integer()}) -> boolean().
expiry_valid(Expiry, Today) ->
{Month, Year} = Expiry,
{Today_month, Today_year} = Today,
gleam@bool:guard(
(Month < 1) orelse (Month > 12),
false,
fun() ->
gleam@bool:guard(
(Today_month < 1) orelse (Today_month > 12),
false,
fun() ->
gleam@bool:guard(
Year > Today_year,
true,
fun() ->
gleam@bool:guard(
Year < Today_year,
false,
fun() -> Month >= Today_month end
)
end
)
end
)
end
).
-file("src/finanza/card.gleam", 459).
-spec parse_month(binary()) -> {ok, integer()} | {error, validation_error()}.
parse_month(S) ->
case gleam_stdlib:parse_int(gleam@string:trim(S)) of
{ok, M} when (M >= 1) andalso (M =< 12) ->
{ok, M};
_ ->
{error, invalid_expiry}
end.
-file("src/finanza/card.gleam", 466).
-spec parse_year(binary()) -> {ok, integer()} | {error, validation_error()}.
parse_year(S) ->
Trimmed = gleam@string:trim(S),
Length = string:length(Trimmed),
gleam@bool:guard(
(Length /= 2) andalso (Length /= 4),
{error, invalid_expiry},
fun() -> case gleam_stdlib:parse_int(Trimmed) of
{ok, Y} when Length =:= 4 ->
{ok, Y};
{ok, Y@1} ->
{ok, 2000 + Y@1};
{error, nil} ->
{error, invalid_expiry}
end end
).
-file("src/finanza/card.gleam", 448).
?DOC(
" Parse a `\"MM/YY\"` or `\"MM/YYYY\"` expiry string into a\n"
" `#(month, year)` tuple. Years given as two digits are expanded by\n"
" prefixing `20`.\n"
).
-spec parse_expiry(binary()) -> {ok, {integer(), integer()}} |
{error, validation_error()}.
parse_expiry(Input) ->
case gleam@string:split(Input, <<"/"/utf8>>) of
[Month_str, Year_str] ->
gleam@result:'try'(
parse_month(Month_str),
fun(Month) ->
gleam@result:map(
parse_year(Year_str),
fun(Year) -> {Month, Year} end
)
end
);
_ ->
{error, invalid_expiry}
end.