dodil/erp-brick-expenses

DODIL ERP brick: expenses

src/brick_expenses/domain/rates.py

"""Mileage and daily allowance by the country's rules (EXP-03, EXP-04): the rates are dated ROWS (exp_rate), the arithmetic is here.

Plain Python. Code never knows a country: which rate, which band, where the tax year starts, which share of the day's allowance a provided meal
takes off - all of it is on the row, with the statute that says so. What this module decides is only how rows turn into money:

  mileage    miles in the tax year fill the bands in order (0.45 up to 10,000 approved business miles, 0.25 after); the cumulative before the
             claim is derived from approved lines, never stored; the line is rounded ONCE at the end (a split shown in parts is not summed rounded)
  per diem   a day is a full day (24 hours away), an arrival or departure day, or - on a trip inside one day - a partial day when it lasts more
             than 8 hours; each meal the employer provided takes its share of the FULL-day rate off that day, and a day never goes below 0.00
"""
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Optional

from brick_expenses.domain.errors import RuleBroken
from brick_expenses.domain.money import ZERO, money

KINDS = ("mileage", "per_diem")
MEALS = ("breakfast", "lunch", "dinner")
MILES_MAX = Decimal("100000")
TRIP_MAX_DAYS = 366


@dataclass(frozen=True)
class Rate:
    kind: str
    country: str
    variant: str
    band: int
    valid_from: date
    rate_amount: Decimal                              # NUMERIC(19,4): per mile / km, or per 24 hours
    currency: str
    band_upto: Optional[Decimal] = None               # mileage: the cumulative miles/km in the tax year this band reaches (None: no end)
    unit: str = ""                                    # mile | km
    partial_day_amount: Optional[Decimal] = None      # per diem: an arrival / departure day, or more than 8 hours
    breakfast_pct: Decimal = ZERO                     # per diem: share of rate_amount a provided meal takes off the day
    lunch_pct: Decimal = ZERO
    dinner_pct: Decimal = ZERO
    year_start: str = "01-01"                         # MM-DD: where this country's tax year begins (UK 04-06)
    source: str = ""                                  # the statute or authority named


def in_force(rows: list, kind: str, country: str, variant: str, on: date) -> list:
    """The bands in force on a date for (kind, country, variant): per band the row with the latest valid_from on or before it; best band first."""
    best: dict = {}
    for r in rows:
        if (r.kind, r.country, r.variant) != (kind, country, variant) or r.valid_from > on:
            continue
        if r.band not in best or r.valid_from > best[r.band].valid_from:
            best[r.band] = r
    return [best[b] for b in sorted(best)]


def tax_year(on: date, year_start: str) -> tuple:
    """(first day, last day) of the tax year `on` falls in, for a year that starts on MM-DD."""
    m, d = (int(x) for x in year_start.split("-"))
    start = date(on.year, m, d)
    if on < start:
        start = date(on.year - 1, m, d)
    nxt = date(start.year + 1, m, d)
    return start, nxt - timedelta(days=1)


def miles_ok(miles: Decimal) -> None:
    if not miles.is_finite() or miles.as_tuple().exponent < -4:
        raise RuleBroken("miles or kilometres have at most four decimal places")
    if not (Decimal(0) < miles <= MILES_MAX):
        raise RuleBroken(f"miles or kilometres are more than zero and at most {MILES_MAX:,.0f}")


def mileage(miles: Decimal, cumulative: Decimal, bands: list) -> tuple:
    """(amount, split): the money for `miles` driven after `cumulative` approved miles of the tax year, band by band. Rounded once, half up."""
    if not bands:
        raise RuleBroken("no mileage rate is in force")
    remaining, start, exact, split = miles, cumulative, ZERO, []
    for b in bands:
        if remaining <= 0:
            break
        if b.band_upto is not None and start >= b.band_upto:
            continue
        take = remaining if b.band_upto is None else min(remaining, b.band_upto - start)
        part = take * b.rate_amount
        exact += part
        split.append({"band": b.band, "miles": f"{take:.4f}", "rate": f"{b.rate_amount:.4f}", "amount": f"{part:.4f}"})
        start, remaining = start + take, remaining - take
    if remaining > 0:
        raise RuleBroken(f"the mileage bands end at {start:,.0f}: {remaining} more miles have no rate")
    return money(exact), split


def per_diem(trip_from: datetime, trip_to: datetime, provided: list, r: Rate) -> tuple:
    """(amount, days) for a trip: `provided` is [(date, meal)] the employer paid for. Each day: its base (full / partial / none) less the provided
    meals' shares of the FULL-day rate, never below zero; the trip's allowance is the sum, rounded once."""
    if trip_to <= trip_from:
        raise RuleBroken("the trip ends before it starts")
    if trip_to - trip_from > timedelta(days=TRIP_MAX_DAYS):
        raise RuleBroken(f"a trip is at most {TRIP_MAX_DAYS} days: split it")
    full, partial = r.rate_amount, r.partial_day_amount if r.partial_day_amount is not None else ZERO
    first, last = trip_from.date(), trip_to.date()
    days: list = []
    if first == last:
        hours = Decimal((trip_to - trip_from).total_seconds()) / Decimal(3600)
        days.append({"date": first, "kind": "same_day" if hours > 8 else "under_8_hours", "base": partial if hours > 8 else ZERO})
    else:
        d = first
        while d <= last:
            kind = "arrival" if d == first else "departure" if d == last else "full"
            days.append({"date": d, "kind": kind, "base": full if kind == "full" else partial})
            d += timedelta(days=1)
    pct = {"breakfast": r.breakfast_pct, "lunch": r.lunch_pct, "dinner": r.dinner_pct}
    seen = set()
    for when, meal in provided:
        if meal not in pct:
            raise RuleBroken(f"a meal is {', '.join(MEALS)}, not {meal}")
        if (when, meal) in seen:
            raise RuleBroken(f"the {meal} of {when} is listed twice")
        seen.add((when, meal))
        if not first <= when <= last:
            raise RuleBroken(f"the {meal} of {when} is outside the trip ({first} to {last})")
    total = ZERO
    out = []
    for day in days:
        cut = sum((full * pct[m] / Decimal(100) for d, m in sorted(seen) if d == day["date"]), ZERO)
        allowance = max(ZERO, day["base"] - cut)
        total += allowance
        out.append({"date": day["date"].isoformat(), "kind": day["kind"], "base": f"{day['base']:.2f}", "meals_deducted": f"{cut:.2f}", "allowance": f"{allowance:.2f}"})
    return money(total), out


def overlaps(a_from: datetime, a_to: datetime, b_from: datetime, b_to: datetime) -> bool:
    """Two trips overlap when they share any time (touching ends do not)."""
    return a_from < b_to and b_from < a_to