dodil/erp-brick-fixed-assets

DODIL ERP brick: fixed assets

tests/test_revaluation_transfers.py

"""FA-18 (transfer between companies), FA-19 (revaluation), FA-20 (impairment and its reversal)."""
from decimal import Decimal as D

import pytest
from conftest import ACCOUNTANT, APPROVER, acquire, api, create_asset, must, on_day, setup_policy

SEP = "2026-09-30T09:00:00+00:00"
SHA = "a" * 64


@pytest.fixture
def world(gl):
    setup_policy(classes=("VEH", "BLDG"), start="2023-01-01")
    return gl


def run(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 values(asset_id, area="01"):
    return next(v for v in must(api("GET", f"/assets/{asset_id}", ACCOUNTANT))["values"] if v["area_code"] == area)


def building(world):
    with on_day("2026-09-01T09:00:00+00:00"):
        b = create_asset("bldg", cls="BLDG", description="Warehouse")
        must(acquire(b["asset_id"], "500000.00", value_date="2026-04-01", ref="pur:BLDG-1:1"))
    run("2026-09", SEP)                                      # April..September: 6 months of 8,333.33 = 50,000.00
    return b


def approve(tx, who=APPROVER):
    return api("POST", f"/asset-transactions/{tx}/approve", who)


def propose(asset_id, kind, **body):
    return api("POST", f"/assets/{asset_id}/{kind}", ACCOUNTANT, body)


def reval(asset_id, amount, when="2026-09-30", sha=SHA):
    return propose(asset_id, "revaluations", area_code="30", revalued_amount=amount, valuation_date=when, evidence_sha256=sha)


def journals(world, event):
    return [j for j in world.journals.values() if j["event"] == event]


# ── FA-19 ───────────────────────────────────────────────────────────────────────────────────

def test_a_revaluation_credits_the_reserve_eliminates_depreciation_and_leaves_the_cost_model_area_alone(world):
    b = building(world)
    with on_day(SEP):
        t = must(reval(b["asset_id"], "600000.00"))
        assert t["evidence_key"] == f"raw/fixed-assets/valuations/{SHA}.pdf" and t["status"] == "proposed"
        assert [a["area_code"] for a in t["areas"]] == ["30"]                 # one area only
        done = must(api("POST", f"/asset-transactions/{t['transaction_id']}/approve", APPROVER))
    (j,) = journals(world, "revalue")
    assert j["ledger_id"] == "20" and j["journal_date"] == "2026-09-30"
    credit = {ln["account_id"]: D(ln["credit"]) for ln in j["lines"] if "credit" in ln}
    debit = {ln["account_id"]: D(ln["debit"]) for ln in j["lines"] if "debit" in ln}
    assert credit == {"320": D("150000.00")}                                   # the reserve (other comprehensive income)
    assert debit == {"150": D("100000.00"), "151": D("50000.00")}              # cost up, accumulated eliminated (IAS 16.35b)
    v30, v01 = values(b["asset_id"], "30"), values(b["asset_id"], "01")
    assert (v30["cost"], v30["accumulated_depreciation"], v30["net_book_value"], v30["revaluation_reserve"]) == (
        "600000.00", "0.00", "600000.00", "150000.00")
    assert (v01["cost"], v01["accumulated_depreciation"], v01["revaluation_reserve"]) == ("500000.00", "50000.00", "0.00")
    assert done["status"] == "posted"
    # future depreciation is on 600,000.00 over the remaining 54 periods = 11,111.11 a month in area 30; area 01 keeps 8,333.33
    with on_day("2026-10-31T09:00:00+00:00"):
        r = must(api("POST", "/depreciation-runs", ACCOUNTANT, {"company_code": "UK01", "period_id": "2026-10"}))
    assert r["totals"] == {"01": "8333.33", "30": "11111.11"}


def test_a_decrease_goes_against_the_assets_own_reserve_first_then_to_profit_or_loss(world):
    b = building(world)
    with on_day(SEP):
        must(api("POST", f"/asset-transactions/{must(reval(b['asset_id'], '600000.00'))['transaction_id']}/approve", APPROVER))
    run("2026-10", "2026-10-31T09:00:00+00:00")                                 # NBV 600,000.00 - 11,111.11 = 588,888.89
    with on_day("2026-10-31T09:00:00+00:00"):
        t = must(reval(b["asset_id"], "400000.00", when="2026-10-31"))          # 188,888.89 down: 150,000 reserve, the rest loss
        must(api("POST", f"/asset-transactions/{t['transaction_id']}/approve", APPROVER))
    j = journals(world, "revalue")[-1]
    debit = {ln["account_id"]: D(ln["debit"]) for ln in j["lines"] if "debit" in ln}
    assert debit.get("320") == D("150000.00") and debit.get("772") == D("38888.89"), j["lines"]
    v = values(b["asset_id"], "30")
    assert v["revaluation_reserve"] == "0.00" and v["net_book_value"] == "400000.00"


def test_a_revaluation_needs_its_report_a_valuation_model_area_and_a_second_person(world):
    b = building(world)
    with on_day(SEP):
        s, body = propose(b["asset_id"], "revaluations", area_code="30", revalued_amount="600000.00", valuation_date="2026-09-30")
        assert s == 422                                                         # the valuer's report is required
        s, body = propose(b["asset_id"], "revaluations", area_code="30", revalued_amount="600000.00", valuation_date="2026-09-30",
                          evidence_sha256="zz")
        assert s == 422
        s, body = propose(b["asset_id"], "revaluations", area_code="01", revalued_amount="600000.00", valuation_date="2026-09-30",
                          evidence_sha256=SHA)
        assert s == 409, body                                                   # area 01 is the cost model
        s, body = propose(b["asset_id"], "revaluations", area_code="30", revalued_amount="600000.00", valuation_date="2026-10-01",
                          evidence_sha256=SHA)
        assert s == 409, body                                                   # the valuation date is a fact: not in the future
        t = must(reval(b["asset_id"], "600000.00"))
        assert api("POST", f"/asset-transactions/{t['transaction_id']}/approve", ACCOUNTANT)[0] in (403, 409)


# ── FA-20 ───────────────────────────────────────────────────────────────────────────────────

def line(world):
    with on_day("2026-11-10T09:00:00+00:00"):
        pl = create_asset("line", cls="VEH", description="Production line")
        must(acquire(pl["asset_id"], "300000.00", value_date="2026-11-10", ref="pur:LINE-1:1"))
    return pl


def test_an_impairment_lowers_the_carrying_amount_and_the_reversal_is_capped(world):
    pl = line(world)
    run("2026-11", "2026-11-30T09:00:00+00:00")                                # one month: NBV 295,000.00
    with on_day("2026-11-30T09:00:00+00:00"):
        t = must(propose(pl["asset_id"], "impairments", amount="115000.00", review_date="2026-11-30", evidence_sha256=SHA,
                         area_codes=["01"]))
        assert t["evidence_key"].endswith(f"{SHA}.pdf")
        must(api("POST", f"/asset-transactions/{t['transaction_id']}/approve", APPROVER))
        (j,) = journals(world, "impair")
        assert j["ledger_id"] == "10" and {ln["account_id"]: D(ln["debit"]) for ln in j["lines"] if "debit" in ln} == {
            "772": D("115000.00")}                                              # charged to impairment loss
        v = values(pl["asset_id"])
        assert (v["cost"], v["net_book_value"]) == ("300000.00", "180000.00")
        assert values(pl["asset_id"], "30")["net_book_value"] == "295000.00"   # only the named area
        s, b = propose(pl["asset_id"], "impairments", amount="180000.01", review_date="2026-11-30", evidence_sha256=SHA,
                       area_codes=["01"])
        assert s == 409 and "more than the net book value" in str(b), b                        # never below nothing
    # a year on, recovery: capped at what the carrying amount would have been without the impairment (IAS 36.117)
    run("2027-10", "2027-10-31T09:00:00+00:00")                                # 11 more months of the lower figure
    carrying = D(values(pl["asset_id"])["net_book_value"])
    without = D("300000.00") - D("5000.00") * 12                                # Nov 2026 .. Oct 2027, as if never impaired
    cap = without - carrying
    with on_day("2027-10-31T09:00:00+00:00"):
        s, body = propose(pl["asset_id"], "impairments", amount=str(cap + D("0.01")), review_date="2027-10-31",
                          evidence_sha256=SHA, area_codes=["01"], reversal=True)
        assert s == 409 and "at most" in str(body), body
        rev = must(propose(pl["asset_id"], "impairments", amount=str(cap), review_date="2027-10-31", evidence_sha256=SHA,
                           area_codes=["01"], reversal=True))
        must(api("POST", f"/asset-transactions/{rev['transaction_id']}/approve", APPROVER))
    assert values(pl["asset_id"])["net_book_value"] == str(without)             # back to, never above, the unimpaired figure
    back = journals(world, "impair")[-1]
    assert {ln["account_id"]: D(ln["credit"]) for ln in back["lines"] if "credit" in ln} == {"492": cap}   # shown separately


# ── FA-18 ───────────────────────────────────────────────────────────────────────────────────

@pytest.fixture
def group(world):
    """UK02 posts to ITS ledgers (11, 21) through its own areas U2-01 / U2-30; the tax area CA serves both companies."""
    from conftest import ACC, OWNER
    with on_day("2023-01-01T09:00:00+00:00"):
        must(api("POST", "/settings/areas", OWNER, {"area_code": "U2-01", "name": "Book UK02", "purpose": "book", "ledger_id": "11",
                                                    "company_code": "UK02", "currency": "GBP"}))
        must(api("POST", "/settings/areas", OWNER, {"area_code": "U2-30", "name": "IFRS UK02", "purpose": "ifrs",
                                                    "ledger_id": "21", "company_code": "UK02", "currency": "GBP",
                                                    "value_model": "revaluation"}))
        must(api("POST", "/settings/classes", OWNER, {"class_code": "VEH2", "name": "Vehicles UK02", "default_ca_pool": "main",
                                                      "areas": [{"area_code": "U2-01", "key_code": "LIN5"},
                                                                {"area_code": "U2-30", "key_code": "LIN5"},
                                                                {"area_code": "CA", "key_code": "LIN5"}]}))
        for code in ("U2-01", "U2-30"):
            must(api("POST", "/settings/account-determination", OWNER, {"class_code": "VEH2", "area_code": code,
                                                                        **{**ACC, "cost_account_id": 250}}))
    return world


# The spec moves the forklift "on 1 November" with 9,600.00 accumulated: the figures are those after October's run, so the move
# is entered on 31 October (the last day it was UK01's); depreciation must be posted up to the transfer's period (the gate).
def forklift(world):
    with on_day("2026-10-01T09:00:00+00:00"):
        f = create_asset("fork", cls="VEH", description="Forklift", cost_centre="WH-1")
        must(acquire(f["asset_id"], "24000.00", value_date="2024-11-01", ref="pur:FORK-1:1"))
    run("2026-10", "2026-10-31T09:00:00+00:00")              # Nov 2024 .. Oct 2026 = 24 months of 400.00 = 9,600.00
    return f


def test_a_transfer_retires_in_one_company_and_acquires_in_the_other_each_to_its_own_ledger(group):
    f = forklift(group)
    assert values(f["asset_id"])["accumulated_depreciation"] == "9600.00"
    with on_day("2026-10-31T09:00:00+00:00"):
        t = must(api("POST", f"/assets/{f['asset_id']}/transfers", ACCOUNTANT,
                     {"transfer_date": "2026-10-31", "to_company_code": "UK02", "to_class_code": "VEH2", "variant": "gross"}))
        assert t["status"] == "proposed" and not any(j["event"] == "transfer" for j in group.journals.values())
        done = must(api("POST", f"/asset-transactions/{t['transaction_id']}/approve", APPROVER))
    assert done["status"] == "posted"
    out = {j["ledger_id"]: j for j in journals(group, "transfer") if j["company_code"] == "UK01"}
    into = {j["ledger_id"]: j for j in journals(group, "transfer") if j["company_code"] == "UK02"}
    assert sorted(out) == ["10", "20"] and sorted(into) == ["11", "21"]        # each company to ITS ledgers
    lines = {(ln["account_id"], "debit" if "debit" in ln else "credit"): D(ln.get("debit") or ln["credit"])
             for ln in out["10"]["lines"]}
    assert lines == {("150", "credit"): D("24000.00"), ("151", "debit"): D("9600.00"), ("2900", "debit"): D("14400.00")}
    lines = {(ln["account_id"], "debit" if "debit" in ln else "credit"): D(ln.get("debit") or ln["credit"])
             for ln in into["11"]["lines"]}
    assert lines == {("250", "debit"): D("24000.00"), ("151", "credit"): D("9600.00"), ("2900", "credit"): D("14400.00")}
    assert group.balance(2900) == 0                                             # the clearing nets to zero across the group
    new = next(x for x in must(api("GET", "/assets", ACCOUNTANT))["assets"] if x["company_code"] == "UK02")
    nv = values(new["asset_id"], "U2-01")
    assert (nv["cost"], nv["accumulated_depreciation"], nv["net_book_value"]) == ("24000.00", "9600.00", "14400.00")
    assert values(f["asset_id"])["cost"] == "0.00"
    got = must(api("GET", f"/assets/{new['asset_id']}", ACCOUNTANT))
    assert {a["area_code"]: a["life_periods"] for a in got["areas"]} == {"U2-01": 60, "U2-30": 60, "CA": 60}
    # depreciation continues in UK02 on what is left: 14,400.00 over the remaining 36 periods = 400.00 a month
    with on_day("2026-11-30T09:00:00+00:00"):
        r = must(api("POST", "/depreciation-runs", ACCOUNTANT, {"company_code": "UK02", "period_id": "2026-11"}))
    assert r["totals"] == {"U2-01": "400.00", "U2-30": "400.00"}


def test_the_net_variant_carries_the_net_book_value_as_cost_and_a_closed_period_in_either_company_stops_it(group):
    f = forklift(group)
    with on_day("2026-10-31T09:00:00+00:00"):
        t = must(api("POST", f"/assets/{f['asset_id']}/transfers", ACCOUNTANT,
                     {"transfer_date": "2026-10-31", "to_company_code": "UK02", "to_class_code": "VEH2", "variant": "net"}))
        group.closed.add(("11", "2026-10"))                                        # UK02's ledger closed for the period
        s, b = api("POST", f"/asset-transactions/{t['transaction_id']}/approve", APPROVER)
        assert s == 409 and "period_open" in str(b), b
        assert not journals(group, "transfer")                                   # nothing half posted
        group.closed.clear()
        must(api("POST", f"/asset-transactions/{t['transaction_id']}/approve", APPROVER))
    new = next(x for x in must(api("GET", "/assets", ACCOUNTANT))["assets"] if x["company_code"] == "UK02")
    nv = values(new["asset_id"], "U2-01")
    assert (nv["cost"], nv["accumulated_depreciation"]) == ("14400.00", "0.00")


def test_a_move_inside_one_company_is_not_a_transfer(group):
    f = forklift(group)
    other = create_asset("fork2", cls="VEH", description="Second forklift")
    with on_day("2026-10-31T09:00:00+00:00"):
        s, b = api("POST", f"/assets/{f['asset_id']}/transfers", ACCOUNTANT,
                   {"transfer_date": "2026-10-31", "to_asset_id": other["asset_id"]})
        assert s == 409 and "master change" in str(b)


def test_a_company_is_gated_only_on_its_own_ledgers(group):
    """UK02's ledger 11 being closed must not stop UK01's depreciation: an area names one ledger of one company."""
    group.closed.add(("11", "2026-10"))
    f = forklift(group)                                         # posts UK01's October run
    assert values(f["asset_id"])["accumulated_depreciation"] == "9600.00"