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