dodil/erp-brick-tax
DODIL ERP brick: tax
src/brick_tax/domain/schedule.py
"""The one rule every dated row obeys (module.yaml in_code_not_rows): a change starts AFTER today, in business_timezone;
a row in effect is never edited or deleted; only a key's FIRST row, which nothing in this system has used yet, may carry
an earlier date (a new installation, the Odoo migration). Pure: the caller says what today is and whether the key has rows.
"""
from datetime import date, timedelta
from typing import Optional
from brick_tax.domain.errors import RuleBroken, unmet
STARTS_AFTER_TODAY = "starts_after_today"
SECOND_PERSON = "second_person"
def start_date(valid_from: Optional[date], *, today: date, has_rows: bool, what: str, first_default: Optional[date] = None,
first_floor_today: bool = False, fix: str = "pick a later date") -> date:
"""The date a new dated row starts, or a refusal {gate: starts_after_today}.
`has_rows`: the key already has a row that is not withdrawn. Then the new row starts after today (no date given =
tomorrow). Without rows it is the key's first row: any date is allowed (a date is required unless `first_default`
stands in), except `first_floor_today` (a rule for a pair nothing covers yet) which may start today but not before.
"""
if has_rows:
if valid_from is None:
return today + timedelta(days=1)
if valid_from <= today:
raise unmet(STARTS_AFTER_TODAY, f"{what} starts tomorrow at the earliest (today is {today} in business_timezone)", fix)
return valid_from
if valid_from is None:
if first_default is None:
raise RuleBroken(f"say the date {what} starts from")
return first_default
if first_floor_today and valid_from < today:
raise unmet(STARTS_AFTER_TODAY, f"{what} for something nothing covers yet starts today at the earliest "
f"(today is {today})", fix)
return valid_from
def needs_second_person(*, control_on: bool, first_row: bool, always: bool = False) -> bool:
"""A change waits for a second holder of tax:code:approve where the owner's control asks for it. A key's first row
never does (nothing has used it); a deregistration always does (the workflow's approval)."""
return always or (control_on and not first_row)
def same_person(mine: tuple, theirs: tuple) -> bool:
"""Two people are one person if ANY identity they carry (email, sub) is the same."""
a = {x.strip().lower() for x in mine if x and x.strip()}
return bool(a & {x.strip().lower() for x in theirs if x and x.strip()})
def approvable(valid_from: date, *, today: date, author: str, approver: str, what: str, author_sub: str = "",
approver_ids: tuple = ()) -> None:
"""A pending row is approved by a second person, before its date. The same person under another identity (their sub
where the author's row names their email) is still the same person."""
if approver == author or same_person((approver, *approver_ids), (author, author_sub)):
raise unmet(SECOND_PERSON, f"the person who made the {what} cannot approve it", "ask another holder of tax:code:approve")
if valid_from <= today:
raise unmet(STARTS_AFTER_TODAY, f"the {what} should have started on {valid_from} and nobody approved it in time: "
"withdraw it and make the change again from tomorrow", "pick a later date")
def withdrawable(valid_from: date, *, today: date, what: str) -> None:
"""Only a row that has not taken effect is withdrawn; one in effect is corrected by a new row from tomorrow."""
if valid_from <= today:
raise RuleBroken(f"the {what} is already in effect (from {valid_from}): a row in effect is never edited or "
"withdrawn — add a correction that starts tomorrow")