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 freecancellation_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 freecancellation_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
| policy | CancellationBand[] | the venue's bands; one must cover 0 days' notice |
| nightly_rates | Money[] | the price of each night booked, in order; at least one |
| arrival | date | the first night of the stay |
| cancelled_on | 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
@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
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
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.
| 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 |