from typing import Optional, Sequence from .dates_add_days import add_days, days_between from .finance_late_payment_interest import late_payment_interest 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 .money_amount import Money, money _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, )