construction.cis-deduction
Construction Industry Scheme deduction (0, 20 or 30%) on the labour part of a subcontractor payment.
1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra
Pinned by 22 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
cisDeduction(£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 onlycisDeduction(£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 labourcisDeduction(£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.
export function cisDeduction(payment: Money, materials: Money, status: CisStatus, onDate: string, 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) |
| onDate | 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
export type CisStatus = "gross" | "standard" | "higher";
/** The payment split into what the deduction is taken from and what is paid. */
export interface CisDeduction {
/** the payment excluding VAT, as passed */
readonly payment: Money;
/** the part the deduction does not apply to */
readonly materials: Money;
/** payment less materials: what the rate is applied to */
readonly labour: Money;
/** the rate applied, 2000 = 20% */
readonly basisPoints: number;
/** withheld and paid to HMRC */
readonly deduction: Money;
/** payment less deduction, before any VAT is added */
readonly netPayment: Money;
}
Your code names it in one line, in the file that uses it
import { cisDeduction } from "#fune/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 { type RoundingMode } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { subtractMoney } from "./money_add.ts"; ← from money.add ^1.0.0 · built alongside by fune
import { type Money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { applyRate } from "./money_apply_rate.ts"; ← from money.apply-rate ^1.0.0 · built alongside by fune
import { CIS_RATES, CIS_RATES_HISTORY } from "./construction_cis_deduction_data.ts"; ← this capability’s own data, compiled from data/cis-rates.json into the same file by fune build
import { type CisDeduction, type CisStatus } from "./construction_cis_deduction_types.ts";
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
function rateOn(status: string, onDate: string): number {
let best: { basisPoints: number; validFrom: string } | null = null;
let earliest: string | null = null;
for (const rule of CIS_RATES) {
if (rule.status !== status) continue;
if (earliest === null || rule.validFrom < earliest) earliest = rule.validFrom;
if (onDate < rule.validFrom) continue;
if (rule.validTo !== null && onDate > rule.validTo) continue;
if (best === null || rule.validFrom > best.validFrom) best = rule;
}
if (best !== null) return best.basisPoints;
// 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" && earliest !== null && onDate < earliest) {
throw new RangeError(
`no CIS rate for ${status} on ${onDate}: this build was installed with history=${CIS_RATES_HISTORY}, ` +
`so it only carries rates from ${earliest}. Reinstall with history=full for older payments.`
);
}
throw new RangeError(`no CIS rate for ${status} on ${onDate}`);
}
/**
* 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.
*/
export function cisDeduction(payment: Money, materials: Money, status: CisStatus, onDate: string, mode: RoundingMode): CisDeduction {
if (status !== "gross" && status !== "standard" && status !== "higher") {
throw new RangeError(`unknown CIS status "${status}"`);
}
if (!ISO_DATE.test(onDate)) {
throw new RangeError(`onDate must be an ISO date (YYYY-MM-DD), received "${onDate}"`);
}
if (payment.minor < 0) {
throw new RangeError(`payment must not be negative, received ${payment.minor}`);
}
if (materials.minor < 0) {
throw new RangeError(`materials must not be negative, received ${materials.minor}`);
}
const labour = subtractMoney(payment, materials);
if (labour.minor < 0) {
throw new RangeError(`materials (${materials.minor}) cannot exceed the payment (${payment.minor})`);
}
const basisPoints = rateOn(status, onDate);
const deduction = applyRate(labour, basisPoints, mode);
return {
payment,
materials,
labour,
basisPoints,
deduction,
netPayment: subtractMoney(payment, deduction),
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the TypeScript 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 TypeScript implementation. Install it without the registry with fune add ./construction.cis-deduction-1.0.0-typescript.fune, or fetch it from a terminal with fune pull construction.cis-deduction@1.0.0:typescript.
The whole function, every language, is one file too: construction.cis-deduction-1.0.0.fune, 32,006 bytes, sha256 dd6fa7c2bb71beabab099d76fc5b21a9dcc123675e9ee6d1d06b66e2676003c0. 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 12 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" |
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
Files
| Path | Bytes |
|---|---|
| README.md | 2,855 |
| data/cis-rates.json | 753 |
| impl/python.py | 3,003 |
| impl/rust.rs | 4,288 |
| impl/typescript.ts | 2,950 |
| vectors.json | 12,098 |