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 freecancellationCharge(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 freecancellationCharge(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
| policy | CancellationBand[] | the venue's bands; one must cover 0 days' notice |
| nightlyRates | Money[] | the price of each night booked, in order; at least one |
| arrival | date | the first night of the stay |
| cancelledOn | date | the day notice was given; on or after arrival is 0 days (a no-show) |
| returns | CancellationCharge |
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";
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 2,360 |
| impl/python.py | 3,041 |
| impl/rust.rs | 4,164 |
| impl/typescript.ts | 2,883 |
| vectors.json | 9,944 |