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