dodil/erp-brick-gl

DODIL ERP brick: general ledger

src/brick_gl/domain/schedule.py

"""Schedules that must sum EXACTLY: an accrual or deferral released over the months it covers, and the months a recurring
entry is due. The last period takes the rounding remainder, so the total released equals the registration to the penny."""
import calendar
from decimal import Decimal

from brick_gl.domain import dates
from brick_gl.domain.money import largest_remainder, q2


def periods_between(first: str, last: str) -> list:
    out, p = [], first
    while p <= last:
        out.append(p)
        p = dates.next_period(p)
        if len(out) > 1200:
            raise ValueError("a schedule runs for at most 100 years")
    return out


def release_schedule(amount: Decimal, first: str, last: str, method: str) -> dict:
    """{period_id: release}. Straight line: amount / months, each rounded to the cent, the LAST period taking the remainder.
    Daily: in proportion to the days each month holds (largest remainder), so a part month carries its part."""
    periods = periods_between(first, last)
    n = len(periods)
    if method == "daily":
        days = [calendar.monthrange(int(p[:4]), int(p[5:]))[1] for p in periods]
        shares = largest_remainder(q2(amount), [Decimal(d) for d in days])
        return dict(zip(periods, shares))
    each = (q2(amount) / n).quantize(Decimal("0.01"), rounding="ROUND_HALF_UP") if n else Decimal(0)
    out = {p: each for p in periods[:-1]}
    out[periods[-1]] = q2(amount) - sum(out.values(), Decimal(0))
    return out


def recurring_due(frequency: str, first_period: str, last_period, period: str) -> bool:
    """Is a template due in a period: monthly every month, quarterly every third from its first, yearly every twelfth."""
    if period < first_period or (last_period and period > last_period):
        return False
    offset = (int(period[:4]) - int(first_period[:4])) * 12 + int(period[5:]) - int(first_period[5:])
    return offset % {"monthly": 1, "quarterly": 3, "yearly": 12}[frequency] == 0


def posting_date(period: str, posting_day) -> object:
    start, end = dates.period_bounds(period)
    if posting_day in (None, "last", ""):
        return end
    return start.replace(day=min(int(posting_day), end.day))