Functional Weave
Code in Python

finance.late-payment-interest@1.0.1

impl/python.py

3,862 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  ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_days_between import days_between  ← from dates.days-between ^1.0.0 · built alongside by fune
from .finance_late_payment_interest_data import FIXED_SUMS, FIXED_SUMS_HISTORY  ← this capability’s own data, compiled from data/fixed-sums.json into the same file by fune build
from .finance_late_payment_interest_types import LatePaymentClaim
from .finance_uk_bank_rate import uk_bank_rate  ← from finance.uk-bank-rate ^1.0.0 · built alongside by fune
from .math_round_div import round_div  ← from math.round-div ^1.0.0 · built alongside by fune
from .money_add import add_money  ← from money.add ^1.0.0 · built alongside by fune
from .money_amount import Money, money  ← from money.amount ^1.0.0 · built alongside by fune

_STATUTORY_MARGIN = 800  # 8% over the reference rate, SI 2002/1675 art. 4
_MAX_EXACT = 2**53 - 1


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 _pruned_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 figure 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 debts." % (what, on_date, history, earliest)
        )


def _fixed_sum_for(debt_minor: int, on_date: str) -> int:
    best = None
    for row in FIXED_SUMS:
        if not _in_force(on_date, row.valid_from, row.valid_to) or debt_minor < row.min_debt_minor:
            continue
        if best is None or row.min_debt_minor > best.min_debt_minor:
            best = row
    if best is None:
        _pruned_horizon(FIXED_SUMS_HISTORY, [r.valid_from for r in FIXED_SUMS], on_date, "Late Payment Act fixed sum")
        raise ValueError("no Late Payment Act fixed sum in force on %s" % (on_date,))
    return best.compensation_minor


def _reference_date_for(start_date: str) -> str:
    """The 30 June or 31 December immediately before the day interest starts."""
    year = int(start_date[0:4])
    month = int(start_date[5:7])
    return "%04d-12-31" % (year - 1,) if month <= 6 else "%s-06-30" % (start_date[0:4],)


def late_payment_interest(debt: Money, due_date: str, payment_date: str) -> LatePaymentClaim:
    """Statutory interest and the fixed sum on a late commercial debt (UK).

    The reference rate is fixed when interest starts, not when it is paid, and
    interest is simple at 1/365 of the yearly rate per day, rounded once.
    """
    if debt.currency != "GBP":
        raise ValueError("late payment interest is defined for GBP debts, received %s" % (debt.currency,))
    if debt.minor <= 0:
        raise ValueError("debt must be greater than zero")
    late = days_between(due_date, payment_date)
    zero = money(0, "GBP")
    if late <= 0:
        return LatePaymentClaim(
            days_late=0,
            reference_date=None,
            reference_rate_basis_points=None,
            interest_rate_basis_points=None,
            interest=zero,
            compensation=zero,
            total=zero,
        )

    start_date = add_days(due_date, 1)
    compensation = money(_fixed_sum_for(debt.minor, start_date), "GBP")
    reference_date = _reference_date_for(start_date)
    reference_rate = uk_bank_rate(reference_date).basis_points
    rate = _STATUTORY_MARGIN + reference_rate

    if debt.minor * rate * late > _MAX_EXACT:
        raise ValueError("the interest calculation exceeds the exact integer range")
    interest = money(round_div(debt.minor * rate * late, 10000 * 365, "half-up"), "GBP")
    return LatePaymentClaim(
        days_late=late,
        reference_date=reference_date,
        reference_rate_basis_points=reference_rate,
        interest_rate_basis_points=rate,
        interest=interest,
        compensation=compensation,
        total=add_money(interest, compensation),
    )