diff --git a/Dockerfile b/Dockerfile index 7c3220a..392cb47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-slim +FROM python:3.13-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 @@ -16,4 +16,4 @@ COPY app ./app RUN mkdir -p /app/exports EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8147"] diff --git a/app/main.py b/app/main.py index 0b5aeef..952cfa1 100644 --- a/app/main.py +++ b/app/main.py @@ -9,6 +9,7 @@ from datetime import datetime from pathlib import Path import httpx +from io import BytesIO from bs4 import BeautifulSoup from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Response from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse @@ -21,6 +22,8 @@ from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak from reportlab.graphics.shapes import Drawing, Line, PolyLine, Rect, String, Wedge +from openpyxl import load_workbook + from .models import SimulationRequest from .simulator import simulate @@ -94,39 +97,107 @@ async def ws_simulate(websocket: WebSocket): except Exception as exc: await websocket.send_json({"error": str(exc)}) - @app.get("/api/rate/nbp") async def nbp_rate(): - """Pomocniczo pobiera stopę referencyjną NBP z oficjalnej strony HTML.""" - url = "https://nbp.pl/en/monetary-policy/mpc-decisions/interest-rates/" - try: - async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client: - resp = await client.get(url, headers={"User-Agent": "mortgage-simulator/1.0"}) - resp.raise_for_status() - soup = BeautifulSoup(resp.text, "html.parser") - text = " ".join(soup.get_text(" ").split()) - patterns = [ - r"Reference rate[^0-9]{0,80}(\d+[\.,]\d+|\d+)\s*%", - r"minimum money market intervention rate[^0-9]{0,80}(\d+[\.,]\d+|\d+)\s*%", - ] - for pattern in patterns: - match = re.search(pattern, text, flags=re.IGNORECASE) - if match: - value = float(match.group(1).replace(",", ".")) - return {"source": "NBP", "url": url, "rate": value, "fetched_at": datetime.utcnow().isoformat() + "Z"} - return JSONResponse({"error": "Nie znaleziono stopy w treści strony NBP", "url": url}, status_code=502) - except Exception as exc: - return JSONResponse({"error": str(exc), "url": url}, status_code=502) + """ + Pobiera średnie oprocentowanie nowych kredytów mieszkaniowych PLN z XLSX NBP. + Uwaga: to nie jest stopa referencyjna NBP, tylko średnie oprocentowanie kredytów. + """ + url = "https://static.nbp.pl/dane/statystyka/inne/stopy_proc_pl_srdW.xlsx" + try: + async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client: + resp = await client.get( + url, + headers={ + "User-Agent": "Mozilla/5.0 mortgage-simulator/1.0", + "Accept": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,*/*", + }, + ) + resp.raise_for_status() + + wb = load_workbook(BytesIO(resp.content), data_only=True, read_only=True) + ws = wb["4 OPN2PLN"] + + target_row = None + + for row in ws.iter_rows(): + values = [cell.value for cell in row] + joined = " ".join(str(v).lower() for v in values if v is not None) + + if "nieruchomości mieszkaniowe" in joined or "nieruchomosci mieszkaniowe" in joined: + target_row = values + break + + if not target_row: + return JSONResponse( + { + "error": "Nie znaleziono wiersza: na nieruchomości mieszkaniowe", + "url": url, + "sheet": "4 OPN2PLN", + }, + status_code=502, + ) + + # Daty są w wierszu 2, wartości w znalezionym wierszu. + dates = [cell.value for cell in ws[2]] + + latest_rate = None + latest_date = None + + for idx in range(len(target_row) - 1, -1, -1): + value = target_row[idx] + + if isinstance(value, (int, float)): + latest_rate = float(value) + latest_date_cell = dates[idx] if idx < len(dates) else None + + if isinstance(latest_date_cell, datetime): + latest_date = latest_date_cell.date().isoformat() + elif latest_date_cell: + latest_date = str(latest_date_cell) + + break + + if latest_rate is None: + return JSONResponse( + { + "error": "Nie znaleziono wartości liczbowej oprocentowania kredytu mieszkaniowego", + "url": url, + "sheet": "4 OPN2PLN", + }, + status_code=502, + ) + + # W XLSX wartości są jako ułamek, np. 0.0591 = 5.91%. + rate_percent = round(latest_rate * 100, 4) + + return { + "source": "NBP", + "kind": "average_new_housing_loan_pln_rate", + "label": "Średnie oprocentowanie nowych kredytów mieszkaniowych PLN", + "url": url, + "sheet": "4 OPN2PLN", + "period": latest_date, + "rate": rate_percent, + "fetched_at": datetime.utcnow().isoformat() + "Z", + } + + except Exception as exc: + return JSONResponse( + {"error": str(exc), "url": url}, + status_code=502, + ) + @app.post("/api/export/csv") def export_csv(req: SimulationRequest): result = simulate(req) buf = io.StringIO() writer = csv.writer(buf, delimiter=";") - writer.writerow(["miesiac", "oprocentowanie", "rata", "kapital", "odsetki", "nadplata", "saldo"]) + writer.writerow(["miesiac", "data_splaty", "dni", "oprocentowanie", "rata", "kapital", "odsetki", "nadplata", "prowizja_nadplaty", "saldo", "karencja", "odsetki_narastajaco", "koszt_narastajaco", "nadplaty_narastajaco"]) for row in result.schedule: - writer.writerow([row.month, row.rate, row.payment, row.principal_part, row.interest_part, row.overpayment, row.remaining]) + writer.writerow([row.month, row.due_date, row.days, row.rate, row.payment, row.principal_part, row.interest_part, row.overpayment, row.overpayment_fee, row.remaining, row.grace_type.value, row.cumulative_interest, row.cumulative_cost, row.cumulative_overpayment]) writer.writerow([]) writer.writerow(["Podsumowanie"]) for key, value in result.summary.model_dump().items(): @@ -278,6 +349,8 @@ def export_pdf(req: SimulationRequest): ["Stopa bazowa", _pct(req.base_rate), "Marza", _pct(req.margin)], ["Oprocentowanie startowe", _pct(req.base_rate + req.margin), "Typ rat", _installment_label(req.installment_type.value)], ["Efekt nadplat", _effect_label(req.overpayment_effect.value), "Liczba rat po symulacji", str(result.summary.months)], + ["Data startu", req.loan_start_date.isoformat(), "Dzien splaty", str(req.due_day)], + ["Przesuwaj dni wolne", "tak" if req.move_due_date_to_business_day else "nie", "Data konca", result.summary.payoff_date or "-"], ] story.append(Paragraph("Parametry wejściowe", styles["Heading2"])) story.append(Table(params, colWidths=[4.9 * cm, 3.4 * cm, 4.4 * cm, 3.9 * cm], style=TableStyle([ @@ -296,16 +369,17 @@ def export_pdf(req: SimulationRequest): ["Liczba rat", s.months, "Bazowo", s.baseline_months], ["Suma zaplacona", _money(s.total_paid), "Suma odsetek", _money(s.total_interest)], ["Oszczednosc na odsetkach", _money(s.interest_saved), "Skrocenie okresu", f"{s.months_saved} mies."], - ["Suma nadplat", _money(s.total_overpayment), "Srednia / maks. rata", f"{_money(s.average_payment)} / {_money(s.max_payment)}"], + ["Suma nadplat", _money(s.total_overpayment), "Prowizje nadplat", _money(s.total_overpayment_fees)], + ["Srednia / maks. rata", f"{_money(s.average_payment)} / {_money(s.max_payment)}", "Data splaty", s.payoff_date or "-"], ] story.append(Paragraph("Podsumowanie", styles["Heading2"])) - story.append(Table(summary, colWidths=[4.9 * cm, 3.4 * cm, 4.4 * cm, 3.9 * cm], style=TableStyle([ + story.append(Table(summary, colWidths=[4.3 * cm, 4.25 * cm, 3.7 * cm, 4.35 * cm], style=TableStyle([ ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")), ("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#ffffff")), ("FONTNAME", (0, 0), (-1, -1), APP_FONT), ("FONTNAME", (0, 0), (0, -1), APP_FONT_BOLD), ("FONTNAME", (2, 0), (2, -1), APP_FONT_BOLD), - ("FONTSIZE", (0, 0), (-1, -1), 7.4), + ("FONTSIZE", (0, 0), (-1, -1), 7.0), ("VALIGN", (0, 0), (-1, -1), "TOP"), ]))) story.append(Spacer(1, 0.35 * cm)) @@ -344,34 +418,61 @@ def export_pdf(req: SimulationRequest): story.append(Spacer(1, 0.35 * cm)) story.append(Paragraph("Nadplaty", styles["Heading2"])) - over_rows = [["Miesiac", "Kwota", "Powtarzanie", "Do miesiaca"]] + over_rows = [["Miesiac", "Kwota", "Prowizja", "Powtarzanie", "Do miesiaca"]] if req.overpayments: for op in sorted(req.overpayments, key=lambda x: x.month): - over_rows.append([str(op.month), _money(op.amount), _repeat_label(op.repeat), str(op.until_month or "-")]) + over_rows.append([str(op.month), _money(op.amount), _pct(op.commission_percent), _repeat_label(op.repeat), str(op.until_month or "-")]) else: - over_rows.append(["-", "-", "-", "-"]) - story.append(Table(over_rows, repeatRows=1, colWidths=[3 * cm, 4 * cm, 4 * cm, 4 * cm], style=TableStyle([ + over_rows.append(["-", "-", "-", "-", "-"]) + story.append(Table(over_rows, repeatRows=1, colWidths=[2.4 * cm, 3.4 * cm, 2.4 * cm, 3.4 * cm, 3.4 * cm], style=TableStyle([ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")), ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")), ("FONTNAME", (0, 0), (-1, -1), APP_FONT), ("FONTNAME", (0, 0), (-1, 0), APP_FONT_BOLD), ("FONTSIZE", (0, 0), (-1, -1), 8), ]))) + story.append(Spacer(1, 0.35 * cm)) + + story.append(Paragraph("Kredyt historyczny", styles["Heading2"])) + hist_rows = [["Miesiac", "Oprocentowanie", "Karencja", "Nadplata", "Prowizja", "Opis"]] + if req.historical_months: + for item in sorted(req.historical_months, key=lambda x: x.month): + hist_rows.append([ + str(item.month), + _pct(item.annual_rate) if item.annual_rate is not None else "-", + item.grace_type.value, + _money(item.overpayment), + _pct(item.overpayment_commission_percent), + item.note or "-", + ]) + else: + hist_rows.append(["-", "-", "-", "-", "-", "-"]) + story.append(Table(hist_rows, repeatRows=1, colWidths=[2 * cm, 2.6 * cm, 2.6 * cm, 2.8 * cm, 2.2 * cm, 4.5 * cm], style=TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")), + ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")), + ("FONTNAME", (0, 0), (-1, -1), APP_FONT), + ("FONTNAME", (0, 0), (-1, 0), APP_FONT_BOLD), + ("FONTSIZE", (0, 0), (-1, -1), 7.5), + ]))) story.append(PageBreak()) story.append(Paragraph("Kompletny harmonogram wszystkich rat", styles["Heading2"])) - schedule_rows = [["Mies.", "Oproc.", "Rata", "Kapital", "Odsetki", "Nadplata", "Saldo"]] + schedule_rows = [["Mies.", "Data", "Dni", "Oproc.", "Rata", "Kapital", "Odsetki", "Nadplata", "Prow.", "Koszt nar.", "Saldo"]] for row in result.schedule: schedule_rows.append([ row.month, + row.due_date, + row.days, _pct(row.rate), _money(row.payment), _money(row.principal_part), _money(row.interest_part), _money(row.overpayment), + _money(row.overpayment_fee), + _money(row.cumulative_cost), _money(row.remaining), ]) - table = Table(schedule_rows, repeatRows=1, colWidths=[1.35 * cm, 1.7 * cm, 2.55 * cm, 2.55 * cm, 2.55 * cm, 2.55 * cm, 3.0 * cm]) + table = Table(schedule_rows, repeatRows=1, colWidths=[0.9 * cm, 1.75 * cm, 0.75 * cm, 1.15 * cm, 1.85 * cm, 1.85 * cm, 1.85 * cm, 1.85 * cm, 1.5 * cm, 2.0 * cm, 2.05 * cm]) table.setStyle(TableStyle([ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")), ("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#111827")), diff --git a/app/models.py b/app/models.py index 75eece0..97243d7 100644 --- a/app/models.py +++ b/app/models.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import date from enum import Enum from typing import Literal from pydantic import BaseModel, Field @@ -15,6 +16,12 @@ class OverpaymentEffect(str, Enum): lower_payment = "lower_payment" +class GraceType(str, Enum): + none = "none" + interest_only = "interest_only" + full = "full" + + class RateChange(BaseModel): month: int = Field(ge=1, description="Miesiac od startu kredytu") annual_rate: float = Field(ge=0, le=30, description="Roczne oprocentowanie procentowo") @@ -25,6 +32,16 @@ class Overpayment(BaseModel): amount: float = Field(gt=0) repeat: Literal["once", "monthly", "yearly"] = "once" until_month: int | None = Field(default=None, ge=1) + commission_percent: float = Field(default=0, ge=0, le=20, description="Prowizja od nadplaty w procentach") + + +class HistoricalMonth(BaseModel): + month: int = Field(ge=1) + annual_rate: float | None = Field(default=None, ge=0, le=30) + grace_type: GraceType = GraceType.none + overpayment: float = Field(default=0, ge=0) + overpayment_commission_percent: float = Field(default=0, ge=0, le=20) + note: str = "" class SimulationRequest(BaseModel): @@ -34,18 +51,29 @@ class SimulationRequest(BaseModel): base_rate: float = Field(ge=0, le=30, default=5.75) installment_type: InstallmentType = InstallmentType.equal overpayment_effect: OverpaymentEffect = OverpaymentEffect.shorten + loan_start_date: date = Field(default_factory=date.today) + due_day: int = Field(default=5, ge=1, le=28, description="Dzien splaty raty") + move_due_date_to_business_day: bool = True rate_changes: list[RateChange] = Field(default_factory=list) overpayments: list[Overpayment] = Field(default_factory=list) + historical_months: list[HistoricalMonth] = Field(default_factory=list) class ScheduleRow(BaseModel): month: int + due_date: str + days: int rate: float payment: float principal_part: float interest_part: float overpayment: float + overpayment_fee: float remaining: float + grace_type: GraceType = GraceType.none + cumulative_interest: float + cumulative_cost: float + cumulative_overpayment: float class Summary(BaseModel): @@ -53,12 +81,14 @@ class Summary(BaseModel): total_paid: float total_interest: float total_overpayment: float + total_overpayment_fees: float interest_saved: float months_saved: int baseline_interest: float baseline_months: int average_payment: float max_payment: float + payoff_date: str | None = None class SimulationResponse(BaseModel): diff --git a/app/simulator.py b/app/simulator.py index 9bf2ca3..786bf16 100644 --- a/app/simulator.py +++ b/app/simulator.py @@ -1,14 +1,94 @@ from __future__ import annotations +from datetime import date, timedelta from math import pow -from .models import SimulationRequest, ScheduleRow, Summary, SimulationResponse, InstallmentType, OverpaymentEffect + +from .models import ( + GraceType, + HistoricalMonth, + InstallmentType, + OverpaymentEffect, + ScheduleRow, + SimulationRequest, + SimulationResponse, + Summary, +) def _round_money(value: float) -> float: return round(max(value, 0.0) + 1e-9, 2) -def _rate_for_month(req: SimulationRequest, month: int) -> float: +def _add_months(base: date, months: int, day: int) -> date: + month_index = base.month - 1 + months + year = base.year + month_index // 12 + month = month_index % 12 + 1 + return date(year, month, min(day, 28)) + + +def _easter(year: int) -> date: + a = year % 19 + b = year // 100 + c = year % 100 + d = b // 4 + e = b % 4 + f = (b + 8) // 25 + g = (b - f + 1) // 3 + h = (19 * a + b - d - g + 15) % 30 + i = c // 4 + k = c % 4 + l = (32 + 2 * e + 2 * i - h - k) % 7 + m = (a + 11 * h + 22 * l) // 451 + month = (h + l - 7 * m + 114) // 31 + day = ((h + l - 7 * m + 114) % 31) + 1 + return date(year, month, day) + + +def _polish_holidays(year: int) -> set[date]: + easter = _easter(year) + return { + date(year, 1, 1), + date(year, 1, 6), + easter, + easter + timedelta(days=1), + date(year, 5, 1), + date(year, 5, 3), + easter + timedelta(days=60), + date(year, 8, 15), + date(year, 11, 1), + date(year, 11, 11), + date(year, 12, 25), + date(year, 12, 26), + } + + +def _is_business_day(value: date) -> bool: + return value.weekday() < 5 and value not in _polish_holidays(value.year) + + +def _move_to_business_day(value: date, enabled: bool) -> date: + if not enabled: + return value + while not _is_business_day(value): + value += timedelta(days=1) + return value + + +def _due_date_for_month(req: SimulationRequest, month: int) -> date: + raw = _add_months(req.loan_start_date, month, req.due_day) + return _move_to_business_day(raw, req.move_due_date_to_business_day) + + +def _historical_map(req: SimulationRequest) -> dict[int, HistoricalMonth]: + return {item.month: item for item in req.historical_months} + + +def _rate_for_month(req: SimulationRequest, month: int, historical: dict[int, HistoricalMonth] | None = None) -> float: + historical = historical or _historical_map(req) + hist = historical.get(month) + if hist and hist.annual_rate is not None: + return hist.annual_rate + changes = sorted(req.rate_changes, key=lambda x: x.month) rate = req.base_rate + req.margin for change in changes: @@ -19,6 +99,11 @@ def _rate_for_month(req: SimulationRequest, month: int) -> float: return rate +def _grace_for_month(req: SimulationRequest, month: int, historical: dict[int, HistoricalMonth]) -> GraceType: + hist = historical.get(month) + return hist.grace_type if hist else GraceType.none + + def _monthly_payment(balance: float, monthly_rate: float, months_left: int) -> float: if months_left <= 0: return balance @@ -28,89 +113,140 @@ def _monthly_payment(balance: float, monthly_rate: float, months_left: int) -> f return balance * monthly_rate * factor / (factor - 1) -def _overpayment_for_month(req: SimulationRequest, month: int) -> float: +def _scheduled_overpayment_for_month(req: SimulationRequest, month: int) -> tuple[float, float]: total = 0.0 + fee = 0.0 for op in req.overpayments: until = op.until_month or month + active = False if op.repeat == "once" and month == op.month: - total += op.amount + active = True elif op.repeat == "monthly" and month >= op.month and month <= until: - total += op.amount + active = True elif op.repeat == "yearly" and month >= op.month and month <= until and (month - op.month) % 12 == 0: + active = True + if active: total += op.amount - return total + fee += op.amount * op.commission_percent / 100 + return total, fee + + +def _historical_overpayment_for_month(historical: dict[int, HistoricalMonth], month: int) -> tuple[float, float]: + hist = historical.get(month) + if not hist or hist.overpayment <= 0: + return 0.0, 0.0 + return hist.overpayment, hist.overpayment * hist.overpayment_commission_percent / 100 + + +def _overpayment_for_month(req: SimulationRequest, month: int, historical: dict[int, HistoricalMonth]) -> tuple[float, float]: + scheduled_amount, scheduled_fee = _scheduled_overpayment_for_month(req, month) + hist_amount, hist_fee = _historical_overpayment_for_month(historical, month) + return scheduled_amount + hist_amount, scheduled_fee + hist_fee def _simulate_raw(req: SimulationRequest, include_overpayments: bool = True) -> list[ScheduleRow]: balance = float(req.principal) total_months = int(req.years * 12) rows: list[ScheduleRow] = [] - fixed_payment = None - recalculation_month = 1 + fixed_payment: float | None = None month = 1 + previous_due = req.loan_start_date + historical = _historical_map(req) + previous_rate: float | None = None + cumulative_interest = 0.0 + cumulative_cost = 0.0 + cumulative_overpayment = 0.0 while balance > 0.005 and month <= total_months + 600: months_left = max(total_months - month + 1, 1) - annual_rate = _rate_for_month(req, month) - monthly_rate = annual_rate / 100 / 12 - interest = balance * monthly_rate + due_date = _due_date_for_month(req, month) + days = max((due_date - previous_due).days, 1) + annual_rate = _rate_for_month(req, month, historical) + daily_rate = annual_rate / 100 / 365 + formula_monthly_rate = annual_rate / 100 / 12 + interest = balance * daily_rate * days + grace_type = _grace_for_month(req, month, historical) if req.installment_type == InstallmentType.equal: - if fixed_payment is None or req.overpayment_effect == OverpaymentEffect.lower_payment: - fixed_payment = _monthly_payment(balance, monthly_rate, months_left) + needs_recalc = fixed_payment is None or annual_rate != previous_rate + if include_overpayments and req.overpayment_effect == OverpaymentEffect.lower_payment: + needs_recalc = True + if needs_recalc: + fixed_payment = _monthly_payment(balance, formula_monthly_rate, months_left) payment = fixed_payment principal_part = max(payment - interest, 0.0) else: principal_base = req.principal / total_months if req.overpayment_effect == OverpaymentEffect.lower_payment: - active_months_left = max(total_months - month + 1, 1) - principal_base = balance / active_months_left + principal_base = balance / months_left payment = principal_base + interest principal_part = principal_base + if grace_type == GraceType.interest_only: + principal_part = 0.0 + payment = interest + elif grace_type == GraceType.full: + principal_part = 0.0 + payment = 0.0 + interest = 0.0 + if principal_part > balance: principal_part = balance payment = interest + principal_part balance -= principal_part - overpayment = _overpayment_for_month(req, month) if include_overpayments else 0.0 - overpayment = min(overpayment, balance) - balance -= overpayment + overpayment = 0.0 + overpayment_fee = 0.0 + if include_overpayments: + requested_overpayment, requested_fee = _overpayment_for_month(req, month, historical) + overpayment = min(requested_overpayment, balance) + overpayment_fee = requested_fee * (overpayment / requested_overpayment) if requested_overpayment > 0 else 0.0 + balance -= overpayment + + cumulative_interest += interest + cumulative_overpayment += overpayment + cumulative_cost += interest + overpayment_fee rows.append(ScheduleRow( month=month, + due_date=due_date.isoformat(), + days=days, rate=round(annual_rate, 4), payment=_round_money(payment), principal_part=_round_money(principal_part), interest_part=_round_money(interest), overpayment=_round_money(overpayment), + overpayment_fee=_round_money(overpayment_fee), remaining=_round_money(balance), + grace_type=grace_type, + cumulative_interest=_round_money(cumulative_interest), + cumulative_cost=_round_money(cumulative_cost), + cumulative_overpayment=_round_money(cumulative_overpayment), )) if include_overpayments and overpayment > 0 and req.overpayment_effect == OverpaymentEffect.lower_payment: fixed_payment = None - recalculation_month = month + 1 - - if include_overpayments and overpayment > 0 and req.overpayment_effect == OverpaymentEffect.shorten: - # przy skroceniu okresu rata zostaje z grubsza taka sama; petla zakonczy sie szybciej - pass + previous_due = due_date + previous_rate = annual_rate month += 1 - if month - recalculation_month > 1200: - break return rows def simulate(req: SimulationRequest) -> SimulationResponse: actual = _simulate_raw(req, include_overpayments=True) - baseline_req = req.model_copy(update={"overpayments": []}) + baseline_req = req.model_copy(update={"overpayments": [], "historical_months": [ + h.model_copy(update={"overpayment": 0.0, "overpayment_commission_percent": 0.0}) + for h in req.historical_months + ]}) baseline = _simulate_raw(baseline_req, include_overpayments=False) total_interest = sum(r.interest_part for r in actual) total_overpayment = sum(r.overpayment for r in actual) - total_paid = sum(r.payment + r.overpayment for r in actual) + total_overpayment_fees = sum(r.overpayment_fee for r in actual) + total_paid = sum(r.payment + r.overpayment + r.overpayment_fee for r in actual) baseline_interest = sum(r.interest_part for r in baseline) payments = [r.payment for r in actual] @@ -119,11 +255,13 @@ def simulate(req: SimulationRequest) -> SimulationResponse: total_paid=_round_money(total_paid), total_interest=_round_money(total_interest), total_overpayment=_round_money(total_overpayment), - interest_saved=_round_money(baseline_interest - total_interest), + total_overpayment_fees=_round_money(total_overpayment_fees), + interest_saved=_round_money(baseline_interest - total_interest - total_overpayment_fees), months_saved=max(len(baseline) - len(actual), 0), baseline_interest=_round_money(baseline_interest), baseline_months=len(baseline), average_payment=_round_money(sum(payments) / len(payments)) if payments else 0, max_payment=_round_money(max(payments)) if payments else 0, + payoff_date=actual[-1].due_date if actual else None, ) return SimulationResponse(schedule=actual, summary=summary) diff --git a/app/static/app.js b/app/static/app.js index 3b26ef7..c636111 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -2,12 +2,17 @@ const $ = (id) => document.getElementById(id); const money = (v) => new Intl.NumberFormat('pl-PL', { style: 'currency', currency: 'PLN', maximumFractionDigits: 0 }).format(v || 0); const num = (id) => Number($(id).value || 0); -let lineChart, pieChart, barChart; +let lineChart, pieChart, barChart, detailChart; let socket; let debounceTimer; let lastRequest = null; window.lastSimulationData = null; +function todayIso() { + const d = new Date(); + return d.toISOString().slice(0, 10); +} + function setTheme(theme) { document.body.dataset.theme = theme; localStorage.setItem('mortgage-theme', theme); @@ -43,9 +48,22 @@ function buildRequest() { month: Number(row.querySelector('[data-field="month"]').value || 1), amount: Number(row.querySelector('[data-field="amount"]').value || 0), repeat: row.querySelector('[data-field="repeat"]').value, - until_month: Number(row.querySelector('[data-field="until"]').value || 0) || null + until_month: Number(row.querySelector('[data-field="until"]').value || 0) || null, + commission_percent: Number(row.querySelector('[data-field="commission"]').value || 0) })).filter(x => x.month > 0 && x.amount > 0); + const historicalMonths = [...document.querySelectorAll('#historicalMonths .dynamic-row')].map(row => { + const rateRaw = row.querySelector('[data-field="rate"]').value; + return { + month: Number(row.querySelector('[data-field="month"]').value || 1), + annual_rate: rateRaw === '' ? null : Number(rateRaw), + grace_type: row.querySelector('[data-field="grace"]').value, + overpayment: Number(row.querySelector('[data-field="overpayment"]').value || 0), + overpayment_commission_percent: Number(row.querySelector('[data-field="commission"]').value || 0), + note: row.querySelector('[data-field="note"]').value || '' + }; + }).filter(x => x.month > 0); + return { principal: num('principal'), years: num('years'), @@ -53,8 +71,12 @@ function buildRequest() { base_rate: num('baseRate'), installment_type: $('installmentType').value, overpayment_effect: $('overpaymentEffect').value, + loan_start_date: $('loanStartDate').value || todayIso(), + due_day: num('dueDay') || 5, + move_due_date_to_business_day: $('moveDueDate').checked, rate_changes: rateChanges, - overpayments: overpayments + overpayments: overpayments, + historical_months: historicalMonths }; } @@ -68,7 +90,7 @@ function recalc() { function addRateRow(month = 13, rate = 7.0) { const div = document.createElement('div'); - div.className = 'dynamic-row'; + div.className = 'dynamic-row rate-row'; div.innerHTML = `
@@ -79,14 +101,15 @@ function addRateRow(month = 13, rate = 7.0) { recalc(); } -function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', until = '') { +function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', until = '', commission = 0) { const div = document.createElement('div'); div.className = 'dynamic-row overpay'; div.innerHTML = ` + - + `; div.querySelector('[data-field="repeat"]').value = repeat; div.querySelector('button').onclick = () => { div.remove(); recalc(); }; @@ -95,21 +118,41 @@ function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', until = recalc(); } +function addHistoricalRow(month = 1, rate = '', grace = 'none', overpayment = 0, commission = 0, note = '') { + const div = document.createElement('div'); + div.className = 'dynamic-row historical'; + div.innerHTML = ` + + + + + + + `; + div.querySelector('[data-field="grace"]').value = grace || 'none'; + div.querySelector('button').onclick = () => { div.remove(); recalc(); }; + div.querySelectorAll('input,select').forEach(x => x.addEventListener('input', recalc)); + $('historicalMonths').appendChild(div); + recalc(); +} + function render(data) { window.lastSimulationData = data; const s = data.summary; $('summaryCards').innerHTML = [ ['Odsetki', money(s.total_interest)], - ['Oszczędność', money(s.interest_saved)], + ['Oszczędność netto', money(s.interest_saved)], ['Nadpłaty', money(s.total_overpayment)], + ['Prowizje', money(s.total_overpayment_fees)], ['Okres', `${s.months} mies. / ${Math.ceil(s.months / 12)} lat`], + ['Data spłaty', s.payoff_date || '-'], ['Skrócenie', `${s.months_saved} mies.`], ['Średnia rata', money(s.average_payment)] - ].map(([label, value]) => `| Mies. | Oproc. | Rata | Kapitał | Odsetki | Nadpłata | Saldo | ||||
|---|---|---|---|---|---|---|---|---|---|---|
| Mies. | Data | Dni | Oproc. | Rata | Kapitał | Odsetki | Nadpłata | Prow. | Koszt nar. | Saldo |