Initial income_calculator project

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-23 16:49:24 +03:00
commit 31dc287c3d
44 changed files with 1935 additions and 0 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
venv
.git
__pycache__
*.pyc
.cursor
*.db
.DS_Store
*.md
!backend/requirements.txt

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
venv/
__pycache__/
*.py[cod]
*.sqlite3
*.db
# macOS
.DS_Store
# IDE
.idea/
.vscode/
# Docker artifacts (if any)
*.log

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM python:3.11-slim
WORKDIR /app
# Зависимости
COPY backend/requirements.txt backend/requirements.txt
RUN pip install --no-cache-dir -r backend/requirements.txt
# Код приложения (backend + frontend/dist)
COPY backend backend
COPY frontend frontend
ENV PYTHONPATH=/app
# БД в volume, чтобы данные сохранялись между запусками
ENV DATABASE_PATH=/data/finance.db
EXPOSE 8000
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]

45
Makefile Normal file
View File

@@ -0,0 +1,45 @@
# Income Calculator — сборка и запуск в Docker
IMAGE := income-calculator
CONTAINER := income-calculator
PORT := 8000
DATA_VOLUME := income_calculator_data
STATEMENTS_DIR := Выписки банков
.PHONY: build run stop logs shell clean help
help:
@echo "Доступные цели:"
@echo " make build — собрать образ Docker"
@echo " make run — запустить контейнер (порт $(PORT), volume для БД и папки выписок)"
@echo " make stop — остановить и удалить контейнер"
@echo " make logs — показать логи контейнера"
@echo " make shell — войти в shell контейнера"
@echo " make clean — остановить контейнер и удалить образ"
build:
docker build -t $(IMAGE) .
run: build
@mkdir -p '$(STATEMENTS_DIR)'
docker run -d \
-p $(PORT):8000 \
-v $(DATA_VOLUME):/data \
-v "$$(pwd)/$(STATEMENTS_DIR):/app/statements" \
-e STATEMENTS_DIR=/app/statements \
--name $(CONTAINER) \
$(IMAGE)
@echo "Приложение: http://127.0.0.1:$(PORT)"
stop:
-docker stop $(CONTAINER)
-docker rm $(CONTAINER)
logs:
docker logs -f $(CONTAINER)
shell:
docker exec -it $(CONTAINER) /bin/bash
clean: stop
-docker rmi $(IMAGE)

78
README.md Normal file
View File

@@ -0,0 +1,78 @@
# Личные финансы — учёт баланса и выписок
Учёт операций по нескольким банкам: импорт PDF-выписок, дедупликация, расчёт баланса, дохода, дебета/кредита, копилки. Дашборд с фильтрами и графиками на localhost.
## Поддерживаемые банки
- **Т** — Т-банк (файлы `Т-MM-YY.pdf`)
- **С** — Сбербанк (`С-MM-YY.pdf`)
- **Я** — Яндекс Банк (`Я-MM-YY.pdf`)
Выписки кладутся в папку `Выписки банков/`.
## Запуск
### Вариант 1: Docker (рекомендуется)
Нужны установленные Docker и Make.
```bash
make run
```
Откройте в браузере **http://127.0.0.1:8000**. База данных хранится в Docker-volume `income_calculator_data`, папка `Выписки банков/` с хоста монтируется в контейнер — импорт из папки работает с вашими PDF.
Полезные команды:
- `make stop` — остановить контейнер
- `make logs` — логи
- `make shell` — shell внутри контейнера
- `make help` — список целей
### Вариант 2: Локально (venv)
1. Создать venv и установить зависимости:
```bash
python3 -m venv venv
./venv/bin/pip install -r backend/requirements.txt
```
2. Запустить сервер:
```bash
cd /path/to/income_calculator
PYTHONPATH=. ./venv/bin/uvicorn backend.main:app --host 127.0.0.1 --port 8000
```
3. Открыть в браузере: **http://127.0.0.1:8000**
### Настройки приложения
В разделе **Настройки**:
- Выбрать **зарплатный банк** (кнопка «Сделать зарплатным»), при необходимости — **зарплатную карту** (доход считается по приходам на этот банк или только на выбранную карту).
- Включить при необходимости «Доход только по зарплатной карте».
- Нажать **Импорт из папки**, чтобы загрузить все выписки из `Выписки банков/`, или загрузить один PDF вручную.
## Дашборд
- **Фильтры**: период (дата с/по), банки (галочки), **«Не учитывать переводы»** — при включении все операции с текстом «Перевод» в описании исключаются из расчётов (баланс, доход, дебет/кредит, копилка и графики).
- **Сводка**: суммарный баланс, доход (по зарплатному банку/карте), дебет, кредит, копилка (накопления).
- **Графики**: доход и расход по месяцам, динамика остатка, динамика копилки.
- **Операции**: список с пагинацией; у каждой операции можно снять галочку «В расчёте», тогда она не участвует в балансе и сводке.
**Копилка**: операции с описанием «Перевод между счетами одного клиента» (Яндекс Банк): отрицательная сумма — взнос в копилку, положительная — вывод; в сводке и на графике отображается итог по копилке.
## Структура проекта
- `backend/` — FastAPI, SQLite, парсеры выписок Т/С/Я, API и раздача статики.
- `frontend/dist/` — фронтенд (один HTML с Chart.js), сборка не требуется.
- `Выписки банков/` — PDF-выписки (`Т-MM-YY.pdf`, `С-MM-YY.pdf`, `Я-MM-YY.pdf`).
База данных: **SQLite** — `backend/finance.db`.
## API (кратко)
- **Банки**: `GET /api/banks`, `PUT /api/banks/salary/{id}`.
- **Счета**: `GET /api/accounts`, `POST /api/accounts/{account_id}/opening-balance`.
- **Импорт**: `POST /api/import/upload`, `POST /api/import/from-folder`.
- **Баланс и операции**: `GET /api/balance/summary`, `GET /api/balance/by-account`, `GET /api/transactions` (параметры: `period_start`, `period_end`, `bank_ids`).
- **Графики**: `GET /api/charts/income-expense`, `GET /api/charts/balance-dynamics`, `GET /api/charts/savings-dynamics` (те же параметры фильтрации).
- **Настройки**: `GET/PUT /api/settings/income` (зарплатная карта, «только по карте»), `GET/PUT /api/settings/exclude-transfers` (не учитывать переводы).

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Backend package

1
backend/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
# API package

39
backend/api/accounts.py Normal file
View File

@@ -0,0 +1,39 @@
from fastapi import APIRouter, HTTPException
from backend.db.database import get_connection
from pydantic import BaseModel
router = APIRouter(prefix="/api/accounts", tags=["accounts"])
class OpeningBalanceBody(BaseModel):
period_start: str # YYYY-MM-DD
amount: float
@router.get("")
def list_accounts():
conn = get_connection()
try:
rows = conn.execute(
"""SELECT a.id, a.bank_id, a.external_id, a.name, b.code as bank_code, b.name as bank_name, b.is_salary
FROM accounts a JOIN banks b ON a.bank_id = b.id ORDER BY b.code, a.external_id"""
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
@router.post("/{account_id}/opening-balance")
def set_opening_balance(account_id: int, body: OpeningBalanceBody):
conn = get_connection()
try:
conn.execute(
"""INSERT INTO opening_balances (account_id, period_start, amount) VALUES (?, ?, ?)
ON CONFLICT(account_id, period_start) DO UPDATE SET amount = excluded.amount""",
(account_id, body.period_start, body.amount),
)
conn.commit()
return {"account_id": account_id, "period_start": body.period_start, "amount": body.amount}
finally:
conn.close()

83
backend/api/balance.py Normal file
View File

@@ -0,0 +1,83 @@
from fastapi import APIRouter, HTTPException, Query
from backend.db.database import get_connection
from backend.services.balance_service import get_summary, get_transactions
from pydantic import BaseModel
router = APIRouter(tags=["balance"])
class TransactionExcludedBody(BaseModel):
excluded_from_balance: bool
def _parse_bank_ids(bank_ids: str | None) -> list[int] | None:
if not bank_ids or not bank_ids.strip():
return None
try:
return [int(x.strip()) for x in bank_ids.split(",") if x.strip()]
except ValueError:
return None
@router.get("/api/balance/summary")
def balance_summary(
period_start: str | None = Query(None, description="YYYY-MM-DD"),
period_end: str | None = Query(None, description="YYYY-MM-DD"),
bank_ids: str | None = Query(None, description="ID банков через запятую, например 1,2"),
):
return get_summary(
period_start=period_start,
period_end=period_end,
bank_ids=_parse_bank_ids(bank_ids),
)
@router.get("/api/balance/by-account")
def balance_by_account(
period_start: str | None = Query(None),
period_end: str | None = Query(None),
bank_ids: str | None = Query(None),
):
data = get_summary(
period_start=period_start,
period_end=period_end,
bank_ids=_parse_bank_ids(bank_ids),
)
return {"by_account": data["by_account"]}
@router.get("/api/transactions")
def list_transactions(
account_id: int | None = Query(None),
bank_ids: str | None = Query(None),
period_start: str | None = Query(None),
period_end: str | None = Query(None),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
):
items, total = get_transactions(
account_id=account_id,
bank_ids=_parse_bank_ids(bank_ids),
period_start=period_start,
period_end=period_end,
limit=limit,
offset=offset,
)
return {"items": items, "total": total}
@router.patch("/api/transactions/{transaction_id}")
def set_transaction_excluded(transaction_id: int, body: TransactionExcludedBody):
conn = get_connection()
try:
cur = conn.execute(
"UPDATE transactions SET excluded_from_balance = ? WHERE id = ?",
(1 if body.excluded_from_balance else 0, transaction_id),
)
conn.commit()
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="Transaction not found")
return {"id": transaction_id, "excluded_from_balance": body.excluded_from_balance}
finally:
conn.close()

