commit 31dc287c3d87acb0bb750c9a2b99eaf64ebbc65a Author: Andrey Date: Mon Feb 23 16:49:24 2026 +0300 Initial income_calculator project Co-authored-by: Cursor diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9660493 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +venv +.git +__pycache__ +*.pyc +.cursor +*.db +.DS_Store +*.md +!backend/requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c2ee01 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +venv/ +__pycache__/ +*.py[cod] +*.sqlite3 +*.db + +# macOS +.DS_Store + +# IDE +.idea/ +.vscode/ + +# Docker artifacts (if any) +*.log + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8879925 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..15a2637 --- /dev/null +++ b/Makefile @@ -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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..9629b21 --- /dev/null +++ b/README.md @@ -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` (не учитывать переводы). diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..7f83169 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +# Backend package diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000..28b07ef --- /dev/null +++ b/backend/api/__init__.py @@ -0,0 +1 @@ +# API package diff --git a/backend/api/accounts.py b/backend/api/accounts.py new file mode 100644 index 0000000..f34606e --- /dev/null +++ b/backend/api/accounts.py @@ -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() diff --git a/backend/api/balance.py b/backend/api/balance.py new file mode 100644 index 0000000..0db2b23 --- /dev/null +++ b/backend/api/balance.py @@ -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() diff --git a/backend/api/banks.py b/backend/api/banks.py new file mode 100644 index 0000000..efbb37e --- /dev/null +++ b/backend/api/banks.py @@ -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() diff --git a/backend/api/charts.py b/backend/api/charts.py new file mode 100644 index 0000000..c2921d8 --- /dev/null +++ b/backend/api/charts.py @@ -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() diff --git a/backend/api/import_api.py b/backend/api/import_api.py new file mode 100644 index 0000000..ace3ebd --- /dev/null +++ b/backend/api/import_api.py @@ -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} diff --git a/backend/api/settings_api.py b/backend/api/settings_api.py new file mode 100644 index 0000000..40d46b6 --- /dev/null +++ b/backend/api/settings_api.py @@ -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} diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..6d926a1 --- /dev/null +++ b/backend/config.py @@ -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 / "Выписки банков")) diff --git a/backend/db/__init__.py b/backend/db/__init__.py new file mode 100644 index 0000000..182d44d --- /dev/null +++ b/backend/db/__init__.py @@ -0,0 +1,3 @@ +from .database import init_db, get_connection + +__all__ = ["init_db", "get_connection"] diff --git a/backend/db/database.py b/backend/db/database.py new file mode 100644 index 0000000..9882a1d --- /dev/null +++ b/backend/db/database.py @@ -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() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..b7ec053 --- /dev/null +++ b/backend/main.py @@ -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"} diff --git a/backend/parsers/__init__.py b/backend/parsers/__init__.py new file mode 100644 index 0000000..a07ec63 --- /dev/null +++ b/backend/parsers/__init__.py @@ -0,0 +1 @@ +# Parsers package diff --git a/backend/parsers/bank_s.py b/backend/parsers/bank_s.py new file mode 100644 index 0000000..3b91f62 --- /dev/null +++ b/backend/parsers/bank_s.py @@ -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 diff --git a/backend/parsers/bank_t.py b/backend/parsers/bank_t.py new file mode 100644 index 0000000..4c2bf68 --- /dev/null +++ b/backend/parsers/bank_t.py @@ -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 diff --git a/backend/parsers/bank_y.py b/backend/parsers/bank_y.py new file mode 100644 index 0000000..59c392f --- /dev/null +++ b/backend/parsers/bank_y.py @@ -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 diff --git a/backend/parsers/base.py b/backend/parsers/base.py new file mode 100644 index 0000000..ecce215 --- /dev/null +++ b/backend/parsers/base.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..597f46b --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..a70b302 --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1 @@ +# Services package diff --git a/backend/services/balance_service.py b/backend/services/balance_service.py new file mode 100644 index 0000000..2cf1604 --- /dev/null +++ b/backend/services/balance_service.py @@ -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() diff --git a/backend/services/import_service.py b/backend/services/import_service.py new file mode 100644 index 0000000..5d2013c --- /dev/null +++ b/backend/services/import_service.py @@ -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 diff --git a/backend/services/settings_service.py b/backend/services/settings_service.py new file mode 100644 index 0000000..7804edb --- /dev/null +++ b/backend/services/settings_service.py @@ -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() diff --git a/frontend/dist/assets/.gitkeep b/frontend/dist/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/dist/index.html b/frontend/dist/index.html new file mode 100644 index 0000000..0a0e3bd --- /dev/null +++ b/frontend/dist/index.html @@ -0,0 +1,579 @@ + + + + + + Личные финансы + + + + +

