Functional Weave
Code in TypeScript

hospitality.cancellation-charge

Cancellation fee for a booking from a policy of notice bands and the days of notice given.

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

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

What it does

The fee a booking policy charges for cancelling with a given amount of notice. A policy is a list of bands, for example:

| notice | charge | |---|---| | 14 days or more | free | | 7 to 13 days | 50% of the first night | | under 7 days, or a no-show | 100% of the whole stay |

For example

  • cancellationCharge(policy ×3, £120.00, £120.00, £150.00, 2026-12-18, 2026-11-28) → days notice 20, min days notice 14, charged nights 3, charge base £390.00, rate 0%, charge £0.00 20 days' notice is free
  • cancellationCharge(policy ×3, £120.00, £120.00, £150.00, 2026-12-18, 2026-12-04) → days notice 14, min days notice 14, charged nights 3, charge base £390.00, rate 0%, charge £0.00 exactly 14 days' notice is still free
  • cancellationCharge(policy ×3, £120.00, £120.00, £150.00, 2026-12-18, 2026-12-05) → days notice 13, min days notice 7, charged nights 1, charge base £120.00, rate 50%, charge £60.00 13 days' notice: half the first night

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 cancellationCharge(policy: readonly CancellationBand[], nightlyRates: readonly Money[], arrival: string, cancelledOn: string): CancellationCharge
policyCancellationBand[]the venue's bands; one must cover 0 days' notice
nightlyRatesMoney[]the price of each night booked, in order; at least one
arrivaldatethe first night of the stay
cancelledOndatethe day notice was given; on or after arrival is 0 days (a no-show)
returnsCancellationCharge

The types it declares, generated into your project

/** One band of a policy: at least this much notice, this charge. */
export interface CancellationBand {
  /** the band applies from this many days' notice upwards, until a band with more */
  readonly minDaysNotice: number;
  /** charge on the first this many nights; null for the whole stay */
  readonly nights: number | null;
  /** share of those nights charged; 10000 = 100%, 0 = free */
  readonly basisPoints: number;
}

/** The band that applied and what it costs. */
export interface CancellationCharge {
  /** arrival minus cancelledOn, in days; negative after arrival */
  readonly daysNotice: number;
  /** the band that applied */
  readonly minDaysNotice: number;
  /** nights the percentage was taken of */
  readonly chargedNights: number;
  /** the price of those nights */
  readonly chargeBase: Money;
  readonly basisPoints: number;
  readonly charge: Money;
}

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

import { cancellationCharge } from "#fune/hospitality.cancellation-charge@^1";
impl/typescript.ts · 64 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 { daysBetween } from "./dates_days_between.ts";  ← from dates.days-between ^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 { sumMoney } from "./money_sum.ts";  ← from money.sum ^1.0.0 · built alongside by fune
import { type CancellationBand, type CancellationCharge } from "./hospitality_cancellation_charge_types.ts";

/**
 * The cancellation fee a policy sets for the notice given.
 *
 * Notice is counted in calendar days from the day of cancelling to the day of
 * arrival, so cancelling on 4 December for an 18 December arrival is 14 days.
 * The band with the most notice that the cancellation still meets applies. A
 * cancellation on or after the arrival day is 0 days' notice, the no-show band.
 */
