from typing import Optional, Sequence from .dates_add_days import add_days from .dates_add_months import add_months from .dates_days_between import days_between from .lending_early_settlement_types import DatedAmount, EarlySettlement, SettlementDeferral from .math_fractional_power import FIXED_SCALE, pow_fixed, root_fixed from .money_amount import money def _settlement_date( advances: Sequence[DatedAmount], repayments: Sequence[DatedAmount], notice_received_iso: str, requested_settlement_iso: Optional[str], deferral: SettlementDeferral, ) -> str: """The settlement date: 28 days after the creditor receives the notice (regulation 5(1)(a)), or a later date the notice names, then deferred by a month or 30 days where the creditor elects and regulation 6 allows it (credit repaid over more than a year from the first drawdown).""" date = add_days(notice_received_iso, 28) if requested_settlement_iso is not None: days_between(requested_settlement_iso, requested_settlement_iso) if requested_settlement_iso > date: date = requested_settlement_iso if deferral == "none": return date if deferral not in ("one-month", "thirty-days"): raise ValueError('unknown deferral "%s": expected none, one-month or thirty-days' % (deferral,)) first = min(a.date for a in advances) last = max(r.date for r in repayments) if last <= add_months(first, 12): raise ValueError("deferral is only allowed when the credit is repaid over more than a year (regulation 6)") return add_months(date, 1) if deferral == "one-month" else add_days(date, 30) def early_settlement( advances: Sequence[DatedAmount], repayments: Sequence[DatedAmount], apr_basis_points: int, notice_received_iso: str, requested_settlement_iso: Optional[str], deferral: SettlementDeferral, ) -> EarlySettlement: """The amount a debtor pays to settle a regulated consumer credit agreement early, and the rebate, per the Consumer Credit (Early Settlement) Regulations 2004, regulation 4(1). The rebate is the scheduled repayments falling due after the settlement date less sum A_i (1 + r)^a_i - sum B_j (1 + r)^b_j over the advances and the repayments made before the settlement date, each carried forward to the settlement date at the APR. That difference is the settlement figure. Times are days / 365 of a year. """ if len(advances) == 0: raise ValueError("advances must not be empty") if len(repayments) == 0: raise ValueError("repayments must not be empty") b = apr_basis_points if isinstance(b, bool) or not isinstance(b, int) or b < 0 or b > 1000000: raise ValueError("aprBasisPoints must be between 0 and 1000000, received %r" % (b,)) currency = advances[0].amount.currency for flow in list(advances) + list(repayments): days_between(flow.date, flow.date) if flow.amount.currency != currency: raise ValueError("currency mismatch: %s and %s" % (currency, flow.amount.currency)) if flow.amount.minor <= 0: raise ValueError("every amount must be greater than zero, received %r" % (flow.amount.minor,)) date = _settlement_date(advances, repayments, notice_received_iso, requested_settlement_iso, deferral) # (1 + APR)^(1/365), once; each flow is then a whole power of it. daily = root_fixed(FIXED_SCALE * (10000 + b) // 10000, 365) def carried(flow: DatedAmount) -> int: return flow.amount.minor * pow_fixed(daily, days_between(flow.date, date)) owed = 0 drawn = False for advance in advances: if advance.date < date: owed += carried(advance) drawn = True if not drawn: raise ValueError("no credit was advanced before the settlement date %s" % (date,)) remaining = 0 for repayment in repayments: if repayment.date < date: owed -= carried(repayment) else: remaining += repayment.amount.minor # Rounded down: the rebate must be at least the one the formula gives. formula = owed // FIXED_SCALE if owed > 0 else 0 settlement = min(formula, remaining) return EarlySettlement( settlement_date=date, settlement_amount=money(settlement, currency), remaining_repayments=money(remaining, currency), rebate=money(remaining - settlement, currency), )