dodil/erp-brick-fixed-assets
DODIL ERP brick: fixed assets
tests/test_retirement.py
"""FA-16 (sell, scrap, partial retirement) and FA-17 (second person approves every retirement, revaluation, impairment, transfer)."""
from decimal import Decimal as D
import pytest
from conftest import (
ACCOUNTANT,
APPROVER,
APPROVER2,
ROLES,
acquire,
api,
create_asset,
must,
on_day,
person,
setup_policy,
)
SEP = "2026-09-30T09:00:00+00:00"
OCT15 = "2026-10-15T09:00:00+00:00"
@pytest.fixture
def world(gl):
setup_policy(classes=("VEH", "PLANT"), start="2023-01-01")
return gl
def depreciate_to(period, when):
with on_day(when):
r = must(api("POST", "/depreciation-runs", ACCOUNTANT, {"company_code": "UK01", "period_id": period}))
return must(api("POST", f"/depreciation-runs/{r['run_id']}/approve", APPROVER))
def van_with_7200(world):
"""cost 12,000.00 acquired 2023-10-01, LIN5 full_month: 36 periods x 200.00 = 7,200.00 by 30 September 2026."""
with on_day("2026-09-01T09:00:00+00:00"):
van = create_asset("van")
must(acquire(van["asset_id"], "12000.00", value_date="2023-10-01", ref="pur:VAN-1:1"))
depreciate_to("2026-09", SEP)
return van
def values(asset_id, area="01"):
got = must(api("GET", f"/assets/{asset_id}", ACCOUNTANT))
return next(v for v in got["values"] if v["area_code"] == area)
def propose(asset_id, **body):
return api("POST", f"/assets/{asset_id}/retirements", ACCOUNTANT, body)
def approve(tx, who=APPROVER):
return api("POST", f"/asset-transactions/{tx}/approve", who)
def kinds(world, event):
return [j for j in world.journals.values() if j["event"] == event]
# ── FA-16 ───────────────────────────────────────────────────────────────────────────────────
def test_a_sale_charges_the_retirement_month_and_posts_the_gain_per_area(world):
van = van_with_7200(world)
n = len(world.journals)
with on_day(OCT15):
t = must(propose(van["asset_id"], retirement_date="2026-10-15", percent="100", proceeds="5500.00"))
assert len(world.journals) == n and t["status"] == "proposed" # nothing posts before a second person approves
done = must(approve(t["transaction_id"]))
assert done["status"] == "posted"
by_area = {a["area_code"]: a for a in done["areas"]}
assert by_area["01"]["catch_up"] == "200.00" and by_area["01"]["gain_loss"] == "900.00" # October, full_month
assert (by_area["01"]["cost"], by_area["01"]["accumulated_depreciation"]) == ("-12000.00", "-7400.00")
for ledger in (10, 20): # areas 01 and 30 each in their own ledger
assert world.balance(150, ledger) == 0 and world.balance(151, ledger) == 0 # cost and accumulated both left
assert world.balance(1300, ledger) == D("5500.00") # debit asset sale clearing
assert world.balance(491, ledger) == D("-900.00") and world.balance(771, ledger) == D("7400.00")
ret = kinds(world, "retire")
assert sorted(j["ledger_id"] for j in ret) == ["10", "20"] and all(j["journal_date"] == "2026-10-15" for j in ret)
assert by_area["CA"]["journal_id"] is None # the tax area posts nothing
v = values(van["asset_id"])
assert (v["cost"], v["accumulated_depreciation"], v["net_book_value"]) == ("0.00", "0.00", "0.00")
assert must(api("GET", f"/assets/{van['asset_id']}", ACCOUNTANT))["status"] == "retired"
def test_a_partial_retirement_takes_its_share_of_cost_and_accumulated_depreciation(world):
with on_day("2026-09-01T09:00:00+00:00"):
press = create_asset("press", cls="PLANT", description="Press")
must(acquire(press["asset_id"], "8000.00", value_date="2023-10-01", ref="pur:PRESS-1:1"))
depreciate_to("2026-09", SEP) # 36 / 60 of 8,000 = 4,800.00
with on_day(OCT15):
t = must(propose(press["asset_id"], retirement_date="2026-10-15", percent="25"))
done = must(approve(t["transaction_id"]))
a01 = next(a for a in done["areas"] if a["area_code"] == "01")
assert (a01["cost"], a01["accumulated_depreciation"]) == ("-2000.00", "-1233.33") # 25% of 8,000 and of 4,933.33 (36 months + October)
assert a01["gain_loss"] == "-766.67" # no proceeds: a loss of the NBV
v = values(press["asset_id"])
assert (v["cost"], v["accumulated_depreciation"]) == ("6000.00", "3700.00")
# the asset keeps going: 75% remains, a second partial of 80% of the ORIGINAL is more than remains
s, b = propose(press["asset_id"], retirement_date="2026-10-15", percent="80")
assert s == 409 and "remain" in str(b).lower(), b
with on_day("2026-10-15T10:00:00+00:00"):
by_amount = must(propose(press["asset_id"], retirement_date="2026-10-15", amount="2000.00")) # 25% of the original
assert by_amount["params"]["orig_share"] == "1/4"
s, b = propose(press["asset_id"], retirement_date="2026-10-15", percent="60") # together with the proposal: > 100%
assert s == 409, b
def test_the_date_is_a_fact_not_in_the_future_and_the_method_is_exactly_one_of_three(world):
van = van_with_7200(world)
with on_day(OCT15):
s, b = propose(van["asset_id"], retirement_date="2026-10-16", percent="100")
assert s == 409 and "not_future" in str(b)
for body in ({}, {"percent": "50", "amount": "100.00"}):
s, b = propose(van["asset_id"], retirement_date="2026-10-15", **body)
assert s == 409 and "exactly one" in str(b)
s, b = propose(van["asset_id"], retirement_date="2026-10-15", percent="100.01")
assert s in (409, 422)
s, b = propose(van["asset_id"], retirement_date="2026-10-15", percent="100", proceeds="100.00",
sale_clearing_account_id=None)
assert s == 200 # the owner's asset_sale_clearing_account_id applies
def test_a_retired_asset_cannot_be_retired_again(world):
van = van_with_7200(world)
with on_day(OCT15):
t = must(propose(van["asset_id"], retirement_date="2026-10-15", percent="100"))
must(approve(t["transaction_id"]))
s, b = propose(van["asset_id"], retirement_date="2026-10-15", percent="100")
assert s == 409 and "retired" in str(b)
# ── FA-17 ───────────────────────────────────────────────────────────────────────────────────
DUAL = ROLES["asset_accountant"] + ROLES["finance_approver"]
SAME_EMAIL_OTHER_SUB = person("asset_accountant@uk.example", DUAL, sub="another-login")
SAME_SUB_OTHER_EMAIL = person("someone.else@uk.example", DUAL, sub="asset_accountant@uk.example")
@pytest.mark.parametrize("who", [SAME_EMAIL_OTHER_SUB, SAME_SUB_OTHER_EMAIL, ACCOUNTANT])
def test_the_proposer_cannot_approve_under_any_identity(world, who):
machine = van_with_7200(world)
with on_day(OCT15):
t = must(propose(machine["asset_id"], retirement_date="2026-10-15", percent="100"))
n = len(world.journals)
s, b = approve(t["transaction_id"], who)
assert s in (403, 409) and ("second" in str(b).lower() or "missing permission" in str(b)), b
assert len(world.journals) == n
assert must(api("GET", f"/asset-transactions/{t['transaction_id']}", APPROVER))["status"] == "proposed"
done = must(approve(t["transaction_id"], APPROVER))
assert done["approved_by_email"] == "finance_approver@uk.example" and done["approved_by_sub"] == "finance_approver@uk.example"
assert done["approved_on"] == "2026-10-15"
def test_a_rejection_keeps_its_reason_posts_nothing_and_an_approved_transaction_is_never_edited(world):
machine = van_with_7200(world)
n = len(world.journals)
with on_day(OCT15):
t = must(propose(machine["asset_id"], retirement_date="2026-10-15", percent="100"))
no = must(api("POST", f"/asset-transactions/{t['transaction_id']}/reject", APPROVER, {"reason": "still in use"}))
assert no["status"] == "rejected" and no["rejected_reason"] == "still in use" and len(world.journals) == n
assert approve(t["transaction_id"])[0] == 409 # a rejected one is closed
with on_day("2026-10-15T10:00:00+00:00"): # a later moment: a transaction id carries the instant
t2 = must(propose(machine["asset_id"], retirement_date="2026-10-15", percent="100"))
must(approve(t2["transaction_id"]))
assert api("POST", f"/asset-transactions/{t2['transaction_id']}/reject", APPROVER, {"reason": "oops"})[0] == 409
rev = must(api("POST", f"/asset-transactions/{t2['transaction_id']}/reverse", APPROVER2, {"reason": "sold in error"}))
assert rev["reverses_transaction_id"] == t2["transaction_id"]
assert must(api("GET", f"/asset-transactions/{t2['transaction_id']}", APPROVER))["areas"][0]["cost"] == "-12000.00"
assert values(machine["asset_id"])["cost"] == "12000.00" # the reversal put it back, the original untouched