hospitality.room-rate
Price a hotel stay night by night from the caller's seasonal, day-of-week rate table, less any length-of-stay discount.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 18 tests, run in TypeScript, Python and Rust.
What it does
Prices a hotel stay night by night from a rate table the caller supplies, then takes off the length-of-stay discount the stay qualifies for. The result lists every night with its date, season and price, then the subtotal, discount and total.
## The rate table is the hotel's
For example
roomRate(rates ×3, stay discounts ×2, 2026-03-30, 3)→ nights ×3, subtotal £320.00, discount basis points 5%, discount £16.00, total £304.00 three nights across the season change: two low weekdays and a high night, 5% off for 3 nightsroomRate(rates ×3, stay discounts ×2, 2026-03-27, 2)→ nights ×2, subtotal £220.00, discount basis points 0%, discount £0.00, total £220.00 Friday and Saturday nights at the weekend rate, too short for a discountroomRate(rates ×3, stay discounts ×2, 2026-03-26, 7)→ nights ×7, subtotal £720.00, discount basis points 10%, discount £72.00, total £648.00 a week: Sunday night is a weekday night, and 7 nights earns 10% rather than 5%
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 roomRate(rates: readonly NightlyRate[], stayDiscounts: readonly StayDiscount[], arrival: string, nights: number): RoomRateQuote
| rates | NightlyRate[] | the rate table; exactly one row must match each night |
| stayDiscounts | StayDiscount[] | length-of-stay discounts; [] for none |
| arrival | date | the first night of the stay |
| nights | int | how many nights, at least 1 |
| returns | RoomRateQuote |
The types it declares, generated into your project
/** One row of a rate table. */
export interface NightlyRate {
/** a label, such as "low" or "christmas" */
readonly season: string;
/** first night the row covers */
readonly firstNight: string;
/** last night the row covers, inclusive */
readonly lastNight: string;
/** ISO weekdays of the night (1 Monday to 7 Sunday); [] for every day */
readonly daysOfWeek: readonly number[];
/** the price of one night */
readonly rate: Money;
}
/** A discount for staying at least so many nights. */
export interface StayDiscount {
readonly minNights: number;
/** 1000 = 10% off the stay */
readonly basisPoints: number;
}
/** One night of the stay and what it costs. */
export interface RoomNight {
readonly date: string;
readonly season: string;
readonly rate: Money;
}
/** The stay, night by night, and its total. */
export interface RoomRateQuote {
readonly nights: readonly RoomNight[];
readonly subtotal: Money;
/** the length-of-stay discount applied; 0 for none */
readonly discountBasisPoints: number;
readonly discount: Money;
/** subtotal less discount */
readonly total: Money;
}
Your code names it in one line, in the file that uses it
import { roomRate } from "#fune/hospitality.room-rate@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { addDays } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { dayOfWeek } from "./dates_day_of_week.ts"; ← from dates.day-of-week ^1.0.0 · built alongside by fune
import { subtractMoney } from "./money_add.ts"; ← from money.add ^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 NightlyRate, type RoomNight, type RoomRateQuote, type StayDiscount } from "./hospitality_room_rate_types.ts";
/**
* Price a stay night by night from a rate table, then take off the
* length-of-stay discount the stay qualifies for.
*
* A night is named by the date it starts, so a Friday night is the night of
* a Friday. Exactly one row must match each night: two matching rows are
* an error rather than a guess about which one the hotel meant.
*/
export function roomRate(
rates: readonly NightlyRate[],
stayDiscounts: readonly StayDiscount[],
arrival: string,
nights: number,
): RoomRateQuote {
if (!Number.isInteger(nights) || nights < 1) throw new RangeError(`nights must be at least 1, received ${nights}`);
if (rates.length === 0) throw new RangeError("the rate table is empty");
for (const row of rates) {
// addDays parses both ends strictly, so a malformed date fails here.
addDays(row.firstNight, 0);
addDays(row.lastNight, 0);
if (row.lastNight < row.firstNight) throw new RangeError(`rate "${row.season}" ends before it starts`);
for (const d of row.daysOfWeek) {
if (!Number.isInteger(d) || d < 1 || d > 7) {
throw new RangeError(`daysOfWeek must be 1 (Monday) to 7 (Sunday), received ${d} in rate "${row.season}"`);
}
}
}
const seen = new Set<number>();
for (const discount of stayDiscounts) {
if (!Number.isInteger(discount.minNights) || discount.minNights < 1) {
throw new RangeError(`minNights must be at least 1, received ${discount.minNights}`);
}
if (seen.has(discount.minNights)) throw new RangeError(`two stay discounts start at ${discount.minNights} nights`);
seen.add(discount.minNights);
if (!Number.isInteger(discount.basisPoints) || discount.basisPoints < 0 || discount.basisPoints > 10000) {
throw new RangeError(`basisPoints must be a whole number from 0 to 10000, received ${discount.basisPoints}`);
}
}
const stay: RoomNight[] = [];
for (let i = 0; i < nights; i++) {
const date = addDays(arrival, i);
const weekday = dayOfWeek(date);
const matches = rates.filter(
(row) => row.firstNight <= date && date <= row.lastNight && (row.daysOfWeek.length === 0 || row.daysOfWeek.includes(weekday)),
);
if (matches.length === 0) throw new RangeError(`no rate for the night of ${date}`);
if (matches.length > 1) {
throw new RangeError(`more than one rate for the night of ${date}: "${matches[0].season}" and "${matches[1].season}"`);
}
stay.push({ date, season: matches[0].season, rate: matches[0].rate });
}
const subtotal = sumMoney(
stay.map((night) => night.rate),
stay[0].rate.currency,
);
let discountBasisPoints = 0;
let best = 0;
for (const discount of stayDiscounts) {
if (discount.minNights <= nights && discount.minNights > best) {
best = discount.minNights;
discountBasisPoints = discount.basisPoints;
}
}
const discount = applyRate(subtotal, discountBasisPoints, "half-up");
return { nights: stay, subtotal, discountBasisPoints, discount, total: subtractMoney(subtotal, discount) };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 6 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.room-rate
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./hospitality.room-rate-1.0.0-typescript.fune, or fetch it from a terminal with fune pull hospitality.room-rate@1.0.0:typescript.
The whole function, every language, is one file too: hospitality.room-rate-1.0.0.fune, 34,278 bytes, sha256 208333290373505148e2a6ca259cfffd40928fb3539a293ed8d040da0440476b. 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.room-rate
after — your function gets the result and the arguments, and returns the final result.
// fune: after hospitality.room-rate
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.add-days in hospitality.room-rate
// fune: replace dates.day-of-week in hospitality.room-rate
// fune: replace money.add in hospitality.room-rate
// fune: replace money.amount in hospitality.room-rate
// fune: replace money.apply-rate in hospitality.room-rate
// fune: replace money.sum in hospitality.room-rate
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.room-rate --steps.
// fune: step hospitality.room-rate 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 | |
|---|---|---|---|
| three nights across the season change: two low weekdays and a high night, 5% off for 3 nights | rates ×3, stay discounts ×2, 2026-03-30, 3 | → | nights ×3, subtotal £320.00, discount basis points 5%, discount £16.00, total £304.00 |
| Friday and Saturday nights at the weekend rate, too short for a discount | rates ×3, stay discounts ×2, 2026-03-27, 2 | → | nights ×2, subtotal £220.00, discount basis points 0%, discount £0.00, total £220.00 |
| a week: Sunday night is a weekday night, and 7 nights earns 10% rather than 5% | rates ×3, stay discounts ×2, 2026-03-26, 7 | → | nights ×7, subtotal £720.00, discount basis points 10%, discount £72.00, total £648.00 |
| one Saturday night | rates ×3, stay discounts ×2, 2026-03-28, 1 | → | nights ×1, subtotal £110.00, discount basis points 0%, discount £0.00, total £110.00 |
| the last night of a season is inclusive | rates ×3, , 2026-03-31, 1 | → | nights ×1, subtotal £90.00, discount basis points 0%, discount £0.00, total £90.00 |
| a stay over 29 February prices the leap day, not 1 March | rates ×2, , 2028-02-28, 2 | → | nights ×2, subtotal £200.00, discount basis points 0%, discount £0.00, total £200.00 |
| the discount rounds half-up once on the stay: 3.33% of 220.00 is 7.326 | rates ×3, stay discounts ×1, 2026-03-27, 2 | → | nights ×2, subtotal £220.00, discount basis points 3.33%, discount £7.33, total £212.67 |
| discounts may be listed in any order | rates ×3, stay discounts ×2, 2026-03-30, 3 | → | nights ×3, subtotal £320.00, discount basis points 5%, discount £16.00, total £304.00 |
| a night with no rate is an error | rates ×3, stay discounts ×2, 2026-09-30, 2 | → | error: no rate for the night of 2026-10-01 |
| two rows for one night are an error, not a guess | rates ×4, stay discounts ×2, 2026-04-02, 2 | → | error: more than one rate for the night of 2026-04-03 |
Show the other 8 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| no nights is an error | rates ×3, stay discounts ×2, 2026-03-30, 0 | → | error: nights must be at least 1 |
| an empty rate table is an error | , stay discounts ×2, 2026-03-30, 1 | → | error: the rate table is empty |
| a weekday of 8 is an error | rates ×1, , 2026-03-30, 1 | → | error: daysOfWeek must be 1 (Monday) to 7 (Sunday) |
| a row that ends before it starts is an error | rates ×1, , 2026-03-30, 1 | → | error: rate "x" ends before it starts |
| two discounts for the same length are an error | rates ×3, stay discounts ×2, 2026-03-30, 3 | → | error: two stay discounts start at 3 nights |
| a discount over 100% is an error | rates ×3, stay discounts ×1, 2026-03-30, 3 | → | error: basisPoints must be a whole number from 0 to 10000 |
| mixed currencies are an error | rates ×2, , 2026-01-01, 2 | → | error: currency mismatch |
| an impossible arrival date is an error | rates ×3, stay discounts ×2, 2026-02-29, 1 | → | error: is not a real calendar date |
More from the author
Rates are commercial decisions, not published rules, so there is no data file here. Each row covers a range of nights (`firstNight` to `lastNight`, inclusive) and, optionally, only some days of the week:
low 2026-01-01 .. 2026-03-31 Sun-Thu 90.00
low weekend 2026-01-01 .. 2026-03-31 Fri-Sat 110.00
high 2026-04-01 .. 2026-09-30 any 140.00A night is named by the date it starts, so "Friday night" is the night of a Friday, with the ISO weekday of that date (1 Monday to 7 Sunday).
**Exactly one row must match each night.** If none does, or two do, the function fails and names the night. The obvious alternatives, "the last row wins" or "the narrowest range wins", both quietly price a stay wrong the day someone adds an event rate on top of a season without removing the season. Make the rows disjoint, for example by splitting the season around the event.
## Length-of-stay discounts
`stayDiscounts` is a list of "at least n nights, x% off". The stay gets the discount with the largest `minNights` it reaches (7 nights beats 3 nights), and only that one. The percentage is taken off the subtotal once, rounded half-up, so there is no per-night rounding drift.
## Edge cases
- A stay across 29 February, a month end or a year end steps through the calendar with `dates.add-days`. - Every row must be in the same currency as the others the stay uses. - A malformed or impossible date anywhere in the table or the arrival is an error.
## Not covered
Per-person pricing, occupancy supplements, children's rates, packages and taxes (see `hospitality.tourist-tax` and `finance.tax.add-vat`) are separate steps.
Files
| Path | Bytes |
|---|---|
| README.md | 1,958 |
| impl/python.py | 3,663 |
| impl/rust.rs | 5,564 |
| impl/typescript.ts | 3,382 |
| vectors.json | 13,139 |