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

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()