Functional Weave
Code in Rust

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

  • room_rate(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 nights
  • room_rate(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 discount
  • room_rate(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.

pub fn room_rate(rates: &[NightlyRate], stay_discounts: &[StayDiscount], arrival: &str, nights: i64) -> RoomRateQuote
ratesNightlyRate[]the rate table; exactly one row must match each night
stay_discountsStayDiscount[]length-of-stay discounts; [] for none
arrivaldatethe first night of the stay
nightsinthow many nights, at least 1
returnsRoomRateQuote

The types it declares, generated into your project

/// One row of a rate table.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NightlyRate {
    /// a label, such as "low" or "christmas"
    pub season: String,
    /// first night the row covers
    pub first_night: String,
    /// last night the row covers, inclusive
    pub last_night: String,
    /// ISO weekdays of the night (1 Monday to 7 Sunday); [] for every day
    pub days_of_week: Vec<i64>,
    /// the price of one night
    pub rate: Money,
}

/// A discount for staying at least so many nights.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StayDiscount {
    pub min_nights: i64,
    /// 1000 = 10% off the stay
    pub basis_points: i64,
}

/// One night of the stay and what it costs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoomNight {
    pub date: String,
    pub season: String,
    pub rate: Money,
}

/// The stay, night by night, and its total.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoomRateQuote {
    pub nights: Vec<RoomNight>,
    pub subtotal: Money,
    /// the length-of-stay discount applied; 0 for none
    pub discount_basis_points: i64,
    pub discount: Money,
    /// subtotal less discount
    pub total: Money,
}

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

fune!(hospitality.room-rate@^1);  // then call room_rate(…)
impl/rust.rs · 145 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_add_days::add_days;  ← from dates.add-days ^1.0.0 · built alongside by fune
use super::dates_day_of_week::day_of_week;  ← from dates.day-of-week ^1.0.0 · built alongside by fune
use super::money_add::subtract_money;  ← from money.add ^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

/// 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.
///
/// # Panics
/// Panics on a malformed table or discount list, a bad date, a night no row
/// or several rows match, or mixed currencies.
pub fn room_rate(rates: &[NightlyRate], stay_discounts: &[StayDiscount], arrival: &str, nights: i64) -> RoomRateQuote {
    if nights < 1 {
        panic!("nights must be at least 1, received {}", nights);
    }
    if rates.is_empty() {
        panic!("the rate table is empty");
    }
    for row in rates {
        // add_days parses both ends strictly, so a malformed date fails here.
        add_days(&row.first_night, 0);
        add_days(&row.last_night, 0);
        if row.last_night < row.first_night {
            panic!("rate \"{}\" ends before it starts", row.season);
        }
        for d in &row.days_of_week {
            if *d < 1 || *d > 7 {
                panic!(
                    "daysOfWeek must be 1 (Monday) to 7 (Sunday), received {} in rate \"{}\"",
                    d, row.season
                );
            }
        }
    }
    let mut seen: Vec<i64> = Vec::new();
    for discount in stay_discounts {
        if discount.min_nights < 1 {
            panic!("minNights must be at least 1, received {}", discount.min_nights);
        }
        if seen.contains(&discount.min_nights) {
            panic!("two stay discounts start at {} nights", discount.min_nights);
        }
        seen.push(discount.min_nights);
        if !(0..=10000).contains(&discount.basis_points) {
            panic!(
                "basisPoints must be a whole number from 0 to 10000, received {}",
                discount.basis_points
            );
        }
    }

    let mut stay: Vec<RoomNight> = Vec::new();
    for i in 0..nights {
        let date = add_days(arrival, i);
        let weekday = day_of_week(&date);
        let matches: Vec<&NightlyRate> = rates
            .iter()
            .filter(|row| {
                row.first_night.as_str() <= date.as_str()
                    && date.as_str() <= row.last_night.as_str()
                    && (row.days_of_week.is_empty() || row.days_of_week.contains(&weekday))
            })
            .collect();
        if matches.is_empty() {
            panic!("no rate for the night of {}", date);
        }
        if matches.len() > 1 {
            panic!(
                "more than one rate for the night of {}: \"{}\" and \"{}\"",
                date, matches[0].season, matches[1].season
            );
        }
        stay.push(RoomNight { season: matches[0].season.clone(), rate: matches[0].rate.clone(), date });
    }

    let amounts: Vec<Money> = stay.iter().map(|n| n.rate.clone()).collect();
    let subtotal = sum_money(&amounts, &stay[0].rate.currency);
    let mut discount_basis_points = 0;
    let mut best = 0;
    for discount in stay_discounts {
        if discount.min_nights <= nights && discount.min_nights > best {
            best = discount.min_nights;
            discount_basis_points = discount.basis_points;
        }
    }
    let discount = apply_rate(&subtotal, discount_basis_points, "half-up");
    RoomRateQuote {
        nights: stay,
        total: subtract_money(&subtotal, &discount),
        subtotal,
        discount_basis_points,
        discount,
    }
}

pub fn nightly_rate_from_value(v: &Value) -> NightlyRate {
    NightlyRate {
        season: v.get("season").as_str().to_string(),
        first_night: v.get("firstNight").as_str().to_string(),
        last_night: v.get("lastNight").as_str().to_string(),
        days_of_week: v.get("daysOfWeek").as_arr().iter().map(|d| d.as_i64()).collect(),
        rate: money_from_value(v.get("rate")),
    }
}

pub fn stay_discount_from_value(v: &Value) -> StayDiscount {
    StayDiscount { min_nights: v.get("minNights").as_i64(), basis_points: v.get("basisPoints").as_i64() }
}

pub fn room_rate_quote_to_value(q: &RoomRateQuote) -> Value {
    Value::obj(vec![
        (
            "nights",
            Value::Arr(
                q.nights
                    .iter()
                    .map(|n| {
                        Value::obj(vec![
                            ("date", Value::str(&n.date)),
                            ("season", Value::str(&n.season)),
                            ("rate", money_to_value(&n.rate)),
                        ])
                    })
                    .collect(),
            ),
        ),
        ("subtotal", money_to_value(&q.subtotal)),
        ("discountBasisPoints", Value::Int(q.discount_basis_points)),
        ("discount", money_to_value(&q.discount)),
        ("total", money_to_value(&q.total)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let rates: Vec<NightlyRate> = args[0].as_arr().iter().map(nightly_rate_from_value).collect();
    let discounts: Vec<StayDiscount> = args[1].as_arr().iter().map(stay_discount_from_value).collect();
    room_rate_quote_to_value(&room_rate(&rates, &discounts, args[2].as_str(), args[3].as_i64()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 6 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.room-rate
Download for Rust hospitality.room-rate-1.0.0-rust.fune · 26,985 bytes sha256 38e9a2183de1844993cff70f2ae1062a9eb70dd3866405dc5622d62a7cb21df4

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

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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.00

A 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

PathBytes
README.md1,958
impl/python.py3,663
impl/rust.rs5,564
impl/typescript.ts3,382
vectors.json13,139