финальная версия Finance Control

This commit is contained in:
2026-05-25 22:06:16 +03:00
parent 7dcb1a23a7
commit 5890e4ae64
22 changed files with 1441 additions and 927 deletions

3
app/services/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from app.services.finance_service import FinanceService
__all__ = ["FinanceService"]

View File

@@ -0,0 +1,433 @@
import csv
from pathlib import Path
from sqlite3 import IntegrityError, Row
from typing import Iterable
from app.database import get_connection
from app.utils.formatters import current_month_key, format_money
class FinanceService:
"""Business layer for Finance Control.
UI classes call this object instead of duplicating SQL logic. It makes the
program easier to test, maintain and explain from the OOP point of view.
"""
def get_setting(self, key: str, default: str) -> str:
with get_connection() as connection:
row = connection.execute(
"SELECT value FROM settings WHERE key = ?",
(key,),
).fetchone()
return default if row is None else row["value"]
def set_setting(self, key: str, value: str) -> None:
with get_connection() as connection:
connection.execute(
"""
INSERT INTO settings (key, value)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
""",
(key, value),
)
connection.commit()
def add_account(self, name: str, account_type: str, balance: float) -> None:
with get_connection() as connection:
connection.execute(
"""
INSERT INTO accounts (name, account_type, balance)
VALUES (?, ?, ?)
""",
(name, account_type, balance),
)
connection.commit()
def delete_account(self, account_id: int) -> None:
with get_connection() as connection:
connection.execute("DELETE FROM accounts WHERE id = ?", (account_id,))
connection.commit()
def list_accounts(self) -> list[Row]:
with get_connection() as connection:
return connection.execute(
"SELECT * FROM accounts ORDER BY id DESC"
).fetchall()
def list_account_options(self) -> list[Row]:
with get_connection() as connection:
return connection.execute(
"SELECT id, name, account_type, balance FROM accounts ORDER BY name"
).fetchall()
def add_category(self, name: str, category_type: str) -> None:
with get_connection() as connection:
connection.execute(
"""
INSERT INTO categories (name, category_type)
VALUES (?, ?)
""",
(name, category_type),
)
connection.commit()
def delete_category(self, category_id: int) -> None:
with get_connection() as connection:
connection.execute("DELETE FROM categories WHERE id = ?", (category_id,))
connection.commit()
def list_categories(self, category_type: str | None = None) -> list[Row]:
with get_connection() as connection:
if category_type is None:
return connection.execute(
"""
SELECT * FROM categories
ORDER BY category_type DESC, name
"""
).fetchall()
return connection.execute(
"""
SELECT * FROM categories
WHERE category_type = ?
ORDER BY name
""",
(category_type,),
).fetchall()
def add_transaction(
self,
title: str,
amount: float,
transaction_type: str,
account_id: int,
category_id: int,
created_at: str,
) -> None:
sign = 1 if transaction_type == "Доход" else -1
delta = amount * sign
with get_connection() as connection:
connection.execute("BEGIN")
connection.execute(
"""
INSERT INTO transactions (
title, amount, transaction_type,
account_id, category_id, created_at
)
VALUES (?, ?, ?, ?, ?, ?)
""",
(title, amount, transaction_type, account_id, category_id, created_at),
)
connection.execute(
"UPDATE accounts SET balance = balance + ? WHERE id = ?",
(delta, account_id),
)
connection.commit()
def delete_transaction(self, transaction_id: int) -> None:
with get_connection() as connection:
row = connection.execute(
"SELECT * FROM transactions WHERE id = ?",
(transaction_id,),
).fetchone()
if row is None:
return
sign = -1 if row["transaction_type"] == "Доход" else 1
delta = row["amount"] * sign
connection.execute("BEGIN")
connection.execute(
"UPDATE accounts SET balance = balance + ? WHERE id = ?",
(delta, row["account_id"]),
)
connection.execute(
"DELETE FROM transactions WHERE id = ?",
(transaction_id,),
)
connection.commit()
def list_transactions(
self,
search: str = "",
transaction_type: str = "Все",
limit: int = 100,
) -> list[Row]:
clauses = []
params: list[object] = []
if search:
clauses.append("t.title LIKE ?")
params.append(f"%{search}%")
if transaction_type != "Все":
clauses.append("t.transaction_type = ?")
params.append(transaction_type)
where_sql = ""
if clauses:
where_sql = "WHERE " + " AND ".join(clauses)
params.append(limit)
with get_connection() as connection:
return connection.execute(
f"""
SELECT t.id,
t.title,
t.amount,
t.transaction_type,
t.created_at,
a.name AS account_name,
c.name AS category_name
FROM transactions AS t
JOIN accounts AS a ON a.id = t.account_id
JOIN categories AS c ON c.id = t.category_id
{where_sql}
ORDER BY t.created_at DESC, t.id DESC
LIMIT ?
""",
params,
).fetchall()
def add_subscription(
self,
name: str,
amount: float,
billing_day: int,
period: str,
status: str,
) -> None:
with get_connection() as connection:
connection.execute(
"""
INSERT INTO subscriptions (name, amount, billing_day, period, status)
VALUES (?, ?, ?, ?, ?)
""",
(name, amount, billing_day, period, status),
)
connection.commit()
def delete_subscription(self, subscription_id: int) -> None:
with get_connection() as connection:
connection.execute(
"DELETE FROM subscriptions WHERE id = ?",
(subscription_id,),
)
connection.commit()
def toggle_subscription_status(self, subscription_id: int) -> None:
with get_connection() as connection:
row = connection.execute(
"SELECT status FROM subscriptions WHERE id = ?",
(subscription_id,),
).fetchone()
if row is None:
return
new_status = "Пауза" if row["status"] == "Активна" else "Активна"
connection.execute(
"UPDATE subscriptions SET status = ? WHERE id = ?",
(new_status, subscription_id),
)
connection.commit()
def list_subscriptions(self) -> list[Row]:
with get_connection() as connection:
return connection.execute(
"SELECT * FROM subscriptions ORDER BY status, billing_day, name"
).fetchall()
def add_or_update_budget(self, category_id: int, limit_amount: float) -> None:
with get_connection() as connection:
connection.execute(
"""
INSERT INTO budgets (category_id, limit_amount)
VALUES (?, ?)
ON CONFLICT(category_id)
DO UPDATE SET limit_amount = excluded.limit_amount
""",
(category_id, limit_amount),
)
connection.commit()
def delete_budget(self, budget_id: int) -> None:
with get_connection() as connection:
connection.execute("DELETE FROM budgets WHERE id = ?", (budget_id,))
connection.commit()
def list_budgets(self) -> list[Row]:
month_key = current_month_key()
with get_connection() as connection:
return connection.execute(
"""
SELECT b.id,
b.limit_amount,
c.name AS category_name,
COALESCE(SUM(t.amount), 0) AS spent
FROM budgets AS b
JOIN categories AS c ON c.id = b.category_id
LEFT JOIN transactions AS t
ON t.category_id = b.category_id
AND t.transaction_type = 'Расход'
AND strftime('%Y-%m', t.created_at) = ?
GROUP BY b.id, b.limit_amount, c.name
ORDER BY c.name
""",
(month_key,),
).fetchall()
def dashboard_summary(self) -> dict[str, object]:
month_key = current_month_key()
with get_connection() as connection:
balance = connection.execute(
"SELECT COALESCE(SUM(balance), 0) AS total FROM accounts"
).fetchone()["total"]
summary = connection.execute(
"""
SELECT
COALESCE(SUM(CASE WHEN transaction_type = 'Доход'
THEN amount END), 0) AS income,
COALESCE(SUM(CASE WHEN transaction_type = 'Расход'
THEN amount END), 0) AS expense
FROM transactions
WHERE strftime('%Y-%m', created_at) = ?
""",
(month_key,),
).fetchone()
subscriptions = connection.execute(
"""
SELECT COUNT(*) AS count,
COALESCE(SUM(
CASE WHEN period = 'Год'
THEN amount / 12.0
ELSE amount
END
), 0) AS monthly_total
FROM subscriptions
WHERE status = 'Активна'
"""
).fetchone()
upcoming = connection.execute(
"""
SELECT id, name, amount, billing_day, period
FROM subscriptions
WHERE status = 'Активна'
ORDER BY billing_day, name
LIMIT 8
"""
).fetchall()
recent = self.list_transactions(limit=8)
warnings = self.list_budget_warnings()
return {
"balance": balance,
"income": summary["income"],
"expense": summary["expense"],
"net": summary["income"] - summary["expense"],
"subscriptions_count": subscriptions["count"],
"subscriptions_total": subscriptions["monthly_total"],
"upcoming": upcoming,
"recent": recent,
"warnings": warnings,
}
def list_budget_warnings(self) -> list[Row]:
rows = self.list_budgets()
return [row for row in rows if row["limit_amount"] and row["spent"] >= row["limit_amount"]]
def report_data(self) -> dict[str, object]:
month_key = current_month_key()
with get_connection() as connection:
summary = connection.execute(
"""
SELECT
COALESCE(SUM(CASE WHEN transaction_type = 'Доход'
THEN amount END), 0) AS income,
COALESCE(SUM(CASE WHEN transaction_type = 'Расход'
THEN amount END), 0) AS expense
FROM transactions
WHERE strftime('%Y-%m', created_at) = ?
""",
(month_key,),
).fetchone()
balance = connection.execute(
"SELECT COALESCE(SUM(balance), 0) AS total FROM accounts"
).fetchone()["total"]
subscription_total = connection.execute(
"""
SELECT COALESCE(SUM(
CASE WHEN period = 'Год'
THEN amount / 12.0
ELSE amount
END
), 0) AS total
FROM subscriptions
WHERE status = 'Активна'
"""
).fetchone()["total"]
categories = connection.execute(
"""
SELECT c.name, SUM(t.amount) AS total
FROM transactions AS t
JOIN categories AS c ON c.id = t.category_id
WHERE t.transaction_type = 'Расход'
AND strftime('%Y-%m', t.created_at) = ?
GROUP BY c.name
HAVING total > 0
ORDER BY total DESC
""",
(month_key,),
).fetchall()
return {
"income": summary["income"],
"expense": summary["expense"],
"net": summary["income"] - summary["expense"],
"balance": balance,
"subscription_total": subscription_total,
"categories": categories,
}
def build_report_text(self) -> str:
data = self.report_data()
categories: Iterable[Row] = data["categories"]
top_category = "Нет данных"
top_category_sum = 0.0
category_lines = []
for row in categories:
if top_category == "Нет данных":
top_category = row["name"]
top_category_sum = row["total"]
category_lines.append(f"- {row['name']}: {format_money(row['total'])}")
details = "\n".join(category_lines) or "Нет расходов за текущий месяц."
return (
"ОТЧЁТ ЗА ТЕКУЩИЙ МЕСЯЦ\n\n"
f"Доходы: {format_money(data['income'])}\n"
f"Расходы: {format_money(data['expense'])}\n"
f"Финансовый результат: {format_money(data['net'])}\n"
f"Общий баланс по всем счетам: {format_money(data['balance'])}\n"
"Активные подписки в пересчёте на месяц: "
f"{format_money(data['subscription_total'])}\n"
"Самая затратная категория: "
f"{top_category} ({format_money(top_category_sum)})\n\n"
"Расходы по категориям:\n"
f"{details}\n"
)
def export_transactions_csv(self, path: str | Path) -> None:
rows = self.list_transactions(limit=10_000)
with open(path, "w", encoding="utf-8-sig", newline="") as file:
writer = csv.writer(file, delimiter=";")
writer.writerow(
["Дата", "Название", "Тип", "Сумма", "Счёт", "Категория"]
)
for row in rows:
writer.writerow(
[
row["created_at"],
row["title"],
row["transaction_type"],
row["amount"],
row["account_name"],
row["category_name"],
]
)
@staticmethod
def is_integrity_error(error: Exception) -> bool:
return isinstance(error, IntegrityError)