31
backend/api/banks.py Normal file
View File

@@ -0,0 +1,31 @@
from fastapi import APIRouter, HTTPException
from backend.db.database import get_connection
router = APIRouter(prefix="/api/banks", tags=["banks"])
@router.get("")
def list_banks():
conn = get_connection()
try:
rows = conn.execute(
"SELECT id, code, name, is_salary FROM banks ORDER BY code"
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
@router.put("/salary/{bank_id}")
def set_salary_bank(bank_id: int):
conn = get_connection()
try:
conn.execute("UPDATE banks SET is_salary = 0")
cur = conn.execute("UPDATE banks SET is_salary = 1 WHERE id = ?", (bank_id,))
conn.commit()
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="Bank not found")
return {"salary_bank_id": bank_id}
finally:
conn.close()

164
backend/api/charts.py Normal file
View File

@@ -0,0 +1,164 @@
from fastapi import APIRouter, Query
from backend.db.database import get_connection
from backend.services.settings_service import get_income_settings, get_exclude_transfers
router = APIRouter(prefix="/api/charts", tags=["charts"])
def _parse_bank_ids(bank_ids: str | None) -> list[int] | None:
if not bank_ids or not bank_ids.strip():
return None
try:
return [int(x.strip()) for x in bank_ids.split(",") if x.strip()]
except ValueError:
return None
@router.get("/income-expense")
def income_expense_by_month(
year: int | None = Query(None),
period_start: str | None = Query(None),
period_end: str | None = Query(None),
bank_ids: str | None = Query(None),
):
"""Доход и расход по месяцам. Фильтры: year или period_start/period_end, bank_ids."""
conn = get_connection()
try:
salary_id = conn.execute("SELECT id FROM banks WHERE is_salary = 1").fetchone()
salary_id = salary_id["id"] if salary_id else None
only_salary_card, salary_account_id = get_income_settings()
exclude_filter = " AND (t.excluded_from_balance = 0 OR t.excluded_from_balance IS NULL)"
if get_exclude_transfers():
exclude_filter += " AND t.description NOT LIKE '%Перевод%'"
bank_id_list = _parse_bank_ids(bank_ids)
bank_filter = ""
params = []
if bank_id_list:
placeholders = ",".join("?" * len(bank_id_list))
bank_filter = f" AND a.bank_id IN ({placeholders})"
params = list(bank_id_list)
date_filter = ""
if year is not None:
date_filter = " AND strftime('%Y', t.operation_date) = ?"
params = [str(year)] + params
elif period_start or period_end:
if period_start:
date_filter += " AND t.operation_date >= ?"
params.append(period_start)
if period_end:
date_filter += " AND t.operation_date <= ?"
params.append(period_end)
rows = conn.execute("""
SELECT strftime('%Y-%m', t.operation_date) as month,
SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END) as debit,
SUM(CASE WHEN t.amount < 0 THEN -t.amount ELSE 0 END) as credit,
a.bank_id, a.id as account_id
FROM transactions t
JOIN accounts a ON t.account_id = a.id
WHERE 1=1
""" + exclude_filter + bank_filter + date_filter + """
GROUP BY month, a.bank_id, a.id
ORDER BY month
""", params).fetchall()
# Агрегируем по месяцам; доход — по зарплатному банку или только по выбранной карте
by_month = {}
for r in rows:
m = r["month"]
if m not in by_month:
by_month[m] = {"month": m, "debit": 0.0, "credit": 0.0, "income": 0.0}
by_month[m]["debit"] += r["debit"]
by_month[m]["credit"] += r["credit"]
if only_salary_card and salary_account_id and r["account_id"] == salary_account_id:
by_month[m]["income"] += r["debit"]
elif not only_salary_card and salary_id and r["bank_id"] == salary_id:
by_month[m]["income"] += r["debit"]
return list(by_month.values())
finally:
conn.close()
@router.get("/balance-dynamics")
def balance_dynamics(
period_start: str | None = Query(None),
period_end: str | None = Query(None),
bank_ids: str | None = Query(None),
):
"""Суммарный остаток на конец каждого дня (накопленно). bank_ids — фильтр по банкам."""
conn = get_connection()
try:
bank_id_list = _parse_bank_ids(bank_ids)
if bank_id_list:
placeholders = ",".join("?" * len(bank_id_list))
acc_filter = f" AND account_id IN (SELECT id FROM accounts WHERE bank_id IN ({placeholders}))"
params = list(bank_id_list)
else:
acc_filter = ""
params = []
transfer_filter = " AND description NOT LIKE '%Перевод%'" if get_exclude_transfers() else ""
sql = """
SELECT date(operation_date) as day, SUM(amount) as daily_total
FROM transactions
WHERE (excluded_from_balance = 0 OR excluded_from_balance IS NULL)
""" + transfer_filter + acc_filter
if period_start:
sql += " AND operation_date >= ?"
params.append(period_start)
if period_end:
sql += " AND operation_date <= ?"
params.append(period_end)
sql += " GROUP BY day ORDER BY day"
rows = conn.execute(sql, params).fetchall()
# Накопленный итог
cumul = 0.0
result = []
for r in rows:
cumul += r["daily_total"]
result.append({"date": r["day"], "balance": round(cumul, 2)})
return result
finally:
conn.close()
@router.get("/savings-dynamics")
def savings_dynamics(
period_start: str | None = Query(None),
period_end: str | None = Query(None),
bank_ids: str | None = Query(None),
):
"""Динамика копилки: накопленная сумма операций «Перевод между счетами одного клиента» по дням."""
conn = get_connection()
try:
bank_id_list = _parse_bank_ids(bank_ids)
if bank_id_list:
placeholders = ",".join("?" * len(bank_id_list))
acc_filter = f" AND account_id IN (SELECT id FROM accounts WHERE bank_id IN ({placeholders}))"
params = ["%Перевод между счетами одного клиента%"] + list(bank_id_list)
else:
acc_filter = ""
params = ["%Перевод между счетами одного клиента%"]
transfer_filter = " AND description NOT LIKE '%Перевод%'" if get_exclude_transfers() else ""
sql = """
SELECT date(operation_date) as day, SUM(-amount) as daily_savings
FROM transactions
WHERE description LIKE ?
AND (excluded_from_balance = 0 OR excluded_from_balance IS NULL)
""" + transfer_filter + acc_filter
if period_start:
sql += " AND operation_date >= ?"
params.append(period_start)
if period_end:
sql += " AND operation_date <= ?"
params.append(period_end)
sql += " GROUP BY day ORDER BY day"
rows = conn.execute(sql, params).fetchall()
cumul = 0.0
result = []
for r in rows:
cumul += r["daily_savings"]
result.append({"date": r["day"], "savings": round(cumul, 2)})
return result
finally:
conn.close()

33
backend/api/import_api.py Normal file
View File

