from __future__ import annotations from dataclasses import dataclass from datetime import date from decimal import ROUND_HALF_UP, Decimal MONEY = Decimal("0.01") @dataclass(frozen=True) class LoanPayment: number: int payment_date: date | None payment: Decimal principal: Decimal interest: Decimal remaining_principal: Decimal def quantize_money(value: Decimal) -> Decimal: return value.quantize(MONEY, rounding=ROUND_HALF_UP) def annuity_payment(principal: Decimal, months: int, annual_rate: Decimal) -> Decimal: if principal <= 0: raise ValueError("principal must be positive") if months <= 0: raise ValueError("months must be positive") if annual_rate < 0: raise ValueError("annual_rate must be non-negative") if annual_rate == 0: return quantize_money(principal / Decimal(months)) monthly_rate = annual_rate / Decimal("12") / Decimal("100") factor = (Decimal("1") + monthly_rate) ** months payment = principal * monthly_rate * factor / (factor - Decimal("1")) return quantize_money(payment) def loan_summary(principal: Decimal, months: int, annual_rate: Decimal) -> dict: payment = annuity_payment(principal, months, annual_rate) total_payment = quantize_money(payment * Decimal(months)) total_interest = max(total_payment - principal, Decimal("0")).quantize(MONEY) return { "monthly_payment": payment, "total_payment": total_payment, "overpayment": total_interest, "total_interest": total_interest, "principal": principal, "months": months, "annual_rate": annual_rate, } def generate_annuity_schedule( *, principal: Decimal, months: int, annual_rate: Decimal, first_payment_date: date | None = None, ) -> list[LoanPayment]: payment = annuity_payment(principal, months, annual_rate) monthly_rate = annual_rate / Decimal("12") / Decimal("100") remaining = principal rows: list[LoanPayment] = [] for number in range(1, months + 1): interest = quantize_money(remaining * monthly_rate) if annual_rate else Decimal("0.00") principal_part = payment - interest if number == months or principal_part > remaining: principal_part = remaining payment_for_row = principal_part + interest else: payment_for_row = payment remaining = max(remaining - principal_part, Decimal("0")) rows.append( LoanPayment( number=number, payment_date=None if first_payment_date is None else add_months(first_payment_date, number - 1), payment=quantize_money(payment_for_row), principal=quantize_money(principal_part), interest=quantize_money(interest), remaining_principal=quantize_money(remaining), ) ) return rows def add_months(value: date, months: int) -> date: import calendar month = value.month - 1 + months year = value.year + month // 12 month = month % 12 + 1 day = min(value.day, calendar.monthrange(year, month)[1]) return date(year, month, day)