Functional Weave
Code in Python

lending.early-settlement@1.0.0

impl/typescript.ts

4,612 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 Money, money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { addDays } from "./dates_add_days.ts";  ← from dates.add-days ^1.0.0 · built alongside by fune
import { addMonths } from "./dates_add_months.ts";  ← from dates.add-months ^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 { FIXED_SCALE, powFixed, rootFixed } from "./math_fractional_power.ts";  ← from math.fractional-power ^1.0.0 · built alongside by fune
import { type DatedAmount, type EarlySettlement, type SettlementDeferral } from "./lending_early_settlement_types.ts";

/**
 * The settlement date: 28 days after the creditor receives the notice
 * (regulation 5(1)(a)), or a later date the notice names, then deferred by a
 * month or 30 days where the creditor elects and regulation 6 allows it
 * (credit repaid over more than a year from the first drawdown).
 */
function settlementDate(
  advances: readonly DatedAmount[],
  repayments: readonly DatedAmount[],
  noticeReceivedIso: string,
  requestedSettlementIso: string | null,
  deferral: SettlementDeferral,
): string {
  let date = addDays(noticeReceivedIso, 28);
  if (requestedSettlementIso !== null) {
    daysBetween(requestedSettlementIso, requestedSettlementIso);
    if (requestedSettlementIso > date) date = requestedSettlementIso;
  }
  if (deferral === "none") return date;
  if (deferral !== "one-month" && deferral !== "thirty-days") {
    throw new RangeError(`unknown deferral "${deferral}": expected none, one-month or thirty-days`);
  }
  const first = advances.reduce((min, a) => (a.date < min ? a.date : min), advances[0].date);
  const last = repayments.reduce((max, r) => (r.date > max ? r.date : max), repayments[0].date);
  if (last <= addMonths(first, 12)) {
    throw new RangeError("deferral is only allowed when the credit is repaid over more than a year (regulation 6)");
  }
  return deferral === "one-month" ? addMonths(date, 1) : addDays(date, 30);
}

/**
 * The amount a debtor pays to settle a regulated consumer credit agreement
 * early, and the rebate, per the Consumer Credit (Early Settlement)
 * Regulations 2004, regulation 4(1): the rebate is the scheduled repayments
 * falling due after the settlement date less
 *
 *   Σ A_i (1 + r)^a_i  −  Σ B_j (1 + r)^b_j
 *
 * over the advances and the repayments made before the settlement date,
 * each carried forward to the settlement date at the APR. That difference is
 * the settlement figure. Times are days / 365 of a year.
 */
export function earlySettlement(
  advances: readonly DatedAmount[],
  repayments: readonly DatedAmount[],
  aprBasisPoints: number,
  noticeReceivedIso: string,
  requestedSettlementIso: string | null,
  deferral: SettlementDeferral,
): EarlySettlement {
  if (advances.length === 0) throw new RangeError("advances must not be empty");
  if (repayments.length === 0) throw new RangeError("repayments must not be empty");
  if (!Number.isInteger(aprBasisPoints) || aprBasisPoints < 0 || aprBasisPoints > 1000000) {
    throw new RangeError(`aprBasisPoints must be between 0 and 1000000, received ${aprBasisPoints}`);
  }
  const currency = advances[0].amount.currency;
  for (const flow of [...advances, ...repayments]) {
    daysBetween(flow.date, flow.date);
    if (flow.amount.currency !== currency) {
      throw new RangeError(`currency mismatch: ${currency} and ${flow.amount.currency}`);
    }
    if (!Number.isInteger(flow.amount.minor) || flow.amount.minor <= 0) {
      throw new RangeError(`every amount must be greater than zero, received ${flow.amount.minor}`);
    }
  }
  const date = settlementDate(advances, repayments, noticeReceivedIso, requestedSettlementIso, deferral);
  // (1 + APR)^(1/365), once; each flow is then a whole power of it.
  const daily = rootFixed((FIXED_SCALE * BigInt(10000 + aprBasisPoints)) / 10000n, 365);
  const carried = (flow: DatedAmount) =>
    BigInt(flow.amount.minor) * powFixed(daily, daysBetween(flow.date, date));
  let owed = 0n;
  let drawn = false;
  for (const advance of advances) {
    if (advance.date < date) {
      owed += carried(advance);
      drawn = true;
    }
  }
  if (!drawn) throw new RangeError(`no credit was advanced before the settlement date ${date}`);
  let remaining = 0;
  for (const repayment of repayments) {
    if (repayment.date < date) owed -= carried(repayment);
    else remaining += repayment.amount.minor;
  }
  // Rounded down: the rebate must be at least the one the formula gives.
  const formula = owed > 0n ? Number(owed / FIXED_SCALE) : 0;
  const settlement = Math.min(formula, remaining);
  return {
    settlementDate: date,
    settlementAmount: money(settlement, currency),
    remainingRepayments: money(remaining, currency),
    rebate: money(remaining - settlement, currency),
  };
}