dodil/erp-platform

tests/test_money.py

from decimal import Decimal

import pytest

from erp_platform.money import CurrencyMismatch, InvalidMoney, Money, pct, totals_by_currency


def test_two_currencies_never_add():
    with pytest.raises(CurrencyMismatch):
        Money.of(1, "SEK") + Money.of(1, "EUR")


def test_rounding_is_half_up_like_a_calculator():
    assert Money.of("0.125", "eur").amount == Decimal("0.13")
    assert Money.of("0.125", "eur").currency == "EUR"


def test_a_split_sums_back_to_the_whole_and_the_remainder_goes_first():
    parts = Money.of("100000", "SEK").split(3)
    assert [p.amount for p in parts] == [Decimal("33333.34"), Decimal("33333.33"), Decimal("33333.33")]
    assert sum((p.amount for p in parts), Decimal(0)) == Decimal("100000.00")


def test_percent_of_is_exact_to_two_places_and_zero_safe():
    assert Money.of("18", "EUR").percent_of(Money.of("100", "EUR")) == Decimal("18.00")
    assert Money.of("1", "EUR").percent_of(Money.of("3", "EUR")) == Decimal("33.33")
    assert Money.zero("EUR").percent_of(Money.zero("EUR")) == Decimal("0.00")
    assert pct("15.005") == Decimal("15.01")


def test_totals_are_per_currency():
    t = totals_by_currency([Money.of(1, "SEK"), Money.of(2, "SEK"), Money.of(5, "EUR")])
    assert t == {"SEK": Money.of(3, "SEK"), "EUR": Money.of(5, "EUR")}


def test_money_times_money_is_refused():
    with pytest.raises(TypeError):
        Money.of(1, "SEK") * Money.of(1, "SEK")


@pytest.mark.parametrize("amount, currency", [("1", "EURO"), ("1", ""), ("abc", "EUR"), ("NaN", "EUR"),
                                              ("Infinity", "EUR")])
def test_what_is_not_money_says_so_in_its_own_type(amount, currency):
    with pytest.raises(InvalidMoney):
        Money.of(amount, currency)


# ── currencies whose minor unit is not two places ──────────────────────────────────────────────

from decimal import ROUND_CEILING, ROUND_DOWN, ROUND_FLOOR, ROUND_HALF_EVEN, ROUND_HALF_UP, ROUND_UP
from fractions import Fraction

from erp_platform.money import (ExactAmount, InvalidRate, Rate, minor_unit, quantum, round_once)


def test_yen_has_no_minor_unit():
    assert Money.of("1234.5", "JPY").amount == Decimal("1235")           # HALF_UP, to the yen
    assert str(Money.of(1000, "jpy")) == "1000 JPY"
    assert [p.amount for p in Money.of(100, "JPY").split(3)] == [Decimal("34"), Decimal("33"), Decimal("33")]


def test_kuwaiti_dinar_keeps_its_fils():
    assert Money.of("1234.567", "KWD").amount == Decimal("1234.567")     # was 1234.57 before 0.2.0
    assert Money.of("0.0005", "KWD").amount == Decimal("0.001")
    assert (Money.of("0.001", "KWD") + Money.of("0.002", "KWD")).amount == Decimal("0.003")


def test_an_unlisted_currency_is_carried_to_two_places_as_before():
    assert minor_unit("ZZZ") == 2 and quantum("eur") == Decimal("0.01")
    assert Money.of("1.005", "ZZZ").amount == Decimal("1.01")


# ── rates: exact until a payable line is rounded once ─────────────────────────────────────────

def test_round_once_differs_from_round_each_then_sum():
    """Three one-minute slots at 10.01/hour: exactly 0.5005, which is 0.50 — rounded per slot it is 0.51."""
    slot = Rate("10.01", "hour", "EUR").for_minutes(1)
    per_slot = slot.to_money() + slot.to_money() + slot.to_money()
    assert per_slot == Money.of("0.51", "EUR")
    assert round_once([slot, slot, slot]) == Money.of("0.50", "EUR")


