dodil/erp-brick-fixed-assets

DODIL ERP brick: fixed assets

tests/test_depreciation_engine.py

"""FA-10 and the arithmetic under FA-11, FA-14, FA-16: planned depreciation is CUMULATIVE — round_half_up of an exact
fraction, rounded once — so the periods of a life sum to exactly cost minus scrap and no period drifts by a penny."""
from datetime import date
from decimal import Decimal as D
from fractions import Fraction as F

import pytest

from brick_fixed_assets.domain.depreciation import Add, DepKey, Open, Params, Plan, Reset, Retire, SetNbv
from brick_fixed_assets.domain.errors import RuleBroken
from brick_fixed_assets.domain.values import money, period_add

LIN5 = DepKey("LIN5", "straight_line", life_periods=60)


def plan(events, key=LIN5, life=60, scrap="0.00", units=None, usage=None, fy=12):
    return Plan(Params(key, life, units, D(scrap)), events, usage, fy)


def test_a_van_of_12000_over_60_periods_is_200_a_month_and_sums_to_exactly_the_cost():
    p = plan([Add(date(2026, 10, 20), D("12000.00"))])
    assert p.charge("2026-10") == D("200.00") and p.charge("2026-11") == D("200.00")
    assert p.cum("2031-09") == D("12000.00") and p.cum("2040-01") == D("12000.00")      # the 60th period; nothing after
    assert sum((c for _, c in p.schedule("2026-10", "2031-09")), D(0)) == D("12000.00")
    assert p.cum("2026-09") == D("0.00")                                               # nothing before it is acquired


def test_the_cumulative_rounding_never_drifts_a_penny():
    key = DepKey("L3", "straight_line", life_periods=3)
    p = plan([Add(date(2026, 1, 5), D("100.00"))], key, 3)
    assert [str(p.charge(m)) for m in ("2026-01", "2026-02", "2026-03")] == ["33.33", "33.34", "33.33"]
    odd = plan([Add(date(2026, 1, 1), D("1000.01"))], DepKey("L7", "straight_line", life_periods=7), 7)
    assert sum((c for _, c in odd.schedule("2026-01", "2026-07")), D(0)) == D("1000.01")


@pytest.mark.parametrize("conv,expect", [
    ("full_month", ["200.00", "200.00", "200.00"]),
    ("next_month", ["0.00", "200.00", "200.00"]),
    ("mid_month", ["0.00", "200.00", "200.00"]),           # acquired after the 15th: from next month
    ("daily", ["77.42", "200.00", "200.00"]),              # 200.00 x 12 / 31
])
def test_each_acquisition_convention_starts_the_charge_where_the_spec_says(conv, expect):
    key = DepKey("K", "straight_line", life_periods=60, acq_conv=conv)
    p = plan([Add(date(2026, 10, 20), D("12000.00"))], key)
    assert [str(p.charge(m)) for m in ("2026-10", "2026-11", "2026-12")] == expect
    assert p.cum("2032-01") == D("12000.00")                # whatever the convention, exactly the cost in the end


def test_mid_month_on_or_before_the_15th_charges_that_month():
    key = DepKey("K", "straight_line", life_periods=60, acq_conv="mid_month")
    assert plan([Add(date(2026, 10, 15), D("12000.00"))], key).charge("2026-10") == D("200.00")


def test_half_year_gives_the_first_fiscal_year_half_a_years_charge_spread_to_year_end():
    key = DepKey("H", "straight_line", life_periods=60, acq_conv="half_year")
    p = plan([Add(date(2026, 10, 20), D("12000.00"))], key)
    assert p.cum("2026-12") == D("1200.00")                  # half a year = 6 x 200, over Oct, Nov, Dec
    assert [str(p.charge(m)) for m in ("2026-10", "2026-11", "2026-12", "2027-01")] == ["400.00", "400.00", "400.00", "200.00"]
    assert p.cum("2031-12") == D("12000.00")


def test_declining_balance_25_percent_switches_to_straight_line_when_that_is_higher():
    key = DepKey("DB25", "declining_then_straight", rate_pct=D("25"), life_periods=60)
    p = plan([Add(date(2026, 1, 5), D("10000.00"))], key)
    assert p.cum("2026-12") == D("2500.00")                  # 25% of cost in the year of acquisition
    assert p.cum("2027-12") == D("4375.00")                  # 25% of the NBV at the start of the year (7,500)
    assert p.cum("2028-12") == D("6250.00")                  # SL over the 3 remaining years (5,625 / 3) is higher: switch
    assert p.cum("2030-12") == D("10000.00") and p.cum("2031-06") == D("10000.00")
    pure = plan([Add(date(2026, 1, 5), D("10000.00"))], DepKey("D", "declining_balance", rate_pct=D("25"), life_periods=60))
    assert pure.cum("2027-12") == D("4375.00") and pure.cum("2028-12") == D("5781.25")     # no switch: 25% of 5,625 again


