dodil/erp-brick-gl
DODIL ERP brick: general ledger
src/brick_gl/domain/dates.py
"""Dates: months, financial years and the rule that no dated row takes effect today or earlier."""
import calendar
from datetime import date, timedelta
from typing import Optional
from brick_gl.domain.errors import RuleBroken
def end_of_month(d: date) -> date:
return d.replace(day=calendar.monthrange(d.year, d.month)[1])
def first_of_next_month(d: date) -> date:
return end_of_month(d) + timedelta(days=1)
def add_months(d: date, n: int) -> date:
m = d.month - 1 + n
y, m = d.year + m // 12, m % 12 + 1
return date(y, m, min(d.day, calendar.monthrange(y, m)[1]))
def period_id(d: date) -> str:
"""'YYYY-MM' (shared.yaml keys.period_id): a calendar month."""
return f"{d.year:04d}-{d.month:02d}"
def period_bounds(pid: str) -> tuple:
y, m = int(pid[:4]), int(pid[5:7])
start = date(y, m, 1)
return start, end_of_month(start)
def next_period(pid: str) -> str:
return period_id(first_of_next_month(period_bounds(pid)[0]))
def prev_period(pid: str) -> str:
return period_id(period_bounds(pid)[0] - timedelta(days=1))
def valid_period_id(pid: str) -> bool:
try:
return len(pid) == 7 and pid[4] == "-" and 1 <= int(pid[5:]) <= 12 and int(pid[:4]) >= 1900
except ValueError:
return False
def fiscal_year_of(d: date, end_month: int) -> int:
"""The year a financial year is NAMED by (the year it ends in): with a year end in March, 2026-04 is in FY2027."""
return d.year if d.month <= end_month else d.year + 1
def fy_name(year: int) -> str:
return f"FY{year}"
def fy_number(name: str) -> int:
return int(name[2:])
def fy_bounds(year: int, end_month: int) -> tuple:
"""(first day, last day) of the financial year named `year`."""
end = end_of_month(date(year, end_month, 1))
start = date(year - 1, end_month, 1)
start = first_of_next_month(start)
return start, end
def fy_periods(year: int, end_month: int) -> list:
"""[(period_id, starts_on, ends_on)] for the twelve months of the year, in order."""
start, _ = fy_bounds(year, end_month)
out = []
for i in range(12):
s = add_months(start, i)
out.append((period_id(s), s, end_of_month(s)))
return out
def check_dated(what: str, valid_from: date, today: date, first: bool) -> None:
"""A dated row (a control, an account-map row, a deactivation) starts AFTER today. The FIRST row of its key is the only
exception: it names the day the thing began (books are set up with their history), so it may start on any day. Nothing
else starts earlier: a posting made last month never changes and a past day is never rewritten."""
if first or valid_from > today:
return
raise RuleBroken(f"{what} starts tomorrow at the earliest: {valid_from} is today ({today}) or earlier, and a day "
"that has passed is never rewritten — date it from tomorrow", status=422)
def parse(v) -> Optional[date]:
if isinstance(v, date):
return v
return None if v in (None, "") else date.fromisoformat(str(v).strip()[:10])