dodil/erp-brick-fixed-assets

DODIL ERP brick: fixed assets

tests/test_settings.py

"""FA-01 and FA-02: the controls on the people who value assets — areas and the ledger each posts to, keys, classes, account
determination — are DATED rows set by the owner alone, never edited, prospective."""
from decimal import Decimal

from conftest import ACC, ACCOUNTANT, APPROVER, OWNER, api, create_asset, must, on_day, person, setup_policy


def test_the_owner_records_a_class_from_tomorrow_and_an_asset_created_then_takes_its_defaults(gl):
    setup_policy()
    must(api("POST", "/settings/keys", OWNER, {"key_code": "LIN3", "method": "straight_line", "life_periods": 36,
                                               "valid_from": "2026-11-21"}))
    got = must(api("POST", "/settings/classes", OWNER, {
        "class_code": "IT", "name": "IT equipment", "valid_from": "2026-11-21", "default_ca_pool": "main",
        "areas": [{"area_code": "01", "key_code": "LIN3"}, {"area_code": "30", "key_code": "LIN4"},
                  {"area_code": "CA", "key_code": "LIN3"}]}))
    assert got["valid_from"] == "2026-11-21" and len(got["areas"]) == 3
    for area in ("01", "30"):
        r = must(api("POST", "/settings/account-determination", OWNER, {
            "class_code": "IT", "area_code": area, "valid_from": "2026-11-21", **{**ACC, "cost_account_id": "160",
                                                                                   "accumulated_depreciation_account_id": "161",
                                                                                   "depreciation_expense_account_id": "7710",
                                                                                   "gain_account_id": "4910",
                                                                                   "loss_account_id": "7910",
                                                                                   "revaluation_reserve_account_id": "3200",
                                                                                   "impairment_account_id": "7720"}}))
        assert r["cost_account_id"] == "160" and r["valid_from"] == "2026-11-21"
    status, body = api("POST", "/assets", ACCOUNTANT, {"request_id": "r1", "company_code": "UK01", "class_code": "IT",
                                                       "description": "Laptop"})
    assert status == 409 and "class_in_force" in body["detail"]                       # not in force until tomorrow
    with on_day("2026-11-21T09:00:00+00:00"):
        made = create_asset("r2", cls="IT", description="Laptop")
    keys = {a["area_code"]: (a["key_code"], a["life_periods"]) for a in made["areas"]}
    assert keys == {"01": ("LIN3", 36), "30": ("LIN4", 48), "CA": ("LIN3", 36)}


def test_the_accountant_who_tries_a_control_is_refused_and_so_is_whoever_a_control_governs():
    body = {"area_code": "01", "name": "Book", "purpose": "book"}
    assert api("POST", "/settings/areas", ACCOUNTANT, body) == (403, {"detail": "missing permission: fa:settings:controls"})
    assert api("POST", "/settings/areas", APPROVER, body)[0] == 403                    # settings:write is day-to-day only
    # fa:settings:write cannot change a control, whatever the key
    s, b = api("POST", "/settings/day-to-day", APPROVER, {"setting_key": "capitalisation_threshold", "value": "1"})
    assert s == 403 and "control" in b["detail"]
    # a person who holds the controls AND a role a control governs is a configuration error, refused at configuration
    both = person("both@uk.example", ["fa:settings:controls", "fa:asset:write"])
    s, b = api("POST", "/settings/controls", both, {"setting_key": "capitalisation_threshold", "value": "250.00"})
    assert s == 403 and "never held by whoever it controls" in b["detail"]
    approver_owner = person("x@uk.example", ["fa:settings:controls", "fa:depreciation:approve"])
    assert api("POST", "/settings/keys", approver_owner, {"key_code": "K", "method": "straight_line"})[0] == 403
    tax_owner = person("t@uk.example", ["fa:settings:controls", "fa:capital_allowance:write"])
    assert api("POST", "/settings/ca-rates", tax_owner, {"rate_key": "aia_limit", "value": "1"})[0] == 403


def test_an_area_naming_a_ledger_gl_does_not_have_is_refused_naming_it_and_the_tax_area_takes_none(gl):
    s, b = api("POST", "/settings/areas", OWNER, {"area_code": "01", "name": "Book", "purpose": "book", "ledger_id": "99",
                                                  "company_code": "UK01"})
    assert s == 409 and "no ledger 99" in b["detail"]
    s, b = api("POST", "/settings/areas", OWNER, {"area_code": "CA", "name": "Tax", "purpose": "tax", "ledger_id": "10",
                                                  "company_code": "UK01"})
    assert s == 409 and "tax area never posts" in b["detail"]
    s, b = api("POST", "/settings/areas", OWNER, {"area_code": "CA", "name": "Tax", "purpose": "tax"})
    assert s == 200 and b["posting"] == "none" and b["ledger_id"] is None
    must(api("POST", "/settings/areas", OWNER, {"area_code": "01", "name": "Book", "purpose": "book", "ledger_id": "10",
                                                "company_code": "UK01"}))
    s, b = api("POST", "/settings/areas", OWNER, {"area_code": "02", "name": "Other", "purpose": "book", "ledger_id": "10",
                                                  "company_code": "UK01"})
    assert s == 409 and "already posts to ledger 10" in b["detail"]                    # one area per ledger
    s, b = api("POST", "/settings/areas", OWNER, {"area_code": "03", "name": "No gl", "purpose": "book", "ledger_id": "10"})
    assert s == 409 and "company_code" in b["detail"]