Личные финансы

+ + +
+
+

Фильтры

+
+
+ + +
+
+ + +
+
+ Банки: + +
+ + + +
+
+
+

Сводка

+
+
+
+
+

Доход и расход по месяцам

+
+
+
+

Динамика остатка

+
+
+
+

Динамика копилки

+
+
+
+
+

Все операции

+
+ На странице: + + + + +
+
+
+
+ +
+
+

Зарплатный банк

+

Входящие на выбранный банк считаются доходом.

+
    +
    +
    +

    Доходы

    +

    Учёт дохода в сводке и на графиках.

    + + + +
    +
    +

    Импорт выписок

    +

    Загрузите PDF файл (Т-MM-YY.pdf) или импортируйте из папки «Выписки банков».

    + + + +
    + + +

    Если добавлено 0 при повторном нажатии — перезапустите сервер (uvicorn) и нажмите снова.

    +
    +
    +
    + + + + diff --git a/Выписки банков/С-01-25.pdf b/Выписки банков/С-01-25.pdf new file mode 100644 index 0000000..5a24dcd Binary files /dev/null and b/Выписки банков/С-01-25.pdf differ diff --git a/Выписки банков/С-01-26.pdf b/Выписки банков/С-01-26.pdf new file mode 100644 index 0000000..9d6c304 Binary files /dev/null and b/Выписки банков/С-01-26.pdf differ diff --git a/Выписки банков/С-10-25.pdf b/Выписки банков/С-10-25.pdf new file mode 100644 index 0000000..a1214e5 Binary files /dev/null and b/Выписки банков/С-10-25.pdf differ diff --git a/Выписки банков/С-11-25.pdf b/Выписки банков/С-11-25.pdf new file mode 100644 index 0000000..d0b04c8 Binary files /dev/null and b/Выписки банков/С-11-25.pdf differ diff --git a/Выписки банков/С-12-25.pdf b/Выписки банков/С-12-25.pdf new file mode 100644 index 0000000..edbe459 Binary files /dev/null and b/Выписки банков/С-12-25.pdf differ diff --git a/Выписки банков/Т-01-25.pdf b/Выписки банков/Т-01-25.pdf new file mode 100644 index 0000000..c0458ba Binary files /dev/null and b/Выписки банков/Т-01-25.pdf differ diff --git a/Выписки банков/Т-01-26.pdf b/Выписки банков/Т-01-26.pdf new file mode 100644 index 0000000..4dc362c Binary files /dev/null and b/Выписки банков/Т-01-26.pdf differ diff --git a/Выписки банков/Т-10-25.pdf b/Выписки банков/Т-10-25.pdf new file mode 100644 index 0000000..35a6e46 Binary files /dev/null and b/Выписки банков/Т-10-25.pdf differ diff --git a/Выписки банков/Т-11-25.pdf b/Выписки банков/Т-11-25.pdf new file mode 100644 index 0000000..89685c6 Binary files /dev/null and b/Выписки банков/Т-11-25.pdf differ diff --git a/Выписки банков/Т-12-25.pdf b/Выписки банков/Т-12-25.pdf new file mode 100644 index 0000000..5cb9206 Binary files /dev/null and b/Выписки банков/Т-12-25.pdf differ diff --git a/Выписки банков/Я-01-25.pdf b/Выписки банков/Я-01-25.pdf new file mode 100644 index 0000000..28e8316 Binary files /dev/null and b/Выписки банков/Я-01-25.pdf differ diff --git a/Выписки банков/Я-01-26.pdf b/Выписки банков/Я-01-26.pdf new file mode 100644 index 0000000..fccc3cb Binary files /dev/null and b/Выписки банков/Я-01-26.pdf differ diff --git a/Выписки банков/Я-10-25.pdf b/Выписки банков/Я-10-25.pdf new file mode 100644 index 0000000..6773277 Binary files /dev/null and b/Выписки банков/Я-10-25.pdf differ diff --git a/Выписки банков/Я-11-25.pdf b/Выписки банков/Я-11-25.pdf new file mode 100644 index 0000000..1ae4d9a Binary files /dev/null and b/Выписки банков/Я-11-25.pdf differ diff --git a/Выписки банков/Я-12-25.pdf b/Выписки банков/Я-12-25.pdf new file mode 100644 index 0000000..448239d Binary files /dev/null and b/Выписки банков/Я-12-25.pdf differ