def test_declining_balance_is_prorated_by_the_convention_in_the_year_of_acquisition():
    key = DepKey("DB25", "declining_balance", rate_pct=D("25"), life_periods=120)
    p = plan([Add(date(2026, 7, 1), D("10000.00"))], key, 120)           # 6 months in use in the first year
    assert p.cum("2026-12") == D("1250.00")                  # 25% x 10,000 x 6/12


def test_units_of_production_charges_cost_minus_scrap_times_units_over_total_units():
    key = DepKey("UOP", "units_of_production")
    p = plan([Add(date(2026, 1, 5), D("90000.00"))], key, None, units=D("30000"), usage={"2026-10": D("410")})
    assert p.cum("2026-10") == D("1230.00") and p.cum("2026-09") == D("0.00")      # 90,000 x 410 / 30,000
    p2 = plan([Add(date(2026, 1, 5), D("90000.00"))], key, None, scrap="3000.00", units=D("30000"),
              usage={"2026-10": D("300")})
    assert p2.cum("2026-10") == D("870.00")                  # (90,000 - 3,000) x 300 / 30,000


def test_scrap_value_is_never_depreciated_and_a_key_that_stops_at_zero_ignores_it():
    p = plan([Add(date(2026, 10, 1), D("12000.00"))], scrap="600.00")
    assert p.cum("2031-09") == D("11400.00")
    zero = DepKey("Z", "straight_line", life_periods=60, stop_at="zero")
    assert plan([Add(date(2026, 10, 1), D("12000.00"))], zero, scrap="600.00").cum("2031-09") == D("12000.00")


def test_immediate_and_none_keys():
    imm = plan([Add(date(2026, 10, 20), D("450.00"))], DepKey("IMM", "immediate"), None)
    assert imm.cum("2026-10") == D("450.00") and imm.cum("2026-09") == D("0.00")
    assert plan([Add(date(2026, 10, 20), D("450.00"))], DepKey("NONE", "none"), None).cum("2030-01") == D("0.00")


def test_a_change_of_life_spreads_the_remaining_nbv_over_the_new_remaining_life():
    """A van with 30 of 60 periods gone gets 72 from 1 December: the remaining NBV over the 42 periods left (IAS 8.36)."""
    p = plan([Add(date(2024, 6, 1), D("12000.00")), Reset(date(2026, 12, 1), Params(LIN5, 72))])
    assert p.cum("2026-11") == D("6000.00")                  # posted periods are never recomputed
    assert p.charge("2026-12") == D("142.86") and p.remaining_periods("2026-12") == 41
    assert p.cum("2030-05") == D("12000.00") and p.cum("2030-06") == D("12000.00")


def test_a_later_addition_is_depreciated_from_its_own_date_over_the_remaining_life():
    p = plan([Add(date(2026, 10, 20), D("12000.00")), Add(date(2026, 11, 20), D("3000.00"))])
    assert p.cum("2026-10") == D("200.00")
    assert p.cum("2026-11") == D("450.85")                   # (11,800 + 3,000) / 59 in November
    assert p.cum("2031-09") == D("15000.00")


def test_a_revaluation_or_impairment_continues_on_the_new_carrying_amount():
    p = plan([Add(date(2026, 10, 20), D("12000.00")), SetNbv(date(2027, 3, 31), D("6000.00"))])
    assert p.cum("2027-03") == D("1200.00")                  # six periods at 200 before it
    assert p.charge("2027-04") == D("111.11")                # 6,000 over the 54 left
    assert p.cum("2031-09") == D("7200.00")                  # what was charged plus the new carrying amount


def test_a_retirement_charges_the_closing_period_by_the_retirement_convention():
    for conv, expect in (("full_month", "2600.00"), ("next_month", "2400.00"), ("mid_month", "2400.00"),
                         ("daily", "2400.00")):
        key = DepKey("K", "straight_line", life_periods=60, acq_conv="full_month", ret_conv=conv)
        p = plan([Add(date(2026, 10, 20), D("12000.00")), Retire(date(2027, 10, 15))], key)
        got = p.cum("2027-10")
        if conv == "daily":
            assert got == D("2400.00") + money(F(200 * 15, 31))
        else:
            assert got == D(expect), conv
        assert p.cum("2029-01") == got                      # nothing after a full retirement


