Functional Weave
Code in TypeScript

insurance.no-claims-discount Unreviewed

No-claims discount at renewal: earn a year, or step back after claims on the insurer's scale, with NCD protection.

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

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

Unreviewed. This capability’s implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified actuary has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.

Not professional advice. This capability calculates insurance figures from published rules. It is a software component for developers, not financial 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 an actuary review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.

What it does

The no-claims discount (NCD, or no-claims bonus) a policyholder holds at renewal, from the years they held, the claims in the year just ended and the insurer's scale.

## The scale is the caller's

For example

  • nextNoClaimsDiscount(scale ×7, 0, 0, —) → years 1, discount basis points 30%, protection applied false a first claim-free year earns 30%
  • nextNoClaimsDiscount(scale ×7, 4, 0, —) → years 5, discount basis points 65%, protection applied false four years claim-free becomes five: 65%
  • nextNoClaimsDiscount(scale ×7, 5, 0, —) → years 6, discount basis points 65%, protection applied false six years sits on the 5-year level until 9 years

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 nextNoClaimsDiscount(scale: readonly NcdLevel[], currentYears: number, claims: number, protection: NcdProtection | null): NcdOutcome
scaleNcdLevel[]the insurer's NCD scale, from 0 years upwards
currentYearsintclaim-free years held at the start of the policy year just ended
claimsintclaims counting against NCD in that policy year
protectionNcdProtection?the protected NCD terms, or null when NCD is not protected
returnsNcdOutcome

The types it declares, generated into your project

/** One step of an NCD scale. */
export interface NcdLevel {
  /** claim-free years from which this level applies */
  readonly years: number;
  /** discount at this level, 6500 = 65% */
  readonly discountBasisPoints: number;
  /** years held after 1, 2, 3... claims from this level; more claims than listed use the last */
  readonly stepBack: readonly number[];
}

/** Protected NCD: claims up to a limit in a window leave the years untouched. */
export interface NcdProtection {
  /** the most claims the protection allows in its window, e.g. 2 in 5 years */
  readonly claimLimit: number;
  /** claims in the window, this year's included */
  readonly claimsInWindow: number;
}

/** The NCD held at renewal. */
export interface NcdOutcome {
  readonly years: number;
  readonly discountBasisPoints: number;
  /** true when protection kept the years from stepping back */
  readonly protectionApplied: boolean;
}

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

import { nextNoClaimsDiscount } from "#fune/insurance.no-claims-discount@^1";
impl/typescript.ts · 76 lines · open · raw
import { type NcdLevel, type NcdOutcome, type NcdProtection } from "./insurance_no_claims_discount_types.ts";

function wholeAtLeastZero(value: number, name: string): void {
  if (!Number.isInteger(value) || value < 0) {
    throw new RangeError(`${name} must be a whole number of 0 or more, received ${value}`);
  }
}

function levelFor(scale: readonly NcdLevel[], years: number): NcdLevel {
  let found = scale[0];
  for (const level of scale) if (level.years <= years) found = level;
  return found;
}

/**
 * The no-claims discount held at renewal.
 *
 * A claim-free year earns a year. A year with claims steps back to what the
 * scale says for the level held and the number of claims, unless protected
 * NCD covers them, in which case the years stay where they were (a year with
 * a claim earns nothing). Years keep counting past the top of the scale; the
 * discount is the highest level not above them.
 */
