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),
};
}