@@ -0,0 +1,33 @@
import os
import tempfile
from pathlib import Path
from fastapi import APIRouter, File, HTTPException, UploadFile
from backend.services.import_service import import_file, import_from_statements_dir
router = APIRouter(prefix="/api/import", tags=["import"])
@router.post("/upload")
async def upload_statement(file: UploadFile = File(...)):
if not file.filename or not file.filename.lower().endswith(".pdf"):
raise HTTPException(status_code=400, detail="Only PDF files are allowed")
if not (file.filename.startswith("Т-") or file.filename.startswith("С-") or file.filename.startswith("Я-")):
raise HTTPException(status_code=400, detail="Поддерживаются выписки Т-, С- и Я-банка (Т/С/Я-MM-YY.pdf)")
suffix = Path(file.filename).suffix
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
try:
added, skipped, parsed = import_file(tmp_path, file.filename)
return {"added": added, "skipped_duplicates": skipped, "parsed": parsed, "filename": file.filename}
finally:
os.unlink(tmp_path)
@router.post("/from-folder")
def import_from_folder():
added, skipped, parsed = import_from_statements_dir()
return {"added": added, "skipped_duplicates": skipped, "parsed": parsed}

View File

@@ -0,0 +1,58 @@
from fastapi import APIRouter
from backend.db.database import get_connection
from backend.services.settings_service import get_income_settings, set_income_settings
from pydantic import BaseModel
router = APIRouter(prefix="/api/settings", tags=["settings"])
class IncomeSettingsBody(BaseModel):
count_income_only_salary_card: bool
salary_account_id: int | None = None
@router.get("/income")
def get_income_settings_api():
"""Настройки учёта доходов + список счетов зарплатного банка для выбора карты."""
only_card, account_id = get_income_settings()
conn = get_connection()
try:
rows = conn.execute("""
SELECT a.id, a.external_id, a.name, b.name as bank_name
FROM accounts a
JOIN banks b ON a.bank_id = b.id
WHERE b.is_salary = 1
ORDER BY a.external_id
""").fetchall()
salary_accounts = [dict(r) for r in rows]
finally:
conn.close()
return {
"count_income_only_salary_card": only_card,
"salary_account_id": account_id,
"salary_accounts": salary_accounts,
}
@router.put("/income")
def put_income_settings(body: IncomeSettingsBody):
set_income_settings(body.count_income_only_salary_card, body.salary_account_id)
return {"count_income_only_salary_card": body.count_income_only_salary_card, "salary_account_id": body.salary_account_id}
@router.get("/exclude-transfers")
def get_exclude_transfers_api():
from backend.services.settings_service import get_exclude_transfers
return {"exclude_transfers": get_exclude_transfers()}
class ExcludeTransfersBody(BaseModel):
exclude_transfers: bool
@router.put("/exclude-transfers")
def put_exclude_transfers_api(body: ExcludeTransfersBody):
from backend.services.settings_service import set_exclude_transfers
set_exclude_transfers(body.exclude_transfers)
return {"exclude_transfers": body.exclude_transfers}

8
backend/config.py Normal file
View File

@@ -0,0 +1,8 @@
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = BASE_DIR.parent
DATABASE_PATH = os.environ.get("DATABASE_PATH", str(BASE_DIR / "finance.db"))
STATEMENTS_DIR = os.environ.get("STATEMENTS_DIR", str(PROJECT_ROOT / "Выписки банков"))

3
backend/db/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .database import init_db, get_connection
__all__ = ["init_db", "get_connection"]

74
backend/db/database.py Normal file
View File

