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