Functional Weave
Code in Python

construction.cis-deduction

Construction Industry Scheme deduction (0, 20 or 30%) on the labour part of a subcontractor payment.

1.0.2 · published 2026-10-03 by charlie · Anterra

Pinned by 24 tests, run in TypeScript, Python and Rust.

Not professional advice. This capability calculates tax figures from published rules. It is a software component for developers, not tax advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have a tax adviser review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.

What it does

The deduction a contractor withholds under the Construction Industry Scheme from a payment to a subcontractor, and what is left to pay.

## What the rate applies to

For example

  • cis_deduction(£1,000.00, £400.00, standard, 2026-09-23, down) → payment £1,000.00, materials £400.00, labour £600.00, rate 20%, deduction £120.00, net payment £880.00 1,000.00 with 400.00 of materials at the standard rate: 20% of the 600.00 labour only
  • cis_deduction(£1,000.00, £400.00, higher, 2026-09-23, down) → payment £1,000.00, materials £400.00, labour £600.00, rate 30%, deduction £180.00, net payment £820.00 the same payment to an unmatched subcontractor: 30% of the labour
  • cis_deduction(£1,000.00, £400.00, gross, 2026-09-23, down) → payment £1,000.00, materials £400.00, labour £600.00, rate 0%, deduction £0.00, net payment £1,000.00 gross payment status: nothing withheld

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

def cis_deduction(payment: Money, materials: Money, status: CisStatus, on_date: str, mode: RoundingMode) -> CisDeduction
paymentMoneythe payment excluding VAT
materialsMoneymaterials, consumable stores, non-travel fuel, plant hire and prefabricated materials within the payment, excluding VAT
statusCisStatusgross, standard (registered and matched) or higher (unregistered or unmatched)
on_datedatethe date of payment, which decides the rate
modeRoundingModehow to round the deduction to a penny; HMRC publishes no rule, down never over-deducts
returnsCisDeduction

The types it declares, generated into your project

CisStatus = Literal["gross", "standard", "higher"]

@dataclass(frozen=True)
class CisDeduction:
    """The payment split into what the deduction is taken from and what is paid."""

    #: the payment excluding VAT, as passed
    payment: Money
    #: the part the deduction does not apply to
    materials: Money
    #: payment less materials: what the rate is applied to
    labour: Money
    #: the rate applied, 2000 = 20%
    basis_points: int
    #: withheld and paid to HMRC
    deduction: Money
    #: payment less deduction, before any VAT is added
    net_payment: Money

Your code names it in one line, in the file that uses it

from fune.construction.cis_deduction import cis_deduction  # construction.cis-deduction@^1
impl/python.py · 68 lines · open · 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 .construction_cis_deduction_data import CIS_RATES, CIS_RATES_HISTORY  ← this capability’s own data, compiled from data/cis-rates.json into the same file by fune build
from .construction_cis_deduction_types import CisDeduction, CisStatus
from .math_round_div import RoundingMode  ← from math.round-div ^1.0.0 · built alongside by fune
from .money_add import subtract_money  ← from money.add ^1.0.0 · built alongside by fune
from .money_amount import Money  ← from money.amount ^1.0.0 · built alongside by fune
from .money_apply_rate import apply_rate  ← from money.apply-rate ^1.0.0 · built alongside by fune

ISO_DATE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}")


def _rate_on(status: str, on_date: str) -> int:
    best = None
    earliest = None
    for rule in CIS_RATES:
        if rule.status != status:
            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.basis_points
    # A history=current build has dropped the old rows; say so rather than
    # answering an old payment at today's rate.
    if CIS_RATES_HISTORY != "full" and earliest is not None and on_date < earliest:
        raise ValueError(
            "no CIS 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 payments."
            % (status, on_date, CIS_RATES_HISTORY, earliest)
        )
    raise ValueError("no CIS rate for %s on %s" % (status, on_date))


