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}