dodil/erp-brick-fixed-assets

DODIL ERP brick: fixed assets

src/brick_fixed_assets/domain/capital_allowances.py

"""UK capital allowances (FA-21): pools, AIA, full expensing, first-year allowances, writing-down allowances, balancing
charges — Capital Allowances Act 2001 Part 2. Exact Decimals; the rates and limits are dated ROWS passed in
(fa_ca_rate), never constants here.

  AIA                min(claimed, allocated limit x months/12), never on a car; the rest of the cost enters its pool
  full expensing     100% of a company's new and unused main-pool plant; disposals are a balancing CHARGE of the proceeds
  fya_50             50% first-year allowance; the other half enters the pool
  WDA                pool (after additions and disposals) x rate x months/12; a pool at or under the small-pools limit
                     may be written off whole
  balancing          a pool that goes negative is a balancing charge; a single-asset pool sold is an allowance or a charge
  disposal value     the lower of proceeds and the asset's cost
"""
from dataclasses import dataclass
from decimal import Decimal
from fractions import Fraction

from brick_fixed_assets.domain.errors import RuleBroken
from brick_fixed_assets.domain.values import CLAIMS, POOLS, ZERO, frac, money

POOL_RATE = {"main": "wda_main_pct", "special_rate": "wda_special_pct", "sba": "sba_pct"}


@dataclass(frozen=True)
class Addition:
    asset_id: str
    cost: Decimal
    claim: str                      # aia | full_expensing | fya_50 | pool | single_asset | sba | none
    pool: str                       # main | special_rate | single_asset | sba
    amount: Decimal = ZERO          # AIA claimed (the part of cost taken by AIA); for fya/full the rate decides
    is_car: bool = False
    new_and_unused: bool = True


@dataclass(frozen=True)
class Disposal:
    asset_id: str
    pool: str
    proceeds: Decimal
    cost: Decimal
    claim: str = "pool"             # how the asset was claimed when it was added

    @property
    def value(self) -> Decimal:
        return min(self.proceeds, self.cost)


def check_claim(a: Addition, rates: dict) -> None:
    """The claim fits the asset (`claim_allowed`): AIA never on a car; full expensing only on a company's new and
    unused main-pool plant; a first-year allowance only on a new asset."""
    if a.claim not in CLAIMS:
        raise RuleBroken(f"a claim is one of {', '.join(CLAIMS)}")
    if a.pool not in POOLS or a.pool == "none":
        raise RuleBroken(f"a pool is one of {', '.join(p for p in POOLS if p != 'none')}")
    if a.claim == "aia" and a.is_car:
        raise RuleBroken("a car cannot take the annual investment allowance (CAA 2001 s46: cars are excluded): "
                         "pick the pool the asset belongs to")
    if a.claim == "full_expensing":
        if a.is_car or a.pool != "main" or not a.new_and_unused:
            raise RuleBroken("full expensing is for a company's new and unused main-pool plant, not a car or used plant")
    if a.claim == "fya_50" and not a.new_and_unused:
        raise RuleBroken("a first-year allowance is for new and unused plant")
    if a.claim == "aia" and not (ZERO < a.amount <= a.cost):
        raise RuleBroken("the AIA claimed is more than zero and at most the asset's cost")


def prorate(months: int) -> Fraction:
    if not (1 <= months <= 18):
        raise RuleBroken("a chargeable period is 1 to 18 months")
    return Fraction(months, 12)


def aia_within_limit(additions: list, limit: Decimal, months: int) -> tuple:
    """(claimed, allowed): the AIA claimed against the company's share of the limit for this period, pro-rated."""
    allowed = money(frac(limit) * prorate(months))
    claimed = sum((a.amount for a in additions if a.claim == "aia"), ZERO)
    return claimed, allowed


def compute(months: int, opening: dict, additions: list, disposals: list, rates: dict,
            ends: tuple = ()) -> dict:
    """The computation. `opening` {pool: balance}; `rates` {rate_key: Decimal} in force; `ends` pools that end (a
    balancing allowance or charge). Returns pools, the allowances and the charges, every figure exact to the cent."""
    f = prorate(months)
    pools: dict = {}

    def pool(name: str) -> dict:
        return pools.setdefault(name, {"opening": ZERO, "additions": ZERO, "disposals": ZERO, "first_year": ZERO,
                                       "wda": ZERO, "balancing_allowance": ZERO, "balancing_charge": ZERO,
                                       "closing": ZERO})
    for name, bal in opening.items():
        pool(name)["opening"] = bal
    aia = fe = fya = ZERO
    charge_direct = ZERO                       # full expensing disposals: the proceeds are a balancing charge
    for a in additions:
        check_claim(a, rates)
        name = f"single_asset:{a.asset_id}" if a.pool == "single_asset" else a.pool
        taken = ZERO
        if a.claim == "aia":
            taken = a.amount
            aia += taken
        elif a.claim == "full_expensing":
            taken = money(frac(a.cost) * frac(rates["fya_full_expensing_pct"]) / 100)
            fe += taken
        elif a.claim == "fya_50":
            taken = money(frac(a.cost) * frac(rates["fya_50_pct"]) / 100)
            fya += taken
        rest = a.cost - taken
        if rest:
            p = pool(name)
            p["additions"] += rest
    for d in disposals:
        if d.claim == "full_expensing":
            charge_direct += d.value
            continue
        name = f"single_asset:{d.asset_id}" if d.pool == "single_asset" else d.pool
        pool(name)["disposals"] += d.value
    wda = ba = bc = ZERO
    for name, p in pools.items():
        balance = p["opening"] + p["additions"] - p["disposals"]
        single = name.startswith("single_asset:")
        if balance < 0:
            p["balancing_charge"] = -balance
            bc += -balance
            p["closing"] = ZERO
            continue
        if single and p["disposals"] > 0:
            # a single-asset pool ends when its asset is sold: what is left is a balancing allowance
            p["balancing_allowance"] = balance
            ba += balance
            p["closing"] = ZERO
            continue
        if name in ends:
            p["balancing_allowance"] = balance
            ba += balance
            p["closing"] = ZERO
            continue
        rate_key = POOL_RATE.get(name, "wda_main_pct" if single else None)
        rate = frac(rates.get(rate_key, 0)) / 100 if rate_key else Fraction(0)
        small = frac(rates.get("small_pool_limit", 0)) * f
        if name in ("main", "special_rate") and balance <= money(small) and balance > 0:
            allowance = balance                    # the small pools allowance: write the whole pool off
        else:
            allowance = min(money(frac(balance) * rate * f), balance)
        p["wda"] = allowance
        wda += allowance
        p["closing"] = balance - allowance
    bc += charge_direct
    total = aia + fe + fya + wda + ba
    return {"months": months, "pools": pools, "aia": aia, "full_expensing": fe, "first_year": fya, "wda": wda,
            "balancing_allowance": ba, "balancing_charge": bc, "total_allowances": total,
            "net_allowances": total - bc}