export function nextNoClaimsDiscount(
  scale: readonly NcdLevel[],
  currentYears: number,
  claims: number,
  protection: NcdProtection | null
): NcdOutcome {
  if (scale.length === 0) throw new RangeError("the NCD scale must not be empty");
  if (scale[0].years !== 0) throw new RangeError("the NCD scale must start at 0 years");
  for (let i = 0; i < scale.length; i++) {
    const level = scale[i];
    wholeAtLeastZero(level.years, "years");
    if (i > 0 && level.years <= scale[i - 1].years) {
      throw new RangeError("the NCD scale must be in ascending order of years");
    }
    if (!Number.isInteger(level.discountBasisPoints) || level.discountBasisPoints < 0 || level.discountBasisPoints > 10000) {
      throw new RangeError(`discountBasisPoints must be from 0 to 10000, received ${level.discountBasisPoints}`);
    }
    let previous = level.years;
    for (const back of level.stepBack) {
      wholeAtLeastZero(back, "stepBack");
      if (back > previous) {
        throw new RangeError(`stepBack from ${level.years} years must not rise above the level or the step before it`);
      }
      previous = back;
    }
  }
  wholeAtLeastZero(currentYears, "currentYears");
  wholeAtLeastZero(claims, "claims");

  let years: number;
  let protectionApplied = false;
  if (claims === 0) {
    years = currentYears + 1;
  } else {
    let covered = false;
    if (protection !== null && protection !== undefined) {
      wholeAtLeastZero(protection.claimLimit, "claimLimit");
      wholeAtLeastZero(protection.claimsInWindow, "claimsInWindow");
      if (protection.claimsInWindow < claims) {
        throw new RangeError("claimsInWindow must include this year's claims");
      }
      covered = protection.claimsInWindow <= protection.claimLimit;
    }
    if (covered) {
      years = currentYears;
      protectionApplied = true;
    } else {
      const steps = levelFor(scale, currentYears).stepBack;
      years = steps.length === 0 ? 0 : steps[Math.min(claims, steps.length) - 1];
    }
  }
  return { years, discountBasisPoints: levelFor(scale, years).discountBasisPoints, protectionApplied };
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, 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 insurance.no-claims-discount
Download for TypeScript insurance.no-claims-discount-1.0.1-typescript.fune · 32,733 bytes sha256 bbb8fa6014db30f8cee9969bd5aaf0f8168f6b6f366c84ef0e5c2f91e230390f

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./insurance.no-claims-discount-1.0.1-typescript.fune, or fetch it from a terminal with fune pull insurance.no-claims-discount@1.0.1:typescript.

The whole function, every language, is one file too: insurance.no-claims-discount-1.0.1.fune, 40,702 bytes, sha256 27923c7f066fa8f20dfcb109cdee16c333ccada33e48bdf747d60c63e1e3d800. 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 insurance.no-claims-discount

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

// fune: after insurance.no-claims-discount

replace — it requires no other capability, so there is no dependency to replace.

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 insurance.no-claims-discount --steps.

// fune: step insurance.no-claims-discount 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
a first claim-free year earns 30% scale ×7, 0, 0, — → years 1, discount basis points 30%, protection applied false
four years claim-free becomes five: 65% scale ×7, 4, 0, — → years 5, discount basis points 65%, protection applied false
six years sits on the 5-year level until 9 years scale ×7, 5, 0, — → years 6, discount basis points 65%, protection applied false
reaching 9 years: the top of the scale scale ×7, 8, 0, — → years 9, discount basis points 70%, protection applied false
years keep counting past the top; the discount stays at the top scale ×7, 12, 0, — → years 13, discount basis points 70%, protection applied false
one claim at 5 years steps back to 3 years: 50% scale ×7, 5, 1, — → years 3, discount basis points 50%, protection applied false
two claims at 5 years step back to 1 year scale ×7, 5, 2, — → years 1, discount basis points 30%, protection applied false
three claims at 5 years lose it all scale ×7, 5, 3, — → years 0, discount basis points 0%, protection applied false
more claims than the scale lists use its last step scale ×7, 5, 4, — → years 0, discount basis points 0%, protection applied false
above the top level the top level's step-back applies: 12 years, one claim, 7 years at 65% scale ×7, 12, 1, — → years 7, discount basis points 65%, protection applied false
Show the other 15 tests
CaseArgumentsExpected
between levels the level below applies: 7 years, one claim, the 5-year step to 3 scale ×7, 7, 1, — → years 3, discount basis points 50%, protection applied false
one claim at 1 year loses it scale ×7, 1, 1, — → years 0, discount basis points 0%, protection applied false
a claim with no NCD stays at 0 scale ×7, 0, 1, — → years 0, discount basis points 0%, protection applied false
protected NCD, one claim of two allowed: years kept, none earned scale ×7, 9, 1, claim limit 2, claims in window 1 → years 9, discount basis points 70%, protection applied true
protected NCD, the second allowed claim in the window is still covered scale ×7, 9, 1, claim limit 2, claims in window 2 → years 9, discount basis points 70%, protection applied true
protected NCD exceeded: the third claim in the window steps back as usual scale ×7, 9, 1, claim limit 2, claims in window 3 → years 7, discount basis points 65%, protection applied false
protection with no claims earns a year as normal scale ×7, 9, 0, claim limit 2, claims in window 0 → years 10, discount basis points 70%, protection applied false
an empty scale is refused , 3, 0, — → error: the NCD scale must not be empty
a scale must start at 0 years scale ×1, 3, 0, — → error: the NCD scale must start at 0 years
a scale out of order is refused scale ×3, 3, 0, — → error: ascending order of years
a step-back above the level is refused scale ×2, 2, 1, — → error: must not rise above the level
a discount above 100% is refused scale ×2, 1, 0, — → error: discountBasisPoints must be from 0 to 10000
negative claims are refused scale ×7, 3, -1, — → error: claims must be a whole number of 0 or more
fractional years are refused scale ×7, 2.5, 0, — → error: currentYears must be a whole number of 0 or more
a protection window without this year's claims is refused scale ×7, 5, 2, claim limit 2, claims in window 1 → error: claimsInWindow must include this year's claims

More from the author

Every insurer publishes its own scale: the discount at each number of claim-free years, and how far a claim steps you back. So the scale is an argument, not data in this package. Each level says:

- `years`: the claim-free years from which it applies (the scale starts at 0 and rises; gaps are fine: years 6 to 8 on a scale with levels at 5 and 9 get the 5-year discount); - `discountBasisPoints`: its discount; - `stepBack`: the years held after 1, 2, 3... claims in a year from this level. More claims than listed use the last entry, and an empty list means any claim goes back to 0. A step never rises above the level or the step before it.

The vectors use an illustrative scale (0, 30, 40, 50, 60, 65% up to 5 years, 70% at 9; one claim at 5 years steps back to 3), not any insurer's.

## The rules applied

- A claim-free year earns one year. Years keep counting beyond the top of the scale (UK insurers quote "9+ years"), and the discount is that of the highest level not above them. - A year with claims steps back by the level the policyholder held at the start of the year: 12 years with a top level at 9 uses the 9-year steps. - **Protected NCD**: when `claimsInWindow` (this year's claims included) is within `claimLimit` (commonly 2 claims in 5 years), the years stay as they were. The claim year earns no extra year, which is how protection is usually sold: it protects the discount, not the premium. Beyond the limit the normal step-back applies. Some insurers do let a protected claim year earn; add one to `years` if yours does.

Which claims count against NCD (a non-fault claim where costs are fully recovered usually does not) is for the caller to decide before calling. Applying the discount to a premium is `money.apply-rate` or `insurance.rating-factors`.

## Before you rely on this

**Not professional advice.** This capability calculates insurance figures from published rules. It is a software component for developers, not financial 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 above, and have an actuary review how you use it, before anyone relies on the output. Provided "as is" under its licence, without warranty.

**Unreviewed.** This capability's implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified actuary has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.

1.0.1 marks it unreviewed. The code and the tests are unchanged.

Files

PathBytes
README.md3,108
impl/python.py3,125
impl/rust.rs4,525
impl/typescript.ts3,038
vectors.json20,338