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