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