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