Functional Weave
Code in Rust

logistics.demurrage

Demurrage or detention charge for a container: free days, then a tiered daily rate from the carrier's tariff.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 22 tests, run in TypeScript, Python and Rust.

What it does

What a shipping line charges for keeping its container too long. The same arithmetic serves both charges, called with different dates:

| charge | startDate | endDate | |---|---|---| | demurrage (container full, in the terminal) | discharge from the vessel | gate-out full | | detention (container outside the terminal) | gate-out full | returned empty | | combined "D&D" tariffs | discharge | returned empty |

For example

  • demurrage(2026-03-01, 2026-03-04, 4, tiers ×3) → total days 4, free days used 4, chargeable days 0, free time ends 2026-03-04, lines , total $0.00 collected on the last free day: nothing to pay
  • demurrage(2026-03-01, 2026-03-05, 4, tiers ×3) → total days 5, free days used 4, chargeable days 1, free time ends 2026-03-04, lines ×1, total $75.00 one day over free time
  • demurrage(2026-03-01, 2026-03-20, 4, tiers ×3) → total days 20, free days used 4, chargeable days 16, free time ends 2026-03-04, lines ×3, total $3,075.00 twenty days runs through all three tiers

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 demurrage(start_date: &str, end_date: &str, free_days: i64, tiers: &[DemurrageTier]) -> DemurrageCharge
start_datedateday 1 of the count: discharge (demurrage) or gate-out (detention), or the day after if the tariff says so
end_datedatethe last day that counts: gate-out (demurrage) or empty return (detention), inclusive
free_daysintcalendar days free of charge from startDate, 0 or more
tiersDemurrageTier[]the tariff's daily rates by day number, in any order; they must not overlap
returnsDemurrageCharge

The types it declares, generated into your project

/// One band of the tariff, by day number counted from startDate as day 1, as tariffs print them.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DemurrageTier {
    /// first day the rate applies to, 1 or more
    pub from_day: i64,
    /// last day, inclusive; null for every day after fromDay
    pub to_day: Option<i64>,
    /// per container per day
    pub daily_rate: Money,
}

/// The days one tier charged.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DemurrageLine {
    pub from_day: i64,
    pub to_day: i64,
    pub days: i64,
    pub daily_rate: Money,
    pub amount: Money,
}

/// What is owed, and how the free time and each tier contributed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DemurrageCharge {
    /// startDate to endDate, both counted
    pub total_days: i64,
    /// the free days that fell inside the period
    pub free_days_used: i64,
    pub chargeable_days: i64,
    /// last free day, even if the container went back earlier; null with no free days
    pub free_time_ends: Option<String>,
    /// one per tier that charged anything, in day order
    pub lines: Vec<DemurrageLine>,
    pub total: Money,
}

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