@@ -0,0 +1,74 @@
import sqlite3
from pathlib import Path
from typing import Optional
from backend.config import DATABASE_PATH
def get_connection() -> sqlite3.Connection:
conn = sqlite3.connect(DATABASE_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db() -> None:
Path(DATABASE_PATH).parent.mkdir(parents=True, exist_ok=True)
conn = get_connection()
try:
conn.executescript("""
CREATE TABLE IF NOT EXISTS banks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
is_salary INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bank_id INTEGER NOT NULL REFERENCES banks(id),
external_id TEXT NOT NULL,
name TEXT,
UNIQUE(bank_id, external_id)
);
CREATE TABLE IF NOT EXISTS opening_balances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id),
period_start TEXT NOT NULL,
amount REAL NOT NULL,
UNIQUE(account_id, period_start)
);
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id),
operation_date TEXT NOT NULL,
debit_date TEXT,
amount REAL NOT NULL,
currency TEXT DEFAULT 'RUB',
amount_card_currency REAL,
description TEXT,
source_file TEXT NOT NULL,
imported_at TEXT NOT NULL,
dedup_key TEXT NOT NULL UNIQUE
);
CREATE INDEX IF NOT EXISTS idx_transactions_account ON transactions(account_id);
CREATE INDEX IF NOT EXISTS idx_transactions_operation_date ON transactions(operation_date);
CREATE INDEX IF NOT EXISTS idx_transactions_dedup ON transactions(dedup_key);
""")
conn.commit()
try:
conn.execute("ALTER TABLE transactions ADD COLUMN excluded_from_balance INTEGER NOT NULL DEFAULT 0")
conn.commit()
except sqlite3.OperationalError:
pass
conn.executescript("""
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT
);
""")
conn.commit()
finally:
conn.close()

50
backend/main.py Normal file
View File

@@ -0,0 +1,50 @@
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from backend.api import accounts, balance, banks, charts, import_api, settings_api
from backend.db.database import init_db
init_db()
app = FastAPI(title="Income Calculator", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(banks.router)
app.include_router(accounts.router)
app.include_router(import_api.router)
app.include_router(balance.router)
app.include_router(charts.router)
app.include_router(settings_api.router)
# Статика Vue (собранная в frontend/dist)
static_path = Path(__file__).resolve().parent.parent / "frontend" / "dist"
if static_path.exists():
app.mount("/assets", StaticFiles(directory=static_path / "assets"), name="assets")
@app.get("/")
def index():
from fastapi.responses import FileResponse
return FileResponse(static_path / "index.html")
@app.get("/{full_path:path}")
def serve_spa(full_path: str):
from fastapi.responses import FileResponse
if full_path.startswith("api/"):
return FileResponse(static_path / "index.html") # let API handle; this won't be hit
f = static_path / full_path
if f.is_file():
return FileResponse(f)
return FileResponse(static_path / "index.html")
else:
@app.get("/")
def root():
return {"message": "Backend running. Build frontend: cd frontend && npm run build"}

View File

@@ -0,0 +1 @@
# Parsers package

87
backend/parsers/bank_s.py Normal file
View File

@@ -0,0 +1,87 @@
import re
from datetime import datetime
from pathlib import Path
from typing import List
import pdfplumber
from backend.parsers.base import BaseBankParser, ParsedTransaction
def _normalize_amount(s: str) -> float:
"""Пробелы убрать, запятая — десятичный разделитель."""
return float(s.replace("\u00a0", " ").replace(" ", "").replace(",", "."))
def _parse_datetime_s(date_str: str, time_str: str) -> str:
"""DD.MM.YYYY + HH:MM -> ISO."""
try:
part = date_str.strip() + " " + (time_str or "00:00").strip()
dt = datetime.strptime(part, "%d.%m.%Y %H:%M")
return dt.strftime("%Y-%m-%dT%H:%M:%S")
except ValueError:
return date_str.strip()[:10].replace(".", "-") + "T00:00:00"
class BankSParser(BaseBankParser):
"""Парсер выписок Сбербанка. Файлы С-MM-YY.pdf."""
# Первая строка: дата время код_авторизации категория сумма остаток (сумма с + = приход, без = расход)
ROW_RE = re.compile(
r"^(\d{2}\.\d{2}\.\d{4})\s+(\d{1,2}:\d{2})\s+(\d{6})\s+(.+?)\s+([+-]?[\d\s]+,\d{2})\s+([\d\s]+,\d{2})\s*$",
re.UNICODE,
)
# Вторая строка: дата описание ... Операция по (перенос)
DESC_LINE1_RE = re.compile(r"^(\d{2}\.\d{2}\.\d{4})\s+(.+?)\s+Операция по\s*$", re.UNICODE)
# Третья строка: карте ****0566
CARD_LINE_RE = re.compile(r"^карте\s+\*\*\*\*(\d{4})\s*$", re.UNICODE)
def can_parse(self, filename: str) -> bool:
name = Path(filename).name
return name.startswith("С-") and name.lower().endswith(".pdf")
def parse(self, file_path: str) -> List[ParsedTransaction]:
result: List[ParsedTransaction] = []
with pdfplumber.open(file_path) as pdf:
lines: List[str] = []
for page in pdf.pages:
text = page.extract_text()
if text:
lines.extend(text.split("\n"))
i = 0
while i < len(lines):
line = lines[i].strip()
m = self.ROW_RE.match(line)
if m:
date_op, time_op, _code, category, amount_str, _balance = m.groups()
amount = _normalize_amount(amount_str)
if not amount_str.strip().startswith("+"):
amount = -amount
desc_extra = ""
card_tail = ""
if i + 1 < len(lines):
d1 = self.DESC_LINE1_RE.match(lines[i + 1].strip())
if d1:
desc_extra = " " + d1.group(2).strip()
i += 1
if i + 1 < len(lines):
c2 = self.CARD_LINE_RE.match(lines[i + 1].strip())
if c2:
card_tail = c2.group(1)
i += 1
if not card_tail:
card_tail = "0000"
description = (category + desc_extra).strip()
result.append(
ParsedTransaction(
operation_date=_parse_datetime_s(date_op, time_op),
debit_date=None,
amount=amount,
amount_card_currency=None,
description=description,
card_tail=card_tail,
)
)
i += 1
return result

86
backend/parsers/bank_t.py Normal file
View File

@@ -0,0 +1,86 @@
import re
from datetime import datetime
from pathlib import Path
from typing import List
import pdfplumber
from backend.parsers.base import BaseBankParser, ParsedTransaction
def _normalize_amount(s: str) -> float:
return float(s.replace("\u00a0", " ").replace(" ", "").replace(",", "."))
def _parse_date(d: str) -> str:
"""DD.MM.YYYY -> YYYY-MM-DD"""
try:
dt = datetime.strptime(d.strip(), "%d.%m.%Y")
return dt.strftime("%Y-%m-%d")
except ValueError:
return d.strip()
def _parse_datetime(d: str, time_str: str) -> str:
"""Date DD.MM.YYYY + time HH:MM -> ISO"""
try:
part = d.strip() + " " + (time_str or "00:00").strip()
dt = datetime.strptime(part, "%d.%m.%Y %H:%M")
return dt.strftime("%Y-%m-%dT%H:%M:%S")
except ValueError:
return _parse_date(d) + "T00:00:00"
class BankTParser(BaseBankParser):
"""Парсер выписок Т-банка. Файлы Т-MM-YY.pdf."""
# Первая строка операции: дата дата сумма ₽ сумма ₽ описание 4цифры (сумма может быть + или -)
ROW_RE = re.compile(
r"^(\d{2}\.\d{2}\.\d{4})\s+(\d{2}\.\d{2}\.\d{4})\s+([-+]?[\d\s,]+\.\d{2})\s*₽\s+([-+]?[\d\s,]+\.\d{2})\s*₽\s+(.+?)\s+(\d{4})\s*$",
re.UNICODE,
)
# Вторая строка (время): HH:MM HH:MM остаток текста
TIME_RE = re.compile(r"^(\d{1,2}:\d{2})\s+(\d{1,2}:\d{2})\s*(.*)$")
def can_parse(self, filename: str) -> bool:
name = Path(filename).name
return name.startswith("Т-") and name.lower().endswith(".pdf")
def parse(self, file_path: str) -> List[ParsedTransaction]:
result: List[ParsedTransaction] = []
with pdfplumber.open(file_path) as pdf:
lines: List[str] = []
for page in pdf.pages:
text = page.extract_text()
if text:
lines.extend(text.split("\n"))
i = 0
while i < len(lines):
line = lines[i]
m = self.ROW_RE.match(line.strip())
if m:
date_op, date_debit, amt_op, amt_card, desc, card_tail = m.groups()
op_time, debit_time = "00:00", "00:00"
if i + 1 < len(lines):
tm = self.TIME_RE.match(lines[i + 1].strip())
if tm:
op_time, debit_time, rest = tm.groups()
if rest:
desc = (desc + " " + rest).strip()
i += 1
amount = _normalize_amount(amt_op)
amount_card = _normalize_amount(amt_card) if amt_card else None
result.append(
ParsedTransaction(
operation_date=_parse_datetime(date_op, op_time),
debit_date=_parse_datetime(date_debit, debit_time),
amount=amount,
amount_card_currency=amount_card,
description=(desc or "").strip(),
card_tail=card_tail,
)
)
i += 1
return result

84
backend/parsers/bank_y.py Normal file
View File

@@ -0,0 +1,84 @@
import re
from datetime import datetime
from pathlib import Path
from typing import List
import pdfplumber
from backend.parsers.base import BaseBankParser, ParsedTransaction
# Я-банк использует EN DASH (U+2013) для минуса
MINUS_CHARS = "\u2013-"
def _normalize_amount(s: str) -> float:
s = s.replace("\u00a0", " ").replace(" ", "").replace(",", ".")
for c in MINUS_CHARS:
s = s.replace(c, "-")
if s.startswith(""):
s = "-" + s[1:]
return float(s)
def _parse_datetime_y(date_str: str, time_str: str = "") -> str:
try:
part = date_str.strip() + " " + (time_str or "00:00").strip()
dt = datetime.strptime(part, "%d.%m.%Y %H:%M")
return dt.strftime("%Y-%m-%dT%H:%M:%S")
except ValueError:
return date_str.strip().replace(".", "-")[:10] + "T00:00:00"
class BankYParser(BaseBankParser):
"""Парсер выписок Яндекс Банка. Файлы Я-MM-YY.pdf."""
# Строка: описание ... DD.MM.YYYY DD.MM.YYYY [*XXXX] сумма ₽ сумма ₽ (минус может быть U+2013, карта опциональна)
ROW_RE = re.compile(
r"^(.+?)\s+(\d{2}\.\d{2}\.\d{4})\s+(\d{2}\.\d{2}\.\d{4})\s+(?:\*(\d{4})\s+)?([+\u2013\-]?[\d\s,]+)\s*₽\s+([+\u2013\-]?[\d\s,]+)\s*₽\s*$",
re.UNICODE,
)
# Вторая строка может содержать время: "в 18:13" или "клиента в 21:35"
TIME_RE = re.compile(r"^(?:.*\s+)?в\s+(\d{1,2}:\d{2})\s*$", re.UNICODE)
PIGGY_MARKER = "Перевод между счетами одного клиента"
def can_parse(self, filename: str) -> bool:
name = Path(filename).name
return name.startswith("Я-") and name.lower().endswith(".pdf")
def parse(self, file_path: str) -> List[ParsedTransaction]:
result: List[ParsedTransaction] = []
with pdfplumber.open(file_path) as pdf:
lines: List[str] = []
for page in pdf.pages:
text = page.extract_text()
if text:
lines.extend(text.split("\n"))
i = 0
while i < len(lines):
line = lines[i].strip()
m = self.ROW_RE.match(line)
if m:
desc, date_op, date_proc, card_tail, amt1, amt2 = m.groups()
card_tail = card_tail or "0000"
amount = _normalize_amount(amt1)
time_str = ""
if i + 1 < len(lines):
tm = self.TIME_RE.match(lines[i + 1].strip())
if tm:
time_str = tm.group(1)
desc = (desc + " " + lines[i + 1].strip()).strip()
i += 1
result.append(
ParsedTransaction(
operation_date=_parse_datetime_y(date_op, time_str),
debit_date=_parse_datetime_y(date_proc),
amount=amount,
amount_card_currency=_normalize_amount(amt2) if amt2 else None,
description=desc.strip(),
card_tail=card_tail,
)
)
i += 1
return result

23
backend/parsers/base.py Normal file
View File

@@ -0,0 +1,23 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import List
@dataclass
class ParsedTransaction:
operation_date: str # ISO date or datetime
debit_date: str | None
amount: float # signed: negative = expense
amount_card_currency: float | None
description: str
card_tail: str # last 4 digits for account matching
class BaseBankParser(ABC):
@abstractmethod
def can_parse(self, filename: str) -> bool:
pass
@abstractmethod
def parse(self, file_path: str) -> List[ParsedTransaction]:
pass

5
backend/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
pdfplumber==0.11.4
pydantic==2.10.3
python-multipart==0.0.17

View File

@@ -0,0 +1 @@
# Services package

View File

@@ -0,0 +1,172 @@
from typing import Any
from backend.db.database import get_connection
from backend.services.settings_service import get_income_settings, get_exclude_transfers
def get_summary(
period_start: str | None = None,
period_end: str | None = None,
bank_ids: list[int] | None = None,
) -> dict[str, Any]:
"""
Итог по всем счетам (или по выбранным банкам): суммарный баланс, доход, дебет, кредит.
period_start/period_end в формате YYYY-MM-DD; bank_ids — список id банков для фильтра (пусто = все).
"""
conn = get_connection()
try:
salary_bank_id = conn.execute(
"SELECT id FROM banks WHERE is_salary = 1"
).fetchone()
salary_bank_id = salary_bank_id["id"] if salary_bank_id else None
only_salary_card, salary_account_id = get_income_settings()
income_account_filter = salary_account_id if (only_salary_card and salary_account_id) else None
accounts_sql = "SELECT a.id, a.bank_id, a.external_id, a.name, b.code FROM accounts a JOIN banks b ON a.bank_id = b.id"
params_acc = []
if bank_ids:
placeholders = ",".join("?" * len(bank_ids))
accounts_sql += f" WHERE a.bank_id IN ({placeholders})"
params_acc = list(bank_ids)
accounts = conn.execute(accounts_sql, params_acc).fetchall()
total_balance = 0.0
by_account = []
income = 0.0
debit = 0.0
credit = 0.0
exclude_filter = " AND (t.excluded_from_balance = 0 OR t.excluded_from_balance IS NULL)"
if get_exclude_transfers():
exclude_filter += " AND t.description NOT LIKE '%Перевод%'"
for acc in accounts:
# Начальный остаток на начало периода (последний установленный на дату <= period_start)
ob = conn.execute(
"SELECT amount FROM opening_balances WHERE account_id = ? AND (? IS NULL OR period_start <= ?) ORDER BY period_start DESC LIMIT 1",
(acc["id"], period_start, period_start or "0000-01-01"),
).fetchone()
opening = float(ob["amount"]) if ob else 0.0
sql = "SELECT COALESCE(SUM(t.amount), 0) FROM transactions t WHERE t.account_id = ?" + exclude_filter
params = [acc["id"]]
if period_start:
sql += " AND t.operation_date >= ?"
params.append(period_start)
if period_end:
sql += " AND t.operation_date <= ?"
params.append(period_end)
row = conn.execute(sql, params).fetchone()
tx_sum = float(row[0])
balance = opening + tx_sum
total_balance += balance
# Доход: приходы по зарплатному банку или только по выбранной зарплатной карте
count_income = False
if income_account_filter is not None:
count_income = acc["id"] == income_account_filter
elif acc["bank_id"] == salary_bank_id:
count_income = True
if count_income:
inc_sql = "SELECT COALESCE(SUM(t.amount), 0) FROM transactions t WHERE t.account_id = ? AND t.amount > 0" + exclude_filter
inc_params = [acc["id"]]
if period_start:
inc_sql += " AND t.operation_date >= ?"
inc_params.append(period_start)
if period_end:
inc_sql += " AND t.operation_date <= ?"
inc_params.append(period_end)
income += float(conn.execute(inc_sql, inc_params).fetchone()[0])
# Дебет = приход (увеличение счёта), Кредит = расход (уменьшение счёта)
d_sql = "SELECT COALESCE(SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END), 0), COALESCE(SUM(CASE WHEN t.amount < 0 THEN -t.amount ELSE 0 END), 0) FROM transactions t WHERE t.account_id = ?" + exclude_filter
d_params = [acc["id"]]
if period_start:
d_sql += " AND t.operation_date >= ?"
d_params.append(period_start)
if period_end:
d_sql += " AND t.operation_date <= ?"
d_params.append(period_end)
d_row = conn.execute(d_sql, d_params).fetchone()
debit += float(d_row[0]) # приход
credit += float(d_row[1]) # расход
by_account.append({
"account_id": acc["id"],
"bank_code": acc["code"],
"external_id": acc["external_id"],
"name": acc["name"],
"opening_balance": opening,
"transactions_sum": tx_sum,
"balance": balance,
})
# Копилка: только если в фильтр входит Я-банк (или фильтра нет)
piggy_sql = "SELECT COALESCE(SUM(-t.amount), 0) FROM transactions t WHERE t.description LIKE ? " + exclude_filter
piggy_params = ["%Перевод между счетами одного клиента%"]
if bank_ids:
placeholders = ",".join("?" * len(bank_ids))
piggy_sql += f" AND t.account_id IN (SELECT id FROM accounts WHERE bank_id IN ({placeholders}))"
piggy_params.extend(bank_ids)
piggy_row = conn.execute(piggy_sql, piggy_params).fetchone()
savings_balance = float(piggy_row[0]) if piggy_row else 0.0
net_flow = debit - credit # за период: положительный = накопление, отрицательный = траты
return {
"total_balance": round(total_balance, 2),
"income": round(income, 2),
"debit": round(debit, 2),
"credit": round(credit, 2),
"net_flow": round(net_flow, 2),
"savings_balance": round(savings_balance, 2),
"by_account": by_account,
"salary_bank_id": salary_bank_id,
}
finally:
conn.close()
def get_transactions(
account_id: int | None = None,
bank_ids: list[int] | None = None,
period_start: str | None = None,
period_end: str | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[dict], int]:
"""Возвращает (список транзакций, общее количество). Фильтры: bank_ids, period_start, period_end."""
conn = get_connection()
try:
conditions = []
params = []
if account_id is not None:
conditions.append("t.account_id = ?")
params.append(account_id)
if bank_ids:
placeholders = ",".join("?" * len(bank_ids))
conditions.append(f"a.bank_id IN ({placeholders})")
params.extend(bank_ids)
if period_start:
conditions.append("t.operation_date >= ?")
params.append(period_start)
if period_end:
conditions.append("t.operation_date <= ?")
params.append(period_end)
where = " WHERE " + " AND ".join(conditions) if conditions else ""
count_sql = "SELECT COUNT(*) FROM transactions t JOIN accounts a ON t.account_id = a.id JOIN banks b ON a.bank_id = b.id" + where
total = conn.execute(count_sql, params).fetchone()[0]
sql = """
SELECT t.id, t.account_id, t.operation_date, t.amount, t.description, t.source_file,
t.excluded_from_balance,
a.external_id, b.code as bank_code, b.name as bank_name
FROM transactions t
JOIN accounts a ON t.account_id = a.id
JOIN banks b ON a.bank_id = b.id
""" + where + " ORDER BY t.operation_date DESC, t.id DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
rows = conn.execute(sql, params).fetchall()
items = [dict(r) for r in rows]
return items, total
finally:
conn.close()

View File

@@ -0,0 +1,124 @@
import hashlib
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Tuple
from backend.config import STATEMENTS_DIR
from backend.db.database import get_connection
from backend.parsers.bank_s import BankSParser
from backend.parsers.bank_t import BankTParser
from backend.parsers.bank_y import BankYParser
from backend.parsers.base import BaseBankParser, ParsedTransaction
BANK_REGISTRY: list[tuple[str, str, type[BaseBankParser]]] = [
("T", "Т-банк", BankTParser),
("S", "Сбербанк", BankSParser),
("Y", "Яндекс Банк", BankYParser),
]
def _dedup_key(account_id: int, operation_date: str, amount: float, description: str) -> str:
raw = f"{account_id}|{operation_date}|{amount}|{description}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _ensure_bank(conn, code: str, name: str) -> int:
"""Создать банк по коду и имени. Возвращает bank_id."""
conn.execute(
"INSERT OR IGNORE INTO banks (code, name, is_salary) VALUES (?, ?, 0)",
(code, name),
)
conn.commit()
row = conn.execute("SELECT id FROM banks WHERE code = ?", (code,)).fetchone()
return row["id"]
def _parser_for_filename(filename: str) -> tuple[str, str, BaseBankParser] | None:
"""По имени файла возвращает (code, name, parser) или None."""
for code, name, parser_cls in BANK_REGISTRY:
p = parser_cls()
if p.can_parse(filename):
return (code, name, p)
return None
def _get_or_create_account(conn, bank_id: int, card_tail: str) -> int:
cur = conn.execute(
"SELECT id FROM accounts WHERE bank_id = ? AND external_id = ?",
(bank_id, card_tail),
)
row = cur.fetchone()
if row:
return row["id"]
conn.execute(
"INSERT INTO accounts (bank_id, external_id, name) VALUES (?, ?, ?)",
(bank_id, card_tail, f"Карта ***{card_tail}"),
)
conn.commit()
return conn.execute("SELECT last_insert_rowid()").fetchone()[0]
def import_file(file_path: str, source_filename: str) -> Tuple[int, int, int]:
"""
Парсит PDF выписку (Т- или С-банк), дедуплицирует и сохраняет в БД.
Возвращает (добавлено, пропущеноубликатов, распознаносего).
"""
match = _parser_for_filename(source_filename)
if not match:
raise ValueError("Поддерживаются выписки Т-банка (Т-MM-YY.pdf), С-банка (С-MM-YY.pdf) и Я-банка (Я-MM-YY.pdf)")
code, name, parser = match
transactions = parser.parse(file_path)
parsed_count = len(transactions)
conn = get_connection()
try:
bank_id = _ensure_bank(conn, code, name)
added, skipped = 0, 0
imported_at = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
for t in transactions:
account_id = _get_or_create_account(conn, bank_id, t.card_tail)
key = _dedup_key(account_id, t.operation_date, t.amount, t.description)
try:
conn.execute(
"""INSERT INTO transactions
(account_id, operation_date, debit_date, amount, currency, amount_card_currency, description, source_file, imported_at, dedup_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
account_id,
t.operation_date,
t.debit_date,
t.amount,
"RUB",
t.amount_card_currency,
t.description,
source_filename,
imported_at,
key,
),
)
added += 1
except sqlite3.IntegrityError:
skipped += 1
except Exception:
raise
conn.commit()
return added, skipped, parsed_count
finally:
conn.close()
def import_from_statements_dir() -> Tuple[int, int, int]:
"""Импортировать все файлы Т-*.pdf и С-*.pdf из папки Выписки банков. Возвращает (добавлено, дубликатов, распознано)."""
path = Path(STATEMENTS_DIR)
if not path.exists():
return 0, 0, 0
total_added, total_skipped, total_parsed = 0, 0, 0
for pattern in ("Т-*.pdf", "С-*.pdf", "Я-*.pdf"):
for f in sorted(path.glob(pattern)):
added, skipped, parsed = import_file(str(f), f.name)
total_added += added
total_skipped += skipped
total_parsed += parsed
return total_added, total_skipped, total_parsed

View File

@@ -0,0 +1,61 @@
from backend.db.database import get_connection
def get_income_settings() -> tuple[bool, int | None]:
"""Возвращает (count_income_only_salary_card, salary_account_id)."""
conn = get_connection()
try:
row = conn.execute(
"SELECT value FROM app_settings WHERE key = ?",
("count_income_only_salary_card",),
).fetchone()
only_salary_card = row and row["value"] == "1"
row2 = conn.execute(
"SELECT value FROM app_settings WHERE key = ?",
("salary_account_id",),
).fetchone()
account_id = int(row2["value"]) if row2 and row2["value"] else None
return (only_salary_card, account_id)
finally:
conn.close()
def set_income_settings(count_income_only_salary_card: bool, salary_account_id: int | None) -> None:
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)",
("count_income_only_salary_card", "1" if count_income_only_salary_card else "0"),
)
conn.execute(
"INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)",
("salary_account_id", str(salary_account_id) if salary_account_id else ""),
)
conn.commit()
finally:
conn.close()
def get_exclude_transfers() -> bool:
"""Игнорировать операции с «Перевод» в расчётах."""
conn = get_connection()
try:
row = conn.execute(
"SELECT value FROM app_settings WHERE key = ?",
("exclude_transfers",),
).fetchone()
return row and row["value"] == "1"
finally:
conn.close()
def set_exclude_transfers(value: bool) -> None:
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)",
("exclude_transfers", "1" if value else "0"),
)
conn.commit()
finally:
conn.close()

0
frontend/dist/assets/.gitkeep vendored Normal file
View File

579
frontend/dist/index.html vendored Normal file
View File

@@ -0,0 +1,579 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Личные финансы</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root {
--bg: #0f0f12;
--card: #1a1a1f;
--text: #e4e4e7;
--muted: #71717a;
--accent: #22c55e;
--negative: #ef4444;
--border: #27272a;
}
* { box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
padding: 1.5rem;
line-height: 1.5;
}
h1 { font-size: 1.5rem; margin: 0 0 1.5rem; font-weight: 600; }
h2 { font-size: 1.1rem; margin: 0 0 0.75rem; color: var(--muted); font-weight: 500; }
.nav { display: flex; gap: 1rem; margin-bottom: 1.5rem; }
.nav a { color: var(--muted); text-decoration: none; }
.nav a.active { color: var(--accent); }
.nav a:hover { color: var(--text); }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 1rem;
}
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 2rem; }
.metric { text-align: center; min-width: 0; padding: 0 0.5rem; }
.metric .value { font-size: 1.5rem; font-weight: 700; }
.metric .value.positive { color: var(--accent); }
.metric .value.negative { color: var(--negative); }
.metric .label { font-size: 0.8rem; color: var(--muted); margin-top: 0.25rem; }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
th, td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 500; }
.amount.in { color: var(--accent); }
.amount.out { color: var(--negative); }
.page { display: none; }
.page.active { display: block; }
button, .btn {
background: var(--accent);
color: var(--bg);
border: none;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
}
button.secondary { background: var(--border); color: var(--text); }
button:hover { opacity: 0.9; }
input[type="file"] { margin: 0.5rem 0; }
.banks-list { list-style: none; padding: 0; margin: 0; }
.banks-list li {
display: flex; align-items: center; justify-content: space-between;
padding: 0.5rem 0; border-bottom: 1px solid var(--border);
}
.charts-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 700px) { .charts-row { grid-template-columns: 1fr; } }
.chart-wrap { height: 220px; }
.error { color: var(--negative); font-size: 0.9rem; margin-top: 0.5rem; }
.tx-excluded { opacity: 0.6; }
input[type="checkbox"] { cursor: pointer; accent-color: var(--accent); }
</style>
</head>
<body>
<h1>Личные финансы</h1>
<nav class="nav">
<a href="#" data-page="dashboard" class="active">Дашборд</a>
<a href="#" data-page="settings">Настройки</a>
</nav>
<div id="page-dashboard" class="page active">
<div class="card filters-card">
<h2>Фильтры</h2>
<div class="filters-row" style="display:flex; flex-wrap:wrap; gap:1.5rem; align-items:flex-end;">
<div>
<label style="display:block; font-size:0.85rem; color:var(--muted); margin-bottom:0.25rem;">Период с</label>
<input type="date" id="filter-period-start" style="background:var(--bg); color:var(--text); border:1px solid var(--border); padding:0.4rem; border-radius:6px;" />
</div>
<div>
<label style="display:block; font-size:0.85rem; color:var(--muted); margin-bottom:0.25rem;">по</label>
<input type="date" id="filter-period-end" style="background:var(--bg); color:var(--text); border:1px solid var(--border); padding:0.4rem; border-radius:6px;" />
</div>
<div>
<span style="font-size:0.85rem; color:var(--muted); margin-right:0.5rem;">Банки:</span>
<span id="filter-banks"></span>
</div>
<label style="display:flex; align-items:center; gap:0.5rem; white-space:nowrap;">
<input type="checkbox" id="exclude-transfers-cb" />
<span>Не учитывать переводы</span>
</label>
<button type="button" id="filter-apply" class="secondary" style="padding:0.4rem 0.75rem;">Применить</button>
<button type="button" id="filter-reset" class="secondary" style="padding:0.4rem 0.75rem;">Сбросить</button>
</div>
</div>
<div class="card">
<h2>Сводка</h2>
<div class="grid" id="summary-metrics"></div>
</div>
<div class="charts-row">
<div class="card">
<h2>Доход и расход по месяцам</h2>
<div class="chart-wrap"><canvas id="chart-income-expense"></canvas></div>
</div>
<div class="card">
<h2>Динамика остатка</h2>
<div class="chart-wrap"><canvas id="chart-balance"></canvas></div>
</div>
<div class="card">
<h2>Динамика копилки</h2>
<div class="chart-wrap"><canvas id="chart-savings"></canvas></div>
</div>
</div>
<div class="card">
<h2>Все операции</h2>
<div class="pagination-controls" style="display:flex; align-items:center; gap:1rem; margin-bottom:0.75rem; flex-wrap:wrap;">
<span>На странице:</span>
<select id="page-size" style="background:var(--card); color:var(--text); border:1px solid var(--border); padding:0.35rem 0.5rem; border-radius:6px;">
<option value="50">50</option>
<option value="100">100</option>
</select>
<span id="pagination-info" style="color:var(--muted); font-size:0.9rem;"></span>
<button type="button" id="btn-prev" class="secondary" style="padding:0.35rem 0.75rem;">Назад</button>
<button type="button" id="btn-next" class="secondary" style="padding:0.35rem 0.75rem;">Вперёд</button>
</div>
<div id="transactions-table"></div>
</div>
</div>
<div id="page-settings" class="page">
<div class="card">
<h2>Зарплатный банк</h2>
<p class="muted" style="font-size:0.9rem; color: var(--muted);">Входящие на выбранный банк считаются доходом.</p>
<ul class="banks-list" id="banks-list"></ul>
</div>
<div class="card">
<h2>Доходы</h2>
<p class="muted" style="font-size:0.9rem; color: var(--muted);">Учёт дохода в сводке и на графиках.</p>
<label style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.75rem;">
<input type="checkbox" id="income-only-salary-card" />
<span>Считать доходы только по зарплатной карте</span>
</label>
<div id="income-salary-card-wrap" style="display:none; margin-top:0.5rem;">
<label style="font-size:0.9rem; color:var(--muted);">Зарплатная карта:</label>
<select id="income-salary-account" style="background:var(--bg); color:var(--text); border:1px solid var(--border); padding:0.4rem; border-radius:6px; margin-left:0.5rem; min-width:200px;">
<option value="">— выбрать карту —</option>
</select>
</div>
<button type="button" id="btn-save-income-settings" class="secondary" style="margin-top:0.75rem;">Сохранить</button>
</div>
<div class="card">
<h2>Импорт выписок</h2>
<p>Загрузите PDF файл (Т-MM-YY.pdf) или импортируйте из папки «Выписки банков».</p>
<input type="file" id="file-input" accept=".pdf" />
<button type="button" id="btn-upload">Загрузить файл</button>
<span class="error" id="upload-error"></span>
<div style="margin-top:1rem;">
<button type="button" id="btn-import-folder" class="secondary">Импорт из папки</button>
<span id="import-result"></span>
<p style="font-size:0.85rem; color:var(--muted); margin-top:0.5rem;">Если добавлено 0 при повторном нажатии — перезапустите сервер (uvicorn) и нажмите снова.</p>
</div>
</div>
</div>
<script>
const API = '';
async function get(url) {
const r = await fetch(API + url);
if (!r.ok) throw new Error(await r.text());
return r.json();
}
async function put(url) {
const r = await fetch(API + url, { method: 'PUT' });
if (!r.ok) throw new Error(await r.text());
return r.json();
}
async function patch(url, body) {
const r = await fetch(API + url, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) throw new Error(await r.text());
return r.json();
}
function fmtMoney(n) {
return new Intl.NumberFormat('ru-RU', { style: 'decimal', minimumFractionDigits: 2 }).format(n);
}
function fmtDate(s) {
if (!s) return '';
return s.slice(0, 10).split('-').reverse().join('.');
}
let chartIncomeExpense, chartBalance, chartSavings;
let filterPeriodStart = '';
let filterPeriodEnd = '';
let filterBankIds = [];
function buildFilterQuery() {
const p = [];
if (filterPeriodStart) p.push('period_start=' + encodeURIComponent(filterPeriodStart));
if (filterPeriodEnd) p.push('period_end=' + encodeURIComponent(filterPeriodEnd));
if (filterBankIds.length) p.push('bank_ids=' + filterBankIds.join(','));
return p.length ? '?' + p.join('&') : '';
}
async function loadSummary() {
const data = await get('/api/balance/summary' + buildFilterQuery());
const el = document.getElementById('summary-metrics');
el.innerHTML = `
<div class="metric">
<div class="value ${data.total_balance >= 0 ? 'positive' : 'negative'}">${fmtMoney(data.total_balance)} ₽</div>
<div class="label">Суммарный баланс</div>
</div>
<div class="metric">
<div class="value positive">${fmtMoney(data.income)} ₽</div>
<div class="label">Доход (зарплатный счёт)</div>
</div>
<div class="metric">
<div class="value positive">${fmtMoney(data.debit)} ₽</div>
<div class="label">Дебет (приход)</div>
</div>
<div class="metric">
<div class="value negative">${fmtMoney(data.credit)} ₽</div>
<div class="label">Кредит (расход)</div>
</div>
<div class="metric">
<div class="value positive">${fmtMoney(data.savings_balance ?? 0)} ₽</div>
<div class="label">Копилка (накопления)</div>
</div>
<div class="metric">
<div class="value ${(data.net_flow ?? 0) >= 0 ? 'positive' : 'negative'}">${fmtMoney(data.net_flow ?? 0)} ₽</div>
<div class="label">За период: ${(data.net_flow ?? 0) >= 0 ? 'накопление' : 'траты'}</div>
</div>
`;
}
let transactionsPage = 0;
let transactionsPageSize = 50;
async function loadTransactions() {
const offset = transactionsPage * transactionsPageSize;
const base = buildFilterQuery();
const data = await get('/api/transactions' + (base ? base + '&' : '?') + 'limit=' + transactionsPageSize + '&offset=' + offset);
const items = data.items || [];
const total = data.total ?? 0;
const el = document.getElementById('transactions-table');
const infoEl = document.getElementById('pagination-info');
if (!items.length && total === 0) {
el.innerHTML = '<p style="color:var(--muted)">Нет операций. Импортируйте выписки в Настройках.</p>';
infoEl.textContent = '';
document.getElementById('btn-prev').disabled = true;
document.getElementById('btn-next').disabled = true;
return;
}
const from = offset + 1;
const to = Math.min(offset + transactionsPageSize, total);
infoEl.textContent = 'Показано ' + from + '' + to + ' из ' + total;
document.getElementById('btn-prev').disabled = transactionsPage === 0;
document.getElementById('btn-next').disabled = offset + items.length >= total;
el.innerHTML = `
<table>
<thead><tr><th title="Учитывать в расчёте баланса"><input type="checkbox" id="th-include-all" title="В расчёте" /></th><th>Банк</th><th>Дата</th><th>Счёт</th><th>Описание</th><th>Сумма</th></tr></thead>
<tbody>
${items.map(t => {
const included = !t.excluded_from_balance;
return `
<tr class="${included ? '' : 'tx-excluded'}" data-id="${t.id}">
<td><input type="checkbox" class="tx-include-cb" data-id="${t.id}" ${included ? 'checked' : ''} title="Учитывать в расчёте баланса" /></td>
<td>${t.bank_name || t.bank_code || ''}</td>
<td>${fmtDate(t.operation_date)}</td>
<td>***${t.external_id}</td>
<td>${(t.description || '').slice(0, 40)}${(t.description||'').length > 40 ? '…' : ''}</td>
<td class="amount ${t.amount >= 0 ? 'in' : 'out'}">${t.amount >= 0 ? '+' : ''}${fmtMoney(t.amount)} ₽</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
el.querySelectorAll('.tx-include-cb').forEach(cb => {
cb.onclick = async () => {
const id = parseInt(cb.dataset.id, 10);
const excluded = !cb.checked;
try {
await patch('/api/transactions/' + id, { excluded_from_balance: excluded });
cb.closest('tr').classList.toggle('tx-excluded', excluded);
loadSummary();
loadCharts();
} catch (e) {
cb.checked = !cb.checked;
console.error(e);
}
};
});
const thAll = document.getElementById('th-include-all');
if (thAll) {
const includedCount = items.filter(t => !t.excluded_from_balance).length;
thAll.checked = includedCount === items.length;
thAll.indeterminate = includedCount > 0 && includedCount < items.length;
thAll.onclick = async () => {
const wantInclude = thAll.checked;
for (const row of el.querySelectorAll('tbody tr')) {
const id = parseInt(row.dataset.id, 10);
const cb = row.querySelector('.tx-include-cb');
if (cb.checked !== wantInclude) {
try {
await patch('/api/transactions/' + id, { excluded_from_balance: !wantInclude });
cb.checked = wantInclude;
row.classList.toggle('tx-excluded', !wantInclude);
} catch (e) { console.error(e); }
}
}
loadSummary();
loadCharts();
};
}
}
function initPagination() {
document.getElementById('page-size').onchange = () => {
transactionsPageSize = parseInt(document.getElementById('page-size').value, 10);
transactionsPage = 0;
loadTransactions();
};
document.getElementById('btn-prev').onclick = () => {
if (transactionsPage > 0) { transactionsPage--; loadTransactions(); }
};
document.getElementById('btn-next').onclick = () => {
transactionsPage++; loadTransactions();
};
}
async function loadCharts() {
const chartQ = buildFilterQuery();
const [byMonth, dynamics, savingsDynamics] = await Promise.all([
get('/api/charts/income-expense' + chartQ),
get('/api/charts/balance-dynamics' + chartQ),
get('/api/charts/savings-dynamics' + chartQ)
]);
const ctx1 = document.getElementById('chart-income-expense').getContext('2d');
if (chartIncomeExpense) chartIncomeExpense.destroy();
chartIncomeExpense = new Chart(ctx1, {
type: 'bar',
data: {
labels: (byMonth || []).map(x => x.month),
datasets: [
{ label: 'Доход', data: (byMonth || []).map(x => x.income), backgroundColor: 'rgba(34, 197, 94, 0.7)' },
{ label: 'Расход', data: (byMonth || []).map(x => x.credit), backgroundColor: 'rgba(239, 68, 68, 0.7)' }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { x: { grid: { color: 'rgba(255,255,255,0.06)' } }, y: { grid: { color: 'rgba(255,255,255,0.06)' } } },
plugins: { legend: { labels: { color: '#e4e4e7' } } }
}
});
const ctx2 = document.getElementById('chart-balance').getContext('2d');
if (chartBalance) chartBalance.destroy();
chartBalance = new Chart(ctx2, {
type: 'line',
data: {
labels: (dynamics || []).map(x => x.date),
datasets: [{ label: 'Остаток (накопл.)', data: (dynamics || []).map(x => x.balance), borderColor: '#22c55e', fill: true, tension: 0.2 }]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { x: { grid: { color: 'rgba(255,255,255,0.06)' } }, y: { grid: { color: 'rgba(255,255,255,0.06)' } } },
plugins: { legend: { labels: { color: '#e4e4e7' } } }
}
});
const ctx3 = document.getElementById('chart-savings').getContext('2d');
if (chartSavings) chartSavings.destroy();
chartSavings = new Chart(ctx3, {
type: 'line',
data: {
labels: (savingsDynamics || []).map(x => x.date),
datasets: [{ label: 'Копилка (накопл.)', data: (savingsDynamics || []).map(x => x.savings), borderColor: '#eab308', fill: true, tension: 0.2 }]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { x: { grid: { color: 'rgba(255,255,255,0.06)' } }, y: { grid: { color: 'rgba(255,255,255,0.06)' } } },
plugins: { legend: { labels: { color: '#e4e4e7' } } }
}
});
}
async function ensureFilterBanks() {
if (document.querySelector('.filter-bank-cb')) return;
const banks = await get('/api/banks');
const el = document.getElementById('filter-banks');
if (!el || !banks.length) return;
el.innerHTML = banks.map(b => `<label style="margin-right:0.75rem; white-space:nowrap;"><input type="checkbox" class="filter-bank-cb" data-id="${b.id}" checked /> ${b.name}</label>`).join('');
}
function applyFilters() {
filterPeriodStart = document.getElementById('filter-period-start').value || '';
filterPeriodEnd = document.getElementById('filter-period-end').value || '';
const all = document.querySelectorAll('.filter-bank-cb');
const checked = document.querySelectorAll('.filter-bank-cb:checked');
filterBankIds = (all.length && checked.length < all.length) ? Array.from(checked).map(cb => parseInt(cb.dataset.id, 10)) : [];
transactionsPage = 0;
loadDashboard();
}
function resetFilters() {
document.getElementById('filter-period-start').value = '';
document.getElementById('filter-period-end').value = '';
document.querySelectorAll('.filter-bank-cb').forEach(cb => { cb.checked = true; });
filterPeriodStart = '';
filterPeriodEnd = '';
filterBankIds = [];
transactionsPage = 0;
loadDashboard();
}
async function loadExcludeTransfersCheckbox() {
try {
const data = await get('/api/settings/exclude-transfers');
const cb = document.getElementById('exclude-transfers-cb');
if (cb) cb.checked = data.exclude_transfers || false;
} catch (e) {
console.error(e);
}
}
async function loadDashboard() {
try {
await ensureFilterBanks();
await loadExcludeTransfersCheckbox();
await loadSummary();
await loadTransactions();
await loadCharts();
} catch (e) {
console.error(e);
document.getElementById('summary-metrics').innerHTML = '<p class="error">Ошибка загрузки: ' + e.message + '</p>';
}
}
async function loadIncomeSettings() {
const data = await get('/api/settings/income');
const cb = document.getElementById('income-only-salary-card');
const wrapDiv = document.getElementById('income-salary-card-wrap');
const sel = document.getElementById('income-salary-account');
cb.checked = data.count_income_only_salary_card || false;
if (wrapDiv) wrapDiv.style.display = cb.checked ? 'block' : 'none';
sel.innerHTML = '<option value="">— выбрать карту —</option>' +
(data.salary_accounts || []).map(a => `<option value="${a.id}" ${a.id === data.salary_account_id ? 'selected' : ''}>***${a.external_id} ${a.name || ''}</option>`).join('');
if (cb.checked && data.salary_account_id && !sel.querySelector('option[value="' + data.salary_account_id + '"]')) {
const opt = document.createElement('option');
opt.value = data.salary_account_id;
opt.textContent = '*** (id ' + data.salary_account_id + ')';
opt.selected = true;
sel.appendChild(opt);
}
}
async function loadBanks() {
const banks = await get('/api/banks');
const el = document.getElementById('banks-list');
if (!banks.length) {
el.innerHTML = '<li style="color:var(--muted)">Нет банков. Импортируйте выписки Т-банка.</li>';
return;
}
el.innerHTML = banks.map(b => `
<li>
<span>${b.name} (${b.code}) ${b.is_salary ? ' ★ зарплатный' : ''}</span>
${!b.is_salary ? `<button type="button" data-bank-id="${b.id}" class="btn-set-salary secondary">Сделать зарплатным</button>` : ''}
</li>
`).join('');
el.querySelectorAll('.btn-set-salary').forEach(btn => {
btn.onclick = async () => {
await put('/api/banks/salary/' + btn.dataset.bankId);
loadBanks();
loadDashboard();
};
});
}
document.querySelectorAll('.nav a').forEach(a => {
a.onclick = (e) => {
e.preventDefault();
document.querySelectorAll('.nav a').forEach(x => x.classList.remove('active'));
document.querySelectorAll('.page').forEach(x => x.classList.remove('active'));
a.classList.add('active');
document.getElementById('page-' + a.dataset.page).classList.add('active');
if (a.dataset.page === 'settings') { loadBanks(); loadIncomeSettings(); }
else loadDashboard();
};
});
document.getElementById('btn-upload').onclick = async () => {
const input = document.getElementById('file-input');
const err = document.getElementById('upload-error');
if (!input.files?.length) { err.textContent = 'Выберите файл'; return; }
err.textContent = '';
const form = new FormData();
form.append('file', input.files[0]);
try {
const r = await fetch(API + '/api/import/upload', { method: 'POST', body: form });
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.detail || r.statusText);
err.style.color = 'var(--accent)';
err.textContent = data.parsed != null ? `Распознано: ${data.parsed}, добавлено: ${data.added}, дубликатов: ${data.skipped_duplicates}` : `Добавлено: ${data.added}, дубликатов: ${data.skipped_duplicates}`;
input.value = '';
loadDashboard();
} catch (e) {
err.style.color = 'var(--negative)';
err.textContent = e.message;
}
};
document.getElementById('btn-import-folder').onclick = async () => {
const res = document.getElementById('import-result');
res.textContent = ' Загрузка…';
try {
const r = await fetch(API + '/api/import/from-folder', { method: 'POST' });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || r.statusText);
res.style.color = 'var(--accent)';
res.textContent = d.parsed != null ? ` Распознано: ${d.parsed}, добавлено: ${d.added}, дубликатов: ${d.skipped_duplicates}` : ` Добавлено: ${d.added}, дубликатов: ${d.skipped_duplicates}`;
loadDashboard();
loadBanks();
} catch (e) {
res.style.color = 'var(--negative)';
res.textContent = ' ' + e.message;
}
};
document.getElementById('filter-apply').onclick = applyFilters;
document.getElementById('filter-reset').onclick = resetFilters;
document.getElementById('exclude-transfers-cb').onchange = async function() {
try {
await fetch(API + '/api/settings/exclude-transfers', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ exclude_transfers: this.checked })
});
await loadSummary();
await loadCharts();
} catch (e) { console.error(e); }
};
document.getElementById('income-only-salary-card').onchange = function() {
document.getElementById('income-salary-card-wrap').style.display = this.checked ? 'block' : 'none';
};
document.getElementById('btn-save-income-settings').onclick = async function() {
const cb = document.getElementById('income-only-salary-card');
const sel = document.getElementById('income-salary-account');
const accountId = sel.value ? parseInt(sel.value, 10) : null;
try {
const r = await fetch(API + '/api/settings/income', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ count_income_only_salary_card: cb.checked, salary_account_id: accountId })
});
if (!r.ok) throw new Error('Ошибка сохранения');
await loadIncomeSettings();
loadDashboard();
} catch (e) {
console.error(e);
}
};
initPagination();
loadDashboard();
</script>
</body>
</html>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.