def test_exact_means_exact_even_when_the_decimal_does_not_terminate():
    """20 minutes at 1.00/hour is a third; three thirds rounded DOWN is 1.00, not the 0.99 a 28-digit
    Decimal would give (0.3333…3 × 3)."""
    third = Rate("1", "hour", "EUR").for_minutes(20)
    assert third.value == Fraction(1, 3)
    assert round_once([third] * 3, rounding_mode="DOWN") == Money.of("1.00", "EUR")


def test_for_minutes_and_for_days_and_times():
    assert Rate("60", "hour", "GBP").for_minutes(90).to_money() == Money.of("90.00", "GBP")
    assert Rate("412.3077", "day", "SEK").for_days(Decimal("0.5")).value == Fraction("206.15385")
    assert Rate("4000", "month", "EUR").times("1.5").to_money() == Money.of("6000.00", "EUR")
    assert Rate("250", "fixed", "EUR").times(3).to_money() == Money.of("750.00", "EUR")
    assert Rate("1000", "day", "KWD").for_days(Decimal("0.3333")).to_money() == Money.of("333.300", "KWD")
    assert Rate("10000", "day", "JPY").for_days(Decimal("0.25")).to_money() == Money.of("2500", "JPY")


@pytest.mark.parametrize("mode, expected", [("HALF_UP", "0.13"), ("HALF_EVEN", "0.12"), ("DOWN", "0.12"),
                                            ("UP", "0.13"), ("CEILING", "0.13"), ("FLOOR", "0.12"),
                                            ("ROUND_HALF_EVEN", "0.12")])
def test_the_rounding_mode_is_a_parameter(mode, expected):
    assert ExactAmount.of("0.125", "EUR").to_money(mode).amount == Decimal(expected)


def test_rounding_a_fraction_agrees_with_decimal_wherever_decimal_is_exact():
    """The hand-written rounding must be the decimal module's, mode for mode, sign for sign."""
    modes = {"HALF_UP": ROUND_HALF_UP, "HALF_EVEN": ROUND_HALF_EVEN, "DOWN": ROUND_DOWN, "UP": ROUND_UP,
             "CEILING": ROUND_CEILING, "FLOOR": ROUND_FLOOR}
    for thousandths in range(-2600, 2601, 7):
        d = Decimal(thousandths).scaleb(-3)
        for name, mode in modes.items():
            assert ExactAmount.of(d, "EUR").to_money(name).amount == d.quantize(Decimal("0.01"), rounding=mode), (d, name)
            assert ExactAmount.of(d, "JPY").to_money(name).amount == d.quantize(Decimal("1"), rounding=mode), (d, name)


@pytest.mark.parametrize("build", [lambda: Rate(10.5, "hour", "EUR"), lambda: Rate("10", "hour", "EUR").for_days(1),
                                   lambda: Rate("10", "day", "EUR").for_days(0.5),
                                   lambda: Rate("10", "hour", "EUR").for_minutes(1.0),
                                   lambda: Rate("10", "month", "EUR").times(1.5),
                                   lambda: ExactAmount.of(0.1, "EUR")])
def test_floats_and_wrong_bases_are_refused_with_their_own_error(build):
    with pytest.raises(InvalidMoney):            # InvalidRate is one
        build()


def test_a_float_rate_says_why():
    with pytest.raises(InvalidMoney, match="float"):
        Rate(10.5, "hour", "EUR")


def test_a_rate_is_never_silently_rounded_and_its_basis_is_checked():
    assert Rate("12.3456", "hour", "EUR").amount == Decimal("12.3456")
    with pytest.raises(InvalidRate, match="four decimal places"):
        Rate("12.34567", "hour", "EUR")
    with pytest.raises(InvalidRate, match="basis"):
        Rate("1", "week", "EUR")
    with pytest.raises(InvalidRate, match="NUMERIC"):
        Rate("1000000000000000", "year", "EUR")


def test_exact_amounts_keep_their_currency():
    with pytest.raises(CurrencyMismatch):
        round_once([ExactAmount.of(1, "EUR"), ExactAmount.of(1, "SEK")])
    with pytest.raises(TypeError):
        ExactAmount.of(1, "EUR") + Money.of(1, "EUR")
    assert round_once([], currency="EUR") == Money.zero("EUR")