Functional Weave
Code in Rust

logistics.demurrage@1.0.0

impl/typescript.ts

3,169 bytes · the TypeScript implementation · view raw

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