Functional Weave
Code in Python

legal.statutory-interest@1.0.0

impl/python.py

5,068 bytes · the Python 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.

from typing import Optional, Sequence

from .dates_add_days import add_days, days_between  ← from dates.add-days ^1.0.0 · built alongside by fune
from .finance_late_payment_interest import late_payment_interest  ← from finance.late-payment-interest ^1.0.0 · built alongside by fune
from .legal_statutory_interest_data import (
    COUNTY_COURT_THRESHOLDS,
    COUNTY_COURT_THRESHOLDS_HISTORY,
    JUDGMENT_RATES,
    JUDGMENT_RATES_HISTORY,
)
from .legal_statutory_interest_types import InterestBasis, StatutoryInterest
from .math_round_div import round_div  ← from math.round-div ^1.0.0 · built alongside by fune
from .money_amount import Money, money  ← from money.amount ^1.0.0 · built alongside by fune

_YEAR = 10000 * 365
_MAX_EXACT = 2**53 - 1


def _horizon(history: str, froms: Sequence[str], on_date: str, what: str) -> None:
    # A history=current build keeps only rows still in force; answering an older
    # date with today's rule is the failure dated data exists to prevent.
    if history == "full" or len(froms) == 0:
        return
    earliest = min(froms)
    if on_date < earliest:
        raise ValueError(
            "no %s on %s: this build was installed with history=%s, so it only carries rules from %s. "
            "Reinstall with history=full for older judgments." % (what, on_date, history, earliest)
        )


def _in_force(on_date: str, valid_from: str, valid_to: Optional[str]) -> bool:
    return on_date >= valid_from and (valid_to is None or on_date <= valid_to)


def _rate_on(on_date: str) -> int:
    for row in JUDGMENT_RATES:
        if _in_force(on_date, row.valid_from, row.valid_to):
            return row.basis_points
    _horizon(JUDGMENT_RATES_HISTORY, [r.valid_from for r in JUDGMENT_RATES], on_date, "judgment interest rate")
    raise ValueError("no judgment interest rate in force on %s" % (on_date,))


def _checked(a: int, b: int, c: int) -> int:
    if a * b * c > _MAX_EXACT:
        raise ValueError("the interest calculation exceeds the exact integer range")
    return a * b * c


def statutory_interest(amount: Money, from_date: str, to_date: str, basis: InterestBasis) -> StatutoryInterest:
    """Statutory interest on a judgment debt or a late commercial debt."""
    if amount.currency != "GBP":
        raise ValueError("statutory interest is defined for GBP debts, received %s" % (amount.currency,))
    if amount.minor <= 0:
        raise ValueError("amount must be greater than zero")
    zero = money(0, "GBP")
    if basis == "late-payment":
        claim = late_payment_interest(amount, from_date, to_date)
        rate = claim.interest_rate_basis_points or 0
        return StatutoryInterest(
            days=claim.days_late,
            rate_basis_points=rate,
            daily_interest=money(round_div(amount.minor * rate, _YEAR, "half-up"), "GBP"),
            interest=claim.interest,
            compensation=claim.compensation,
            total=claim.total,
        )
    if basis not in ("judgment-high-court", "judgment-county-court"):
        raise ValueError(
            'unknown interest basis "%s": expected judgment-high-court, judgment-county-court or late-payment' % (basis,)
        )
    days = days_between(from_date, to_date)
    if days < 0:
        raise ValueError("toDate %s is before fromDate %s" % (to_date, from_date))
    if basis == "judgment-county-court":
        threshold = next((r for r in COUNTY_COURT_THRESHOLDS if _in_force(from_date, r.valid_from, r.valid_to)), None)
        if threshold is None:
            _horizon(
                COUNTY_COURT_THRESHOLDS_HISTORY,
                [r.valid_from for r in COUNTY_COURT_THRESHOLDS],
                from_date,
                "County Court judgment interest rule",
            )
            raise ValueError("no County Court judgment interest rule in force on %s" % (from_date,))
        if amount.minor < threshold.min_judgment_minor:
            # Not a "relevant judgment": no statutory interest at all.
            return StatutoryInterest(days=days, rate_basis_points=0, daily_interest=zero, interest=zero, compensation=zero, total=zero)
    # Each day from the day after judgment to the payment date at the rate in force on it.
    numerator = 0
    covered = 0
    first = add_days(from_date, 1)
    for row in JUDGMENT_RATES:
        if days == 0:
            break
        start = first if first > row.valid_from else row.valid_from
        end = to_date if row.valid_to is None or to_date < row.valid_to else row.valid_to
        if start > end:
            continue
        segment = days_between(start, end) + 1
        covered += segment
        numerator += _checked(amount.minor, row.basis_points, segment)
    if covered != days:
        _rate_on(first)
        raise ValueError("no judgment interest rate for every day from %s to %s" % (first, to_date))
    if numerator > _MAX_EXACT:
        raise ValueError("the interest calculation exceeds the exact integer range")
    rate = _rate_on(to_date)
    interest = money(round_div(numerator, _YEAR, "half-up"), "GBP")
    return StatutoryInterest(
        days=days,
        rate_basis_points=rate,
        daily_interest=money(round_div(amount.minor * rate, _YEAR, "half-up"), "GBP"),
        interest=interest,
        compensation=zero,
        total=interest,
    )