Functional Weave
Code in Python

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.

def cancellation_charge(policy: Sequence[CancellationBand], nightly_rates: Sequence[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

@dataclass(frozen=True)
class CancellationBand:
    """One band of a policy: at least this much notice, this charge."""

    #: the band applies from this many days' notice upwards, until a band with more
    min_days_notice: int
    #: charge on the first this many nights; null for the whole stay
    nights: Optional[int]
    #: share of those nights charged; 10000 = 100%, 0 = free
    basis_points: int

@dataclass(frozen=True)
class CancellationCharge:
    """The band that applied and what it costs."""

    #: arrival minus cancelledOn, in days; negative after arrival
    days_notice: int
    #: the band that applied
    min_days_notice: int
    #: nights the percentage was taken of
    charged_nights: int
    #: the price of those nights
    charge_base: Money
    basis_points: int
    charge: Money

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

from fune.hospitality.cancellation_charge import cancellation_charge  # hospitality.cancellation-charge@^1
impl/python.py · 66 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.

from typing import Optional, Sequence

from .dates_days_between import days_between  ← from dates.days-between ^1.0.0 · built alongside by fune
from .hospitality_cancellation_charge_types import CancellationBand, CancellationCharge
from .money_amount import Money  ← from money.amount ^1.0.0 · built alongside by fune
from .money_apply_rate import apply_rate  ← from money.apply-rate ^1.0.0 · built alongside by fune
from .money_sum import sum_money  ← from money.sum ^1.0.0 · built alongside by fune


def _whole(value: object) -> bool:
    return isinstance(value, int) and not isinstance(value, bool)


def cancellation_charge(
    policy: Sequence[CancellationBand],
    nightly_rates: Sequence[Money],
    arrival: str,
    cancelled_on: str,
) -> CancellationCharge:
    """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.
    """
    if len(policy) == 0:
        raise ValueError("policy must have at least one band")
    if len(nightly_rates) == 0:
        raise ValueError("nightlyRates must have at least one night")
    seen = set()
    for band in policy:
        if not _whole(band.min_days_notice) or band.min_days_notice < 0:
            raise ValueError("minDaysNotice must not be negative, received %s" % (band.min_days_notice,))
        if band.min_days_notice in seen:
            raise ValueError("policy has two bands for %d days' notice" % (band.min_days_notice,))
        seen.add(band.min_days_notice)
        if band.nights is not None and (not _whole(band.nights) or band.nights < 1):
            raise ValueError("nights must be at least 1 or null for the whole stay, received %s" % (band.nights,))
        if not _whole(band.basis_points) or band.basis_points < 0 or band.basis_points > 10000:
            raise ValueError(
                "basisPoints must be a whole number from 0 to 10000, received %s" % (band.basis_points,)
            )

    days_notice = days_between(cancelled_on, arrival)
    effective = max(days_notice, 0)
    chosen: Optional[CancellationBand] = None
    for band in policy:
        if band.min_days_notice <= effective and (chosen is None or band.min_days_notice > chosen.min_days_notice):
            chosen = band
    if chosen is None:
        raise ValueError("no band in the policy covers %d days' notice" % (effective,))

    charged_nights = len(nightly_rates) if chosen.nights is None else min(chosen.nights, len(nightly_rates))
    currency = nightly_rates[0].currency
    charge_base = sum_money(list(nightly_rates[:charged_nights]), currency)
    # The whole stay is checked for one currency, not only the nights charged.
    sum_money(nightly_rates, currency)
    return CancellationCharge(
        days_notice=days_notice,
        min_days_notice=chosen.min_days_notice,
        charged_nights=charged_nights,
        charge_base=charge_base,
        basis_points=chosen.basis_points,
        charge=apply_rate(charge_base, chosen.basis_points, "half-up"),
    )

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the Python 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 Python hospitality.cancellation-charge-1.0.0-python.fune · 20,189 bytes sha256 fb1dca62bec170f8d400fb7fdb8fe282382781d0d0e55cd8a526e26150b936c7

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

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