export function cancellationCharge(
  policy: readonly CancellationBand[],
  nightlyRates: readonly Money[],
  arrival: string,
  cancelledOn: string,
): CancellationCharge {
  if (policy.length === 0) throw new RangeError("policy must have at least one band");
  if (nightlyRates.length === 0) throw new RangeError("nightlyRates must have at least one night");
  const seen = new Set<number>();
  for (const band of policy) {
    if (!Number.isInteger(band.minDaysNotice) || band.minDaysNotice < 0) {
      throw new RangeError(`minDaysNotice must not be negative, received ${band.minDaysNotice}`);
    }
    if (seen.has(band.minDaysNotice)) {
      throw new RangeError(`policy has two bands for ${band.minDaysNotice} days' notice`);
    }
    seen.add(band.minDaysNotice);
    if (band.nights !== null && (!Number.isInteger(band.nights) || band.nights < 1)) {
      throw new RangeError(`nights must be at least 1 or null for the whole stay, received ${band.nights}`);
    }
    if (!Number.isInteger(band.basisPoints) || band.basisPoints < 0 || band.basisPoints > 10000) {
      throw new RangeError(`basisPoints must be a whole number from 0 to 10000, received ${band.basisPoints}`);
    }
  }

  const daysNotice = daysBetween(cancelledOn, arrival);
  const effective = Math.max(daysNotice, 0);
  let chosen: CancellationBand | null = null;
  for (const band of policy) {
    if (band.minDaysNotice <= effective && (chosen === null || band.minDaysNotice > chosen.minDaysNotice)) {
      chosen = band;
    }
  }
  if (chosen === null) {
    throw new RangeError(`no band in the policy covers ${effective} days' notice`);
  }

  const chargedNights = chosen.nights === null ? nightlyRates.length : Math.min(chosen.nights, nightlyRates.length);
  const chargeBase = sumMoney(nightlyRates.slice(0, chargedNights), nightlyRates[0].currency);
  // The whole stay is checked for one currency, not only the nights charged.
  sumMoney(nightlyRates, nightlyRates[0].currency);
  return {
    daysNotice,
    minDaysNotice: chosen.minDaysNotice,
    chargedNights,
    chargeBase,
    basisPoints: chosen.basisPoints,
    charge: applyRate(chargeBase, chosen.basisPoints, "half-up"),
  };
}

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 hospitality.cancellation-charge
Download for TypeScript hospitality.cancellation-charge-1.0.0-typescript.fune · 20,031 bytes sha256 070142437836d3e0e52c28bd465dfbc4bb918b44853240b61fe3d783025fa493

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./hospitality.cancellation-charge-1.0.0-typescript.fune, or fetch it from a terminal with fune pull hospitality.cancellation-charge@1.0.0:typescript.

The whole function, every language, is one file too: hospitality.cancellation-charge-1.0.0.fune, 27,490 bytes, sha256 3ba4ab8923acd8d3ee6e170e44351889efac20e81a0702eea2b9c043ef56efa7. 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 hospitality.cancellation-charge

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

// fune: after hospitality.cancellation-charge

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 dates.days-between in hospitality.cancellation-charge
// fune: replace money.amount in hospitality.cancellation-charge
// fune: replace money.apply-rate in hospitality.cancellation-charge
// fune: replace money.sum in hospitality.cancellation-charge

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 hospitality.cancellation-charge --steps.

