src/finanza@interest.erl

-module(finanza@interest).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/finanza/interest.gleam").
-export([simple_interest/4, compound_interest/5, future_value/4, present_value/4, payment/4, effective_annual_rate/3]).
-export_type([interest_error/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(
    " Time-value-of-money helpers built on\n"
    " [`finanza/decimal`](./decimal.html).\n"
    "\n"
    " Every function takes its inputs as decimals, computes in decimal,\n"
    " and rounds the final result with `HalfEven` (\"banker's\") to the\n"
    " caller-supplied number of decimal places.\n"
).

-type interest_error() :: negative_principal |
    negative_rate |
    periods_out_of_range |
    compounds_out_of_range |
    negative_digits |
    {arithmetic_error, finanza@decimal:arithmetic_error()}.

-file("src/finanza/interest.gleam", 193).
-spec straight_line_payment(finanza@decimal:decimal(), integer(), integer()) -> {ok,
        finanza@decimal:decimal()} |
    {error, interest_error()}.
straight_line_payment(Principal, Periods, Digits) ->
    _pipe = finanza@decimal:divide(
        Principal,
        finanza@decimal:from_int(Periods),
        Digits,
        half_even
    ),
    gleam@result:map_error(
        _pipe,
        fun(Field@0) -> {arithmetic_error, Field@0} end
    ).

-file("src/finanza/interest.gleam", 296).
-spec pow_loop(
    finanza@decimal:decimal(),
    integer(),
    finanza@decimal:decimal(),
    integer()
) -> {ok, finanza@decimal:decimal()} | {error, interest_error()}.
pow_loop(Base, Exponent, Acc, Digits) ->
    gleam@bool:guard(
        Exponent =< 0,
        {ok, Acc},
        fun() ->
            gleam@result:'try'(
                begin
                    _pipe = finanza@decimal:multiply(Acc, Base),
                    gleam@result:map_error(
                        _pipe,
                        fun(Field@0) -> {arithmetic_error, Field@0} end
                    )
                end,
                fun(Product) ->
                    Trimmed = finanza@decimal:round(Product, Digits, half_even),
                    pow_loop(Base, Exponent - 1, Trimmed, Digits)
                end
            )
        end
    ).

-file("src/finanza/interest.gleam", 285).
?DOC(
    " `(1 + rate)^periods`, computed by repeated multiplication and\n"
    " rounded to `digits` decimal places between steps so the\n"
    " coefficient cannot grow unboundedly.\n"
).
-spec growth_factor(finanza@decimal:decimal(), integer(), integer()) -> {ok,
        finanza@decimal:decimal()} |
    {error, interest_error()}.
growth_factor(Rate, Periods, Digits) ->
    gleam@result:'try'(
        begin
            _pipe = finanza@decimal:add(finanza@decimal:one(), Rate),
            gleam@result:map_error(
                _pipe,
                fun(Field@0) -> {arithmetic_error, Field@0} end
            )
        end,
        fun(Base) -> pow_loop(Base, Periods, finanza@decimal:one(), Digits) end
    ).

-file("src/finanza/interest.gleam", 311).
-spec check_principal(finanza@decimal:decimal()) -> {ok, nil} |
    {error, interest_error()}.
check_principal(P) ->
    gleam@bool:guard(
        finanza@decimal:is_negative(P),
        {error, negative_principal},
        fun() -> {ok, nil} end
    ).

-file("src/finanza/interest.gleam", 319).
-spec check_rate(finanza@decimal:decimal()) -> {ok, nil} |
    {error, interest_error()}.
check_rate(R) ->
    gleam@bool:guard(
        finanza@decimal:is_negative(R),
        {error, negative_rate},
        fun() -> {ok, nil} end
    ).

-file("src/finanza/interest.gleam", 332).
-spec check_compounds(integer()) -> {ok, nil} | {error, interest_error()}.
check_compounds(N) ->
    gleam@bool:guard(
        N =< 0,
        {error, compounds_out_of_range},
        fun() -> {ok, nil} end
    ).

-file("src/finanza/interest.gleam", 337).
-spec check_digits(integer()) -> {ok, nil} | {error, interest_error()}.
check_digits(D) ->
    gleam@bool:guard(D < 0, {error, negative_digits}, fun() -> {ok, nil} end).

-file("src/finanza/interest.gleam", 324).
-spec check_periods(integer()) -> {ok, nil} | {error, interest_error()}.
check_periods(P) ->
    gleam@bool:guard(
        (P =< 0) orelse (P > 1200),
        {error, periods_out_of_range},
        fun() -> {ok, nil} end
    ).

-file("src/finanza/interest.gleam", 44).
?DOC(" Simple interest: `I = P × r × t`.\n").
-spec simple_interest(
    finanza@decimal:decimal(),
    finanza@decimal:decimal(),
    integer(),
    integer()
) -> {ok, finanza@decimal:decimal()} | {error, interest_error()}.
simple_interest(Principal, Rate, Periods, Digits) ->
    gleam@result:'try'(
        check_principal(Principal),
        fun(_) ->
            gleam@result:'try'(
                check_rate(Rate),
                fun(_) ->
                    gleam@result:'try'(
                        check_periods(Periods),
                        fun(_) ->
                            gleam@result:'try'(
                                check_digits(Digits),
                                fun(_) ->
                                    gleam@result:'try'(
                                        begin
                                            _pipe = finanza@decimal:multiply(
                                                Principal,
                                                Rate
                                            ),
                                            gleam@result:map_error(
                                                _pipe,
                                                fun(Field@0) -> {arithmetic_error, Field@0} end
                                            )
                                        end,
                                        fun(Pr) ->
                                            gleam@result:map(
                                                begin
                                                    _pipe@1 = finanza@decimal:multiply(
                                                        Pr,
                                                        finanza@decimal:from_int(
                                                            Periods
                                                        )
                                                    ),
                                                    gleam@result:map_error(
                                                        _pipe@1,
                                                        fun(Field@0) -> {arithmetic_error, Field@0} end
                                                    )
                                                end,
                                                fun(Product) ->
                                                    finanza@decimal:round(
                                                        Product,
                                                        Digits,
                                                        half_even
                                                    )
                                                end
                                            )
                                        end
                                    )
                                end
                            )
                        end
                    )
                end
            )
        end
    ).

-file("src/finanza/interest.gleam", 71).
?DOC(
    " Future value under compound interest:\n"
    "\n"
    " ```text\n"
    " FV = principal × (1 + annual_rate / compounds_per_year)^(compounds_per_year × years)\n"
    " ```\n"
).
-spec compound_interest(
    finanza@decimal:decimal(),
    finanza@decimal:decimal(),
    integer(),
    integer(),
    integer()
) -> {ok, finanza@decimal:decimal()} | {error, interest_error()}.
compound_interest(Principal, Annual_rate, Years, Compounds_per_year, Digits) ->
    gleam@result:'try'(
        check_principal(Principal),
        fun(_) ->
            gleam@result:'try'(
                check_rate(Annual_rate),
                fun(_) ->
                    gleam@result:'try'(
                        check_periods(Years),
                        fun(_) ->
                            gleam@result:'try'(
                                check_compounds(Compounds_per_year),
                                fun(_) ->
                                    gleam@result:'try'(
                                        check_digits(Digits),
                                        fun(_) ->
                                            Total_periods = Years * Compounds_per_year,
                                            gleam@result:'try'(
                                                check_periods(Total_periods),
                                                fun(_) ->
                                                    Work_digits = gleam@int:min(
                                                        Digits + 4,
                                                        6
                                                    ),
                                                    gleam@result:'try'(
                                                        begin
                                                            _pipe = finanza@decimal:divide(
                                                                Annual_rate,
                                                                finanza@decimal:from_int(
                                                                    Compounds_per_year
                                                                ),
                                                                Work_digits,
                                                                half_even
                                                            ),
                                                            gleam@result:map_error(
                                                                _pipe,
                                                                fun(Field@0) -> {arithmetic_error, Field@0} end
                                                            )
                                                        end,
                                                        fun(Rate_per_period) ->
                                                            gleam@result:'try'(
                                                                growth_factor(
                                                                    Rate_per_period,
                                                                    Total_periods,
                                                                    Work_digits
                                                                ),
                                                                fun(Growth) ->
                                                                    gleam@result:map(
                                                                        begin
                                                                            _pipe@1 = finanza@decimal:multiply(
                                                                                Principal,
                                                                                Growth
                                                                            ),
                                                                            gleam@result:map_error(
                                                                                _pipe@1,
                                                                                fun(Field@0) -> {arithmetic_error, Field@0} end
                                                                            )
                                                                        end,
                                                                        fun(
                                                                            Product
                                                                        ) ->
                                                                            finanza@decimal:round(
                                                                                Product,
                                                                                Digits,
                                                                                half_even
                                                                            )
                                                                        end
                                                                    )
                                                                end
                                                            )
                                                        end
                                                    )
                                                end
                                            )
                                        end
                                    )
                                end
                            )
                        end
                    )
                end
            )
        end
    ).

-file("src/finanza/interest.gleam", 109).
?DOC(" Future value of `present` after `periods` periods at `rate_per_period`.\n").
-spec future_value(
    finanza@decimal:decimal(),
    finanza@decimal:decimal(),
    integer(),
    integer()
) -> {ok, finanza@decimal:decimal()} | {error, interest_error()}.
future_value(Present, Rate_per_period, Periods, Digits) ->
    gleam@result:'try'(
        check_rate(Rate_per_period),
        fun(_) ->
            gleam@result:'try'(
                check_periods(Periods),
                fun(_) ->
                    gleam@result:'try'(
                        check_digits(Digits),
                        fun(_) ->
                            Work_digits = gleam@int:min(Digits + 4, 6),
                            gleam@result:'try'(
                                growth_factor(
                                    Rate_per_period,
                                    Periods,
                                    Work_digits
                                ),
                                fun(Growth) ->
                                    gleam@result:map(
                                        begin
                                            _pipe = finanza@decimal:multiply(
                                                Present,
                                                Growth
                                            ),
                                            gleam@result:map_error(
                                                _pipe,
                                                fun(Field@0) -> {arithmetic_error, Field@0} end
                                            )
                                        end,
                                        fun(Product) ->
                                            finanza@decimal:round(
                                                Product,
                                                Digits,
                                                half_even
                                            )
                                        end
                                    )
                                end
                            )
                        end
                    )
                end
            )
        end
    ).

-file("src/finanza/interest.gleam", 132).
?DOC(
    " Present value of `future` discounted at `rate_per_period` for\n"
    " `periods` periods.\n"
).
-spec present_value(
    finanza@decimal:decimal(),
    finanza@decimal:decimal(),
    integer(),
    integer()
) -> {ok, finanza@decimal:decimal()} | {error, interest_error()}.
present_value(Future, Rate_per_period, Periods, Digits) ->
    gleam@result:'try'(
        check_rate(Rate_per_period),
        fun(_) ->
            gleam@result:'try'(
                check_periods(Periods),
                fun(_) ->
                    gleam@result:'try'(
                        check_digits(Digits),
                        fun(_) ->
                            Work_digits = gleam@int:min(Digits + 4, 6),
                            gleam@result:'try'(
                                growth_factor(
                                    Rate_per_period,
                                    Periods,
                                    Work_digits
                                ),
                                fun(Growth) ->
                                    gleam@result:map(
                                        begin
                                            _pipe = finanza@decimal:divide(
                                                Future,
                                                Growth,
                                                Digits,
                                                half_even
                                            ),
                                            gleam@result:map_error(
                                                _pipe,
                                                fun(Field@0) -> {arithmetic_error, Field@0} end
                                            )
                                        end,
                                        fun(Quotient) -> Quotient end
                                    )
                                end
                            )
                        end
                    )
                end
            )
        end
    ).

-file("src/finanza/interest.gleam", 207).
-spec amortising_payment(
    finanza@decimal:decimal(),
    finanza@decimal:decimal(),
    integer(),
    integer()
) -> {ok, finanza@decimal:decimal()} | {error, interest_error()}.
amortising_payment(Principal, Rate, Periods, Digits) ->
    Work_digits = gleam@int:min(Digits + 4, 6),
    gleam@result:'try'(
        growth_factor(Rate, Periods, Work_digits),
        fun(Growth) ->
            gleam@result:'try'(
                begin
                    _pipe = finanza@decimal:divide(
                        finanza@decimal:one(),
                        Growth,
                        Work_digits,
                        half_even
                    ),
                    gleam@result:map_error(
                        _pipe,
                        fun(Field@0) -> {arithmetic_error, Field@0} end
                    )
                end,
                fun(Inv_growth) ->
                    gleam@result:'try'(
                        begin
                            _pipe@1 = finanza@decimal:subtract(
                                finanza@decimal:one(),
                                Inv_growth
                            ),
                            gleam@result:map_error(
                                _pipe@1,
                                fun(Field@0) -> {arithmetic_error, Field@0} end
                            )
                        end,
                        fun(Denominator) ->
                            gleam@result:'try'(
                                begin
                                    _pipe@2 = finanza@decimal:multiply(
                                        Principal,
                                        Rate
                                    ),
                                    gleam@result:map_error(
                                        _pipe@2,
                                        fun(Field@0) -> {arithmetic_error, Field@0} end
                                    )
                                end,
                                fun(Numerator) ->
                                    _pipe@3 = finanza@decimal:divide(
                                        Numerator,
                                        Denominator,
                                        Digits,
                                        half_even
                                    ),
                                    gleam@result:map_error(
                                        _pipe@3,
                                        fun(Field@0) -> {arithmetic_error, Field@0} end
                                    )
                                end
                            )
                        end
                    )
                end
            )
        end
    ).

-file("src/finanza/interest.gleam", 167).
?DOC(
    " Periodic payment for a fully-amortising loan:\n"
    "\n"
    " ```text\n"
    " PMT = principal × rate / (1 - (1 + rate)^(-periods))\n"
    " ```\n"
    "\n"
    " When `rate_per_period` is zero, returns straight-line\n"
    " `principal / periods`.\n"
).
-spec payment(
    finanza@decimal:decimal(),
    finanza@decimal:decimal(),
    integer(),
    integer()
) -> {ok, finanza@decimal:decimal()} | {error, interest_error()}.
payment(Principal, Rate_per_period, Periods, Digits) ->
    gleam@result:'try'(
        check_principal(Principal),
        fun(_) ->
            gleam@result:'try'(
                check_rate(Rate_per_period),
                fun(_) ->
                    gleam@result:'try'(
                        check_periods(Periods),
                        fun(_) ->
                            gleam@result:'try'(
                                check_digits(Digits),
                                fun(_) ->
                                    gleam@bool:guard(
                                        finanza@decimal:is_zero(Rate_per_period),
                                        straight_line_payment(
                                            Principal,
                                            Periods,
                                            Digits
                                        ),
                                        fun() ->
                                            amortising_payment(
                                                Principal,
                                                Rate_per_period,
                                                Periods,
                                                Digits
                                            )
                                        end
                                    )
                                end
                            )
                        end
                    )
                end
            )
        end
    ).

-file("src/finanza/interest.gleam", 250).
?DOC(
    " Effective annual rate from a nominal rate compounded\n"
    " `compounds_per_year` times per year:\n"
    "\n"
    " ```text\n"
    " EAR = (1 + nominal_rate / compounds_per_year)^compounds_per_year - 1\n"
    " ```\n"
).
-spec effective_annual_rate(finanza@decimal:decimal(), integer(), integer()) -> {ok,
        finanza@decimal:decimal()} |
    {error, interest_error()}.
effective_annual_rate(Nominal_rate, Compounds_per_year, Digits) ->
    gleam@result:'try'(
        check_rate(Nominal_rate),
        fun(_) ->
            gleam@result:'try'(
                check_compounds(Compounds_per_year),
                fun(_) ->
                    gleam@result:'try'(
                        check_digits(Digits),
                        fun(_) ->
                            Work_digits = gleam@int:min(Digits + 4, 6),
                            gleam@result:'try'(
                                begin
                                    _pipe = finanza@decimal:divide(
                                        Nominal_rate,
                                        finanza@decimal:from_int(
                                            Compounds_per_year
                                        ),
                                        Work_digits,
                                        half_even
                                    ),
                                    gleam@result:map_error(
                                        _pipe,
                                        fun(Field@0) -> {arithmetic_error, Field@0} end
                                    )
                                end,
                                fun(Rate_per_period) ->
                                    gleam@result:'try'(
                                        growth_factor(
                                            Rate_per_period,
                                            Compounds_per_year,
                                            Work_digits
                                        ),
                                        fun(Growth) ->
                                            gleam@result:map(
                                                begin
                                                    _pipe@1 = finanza@decimal:subtract(
                                                        Growth,
                                                        finanza@decimal:one()
                                                    ),
                                                    gleam@result:map_error(
                                                        _pipe@1,
                                                        fun(Field@0) -> {arithmetic_error, Field@0} end
                                                    )
                                                end,
                                                fun(Ear_raw) ->
                                                    finanza@decimal:round(
                                                        Ear_raw,
                                                        Digits,
                                                        half_even
                                                    )
                                                end
                                            )
                                        end
                                    )
                                end
                            )
                        end
                    )
                end
            )
        end
    ).