This commit is contained in:
Mateusz Gruszczyński
2026-06-03 13:02:07 +02:00
parent fdc946989f
commit 7cb2eddafe
8 changed files with 532 additions and 84 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
FROM python:3.12-slim FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1
@@ -16,4 +16,4 @@ COPY app ./app
RUN mkdir -p /app/exports RUN mkdir -p /app/exports
EXPOSE 8000 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"]
+130 -29
View File
@@ -9,6 +9,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
import httpx import httpx
from io import BytesIO
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Response from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Response
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
@@ -21,6 +22,8 @@ from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak
from reportlab.graphics.shapes import Drawing, Line, PolyLine, Rect, String, Wedge from reportlab.graphics.shapes import Drawing, Line, PolyLine, Rect, String, Wedge
from openpyxl import load_workbook
from .models import SimulationRequest from .models import SimulationRequest
from .simulator import simulate from .simulator import simulate
@@ -94,29 +97,97 @@ async def ws_simulate(websocket: WebSocket):
except Exception as exc: except Exception as exc:
await websocket.send_json({"error": str(exc)}) await websocket.send_json({"error": str(exc)})
@app.get("/api/rate/nbp") @app.get("/api/rate/nbp")
async def nbp_rate(): async def nbp_rate():
"""Pomocniczo pobiera stopę referencyjną NBP z oficjalnej strony HTML.""" """
url = "https://nbp.pl/en/monetary-policy/mpc-decisions/interest-rates/" 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: try:
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client: async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
resp = await client.get(url, headers={"User-Agent": "mortgage-simulator/1.0"}) 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() resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
text = " ".join(soup.get_text(" ").split()) wb = load_workbook(BytesIO(resp.content), data_only=True, read_only=True)
patterns = [ ws = wb["4 OPN2PLN"]
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*%", target_row = None
]
for pattern in patterns: for row in ws.iter_rows():
match = re.search(pattern, text, flags=re.IGNORECASE) values = [cell.value for cell in row]
if match: joined = " ".join(str(v).lower() for v in values if v is not None)
value = float(match.group(1).replace(",", "."))
return {"source": "NBP", "url": url, "rate": value, "fetched_at": datetime.utcnow().isoformat() + "Z"} if "nieruchomości mieszkaniowe" in joined or "nieruchomosci mieszkaniowe" in joined:
return JSONResponse({"error": "Nie znaleziono stopy w treści strony NBP", "url": url}, status_code=502) 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: except Exception as exc:
return JSONResponse({"error": str(exc), "url": url}, status_code=502) return JSONResponse(
{"error": str(exc), "url": url},
status_code=502,
)
@app.post("/api/export/csv") @app.post("/api/export/csv")
@@ -124,9 +195,9 @@ def export_csv(req: SimulationRequest):
result = simulate(req) result = simulate(req)
buf = io.StringIO() buf = io.StringIO()
writer = csv.writer(buf, delimiter=";") 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: 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([])
writer.writerow(["Podsumowanie"]) writer.writerow(["Podsumowanie"])
for key, value in result.summary.model_dump().items(): 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)], ["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)], ["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)], ["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(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([ 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], ["Liczba rat", s.months, "Bazowo", s.baseline_months],
["Suma zaplacona", _money(s.total_paid), "Suma odsetek", _money(s.total_interest)], ["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."], ["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(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")), ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")),
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#ffffff")), ("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#ffffff")),
("FONTNAME", (0, 0), (-1, -1), APP_FONT), ("FONTNAME", (0, 0), (-1, -1), APP_FONT),
("FONTNAME", (0, 0), (0, -1), APP_FONT_BOLD), ("FONTNAME", (0, 0), (0, -1), APP_FONT_BOLD),
("FONTNAME", (2, 0), (2, -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"), ("VALIGN", (0, 0), (-1, -1), "TOP"),
]))) ])))
story.append(Spacer(1, 0.35 * cm)) 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(Spacer(1, 0.35 * cm))
story.append(Paragraph("Nadplaty", styles["Heading2"])) story.append(Paragraph("Nadplaty", styles["Heading2"]))
over_rows = [["Miesiac", "Kwota", "Powtarzanie", "Do miesiaca"]] over_rows = [["Miesiac", "Kwota", "Prowizja", "Powtarzanie", "Do miesiaca"]]
if req.overpayments: if req.overpayments:
for op in sorted(req.overpayments, key=lambda x: x.month): 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: else:
over_rows.append(["-", "-", "-", "-"]) over_rows.append(["-", "-", "-", "-", "-"])
story.append(Table(over_rows, repeatRows=1, colWidths=[3 * cm, 4 * cm, 4 * cm, 4 * cm], style=TableStyle([ 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")), ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")),
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")), ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")),
("FONTNAME", (0, 0), (-1, -1), APP_FONT), ("FONTNAME", (0, 0), (-1, -1), APP_FONT),
("FONTNAME", (0, 0), (-1, 0), APP_FONT_BOLD), ("FONTNAME", (0, 0), (-1, 0), APP_FONT_BOLD),
("FONTSIZE", (0, 0), (-1, -1), 8), ("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(PageBreak())
story.append(Paragraph("Kompletny harmonogram wszystkich rat", styles["Heading2"])) 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: for row in result.schedule:
schedule_rows.append([ schedule_rows.append([
row.month, row.month,
row.due_date,
row.days,
_pct(row.rate), _pct(row.rate),
_money(row.payment), _money(row.payment),
_money(row.principal_part), _money(row.principal_part),
_money(row.interest_part), _money(row.interest_part),
_money(row.overpayment), _money(row.overpayment),
_money(row.overpayment_fee),
_money(row.cumulative_cost),
_money(row.remaining), _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([ table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")), ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#111827")), ("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#111827")),
+30
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import date
from enum import Enum from enum import Enum
from typing import Literal from typing import Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -15,6 +16,12 @@ class OverpaymentEffect(str, Enum):
lower_payment = "lower_payment" lower_payment = "lower_payment"
class GraceType(str, Enum):
none = "none"
interest_only = "interest_only"
full = "full"
class RateChange(BaseModel): class RateChange(BaseModel):
month: int = Field(ge=1, description="Miesiac od startu kredytu") month: int = Field(ge=1, description="Miesiac od startu kredytu")
annual_rate: float = Field(ge=0, le=30, description="Roczne oprocentowanie procentowo") annual_rate: float = Field(ge=0, le=30, description="Roczne oprocentowanie procentowo")
@@ -25,6 +32,16 @@ class Overpayment(BaseModel):
amount: float = Field(gt=0) amount: float = Field(gt=0)
repeat: Literal["once", "monthly", "yearly"] = "once" repeat: Literal["once", "monthly", "yearly"] = "once"
until_month: int | None = Field(default=None, ge=1) 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): class SimulationRequest(BaseModel):
@@ -34,18 +51,29 @@ class SimulationRequest(BaseModel):
base_rate: float = Field(ge=0, le=30, default=5.75) base_rate: float = Field(ge=0, le=30, default=5.75)
installment_type: InstallmentType = InstallmentType.equal installment_type: InstallmentType = InstallmentType.equal
overpayment_effect: OverpaymentEffect = OverpaymentEffect.shorten 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) rate_changes: list[RateChange] = Field(default_factory=list)
overpayments: list[Overpayment] = Field(default_factory=list) overpayments: list[Overpayment] = Field(default_factory=list)
historical_months: list[HistoricalMonth] = Field(default_factory=list)
class ScheduleRow(BaseModel): class ScheduleRow(BaseModel):
month: int month: int
due_date: str
days: int
rate: float rate: float
payment: float payment: float
principal_part: float principal_part: float
interest_part: float interest_part: float
overpayment: float overpayment: float
overpayment_fee: float
remaining: float remaining: float
grace_type: GraceType = GraceType.none
cumulative_interest: float
cumulative_cost: float
cumulative_overpayment: float
class Summary(BaseModel): class Summary(BaseModel):
@@ -53,12 +81,14 @@ class Summary(BaseModel):
total_paid: float total_paid: float
total_interest: float total_interest: float
total_overpayment: float total_overpayment: float
total_overpayment_fees: float
interest_saved: float interest_saved: float
months_saved: int months_saved: int
baseline_interest: float baseline_interest: float
baseline_months: int baseline_months: int
average_payment: float average_payment: float
max_payment: float max_payment: float
payoff_date: str | None = None
class SimulationResponse(BaseModel): class SimulationResponse(BaseModel):
+166 -28
View File
@@ -1,14 +1,94 @@
from __future__ import annotations from __future__ import annotations
from datetime import date, timedelta
from math import pow 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: def _round_money(value: float) -> float:
return round(max(value, 0.0) + 1e-9, 2) 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) changes = sorted(req.rate_changes, key=lambda x: x.month)
rate = req.base_rate + req.margin rate = req.base_rate + req.margin
for change in changes: for change in changes:
@@ -19,6 +99,11 @@ def _rate_for_month(req: SimulationRequest, month: int) -> float:
return rate 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: def _monthly_payment(balance: float, monthly_rate: float, months_left: int) -> float:
if months_left <= 0: if months_left <= 0:
return balance 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) 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 total = 0.0
fee = 0.0
for op in req.overpayments: for op in req.overpayments:
until = op.until_month or month until = op.until_month or month
active = False
if op.repeat == "once" and month == op.month: if op.repeat == "once" and month == op.month:
total += op.amount active = True
elif op.repeat == "monthly" and month >= op.month and month <= until: 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: elif op.repeat == "yearly" and month >= op.month and month <= until and (month - op.month) % 12 == 0:
active = True
if active:
total += op.amount 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]: def _simulate_raw(req: SimulationRequest, include_overpayments: bool = True) -> list[ScheduleRow]:
balance = float(req.principal) balance = float(req.principal)
total_months = int(req.years * 12) total_months = int(req.years * 12)
rows: list[ScheduleRow] = [] rows: list[ScheduleRow] = []
fixed_payment = None fixed_payment: float | None = None
recalculation_month = 1
month = 1 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: while balance > 0.005 and month <= total_months + 600:
months_left = max(total_months - month + 1, 1) months_left = max(total_months - month + 1, 1)
annual_rate = _rate_for_month(req, month) due_date = _due_date_for_month(req, month)
monthly_rate = annual_rate / 100 / 12 days = max((due_date - previous_due).days, 1)
interest = balance * monthly_rate 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 req.installment_type == InstallmentType.equal:
if fixed_payment is None or req.overpayment_effect == OverpaymentEffect.lower_payment: needs_recalc = fixed_payment is None or annual_rate != previous_rate
fixed_payment = _monthly_payment(balance, monthly_rate, months_left) 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 payment = fixed_payment
principal_part = max(payment - interest, 0.0) principal_part = max(payment - interest, 0.0)
else: else:
principal_base = req.principal / total_months principal_base = req.principal / total_months
if req.overpayment_effect == OverpaymentEffect.lower_payment: if req.overpayment_effect == OverpaymentEffect.lower_payment:
active_months_left = max(total_months - month + 1, 1) principal_base = balance / months_left
principal_base = balance / active_months_left
payment = principal_base + interest payment = principal_base + interest
principal_part = principal_base 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: if principal_part > balance:
principal_part = balance principal_part = balance
payment = interest + principal_part payment = interest + principal_part
balance -= principal_part balance -= principal_part
overpayment = _overpayment_for_month(req, month) if include_overpayments else 0.0 overpayment = 0.0
overpayment = min(overpayment, balance) overpayment_fee = 0.0
balance -= overpayment 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( rows.append(ScheduleRow(
month=month, month=month,
due_date=due_date.isoformat(),
days=days,
rate=round(annual_rate, 4), rate=round(annual_rate, 4),
payment=_round_money(payment), payment=_round_money(payment),
principal_part=_round_money(principal_part), principal_part=_round_money(principal_part),
interest_part=_round_money(interest), interest_part=_round_money(interest),
overpayment=_round_money(overpayment), overpayment=_round_money(overpayment),
overpayment_fee=_round_money(overpayment_fee),
remaining=_round_money(balance), 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: if include_overpayments and overpayment > 0 and req.overpayment_effect == OverpaymentEffect.lower_payment:
fixed_payment = None 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 month += 1
if month - recalculation_month > 1200:
break
return rows return rows
def simulate(req: SimulationRequest) -> SimulationResponse: def simulate(req: SimulationRequest) -> SimulationResponse:
actual = _simulate_raw(req, include_overpayments=True) 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) baseline = _simulate_raw(baseline_req, include_overpayments=False)
total_interest = sum(r.interest_part for r in actual) total_interest = sum(r.interest_part for r in actual)
total_overpayment = sum(r.overpayment 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) baseline_interest = sum(r.interest_part for r in baseline)
payments = [r.payment for r in actual] payments = [r.payment for r in actual]
@@ -119,11 +255,13 @@ def simulate(req: SimulationRequest) -> SimulationResponse:
total_paid=_round_money(total_paid), total_paid=_round_money(total_paid),
total_interest=_round_money(total_interest), total_interest=_round_money(total_interest),
total_overpayment=_round_money(total_overpayment), 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), months_saved=max(len(baseline) - len(actual), 0),
baseline_interest=_round_money(baseline_interest), baseline_interest=_round_money(baseline_interest),
baseline_months=len(baseline), baseline_months=len(baseline),
average_payment=_round_money(sum(payments) / len(payments)) if payments else 0, average_payment=_round_money(sum(payments) / len(payments)) if payments else 0,
max_payment=_round_money(max(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) return SimulationResponse(schedule=actual, summary=summary)
+125 -14
View File
@@ -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 money = (v) => new Intl.NumberFormat('pl-PL', { style: 'currency', currency: 'PLN', maximumFractionDigits: 0 }).format(v || 0);
const num = (id) => Number($(id).value || 0); const num = (id) => Number($(id).value || 0);
let lineChart, pieChart, barChart; let lineChart, pieChart, barChart, detailChart;
let socket; let socket;
let debounceTimer; let debounceTimer;
let lastRequest = null; let lastRequest = null;
window.lastSimulationData = null; window.lastSimulationData = null;
function todayIso() {
const d = new Date();
return d.toISOString().slice(0, 10);
}
function setTheme(theme) { function setTheme(theme) {
document.body.dataset.theme = theme; document.body.dataset.theme = theme;
localStorage.setItem('mortgage-theme', theme); localStorage.setItem('mortgage-theme', theme);
@@ -43,9 +48,22 @@ function buildRequest() {
month: Number(row.querySelector('[data-field="month"]').value || 1), month: Number(row.querySelector('[data-field="month"]').value || 1),
amount: Number(row.querySelector('[data-field="amount"]').value || 0), amount: Number(row.querySelector('[data-field="amount"]').value || 0),
repeat: row.querySelector('[data-field="repeat"]').value, 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); })).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 { return {
principal: num('principal'), principal: num('principal'),
years: num('years'), years: num('years'),
@@ -53,8 +71,12 @@ function buildRequest() {
base_rate: num('baseRate'), base_rate: num('baseRate'),
installment_type: $('installmentType').value, installment_type: $('installmentType').value,
overpayment_effect: $('overpaymentEffect').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, rate_changes: rateChanges,
overpayments: overpayments overpayments: overpayments,
historical_months: historicalMonths
}; };
} }
@@ -68,7 +90,7 @@ function recalc() {
function addRateRow(month = 13, rate = 7.0) { function addRateRow(month = 13, rate = 7.0) {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'dynamic-row'; div.className = 'dynamic-row rate-row';
div.innerHTML = ` div.innerHTML = `
<div><label class="form-label">Od miesiąca</label><input data-field="month" type="number" min="1" class="form-control form-control-sm" value="${month}"></div> <div><label class="form-label">Od miesiąca</label><input data-field="month" type="number" min="1" class="form-control form-control-sm" value="${month}"></div>
<div><label class="form-label">Oproc. roczne %</label><input data-field="rate" type="number" step="0.01" class="form-control form-control-sm" value="${rate}"></div> <div><label class="form-label">Oproc. roczne %</label><input data-field="rate" type="number" step="0.01" class="form-control form-control-sm" value="${rate}"></div>
@@ -79,14 +101,15 @@ function addRateRow(month = 13, rate = 7.0) {
recalc(); 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'); const div = document.createElement('div');
div.className = 'dynamic-row overpay'; div.className = 'dynamic-row overpay';
div.innerHTML = ` div.innerHTML = `
<div><label class="form-label">Miesiąc</label><input data-field="month" type="number" min="1" class="form-control form-control-sm" value="${month}"></div> <div><label class="form-label">Miesiąc</label><input data-field="month" type="number" min="1" class="form-control form-control-sm" value="${month}"></div>
<div><label class="form-label">Kwota</label><input data-field="amount" type="number" min="1" class="form-control form-control-sm" value="${amount}"></div> <div><label class="form-label">Kwota</label><input data-field="amount" type="number" min="1" class="form-control form-control-sm" value="${amount}"></div>
<div><label class="form-label">Prowizja %</label><input data-field="commission" type="number" min="0" step="0.01" class="form-control form-control-sm" value="${commission}"></div>
<div><label class="form-label">Powtarzaj</label><select data-field="repeat" class="form-select form-select-sm"><option value="once">raz</option><option value="monthly">co miesiąc</option><option value="yearly">co rok</option></select></div> <div><label class="form-label">Powtarzaj</label><select data-field="repeat" class="form-select form-select-sm"><option value="once">raz</option><option value="monthly">co miesiąc</option><option value="yearly">co rok</option></select></div>
<div><label class="form-label">Do mies.</label><input data-field="until" type="number" min="1" class="form-control form-control-sm" value="${until}"></div> <div><label class="form-label">Do mies.</label><input data-field="until" type="number" min="1" class="form-control form-control-sm" value="${until || ''}"></div>
<button class="btn btn-sm btn-outline-danger" type="button">Usuń</button>`; <button class="btn btn-sm btn-outline-danger" type="button">Usuń</button>`;
div.querySelector('[data-field="repeat"]').value = repeat; div.querySelector('[data-field="repeat"]').value = repeat;
div.querySelector('button').onclick = () => { div.remove(); recalc(); }; div.querySelector('button').onclick = () => { div.remove(); recalc(); };
@@ -95,21 +118,41 @@ function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', until =
recalc(); 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><label class="form-label">Miesiąc</label><input data-field="month" type="number" min="1" class="form-control form-control-sm" value="${month}"></div>
<div><label class="form-label">Oproc. %</label><input data-field="rate" type="number" step="0.01" class="form-control form-control-sm" value="${rate ?? ''}" placeholder="auto"></div>
<div><label class="form-label">Karencja</label><select data-field="grace" class="form-select form-select-sm"><option value="none">brak</option><option value="interest_only">tylko odsetki</option><option value="full">pełna</option></select></div>
<div><label class="form-label">Nadpłata</label><input data-field="overpayment" type="number" min="0" class="form-control form-control-sm" value="${overpayment || 0}"></div>
<div><label class="form-label">Prow. %</label><input data-field="commission" type="number" min="0" step="0.01" class="form-control form-control-sm" value="${commission || 0}"></div>
<div><label class="form-label">Opis</label><input data-field="note" type="text" class="form-control form-control-sm" value="${String(note || '').replaceAll('"', '&quot;')}"></div>
<button class="btn btn-sm btn-outline-danger" type="button">Usuń</button>`;
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) { function render(data) {
window.lastSimulationData = data; window.lastSimulationData = data;
const s = data.summary; const s = data.summary;
$('summaryCards').innerHTML = [ $('summaryCards').innerHTML = [
['Odsetki', money(s.total_interest)], ['Odsetki', money(s.total_interest)],
['Oszczędność', money(s.interest_saved)], ['Oszczędność netto', money(s.interest_saved)],
['Nadpłaty', money(s.total_overpayment)], ['Nadpłaty', money(s.total_overpayment)],
['Prowizje', money(s.total_overpayment_fees)],
['Okres', `${s.months} mies. / ${Math.ceil(s.months / 12)} lat`], ['Okres', `${s.months} mies. / ${Math.ceil(s.months / 12)} lat`],
['Data spłaty', s.payoff_date || '-'],
['Skrócenie', `${s.months_saved} mies.`], ['Skrócenie', `${s.months_saved} mies.`],
['Średnia rata', money(s.average_payment)] ['Średnia rata', money(s.average_payment)]
].map(([label, value]) => `<div class="col-6 col-md-4 col-xxl-2"><div class="card border-0 shadow-sm stat-card"><div class="card-body py-3"><div class="stat-label">${label}</div><div class="stat-value">${value}</div></div></div></div>`).join(''); ].map(([label, value]) => `<div class="col-6 col-md-4 col-xxl-3"><div class="card border-0 shadow-sm stat-card"><div class="card-body py-3"><div class="stat-label">${label}</div><div class="stat-value">${value}</div></div></div></div>`).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 => `<tr><td>${r.month}</td><td>${r.rate.toFixed(2)}%</td><td>${money(r.payment)}</td><td>${money(r.principal_part)}</td><td>${money(r.interest_part)}</td><td>${money(r.overpayment)}</td><td>${money(r.remaining)}</td></tr>`).join(''); $('scheduleTable').innerHTML = data.schedule.slice(0, 240).map(r => `<tr><td>${r.month}</td><td>${r.due_date}</td><td>${r.days}</td><td>${r.rate.toFixed(2)}%</td><td>${money(r.payment)}</td><td>${money(r.principal_part)}</td><td>${money(r.interest_part)}</td><td>${money(r.overpayment)}</td><td>${money(r.overpayment_fee)}</td><td>${money(r.cumulative_cost)}</td><td>${money(r.remaining)}</td></tr>`).join('');
renderCharts(data); renderCharts(data);
} }
@@ -119,6 +162,10 @@ function renderCharts(data) {
const labels = data.schedule.map(r => r.month); const labels = data.schedule.map(r => r.month);
const balance = data.schedule.map(r => r.remaining); const balance = data.schedule.map(r => r.remaining);
const payment = data.schedule.map(r => r.payment); 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(); const yearly = new Map();
data.schedule.forEach(r => { data.schedule.forEach(r => {
const year = Math.ceil(r.month / 12); const year = Math.ceil(r.month / 12);
@@ -135,7 +182,7 @@ function renderCharts(data) {
pieChart?.destroy(); pieChart?.destroy();
pieChart = new Chart($('pieChart'), { pieChart = new Chart($('pieChart'), {
type: 'pie', 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 } 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()] }] }, data: { labels: [...yearly.keys()].map(x => `Rok ${x}`), datasets: [{ label: 'Odsetki', data: [...yearly.values()] }] },
options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } } 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) { async function download(endpoint, filename) {
@@ -158,6 +219,51 @@ async function download(endpoint, filename) {
URL.revokeObjectURL(url); 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() { async function loadNbp() {
const btn = $('loadNbp'); const btn = $('loadNbp');
const old = btn.textContent; 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(); $('addRate').onclick = () => addRateRow();
$('addOverpayment').onclick = () => addOverpaymentRow(); $('addOverpayment').onclick = () => addOverpaymentRow();
$('addHistorical').onclick = () => addHistoricalRow();
$('exportCsv').onclick = () => download('/api/export/csv', 'symulacja-kredytu.csv'); $('exportCsv').onclick = () => download('/api/export/csv', 'symulacja-kredytu.csv');
$('exportPdf').onclick = () => download('/api/export/pdf', 'symulacja-kredytu.pdf'); $('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; $('loadNbp').onclick = loadNbp;
$('loanStartDate').value = todayIso();
initTheme(); initTheme();
addOverpaymentRow(24, 20000, 'once', ''); addOverpaymentRow(24, 20000, 'once', '', 0);
addOverpaymentRow(36, 500, 'monthly', 120); addOverpaymentRow(36, 500, 'monthly', 120, 0);
connectWs(); connectWs();
+36 -1
View File
@@ -18,8 +18,11 @@
<div class="d-flex gap-2 flex-wrap justify-content-end"> <div class="d-flex gap-2 flex-wrap justify-content-end">
<button id="themeToggle" class="btn btn-outline-secondary btn-sm" type="button" aria-label="Przełącz motyw">☀️ Jasny</button> <button id="themeToggle" class="btn btn-outline-secondary btn-sm" type="button" aria-label="Przełącz motyw">☀️ Jasny</button>
<button id="loadNbp" class="btn btn-outline-primary btn-sm">Pobierz stopę NBP</button> <button id="loadNbp" class="btn btn-outline-primary btn-sm">Pobierz stopę NBP</button>
<button id="exportJson" class="btn btn-outline-secondary btn-sm">Eksport JSON</button>
<button id="importJson" class="btn btn-outline-secondary btn-sm">Import JSON</button>
<button id="exportCsv" class="btn btn-outline-secondary btn-sm">CSV</button> <button id="exportCsv" class="btn btn-outline-secondary btn-sm">CSV</button>
<button id="exportPdf" class="btn btn-primary btn-sm">PDF</button> <button id="exportPdf" class="btn btn-primary btn-sm">PDF</button>
<input id="jsonFile" type="file" accept="application/json,.json" hidden>
</div> </div>
</div> </div>
</header> </header>
@@ -60,6 +63,20 @@
<option value="lower_payment">Zmniejszenie raty</option> <option value="lower_payment">Zmniejszenie raty</option>
</select> </select>
</div> </div>
<div class="col-6">
<label class="form-label">Data startu kredytu</label>
<input id="loanStartDate" type="date" class="form-control form-control-sm">
</div>
<div class="col-6">
<label class="form-label">Dzień spłaty raty</label>
<input id="dueDay" type="number" min="1" max="28" class="form-control form-control-sm" value="5">
</div>
<div class="col-12">
<label class="form-check small mb-0">
<input id="moveDueDate" class="form-check-input" type="checkbox" checked>
<span class="form-check-label">Przesuwaj spłatę na pierwszy dzień roboczy, jeśli wypada w dzień wolny</span>
</label>
</div>
</div> </div>
<hr> <hr>
@@ -75,6 +92,16 @@
<button id="addOverpayment" class="btn btn-sm btn-outline-primary">+</button> <button id="addOverpayment" class="btn btn-sm btn-outline-primary">+</button>
</div> </div>
<div id="overpayments" class="stack"></div> <div id="overpayments" class="stack"></div>
<hr>
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<h3 class="h6 mb-0">Kredyt historyczny</h3>
<div class="text-muted small">Miesięczne oprocentowanie, karencja i faktyczne nadpłaty</div>
</div>
<button id="addHistorical" class="btn btn-sm btn-outline-primary">+</button>
</div>
<div id="historicalMonths" class="stack"></div>
</div> </div>
</div> </div>
</section> </section>
@@ -106,6 +133,14 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-12">
<div class="card shadow-sm border-0 chart-card">
<div class="card-body">
<h2 class="h6">Podział raty i koszt narastająco</h2>
<canvas id="detailChart"></canvas>
</div>
</div>
</div>
<div class="col-12"> <div class="col-12">
<div class="card shadow-sm border-0"> <div class="card shadow-sm border-0">
<div class="card-body"> <div class="card-body">
@@ -119,7 +154,7 @@
<div class="card-body table-responsive"> <div class="card-body table-responsive">
<h2 class="h6">Harmonogram</h2> <h2 class="h6">Harmonogram</h2>
<table class="table table-sm align-middle mb-0"> <table class="table table-sm align-middle mb-0">
<thead><tr><th>Mies.</th><th>Oproc.</th><th>Rata</th><th>Kapitał</th><th>Odsetki</th><th>Nadpłata</th><th>Saldo</th></tr></thead> <thead><tr><th>Mies.</th><th>Data</th><th>Dni</th><th>Oproc.</th><th>Rata</th><th>Kapitał</th><th>Odsetki</th><th>Nadpłata</th><th>Prow.</th><th>Koszt nar.</th><th>Saldo</th></tr></thead>
<tbody id="scheduleTable"></tbody> <tbody id="scheduleTable"></tbody>
</table> </table>
</div> </div>
+39 -7
View File
@@ -27,13 +27,13 @@
} }
body[data-theme="dark"] { body[data-theme="dark"] {
--bg: #05070d; --bg: #03050a;
--text: #e5e7eb; --text: #e5e7eb;
--card: #0d1422; --card: #0b1220;
--muted: #9aa7bb; --muted: #9aa7bb;
--border: #10192a; --border: #2a3547;
--row: #111a2b; --row: #101827;
--input-bg: #070b13; --input-bg: #050914;
--input-text: #e5e7eb; --input-text: #e5e7eb;
--table-border: #3b4658; --table-border: #3b4658;
} }
@@ -100,14 +100,15 @@ body {
padding: .55rem; padding: .55rem;
border-radius: 12px; 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-card { border-radius: 16px; }
.stat-value { font-size: 1.15rem; font-weight: 700; } .stat-value { font-size: 1.15rem; font-weight: 700; }
.table { font-size: .84rem; } .table { font-size: .84rem; }
.btn { border-radius: 999px; } .btn { border-radius: 999px; }
@media (max-width: 768px) { @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; } .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"] .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-secondary { border-color: var(--border); color: var(--text); }
body[data-theme="dark"] .btn-outline-primary { border-color: #52637a; } 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);
}
+1
View File
@@ -5,3 +5,4 @@ httpx==0.28.1
beautifulsoup4==4.12.3 beautifulsoup4==4.12.3
reportlab==4.2.5 reportlab==4.2.5
python-multipart==0.0.20 python-multipart==0.0.20
openpyxl>=3.1.5