from typing import Optional, Sequence from .dates_add_days import add_days from .dates_days_between import days_between from .finance_late_payment_interest_data import FIXED_SUMS, FIXED_SUMS_HISTORY from .finance_late_payment_interest_types import LatePaymentClaim from .finance_uk_bank_rate import uk_bank_rate from .math_round_div import round_div from .money_add import add_money from .money_amount import Money, money _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), )