from typing import Tuple from .lending_amortisation_schedule import amortisation_schedule, period_interest from .lending_loan_payment import payment_count from .lending_overpayment_effect_types import OverpaymentEffect from .math_round_div import RoundingMode from .money_amount import Money, money def _pay_off( balance: int, annual_rate_basis_points: int, payments_per_year: int, payment: int, term: int ) -> Tuple[int, int, int]: """Run a balance down at a fixed payment, with the schedule's rounding, until it is clear or the term ends: (payments, final payment, interest). As in lending.amortisation-schedule, the last payment of the term is whatever clears the balance.""" owed = balance payments = 0 interest = 0 final_payment = 0 while owed > 0: accrued = period_interest(owed, annual_rate_basis_points, payments_per_year) if payment <= accrued: raise ValueError("the payment of %d does not cover the interest of %d" % (payment, accrued)) due = owed + accrued if payments + 1 == term or owed + accrued <= payment else payment owed -= due - accrued interest += accrued payments += 1 final_payment = due return payments, final_payment, interest def overpayment_effect( balance: Money, annual_rate_basis_points: int, remaining_term_months: int, payments_per_year: int, payment: Money, overpayment: Money, mode: RoundingMode, ) -> OverpaymentEffect: """The two things a lender offers after a lump-sum overpayment, side by side: keep paying the same amount and finish sooner, or keep the same end date and pay less each period. Both are built period by period with the same rounding as lending.amortisation-schedule, and compared with carrying on as if nothing had been overpaid. """ term = payment_count(annual_rate_basis_points, remaining_term_months, payments_per_year) currency = balance.currency for other in (payment, overpayment): if other.currency != currency: raise ValueError("currency mismatch: %s and %s" % (currency, other.currency)) if payment.minor <= 0: raise ValueError("payment must be greater than zero, received %r" % (payment.minor,)) if overpayment.minor <= 0: raise ValueError("overpayment must be greater than zero, received %r" % (overpayment.minor,)) if overpayment.minor >= balance.minor: raise ValueError("overpayment must be less than the balance; paying it all off is an early settlement") new_balance = balance.minor - overpayment.minor base_n, _, base_interest = _pay_off(balance.minor, annual_rate_basis_points, payments_per_year, payment.minor, term) short_n, short_final, short_interest = _pay_off( new_balance, annual_rate_basis_points, payments_per_year, payment.minor, term ) lower = amortisation_schedule( money(new_balance, currency), annual_rate_basis_points, remaining_term_months, payments_per_year, mode ) return OverpaymentEffect( new_balance=money(new_balance, currency), baseline_payments=base_n, baseline_interest=money(base_interest, currency), reduced_term_payments=short_n, reduced_term_final_payment=money(short_final, currency), reduced_term_interest=money(short_interest, currency), payments_saved=base_n - short_n, reduced_payment=lower.payment, reduced_payment_interest=lower.total_interest, )