Functional Weave
Code in Rust

lending.daily-interest@1.0.0

impl/typescript.ts

2,874 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 { 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 { type Rational, addRational, multiplyRational, rational, rationalToInteger } from "./math_rational.ts";  ← from math.rational ^1.0.0 · built alongside by fune
import { type DayCountConvention, dayCountFraction } from "./dates_day_count_fraction.ts";  ← from dates.day-count-fraction ^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 DailyInterest, type DatedBalance } from "./lending_daily_interest_types.ts";

/**
 * Interest on a balance that changes during the period: each balance earns
 * (or is charged) balance × rate × year fraction for the days it was held,
 * the year fractions come from dates.day-count-fraction, and the pieces are
 * added as exact fractions. Only the total is rounded, once, so splitting a
 * month into more balance changes never moves the answer by a stray penny.
 */
export function dailyInterest(
  balances: readonly DatedBalance[],
  fromIso: string,
  toIso: string,
  annualRateBasisPoints: number,
  convention: DayCountConvention,
  mode: RoundingMode,
): DailyInterest {
  if (!Number.isInteger(annualRateBasisPoints) || annualRateBasisPoints < -100000 || annualRateBasisPoints > 100000) {
    throw new RangeError(`annualRateBasisPoints must be between -100000 and 100000, received ${annualRateBasisPoints}`);
  }
  if (balances.length === 0) {
    throw new RangeError("balances must not be empty");
  }
  const days = daysBetween(fromIso, toIso);
  if (days < 0) {
    throw new RangeError(`fromIso ${fromIso} must not be after toIso ${toIso}`);
  }
  const currency = balances[0].balance.currency;
  for (let i = 0; i < balances.length; i += 1) {
    daysBetween(balances[i].date, balances[i].date);
    if (balances[i].balance.currency !== currency) {
      throw new RangeError(`currency mismatch: ${currency} and ${balances[i].balance.currency}`);
    }
    if (i > 0 && balances[i].date <= balances[i - 1].date) {
      throw new RangeError("balances must be in strictly ascending date order");
    }
  }
  if (balances[0].date > fromIso) {
    throw new RangeError(`the first balance starts on ${balances[0].date}, after fromIso ${fromIso}`);
  }
  const rate = rational(annualRateBasisPoints, 10000);
  let total: Rational = rational(0, 1);
  for (let i = 0; i < balances.length; i += 1) {
    const start = balances[i].date > fromIso ? balances[i].date : fromIso;
    const next = i + 1 < balances.length ? balances[i + 1].date : toIso;
    const end = next < toIso ? next : toIso;
    if (start >= end) continue;
    const fraction = dayCountFraction(start, end, convention);
    const amount = multiplyRational(rational(balances[i].balance.minor, 1), rate);
    total = addRational(total, multiplyRational(amount, rational(fraction.numerator, fraction.denominator)));
  }
  return { interest: money(rationalToInteger(total, mode), currency), exact: total, days };
}