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/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}