import re from typing import Optional, Tuple from .fleet_mileage_allowance_data import AMAP_RATES, AMAP_RATES_HISTORY from .fleet_mileage_allowance_types import MileageAllowance, MileageVehicle from .money_add import subtract_money from .money_amount import Money, money ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$") def _rate_on(vehicle: str, on_date: str): best = None earliest: Optional[str] = None for rule in AMAP_RATES: if rule.vehicle != vehicle: continue if earliest is None or rule.valid_from < earliest: earliest = rule.valid_from if on_date < rule.valid_from: continue if rule.valid_to is not None and on_date > rule.valid_to: continue if best is None or rule.valid_from > best.valid_from: best = rule if best is not None: return best # A history=current build has dropped the old rows; refuse rather than pay an # old claim at today's rate. if AMAP_RATES_HISTORY != "full" and earliest is not None and on_date < earliest: raise ValueError( "no approved mileage rate for %s on %s: this build was installed with history=%s, " "so it only carries rates from %s. Reinstall with history=full for older claims." % (vehicle, on_date, AMAP_RATES_HISTORY, earliest) ) raise ValueError("no approved mileage rate for %s on %s" % (vehicle, on_date)) def _whole_miles(name: str, value: int) -> None: if isinstance(value, bool) or not isinstance(value, int) or value < 0: raise ValueError("%s must be a whole number of miles, not negative, received %s" % (name, value)) def _gbp(name: str, value: Money) -> None: if value.currency != "GBP": raise ValueError("%s must be in GBP, received %s" % (name, value.currency)) if value.minor < 0: raise ValueError("%s must not be negative, received %d" % (name, value.minor)) def _excess_and_shortfall(paid: Money, approved: Money) -> Tuple[Money, Money]: difference = subtract_money(paid, approved) if difference.minor > 0: return difference, money(0, "GBP") return money(0, "GBP"), money(-difference.minor, "GBP") def mileage_allowance( vehicle: MileageVehicle, previous_miles: int, miles: int, passenger_miles: int, paid: Money, passenger_paid: Money, on_date: str, ) -> MileageAllowance: """HMRC approved mileage allowance payments for business miles in an employee's own vehicle. The 10,000-mile threshold is per tax year, so a claim that crosses it is split: previous_miles says where in the year this claim starts. Paying a flat 45p (or 55p) on every mile past 10,000 is the usual mistake; the excess over 25p is taxable. """ if vehicle not in ("car-van", "motorcycle", "bicycle"): raise ValueError('unknown vehicle "%s": use car-van, motorcycle or bicycle' % (vehicle,)) _whole_miles("previousMiles", previous_miles) _whole_miles("miles", miles) _whole_miles("passengerMiles", passenger_miles) _gbp("paid", paid) _gbp("passengerPaid", passenger_paid) if not isinstance(on_date, str) or not ISO_DATE.match(on_date): raise ValueError('onDate must be an ISO date (YYYY-MM-DD), received "%s"' % (on_date,)) if vehicle != "car-van" and (passenger_miles > 0 or passenger_paid.minor > 0): raise ValueError("passenger payments apply only to cars and vans") rate = _rate_on(vehicle, on_date) miles_at_first_rate = miles miles_at_second_rate = 0 if rate.threshold_miles is not None: first_left = max(0, rate.threshold_miles - previous_miles) miles_at_first_rate = min(miles, first_left) miles_at_second_rate = miles - miles_at_first_rate approved = money( miles_at_first_rate * rate.first_rate_pence + miles_at_second_rate * rate.second_rate_pence, "GBP" ) taxable_excess, relief = _excess_and_shortfall(paid, approved) passenger_approved = money(0, "GBP") if vehicle == "car-van": passenger_approved = money(passenger_miles * _rate_on("passenger", on_date).first_rate_pence, "GBP") # There is no relief for passenger payments below 5p, only tax above it. passenger_taxable_excess, _ = _excess_and_shortfall(passenger_paid, passenger_approved) return MileageAllowance( approved=approved, paid=paid, taxable_excess=taxable_excess, relief=relief, miles_at_first_rate=miles_at_first_rate, miles_at_second_rate=miles_at_second_rate, first_rate_pence=rate.first_rate_pence, second_rate_pence=rate.second_rate_pence, passenger_approved=passenger_approved, passenger_taxable_excess=passenger_taxable_excess, )