def cis_deduction(payment: Money, materials: Money, status: CisStatus, on_date: str, mode: RoundingMode) -> CisDeduction:
    """The CIS deduction a contractor withholds from a subcontractor's payment.

    The rate applies only to the labour: VAT is left out by taking the payment
    net of VAT, and materials (with consumables, non-travel fuel, plant hire and
    prefabricated materials) are taken off before the rate is applied. Applying
    20% to the whole invoice is the classic over-deduction.
    """
    if status not in ("gross", "standard", "higher"):
        raise ValueError('unknown CIS status "%s"' % (status,))
    if not isinstance(on_date, str) or not ISO_DATE.fullmatch(on_date):
        raise ValueError('onDate must be an ISO date (YYYY-MM-DD), received "%s"' % (on_date,))
    if payment.minor < 0:
        raise ValueError("payment must not be negative, received %d" % (payment.minor,))
    if materials.minor < 0:
        raise ValueError("materials must not be negative, received %d" % (materials.minor,))
    labour = subtract_money(payment, materials)
    if labour.minor < 0:
        raise ValueError("materials (%d) cannot exceed the payment (%d)" % (materials.minor, payment.minor))
    basis_points = _rate_on(status, on_date)
    deduction = apply_rate(labour, basis_points, mode)
    return CisDeduction(
        payment=payment,
        materials=materials,
        labour=labour,
        basis_points=basis_points,
        deduction=deduction,
        net_payment=subtract_money(payment, deduction),
    )

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add construction.cis-deduction
Download for Python construction.cis-deduction-1.0.2-python.fune · 25,804 bytes sha256 0c0b7df8f4c3d55615c1fc28465f0e6646d7f8fbc850ff4983f98e5ce337c673

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./construction.cis-deduction-1.0.2-python.fune, or fetch it from a terminal with fune pull construction.cis-deduction@1.0.2:python.

The whole function, every language, is one file too: construction.cis-deduction-1.0.2.fune, 33,328 bytes, sha256 05e4a04b1844ab419c0e958d1e26ba9a3d67b236a001886fd0e72033e38a7696. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

# fune: before construction.cis-deduction

after — your function gets the result and the arguments, and returns the final result.

# fune: after construction.cis-deduction

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

# fune: replace math.round-div in construction.cis-deduction
# fune: replace money.add in construction.cis-deduction
# fune: replace money.amount in construction.cis-deduction
# fune: replace money.apply-rate in construction.cis-deduction

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show construction.cis-deduction --steps.

# fune: step construction.cis-deduction after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
1,000.00 with 400.00 of materials at the standard rate: 20% of the 600.00 labour only £1,000.00, £400.00, standard, 2026-09-23, down → payment £1,000.00, materials £400.00, labour £600.00, rate 20%, deduction £120.00, net payment £880.00
the same payment to an unmatched subcontractor: 30% of the labour £1,000.00, £400.00, higher, 2026-09-23, down → payment £1,000.00, materials £400.00, labour £600.00, rate 30%, deduction £180.00, net payment £820.00
gross payment status: nothing withheld £1,000.00, £400.00, gross, 2026-09-23, down → payment £1,000.00, materials £400.00, labour £600.00, rate 0%, deduction £0.00, net payment £1,000.00
labour only: 20% of 2,500.00 £2,500.00, £0.00, standard, 2026-09-23, down → payment £2,500.00, materials £0.00, labour £2,500.00, rate 20%, deduction £500.00, net payment £2,000.00
not 20% of the whole invoice: 1,500.00 with 900.00 materials withholds 120.00, not 300.00 £1,500.00, £900.00, standard, 2026-09-23, half-up → payment £1,500.00, materials £900.00, labour £600.00, rate 20%, deduction £120.00, net payment £1,380.00
123.48 at 20% is 24.696: down gives 24.69 £123.48, £0.00, standard, 2026-09-23, down → payment £123.48, materials £0.00, labour £123.48, rate 20%, deduction £24.69, net payment £98.79
123.48 at 20% is 24.696: half-up gives 24.70 £123.48, £0.00, standard, 2026-09-23, half-up → payment £123.48, materials £0.00, labour £123.48, rate 20%, deduction £24.70, net payment £98.78
100.01 at 30% is 30.003: up gives 30.01 £100.01, £0.00, higher, 2026-09-23, up → payment £100.01, materials £0.00, labour £100.01, rate 30%, deduction £30.01, net payment £70.00
all materials: no labour, no deduction £500.00, £500.00, standard, 2026-09-23, down → payment £500.00, materials £500.00, labour £0.00, rate 20%, deduction £0.00, net payment £500.00
zero payment £0.00, £0.00, higher, 2026-09-23, down → payment £0.00, materials £0.00, labour £0.00, rate 30%, deduction £0.00, net payment £0.00
Show the other 14 tests
CaseArgumentsExpected
the first day of the 2007 scheme: 20% £1,000.00, £0.00, standard, 2007-04-06, down → payment £1,000.00, materials £0.00, labour £1,000.00, rate 20%, deduction £200.00, net payment £800.00
the last day of the old scheme: 18% for a CIS4 card holder £1,000.00, £0.00, standard, 2007-04-05, down → payment £1,000.00, materials £0.00, labour £1,000.00, rate 18%, deduction £180.00, net payment £820.00
a 2003 payment to a CIS6 certificate holder: gross £1,000.00, £0.00, gross, 2003-01-15, down → payment £1,000.00, materials £0.00, labour £1,000.00, rate 0%, deduction £0.00, net payment £1,000.00
there was no higher rate before 6 April 2007 £1,000.00, £0.00, higher, 2006-06-01, down → error: no CIS rate for higher on 2006-06-01
before the 2000 rates this registry carries £1,000.00, £0.00, standard, 1999-12-31, down → error: no CIS rate for standard on 1999-12-31
materials above the payment £100.00, £100.01, standard, 2026-09-23, down → error: materials (10001) cannot exceed the payment (10000)
a negative payment -£1.00, £0.00, standard, 2026-09-23, down → error: payment must not be negative
negative materials £1.00, -£0.01, standard, 2026-09-23, down → error: materials must not be negative
payment and materials in different currencies £1.00, €0.10, standard, 2026-09-23, down → error: currency mismatch
a date that is not ISO £1.00, £0.00, standard, 23/09/2026, down → error: onDate must be an ISO date
an unknown status £1.00, £0.00, registered, 2026-09-23, down → error: unknown CIS status "registered"
an unknown rounding mode £123.48, £0.00, standard, 2026-09-23, nearest → error: unknown rounding mode "nearest"
a trailing newline after onDate is not an ISO date £1.00, £0.00, standard, 2026-09-16 , down → error: onDate must be an ISO date
Arabic-Indic digits in onDate are not an ISO date £1.00, £0.00, standard, ٢٠٢٦-09-16, down → error: onDate must be an ISO date

