src/finanza@card.erl

-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.