Functional Weave
Code in Rust

legal.time-billing@1.0.1

impl/typescript.ts

2,626 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 { roundDiv, type RoundingMode } from "./math_round_div.ts";  ← from math.round-div ^1.0.0 · built alongside by fune
import { type Money, money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { roundToIncrement } from "./time_round_to_increment.ts";  ← from time.round-to-increment ^1.0.0 · built alongside by fune
import { type FeeEarnerRate, type FeeEarnerTotal, type TimeBill, type TimeBillLine, type TimeEntry } from "./legal_time_billing_types.ts";

/** Price time entries in whole billing units at each fee earner's hourly rate. */
export function timeBilling(entries: readonly TimeEntry[], rates: readonly FeeEarnerRate[], unitMinutes: number, mode: RoundingMode): TimeBill {
  if (rates.length === 0) {
    throw new RangeError("rates must not be empty");
  }
  const currency = rates[0].hourlyRate.currency;
  const card = new Map<string, Money>();
  for (const rate of rates) {
    if (card.has(rate.feeEarner)) {
      throw new RangeError(`duplicate rate for fee earner "${rate.feeEarner}"`);
    }
    if (rate.hourlyRate.currency !== currency) {
      throw new RangeError(`currency mismatch: ${currency} and ${rate.hourlyRate.currency}`);
    }
    if (rate.hourlyRate.minor < 0) {
      throw new RangeError(`hourly rates must not be negative, received ${rate.hourlyRate.minor}`);
    }
    card.set(rate.feeEarner, rate.hourlyRate);
  }

  const lines: TimeBillLine[] = [];
  const order: string[] = [];
  const totals = new Map<string, { units: number; fee: number }>();
  let units = 0;
  let billedMinutes = 0;
  let total = 0;
  for (const entry of entries) {
    const hourlyRate = card.get(entry.feeEarner);
    if (hourlyRate === undefined) {
      throw new RangeError(`no rate for fee earner "${entry.feeEarner}"`);
    }
    // Rounded per entry: seven 1-minute emails are seven units, not two.
    const billed = roundToIncrement(entry.minutes, unitMinutes, mode);
    const lineUnits = billed / unitMinutes;
    const fee = roundDiv(billed * hourlyRate.minor, 60, "half-up");
    lines.push({ feeEarner: entry.feeEarner, minutes: entry.minutes, billedMinutes: billed, units: lineUnits, hourlyRate, fee: money(fee, currency) });
    let t = totals.get(entry.feeEarner);
    if (t === undefined) {
      t = { units: 0, fee: 0 };
      totals.set(entry.feeEarner, t);
      order.push(entry.feeEarner);
    }
    t.units += lineUnits;
    t.fee += fee;
    units += lineUnits;
    billedMinutes += billed;
    total += fee;
  }
  const byFeeEarner: FeeEarnerTotal[] = order.map((code) => {
    const t = totals.get(code) as { units: number; fee: number };
    return { feeEarner: code, units: t.units, fee: money(t.fee, currency) };
  });
  return { lines, byFeeEarner, units, billedMinutes, total: money(total, currency) };
}