dodil/erp-brick-fixed-assets
DODIL ERP brick: fixed assets
src/brick_fixed_assets/domain/changes.py
"""What each change in value does to an asset's figures — pure functions over exact Decimals.
Figures are SIGNED deltas on an area's values: cost, accumulated depreciation, revaluation reserve, and the gain or
loss to profit or loss. The register's value is the SUM of these (plus depreciation postings); a reversal is the same
figures negated. Nothing here stores or posts anything.
"""
from dataclasses import dataclass
from decimal import Decimal
from fractions import Fraction
from typing import Optional
from brick_fixed_assets.domain.errors import RuleBroken
from brick_fixed_assets.domain.values import ZERO, frac, money
@dataclass(frozen=True)
class Values:
"""An asset's values in one area at a moment: the SUM of its transactions and postings."""
cost: Decimal = ZERO
acc_dep: Decimal = ZERO
reserve: Decimal = ZERO # revaluation reserve held for this asset
@property
def nbv(self) -> Decimal:
return self.cost - self.acc_dep
@dataclass(frozen=True)
class Delta:
"""The change a transaction makes in one area. `gain_loss` is positive for a gain, negative for a loss."""
cost: Decimal = ZERO
acc_dep: Decimal = ZERO
reserve: Decimal = ZERO
gain_loss: Decimal = ZERO
proceeds: Decimal = ZERO
depreciation_in_year: Decimal = ZERO
catch_up: Decimal = ZERO # depreciation to the retirement date, posted with the transaction
def negated(self) -> "Delta":
return Delta(-self.cost, -self.acc_dep, -self.reserve, -self.gain_loss, -self.proceeds,
-self.depreciation_in_year, -self.catch_up)
def retirement_fraction(values: Values, remaining_fraction: Fraction, *, percent: Optional[Decimal] = None,
amount: Optional[Decimal] = None, quantity: Optional[Decimal] = None,
total_quantity: Optional[Decimal] = None) -> Fraction:
"""The share of what REMAINS that leaves: by percent of the original, by cost amount, or by quantity. Refused when
it is more than what remains (another retirement claimed it) — `within_remaining`."""
given = [x for x in (percent, amount, quantity) if x is not None]
if len(given) != 1:
raise RuleBroken("retire by percent, by amount or by quantity: exactly one")
if remaining_fraction <= 0:
raise RuleBroken("nothing of this asset remains to retire")
if percent is not None:
share = frac(percent) / 100
elif amount is not None:
if values.cost <= 0:
raise RuleBroken("the asset has no cost left to retire by amount")
share = frac(amount) / frac(values.cost) * remaining_fraction
else:
if not total_quantity or total_quantity <= 0:
raise RuleBroken("the asset has no quantity to retire by")
share = frac(quantity) / frac(total_quantity)
if share <= 0:
raise RuleBroken("retire more than zero")
if share > remaining_fraction:
raise RuleBroken("more than what remains of the asset (another retirement claimed it): lower the amount or percent")
return share / remaining_fraction # as a share of what remains now
def retire(values: Values, catch_up: Decimal, share_of_remaining: Fraction, proceeds: Decimal) -> Delta:
"""Retire `share_of_remaining` of the asset. `catch_up` is the depreciation still to charge up to the retirement date
(the plan by the key's retirement convention, minus what is posted); it is charged WITH the retirement. Cost and
accumulated depreciation (including the catch-up) leave proportionally; the whole of what is left leaves when the
share is 1 (no cent is stranded by rounding)."""
acc_now = values.acc_dep + catch_up
if share_of_remaining == 1:
cost_out, acc_out = values.cost, acc_now
else:
cost_out = money(frac(values.cost) * share_of_remaining)
acc_out = money(frac(acc_now) * share_of_remaining)
gain = proceeds - (cost_out - acc_out)
return Delta(cost=-cost_out, acc_dep=-acc_out, reserve=ZERO, gain_loss=gain, proceeds=proceeds,
catch_up=catch_up)
def revalue(values: Values, new_value: Decimal) -> Delta:
"""Revalue to a valuer's figure (IAS 16.31-42): the accumulated depreciation is eliminated against cost (35(b)), an
increase goes to the revaluation reserve, a decrease first against THIS asset's own reserve and the rest to
profit or loss (40)."""
if new_value < 0:
raise RuleBroken("a revalued amount is zero or more")
change = new_value - values.nbv
reserve = ZERO
loss = ZERO
if change >= 0:
reserve = change
else:
against = min(-change, values.reserve)
reserve = -against
loss = -(-change - against)
return Delta(cost=new_value - values.cost, acc_dep=-values.acc_dep, reserve=reserve, gain_loss=loss)
def impair(values: Values, amount: Decimal) -> Delta:
"""An impairment of `amount` (IAS 36): first against the asset's revaluation reserve, the rest to profit or loss;
never above the net book value."""
if amount <= 0:
raise RuleBroken("an impairment is more than zero")
if amount > values.nbv:
raise RuleBroken(f"the impairment {amount} is more than the net book value {values.nbv}: lower it to the NBV at most")
against = min(amount, values.reserve)
return Delta(acc_dep=amount, reserve=-against, gain_loss=-(amount - against))
def reversal_cap(unreversed: list, remaining_now: Fraction) -> Decimal:
"""The most an impairment reversal may take the carrying amount up by (IAS 36.117): what the carrying amount would
have been without the impairment. Each impairment I made when R_i periods remained lifted nothing more than
I x R_now / R_i today. `unreversed` is [(amount left, remaining periods when impaired)]."""
total = Fraction(0)
for amount, remaining_then in unreversed:
if remaining_then and remaining_then > 0:
total += frac(amount) * min(frac(remaining_now) / frac(remaining_then), Fraction(1))
else:
total += frac(amount)
return money(total)
def reverse_impairment(values: Values, amount: Decimal, cap: Decimal) -> Delta:
if amount <= 0:
raise RuleBroken("a reversal is more than zero")
if amount > cap:
raise RuleBroken(f"a reversal of {amount} takes the carrying amount above what it would have been without the "
f"impairment: at most {cap}")
return Delta(acc_dep=-amount, gain_loss=amount)
def transfer_values(values: Values, variant: str) -> tuple:
"""(leaving, arriving) deltas of an inter-company transfer. Gross carries cost and accumulated depreciation; net
carries the net book value as cost. The clearing balance is the NBV either way."""
if variant not in ("gross", "net"):
raise RuleBroken("a transfer variant is gross or net")
out = Delta(cost=-values.cost, acc_dep=-values.acc_dep)
if variant == "gross":
into = Delta(cost=values.cost, acc_dep=values.acc_dep)
else:
into = Delta(cost=values.nbv, acc_dep=ZERO)
return out, into