import re from typing import List, NoReturn, Sequence from .math_round_div import round_div from .money_amount import Money, money from .payroll_income_tax_data import INCOME_TAX_BANDS, INCOME_TAX_BANDS_HISTORY, OVERRIDING_LIMIT, OVERRIDING_LIMIT_HISTORY, IncomeTaxBand from .payroll_income_tax_types import IncomeTax from .payroll_tax_code_parse import parse_tax_code 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"), )