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),
)