dodil/erp-brick-fixed-assets

DODIL ERP brick: fixed assets

tests/test_allowances.py

"""FA-21 (capital allowances per pool for a chargeable period) and FA-22 (the controller approves, the computation is filed and frozen)."""

import pytest
from conftest import (
    ACCOUNTANT,
    APPROVER,
    OWNER,
    ROLES,
    TAX,
    acquire,
    api,
    create_asset,
    must,
    on_day,
    person,
    setup_policy,
)

SHA = "b" * 64
P = "/ca-computations/UK01/2026-04-01/2027-03-31"


@pytest.fixture
def world(gl):
    setup_policy(classes=("VEH", "PLANT"), start="2023-01-01")
    with on_day("2023-01-01T09:00:00+00:00"):
        for k, v in (("aia_limit", "1000000"), ("wda_main_pct", "18"), ("wda_special_pct", "6"), ("small_pool_limit", "1000"),
                     ("sba_pct", "3"), ("fya_full_expensing_pct", "100"), ("fya_50_pct", "50")):
            must(api("POST", "/settings/ca-rates", OWNER, {"rate_key": k, "value": v, "legislation_ref": "CAA 2001"}))
    return gl


def register(world):
    """plant 120,000.00 (main) and a car 30,000.00 (special rate) bought in the period; an old plant of 15,000.00 sold for 8,000.00."""
    with on_day("2024-01-02T09:00:00+00:00"):
        old = create_asset("old", cls="PLANT", description="Old press")
        must(acquire(old["asset_id"], "15000.00", value_date="2024-01-01", ref="pur:OLD-1:1"))
    with on_day("2026-07-02T09:00:00+00:00"):
        plant = create_asset("plant", cls="PLANT", description="New line")
        must(acquire(plant["asset_id"], "120000.00", value_date="2026-06-15", ref="pur:PL-1:1"))
        car = create_asset("car", cls="VEH", description="Company car", is_car=True, ca_pool="special_rate")
        must(acquire(car["asset_id"], "30000.00", value_date="2026-07-01", ref="pur:CAR-1:1"))
    with on_day("2026-09-30T09:00:00+00:00"):
        r = must(api("POST", "/depreciation-runs", ACCOUNTANT, {"company_code": "UK01", "period_id": "2026-09"}))
        must(api("POST", f"/depreciation-runs/{r['run_id']}/approve", APPROVER))
    with on_day("2026-09-15T09:00:00+00:00"):
        pass
    return old, plant, car


def retire_old(old, when="2026-09-30"):
    with on_day(when + "T10:00:00+00:00"):
        t = must(api("POST", f"/assets/{old['asset_id']}/retirements", ACCOUNTANT,
                     {"retirement_date": when, "percent": "100", "proceeds": "8000.00"}))
        must(api("POST", f"/asset-transactions/{t['transaction_id']}/approve", APPROVER))


def draft(opening=True, start="2026-04-01", end="2027-03-31", who=TAX):
    body = {"company_code": "UK01", "period_start": start, "period_end": end}
    if opening:
        body["opening_pools"] = [{"pool": "main", "balance": "50000.00"}, {"pool": "special_rate", "balance": "10000.00"}]
    return must(api("POST", "/ca-computations", who, body))


def claim(path, asset, kind, who=TAX, amount=None):
    return api("POST", path + "/claims", who, {"claims": [{"asset_id": asset["asset_id"], "claim": kind,
                                                           **({"amount": amount} if amount else {})}]})


def prepared(world):
    old, plant, car = register(world)
    retire_old(old)
    draft()
    must(claim(P, plant, "aia"))
    must(claim(P, car, "pool"))
    return old, plant, car, must(api("POST", P + "/prepare", TAX))


# ── FA-21 ───────────────────────────────────────────────────────────────────────────────────

def test_the_computation_is_worked_out_from_the_register_per_pool(world):
    _, _, _, c = prepared(world)
    assert c["totals"]["aia"] == "120000.00" and c["totals"]["total_allowances"] == "129960.00"
    main, special = c["pools"]["main"], c["pools"]["special_rate"]
    assert (main["opening"], main["disposals"], main["wda"], main["closing"]) == ("50000.00", "8000.00", "7560.00", "34440.00")
    assert (special["opening"], special["additions"], special["wda"], special["closing"]) == (
        "10000.00", "30000.00", "2400.00", "37600.00")
    assert c["disposals"][0]["disposal_value"] == "8000.00"            # the lower of proceeds (8,000.00) and cost (15,000.00)
    assert c["status"] == "prepared"


def test_a_car_cannot_take_aia_and_every_addition_needs_a_claim(world):
    old, plant, car = register(world)
    draft()
    s, b = claim(P, car, "aia")
    assert s == 409 and "cannot take the annual investment allowance" in str(b)     # names the rule
    must(claim(P, plant, "aia"))
    s, b = api("POST", P + "/prepare", TAX)
    assert s == 409 and "claims_complete" in str(b), b                              # the car has no claim yet


