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])