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]) => `
${label}
${value}
`).join(''); + ].map(([label, value]) => `
${label}
${value}
`).join(''); - $('resultText').textContent = `Dzięki nadpłatom w wysokości ${money(s.total_overpayment)} oszczędzasz około ${money(s.interest_saved)} na odsetkach. Kredyt kończy się po ${s.months} miesiącach zamiast ${s.baseline_months}. Łącznie płacisz ${money(s.total_paid)}, z czego odsetki to ${money(s.total_interest)}.`; + $('resultText').textContent = `Dzięki nadpłatom ${money(s.total_overpayment)} oszczędność netto wynosi około ${money(s.interest_saved)} po uwzględnieniu prowizji ${money(s.total_overpayment_fees)}. Kredyt kończy się ${s.payoff_date || '—'} po ${s.months} miesiącach zamiast ${s.baseline_months}. Odsetki: ${money(s.total_interest)}.`; - $('scheduleTable').innerHTML = data.schedule.slice(0, 180).map(r => `${r.month}${r.rate.toFixed(2)}%${money(r.payment)}${money(r.principal_part)}${money(r.interest_part)}${money(r.overpayment)}${money(r.remaining)}`).join(''); + $('scheduleTable').innerHTML = data.schedule.slice(0, 240).map(r => `${r.month}${r.due_date}${r.days}${r.rate.toFixed(2)}%${money(r.payment)}${money(r.principal_part)}${money(r.interest_part)}${money(r.overpayment)}${money(r.overpayment_fee)}${money(r.cumulative_cost)}${money(r.remaining)}`).join(''); renderCharts(data); } @@ -119,6 +162,10 @@ function renderCharts(data) { const labels = data.schedule.map(r => r.month); const balance = data.schedule.map(r => r.remaining); const payment = data.schedule.map(r => r.payment); + const principal = data.schedule.map(r => r.principal_part); + const interest = data.schedule.map(r => r.interest_part); + const cumulativeCost = data.schedule.map(r => r.cumulative_cost); + const overpayments = data.schedule.map(r => r.overpayment); const yearly = new Map(); data.schedule.forEach(r => { const year = Math.ceil(r.month / 12); @@ -135,7 +182,7 @@ function renderCharts(data) { pieChart?.destroy(); pieChart = new Chart($('pieChart'), { type: 'pie', - data: { labels: ['Kapitał', 'Odsetki', 'Nadpłaty'], datasets: [{ data: [lastRequest.principal, data.summary.total_interest, data.summary.total_overpayment] }] }, + data: { labels: ['Kapitał', 'Odsetki', 'Nadpłaty', 'Prowizje'], datasets: [{ data: [lastRequest.principal, data.summary.total_interest, data.summary.total_overpayment, data.summary.total_overpayment_fees] }] }, options: { responsive: true, maintainAspectRatio: false } }); @@ -145,6 +192,20 @@ function renderCharts(data) { data: { labels: [...yearly.keys()].map(x => `Rok ${x}`), datasets: [{ label: 'Odsetki', data: [...yearly.values()] }] }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } } }); + + detailChart?.destroy(); + detailChart = new Chart($('detailChart'), { + data: { + labels, + datasets: [ + { type: 'bar', label: 'Kapitał w racie', data: principal, stack: 'rata', yAxisID: 'y' }, + { type: 'bar', label: 'Odsetki w racie', data: interest, stack: 'rata', yAxisID: 'y' }, + { type: 'bar', label: 'Nadpłata', data: overpayments, stack: 'rata', yAxisID: 'y' }, + { type: 'line', label: 'Koszt narastająco', data: cumulativeCost, yAxisID: 'y1' } + ] + }, + options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, scales: { x: { stacked: true }, y: { stacked: true, beginAtZero: true }, y1: { position: 'right', beginAtZero: true, grid: { drawOnChartArea: false } } } } + }); } async function download(endpoint, filename) { @@ -158,6 +219,51 @@ async function download(endpoint, filename) { URL.revokeObjectURL(url); } +function exportJson() { + const blob = new Blob([JSON.stringify(buildRequest(), null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'parametry-kredytu.json'; + a.click(); + URL.revokeObjectURL(url); +} + +function clearRows() { + $('rateChanges').innerHTML = ''; + $('overpayments').innerHTML = ''; + $('historicalMonths').innerHTML = ''; +} + +function applyRequest(data) { + $('principal').value = data.principal ?? 600000; + $('years').value = data.years ?? 25; + $('margin').value = data.margin ?? 2; + $('baseRate').value = data.base_rate ?? 5.75; + $('installmentType').value = data.installment_type ?? 'equal'; + $('overpaymentEffect').value = data.overpayment_effect ?? 'shorten'; + $('loanStartDate').value = data.loan_start_date ?? todayIso(); + $('dueDay').value = data.due_day ?? 5; + $('moveDueDate').checked = data.move_due_date_to_business_day ?? true; + clearRows(); + (data.rate_changes || []).forEach(x => addRateRow(x.month, x.annual_rate)); + (data.overpayments || []).forEach(x => addOverpaymentRow(x.month, x.amount, x.repeat, x.until_month || '', x.commission_percent || 0)); + (data.historical_months || []).forEach(x => addHistoricalRow(x.month, x.annual_rate ?? '', x.grace_type || 'none', x.overpayment || 0, x.overpayment_commission_percent || 0, x.note || '')); + recalc(); +} + +function importJsonFile(file) { + const reader = new FileReader(); + reader.onload = () => { + try { + applyRequest(JSON.parse(reader.result)); + } catch (e) { + alert(`Nie udało się wczytać JSON: ${e.message}`); + } + }; + reader.readAsText(file, 'utf-8'); +} + async function loadNbp() { const btn = $('loadNbp'); const old = btn.textContent; @@ -176,14 +282,19 @@ async function loadNbp() { } } -['principal','years','baseRate','margin','installmentType','overpaymentEffect'].forEach(id => $(id).addEventListener('input', recalc)); +['principal','years','baseRate','margin','installmentType','overpaymentEffect','loanStartDate','dueDay','moveDueDate'].forEach(id => $(id).addEventListener('input', recalc)); $('addRate').onclick = () => addRateRow(); $('addOverpayment').onclick = () => addOverpaymentRow(); +$('addHistorical').onclick = () => addHistoricalRow(); $('exportCsv').onclick = () => download('/api/export/csv', 'symulacja-kredytu.csv'); $('exportPdf').onclick = () => download('/api/export/pdf', 'symulacja-kredytu.pdf'); +$('exportJson').onclick = exportJson; +$('importJson').onclick = () => $('jsonFile').click(); +$('jsonFile').onchange = (e) => e.target.files?.[0] && importJsonFile(e.target.files[0]); $('loadNbp').onclick = loadNbp; +$('loanStartDate').value = todayIso(); initTheme(); -addOverpaymentRow(24, 20000, 'once', ''); -addOverpaymentRow(36, 500, 'monthly', 120); +addOverpaymentRow(24, 20000, 'once', '', 0); +addOverpaymentRow(36, 500, 'monthly', 120, 0); connectWs(); diff --git a/app/static/index.html b/app/static/index.html index 07e34d1..9b74bf1 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -18,8 +18,11 @@
+ + +
@@ -60,6 +63,20 @@ +
+ + +
+
+ + +
+
+ +

@@ -75,6 +92,16 @@
+ +
+
+
+

Kredyt historyczny

+
Miesięczne oprocentowanie, karencja i faktyczne nadpłaty
+
+ +
+
@@ -106,6 +133,14 @@ +
+
+
+

Podział raty i koszt narastająco

+ +
+
+
@@ -119,7 +154,7 @@

Harmonogram

- +
Mies.Oproc.RataKapitałOdsetkiNadpłataSaldo
Mies.DataDniOproc.RataKapitałOdsetkiNadpłataProw.Koszt nar.Saldo
diff --git a/app/static/styles.css b/app/static/styles.css index 060ff0d..8440c87 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -27,13 +27,13 @@ } body[data-theme="dark"] { - --bg: #05070d; + --bg: #03050a; --text: #e5e7eb; - --card: #0d1422; + --card: #0b1220; --muted: #9aa7bb; - --border: #10192a; - --row: #111a2b; - --input-bg: #070b13; + --border: #2a3547; + --row: #101827; + --input-bg: #050914; --input-text: #e5e7eb; --table-border: #3b4658; } @@ -100,14 +100,15 @@ body { padding: .55rem; border-radius: 12px; } -.dynamic-row.overpay { grid-template-columns: .75fr 1fr .9fr .75fr auto; } +.dynamic-row.overpay { grid-template-columns: .75fr 1fr .75fr .9fr .75fr auto; } +.dynamic-row.historical { grid-template-columns: .65fr .8fr 1fr .9fr .7fr 1.2fr auto; } .stat-card { border-radius: 16px; } .stat-value { font-size: 1.15rem; font-weight: 700; } .table { font-size: .84rem; } .btn { border-radius: 999px; } @media (max-width: 768px) { - .dynamic-row, .dynamic-row.overpay { grid-template-columns: 1fr 1fr; } + .dynamic-row, .dynamic-row.overpay, .dynamic-row.historical { grid-template-columns: 1fr 1fr; } .dynamic-row button { grid-column: span 2; } } @@ -115,3 +116,34 @@ body { body[data-theme="dark"] .card { box-shadow: 0 14px 38px rgba(0, 0, 0, .34) !important; } body[data-theme="dark"] .btn-outline-secondary { border-color: var(--border); color: var(--text); } body[data-theme="dark"] .btn-outline-primary { border-color: #52637a; } + +.form-check-input { background-color: var(--input-bg); border-color: var(--border); } +body[data-theme="dark"] hr { border-color: var(--border); opacity: 1; } + +@media (min-width: 1200px) { + .slim-card { + max-height: calc(100vh - 2rem); + display: flex; + flex-direction: column; + } + + .slim-card > .card-body { + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + padding-right: 1rem; + } +} + +.slim-card > .card-body::-webkit-scrollbar { + width: 6px; +} + +.slim-card > .card-body::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.18); + border-radius: 999px; +} + +.slim-card > .card-body::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.28); +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 77108f6..f668817 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ httpx==0.28.1 beautifulsoup4==4.12.3 reportlab==4.2.5 python-multipart==0.0.20 +openpyxl>=3.1.5 \ No newline at end of file