Files
Budget_Tool_Martinhal/budget-app/backend/app/calc.py
T

164 lines
5.5 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Amount expressions and budget arithmetic. All money math uses Decimal."""
import ast
import operator
from dataclasses import dataclass, field
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
MONTHS = 12
ZERO = Decimal("0")
_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
}
class ExpressionError(ValueError):
pass
def normalize_number_text(text: str) -> str:
"""Accept '1 234,56', '1.234,56' and '1,234.56' as well as plain numbers."""
s = text.replace(" ", "").replace(" ", "").replace("€", "")
if "," in s and "." in s:
if s.rfind(",") > s.rfind("."):
s = s.replace(".", "").replace(",", ".")
else:
s = s.replace(",", "")
elif "," in s:
s = s.replace(",", ".")
return s
def evaluate(expr: str | None) -> Decimal:
"""Evaluate a typed amount such as '=5512*12' or '3292.56+(12*839.67)'."""
if expr is None:
return ZERO
s = str(expr).strip()
if s.startswith("="):
s = s[1:]
if not s:
return ZERO
s = normalize_number_text(s)
if len(s) > 400:
raise ExpressionError("expression too long")
try:
tree = ast.parse(s, mode="eval")
except SyntaxError as exc:
raise ExpressionError(f"invalid expression: {expr}") from exc
def walk(node: ast.AST) -> Decimal:
if isinstance(node, ast.Expression):
return walk(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
return Decimal(str(node.value))
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
left, right = walk(node.left), walk(node.right)
if isinstance(node.op, ast.Div) and right == 0:
raise ExpressionError("division by zero")
return _OPS[type(node.op)](left, right)
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
value = walk(node.operand)
return -value if isinstance(node.op, ast.USub) else value
raise ExpressionError(f"invalid expression: {expr}")
try:
return walk(tree)
except InvalidOperation as exc:
raise ExpressionError(f"invalid expression: {expr}") from exc
def round2(value: Decimal) -> Decimal:
return Decimal(value).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def factor_for(adj_kind: str, adj_value: Decimal, year_rate: Decimal) -> Decimal:
adj_value = Decimal(adj_value or 0)
if adj_kind == "year_rate":
return Decimal(1) + Decimal(year_rate or 0)
if adj_kind == "percent":
return Decimal(1) + adj_value / Decimal(100)
if adj_kind == "factor":
return adj_value
return Decimal(1)
@dataclass
class LineFigures:
prior: Decimal
base: Decimal
factor: Decimal
adjusted: Decimal
months: list[Decimal]
@property
def change(self) -> Decimal:
return self.adjusted - self.prior
@property
def change_pct(self) -> Decimal | None:
return None if self.prior == 0 else self.change / self.prior * 100
def line_figures(line, year_rate: Decimal) -> LineFigures:
# Each line is rounded to cents before it is summed, as the finance reports do.
prior = round2(Decimal(line.prior_actual or 0))
if line.monthly_kind == "manual":
months = [Decimal(str(v or 0)) for v in (line.monthly_values or [])][:MONTHS]
months += [ZERO] * (MONTHS - len(months))
total = sum(months, ZERO)
return LineFigures(prior, total, Decimal(1), total, months)
base = round2(Decimal(line.base_amount or 0))
factor = factor_for(line.adj_kind, line.adj_value, year_rate)
adjusted = round2(Decimal(line.base_amount or 0) * factor)
if line.monthly_kind == "selected":
return LineFigures(prior, base, factor, adjusted, selected_spread(adjusted, line.monthly_months))
return LineFigures(prior, base, factor, adjusted, even_spread(adjusted))
def even_spread(total: Decimal) -> list[Decimal]:
"""Twelve months in cents; December takes the rounding remainder so the months add up exactly."""
month = round2(total / MONTHS)
return [month] * (MONTHS - 1) + [total - month * (MONTHS - 1)]
def clean_months(months) -> list[int]:
"""Month numbers 1-12, sorted and without repeats."""
return sorted({int(m) for m in (months or []) if 1 <= int(m) <= MONTHS})
def selected_spread(total: Decimal, months) -> list[Decimal]:
"""Equal amounts in cents over the chosen months only (1 = January); the last chosen month takes the
rounding remainder. No months chosen means all twelve."""
chosen = clean_months(months) or list(range(1, MONTHS + 1))
share = round2(total / len(chosen))
out = [ZERO] * MONTHS
for m in chosen:
out[m - 1] = share
out[chosen[-1] - 1] = total - share * (len(chosen) - 1)
return out
@dataclass
class Totals:
prior: Decimal = ZERO
base: Decimal = ZERO
adjusted: Decimal = ZERO
months: list[Decimal] = field(default_factory=lambda: [ZERO] * MONTHS)
def add(self, f: LineFigures) -> None:
self.prior += f.prior
self.base += f.base
self.adjusted += f.adjusted
self.months = [a + b for a, b in zip(self.months, f.months)]
@property
def change(self) -> Decimal:
return self.adjusted - self.prior
@property
def change_pct(self) -> Decimal | None:
return None if self.prior == 0 else self.change / self.prior * 100