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