def test_aia_cannot_exceed_the_limit_pro_rated_for_a_short_period(world):
    old, plant, car = register(world)
    short = "/ca-computations/UK01/2026-04-01/2026-09-30"                           # 6 months: limit 500,000.00
    big = create_asset_big(world)
    must(api("POST", "/ca-computations", TAX, {"company_code": "UK01", "period_start": "2026-04-01", "period_end": "2026-09-30",
                                               "opening_pools": [{"pool": "main", "balance": "0.00"}]}))
    must(claim(short, plant, "aia"))
    must(claim(short, car, "pool"))
    must(claim(short, big, "aia"))
    s, b = api("POST", short + "/prepare", TAX)
    assert s == 409 and "aia_within_limit" in str(b), b                              # 120,000 + 450,000 > 500,000


def create_asset_big(world):
    with on_day("2026-08-02T09:00:00+00:00"):
        big = create_asset("big", cls="PLANT", description="Plant hall")
        must(acquire(big["asset_id"], "450000.00", value_date="2026-08-01", ref="pur:BIG-1:1"))
    return big


def test_a_small_pool_may_be_written_off(world):
    old, plant, car = register(world)
    retire_old(old)
    must(api("POST", "/ca-computations", TAX, {"company_code": "UK01", "period_start": "2026-04-01", "period_end": "2027-03-31",
                                               "opening_pools": [{"pool": "main", "balance": "1500.00"}]}))
    must(claim(P, plant, "aia"))
    must(claim(P, car, "pool"))
    c = must(api("POST", P + "/prepare", TAX))
    # 1,500 - 8,000 disposal: the pool goes negative -> a balancing charge, never a negative closing pool
    assert c["pools"]["main"]["closing"] == "0.00" and c["pools"]["main"]["balancing_charge"] == "6500.00"


# ── FA-22 ───────────────────────────────────────────────────────────────────────────────────

DUAL = ROLES["tax_preparer"] + ROLES["finance_approver"]


@pytest.mark.parametrize("who", [person("tax_preparer@uk.example", DUAL, sub="other-login"),
                                 person("another.person@uk.example", DUAL, sub="tax_preparer@uk.example")])
def test_the_preparer_cannot_approve_their_own_under_any_identity(world, who):
    prepared(world)
    s, b = api("POST", P + "/approve", who)
    assert s == 409 and "second" in str(b).lower(), b
    assert api("POST", P + "/approve", ACCOUNTANT)[0] == 403                          # the accountant has no approval right
    assert api("POST", P + "/approve", TAX)[0] == 403
    c = must(api("POST", P + "/approve", APPROVER))
    assert c["status"] == "approved" and c["approved_by_email"] == "finance_approver@uk.example"


def test_filing_freezes_it_a_later_transaction_lands_in_the_next_period_as_a_named_adjustment(world):
    old, plant, car, _ = prepared(world)
    must(api("POST", P + "/approve", APPROVER))
    f = must(api("POST", P + "/file", APPROVER, {"return_reference": "CT600-2027"}))
    assert f["status"] == "filed" and f["return_reference"] == "CT600-2027"
    assert api("POST", P + "/return", APPROVER, {"reason": "second thoughts"})[0] == 409      # a filed one never moves
    assert api("POST", P + "/claims", TAX, {"claims": [{"asset_id": plant["asset_id"], "claim": "pool"}]})[0] == 409
    with on_day("2026-11-20T10:00:00+00:00"):
        t = must(api("POST", f"/assets/{plant['asset_id']}/retirements", ACCOUNTANT,
                     {"retirement_date": "2026-11-19", "percent": "100", "proceeds": "90000.00"}))
        must(api("POST", f"/asset-transactions/{t['transaction_id']}/approve", APPROVER))
    again = must(api("GET", P, TAX))
    assert again["totals"] == f["totals"] and again["pools"] == f["pools"]                   # it did not move
    nxt = must(api("POST", "/ca-computations", TAX, {"company_code": "UK01", "period_start": "2027-04-01",
                                                      "period_end": "2028-03-31"}))
    assert nxt["opening"] == {"main": "34440.00", "special_rate": "37600.00"}               # closing pools are the next opening
    assert nxt["status"] == "draft"
    assert [a["transaction_id"] for a in nxt["adjustments"]] == [t["transaction_id"]]                # named, with its own period
    assert nxt["adjustments"][0]["from_period"] == "2026-04-01 to 2027-03-31"
    assert nxt["disposals"] == [{"asset_id": plant["asset_id"], "proceeds": "90000.00", "cost": "120000.00"}]