def test_without_gl_an_area_with_a_ledger_cannot_be_checked():
    s, b = api("POST", "/settings/areas", OWNER, {"area_code": "01", "name": "Book", "purpose": "book", "ledger_id": "10",
                                                  "company_code": "UK01"})
    assert s == 409 and "gl is not installed" in b["detail"]


def test_a_dated_row_is_never_edited_and_a_later_one_starts_after_today(gl):
    setup_policy()
    again = {"area_code": "01", "name": "Book", "purpose": "book", "ledger_id": "10", "company_code": "UK01",
             "currency": "GBP", "valid_from": "2026-01-01"}
    assert must(api("POST", "/settings/areas", OWNER, again))["created"] is False       # the same row again: a no-op
    s, b = api("POST", "/settings/areas", OWNER, {**again, "name": "Renamed"})
    assert s == 409 and "never edited" in b["detail"]
    s, b = api("POST", "/settings/areas", OWNER, {**again, "name": "Renamed", "valid_from": "2026-11-20"})
    assert s == 409 and "after today" in b["detail"]                                    # a row dated today is refused
    s, b = api("POST", "/settings/areas", OWNER, {**again, "name": "Renamed", "valid_from": "2026-11-21"})
    assert s == 200 and b["created"] is True


def test_a_key_is_a_dated_row_with_its_method_rate_and_conventions_and_a_rate_outside_0_100_is_a_422(gl):
    got = must(api("POST", "/settings/keys", OWNER, {
        "key_code": "DB25", "method": "declining_then_straight", "rate_pct": "25", "life_periods": 60,
        "acquisition_convention": "full_month", "retirement_convention": "next_month", "stop_at": "scrap",
        "base": "net_book_value"}))
    assert (got["method"], got["ret_conv"], got["stop_at"]) == ("declining_then_straight", "next_month", "scrap")
    assert Decimal(got["rate_pct"]) == 25
    s, b = api("POST", "/settings/keys", OWNER, {"key_code": "BAD", "method": "declining_balance", "rate_pct": "101"})
    assert s == 422 and "rate_pct" in str(b)
    s, b = api("POST", "/settings/keys", OWNER, {"key_code": "BAD", "method": "straight_line", "life_periods": 0})
    assert s == 422 and "life_periods" in str(b)
    s, b = api("POST", "/settings/keys", OWNER, {"key_code": "BAD", "method": "declining_balance"})
    assert s == 409 and "rate_pct" in b["detail"]
    s, b = api("POST", "/settings/keys", OWNER, {"key_code": "BAD", "method": "mystery"})
    assert s == 409 and "method is one of" in b["detail"]
    # superseded by a new dated row, prospectively; the old one is untouched
    s, b = api("POST", "/settings/keys", OWNER, {"key_code": "DB25", "method": "declining_then_straight", "rate_pct": "20",
                                                 "valid_from": "2026-12-01"})
    assert s == 200 and Decimal(b["rate_pct"]) == 20
    shown = must(api("GET", "/settings", OWNER, {"on": "2026-11-25"}))
    assert shown["keys"][0]["rate_pct"] == "25.0000"
    assert must(api("GET", "/settings", OWNER, {"on": "2026-12-02"}))["keys"][0]["rate_pct"] == "20.0000"


def test_the_controls_section_is_shown_to_the_owner_only(gl):
    setup_policy()
    assert "areas" in must(api("GET", "/settings", OWNER))
    seen = must(api("GET", "/settings", APPROVER))
    assert "areas" not in seen and "keys" not in seen and list(seen["settings"]) == ["ai.ai_suggest_asset_class.mode"]


def test_controls_are_validated_and_warned(gl):
    s, b = api("POST", "/settings/controls", OWNER, {"setting_key": "asset_number_format", "value": "no-sequence"})
    assert s == 409
    s, b = api("POST", "/settings/controls", OWNER, {"setting_key": "transfer_variant_default", "value": "sideways"})
    assert s == 409
    s, b = api("POST", "/settings/controls", OWNER, {"setting_key": "depreciation_needs_approval", "value": "false"})
    assert s == 200 and "no longer needs a second person" in b["warning"]
    s, b = api("POST", "/settings/controls", OWNER, {"setting_key": "capitalisation_threshold", "value": "-5"})
    assert s == 409
    assert api("POST", "/settings/controls", OWNER, {"setting_key": "nonsense", "value": "x"})[0] == 409


def test_an_ai_step_mode_is_a_day_to_day_setting(gl):
    s, b = api("POST", "/settings/day-to-day", APPROVER, {"setting_key": "ai.ai_suggest_asset_class.mode", "value": "off"})
    assert s == 200 and b["value"] == "off"
    assert api("POST", "/settings/day-to-day", APPROVER, {"setting_key": "ai.ai_suggest_asset_class.mode",
                                                          "value": "auto", "valid_from": "2026-12-01"})[0] == 409