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 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.
pub fn cis_deduction(payment: &Money, materials: &Money, status: &str, on_date: &str, mode: &str) -> 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 is a string in Rust, one of: "gross", "standard", "higher".
// Parameters take it as &str and results hold it as String.
/// The payment split into what the deduction is taken from and what is paid.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CisDeduction {
/// the payment excluding VAT, as passed
pub payment: Money,
/// the part the deduction does not apply to
pub materials: Money,
/// payment less materials: what the rate is applied to
pub labour: Money,
/// the rate applied, 2000 = 20%
pub basis_points: i64,
/// withheld and paid to HMRC
pub deduction: Money,
/// payment less deduction, before any VAT is added
pub net_payment: Money,
}
Your code names it in one line, in the file that uses it
fune!(construction.cis-deduction@^1); // then call cis_deduction(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::construction_cis_deduction_data::{CIS_RATES, CIS_RATES_HISTORY}; ← this capability’s own data, compiled from data/cis-rates.json into the same file by fune build
use super::money_add::subtract_money; ← from money.add ^1.0.0 · built alongside by fune
use super::money_amount::{money_from_value, money_to_value, Money}; ← from money.amount ^1.0.0 · built alongside by fune
use super::money_apply_rate::apply_rate; ← from money.apply-rate ^1.0.0 · built alongside by fune
fn is_iso_date(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.len() == 10
&& bytes[4] == b'-'
&& bytes[7] == b'-'
&& bytes
.iter()
.enumerate()
.all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit())
}
fn rate_on(status: &str, on_date: &str) -> i64 {
let mut best: Option<(i64, &'static str)> = None;
let mut earliest: Option<&'static str> = None;
for rule in CIS_RATES {
if rule.status != status {
continue;
}
if earliest.map_or(true, |e| rule.valid_from < e) {
earliest = Some(rule.valid_from);
}
if on_date < rule.valid_from {
continue;
}
if let Some(valid_to) = rule.valid_to {
if on_date > valid_to {
continue;
}
}
if best.map_or(true, |(_, from)| rule.valid_from > from) {
best = Some((rule.basis_points, rule.valid_from));
}
}
if let Some((basis_points, _)) = best {
return 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" {
if let Some(earliest) = earliest {
if on_date < earliest {
panic!(
"no CIS rate for {} on {}: this build was installed with history={}, so it only carries rates from {}. Reinstall with history=full for older payments.",
status, on_date, CIS_RATES_HISTORY, earliest
);
}
}
}
panic!("no CIS rate for {} on {}", status, on_date)
}
/// 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.
///
/// # Panics
/// Panics on an unknown status, a malformed date, a negative payment or
/// materials, materials above the payment, mixed currencies, or a date no rate
/// covers.
pub fn cis_deduction(payment: &Money, materials: &Money, status: &str, on_date: &str, mode: &str) -> CisDeduction {
if status != "gross" && status != "standard" && status != "higher" {
panic!("unknown CIS status \"{}\"", status);
}
if !is_iso_date(on_date) {
panic!("onDate must be an ISO date (YYYY-MM-DD), received \"{}\"", on_date);
}
if payment.minor < 0 {
panic!("payment must not be negative, received {}", payment.minor);
}
if materials.minor < 0 {
panic!("materials must not be negative, received {}", materials.minor);
}
let labour = subtract_money(payment, materials);
if labour.minor < 0 {
panic!(
"materials ({}) cannot exceed the payment ({})",
materials.minor, payment.minor
);
}
let basis_points = rate_on(status, on_date);
let deduction = apply_rate(&labour, basis_points, mode);
CisDeduction {
payment: payment.clone(),
materials: materials.clone(),
labour,
basis_points,
net_payment: subtract_money(payment, &deduction),
deduction,
}
}
pub fn cis_deduction_to_value(d: &CisDeduction) -> Value {
Value::obj(vec![
("payment", money_to_value(&d.payment)),
("materials", money_to_value(&d.materials)),
("labour", money_to_value(&d.labour)),
("basisPoints", Value::Int(d.basis_points)),
("deduction", money_to_value(&d.deduction)),
("netPayment", money_to_value(&d.net_payment)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
cis_deduction_to_value(&cis_deduction(
&money_from_value(&args[0]),
&money_from_value(&args[1]),
args[2].as_str(),
args[3].as_str(),
args[4].as_str(),
))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. 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 Rust implementation. Install it without the registry with fune add ./construction.cis-deduction-1.0.2-rust.fune, or fetch it from a terminal with fune pull construction.cis-deduction@1.0.2:rust.
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.
| 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.
## 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
| Path | Bytes |
|---|---|
| NOTICE | 189 |
| README.md | 3,205 |
| data/cis-rates.json | 753 |
| impl/python.py | 3,014 |
| impl/rust.rs | 4,288 |
| impl/typescript.ts | 2,950 |
| vectors.json | 12,750 |