Functional Weave
Code in Rust

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

  • cancellation_charge(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
  • cancellation_charge(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
  • cancellation_charge(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.

pub fn cancellation_charge(policy: &[CancellationBand], nightly_rates: &[Money], arrival: &str, cancelled_on: &str) -> CancellationCharge
policyCancellationBand[]the venue's bands; one must cover 0 days' notice
nightly_ratesMoney[]the price of each night booked, in order; at least one
arrivaldatethe first night of the stay
cancelled_ondatethe 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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CancellationBand {
    /// the band applies from this many days' notice upwards, until a band with more
    pub min_days_notice: i64,
    /// charge on the first this many nights; null for the whole stay
    pub nights: Option<i64>,
    /// share of those nights charged; 10000 = 100%, 0 = free
    pub basis_points: i64,
}

/// The band that applied and what it costs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CancellationCharge {
    /// arrival minus cancelledOn, in days; negative after arrival
    pub days_notice: i64,
    /// the band that applied
    pub min_days_notice: i64,
    /// nights the percentage was taken of
    pub charged_nights: i64,
    /// the price of those nights
    pub charge_base: Money,
    pub basis_points: i64,
    pub charge: Money,
}

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

fune!(hospitality.cancellation-charge@^1);  // then call cancellation_charge(…)
impl/rust.rs · 108 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.

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::dates_days_between::days_between;  ← from dates.days-between ^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
use super::money_sum::sum_money;  ← from money.sum ^1.0.0 · built alongside by fune

/// 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.
///
/// # Panics
/// Panics on an empty or malformed policy, no nights, a bad date, mixed
/// currencies, or when no band covers the notice given.
pub fn cancellation_charge(
    policy: &[CancellationBand],
    nightly_rates: &[Money],
    arrival: &str,
    cancelled_on: &str,
) -> CancellationCharge {
    if policy.is_empty() {
        panic!("policy must have at least one band");
    }
    if nightly_rates.is_empty() {
        panic!("nightlyRates must have at least one night");
    }
    let mut seen: Vec<i64> = Vec::new();
    for band in policy {
        if band.min_days_notice < 0 {
            panic!("minDaysNotice must not be negative, received {}", band.min_days_notice);
        }
        if seen.contains(&band.min_days_notice) {
            panic!("policy has two bands for {} days' notice", band.min_days_notice);
        }
        seen.push(band.min_days_notice);
        if let Some(n) = band.nights {
            if n < 1 {
                panic!("nights must be at least 1 or null for the whole stay, received {}", n);
            }
        }
        if !(0..=10000).contains(&band.basis_points) {
            panic!(
                "basisPoints must be a whole number from 0 to 10000, received {}",
                band.basis_points
            );
        }
    }

    let days_notice = days_between(cancelled_on, arrival);
    let effective = days_notice.max(0);
    let mut chosen: Option<&CancellationBand> = None;
    for band in policy {
        if band.min_days_notice <= effective
            && chosen.map_or(true, |c| band.min_days_notice > c.min_days_notice)
        {
            chosen = Some(band);
        }
    }
    let chosen = match chosen {
        Some(c) => c,
        None => panic!("no band in the policy covers {} days' notice", effective),
    };

    let stay = nightly_rates.len() as i64;
    let charged_nights = match chosen.nights {
        None => stay,
        Some(n) => n.min(stay),
    };
    let currency = nightly_rates[0].currency.clone();
    let charge_base = sum_money(&nightly_rates[..charged_nights as usize], &currency);
    // The whole stay is checked for one currency, not only the nights charged.
    sum_money(nightly_rates, &currency);
    CancellationCharge {
        days_notice,
        min_days_notice: chosen.min_days_notice,
        charged_nights,
        charge: apply_rate(&charge_base, chosen.basis_points, "half-up"),
        charge_base,
        basis_points: chosen.basis_points,
    }
}

pub fn cancellation_band_from_value(v: &Value) -> CancellationBand {
    CancellationBand {
        min_days_notice: v.get("minDaysNotice").as_i64(),
        nights: if v.get("nights").is_null() { None } else { Some(v.get("nights").as_i64()) },
        basis_points: v.get("basisPoints").as_i64(),
    }
}

pub fn cancellation_charge_to_value(c: &CancellationCharge) -> Value {
    Value::obj(vec![
        ("daysNotice", Value::Int(c.days_notice)),
        ("minDaysNotice", Value::Int(c.min_days_notice)),
        ("chargedNights", Value::Int(c.charged_nights)),
        ("chargeBase", money_to_value(&c.charge_base)),
        ("basisPoints", Value::Int(c.basis_points)),
        ("charge", money_to_value(&c.charge)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let policy: Vec<CancellationBand> = args[0].as_arr().iter().map(cancellation_band_from_value).collect();
    let rates: Vec<Money> = args[1].as_arr().iter().map(money_from_value).collect();
    cancellation_charge_to_value(&cancellation_charge(&policy, &rates, args[2].as_str(), args[3].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 hospitality.cancellation-charge
Download for Rust hospitality.cancellation-charge-1.0.0-rust.fune · 21,364 bytes sha256 0edfec855f5029440e9f7a86a7cc742a629ca1bc065d7c1d7c80bce171eb4968

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

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