construction.cis-deduction
Construction Industry Scheme deduction (0, 20 or 30%) on the labour part of a subcontractor payment.
1.0.1 (not the latest) · 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 onlycis_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 labourcis_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
| payment | Money | the payment excluding VAT |
| materials | Money | materials, consumable stores, non-travel fuel, plant hire and prefabricated materials within the payment, excluding VAT |
| status | CisStatus | gross, standard (registered and matched) or higher (unregistered or unmatched) |
| on_date | date | the date of payment, which decides the rate |
| mode | RoundingMode | how to round the deduction to a penny; HMRC publishes no rule, down never over-deducts |
| returns | CisDeduction |
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
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
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./construction.cis-deduction-1.0.1-python.fune, or fetch it from a terminal with fune pull construction.cis-deduction@1.0.1:python.
The whole function, every language, is one file too: construction.cis-deduction-1.0.1.fune, 32,852 bytes, sha256 b435f79ea4a7b39e44bd5fcff16d691bcc4ab19faae7720859ae088a3d55e3e4. 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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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.
Files
| Path | Bytes |
|---|---|
| README.md | 2,947 |
| data/cis-rates.json | 753 |
| impl/python.py | 3,014 |
| impl/rust.rs | 4,288 |
| impl/typescript.ts | 2,950 |
| vectors.json | 12,750 |