финальная версия Finance Control
This commit is contained in:
3
app/services/__init__.py
Normal file
3
app/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.services.finance_service import FinanceService
|
||||
|
||||
__all__ = ["FinanceService"]
|
||||
433
app/services/finance_service.py
Normal file
433
app/services/finance_service.py
Normal 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)
|
||||
Reference in New Issue
Block a user