Functional Weave
Code in Python

payroll.income-tax@1.0.0

impl/python.py

8,636 bytes · the Python implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import re
from typing import List, NoReturn, Sequence

from .math_round_div import round_div  ← from math.round-div ^1.0.0 · built alongside by fune
from .money_amount import Money, money  ← from money.amount ^1.0.0 · built alongside by fune
from .payroll_income_tax_data import INCOME_TAX_BANDS, INCOME_TAX_BANDS_HISTORY, OVERRIDING_LIMIT, OVERRIDING_LIMIT_HISTORY, IncomeTaxBand  ← this capability’s own data, compiled from data/income-tax-bands.json into the same file by fune build
from .payroll_income_tax_types import IncomeTax
from .payroll_tax_code_parse import parse_tax_code  ← from payroll.tax-code-parse ^1.0.0 · built alongside by fune
from .payroll_tax_period_types import PayFrequency

ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")

# HMRC's routines work "to 4 decimal places of a pound without correcting the
# final place". Holding amounts as integer ten-thousandths of a pound (1 = 0.0001
# pounds, 100 = 1p) makes every one of those truncations an exact integer division.
UNITS_PER_PENNY = 100
UNITS_PER_POUND = 10000


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 _no_rule(what: str, on_date: str, history: str, froms: Sequence[str]) -> NoReturn:
    # A build installed with history=current only carries the rules still in
    # force; answering an older date with this year's bands would be a quiet,
    # plausible wrong answer, so say why there is nothing instead.
    if history != "full" and len(froms) > 0:
        earliest = min(froms)
        if on_date < earliest:
            raise ValueError(
                "no %s on %s: this build was installed with history=%s, so it only carries rules from %s. "
                "Reinstall with history=full for earlier tax years." % (what, on_date, history, earliest)
            )
    raise ValueError("no %s on %s" % (what, on_date))


def _bands_for(region: str, on_date: str) -> List[IncomeTaxBand]:
    rows = [b for b in INCOME_TAX_BANDS if b.region == region and _in_force(b, on_date)]
    if len(rows) == 0:
        _no_rule(
            "income tax bands for %s" % (region,),
            on_date,
            INCOME_TAX_BANDS_HISTORY,
            [b.valid_from for b in INCOME_TAX_BANDS if b.region == region],
        )
    return sorted(rows, key=lambda b: b.band)


def _overriding_limit(on_date: str) -> int:
    for row in OVERRIDING_LIMIT:
        if _in_force(row, on_date):
            return row.basis_points
    _no_rule("PAYE overriding limit", on_date, OVERRIDING_LIMIT_HISTORY, [r.valid_from for r in OVERRIDING_LIMIT])


def _period_allowance(code_number: int, periods_per_year: int) -> int:
    """Free pay (or K-code additional pay) for one week or month, in pence: HMRC paragraph 4.3.1."""
    if code_number == 0:
        return 0
    # Codes above 500 are split into blocks of 500 and a remainder of 1-500, each
    # rounded up to the penny separately; that is how the printed tables were
    # built, and computing the whole code in one division is a penny out.
    blocks = (code_number - 1) // 500
    remainder = (code_number - 1) % 500 + 1
    block_value = 9616 if periods_per_year == 52 else 41667
    return round_div((remainder * 10 + 9) * 100, periods_per_year, "up") + blocks * block_value


def _banded_tax(taxable_pence: int, bands: Sequence[IncomeTaxBand], n: int, periods_per_year: int) -> int:
    """Tax due to date on positive taxable pay by the banded Tax Formulae of paragraph 4.4, in pence."""
    taxable_pounds = round_div(taxable_pence, 100, "down")
    previous_threshold = 0
    previous_threshold_tax = 0
    lower_pounds = 0
    cumulative_annual_tax = 0
    for band in bands:
        if band.up_to is not None:
            threshold = round_div(band.up_to * UNITS_PER_POUND * n, periods_per_year, "down")
            # The income test compares the unrounded pay with the threshold
            # rounded UP to a whole pound (the round-pound limits of Tables C),
            # while the formula itself uses the exact threshold.
            cvalue = round_div(threshold, UNITS_PER_POUND, "up")
            if taxable_pence > cvalue * 100:
                cumulative_annual_tax += (band.up_to - lower_pounds) * band.basis_points
                lower_pounds = band.up_to
                previous_threshold = threshold
                previous_threshold_tax = round_div(cumulative_annual_tax * n, periods_per_year, "down")
                continue
        at_this_rate = round_div((taxable_pounds * UNITS_PER_POUND - previous_threshold) * band.basis_points, 10000, "down")
        return round_div(previous_threshold_tax + at_this_rate, UNITS_PER_PENNY, "down")
    raise ValueError("income tax bands have no top band")