// fune: step hospitality.cancellation-charge 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
20 days' notice is free policy ×3, £120.00, £120.00, £150.00, 2026-12-18, 2026-11-28 → days notice 20, min days notice 14, charged nights 3, charge base £390.00, rate 0%, charge £0.00
exactly 14 days' notice is still free policy ×3, £120.00, £120.00, £150.00, 2026-12-18, 2026-12-04 → days notice 14, min days notice 14, charged nights 3, charge base £390.00, rate 0%, charge £0.00
13 days' notice: half the first night policy ×3, £120.00, £120.00, £150.00, 2026-12-18, 2026-12-05 → days notice 13, min days notice 7, charged nights 1, charge base £120.00, rate 50%, charge £60.00
exactly 7 days' notice: half the first night policy ×3, £120.00, £120.00, £150.00, 2026-12-18, 2026-12-11 → days notice 7, min days notice 7, charged nights 1, charge base £120.00, rate 50%, charge £60.00
6 days' notice: the whole stay policy ×3, £120.00, £120.00, £150.00, 2026-12-18, 2026-12-12 → days notice 6, min days notice 0, charged nights 3, charge base £390.00, rate 100%, charge £390.00
cancelling on the arrival day is 0 days' notice policy ×3, £120.00, £120.00, £150.00, 2026-12-18, 2026-12-18 → days notice 0, min days notice 0, charged nights 3, charge base £390.00, rate 100%, charge £390.00
a no-show recorded the day after arrival uses the 0-day band policy ×3, £120.00, £120.00, £150.00, 2026-12-18, 2026-12-19 → days notice -1, min days notice 0, charged nights 3, charge base £390.00, rate 100%, charge £390.00
the notice count crosses 29 February: 23 Feb to 1 Mar 2028 is 7 days policy ×3, £100.00, 2028-03-01, 2028-02-23 → days notice 7, min days notice 7, charged nights 1, charge base £100.00, rate 50%, charge £50.00
the notice count crosses a year end policy ×3, £120.00, £120.00, £150.00, 2027-01-05, 2026-12-22 → days notice 14, min days notice 14, charged nights 3, charge base £390.00, rate 0%, charge £0.00
a band for two nights on a one-night stay charges the one night policy ×1, £95.00, 2026-12-18, 2026-12-18 → days notice 0, min days notice 0, charged nights 1, charge base £95.00, rate 100%, charge £95.00
Show the other 12 tests
CaseArgumentsExpected
a band for the first two nights of three policy ×1, £120.00, £120.00, £150.00, 2026-12-18, 2026-12-18 → days notice 0, min days notice 0, charged nights 2, charge base £240.00, rate 100%, charge £240.00
a third of a night rounds half-up: 33.33% of 123.45 is 41.1459 policy ×1, £123.45, 2026-12-18, 2026-12-10 → days notice 8, min days notice 0, charged nights 1, charge base £123.45, rate 33.33%, charge £41.15
bands may be listed in any order policy ×3, £120.00, £120.00, £150.00, 2026-12-18, 2026-12-08 → days notice 10, min days notice 7, charged nights 1, charge base £120.00, rate 50%, charge £60.00
a policy with no 0-day band cannot price a late cancellation policy ×1, £120.00, £120.00, £150.00, 2026-12-18, 2026-12-17 → error: no band in the policy covers 1 days' notice
an empty policy is an error , £120.00, £120.00, £150.00, 2026-12-18, 2026-12-18 → error: policy must have at least one band
no nights is an error policy ×3, , 2026-12-18, 2026-12-18 → error: nightlyRates must have at least one night
two bands for the same notice are an error policy ×2, £120.00, £120.00, £150.00, 2026-12-18, 2026-12-18 → error: policy has two bands for 0 days' notice
a charge over 100% is an error policy ×1, £120.00, £120.00, £150.00, 2026-12-18, 2026-12-18 → error: basisPoints must be a whole number from 0 to 10000
a band of zero nights is an error policy ×1, £120.00, £120.00, £150.00, 2026-12-18, 2026-12-18 → error: nights must be at least 1 or null for the whole stay
a negative notice band is an error policy ×1, £120.00, £120.00, £150.00, 2026-12-18, 2026-12-18 → error: minDaysNotice must not be negative
mixed currencies are an error policy ×3, £1.00, €1.00, 2026-12-18, 2026-12-11 → error: currency mismatch
an impossible date is an error policy ×3, £120.00, £120.00, £150.00, 2026-02-30, 2026-12-18 → error: is not a real calendar date

More from the author

which is written as `[{minDaysNotice: 14, nights: null, basisPoints: 0}, {minDaysNotice: 7, nights: 1, basisPoints: 5000}, {minDaysNotice: 0, nights: null, basisPoints: 10000}]`.

## How notice is counted

Notice is the number of calendar days from the day the guest cancels to the day they arrive (`dates.days-between`): cancelling on 4 December for 18 December is 14 days. The band with the largest `minDaysNotice` that the notice still meets applies, so 14 days falls in the "14 days or more" band. Cancelling on the arrival day, or recording a no-show after it, is 0 days' notice. The result still reports the true signed count (-1 for the day after), so a caller can tell the two apart.

Times of day are not modelled. A policy that says "48 hours before 3pm check-in" has to be turned into whole days by the caller first.

## The charge

`nights` picks the nights the percentage is taken of: the first `n` nights (capped at the length of the stay) or, when it is `null`, the whole stay. `nightlyRates` lists the price of each night in order, so a stay whose first night is cheaper than the weekend is charged correctly. The percentage is applied once to those nights' total and rounded half-up.

## Edge cases and errors

- The bands may be listed in any order. Two bands with the same `minDaysNotice` are an error, and so is notice that no band covers (a policy should always have a 0-day band). - Every night must be in the same currency.

## Not covered

- **Fairness.** A cancellation charge is a term of a consumer contract. Under the Consumer Rights Act 2015, Part 2, an unfair term does not bind the consumer, and the CMA's guidance on unfair contract terms (CMA37) treats charges that exceed the trader's likely loss as a risk. This function applies whatever policy it is given. It does not judge whether that policy is fair. - **VAT.** How VAT applies to a retained deposit or a cancellation fee depends on HMRC's current view of early termination and cancellation payments. This function works out the amount only.

Files

PathBytes
README.md2,360
impl/python.py3,041
impl/rust.rs4,164
impl/typescript.ts2,883
vectors.json9,944