def test_a_partial_retirement_leaves_a_proportionally_smaller_asset_to_depreciate():
    p = plan([Add(date(2026, 10, 20), D("12000.00")), Retire(date(2027, 10, 15), F(1, 4))])
    assert p.cum("2027-10") == D("2600.00")
    assert p.charge("2027-11") == D("150.00")                # 3/4 of 200
    assert p.cum("2031-09") == D("9650.00")                  # 2,600 + 3/4 of the 9,400 left


def test_a_takeover_depreciates_the_carried_amount_over_the_life_left_from_the_next_period():
    p = plan([Open(date(2026, 9, 30), D("1000.00"), 10, "2026-10")])
    assert p.charge("2026-10") == D("100.00") and p.cum("2027-07") == D("1000.00") and p.cum("2027-08") == D("1000.00")


def test_a_key_needs_its_rate_and_a_known_method():
    with pytest.raises(RuleBroken):
        DepKey("X", "declining_balance")
    with pytest.raises(RuleBroken):
        DepKey("X", "bogus")


def test_periods_before_the_first_capitalisation_plan_nothing():
    p = plan([Add(date(2026, 10, 20), D("12000.00"))])
    assert p.cum(period_add("2026-10", -3)) == D("0.00") and p.remaining_periods("2026-09") == 60


def _retire_cum(conv, retired_on, acq="full_month"):
    key = DepKey("K", "straight_line", life_periods=60, acq_conv=acq, ret_conv=conv)
    p = plan([Add(date(2026, 10, 20), D("12000.00")), Retire(retired_on)], key)
    return p.cum(retired_on.strftime("%Y-%m")), p


def test_half_year_retirement_gives_the_retirement_fiscal_year_half_a_years_charge_in_all():
    # 2026: Oct-Dec = 600. The retirement year 2027 gets 6 periods of life (1,200) in all, whenever in the year it leaves.
    assert _retire_cum("half_year", date(2027, 1, 10))[0] == D("1800.00")        # first period of the year: all six in it
    assert _retire_cum("half_year", date(2027, 4, 10))[0] == D("1800.00")        # 3 planned + 3 in the retirement period
    assert _retire_cum("half_year", date(2027, 6, 30))[0] == D("1800.00")        # the 6th period: the last one that tops up
    assert _retire_cum("half_year", date(2027, 7, 1))[0] == D("1800.00")        # the 7th: already six planned, nothing more
    assert _retire_cum("half_year", date(2027, 10, 15))[0] == D("2400.00")       # later: charges planned are not taken back
    got, p = _retire_cum("half_year", date(2027, 4, 10))
    assert p.cum("2029-01") == got                                               # nothing after a full retirement


def test_half_year_retirement_after_a_half_year_acquisition_in_the_same_year():
    key = DepKey("K", "straight_line", life_periods=60, acq_conv="half_year", ret_conv="half_year")
    p = plan([Add(date(2026, 10, 20), D("12000.00")), Retire(date(2026, 12, 31))], key)
    assert p.cum("2026-12") == D("1200.00")                                      # one half year, not two


def test_each_retirement_convention_boundary():
    assert _retire_cum("full_month", date(2027, 10, 1))[0] == D("2600.00")
    assert _retire_cum("full_month", date(2027, 10, 31))[0] == D("2600.00")
    assert _retire_cum("next_month", date(2027, 10, 31))[0] == D("2400.00")
    assert _retire_cum("mid_month", date(2027, 10, 15))[0] == D("2400.00")       # on the 15th: that month is not charged
    assert _retire_cum("mid_month", date(2027, 10, 16))[0] == D("2600.00")       # after it: charged
    assert _retire_cum("daily", date(2027, 10, 31))[0] == D("2600.00")           # the whole month
    assert _retire_cum("daily", date(2027, 10, 1))[0] == D("2400.00") + money(F(200, 31))


def test_each_acquisition_convention_boundary():
    for conv, on, first in (("full_month", date(2026, 12, 31), "200.00"), ("next_month", date(2026, 12, 31), "0.00"),
                            ("mid_month", date(2026, 12, 15), "200.00"), ("mid_month", date(2026, 12, 16), "0.00"),
                            ("daily", date(2026, 12, 31), str(money(F(200, 31)))), ("daily", date(2026, 12, 1), "200.00")):
        key = DepKey("K", "straight_line", life_periods=60, acq_conv=conv)
        assert str(plan([Add(on, D("12000.00"))], key).charge("2026-12")) == first, (conv, on)
    key = DepKey("H", "straight_line", life_periods=60, acq_conv="half_year")
    assert plan([Add(date(2026, 12, 31), D("12000.00"))], key).charge("2026-12") == D("1200.00")   # one period holds the six
    jan = plan([Add(date(2026, 1, 1), D("12000.00"))], key)
    assert [str(jan.charge(m)) for m in ("2026-01", "2026-12", "2027-01")] == ["100.00", "100.00", "200.00"]