fune!(logistics.demurrage@^1);  // then call demurrage(…)
impl/rust.rs · 135 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_days_between::days_between;  ← from dates.days-between ^1.0.0 · built alongside by fune
use super::money_amount::{assert_same_currency, money, money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune
use super::money_sum::sum_money;  ← from money.sum ^1.0.0 · built alongside by fune

/// Demurrage or detention for one container: calendar days from start_date
/// to end_date inclusive, the first free_days free, every later day at the
/// rate of the tier whose day numbers contain it.
///
/// # Panics
/// Panics on bad dates, negative free days, and a tariff with no tiers,
/// overlapping or backwards tiers, negative rates, mixed currencies or a gap
/// that a chargeable day falls in.
pub fn demurrage(start_date: &str, end_date: &str, free_days: i64, tiers: &[DemurrageTier]) -> DemurrageCharge {
    if free_days < 0 {
        panic!("freeDays must not be negative, received {}", free_days);
    }
    let total_days = days_between(start_date, end_date) + 1;
    if total_days < 1 {
        panic!("endDate must not be before startDate: {} to {}", start_date, end_date);
    }

    if tiers.is_empty() {
        panic!("tiers must not be empty");
    }
    for tier in tiers {
        if tier.from_day < 1 {
            panic!("tier fromDay must be 1 or more, received {}", tier.from_day);
        }
        if let Some(to) = tier.to_day {
            if to < tier.from_day {
                panic!("tier toDay must not be before fromDay, received {} to {}", tier.from_day, to);
            }
        }
        if tier.daily_rate.minor < 0 {
            panic!("dailyRate must not be negative, received {}", tier.daily_rate.minor);
        }
        assert_same_currency(&tiers[0].daily_rate, &tier.daily_rate);
    }
    let mut sorted: Vec<&DemurrageTier> = tiers.iter().collect();
    sorted.sort_by_key(|t| t.from_day);
    for i in 1..sorted.len() {
        let overlaps = match sorted[i - 1].to_day {
            None => true,
            Some(to) => to >= sorted[i].from_day,
        };
        if overlaps {
            panic!("tiers overlap at day {}", sorted[i].from_day);
        }
    }

    let mut lines: Vec<DemurrageLine> = Vec::new();
    let mut day = free_days + 1;
    for tier in &sorted {
        if day > total_days {
            break;
        }
        if let Some(to) = tier.to_day {
            if to < day {
                continue; // wholly inside free time
            }
        }
        // A gap is a mistake in the tariff; charging nothing for it would hide it.
        if tier.from_day > day {
            panic!("no rate for day {}: no tier covers it", day);
        }
        let last = match tier.to_day {
            None => total_days,
            Some(to) => to.min(total_days),
        };
        let days = last - day + 1;
        lines.push(DemurrageLine {
            from_day: day,
            to_day: last,
            days,
            daily_rate: tier.daily_rate.clone(),
            amount: money(tier.daily_rate.minor * days, &tier.daily_rate.currency),
        });
        day = last + 1;
    }
    if day <= total_days {
        panic!("no rate for day {}: no tier covers it", day);
    }

    let amounts: Vec<Money> = lines.iter().map(|l| l.amount.clone()).collect();
    DemurrageCharge {
        total_days,
        free_days_used: free_days.min(total_days),
        chargeable_days: (total_days - free_days).max(0),
        free_time_ends: if free_days > 0 { Some(add_days(start_date, free_days - 1)) } else { None },
        total: sum_money(&amounts, &tiers[0].daily_rate.currency),
        lines,
    }
}

pub fn demurrage_tier_from_value(v: &Value) -> DemurrageTier {
    DemurrageTier {
        from_day: v.get("fromDay").as_i64(),
        to_day: if v.get("toDay").is_null() { None } else { Some(v.get("toDay").as_i64()) },
        daily_rate: money_from_value(v.get("dailyRate")),
    }
}

pub fn demurrage_line_to_value(l: &DemurrageLine) -> Value {
    Value::obj(vec![
        ("fromDay", Value::Int(l.from_day)),
        ("toDay", Value::Int(l.to_day)),
        ("days", Value::Int(l.days)),
        ("dailyRate", money_to_value(&l.daily_rate)),
        ("amount", money_to_value(&l.amount)),
    ])
}

pub fn demurrage_charge_to_value(c: &DemurrageCharge) -> Value {
    Value::obj(vec![
        ("totalDays", Value::Int(c.total_days)),
        ("freeDaysUsed", Value::Int(c.free_days_used)),
        ("chargeableDays", Value::Int(c.chargeable_days)),
        (
            "freeTimeEnds",
            match &c.free_time_ends {
                Some(d) => Value::str(d),
                None => Value::Null,
            },
        ),
        ("lines", Value::Arr(c.lines.iter().map(demurrage_line_to_value).collect())),
        ("total", money_to_value(&c.total)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let tiers: Vec<DemurrageTier> = args[3].as_arr().iter().map(demurrage_tier_from_value).collect();
    demurrage_charge_to_value(&demurrage(args[0].as_str(), args[1].as_str(), args[2].as_i64(), &tiers))
}

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 logistics.demurrage
Download for Rust logistics.demurrage-1.0.0-rust.fune · 21,470 bytes sha256 adf9e73beafc1f5adb407f56063fa95afc20a43d7ed9ed1d30e0ba66ddec8b46

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

The whole function, every language, is one file too: logistics.demurrage-1.0.0.fune, 28,367 bytes, sha256 9bf7d4f8c7b4fe3c08a03715c06c0870b842933ed6581de5c71d421ae761358f. 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 logistics.demurrage

after — your function gets the result and the arguments, and returns the final result.

// fune: after logistics.demurrage

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 logistics.demurrage
// fune: replace dates.days-between in logistics.demurrage
// fune: replace money.amount in logistics.demurrage
// fune: replace money.sum in logistics.demurrage

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 logistics.demurrage --steps.

// fune: step logistics.demurrage 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
collected on the last free day: nothing to pay 2026-03-01, 2026-03-04, 4, tiers ×3 → total days 4, free days used 4, chargeable days 0, free time ends 2026-03-04, lines , total $0.00
one day over free time 2026-03-01, 2026-03-05, 4, tiers ×3 → total days 5, free days used 4, chargeable days 1, free time ends 2026-03-04, lines ×1, total $75.00
twenty days runs through all three tiers 2026-03-01, 2026-03-20, 4, tiers ×3 → total days 20, free days used 4, chargeable days 16, free time ends 2026-03-04, lines ×3, total $3,075.00
tiers given out of order give the same answer 2026-03-01, 2026-03-20, 4, tiers ×3 → total days 20, free days used 4, chargeable days 16, free time ends 2026-03-04, lines ×3, total $3,075.00
extended free time swallows the first tier and part of the second 2026-03-01, 2026-03-20, 10, tiers ×3 → total days 20, free days used 10, chargeable days 10, free time ends 2026-03-10, lines ×2, total $2,400.00
free time runs through 29 February in a leap year 2028-02-27, 2028-03-02, 4, tiers ×1 → total days 5, free days used 4, chargeable days 1, free time ends 2028-03-01, lines ×1, total €90.00
returned early: free time still ends where it would have 2026-12-30, 2026-12-31, 4, tiers ×1 → total days 2, free days used 2, chargeable days 0, free time ends 2027-01-02, lines , total €0.00
no free time, a single day at a flat rate 2026-06-15, 2026-06-15, 0, tiers ×1 → total days 1, free days used 0, chargeable days 1, free time ends —, lines ×1, total £50.00
a zero-rate tier charges nothing but still appears 2026-06-01, 2026-06-10, 2, tiers ×2 → total days 10, free days used 2, chargeable days 8, free time ends 2026-06-02, lines ×2, total £200.00
no free time with a tariff that starts at day 5 leaves days uncovered 2026-03-01, 2026-03-06, 0, tiers ×1 → error: no rate for day 1
Show the other 12 tests
CaseArgumentsExpected
a gap in the tariff is an error, not a free day 2026-03-01, 2026-03-10, 4, tiers ×2 → error: no rate for day 8
a closed last tier leaves later days uncovered 2026-03-01, 2026-03-10, 4, tiers ×1 → error: no rate for day 8
overlapping tiers 2026-03-01, 2026-03-10, 4, tiers ×2 → error: tiers overlap at day 8
an open tier followed by another overlaps 2026-03-01, 2026-03-10, 4, tiers ×2 → error: tiers overlap at day 10
a tier running backwards 2026-03-01, 2026-03-10, 4, tiers ×1 → error: tier toDay must not be before fromDay
a tier starting at day 0 2026-03-01, 2026-03-10, 4, tiers ×1 → error: tier fromDay must be 1 or more
a negative daily rate 2026-03-01, 2026-03-10, 4, tiers ×1 → error: dailyRate must not be negative
tiers in two currencies 2026-03-01, 2026-03-10, 4, tiers ×2 → error: currency mismatch
no tiers at all 2026-03-01, 2026-03-02, 4, → error: tiers must not be empty
negative free days 2026-03-01, 2026-03-10, -1, tiers ×1 → error: freeDays must not be negative
end before start 2026-03-10, 2026-03-09, 4, tiers ×1 → error: endDate must not be before startDate
an impossible date 2026-02-30, 2026-03-09, 4, tiers ×1 → error: 2026-02-30

More from the author

## Counting days

Days are calendar days and both ends count: a container discharged on 1 March and collected on 5 March has been there 5 days. Tariffs differ on whether the discharge day is day 1; if yours starts counting the next day, pass the next day as `startDate`. Free time in working days (some carriers exclude weekends and holidays) is not handled here.

Days 1 to `freeDays` are free. Every later day is charged at the rate of the tier whose `fromDay`..`toDay` contains it, and the tiers use the same day numbers as the tariff prints ("days 5-7 USD 75, days 8-14 USD 150, day 15 onwards USD 300"). With extended free time the free days simply swallow the early tiers: 10 free days on that tariff charges days 11-14 at USD 150 and then USD 300. If your contract restarts the tiers after extended free time, renumber them before calling.

`freeTimeEnds` is the last free day (`startDate + freeDays - 1`), reported even when the container went back earlier, because that is the date operations plan against. It is correct across month ends and 29 February.

## Errors

A chargeable day that no tier covers is an error naming the day, not a free day: a gap in a tariff table is a data mistake, and charging nothing for it would hide it. Overlapping tiers, a tier running backwards, a negative rate, mixed currencies, no tiers at all, negative free days and an end before the start are errors too.

## Money

Each line is `days x dailyRate` in integer minor units, exactly; there is no rounding anywhere. The rate is per container: multiply for several.

Files

PathBytes
README.md1,995
impl/python.py3,503
impl/rust.rs4,964
impl/typescript.ts3,169
vectors.json8,968