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 paydemurrage(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 timedemurrage(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.
export function demurrage(startDate: string, endDate: string, freeDays: number, tiers: readonly DemurrageTier[]): DemurrageCharge
| startDate | date | day 1 of the count: discharge (demurrage) or gate-out (detention), or the day after if the tariff says so |
| endDate | date | the last day that counts: gate-out (demurrage) or empty return (detention), inclusive |
| freeDays | int | calendar days free of charge from startDate, 0 or more |
| tiers | DemurrageTier[] | the tariff's daily rates by day number, in any order; they must not overlap |
| returns | DemurrageCharge |
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. */
export interface DemurrageTier {
/** first day the rate applies to, 1 or more */
readonly fromDay: number;
/** last day, inclusive; null for every day after fromDay */
readonly toDay: number | null;
/** per container per day */
readonly dailyRate: Money;
}
/** The days one tier charged. */
export interface DemurrageLine {
readonly fromDay: number;
readonly toDay: number;
readonly days: number;
readonly dailyRate: Money;
readonly amount: Money;
}
/** What is owed, and how the free time and each tier contributed. */
export interface DemurrageCharge {
/** startDate to endDate, both counted */
readonly totalDays: number;
/** the free days that fell inside the period */
readonly freeDaysUsed: number;
readonly chargeableDays: number;
/** last free day, even if the container went back earlier; null with no free days */
readonly freeTimeEnds: string | null;
/** one per tier that charged anything, in day order */
readonly lines: readonly DemurrageLine[];
readonly total: Money;
}
Your code names it in one line, in the file that uses it
import { demurrage } from "#fune/logistics.demurrage@^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 { daysBetween } from "./dates_days_between.ts"; ← from dates.days-between ^1.0.0 · built alongside by fune
import { type Money, assertSameCurrency, money } from "./money_amount.ts"; ← from money.amount ^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 DemurrageTier, type DemurrageLine, type DemurrageCharge } from "./logistics_demurrage_types.ts";
/**
* Demurrage or detention for one container: calendar days from startDate to
* endDate inclusive, the first freeDays free, every later day at the rate of
* the tier whose day numbers contain it.
*/
export function demurrage(
startDate: string,
endDate: string,
freeDays: number,
tiers: readonly DemurrageTier[],
): DemurrageCharge {
if (!Number.isInteger(freeDays)) throw new TypeError(`freeDays must be an integer, received ${freeDays}`);
if (freeDays < 0) throw new RangeError(`freeDays must not be negative, received ${freeDays}`);
const totalDays = daysBetween(startDate, endDate) + 1;
if (totalDays < 1) throw new RangeError(`endDate must not be before startDate: ${startDate} to ${endDate}`);
if (tiers.length === 0) throw new RangeError("tiers must not be empty");
for (const tier of tiers) {
if (!Number.isInteger(tier.fromDay) || tier.fromDay < 1) {
throw new RangeError(`tier fromDay must be 1 or more, received ${tier.fromDay}`);
}
if (tier.toDay !== null && (!Number.isInteger(tier.toDay) || tier.toDay < tier.fromDay)) {
throw new RangeError(`tier toDay must not be before fromDay, received ${tier.fromDay} to ${tier.toDay}`);
}
if (tier.dailyRate.minor < 0) throw new RangeError(`dailyRate must not be negative, received ${tier.dailyRate.minor}`);
assertSameCurrency(tiers[0].dailyRate, tier.dailyRate);
}
const sorted = [...tiers].sort((a, b) => a.fromDay - b.fromDay);
for (let i = 1; i < sorted.length; i++) {
const previous = sorted[i - 1];
if (previous.toDay === null || previous.toDay >= sorted[i].fromDay) {
throw new RangeError(`tiers overlap at day ${sorted[i].fromDay}`);
}
}
const lines: DemurrageLine[] = [];
let day = freeDays + 1;
for (const tier of sorted) {
if (day > totalDays) break;
if (tier.toDay !== null && tier.toDay < day) continue; // wholly inside free time
// A gap is a mistake in the tariff; charging nothing for it would hide it.
if (tier.fromDay > day) throw new RangeError(`no rate for day ${day}: no tier covers it`);
const last = tier.toDay === null ? totalDays : Math.min(tier.toDay, totalDays);
const days = last - day + 1;
lines.push({
fromDay: day,
toDay: last,
days,
dailyRate: tier.dailyRate,
amount: money(tier.dailyRate.minor * days, tier.dailyRate.currency),
});
day = last + 1;
}
if (day <= totalDays) throw new RangeError(`no rate for day ${day}: no tier covers it`);
const currency = tiers[0].dailyRate.currency;
return {
totalDays,
freeDaysUsed: Math.min(freeDays, totalDays),
chargeableDays: Math.max(totalDays - freeDays, 0),
freeTimeEnds: freeDays > 0 ? addDays(startDate, freeDays - 1) : null,
lines,
total: sumMoney(lines.map((line): Money => line.amount), currency),
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 4 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 logistics.demurrage
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./logistics.demurrage-1.0.0-typescript.fune, or fetch it from a terminal with fune pull logistics.demurrage@1.0.0:typescript.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,995 |
| impl/python.py | 3,503 |
| impl/rust.rs | 4,964 |
| impl/typescript.ts | 3,169 |
| vectors.json | 8,968 |