def _check_gbp(name: str, amount: Money) -> None:
    if amount.currency != "GBP":
        raise ValueError("payroll amounts must be in GBP, received %s for %s" % (amount.currency, name))


def income_tax(
    tax_code: str,
    frequency: PayFrequency,
    period: int,
    pay: Money,
    previous_pay_to_date: Money,
    previous_tax_to_date: Money,
    pay_date: str,
) -> IncomeTax:
    """PAYE income tax for one payment, following HMRC's "Specification for PAYE
    tax table routines" (the computerised form of the tax tables, used by
    payroll software): cumulative or week 1 / month 1, suffix, K, BR, D and NT
    codes, Scottish and Welsh bands, and the 50% overriding limit.
    """
    _check_gbp("pay", pay)
    _check_gbp("previousPayToDate", previous_pay_to_date)
    _check_gbp("previousTaxToDate", previous_tax_to_date)
    if not isinstance(pay_date, str) or not ISO_DATE.match(pay_date):
        raise ValueError('payDate must be an ISO date (YYYY-MM-DD), received "%s"' % (pay_date,))
    if frequency == "monthly":
        weeks_in_period, valid_period = 1, 1 <= period <= 12
    elif frequency == "weekly":
        weeks_in_period, valid_period = 1, 1 <= period <= 53
    elif frequency == "fortnightly":
        weeks_in_period, valid_period = 2, 1 <= period <= 52 or period == 54
    elif frequency == "four-weekly":
        weeks_in_period, valid_period = 4, 1 <= period <= 52 or period == 56
    else:
        raise ValueError('unknown pay frequency "%s"' % (frequency,))
    if isinstance(period, bool) or not isinstance(period, int) or not valid_period:
        raise ValueError("period %s is not a tax period for %s pay" % (period, frequency))

    code = parse_tax_code(tax_code)
    # Looked up even when no band is needed (NT, pay under the allowance), so a
    # date outside the tax years on file is always refused.
    bands = _bands_for(code.region, pay_date)
    periods_per_year = 12 if frequency == "monthly" else 52
    # Weeks 53, 54 and 56 are always taxed on a week 1 basis (paragraph 14),
    # using the week 1, 2 or 4 figures.
    cumulative = code.cumulative and period <= 52
    n = period if cumulative else weeks_in_period
    pay_to_date_for_tax = previous_pay_to_date.minor + pay.minor if cumulative else pay.minor

    liability = 0
    allowance_to_date = 0
    taxable_pay = 0
    if code.kind in ("allowance", "negative-allowance"):
        per_period = _period_allowance(code.number, periods_per_year) * n
        allowance_to_date = per_period if code.kind == "allowance" else -per_period
        taxable_pence = pay_to_date_for_tax - allowance_to_date
        if taxable_pence > 0:
            taxable_pay = round_div(taxable_pence, 100, "down") * 100
            liability = _banded_tax(taxable_pence, bands, n, periods_per_year)
    elif code.kind in ("basic-rate", "d-rate"):
        basic = next((i for i, b in enumerate(bands) if b.basic_rate), -1)
        index = basic if code.kind == "basic-rate" else basic + 1 + code.number
        if basic < 0 or index >= len(bands):
            raise ValueError("no %s rate for %s on %s" % (code.code, code.region, pay_date))
        pounds = round_div(pay_to_date_for_tax, 100, "down") if pay_to_date_for_tax > 0 else 0
        taxable_pay = pounds * 100
        liability = round_div(pounds * bands[index].basis_points, 100, "down")

    # The overriding limit: no more than 50% of this payment may go in tax. It
    # never restricts a refund, and on negative pay it is zero (paragraph 4.5.4).
    limit = round_div(pay.minor * _overriding_limit(pay_date), 10000, "down") if pay.minor > 0 else 0
    due = liability - previous_tax_to_date.minor if cumulative else liability
    tax = limit if due > limit else due

    return IncomeTax(
        tax=money(tax, "GBP"),
        tax_to_date=money(previous_tax_to_date.minor + tax, "GBP"),
        pay_to_date=money(previous_pay_to_date.minor + pay.minor, "GBP"),
        allowance_to_date=money(allowance_to_date, "GBP"),
        taxable_pay=money(taxable_pay, "GBP"),
        cumulative=cumulative,
        limit_applied=due > limit,
        tax_not_deducted=money(due - limit if due > limit else 0, "GBP"),
    )