src/finanza@currency.erl

-module(finanza@currency).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/finanza/currency.gleam").
-export([new_currency/4, code/1, exponent/1, symbol/1, name/1, new_money/2, from_minor/2, to_minor/2, amount/1, currency_of/1, multiply/2, divide/3, negate/1, equal/2, allocate/2, to_string/1, default_format/0, with_symbol_position/2, with_thousands_separator/2, with_decimal_separator/2, with_negative_style/2, with_currency_code/2, with_minor_units/2, format/2, add/2, subtract/2, compare/2]).
-export_type([currency/0, money/0, currency_error/0, symbol_position/0, negative_style/0, format_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(
    " Currency and money types built on top of\n"
    " [`finanza/decimal`](./decimal.html).\n"
    "\n"
    " A [`Currency`](#Currency) is a small record describing an ISO 4217\n"
    " alpha-3 code together with display metadata. A\n"
    " [`Money`](#Money) pairs a `Decimal` amount with a `Currency` so\n"
    " arithmetic that crosses currencies is rejected with a typed error.\n"
    "\n"
    " Both types are `pub opaque`. Build them through the smart\n"
    " constructors [`new_currency`](#new_currency) and\n"
    " [`new`](#new) (for `Money`), or pick a catalogue value from\n"
    " [`finanza/currency/catalog`](./currency/catalog.html).\n"
).

-opaque currency() :: {currency, binary(), integer(), binary(), binary()}.

-opaque money() :: {money, finanza@decimal:decimal(), currency()}.

-type currency_error() :: {currency_mismatch, binary(), binary()} |
    invalid_exponent |
    invalid_currency_code |
    empty_ratios |
    non_positive_ratio |
    {arithmetic_error, finanza@decimal:arithmetic_error()}.

-type symbol_position() :: prefix | suffix | no_symbol.

-type negative_style() :: minus_sign | parentheses.

-opaque format_options() :: {format_options,
        symbol_position(),
        binary(),
        binary(),
        negative_style(),
        boolean(),
        boolean()}.

-file("src/finanza/currency.gleam", 86).
?DOC(
    " Build a custom [`Currency`](#Currency). Use this when the desired\n"
    " currency is outside the static catalogue.\n"
    "\n"
    " `code` must be a non-empty string. `exponent` is the number of\n"
    " minor-unit digits and must be in the range 0–8.\n"
).
-spec new_currency(binary(), integer(), binary(), binary()) -> {ok, currency()} |
    {error, currency_error()}.
new_currency(Code, Exponent, Symbol, Name) ->
    gleam@bool:guard(
        Code =:= <<""/utf8>>,
        {error, invalid_currency_code},
        fun() ->
            gleam@bool:guard(
                (Exponent < 0) orelse (Exponent > 8),
                {error, invalid_exponent},
                fun() -> {ok, {currency, Code, Exponent, Symbol, Name}} end
            )
        end
    ).

-file("src/finanza/currency.gleam", 101).
?DOC(" ISO 4217 alpha-3 code (e.g. `\"USD\"`).\n").
-spec code(currency()) -> binary().
code(C) ->
    erlang:element(2, C).

-file("src/finanza/currency.gleam", 106).
?DOC(" Minor-unit exponent (USD = 2, JPY = 0, BHD = 3, etc.).\n").
-spec exponent(currency()) -> integer().
exponent(C) ->
    erlang:element(3, C).

-file("src/finanza/currency.gleam", 111).
?DOC(" Display symbol (e.g. `\"$\"`, `\"¥\"`).\n").
-spec symbol(currency()) -> binary().
symbol(C) ->
    erlang:element(4, C).

-file("src/finanza/currency.gleam", 116).
?DOC(" English-language name of the currency.\n").
-spec name(currency()) -> binary().
name(C) ->
    erlang:element(5, C).

-file("src/finanza/currency.gleam", 127).
?DOC(
    " Build a `Money` from a [`Decimal`](./decimal.html#Decimal) and a\n"
    " [`Currency`](#Currency). Named `new_money` rather than `new` to\n"
    " keep symmetry with [`new_currency`](#new_currency) and to avoid\n"
    " the surprise of `currency.new` returning the *other* type from\n"
    " the module's name.\n"
).
-spec new_money(finanza@decimal:decimal(), currency()) -> money().
new_money(Amount, Currency) ->
    {money, Amount, Currency}.

-file("src/finanza/currency.gleam", 139).
?DOC(
    " Build a `Money` from an integer number of minor units. The\n"
    " resulting amount has exponent `-currency.exponent`.\n"
    "\n"
    " `from_minor(units: 1234, currency: catalog.usd())` represents\n"
    " `$12.34`.\n"
).
-spec from_minor(integer(), currency()) -> money().
from_minor(Units, Currency) ->
    {money, finanza@decimal:new(Units, - erlang:element(3, Currency)), Currency}.

-file("src/finanza/currency.gleam", 148).
?DOC(
    " Convert a `Money` back to its minor-unit integer count, rounding\n"
    " to the currency's exponent using `mode`.\n"
).
-spec to_minor(money(), finanza@decimal@rounding:mode()) -> {ok, integer()} |
    {error, currency_error()}.
to_minor(M, Mode) ->
    gleam@result:map(
        begin
            _pipe = finanza@decimal:rescale(
                erlang:element(2, M),
                - erlang:element(3, erlang:element(3, M)),
                Mode
            ),
            gleam@result:map_error(
                _pipe,
                fun(Field@0) -> {arithmetic_error, Field@0} end
            )
        end,
        fun(Rescaled) -> finanza@decimal:coefficient(Rescaled) end
    ).

-file("src/finanza/currency.gleam", 160).
?DOC(" The amount component of a `Money`.\n").
-spec amount(money()) -> finanza@decimal:decimal().
amount(M) ->
    erlang:element(2, M).

-file("src/finanza/currency.gleam", 167).
?DOC(
    " The currency component of a `Money`. Named `currency_of` to avoid\n"
    " a `currency.currency(m)` call site, which reads awkwardly given\n"
    " the module name.\n"
).
-spec currency_of(money()) -> currency().
currency_of(M) ->
    erlang:element(3, M).

-file("src/finanza/currency.gleam", 193).
?DOC(" Multiply a money value by a scalar.\n").
-spec multiply(money(), finanza@decimal:decimal()) -> {ok, money()} |
    {error, currency_error()}.
multiply(M, Factor) ->
    gleam@result:map(
        begin
            _pipe = finanza@decimal:multiply(erlang:element(2, M), Factor),
            gleam@result:map_error(
                _pipe,
                fun(Field@0) -> {arithmetic_error, Field@0} end
            )
        end,
        fun(Product) -> {money, Product, erlang:element(3, M)} end
    ).

-file("src/finanza/currency.gleam", 205).
?DOC(
    " Divide a money value by a scalar, rounding the result to the\n"
    " currency's minor-unit exponent using `mode`.\n"
).
-spec divide(
    money(),
    finanza@decimal:decimal(),
    finanza@decimal@rounding:mode()
) -> {ok, money()} | {error, currency_error()}.
divide(M, Divisor, Mode) ->
    gleam@result:map(
        begin
            _pipe = finanza@decimal:divide(
                erlang:element(2, M),
                Divisor,
                erlang:element(3, erlang:element(3, M)),
                Mode
            ),
            gleam@result:map_error(
                _pipe,
                fun(Field@0) -> {arithmetic_error, Field@0} end
            )
        end,
        fun(Quotient) -> {money, Quotient, erlang:element(3, M)} end
    ).

-file("src/finanza/currency.gleam", 223).
?DOC(" Negate a money value.\n").
-spec negate(money()) -> money().
negate(M) ->
    {money, finanza@decimal:negate(erlang:element(2, M)), erlang:element(3, M)}.

-file("src/finanza/currency.gleam", 236).
?DOC(
    " Equality test for two money values. Returns `False` rather than an\n"
    " error on currency mismatch, mirroring `==`.\n"
).
-spec equal(money(), money()) -> boolean().
equal(A, B) ->
    (erlang:element(2, erlang:element(3, A)) =:= erlang:element(
        2,
        erlang:element(3, B)
    ))
    andalso finanza@decimal:equal(erlang:element(2, A), erlang:element(2, B)).

-file("src/finanza/currency.gleam", 263).
-spec check_ratios(list(integer())) -> {ok, nil} | {error, currency_error()}.
check_ratios(Ratios) ->
    gleam@bool:guard(
        gleam@list:is_empty(Ratios),
        {error, empty_ratios},
        fun() ->
            gleam@bool:guard(
                gleam@list:any(Ratios, fun(R) -> R =< 0 end),
                {error, non_positive_ratio},
                fun() -> {ok, nil} end
            )
        end
    ).

-file("src/finanza/currency.gleam", 289).
-spec distribute(list(integer()), integer()) -> list(integer()).
distribute(Shares, Remainder) ->
    case {Shares, Remainder} of
        {_, 0} ->
            Shares;

        {[], _} ->
            Shares;

        {[Head | Tail], _} ->
            [Head + 1 | distribute(Tail, Remainder - 1)]
    end.

-file("src/finanza/currency.gleam", 272).
-spec allocate_units(integer(), list(integer()), integer()) -> list(integer()).
allocate_units(Total, Ratios, Total_ratio) ->
    Sign = case Total < 0 of
        true ->
            -1;

        false ->
            1
    end,
    Abs_total = gleam@int:absolute_value(Total),
    Initial_shares = gleam@list:map(Ratios, fun(R) -> case Total_ratio of
                0 -> 0;
                Gleam@denominator -> Abs_total * R div Gleam@denominator
            end end),
    Used = gleam@list:fold(Initial_shares, 0, fun gleam@int:add/2),
    Remainder = Abs_total - Used,
    Distributed = distribute(Initial_shares, Remainder),
    gleam@list:map(Distributed, fun(S) -> Sign * S end).

-file("src/finanza/currency.gleam", 251).
?DOC(
    " Split a `Money` proportionally to `ratios`, distributing rounding\n"
    " remainders to the first slots so the slices sum back to the\n"
    " original amount exactly.\n"
    "\n"
    " ```gleam\n"
    " let bill = from_minor(units: 1000, currency: catalog.usd())\n"
    " allocate(bill, [1, 1, 1])\n"
    " // Ok([$3.34, $3.33, $3.33])\n"
    " ```\n"
).
-spec allocate(money(), list(integer())) -> {ok, list(money())} |
    {error, currency_error()}.
allocate(M, Ratios) ->
    gleam@result:'try'(
        check_ratios(Ratios),
        fun(_) ->
            gleam@result:map(
                to_minor(M, half_even),
                fun(Minor_units) ->
                    Total_ratio = gleam@list:fold(
                        Ratios,
                        0,
                        fun gleam@int:add/2
                    ),
                    Shares = allocate_units(Minor_units, Ratios, Total_ratio),
                    gleam@list:map(
                        Shares,
                        fun(S) -> from_minor(S, erlang:element(3, M)) end
                    )
                end
            )
        end
    ).

-file("src/finanza/currency.gleam", 301).
?DOC(
    " Render a money value using the default ISO-style format:\n"
    " `\"USD 1234.56\"`.\n"
).
-spec to_string(money()) -> binary().
to_string(M) ->
    <<<<(erlang:element(2, erlang:element(3, M)))/binary, " "/utf8>>/binary,
        (finanza@decimal:to_string(erlang:element(2, M)))/binary>>.

-file("src/finanza/currency.gleam", 310).
?DOC(
    " Default [`FormatOptions`](#FormatOptions): symbol prefix, `,`\n"
    " thousands separator, `.` decimal separator, leading minus sign,\n"
    " no currency code suffix, and the amount is rescaled to the\n"
    " currency's minor-unit exponent (so USD always renders with two\n"
    " cents, JPY with none, etc.).\n"
).
-spec default_format() -> format_options().
default_format() ->
    {format_options,
        prefix,
        <<","/utf8>>,
        <<"."/utf8>>,
        minus_sign,
        false,
        true}.

-file("src/finanza/currency.gleam", 322).
?DOC(" Override the symbol position.\n").
-spec with_symbol_position(format_options(), symbol_position()) -> format_options().
with_symbol_position(Options, Position) ->
    {format_options,
        Position,
        erlang:element(3, Options),
        erlang:element(4, Options),
        erlang:element(5, Options),
        erlang:element(6, Options),
        erlang:element(7, Options)}.

-file("src/finanza/currency.gleam", 330).
?DOC(" Override the thousands separator.\n").
-spec with_thousands_separator(format_options(), binary()) -> format_options().
with_thousands_separator(Options, Separator) ->
    {format_options,
        erlang:element(2, Options),
        Separator,
        erlang:element(4, Options),
        erlang:element(5, Options),
        erlang:element(6, Options),
        erlang:element(7, Options)}.

-file("src/finanza/currency.gleam", 338).
?DOC(" Override the decimal separator.\n").
-spec with_decimal_separator(format_options(), binary()) -> format_options().
with_decimal_separator(Options, Separator) ->
    {format_options,
        erlang:element(2, Options),
        erlang:element(3, Options),
        Separator,
        erlang:element(5, Options),
        erlang:element(6, Options),
        erlang:element(7, Options)}.

-file("src/finanza/currency.gleam", 346).
?DOC(" Override the negative-amount style.\n").
-spec with_negative_style(format_options(), negative_style()) -> format_options().
with_negative_style(Options, Style) ->
    {format_options,
        erlang:element(2, Options),
        erlang:element(3, Options),
        erlang:element(4, Options),
        Style,
        erlang:element(6, Options),
        erlang:element(7, Options)}.

-file("src/finanza/currency.gleam", 354).
?DOC(" Toggle whether the ISO code is appended to the rendered string.\n").
-spec with_currency_code(format_options(), boolean()) -> format_options().
with_currency_code(Options, Enabled) ->
    {format_options,
        erlang:element(2, Options),
        erlang:element(3, Options),
        erlang:element(4, Options),
        erlang:element(5, Options),
        Enabled,
        erlang:element(7, Options)}.

-file("src/finanza/currency.gleam", 366).
?DOC(
    " Toggle whether the amount is rescaled to the currency's\n"
    " minor-unit exponent before rendering. Defaults to `True`; pass\n"
    " `False` to preserve the amount's original precision (useful when\n"
    " the value carries finer-than-minor digits, e.g. for an FX rate\n"
    " or a unit price).\n"
).
-spec with_minor_units(format_options(), boolean()) -> format_options().
with_minor_units(Options, Enabled) ->
    {format_options,
        erlang:element(2, Options),
        erlang:element(3, Options),
        erlang:element(4, Options),
        erlang:element(5, Options),
        erlang:element(6, Options),
        Enabled}.

-file("src/finanza/currency.gleam", 398).
-spec scale_for_render(finanza@decimal:decimal(), currency(), format_options()) -> finanza@decimal:decimal().
scale_for_render(Amount, Currency, Options) ->
    gleam@bool:guard(
        not erlang:element(7, Options),
        Amount,
        fun() ->
            Target_exponent = - erlang:element(3, Currency),
            case finanza@decimal:rescale(Amount, Target_exponent, half_even) of
                {ok, Scaled} ->
                    Scaled;

                {error, precision_exceeded} ->
                    Amount;

                {error, division_by_zero} ->
                    Amount
            end
        end
    ).

-file("src/finanza/currency.gleam", 441).
-spec group_from_right(list(binary()), integer(), list(list(binary()))) -> list(list(binary())).
group_from_right(Chars, Length, Acc) ->
    gleam@bool:guard(
        Length =< 3,
        [Chars | Acc],
        fun() ->
            Head_size = Length - 3,
            Head = gleam@list:take(Chars, Head_size),
            Tail = gleam@list:drop(Chars, Head_size),
            group_from_right(Head, Head_size, [Tail | Acc])
        end
    ).

-file("src/finanza/currency.gleam", 428).
-spec insert_thousands(binary(), binary()) -> binary().
insert_thousands(Digits, Separator) ->
    case Separator of
        <<""/utf8>> ->
            Digits;

        _ ->
            Chars = gleam@string:to_graphemes(Digits),
            Length = erlang:length(Chars),
            Groups = group_from_right(Chars, Length, []),
            _pipe = gleam@list:map(
                Groups,
                fun(Group) -> erlang:list_to_binary(Group) end
            ),
            gleam@string:join(_pipe, Separator)
    end.

-file("src/finanza/currency.gleam", 415).
-spec inject_separators(binary(), format_options()) -> binary().
inject_separators(Raw, Options) ->
    Parts = gleam@string:split(Raw, <<"."/utf8>>),
    case Parts of
        [Integer_part] ->
            insert_thousands(Integer_part, erlang:element(3, Options));

        [Integer_part@1, Fraction_part] ->
            <<<<(insert_thousands(Integer_part@1, erlang:element(3, Options)))/binary,
                    (erlang:element(4, Options))/binary>>/binary,
                Fraction_part/binary>>;

        _ ->
            Raw
    end.

-file("src/finanza/currency.gleam", 453).
-spec wrap_with_symbol(binary(), currency(), format_options()) -> binary().
wrap_with_symbol(Body, Currency, Options) ->
    case erlang:element(2, Options) of
        prefix ->
            <<(erlang:element(4, Currency))/binary, Body/binary>>;

        suffix ->
            <<Body/binary, (erlang:element(4, Currency))/binary>>;

        no_symbol ->
            Body
    end.

-file("src/finanza/currency.gleam", 465).
-spec wrap_for_sign(binary(), boolean(), format_options()) -> binary().
wrap_for_sign(Body, Is_negative, Options) ->
    gleam@bool:guard(
        not Is_negative,
        Body,
        fun() -> case erlang:element(5, Options) of
                minus_sign ->
                    <<"-"/utf8, Body/binary>>;

                parentheses ->
                    <<<<"("/utf8, Body/binary>>/binary, ")"/utf8>>
            end end
    ).

-file("src/finanza/currency.gleam", 379).
?DOC(
    " Render a money value with the given [`FormatOptions`](#FormatOptions).\n"
    "\n"
    " By default the amount is rescaled to the currency's minor-unit\n"
    " exponent (so $12 renders as `$12.00` and ¥1234 as `¥1,234`). Call\n"
    " [`with_minor_units`](#with_minor_units) with `False` to preserve\n"
    " the amount's original precision.\n"
).
-spec format(money(), format_options()) -> binary().
format(M, Options) ->
    Rescaled = scale_for_render(
        erlang:element(2, M),
        erlang:element(3, M),
        Options
    ),
    Raw = finanza@decimal:to_string(finanza@decimal:absolute(Rescaled)),
    Body = inject_separators(Raw, Options),
    With_symbol = wrap_with_symbol(Body, erlang:element(3, M), Options),
    Signed = wrap_for_sign(
        With_symbol,
        finanza@decimal:is_negative(Rescaled),
        Options
    ),
    case erlang:element(6, Options) of
        true ->
            <<<<Signed/binary, " "/utf8>>/binary,
                (erlang:element(2, erlang:element(3, M)))/binary>>;

        false ->
            Signed
    end.

-file("src/finanza/currency.gleam", 479).
-spec require_same_currency(money(), money()) -> {ok, nil} |
    {error, currency_error()}.
require_same_currency(A, B) ->
    gleam@bool:guard(
        erlang:element(2, erlang:element(3, A)) /= erlang:element(
            2,
            erlang:element(3, B)
        ),
        {error,
            {currency_mismatch,
                erlang:element(2, erlang:element(3, A)),
                erlang:element(2, erlang:element(3, B))}},
        fun() -> {ok, nil} end
    ).

-file("src/finanza/currency.gleam", 175).
?DOC(
    " Add two `Money` values. Both operands must share the same\n"
    " currency.\n"
).
-spec add(money(), money()) -> {ok, money()} | {error, currency_error()}.
add(A, B) ->
    gleam@result:'try'(
        require_same_currency(A, B),
        fun(_) ->
            gleam@result:map(
                begin
                    _pipe = finanza@decimal:add(
                        erlang:element(2, A),
                        erlang:element(2, B)
                    ),
                    gleam@result:map_error(
                        _pipe,
                        fun(Field@0) -> {arithmetic_error, Field@0} end
                    )
                end,
                fun(Sum) -> {money, Sum, erlang:element(3, A)} end
            )
        end
    ).

-file("src/finanza/currency.gleam", 184).
?DOC(" Subtract `b` from `a`.\n").
-spec subtract(money(), money()) -> {ok, money()} | {error, currency_error()}.
subtract(A, B) ->
    gleam@result:'try'(
        require_same_currency(A, B),
        fun(_) ->
            gleam@result:map(
                begin
                    _pipe = finanza@decimal:subtract(
                        erlang:element(2, A),
                        erlang:element(2, B)
                    ),
                    gleam@result:map_error(
                        _pipe,
                        fun(Field@0) -> {arithmetic_error, Field@0} end
                    )
                end,
                fun(Diff) -> {money, Diff, erlang:element(3, A)} end
            )
        end
    ).

-file("src/finanza/currency.gleam", 229).
?DOC(
    " Compare two money values. Both operands must share the same\n"
    " currency.\n"
).
-spec compare(money(), money()) -> {ok, gleam@order:order()} |
    {error, currency_error()}.
compare(A, B) ->
    gleam@result:map(
        require_same_currency(A, B),
        fun(_) ->
            finanza@decimal:compare(erlang:element(2, A), erlang:element(2, B))
        end
    ).