import re from typing import NoReturn, Optional from .dates_days_between import days_between from .dates_uk_tax_year import uk_tax_year from .fleet_company_car_bik_data import BIK_BANDS, BIK_BANDS_HISTORY, BIK_LIMITS, BIK_LIMITS_HISTORY, FUEL_MULTIPLIERS, FUEL_MULTIPLIERS_HISTORY from .fleet_company_car_bik_types import CompanyCarBik from .math_round_div import round_div from .money_add import add_money from .money_amount import Money, money ISO_DATE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}") MAX_SAFE = 9007199254740991 # Cars first registered before this date keep their NEDC figures, and in # 2021-22 their own (one point higher) column of the table. WLTP_FROM = "2020-04-06" def _in_force(row, on_date: str) -> bool: return row.valid_from <= on_date and (row.valid_to is None or on_date <= row.valid_to) def _missing(what: str, label: str, rows, history: str, on_date: str) -> NoReturn: # A history=current build has dropped old rows; say so rather than # answering an old tax year with today's figures. if history != "full": earliest: Optional[str] = None for row in rows: if earliest is None or row.valid_from < earliest: earliest = row.valid_from if earliest is not None and on_date < earliest: raise ValueError( "no %s for tax year %s: this build was installed with history=%s, " "so it only carries figures from %s. Reinstall with history=full for earlier years." % (what, label, history, earliest) ) raise ValueError("no %s for tax year %s" % (what, label)) def _check_gbp(name: str, amount: Money) -> None: if amount.currency != "GBP": raise ValueError("company car benefit is worked in GBP; %s is in %s" % (name, amount.currency)) if amount.minor < 0: raise ValueError("%s must not be negative, received %d" % (name, amount.minor)) def _check_whole(name: str, value, unit: str) -> None: if isinstance(value, bool) or not isinstance(value, int) or value < 0: raise ValueError("%s must be a whole number of %s, not negative, received %s" % (name, unit, value)) def _check_date(name: str, value) -> None: if not isinstance(value, str) or not ISO_DATE.fullmatch(value): raise ValueError('%s must be an ISO date (YYYY-MM-DD), received "%s"' % (name, value)) def _proportion(minor: int, basis_points: int, days_available: int, days_in_year: int) -> int: # price x basis points x days available / (10000 x days in year), rounded # down to the penny: one rounding, and never more benefit than the law charges. product = minor * basis_points * days_available # Python would carry on, but TypeScript cannot, and all three must agree. if product > MAX_SAFE: raise ValueError("the benefit calculation exceeds 2^53 - 1; the list price is too large") return round_div(product, 10000 * days_in_year, "down") def company_car_bik( list_price: Money, capital_contributions: Money, co2: int, electric_range_miles: int, diesel_supplement: bool, first_registered: str, on_date: str, unavailable_days: int, private_use_contributions: Money, free_fuel: bool, ) -> CompanyCarBik: """The taxable benefit of a company car for one UK tax year (ITEPA 2003 s121). Price less capital contributions (at most 5,000.00), times the appropriate percentage for the car's CO2 figure and electric range in that year, cut pro rata for days the car was unavailable, less what the employee paid for private use. Free fuel adds the year's fuel benefit multiplier times the same percentage, cut for the same days. """ _check_gbp("listPrice", list_price) _check_gbp("capitalContributions", capital_contributions) _check_gbp("privateUseContributions", private_use_contributions) _check_whole("co2", co2, "g/km") _check_whole("electricRangeMiles", electric_range_miles, "miles") _check_date("firstRegistered", first_registered) _check_date("onDate", on_date) if diesel_supplement and co2 == 0: raise ValueError("a zero-emission car cannot take the diesel supplement") year = uk_tax_year(on_date) if first_registered > year.end: raise ValueError("firstRegistered (%s) is after the end of tax year %s" % (first_registered, year.label)) days_in_year = days_between(year.start, year.end) + 1 if ( isinstance(unavailable_days, bool) or not isinstance(unavailable_days, int) or unavailable_days < 0 or unavailable_days > days_in_year ): raise ValueError( "unavailableDays must be a whole number from 0 to %d, received %s" % (days_in_year, unavailable_days) ) days_available = days_in_year - unavailable_days registration = "before-2020-04-06" if first_registered < WLTP_FROM else "from-2020-04-06" def applies(row) -> bool: return row.registration == "any" or row.registration == registration limit = next((row for row in BIK_LIMITS if applies(row) and _in_force(row, on_date)), None) if limit is None: _missing("company car benefit rates", year.label, BIK_LIMITS, BIK_LIMITS_HISTORY, on_date) if co2 >= 75: # s139(3): one point per whole 5 g/km above 75, up to the year's maximum. basis_points = min(limit.from75_basis_points + 100 * ((co2 - 75) // 5), limit.maximum_basis_points) else: band = next( ( row for row in BIK_BANDS if applies(row) and _in_force(row, on_date) and row.co2_from <= co2 <= row.co2_to and row.range_from <= electric_range_miles and (row.range_to is None or electric_range_miles <= row.range_to) ), None, ) if band is None: _missing("company car benefit band", year.label, BIK_BANDS, BIK_BANDS_HISTORY, on_date) basis_points = band.basis_points if diesel_supplement: basis_points = min(basis_points + limit.diesel_supplement_basis_points, limit.maximum_basis_points) counted = min(capital_contributions.minor, limit.capital_contribution_cap_minor) price = money(max(list_price.minor - counted, 0), "GBP") provisional = _proportion(price.minor, basis_points, days_available, days_in_year) car_benefit = money(max(provisional - private_use_contributions.minor, 0), "GBP") fuel_benefit = money(0, "GBP") # s149(4): electricity, and energy for a car that cannot emit CO2, is not fuel. if free_fuel and co2 > 0: multiplier = next((row for row in FUEL_MULTIPLIERS if _in_force(row, on_date)), None) if multiplier is None: _missing("car fuel benefit multiplier", year.label, FUEL_MULTIPLIERS, FUEL_MULTIPLIERS_HISTORY, on_date) fuel_benefit = money(_proportion(multiplier.pounds * 100, basis_points, days_available, days_in_year), "GBP") return CompanyCarBik( tax_year=year.label, days_in_year=days_in_year, days_available=days_available, price=price, appropriate_basis_points=basis_points, car_benefit=car_benefit, fuel_benefit=fuel_benefit, total=add_money(car_benefit, fuel_benefit), )