More from the author

Only the labour. HMRC's steps are: start from the invoice, take off VAT, take off what the subcontractor paid for materials, consumable stores, fuel (except for travelling), plant hired for the job and manufacturing or prefabricating materials, and apply the rate to what is left. So `payment` is the payment **excluding VAT**, `materials` is all of those costs together (excluding VAT where the subcontractor is VAT registered), and the rate is applied to `payment - materials`. Applying 20% to the whole invoice is the usual mistake: 1,500.00 with 900.00 of materials withholds 120.00, not 300.00.

VAT is paid on top of `netPayment` in full (or reverse charged, see `construction.vat-reverse-charge`); CIS never touches it.

## Rates

Dated rows in `data/cis-rates.json`, looked up on the payment date:

| status | from | rate | |----------|------------|------| | gross | 2007-04-06 | 0% | | standard | 2007-04-06 | 20% (registered and matched) | | higher | 2007-04-06 | 30% (not registered, or not matched on verification) | | standard | 2000-04-06 to 2007-04-05 | 18% (CIS4 card holders) | | gross | 2000-04-06 to 2007-04-05 | 0% (CIS5/CIS6 certificate holders) |

There was no 30% rate before the 2007 scheme, so `higher` before 6 April 2007 is an error, as is any date before 6 April 2000 (earlier rates, 25% to 23%, are not carried). The table declares its effective columns, so a `history=current` build refuses dates before its horizon instead of answering them at today's rate.

## Rounding

HMRC does not publish a rounding rule for the deduction (the monthly return takes it in pounds and pence), so the rounding mode is an argument. `down` never withholds more than the rate; `half-up` is the ordinary commercial choice. Whichever you use, use the same one every month.

## Edges

Materials equal to the payment leave no labour and no deduction. Materials above the payment, negative amounts and mixed currencies are errors.

## Sources

- HMRC, "What you must do as a Construction Industry Scheme (CIS) contractor: Make deductions and pay subcontractors" (rates 20%, 30%, 0% and the items taken off before the rate): https://www.gov.uk/what-you-must-do-as-a-cis-contractor/make-deductions-and-pay-subcontractors - HMRC, "Construction Industry Scheme: a guide for contractors and subcontractors (CIS 340)": https://www.gov.uk/government/publications/construction-industry-scheme-cis-340 - HMRC internal manual CISR71020, "Deductions: overview: the rate of deduction under the Construction Industry Scheme" (the rate history): https://www.gov.uk/hmrc-internal-manuals/construction-industry-scheme-reform/cisr71020

1.0.1 fixes Python accepting a trailing newline or non-ASCII digits in onDate; adds tests.

## Notices

Contains public sector information licensed under the Open Government Licence v3.0 (https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/).

1.0.2 adds its attribution notices (NOTICE). The code and the tests are unchanged.

Files

PathBytes
NOTICE189
README.md3,205
data/cis-rates.json753
impl/python.py3,014
impl/rust.rs4,288
impl/typescript.ts2,950
vectors.json12,750