diff --git a/finance/app/engine/__init__.py b/finance/app/engine/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/finance/app/engine/loans.py b/finance/app/engine/loans.py new file mode 100644 index 0000000..bae82eb --- /dev/null +++ b/finance/app/engine/loans.py @@ -0,0 +1,56 @@ +import calendar +from dataclasses import dataclass +from datetime import date +from decimal import ROUND_HALF_UP, Decimal + +CENT = Decimal("0.01") + + +@dataclass(frozen=True) +class Installment: + due: date + payment: Decimal + interest: Decimal + principal: Decimal + remaining: Decimal + + +def add_months(d: date, months: int) -> date: + y, m0 = divmod(d.year * 12 + d.month - 1 + months, 12) + m = m0 + 1 + return date(y, m, min(d.day, calendar.monthrange(y, m)[1])) + + +def annuity_payment(principal: Decimal, annual_rate_pct: Decimal, term_months: int) -> Decimal: + i = annual_rate_pct / Decimal(100) / Decimal(12) + if i == 0: + return (principal / term_months).quantize(CENT, ROUND_HALF_UP) + q = (Decimal(1) + i) ** term_months + return (principal * i * q / (q - Decimal(1))).quantize(CENT, ROUND_HALF_UP) + + +def loan_schedule(principal: Decimal, annual_rate_pct: Decimal, term_months: int, + payout_date: date, repayment_type: str) -> list[Installment]: + i = annual_rate_pct / Decimal(100) / Decimal(12) + plan: list[Installment] = [] + remaining = principal + if repayment_type == "bullet": + for n in range(1, term_months + 1): + interest = (remaining * i).quantize(CENT, ROUND_HALF_UP) + principal_part = remaining if n == term_months else Decimal("0") + remaining = remaining - principal_part + plan.append(Installment(add_months(payout_date, n), + -(interest + principal_part), interest, + principal_part, remaining.quantize(CENT))) + return plan + rate = annuity_payment(principal, annual_rate_pct, term_months) + for n in range(1, term_months + 1): + interest = (remaining * i).quantize(CENT, ROUND_HALF_UP) + principal_part = rate - interest + if n == term_months or principal_part > remaining: + principal_part = remaining + remaining = remaining - principal_part + plan.append(Installment(add_months(payout_date, n), + -(interest + principal_part), interest, + principal_part, remaining.quantize(CENT))) + return plan diff --git a/finance/tests/test_loans.py b/finance/tests/test_loans.py new file mode 100644 index 0000000..0e72f52 --- /dev/null +++ b/finance/tests/test_loans.py @@ -0,0 +1,29 @@ +from datetime import date +from decimal import Decimal + +from app.engine.loans import add_months, annuity_payment, loan_schedule + + +def test_add_months_clamps_month_end(): + assert add_months(date(2026, 1, 31), 1) == date(2026, 2, 28) + + +def test_annuity_payment(): + # 10.000 EUR, 6 % p.a., 48 Monate -> 234,85 EUR Monatsrate + assert annuity_payment(Decimal("10000"), Decimal("6"), 48) == Decimal("234.85") + + +def test_annuity_schedule_amortizes_fully(): + plan = loan_schedule(Decimal("10000"), Decimal("6"), 48, date(2026, 8, 1), "annuity") + assert len(plan) == 48 + assert plan[-1].remaining == Decimal("0.00") + assert plan[0].due == date(2026, 9, 1) + assert plan[0].interest == Decimal("50.00") # 10000 * 0,5 % + assert plan[0].payment < 0 + + +def test_bullet_loan(): + plan = loan_schedule(Decimal("10000"), Decimal("6"), 12, date(2026, 8, 1), "bullet") + assert all(i.principal == 0 for i in plan[:-1]) + assert plan[-1].principal == Decimal("10000") + assert plan[-1].remaining == Decimal("0.00")