dodil/erp-brick-fixed-assets

DODIL ERP brick: fixed assets

src/brick_fixed_assets/domain/values.py

"""Plain values and the small rules every part of the brick shares: money rounding, periods, the second person, bounds.

Everything here is exact (Decimal / Fraction): no float ever touches an amount, a rate or a unit count.
"""
import calendar
from dataclasses import dataclass
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
from typing import Iterable, Optional, Union

from brick_fixed_assets.domain.errors import RuleBroken

CENT = Decimal("0.01")
ZERO = Decimal("0.00")
MAX_AMOUNT = Decimal("9999999999999999.99")        # NUMERIC(18,2)
MAX_UNITS = Decimal("999999999999999.9999")        # NUMERIC(19,4)
MAX_LIFE = 1200                                    # periods (100 years)

AREA_PURPOSES = ("book", "ifrs", "ledger_specific", "tax")
VALUE_MODELS = ("cost", "revaluation")
METHODS = ("straight_line", "declining_balance", "declining_then_straight", "units_of_production", "immediate", "none")
CONVENTIONS = ("full_month", "next_month", "mid_month", "daily", "half_year")
POOLS = ("main", "special_rate", "single_asset", "sba", "none")
CLAIMS = ("aia", "full_expensing", "fya_50", "pool", "single_asset", "sba", "none")
RESULTS = ("found", "not_found", "damaged", "found_unlisted")

# Asset transaction kinds. `value_date` is a fact for acquisition / retirement; the rest are dated by their own document.
KINDS = ("acquisition", "grant", "settlement", "transfer", "retirement", "revaluation", "impairment",
         "impairment_reversal", "takeover", "reversal")


class AssetState:
    PLANNED, ACTIVE, RETIRED, DEACTIVATED = "planned", "active", "retired", "deactivated"


class TxState:
    PROPOSED, APPROVED, POSTED, REJECTED, REVERSED = "proposed", "approved", "posted", "rejected", "reversed"


class RunState:
    SIMULATED, APPROVED, POSTED, REVERSED = "simulated", "approved", "posted", "reversed"


def money(value: Union[Decimal, Fraction, int, str]) -> Decimal:
    """Round to the cent, half up — the one rounding of this brick's money."""
    if isinstance(value, Fraction):
        n, d = value.numerator, value.denominator
        q, r = divmod(abs(n) * 100, d)
        if r * 2 >= d:
            q += 1
        return Decimal(q if n >= 0 else -q).scaleb(-2).quantize(CENT)
    return Decimal(value).quantize(CENT, rounding=ROUND_HALF_UP)


def frac(value: Union[Decimal, int, str, Fraction]) -> Fraction:
    return value if isinstance(value, Fraction) else Fraction(Decimal(value))


def bound_amount(value: Decimal, field: str) -> Decimal:
    if not value.is_finite() or abs(value) > MAX_AMOUNT:
        raise RuleBroken(f"{field} does not fit a money amount (at most 16 digits before the point)")
    if value.as_tuple().exponent < -2:
        raise RuleBroken(f"{field} has more than two decimal places")
    return value


def bound_units(value: Decimal, field: str) -> Decimal:
    if not value.is_finite() or abs(value) > MAX_UNITS:
        raise RuleBroken(f"{field} does not fit a unit count (at most 15 digits before the point)")
    if value.as_tuple().exponent < -4:
        raise RuleBroken(f"{field} has more than four decimal places")
    return value


def bound_life(life: Optional[int], field: str = "life_periods") -> Optional[int]:
    if life is None:
        return None
    if life < 1 or life > MAX_LIFE:
        raise RuleBroken(f"{field} is a number of periods from 1 to {MAX_LIFE}")
    return life


def bound_rate(rate: Optional[Decimal], field: str = "rate_pct") -> Optional[Decimal]:
    if rate is None:
        return None
    if not rate.is_finite() or rate < 0 or rate > 100:
        raise RuleBroken(f"{field} is a percentage from 0 to 100")
    if rate.as_tuple().exponent < -4:
        raise RuleBroken(f"{field} has more than four decimal places")
    return rate


# ── the second person ─────────────────────────────────────────────────────────────────────────

def ids_of(email: Optional[str], sub: Optional[str]) -> frozenset:
    """Every identity a person has, lower-case: the same person under either is the same person."""
    return frozenset(x.strip().lower() for x in (email, sub) if x and x.strip())


def same_person(email_a: Optional[str], sub_a: Optional[str], email_b: Optional[str], sub_b: Optional[str]) -> bool:
    """True when ANY identity of the first matches ANY identity of the second (email vs email, sub vs sub, and an
    email that is the other's sub). Approving under a second login of the same email or the same sub is the same person."""
    return bool(ids_of(email_a, sub_a) & ids_of(email_b, sub_b))


# ── readiness: {gate, met, reason, fix} ───────────────────────────────────────────────────────

@dataclass(frozen=True)
class Gate:
    gate: str
    met: bool
    reason: str = ""
    fix: str = ""

    def as_dict(self) -> dict:
        return {"gate": self.gate, "met": self.met, "reason": self.reason, "fix": self.fix}


def met(gate: str) -> Gate:
    return Gate(gate, True)


def unmet(gate: str, reason: str, fix: str) -> Gate:
    return Gate(gate, False, reason, fix)


def unmet_gates(gates: Iterable[Gate]) -> list:
    return [g for g in gates if not g.met]


# ── periods: "YYYY-MM" ────────────────────────────────────────────────────────────────────────

def period_of(day: date) -> str:
    return f"{day.year:04d}-{day.month:02d}"


def period_index(period: str) -> int:
    y, m = period.split("-")
    return int(y) * 12 + int(m) - 1


def period_from_index(i: int) -> str:
    return f"{i // 12:04d}-{i % 12 + 1:02d}"


def period_add(period: str, n: int) -> str:
    return period_from_index(period_index(period) + n)


def period_start(period: str) -> date:
    y, m = period.split("-")
    return date(int(y), int(m), 1)


def period_end(period: str) -> date:
    y, m = period.split("-")
    return date(int(y), int(m), calendar.monthrange(int(y), int(m))[1])


def days_in(period: str) -> int:
    return calendar.monthrange(int(period[:4]), int(period[5:]))[1]


def fiscal_year(period: str, fy_end_month: int = 12) -> int:
    """The fiscal year a period belongs to, named by the calendar year it ENDS in."""
    y, m = int(period[:4]), int(period[5:])
    return y if m <= fy_end_month else y + 1


def fiscal_year_of(day: date, fy_end_month: int = 12) -> int:
    return fiscal_year(period_of(day), fy_end_month)


def fy_last_period(fy: int, fy_end_month: int = 12) -> str:
    return f"{fy:04d}-{fy_end_month:02d}"


def fy_first_period(fy: int, fy_end_month: int = 12) -> str:
    return period_add(fy_last_period(fy, fy_end_month), -11)


def parse_period(text: str, field: str = "period_id") -> str:
    try:
        y, m = text.split("-")
        if len(y) != 4 or len(m) != 2 or not (1 <= int(m) <= 12):
            raise ValueError
        int(y)
    except ValueError:
        raise RuleBroken(f"{field} is a period like 2026-10") from None
    return text