feat(proj): init
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Package marker
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP clients for talking to the Please Pay Me API."""
|
||||
@@ -0,0 +1,444 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from bot.db.repository import Budget, Expense
|
||||
from bot.services.budget import (
|
||||
PERIOD_PAGE_SIZE,
|
||||
BudgetStatus,
|
||||
PeriodExpensesPage,
|
||||
)
|
||||
|
||||
|
||||
YANDEX_NAMESPACE_BIT = 1 << 50
|
||||
|
||||
|
||||
class BudgetApiError(Exception):
|
||||
"""HTTP / transport failure talking to the API."""
|
||||
|
||||
|
||||
class YandexLoginRequired(Exception):
|
||||
"""Bot identity is not linked to a Yandex account yet."""
|
||||
|
||||
def __init__(self, login_url: str, detail: str = "") -> None:
|
||||
self.login_url = login_url
|
||||
self.detail = detail or "Чтобы пользоваться ботом, войдите через Яндекс."
|
||||
super().__init__(self.detail)
|
||||
|
||||
|
||||
def _jwt_uid(token: str) -> int | None:
|
||||
try:
|
||||
payload = token.split(".")[1]
|
||||
payload += "=" * (-len(payload) % 4)
|
||||
data = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
|
||||
raw = data.get("uid") or data.get("sub")
|
||||
return int(raw)
|
||||
except (IndexError, ValueError, TypeError, json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
class BudgetApiClient:
|
||||
"""Budget operations via the same HTTP API as the web cabinet."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
api_token: str,
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
) -> None:
|
||||
self._base = base_url.rstrip("/")
|
||||
self._api_token = api_token
|
||||
self._session = session
|
||||
self._owns_session = session is None
|
||||
self._tokens: dict[int, tuple[str, datetime]] = {}
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._session is None:
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._owns_session and self._session is not None:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
def _ensure_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None:
|
||||
raise RuntimeError("BudgetApiClient is not started")
|
||||
return self._session
|
||||
|
||||
@staticmethod
|
||||
def _detail(payload: Any, fallback: str) -> str:
|
||||
if isinstance(payload, dict):
|
||||
detail = payload.get("detail", fallback)
|
||||
if isinstance(detail, str):
|
||||
return detail
|
||||
if isinstance(detail, list) and detail:
|
||||
first = detail[0]
|
||||
if isinstance(first, dict) and "msg" in first:
|
||||
return str(first["msg"])
|
||||
return str(first)
|
||||
return str(detail)
|
||||
return fallback
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
user_id: int | None = None,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
retry_auth: bool = True,
|
||||
) -> Any:
|
||||
session = self._ensure_session()
|
||||
headers: dict[str, str] = {"Accept": "application/json"}
|
||||
if user_id is not None:
|
||||
headers["Authorization"] = f"Bearer {await self._access_token(user_id)}"
|
||||
else:
|
||||
headers["X-API-Token"] = self._api_token
|
||||
|
||||
clean_params = None
|
||||
if params:
|
||||
clean_params = {k: v for k, v in params.items() if v is not None}
|
||||
|
||||
url = f"{self._base}{path}"
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
json=json_body,
|
||||
params=clean_params,
|
||||
headers=headers,
|
||||
) as resp:
|
||||
if resp.status == 401 and user_id is not None and retry_auth:
|
||||
self._tokens.pop(user_id, None)
|
||||
await resp.read()
|
||||
return await self._request(
|
||||
method,
|
||||
path,
|
||||
user_id=user_id,
|
||||
json_body=json_body,
|
||||
params=params,
|
||||
retry_auth=False,
|
||||
)
|
||||
|
||||
if resp.status == 204:
|
||||
return None
|
||||
|
||||
body: Any
|
||||
try:
|
||||
body = await resp.json(content_type=None)
|
||||
except aiohttp.ContentTypeError:
|
||||
text = await resp.text()
|
||||
body = {"detail": text or resp.reason}
|
||||
|
||||
if resp.status >= 400:
|
||||
if (
|
||||
resp.status == 403
|
||||
and isinstance(body, dict)
|
||||
and body.get("code") == "yandex_required"
|
||||
):
|
||||
if user_id is not None:
|
||||
self._tokens.pop(user_id, None)
|
||||
raise YandexLoginRequired(
|
||||
str(body.get("login_url") or ""),
|
||||
self._detail(body, "Чтобы пользоваться ботом, войдите через Яндекс."),
|
||||
)
|
||||
message = self._detail(body, f"API error {resp.status}")
|
||||
if resp.status in {400, 404}:
|
||||
raise ValueError(message)
|
||||
raise BudgetApiError(message)
|
||||
return body
|
||||
|
||||
async def ensure_yandex_login(self, telegram_user_id: int) -> None:
|
||||
await self._access_token(telegram_user_id)
|
||||
|
||||
async def _access_token(self, user_id: int) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
cached = self._tokens.get(user_id)
|
||||
if cached and cached[1] > now + timedelta(seconds=60):
|
||||
uid = _jwt_uid(cached[0])
|
||||
if uid is not None and (uid & YANDEX_NAMESPACE_BIT):
|
||||
return cached[0]
|
||||
self._tokens.pop(user_id, None)
|
||||
|
||||
data = await self._request(
|
||||
"POST",
|
||||
"/api/auth/internal",
|
||||
json_body={"user_id": user_id},
|
||||
)
|
||||
token = str(data["access_token"])
|
||||
uid = _jwt_uid(token)
|
||||
if uid is None or not (uid & YANDEX_NAMESPACE_BIT):
|
||||
raise BudgetApiError("API did not issue a Yandex-linked session")
|
||||
self._tokens[user_id] = (token, now + timedelta(days=1))
|
||||
return token
|
||||
|
||||
@staticmethod
|
||||
def _parse_date(value: str | date) -> date:
|
||||
if isinstance(value, date) and not isinstance(value, datetime):
|
||||
return value
|
||||
return date.fromisoformat(str(value)[:10])
|
||||
|
||||
def _status_from(self, data: dict[str, Any]) -> BudgetStatus:
|
||||
b = data["budget"]
|
||||
budget = Budget(
|
||||
id=int(b["id"]),
|
||||
user_id=int(b["user_id"]),
|
||||
name=str(b.get("name") or "Бюджет"),
|
||||
total_amount=float(b["total_amount"]),
|
||||
start_date=self._parse_date(b["start_date"]),
|
||||
end_date=self._parse_date(b["end_date"]),
|
||||
currency=str(b.get("currency") or "RUB"),
|
||||
is_active=bool(b.get("is_active", True)),
|
||||
)
|
||||
return BudgetStatus(
|
||||
budget=budget,
|
||||
today=self._parse_date(data["today"]),
|
||||
days_left=int(data["days_left"]),
|
||||
total_spent=float(data["total_spent"]),
|
||||
remaining=float(data["remaining"]),
|
||||
daily_limit=float(data["daily_limit"]),
|
||||
spent_today=float(data["spent_today"]),
|
||||
remaining_today=float(data["remaining_today"]),
|
||||
is_over_daily=bool(data["is_over_daily"]),
|
||||
is_over_budget=bool(data["is_over_budget"]),
|
||||
is_expired=bool(data["is_expired"]),
|
||||
selected=bool(data.get("selected", False)),
|
||||
)
|
||||
|
||||
def _expense_from(self, data: dict[str, Any], user_id: int) -> Expense:
|
||||
return Expense(
|
||||
id=int(data["id"]),
|
||||
user_id=user_id,
|
||||
budget_id=int(data.get("budget_id") or 0),
|
||||
amount=float(data["amount"]),
|
||||
note=data.get("note"),
|
||||
spent_at=self._parse_date(data["spent_at"]),
|
||||
)
|
||||
|
||||
async def _page_from(
|
||||
self,
|
||||
user_id: int,
|
||||
data: dict[str, Any],
|
||||
budget_id: int | None = None,
|
||||
) -> PeriodExpensesPage:
|
||||
status = await self.get_status(
|
||||
user_id,
|
||||
budget_id=budget_id or int(data.get("budget_id") or 0) or None,
|
||||
)
|
||||
return PeriodExpensesPage(
|
||||
budget=status.budget,
|
||||
page=int(data["page"]),
|
||||
total_pages=int(data["total_pages"]),
|
||||
total_count=int(data["total_count"]),
|
||||
total_sum=float(data["total_sum"]),
|
||||
page_size=int(data["page_size"]),
|
||||
items=[self._expense_from(item, user_id) for item in data.get("items", [])],
|
||||
)
|
||||
|
||||
async def list_user_statuses(self, user_id: int) -> list[BudgetStatus]:
|
||||
data = await self._request("GET", "/api/me/budgets", user_id=user_id)
|
||||
return [self._status_from(item) for item in data.get("items", [])]
|
||||
|
||||
async def get_status(
|
||||
self,
|
||||
user_id: int,
|
||||
today: date | None = None,
|
||||
budget_id: int | None = None,
|
||||
) -> BudgetStatus:
|
||||
del today
|
||||
data = await self._request(
|
||||
"GET",
|
||||
"/api/me/budget",
|
||||
user_id=user_id,
|
||||
params={"budget_id": budget_id} if budget_id else None,
|
||||
)
|
||||
return self._status_from(data)
|
||||
|
||||
async def create_budget(
|
||||
self,
|
||||
user_id: int,
|
||||
total_amount: float,
|
||||
end_date: date,
|
||||
*,
|
||||
name: str = "Бюджет",
|
||||
start_date: date | None = None,
|
||||
is_active: bool = True,
|
||||
select: bool = True,
|
||||
) -> BudgetStatus:
|
||||
body: dict[str, Any] = {
|
||||
"total_amount": total_amount,
|
||||
"end_date": end_date.isoformat(),
|
||||
"name": name,
|
||||
"is_active": is_active,
|
||||
"select": select,
|
||||
}
|
||||
if start_date is not None:
|
||||
body["start_date"] = start_date.isoformat()
|
||||
data = await self._request(
|
||||
"POST",
|
||||
"/api/me/budgets",
|
||||
user_id=user_id,
|
||||
json_body=body,
|
||||
)
|
||||
return self._status_from(data)
|
||||
|
||||
async def set_budget_active(
|
||||
self,
|
||||
user_id: int,
|
||||
budget_id: int,
|
||||
is_active: bool,
|
||||
) -> BudgetStatus:
|
||||
data = await self._request(
|
||||
"PATCH",
|
||||
f"/api/me/budgets/{budget_id}/active",
|
||||
user_id=user_id,
|
||||
json_body={"is_active": is_active},
|
||||
)
|
||||
return self._status_from(data)
|
||||
|
||||
async def select_budget(self, user_id: int, budget_id: int) -> BudgetStatus:
|
||||
data = await self._request(
|
||||
"POST",
|
||||
f"/api/me/budgets/{budget_id}/select",
|
||||
user_id=user_id,
|
||||
)
|
||||
return self._status_from(data)
|
||||
|
||||
async def delete_budget(self, user_id: int, budget_id: int) -> None:
|
||||
await self._request(
|
||||
"DELETE",
|
||||
f"/api/me/budgets/{budget_id}",
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
async def set_budget(
|
||||
self,
|
||||
user_id: int,
|
||||
total_amount: float,
|
||||
end_date: date,
|
||||
start_date: date | None = None,
|
||||
reset_expenses: bool = True,
|
||||
name: str | None = None,
|
||||
budget_id: int | None = None,
|
||||
) -> BudgetStatus:
|
||||
# Creating a new named budget is the common bot flow.
|
||||
if budget_id is None and name is not None:
|
||||
return await self.create_budget(
|
||||
user_id,
|
||||
total_amount,
|
||||
end_date,
|
||||
name=name,
|
||||
start_date=start_date,
|
||||
)
|
||||
body: dict[str, Any] = {
|
||||
"total_amount": total_amount,
|
||||
"end_date": end_date.isoformat(),
|
||||
"reset_expenses": reset_expenses,
|
||||
}
|
||||
if name is not None:
|
||||
body["name"] = name
|
||||
if budget_id is not None:
|
||||
body["budget_id"] = budget_id
|
||||
if start_date is not None:
|
||||
body["start_date"] = start_date.isoformat()
|
||||
data = await self._request(
|
||||
"PUT",
|
||||
"/api/me/budget",
|
||||
user_id=user_id,
|
||||
json_body=body,
|
||||
)
|
||||
return self._status_from(data)
|
||||
|
||||
async def add_expense(
|
||||
self,
|
||||
user_id: int,
|
||||
amount: float,
|
||||
note: str | None = None,
|
||||
spent_at: date | None = None,
|
||||
budget_id: int | None = None,
|
||||
) -> BudgetStatus:
|
||||
body: dict[str, Any] = {"amount": amount}
|
||||
if note is not None:
|
||||
body["note"] = note
|
||||
if spent_at is not None:
|
||||
body["spent_at"] = spent_at.isoformat()
|
||||
if budget_id is not None:
|
||||
body["budget_id"] = budget_id
|
||||
data = await self._request(
|
||||
"POST",
|
||||
"/api/me/expenses",
|
||||
user_id=user_id,
|
||||
json_body=body,
|
||||
)
|
||||
return self._status_from(data)
|
||||
|
||||
async def undo_last_expense(
|
||||
self,
|
||||
user_id: int,
|
||||
budget_id: int | None = None,
|
||||
) -> tuple[BudgetStatus | None, float | None]:
|
||||
try:
|
||||
data = await self._request(
|
||||
"DELETE",
|
||||
"/api/me/expenses/last",
|
||||
user_id=user_id,
|
||||
params={"budget_id": budget_id} if budget_id else None,
|
||||
)
|
||||
except ValueError:
|
||||
return None, None
|
||||
return self._status_from(data["status"]), float(data["deleted_amount"])
|
||||
|
||||
async def get_period_expenses_page(
|
||||
self,
|
||||
user_id: int,
|
||||
page: int = 0,
|
||||
page_size: int = PERIOD_PAGE_SIZE,
|
||||
budget_id: int | None = None,
|
||||
) -> PeriodExpensesPage:
|
||||
data = await self._request(
|
||||
"GET",
|
||||
"/api/me/expenses",
|
||||
user_id=user_id,
|
||||
params={"page": page, "page_size": page_size, "budget_id": budget_id},
|
||||
)
|
||||
return await self._page_from(user_id, data, budget_id=budget_id)
|
||||
|
||||
async def expenses_on_date(
|
||||
self,
|
||||
user_id: int,
|
||||
day: date,
|
||||
budget_id: int | None = None,
|
||||
) -> list[Expense]:
|
||||
data = await self._request(
|
||||
"GET",
|
||||
"/api/me/expenses",
|
||||
user_id=user_id,
|
||||
params={
|
||||
"spent_at": day.isoformat(),
|
||||
"page": 0,
|
||||
"page_size": 100,
|
||||
"budget_id": budget_id,
|
||||
},
|
||||
)
|
||||
return [self._expense_from(item, user_id) for item in data.get("items", [])]
|
||||
|
||||
async def today_expenses(
|
||||
self,
|
||||
user_id: int,
|
||||
today: date | None = None,
|
||||
budget_id: int | None = None,
|
||||
) -> list[Expense]:
|
||||
return await self.expenses_on_date(
|
||||
user_id,
|
||||
today or date.today(),
|
||||
budget_id=budget_id,
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=BASE_DIR / ".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
bot_token: str
|
||||
# Same API the web cabinet uses (bot no longer opens SQLite).
|
||||
api_base_url: str = "http://127.0.0.1:51291"
|
||||
api_token: str
|
||||
# Example for Docker → host Xray: socks5://127.0.0.1:10808
|
||||
proxy_url: str | None = None
|
||||
|
||||
@field_validator("proxy_url", mode="before")
|
||||
@classmethod
|
||||
def empty_proxy_as_none(cls, value: object) -> object:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return None
|
||||
return value
|
||||
|
||||
@field_validator("api_token", mode="before")
|
||||
@classmethod
|
||||
def require_api_token(cls, value: object) -> object:
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
raise ValueError(
|
||||
"API_TOKEN is required: bot authenticates to the API with it"
|
||||
)
|
||||
return value
|
||||
|
||||
@field_validator("api_base_url")
|
||||
@classmethod
|
||||
def normalize_base_url(cls, value: str) -> str:
|
||||
return value.rstrip("/")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1 @@
|
||||
# Package marker
|
||||
@@ -0,0 +1,243 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
selected_budget_id INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budgets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
total_amount REAL NOT NULL,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'RUB',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS expenses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
budget_id INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
note TEXT,
|
||||
spent_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (budget_id) REFERENCES budgets(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
async def _ensure_indexes(conn: aiosqlite.Connection) -> None:
|
||||
await conn.executescript(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_budgets_user
|
||||
ON budgets(user_id, is_active, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_expenses_budget_spent_at
|
||||
ON expenses(budget_id, spent_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_expenses_user_spent_at
|
||||
ON expenses(user_id, spent_at);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def _table_columns(conn: aiosqlite.Connection, table: str) -> set[str]:
|
||||
cursor = await conn.execute(f"PRAGMA table_info({table})")
|
||||
rows = await cursor.fetchall()
|
||||
return {str(row[1]) for row in rows}
|
||||
|
||||
|
||||
async def _has_unique_user_on_budgets(conn: aiosqlite.Connection) -> bool:
|
||||
cursor = await conn.execute("PRAGMA index_list(budgets)")
|
||||
indexes = await cursor.fetchall()
|
||||
for idx in indexes:
|
||||
# (seq, name, unique, origin, partial)
|
||||
if not idx[2]:
|
||||
continue
|
||||
name = idx[1]
|
||||
info = await conn.execute(f"PRAGMA index_info({name})")
|
||||
cols = [row[2] for row in await info.fetchall()]
|
||||
if cols == ["user_id"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def migrate_schema(conn: aiosqlite.Connection) -> None:
|
||||
"""Upgrade legacy one-budget-per-user schema in place."""
|
||||
tables = {
|
||||
row[0]
|
||||
for row in await (
|
||||
await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
)
|
||||
).fetchall()
|
||||
}
|
||||
if "budgets" not in tables:
|
||||
return
|
||||
|
||||
budget_cols = await _table_columns(conn, "budgets")
|
||||
if "name" not in budget_cols:
|
||||
await conn.execute(
|
||||
"ALTER TABLE budgets ADD COLUMN name TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
if "is_active" not in budget_cols:
|
||||
await conn.execute(
|
||||
"ALTER TABLE budgets ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1"
|
||||
)
|
||||
|
||||
user_cols = await _table_columns(conn, "users")
|
||||
if "selected_budget_id" not in user_cols:
|
||||
await conn.execute(
|
||||
"ALTER TABLE users ADD COLUMN selected_budget_id INTEGER"
|
||||
)
|
||||
|
||||
if await _has_unique_user_on_budgets(conn):
|
||||
await conn.executescript(
|
||||
"""
|
||||
CREATE TABLE budgets_migrated (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
total_amount REAL NOT NULL,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'RUB',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO budgets_migrated (
|
||||
id, user_id, name, total_amount, start_date, end_date,
|
||||
currency, is_active, created_at
|
||||
)
|
||||
SELECT
|
||||
id, user_id,
|
||||
COALESCE(NULLIF(name, ''), 'Бюджет'),
|
||||
total_amount, start_date, end_date, currency,
|
||||
COALESCE(is_active, 1), created_at
|
||||
FROM budgets;
|
||||
DROP TABLE budgets;
|
||||
ALTER TABLE budgets_migrated RENAME TO budgets;
|
||||
"""
|
||||
)
|
||||
|
||||
expense_cols = await _table_columns(conn, "expenses")
|
||||
if "budget_id" not in expense_cols:
|
||||
await conn.execute("ALTER TABLE expenses ADD COLUMN budget_id INTEGER")
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE expenses
|
||||
SET budget_id = (
|
||||
SELECT b.id FROM budgets b
|
||||
WHERE b.user_id = expenses.user_id
|
||||
ORDER BY b.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE budget_id IS NULL
|
||||
"""
|
||||
)
|
||||
# Drop orphan expenses that have no budget (should be rare)
|
||||
await conn.execute("DELETE FROM expenses WHERE budget_id IS NULL")
|
||||
await conn.executescript(
|
||||
"""
|
||||
CREATE TABLE expenses_migrated (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
budget_id INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
note TEXT,
|
||||
spent_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (budget_id) REFERENCES budgets(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO expenses_migrated (
|
||||
id, user_id, budget_id, amount, note, spent_at, created_at
|
||||
)
|
||||
SELECT id, user_id, budget_id, amount, note, spent_at, created_at
|
||||
FROM expenses;
|
||||
DROP TABLE expenses;
|
||||
ALTER TABLE expenses_migrated RENAME TO expenses;
|
||||
"""
|
||||
)
|
||||
|
||||
# Backfill selected budget for users who have budgets
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET selected_budget_id = (
|
||||
SELECT b.id FROM budgets b
|
||||
WHERE b.user_id = users.user_id
|
||||
ORDER BY b.is_active DESC, b.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE selected_budget_id IS NULL
|
||||
AND EXISTS (SELECT 1 FROM budgets b WHERE b.user_id = users.user_id)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE budgets
|
||||
SET name = 'Бюджет'
|
||||
WHERE name IS NULL OR TRIM(name) = ''
|
||||
"""
|
||||
)
|
||||
await _ensure_indexes(conn)
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, path: Path, *, read_only: bool = False) -> None:
|
||||
self.path = path
|
||||
self.read_only = read_only
|
||||
self._conn: aiosqlite.Connection | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
if self.read_only:
|
||||
if not self.path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Database not found: {self.path}. "
|
||||
"Сначала запусти API, чтобы создался data/budget.db"
|
||||
)
|
||||
uri = f"file:{self.path.resolve().as_posix()}?mode=ro"
|
||||
self._conn = await aiosqlite.connect(uri, uri=True)
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
await self._conn.execute("PRAGMA foreign_keys = ON")
|
||||
return
|
||||
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn = await aiosqlite.connect(self.path)
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
await self._conn.execute("PRAGMA foreign_keys = ON")
|
||||
try:
|
||||
await self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
except aiosqlite.OperationalError:
|
||||
pass
|
||||
await self._conn.executescript(SCHEMA)
|
||||
await migrate_schema(self._conn)
|
||||
# Fresh DBs: migrate may no-op early if tables were just created with
|
||||
# full columns — still ensure indexes exist.
|
||||
budget_cols = await _table_columns(self._conn, "budgets")
|
||||
if "is_active" in budget_cols:
|
||||
await _ensure_indexes(self._conn)
|
||||
await self._conn.commit()
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._conn is not None:
|
||||
await self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
@property
|
||||
def conn(self) -> aiosqlite.Connection:
|
||||
if self._conn is None:
|
||||
raise RuntimeError("Database is not connected")
|
||||
return self._conn
|
||||
@@ -0,0 +1,400 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from bot.db.database import Database
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Budget:
|
||||
id: int
|
||||
user_id: int
|
||||
name: str
|
||||
total_amount: float
|
||||
start_date: date
|
||||
end_date: date
|
||||
currency: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Expense:
|
||||
id: int
|
||||
user_id: int
|
||||
budget_id: int
|
||||
amount: float
|
||||
note: str | None
|
||||
spent_at: date
|
||||
|
||||
|
||||
def _parse_date(value: str) -> date:
|
||||
return date.fromisoformat(value[:10])
|
||||
|
||||
|
||||
def _row_to_budget(row: Any) -> Budget:
|
||||
return Budget(
|
||||
id=int(row["id"]),
|
||||
user_id=int(row["user_id"]),
|
||||
name=str(row["name"] or "Бюджет"),
|
||||
total_amount=float(row["total_amount"]),
|
||||
start_date=_parse_date(row["start_date"]),
|
||||
end_date=_parse_date(row["end_date"]),
|
||||
currency=str(row["currency"] or "RUB"),
|
||||
is_active=bool(row["is_active"]),
|
||||
)
|
||||
|
||||
|
||||
def _row_to_expense(row: Any) -> Expense:
|
||||
return Expense(
|
||||
id=int(row["id"]),
|
||||
user_id=int(row["user_id"]),
|
||||
budget_id=int(row["budget_id"]),
|
||||
amount=float(row["amount"]),
|
||||
note=row["note"],
|
||||
spent_at=_parse_date(row["spent_at"]),
|
||||
)
|
||||
|
||||
|
||||
class BudgetRepository:
|
||||
def __init__(self, db: Database) -> None:
|
||||
self._db = db
|
||||
|
||||
async def ensure_user(self, user_id: int) -> None:
|
||||
await self._db.conn.execute(
|
||||
"INSERT OR IGNORE INTO users(user_id) VALUES (?)",
|
||||
(user_id,),
|
||||
)
|
||||
await self._db.conn.commit()
|
||||
|
||||
async def get_selected_budget_id(self, user_id: int) -> int | None:
|
||||
cursor = await self._db.conn.execute(
|
||||
"SELECT selected_budget_id FROM users WHERE user_id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None or row["selected_budget_id"] is None:
|
||||
return None
|
||||
return int(row["selected_budget_id"])
|
||||
|
||||
async def set_selected_budget_id(self, user_id: int, budget_id: int | None) -> None:
|
||||
await self.ensure_user(user_id)
|
||||
await self._db.conn.execute(
|
||||
"UPDATE users SET selected_budget_id = ? WHERE user_id = ?",
|
||||
(budget_id, user_id),
|
||||
)
|
||||
await self._db.conn.commit()
|
||||
|
||||
async def create_budget(
|
||||
self,
|
||||
user_id: int,
|
||||
*,
|
||||
name: str,
|
||||
total_amount: float,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
currency: str = "RUB",
|
||||
is_active: bool = True,
|
||||
select: bool = True,
|
||||
) -> Budget:
|
||||
await self.ensure_user(user_id)
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
INSERT INTO budgets(
|
||||
user_id, name, total_amount, start_date, end_date, currency, is_active
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
name.strip() or "Бюджет",
|
||||
total_amount,
|
||||
start_date.isoformat(),
|
||||
end_date.isoformat(),
|
||||
currency,
|
||||
1 if is_active else 0,
|
||||
),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
await self._db.conn.commit()
|
||||
if row is None:
|
||||
raise RuntimeError("Failed to create budget")
|
||||
budget = _row_to_budget(row)
|
||||
if select:
|
||||
await self.set_selected_budget_id(user_id, budget.id)
|
||||
return budget
|
||||
|
||||
async def update_budget(
|
||||
self,
|
||||
budget_id: int,
|
||||
user_id: int,
|
||||
*,
|
||||
name: str | None = None,
|
||||
total_amount: float | None = None,
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
currency: str | None = None,
|
||||
) -> Budget:
|
||||
budget = await self.get_budget_for_user(budget_id, user_id)
|
||||
if budget is None:
|
||||
raise ValueError("Бюджет не найден")
|
||||
|
||||
next_name = name.strip() if name is not None else budget.name
|
||||
next_total = total_amount if total_amount is not None else budget.total_amount
|
||||
next_start = start_date if start_date is not None else budget.start_date
|
||||
next_end = end_date if end_date is not None else budget.end_date
|
||||
next_currency = currency if currency is not None else budget.currency
|
||||
|
||||
await self._db.conn.execute(
|
||||
"""
|
||||
UPDATE budgets
|
||||
SET name = ?, total_amount = ?, start_date = ?, end_date = ?, currency = ?
|
||||
WHERE id = ? AND user_id = ?
|
||||
""",
|
||||
(
|
||||
next_name or "Бюджет",
|
||||
next_total,
|
||||
next_start.isoformat(),
|
||||
next_end.isoformat(),
|
||||
next_currency,
|
||||
budget_id,
|
||||
user_id,
|
||||
),
|
||||
)
|
||||
await self._db.conn.commit()
|
||||
updated = await self.get_budget_for_user(budget_id, user_id)
|
||||
if updated is None:
|
||||
raise RuntimeError("Failed to update budget")
|
||||
return updated
|
||||
|
||||
async def set_budget_active(
|
||||
self,
|
||||
budget_id: int,
|
||||
user_id: int,
|
||||
is_active: bool,
|
||||
) -> Budget:
|
||||
budget = await self.get_budget_for_user(budget_id, user_id)
|
||||
if budget is None:
|
||||
raise ValueError("Бюджет не найден")
|
||||
await self._db.conn.execute(
|
||||
"UPDATE budgets SET is_active = ? WHERE id = ? AND user_id = ?",
|
||||
(1 if is_active else 0, budget_id, user_id),
|
||||
)
|
||||
await self._db.conn.commit()
|
||||
updated = await self.get_budget_for_user(budget_id, user_id)
|
||||
if updated is None:
|
||||
raise RuntimeError("Failed to update budget activity")
|
||||
return updated
|
||||
|
||||
async def get_budget_by_id(self, budget_id: int) -> Budget | None:
|
||||
cursor = await self._db.conn.execute(
|
||||
"SELECT * FROM budgets WHERE id = ?",
|
||||
(budget_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return _row_to_budget(row) if row else None
|
||||
|
||||
async def get_budget_for_user(self, budget_id: int, user_id: int) -> Budget | None:
|
||||
cursor = await self._db.conn.execute(
|
||||
"SELECT * FROM budgets WHERE id = ? AND user_id = ?",
|
||||
(budget_id, user_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return _row_to_budget(row) if row else None
|
||||
|
||||
async def list_budgets_for_user(self, user_id: int) -> list[Budget]:
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM budgets
|
||||
WHERE user_id = ?
|
||||
ORDER BY is_active DESC, id DESC
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_budget(row) for row in rows]
|
||||
|
||||
async def resolve_budget(
|
||||
self,
|
||||
user_id: int,
|
||||
budget_id: int | None = None,
|
||||
*,
|
||||
require_active: bool = False,
|
||||
) -> Budget | None:
|
||||
if budget_id is not None:
|
||||
budget = await self.get_budget_for_user(budget_id, user_id)
|
||||
if budget is None:
|
||||
return None
|
||||
if require_active and not budget.is_active:
|
||||
raise ValueError("Бюджет неактивен — включи его или выбери другой")
|
||||
return budget
|
||||
|
||||
selected_id = await self.get_selected_budget_id(user_id)
|
||||
if selected_id is not None:
|
||||
selected = await self.get_budget_for_user(selected_id, user_id)
|
||||
if selected is not None:
|
||||
if not require_active or selected.is_active:
|
||||
return selected
|
||||
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM budgets
|
||||
WHERE user_id = ?
|
||||
ORDER BY is_active DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
budget = _row_to_budget(row)
|
||||
if require_active and not budget.is_active:
|
||||
raise ValueError("Нет активного бюджета — создай или включи существующий")
|
||||
return budget
|
||||
|
||||
async def list_all_budgets(self) -> list[Budget]:
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM budgets
|
||||
ORDER BY user_id ASC, is_active DESC, id DESC
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_budget(row) for row in rows]
|
||||
|
||||
async def add_expense(
|
||||
self,
|
||||
user_id: int,
|
||||
budget_id: int,
|
||||
amount: float,
|
||||
note: str | None = None,
|
||||
spent_at: date | None = None,
|
||||
) -> Expense:
|
||||
await self.ensure_user(user_id)
|
||||
spent = spent_at or date.today()
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
INSERT INTO expenses(user_id, budget_id, amount, note, spent_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
RETURNING *
|
||||
""",
|
||||
(user_id, budget_id, amount, note, spent.isoformat()),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
await self._db.conn.commit()
|
||||
if row is None:
|
||||
raise RuntimeError("Failed to insert expense")
|
||||
return _row_to_expense(row)
|
||||
|
||||
async def delete_last_expense(
|
||||
self,
|
||||
user_id: int,
|
||||
budget_id: int | None = None,
|
||||
) -> Expense | None:
|
||||
if budget_id is None:
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM expenses
|
||||
WHERE user_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
else:
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM expenses
|
||||
WHERE user_id = ? AND budget_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id, budget_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
await self._db.conn.execute("DELETE FROM expenses WHERE id = ?", (row["id"],))
|
||||
await self._db.conn.commit()
|
||||
return _row_to_expense(row)
|
||||
|
||||
async def spent_on_date(
|
||||
self,
|
||||
budget_id: int,
|
||||
day: date,
|
||||
) -> float:
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
SELECT COALESCE(SUM(amount), 0) AS total
|
||||
FROM expenses
|
||||
WHERE budget_id = ? AND spent_at = ?
|
||||
""",
|
||||
(budget_id, day.isoformat()),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return float(row["total"]) if row else 0.0
|
||||
|
||||
async def list_expenses_on_date(
|
||||
self,
|
||||
budget_id: int,
|
||||
day: date,
|
||||
) -> list[Expense]:
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM expenses
|
||||
WHERE budget_id = ? AND spent_at = ?
|
||||
ORDER BY id ASC
|
||||
""",
|
||||
(budget_id, day.isoformat()),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_expense(row) for row in rows]
|
||||
|
||||
async def count_expenses_for_budget(self, budget_id: int) -> int:
|
||||
cursor = await self._db.conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM expenses WHERE budget_id = ?",
|
||||
(budget_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row["cnt"]) if row else 0
|
||||
|
||||
async def sum_expenses_for_budget(self, budget_id: int) -> float:
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
SELECT COALESCE(SUM(amount), 0) AS total
|
||||
FROM expenses
|
||||
WHERE budget_id = ?
|
||||
""",
|
||||
(budget_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return float(row["total"]) if row else 0.0
|
||||
|
||||
async def list_expenses_for_budget(
|
||||
self,
|
||||
budget_id: int,
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> list[Expense]:
|
||||
cursor = await self._db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM expenses
|
||||
WHERE budget_id = ?
|
||||
ORDER BY spent_at DESC, id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(budget_id, limit, offset),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_expense(row) for row in rows]
|
||||
|
||||
async def clear_expenses_for_budget_id(self, budget_id: int) -> None:
|
||||
await self._db.conn.execute(
|
||||
"DELETE FROM expenses WHERE budget_id = ?",
|
||||
(budget_id,),
|
||||
)
|
||||
await self._db.conn.commit()
|
||||
@@ -0,0 +1 @@
|
||||
# Package marker
|
||||
@@ -0,0 +1,781 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.filters import Command, CommandObject, CommandStart, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
InlineKeyboardButton,
|
||||
InlineKeyboardMarkup,
|
||||
KeyboardButton,
|
||||
Message,
|
||||
ReplyKeyboardMarkup,
|
||||
ReplyKeyboardRemove,
|
||||
)
|
||||
|
||||
from bot.clients.budget_api import BudgetApiError
|
||||
from bot.handlers.yandex_gate import YandexLoginGateMiddleware
|
||||
from bot.handlers.states import BudgetSetup, DatedExpense
|
||||
from bot.services.budget import BudgetService, PeriodExpensesPage
|
||||
from bot.services.parsing import (
|
||||
format_date,
|
||||
format_money,
|
||||
format_status,
|
||||
parse_amount,
|
||||
parse_end_date,
|
||||
parse_expense_message,
|
||||
parse_spent_date,
|
||||
)
|
||||
|
||||
router = Router()
|
||||
router.message.middleware(YandexLoginGateMiddleware())
|
||||
router.callback_query.middleware(YandexLoginGateMiddleware())
|
||||
|
||||
HELP_TEXT = """\
|
||||
Я помогаю дотянуть до зарплаты без сюрпризов.
|
||||
|
||||
Команды:
|
||||
/budget — новый бюджет (имя · сумма · дата)
|
||||
/budgets — список бюджетов, выбрать / вкл-выкл / удалить
|
||||
/status — сколько можно тратить сегодня
|
||||
/today — траты за сегодня
|
||||
/history — траты за весь период (постранично)
|
||||
/day 12.09 — траты за дату
|
||||
/spend — трата за другую дату (диалог)
|
||||
/undo — отменить последнюю трату
|
||||
/cancel — отменить текущий диалог
|
||||
/help — эта справка
|
||||
|
||||
Быстрый ввод трат:
|
||||
• 250
|
||||
• 250 кофе
|
||||
• кофе 250
|
||||
• за 12.09 250 кофе
|
||||
• 250 кофе за 12.09
|
||||
• 12.09 250 кофе
|
||||
"""
|
||||
|
||||
MENU_STATUS = "📊 Статус"
|
||||
MENU_TODAY = "🧾 Сегодня"
|
||||
MENU_HISTORY = "📒 Период"
|
||||
MENU_DATED = "📅 За дату"
|
||||
MENU_UNDO = "↩️ Отмена траты"
|
||||
MENU_BUDGET = "💰 Новый бюджет"
|
||||
MENU_BUDGETS = "🗂 Бюджеты"
|
||||
|
||||
HISTORY_CB_PREFIX = "hist:"
|
||||
BUDGET_SELECT_PREFIX = "bsel:"
|
||||
BUDGET_TOGGLE_PREFIX = "btgl:"
|
||||
BUDGET_DELETE_PREFIX = "bdel:"
|
||||
|
||||
|
||||
def main_keyboard() -> ReplyKeyboardMarkup:
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text=MENU_STATUS), KeyboardButton(text=MENU_TODAY)],
|
||||
[KeyboardButton(text=MENU_HISTORY), KeyboardButton(text=MENU_DATED)],
|
||||
[KeyboardButton(text=MENU_BUDGETS), KeyboardButton(text=MENU_BUDGET)],
|
||||
[KeyboardButton(text=MENU_UNDO)],
|
||||
],
|
||||
resize_keyboard=True,
|
||||
)
|
||||
|
||||
|
||||
def format_period_page(page_data: PeriodExpensesPage) -> str:
|
||||
budget = page_data.budget
|
||||
header = (
|
||||
f"📒 {budget.name}\n"
|
||||
f"Траты {format_date(budget.start_date)}–{format_date(budget.end_date)}\n"
|
||||
f"Всего записей: {page_data.total_count} · "
|
||||
f"сумма: {format_money(page_data.total_sum)}\n"
|
||||
f"Страница {page_data.page + 1}/{page_data.total_pages}"
|
||||
)
|
||||
if not page_data.items:
|
||||
return header + "\n\nПока нет трат за период."
|
||||
|
||||
lines = []
|
||||
for item in page_data.items:
|
||||
note = f" — {item.note}" if item.note else ""
|
||||
lines.append(
|
||||
f"• {format_date(item.spent_at)} · {format_money(item.amount)}{note}"
|
||||
)
|
||||
return header + "\n\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def format_budgets_list(items) -> str:
|
||||
if not items:
|
||||
return "Бюджетов пока нет. Создай: /budget"
|
||||
lines = ["Твои бюджеты:"]
|
||||
for status in items:
|
||||
b = status.budget
|
||||
marks = []
|
||||
if status.selected:
|
||||
marks.append("текущий")
|
||||
marks.append("активен" if b.is_active else "выкл")
|
||||
mark = ", ".join(marks)
|
||||
lines.append(
|
||||
f"• #{b.id} {b.name} — {format_money(b.total_amount)} "
|
||||
f"до {format_date(b.end_date)} ({mark})"
|
||||
)
|
||||
lines.append("\nКнопки: выбрать · вкл/выкл · удалить.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def budgets_keyboard(items) -> InlineKeyboardMarkup | None:
|
||||
if not items:
|
||||
return None
|
||||
rows: list[list[InlineKeyboardButton]] = []
|
||||
for status in items:
|
||||
b = status.budget
|
||||
select_label = f"{'✓ ' if status.selected else ''}{b.name}"[:28]
|
||||
toggle_label = "Выкл" if b.is_active else "Вкл"
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=select_label,
|
||||
callback_data=f"{BUDGET_SELECT_PREFIX}{b.id}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=toggle_label,
|
||||
callback_data=f"{BUDGET_TOGGLE_PREFIX}{b.id}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🗑",
|
||||
callback_data=f"{BUDGET_DELETE_PREFIX}{b.id}",
|
||||
),
|
||||
]
|
||||
)
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
def history_keyboard(page_data: PeriodExpensesPage) -> InlineKeyboardMarkup | None:
|
||||
if page_data.total_pages <= 1:
|
||||
return None
|
||||
|
||||
buttons: list[InlineKeyboardButton] = []
|
||||
if page_data.page > 0:
|
||||
buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="‹ Назад",
|
||||
callback_data=f"{HISTORY_CB_PREFIX}{page_data.page - 1}",
|
||||
)
|
||||
)
|
||||
buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{page_data.page + 1}/{page_data.total_pages}",
|
||||
callback_data=f"{HISTORY_CB_PREFIX}nop",
|
||||
)
|
||||
)
|
||||
if page_data.page + 1 < page_data.total_pages:
|
||||
buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="Вперёд ›",
|
||||
callback_data=f"{HISTORY_CB_PREFIX}{page_data.page + 1}",
|
||||
)
|
||||
)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[buttons])
|
||||
|
||||
|
||||
async def _reply_expense_saved(
|
||||
message: Message,
|
||||
*,
|
||||
amount: float,
|
||||
note: str | None,
|
||||
spent_at: date | None,
|
||||
status,
|
||||
) -> None:
|
||||
note_part = f" ({note})" if note else ""
|
||||
date_part = f" за {format_date(spent_at)}" if spent_at else ""
|
||||
await message.answer(
|
||||
f"Записал {format_money(amount)}{note_part}{date_part}.\n\n"
|
||||
f"{format_status(status)}",
|
||||
reply_markup=main_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
async def _save_expense(
|
||||
message: Message,
|
||||
budget_service: BudgetService,
|
||||
*,
|
||||
amount: float,
|
||||
note: str | None,
|
||||
spent_at: date | None,
|
||||
state: FSMContext | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
status = await budget_service.add_expense(
|
||||
user_id=message.from_user.id,
|
||||
amount=amount,
|
||||
note=note,
|
||||
spent_at=spent_at,
|
||||
)
|
||||
except ValueError as exc:
|
||||
await message.answer(str(exc), reply_markup=main_keyboard())
|
||||
if state is not None:
|
||||
await state.clear()
|
||||
return False
|
||||
except BudgetApiError as exc:
|
||||
await message.answer(
|
||||
f"API недоступен: {exc}",
|
||||
reply_markup=main_keyboard(),
|
||||
)
|
||||
if state is not None:
|
||||
await state.clear()
|
||||
return False
|
||||
|
||||
if state is not None:
|
||||
await state.clear()
|
||||
await _reply_expense_saved(
|
||||
message,
|
||||
amount=amount,
|
||||
note=note,
|
||||
spent_at=spent_at,
|
||||
status=status,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
# --- Global commands / menu (always win over FSM) ---
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
async def cmd_start(message: Message, state: FSMContext) -> None:
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
"Привет! Я бот «от зарплаты до зарплаты».\n\n"
|
||||
"1) Задай бюджет: /budget\n"
|
||||
"2) Пиши траты: 250 или кофе 250\n"
|
||||
"3) За другую дату: за 12.09 250 кофе\n"
|
||||
"4) Смотри лимит: /status",
|
||||
reply_markup=main_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("help"))
|
||||
async def cmd_help(message: Message, state: FSMContext) -> None:
|
||||
await state.clear()
|
||||
await message.answer(HELP_TEXT, reply_markup=main_keyboard())
|
||||
|
||||
|
||||
@router.message(Command("cancel"))
|
||||
@router.message(F.text.casefold() == "отмена")
|
||||
async def cmd_cancel(message: Message, state: FSMContext) -> None:
|
||||
current = await state.get_state()
|
||||
if current is None:
|
||||
await message.answer("Нечего отменять.", reply_markup=main_keyboard())
|
||||
return
|
||||
await state.clear()
|
||||
await message.answer("Ок, отменил.", reply_markup=main_keyboard())
|
||||
|
||||
|
||||
@router.message(Command("status"))
|
||||
@router.message(F.text == MENU_STATUS)
|
||||
async def cmd_status(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
await state.clear()
|
||||
try:
|
||||
status = await budget_service.get_status(message.from_user.id)
|
||||
except ValueError as exc:
|
||||
await message.answer(str(exc), reply_markup=main_keyboard())
|
||||
return
|
||||
await message.answer(format_status(status), reply_markup=main_keyboard())
|
||||
|
||||
|
||||
@router.message(Command("today"))
|
||||
@router.message(F.text == MENU_TODAY)
|
||||
async def cmd_today(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
await state.clear()
|
||||
try:
|
||||
status = await budget_service.get_status(message.from_user.id)
|
||||
except ValueError as exc:
|
||||
await message.answer(str(exc), reply_markup=main_keyboard())
|
||||
return
|
||||
|
||||
expenses = await budget_service.today_expenses(message.from_user.id)
|
||||
if not expenses:
|
||||
body = "Сегодня трат пока нет."
|
||||
else:
|
||||
lines = []
|
||||
for item in expenses:
|
||||
note = f" — {item.note}" if item.note else ""
|
||||
lines.append(f"• {format_money(item.amount)}{note}")
|
||||
body = "Траты сегодня:\n" + "\n".join(lines)
|
||||
|
||||
await message.answer(
|
||||
f"{body}\n\n{format_status(status)}",
|
||||
reply_markup=main_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("history"))
|
||||
@router.message(F.text == MENU_HISTORY)
|
||||
async def cmd_history(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
await state.clear()
|
||||
try:
|
||||
page_data = await budget_service.get_period_expenses_page(
|
||||
message.from_user.id,
|
||||
page=0,
|
||||
)
|
||||
except ValueError as exc:
|
||||
await message.answer(str(exc), reply_markup=main_keyboard())
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
format_period_page(page_data),
|
||||
reply_markup=history_keyboard(page_data) or main_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith(HISTORY_CB_PREFIX))
|
||||
async def cb_history_page(
|
||||
callback: CallbackQuery,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
raw = (callback.data or "")[len(HISTORY_CB_PREFIX) :]
|
||||
if raw == "nop":
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
page = int(raw)
|
||||
except ValueError:
|
||||
await callback.answer("Некорректная страница", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
page_data = await budget_service.get_period_expenses_page(
|
||||
callback.from_user.id,
|
||||
page=page,
|
||||
)
|
||||
except ValueError as exc:
|
||||
await callback.answer(str(exc), show_alert=True)
|
||||
return
|
||||
|
||||
text = format_period_page(page_data)
|
||||
markup = history_keyboard(page_data)
|
||||
if callback.message:
|
||||
await callback.message.edit_text(text, reply_markup=markup)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.message(Command("day"))
|
||||
async def cmd_day(
|
||||
message: Message,
|
||||
command: CommandObject,
|
||||
state: FSMContext,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
await state.clear()
|
||||
args = (command.args or "").strip()
|
||||
if not args:
|
||||
await message.answer(
|
||||
"Укажи дату: /day 12.09\n"
|
||||
"Или список трат за сегодня: /today",
|
||||
reply_markup=main_keyboard(),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
day = parse_spent_date(args)
|
||||
except ValueError as exc:
|
||||
await message.answer(str(exc), reply_markup=main_keyboard())
|
||||
return
|
||||
|
||||
try:
|
||||
status = await budget_service.get_status(message.from_user.id)
|
||||
except ValueError as exc:
|
||||
await message.answer(str(exc), reply_markup=main_keyboard())
|
||||
return
|
||||
|
||||
expenses = await budget_service.expenses_on_date(message.from_user.id, day)
|
||||
if not expenses:
|
||||
body = f"За {format_date(day)} трат нет."
|
||||
else:
|
||||
lines = []
|
||||
for item in expenses:
|
||||
note = f" — {item.note}" if item.note else ""
|
||||
lines.append(f"• {format_money(item.amount)}{note}")
|
||||
body = f"Траты за {format_date(day)}:\n" + "\n".join(lines)
|
||||
|
||||
await message.answer(
|
||||
f"{body}\n\n{format_status(status)}",
|
||||
reply_markup=main_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("undo"))
|
||||
@router.message(F.text == MENU_UNDO)
|
||||
async def cmd_undo(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
await state.clear()
|
||||
status, amount = await budget_service.undo_last_expense(message.from_user.id)
|
||||
if amount is None:
|
||||
await message.answer("Нечего отменять.", reply_markup=main_keyboard())
|
||||
return
|
||||
|
||||
text = f"Удалил последнюю трату: {format_money(amount)}."
|
||||
if status is not None:
|
||||
text += f"\n\n{format_status(status)}"
|
||||
await message.answer(text, reply_markup=main_keyboard())
|
||||
|
||||
|
||||
@router.message(Command("budget"))
|
||||
@router.message(F.text == MENU_BUDGET)
|
||||
async def cmd_budget(message: Message, state: FSMContext) -> None:
|
||||
await state.set_state(BudgetSetup.waiting_name)
|
||||
await message.answer(
|
||||
"Новый бюджет. Как назвать? (например: Зарплата, Отпуск)\n"
|
||||
"Или «-» чтобы оставить «Бюджет».\n"
|
||||
"Отмена: /cancel",
|
||||
reply_markup=ReplyKeyboardRemove(),
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("budgets"))
|
||||
@router.message(F.text == MENU_BUDGETS)
|
||||
async def cmd_budgets(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
await state.clear()
|
||||
try:
|
||||
items = await budget_service.list_user_statuses(message.from_user.id)
|
||||
except (ValueError, BudgetApiError) as exc:
|
||||
await message.answer(f"Не удалось загрузить: {exc}", reply_markup=main_keyboard())
|
||||
return
|
||||
await message.answer(
|
||||
format_budgets_list(items),
|
||||
reply_markup=budgets_keyboard(items) or main_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith(BUDGET_SELECT_PREFIX))
|
||||
async def cb_budget_select(
|
||||
callback: CallbackQuery,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
raw = (callback.data or "")[len(BUDGET_SELECT_PREFIX) :]
|
||||
try:
|
||||
budget_id = int(raw)
|
||||
except ValueError:
|
||||
await callback.answer("Некорректный id", show_alert=True)
|
||||
return
|
||||
try:
|
||||
status = await budget_service.select_budget(callback.from_user.id, budget_id)
|
||||
items = await budget_service.list_user_statuses(callback.from_user.id)
|
||||
except ValueError as exc:
|
||||
await callback.answer(str(exc), show_alert=True)
|
||||
return
|
||||
if callback.message:
|
||||
await callback.message.edit_text(
|
||||
format_budgets_list(items) + f"\n\nТекущий: {status.budget.name}",
|
||||
reply_markup=budgets_keyboard(items),
|
||||
)
|
||||
await callback.answer(f"Выбран: {status.budget.name}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith(BUDGET_TOGGLE_PREFIX))
|
||||
async def cb_budget_toggle(
|
||||
callback: CallbackQuery,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
raw = (callback.data or "")[len(BUDGET_TOGGLE_PREFIX) :]
|
||||
try:
|
||||
budget_id = int(raw)
|
||||
except ValueError:
|
||||
await callback.answer("Некорректный id", show_alert=True)
|
||||
return
|
||||
try:
|
||||
current = await budget_service.get_status(
|
||||
callback.from_user.id,
|
||||
budget_id=budget_id,
|
||||
)
|
||||
status = await budget_service.set_budget_active(
|
||||
callback.from_user.id,
|
||||
budget_id,
|
||||
not current.budget.is_active,
|
||||
)
|
||||
items = await budget_service.list_user_statuses(callback.from_user.id)
|
||||
except ValueError as exc:
|
||||
await callback.answer(str(exc), show_alert=True)
|
||||
return
|
||||
if callback.message:
|
||||
await callback.message.edit_text(
|
||||
format_budgets_list(items),
|
||||
reply_markup=budgets_keyboard(items),
|
||||
)
|
||||
state_label = "включён" if status.budget.is_active else "выключен"
|
||||
await callback.answer(f"{status.budget.name}: {state_label}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith(BUDGET_DELETE_PREFIX))
|
||||
async def cb_budget_delete(
|
||||
callback: CallbackQuery,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
raw = (callback.data or "")[len(BUDGET_DELETE_PREFIX) :]
|
||||
try:
|
||||
budget_id = int(raw)
|
||||
except ValueError:
|
||||
await callback.answer("Некорректный id", show_alert=True)
|
||||
return
|
||||
try:
|
||||
current = await budget_service.get_status(
|
||||
callback.from_user.id,
|
||||
budget_id=budget_id,
|
||||
)
|
||||
name = current.budget.name
|
||||
await budget_service.delete_budget(callback.from_user.id, budget_id)
|
||||
items = await budget_service.list_user_statuses(callback.from_user.id)
|
||||
except (ValueError, BudgetApiError) as exc:
|
||||
await callback.answer(str(exc), show_alert=True)
|
||||
return
|
||||
if callback.message:
|
||||
await callback.message.edit_text(
|
||||
format_budgets_list(items),
|
||||
reply_markup=budgets_keyboard(items) or None,
|
||||
)
|
||||
await callback.answer(f"Удалён: {name}")
|
||||
|
||||
|
||||
@router.message(Command("spend"))
|
||||
@router.message(F.text == MENU_DATED)
|
||||
async def cmd_spend_dated(message: Message, state: FSMContext) -> None:
|
||||
await state.set_state(DatedExpense.waiting_date)
|
||||
await message.answer(
|
||||
"Трата за дату — одним сообщением или по шагам.\n\n"
|
||||
"Сразу: 12.09 250 кб\n"
|
||||
"Или только дата: 12.09\n"
|
||||
"Отмена: /cancel",
|
||||
reply_markup=ReplyKeyboardRemove(),
|
||||
)
|
||||
|
||||
|
||||
# --- FSM: budget ---
|
||||
|
||||
|
||||
@router.message(BudgetSetup.waiting_name)
|
||||
async def budget_name(message: Message, state: FSMContext) -> None:
|
||||
raw = (message.text or "").strip()
|
||||
name = "Бюджет" if raw in {"", "-", "—"} else raw[:64]
|
||||
await state.update_data(name=name)
|
||||
await state.set_state(BudgetSetup.waiting_amount)
|
||||
await message.answer(
|
||||
f"Название: {name}.\n"
|
||||
"Сколько денег в этом бюджете? (например: 25000)"
|
||||
)
|
||||
|
||||
|
||||
@router.message(BudgetSetup.waiting_amount)
|
||||
async def budget_amount(message: Message, state: FSMContext) -> None:
|
||||
try:
|
||||
amount = parse_amount(message.text or "")
|
||||
except ValueError as exc:
|
||||
await message.answer(f"{exc}\nПопробуй ещё раз, например: 25000")
|
||||
return
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
await state.set_state(BudgetSetup.waiting_end_date)
|
||||
await message.answer(
|
||||
"До какой даты нужно протянуть?\n"
|
||||
"Форматы: 25.09 · 25.09.2026 · 2026-09-25"
|
||||
)
|
||||
|
||||
|
||||
@router.message(BudgetSetup.waiting_end_date)
|
||||
async def budget_end_date(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
try:
|
||||
end_date = parse_end_date(message.text or "")
|
||||
except ValueError as exc:
|
||||
await message.answer(f"{exc}\nПример: 25.09.2026")
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
amount = float(data["amount"])
|
||||
name = str(data.get("name") or "Бюджет")
|
||||
|
||||
try:
|
||||
status = await budget_service.create_budget(
|
||||
user_id=message.from_user.id,
|
||||
total_amount=amount,
|
||||
end_date=end_date,
|
||||
name=name,
|
||||
)
|
||||
except ValueError as exc:
|
||||
await message.answer(str(exc))
|
||||
return
|
||||
except BudgetApiError as exc:
|
||||
await message.answer(f"API недоступен: {exc}")
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
f"Бюджет «{name}» создан: {format_money(amount)} до {format_date(end_date)}.\n\n"
|
||||
f"{format_status(status)}",
|
||||
reply_markup=main_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
# --- FSM: dated expense ---
|
||||
|
||||
|
||||
@router.message(DatedExpense.waiting_date)
|
||||
async def dated_expense_date(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
text = (message.text or "").strip()
|
||||
if not text:
|
||||
await message.answer("Введи дату, например: 12.09")
|
||||
return
|
||||
|
||||
# One-shot: "12.09 250 кб" / "за 12.09 250 кб"
|
||||
try:
|
||||
amount, note, spent_at = parse_expense_message(text)
|
||||
except ValueError:
|
||||
amount, note, spent_at = None, None, None
|
||||
|
||||
if amount is not None and spent_at is not None:
|
||||
await _save_expense(
|
||||
message,
|
||||
budget_service,
|
||||
amount=amount,
|
||||
note=note,
|
||||
spent_at=spent_at,
|
||||
state=state,
|
||||
)
|
||||
return
|
||||
|
||||
# Date only, or "12.09 кб" (date + note without amount)
|
||||
tokens = text.split()
|
||||
try:
|
||||
spent_at = parse_spent_date(tokens[0].rstrip(":"))
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
"Не понял дату.\n"
|
||||
"Примеры: 12.09 или 12.09 250 кб\n"
|
||||
"Отмена: /cancel"
|
||||
)
|
||||
return
|
||||
|
||||
if spent_at > date.today():
|
||||
await message.answer("Нельзя добавить трату на будущую дату. Введи другую:")
|
||||
return
|
||||
|
||||
rest = " ".join(tokens[1:]).strip()
|
||||
if rest:
|
||||
try:
|
||||
amount, note, nested = parse_expense_message(rest)
|
||||
except ValueError:
|
||||
# "12.09 кб" → дата есть, суммы нет: запомним заметку и спросим сумму
|
||||
await state.update_data(spent_at=spent_at.isoformat(), note_hint=rest)
|
||||
await state.set_state(DatedExpense.waiting_expense)
|
||||
await message.answer(
|
||||
f"Дата: {format_date(spent_at)}, заметка: {rest}.\n"
|
||||
"Теперь сумму, например: 250"
|
||||
)
|
||||
return
|
||||
|
||||
if nested is not None:
|
||||
spent_at = nested
|
||||
await _save_expense(
|
||||
message,
|
||||
budget_service,
|
||||
amount=amount,
|
||||
note=note,
|
||||
spent_at=spent_at,
|
||||
state=state,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(spent_at=spent_at.isoformat(), note_hint=None)
|
||||
await state.set_state(DatedExpense.waiting_expense)
|
||||
await message.answer(
|
||||
f"Дата: {format_date(spent_at)}.\n"
|
||||
"Теперь сумма (и комментарий): 250 или 250 кб"
|
||||
)
|
||||
|
||||
|
||||
@router.message(DatedExpense.waiting_expense)
|
||||
async def dated_expense_amount(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
data = await state.get_data()
|
||||
spent_at = date.fromisoformat(data["spent_at"])
|
||||
note_hint = data.get("note_hint")
|
||||
|
||||
try:
|
||||
amount, note, nested_date = parse_expense_message(message.text or "")
|
||||
except ValueError as exc:
|
||||
await message.answer(f"{exc}")
|
||||
return
|
||||
|
||||
if nested_date is not None:
|
||||
spent_at = nested_date
|
||||
if note is None and note_hint:
|
||||
note = note_hint
|
||||
|
||||
await _save_expense(
|
||||
message,
|
||||
budget_service,
|
||||
amount=amount,
|
||||
note=note,
|
||||
spent_at=spent_at,
|
||||
state=state,
|
||||
)
|
||||
|
||||
|
||||
# --- Free-text expense (no active dialog) ---
|
||||
|
||||
|
||||
@router.message(StateFilter(None), F.text)
|
||||
async def add_expense_from_text(
|
||||
message: Message,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
text = (message.text or "").strip()
|
||||
if text.startswith("/"):
|
||||
return
|
||||
|
||||
try:
|
||||
amount, note, spent_at = parse_expense_message(text)
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
"Не понял. Примеры:\n"
|
||||
"• 250 / 250 кофе / кофе 250\n"
|
||||
"• за 12.09 250 кофе\n"
|
||||
"• /spend — диалог за дату"
|
||||
)
|
||||
return
|
||||
|
||||
await _save_expense(
|
||||
message,
|
||||
budget_service,
|
||||
amount=amount,
|
||||
note=note,
|
||||
spent_at=spent_at,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
class BudgetSetup(StatesGroup):
|
||||
waiting_name = State()
|
||||
waiting_amount = State()
|
||||
waiting_end_date = State()
|
||||
|
||||
|
||||
class DatedExpense(StatesGroup):
|
||||
waiting_date = State()
|
||||
waiting_expense = State()
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, TelegramObject
|
||||
|
||||
from bot.clients.budget_api import YandexLoginRequired
|
||||
|
||||
YANDEX_REQUIRED_TEXT = """\
|
||||
Чтобы пользоваться ботом, войдите через Яндекс.
|
||||
|
||||
Так Telegram, сайт и приложение — один аккаунт, и бюджеты не потеряются.
|
||||
|
||||
Откройте ссылку, примите документы и нажмите «Войти через Яндекс». Потом вернитесь в бот и напишите /start.
|
||||
"""
|
||||
|
||||
|
||||
def yandex_login_keyboard(login_url: str) -> InlineKeyboardMarkup | None:
|
||||
url = (login_url or "").strip()
|
||||
if not url.startswith(("https://", "http://")):
|
||||
return None
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text="Войти через Яндекс", url=url)]]
|
||||
)
|
||||
|
||||
|
||||
class YandexLoginGateMiddleware(BaseMiddleware):
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
user = getattr(event, "from_user", None)
|
||||
service = data.get("budget_service")
|
||||
ensure = getattr(service, "ensure_yandex_login", None)
|
||||
if user is None or not callable(ensure):
|
||||
return await handler(event, data)
|
||||
|
||||
try:
|
||||
await ensure(user.id)
|
||||
except YandexLoginRequired as exc:
|
||||
markup = yandex_login_keyboard(exc.login_url)
|
||||
if isinstance(event, Message):
|
||||
await event.answer(YANDEX_REQUIRED_TEXT, reply_markup=markup)
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer()
|
||||
if event.message:
|
||||
await event.message.answer(YANDEX_REQUIRED_TEXT, reply_markup=markup)
|
||||
return None
|
||||
|
||||
return await handler(event, data)
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
|
||||
from bot.clients.budget_api import BudgetApiClient
|
||||
from bot.config import settings
|
||||
from bot.handlers.budget import router as budget_router
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_bot() -> Bot:
|
||||
if settings.proxy_url:
|
||||
logger.info("Using proxy for Telegram API: %s", settings.proxy_url)
|
||||
session = AiohttpSession(proxy=settings.proxy_url)
|
||||
else:
|
||||
session = AiohttpSession()
|
||||
return Bot(token=settings.bot_token, session=session)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
budget_service = BudgetApiClient(
|
||||
base_url=settings.api_base_url,
|
||||
api_token=settings.api_token,
|
||||
)
|
||||
await budget_service.start()
|
||||
|
||||
bot = create_bot()
|
||||
dp = Dispatcher(storage=MemoryStorage())
|
||||
dp["budget_service"] = budget_service
|
||||
dp.include_router(budget_router)
|
||||
|
||||
try:
|
||||
logger.info("Bot started (API %s)", settings.api_base_url)
|
||||
await dp.start_polling(bot)
|
||||
finally:
|
||||
await budget_service.close()
|
||||
await bot.session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1 @@
|
||||
# Package marker
|
||||
@@ -0,0 +1,397 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from math import ceil
|
||||
|
||||
from bot.db.repository import Budget, BudgetRepository, Expense
|
||||
|
||||
PERIOD_PAGE_SIZE = 8
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BudgetStatus:
|
||||
budget: Budget
|
||||
today: date
|
||||
days_left: int
|
||||
total_spent: float
|
||||
remaining: float
|
||||
daily_limit: float
|
||||
spent_today: float
|
||||
remaining_today: float
|
||||
is_over_daily: bool
|
||||
is_over_budget: bool
|
||||
is_expired: bool
|
||||
selected: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PeriodExpensesPage:
|
||||
budget: Budget
|
||||
page: int
|
||||
total_pages: int
|
||||
total_count: int
|
||||
total_sum: float
|
||||
page_size: int
|
||||
items: list[Expense]
|
||||
|
||||
|
||||
class BudgetService:
|
||||
def __init__(self, repo: BudgetRepository) -> None:
|
||||
self._repo = repo
|
||||
|
||||
async def list_user_statuses(self, user_id: int) -> list[BudgetStatus]:
|
||||
budgets = await self._repo.list_budgets_for_user(user_id)
|
||||
selected_id = await self._repo.get_selected_budget_id(user_id)
|
||||
return [
|
||||
await self.get_status_for_budget(
|
||||
budget,
|
||||
selected=(budget.id == selected_id),
|
||||
)
|
||||
for budget in budgets
|
||||
]
|
||||
|
||||
async def create_budget(
|
||||
self,
|
||||
user_id: int,
|
||||
total_amount: float,
|
||||
end_date: date,
|
||||
*,
|
||||
name: str = "Бюджет",
|
||||
start_date: date | None = None,
|
||||
is_active: bool = True,
|
||||
select: bool = True,
|
||||
) -> BudgetStatus:
|
||||
start = start_date or date.today()
|
||||
if end_date < start:
|
||||
raise ValueError("Дата окончания не может быть раньше сегодняшнего дня")
|
||||
if total_amount <= 0:
|
||||
raise ValueError("Сумма бюджета должна быть больше нуля")
|
||||
|
||||
budget = await self._repo.create_budget(
|
||||
user_id,
|
||||
name=name,
|
||||
total_amount=total_amount,
|
||||
start_date=start,
|
||||
end_date=end_date,
|
||||
is_active=is_active,
|
||||
select=select,
|
||||
)
|
||||
return await self.get_status_for_budget(budget, selected=select)
|
||||
|
||||
async def update_budget(
|
||||
self,
|
||||
user_id: int,
|
||||
budget_id: int,
|
||||
*,
|
||||
name: str | None = None,
|
||||
total_amount: float | None = None,
|
||||
end_date: date | None = None,
|
||||
start_date: date | None = None,
|
||||
reset_expenses: bool = False,
|
||||
) -> BudgetStatus:
|
||||
existing = await self._repo.get_budget_for_user(budget_id, user_id)
|
||||
if existing is None:
|
||||
raise ValueError("Бюджет не найден")
|
||||
|
||||
next_end = end_date if end_date is not None else existing.end_date
|
||||
next_start = start_date if start_date is not None else existing.start_date
|
||||
if next_end < next_start:
|
||||
raise ValueError("Дата окончания не может быть раньше даты начала")
|
||||
if total_amount is not None and total_amount <= 0:
|
||||
raise ValueError("Сумма бюджета должна быть больше нуля")
|
||||
|
||||
if reset_expenses:
|
||||
await self._repo.clear_expenses_for_budget_id(budget_id)
|
||||
|
||||
budget = await self._repo.update_budget(
|
||||
budget_id,
|
||||
user_id,
|
||||
name=name,
|
||||
total_amount=total_amount,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
selected_id = await self._repo.get_selected_budget_id(user_id)
|
||||
return await self.get_status_for_budget(
|
||||
budget,
|
||||
selected=(budget.id == selected_id),
|
||||
)
|
||||
|
||||
async def set_budget_active(
|
||||
self,
|
||||
user_id: int,
|
||||
budget_id: int,
|
||||
is_active: bool,
|
||||
) -> BudgetStatus:
|
||||
budget = await self._repo.set_budget_active(budget_id, user_id, is_active)
|
||||
selected_id = await self._repo.get_selected_budget_id(user_id)
|
||||
if not is_active and selected_id == budget_id:
|
||||
# Prefer another active budget as selected.
|
||||
others = await self._repo.list_budgets_for_user(user_id)
|
||||
next_selected = next((b.id for b in others if b.is_active), None)
|
||||
await self._repo.set_selected_budget_id(user_id, next_selected)
|
||||
selected_id = next_selected
|
||||
return await self.get_status_for_budget(
|
||||
budget,
|
||||
selected=(budget.id == selected_id),
|
||||
)
|
||||
|
||||
async def select_budget(self, user_id: int, budget_id: int) -> BudgetStatus:
|
||||
budget = await self._repo.get_budget_for_user(budget_id, user_id)
|
||||
if budget is None:
|
||||
raise ValueError("Бюджет не найден")
|
||||
await self._repo.set_selected_budget_id(user_id, budget_id)
|
||||
return await self.get_status_for_budget(budget, selected=True)
|
||||
|
||||
async def set_budget(
|
||||
self,
|
||||
user_id: int,
|
||||
total_amount: float,
|
||||
end_date: date,
|
||||
start_date: date | None = None,
|
||||
reset_expenses: bool = True,
|
||||
name: str | None = None,
|
||||
budget_id: int | None = None,
|
||||
) -> BudgetStatus:
|
||||
"""Backward-compatible: update selected/specified budget or create new."""
|
||||
if budget_id is not None:
|
||||
return await self.update_budget(
|
||||
user_id,
|
||||
budget_id,
|
||||
name=name,
|
||||
total_amount=total_amount,
|
||||
end_date=end_date,
|
||||
start_date=start_date,
|
||||
reset_expenses=reset_expenses,
|
||||
)
|
||||
|
||||
current = await self._repo.resolve_budget(user_id)
|
||||
if current is None:
|
||||
return await self.create_budget(
|
||||
user_id,
|
||||
total_amount,
|
||||
end_date,
|
||||
name=name or "Бюджет",
|
||||
start_date=start_date,
|
||||
)
|
||||
return await self.update_budget(
|
||||
user_id,
|
||||
current.id,
|
||||
name=name,
|
||||
total_amount=total_amount,
|
||||
end_date=end_date,
|
||||
start_date=start_date,
|
||||
reset_expenses=reset_expenses,
|
||||
)
|
||||
|
||||
async def add_expense(
|
||||
self,
|
||||
user_id: int,
|
||||
amount: float,
|
||||
note: str | None = None,
|
||||
spent_at: date | None = None,
|
||||
budget_id: int | None = None,
|
||||
) -> BudgetStatus:
|
||||
if amount <= 0:
|
||||
raise ValueError("Сумма траты должна быть больше нуля")
|
||||
budget = await self._repo.resolve_budget(
|
||||
user_id,
|
||||
budget_id,
|
||||
require_active=True,
|
||||
)
|
||||
if budget is None:
|
||||
raise ValueError("Сначала задай бюджет: /budget")
|
||||
|
||||
day = spent_at or date.today()
|
||||
if day > date.today():
|
||||
raise ValueError("Нельзя добавить трату на будущую дату")
|
||||
if day < budget.start_date or day > budget.end_date:
|
||||
raise ValueError(
|
||||
"Дата вне периода бюджета "
|
||||
f"({budget.start_date.strftime('%d.%m.%Y')}–"
|
||||
f"{budget.end_date.strftime('%d.%m.%Y')})"
|
||||
)
|
||||
|
||||
await self._repo.add_expense(
|
||||
user_id,
|
||||
budget.id,
|
||||
amount=amount,
|
||||
note=note,
|
||||
spent_at=day,
|
||||
)
|
||||
selected_id = await self._repo.get_selected_budget_id(user_id)
|
||||
return await self.get_status_for_budget(
|
||||
budget,
|
||||
selected=(budget.id == selected_id),
|
||||
)
|
||||
|
||||
async def expenses_on_date(
|
||||
self,
|
||||
user_id: int,
|
||||
day: date,
|
||||
budget_id: int | None = None,
|
||||
) -> list[Expense]:
|
||||
budget = await self._repo.resolve_budget(user_id, budget_id)
|
||||
if budget is None:
|
||||
raise ValueError("Сначала задай бюджет: /budget")
|
||||
return await self._repo.list_expenses_on_date(budget.id, day)
|
||||
|
||||
async def today_expenses(
|
||||
self,
|
||||
user_id: int,
|
||||
today: date | None = None,
|
||||
budget_id: int | None = None,
|
||||
) -> list[Expense]:
|
||||
day = today or date.today()
|
||||
return await self.expenses_on_date(user_id, day, budget_id=budget_id)
|
||||
|
||||
async def get_expenses_on_date_page(
|
||||
self,
|
||||
user_id: int,
|
||||
day: date,
|
||||
page: int = 0,
|
||||
page_size: int = PERIOD_PAGE_SIZE,
|
||||
budget_id: int | None = None,
|
||||
) -> PeriodExpensesPage:
|
||||
budget = await self._repo.resolve_budget(user_id, budget_id)
|
||||
if budget is None:
|
||||
raise ValueError("Сначала задай бюджет: /budget")
|
||||
|
||||
if page < 0:
|
||||
page = 0
|
||||
if page_size < 1:
|
||||
page_size = PERIOD_PAGE_SIZE
|
||||
|
||||
items_all = await self._repo.list_expenses_on_date(budget.id, day)
|
||||
total_count = len(items_all)
|
||||
total_sum = sum(item.amount for item in items_all)
|
||||
total_pages = max(1, ceil(total_count / page_size)) if total_count else 1
|
||||
if page >= total_pages:
|
||||
page = total_pages - 1
|
||||
|
||||
start = page * page_size
|
||||
items = items_all[start : start + page_size]
|
||||
return PeriodExpensesPage(
|
||||
budget=budget,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
total_count=total_count,
|
||||
total_sum=total_sum,
|
||||
page_size=page_size,
|
||||
items=items,
|
||||
)
|
||||
|
||||
async def get_period_expenses_page(
|
||||
self,
|
||||
user_id: int,
|
||||
page: int = 0,
|
||||
page_size: int = PERIOD_PAGE_SIZE,
|
||||
budget_id: int | None = None,
|
||||
) -> PeriodExpensesPage:
|
||||
budget = await self._repo.resolve_budget(user_id, budget_id)
|
||||
if budget is None:
|
||||
raise ValueError("Сначала задай бюджет: /budget")
|
||||
|
||||
if page < 0:
|
||||
page = 0
|
||||
if page_size < 1:
|
||||
page_size = PERIOD_PAGE_SIZE
|
||||
|
||||
total_count = await self._repo.count_expenses_for_budget(budget.id)
|
||||
total_sum = await self._repo.sum_expenses_for_budget(budget.id)
|
||||
total_pages = max(1, ceil(total_count / page_size)) if total_count else 1
|
||||
if page >= total_pages:
|
||||
page = total_pages - 1
|
||||
|
||||
items = await self._repo.list_expenses_for_budget(
|
||||
budget.id,
|
||||
limit=page_size,
|
||||
offset=page * page_size,
|
||||
)
|
||||
return PeriodExpensesPage(
|
||||
budget=budget,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
total_count=total_count,
|
||||
total_sum=total_sum,
|
||||
page_size=page_size,
|
||||
items=items,
|
||||
)
|
||||
|
||||
async def list_budget_summaries(self) -> list[BudgetStatus]:
|
||||
budgets = await self._repo.list_all_budgets()
|
||||
return [await self.get_status_for_budget(budget) for budget in budgets]
|
||||
|
||||
async def undo_last_expense(
|
||||
self,
|
||||
user_id: int,
|
||||
budget_id: int | None = None,
|
||||
) -> tuple[BudgetStatus | None, float | None]:
|
||||
budget = await self._repo.resolve_budget(user_id, budget_id)
|
||||
target_id = budget.id if budget else None
|
||||
deleted = await self._repo.delete_last_expense(user_id, target_id)
|
||||
if deleted is None:
|
||||
return None, None
|
||||
status = await self.get_status(
|
||||
user_id,
|
||||
budget_id=deleted.budget_id,
|
||||
)
|
||||
return status, deleted.amount
|
||||
|
||||
async def get_status(
|
||||
self,
|
||||
user_id: int,
|
||||
today: date | None = None,
|
||||
budget_id: int | None = None,
|
||||
) -> BudgetStatus:
|
||||
budget = await self._repo.resolve_budget(user_id, budget_id)
|
||||
if budget is None:
|
||||
raise ValueError("Сначала задай бюджет: /budget")
|
||||
selected_id = await self._repo.get_selected_budget_id(user_id)
|
||||
return await self.get_status_for_budget(
|
||||
budget,
|
||||
today=today,
|
||||
selected=(budget.id == selected_id),
|
||||
)
|
||||
|
||||
async def get_status_for_budget(
|
||||
self,
|
||||
budget: Budget,
|
||||
today: date | None = None,
|
||||
*,
|
||||
selected: bool = False,
|
||||
) -> BudgetStatus:
|
||||
day = today or date.today()
|
||||
total_spent = await self._repo.sum_expenses_for_budget(budget.id)
|
||||
spent_today = await self._repo.spent_on_date(budget.id, day)
|
||||
remaining = budget.total_amount - total_spent
|
||||
|
||||
if day > budget.end_date:
|
||||
days_left = 0
|
||||
daily_limit = 0.0
|
||||
remaining_today = 0.0
|
||||
is_expired = True
|
||||
else:
|
||||
days_left = (budget.end_date - day).days + 1
|
||||
remaining_at_day_start = remaining + spent_today
|
||||
daily_limit = (
|
||||
remaining_at_day_start / days_left if days_left > 0 else 0.0
|
||||
)
|
||||
remaining_today = daily_limit - spent_today
|
||||
is_expired = False
|
||||
|
||||
return BudgetStatus(
|
||||
budget=budget,
|
||||
today=day,
|
||||
days_left=days_left,
|
||||
total_spent=total_spent,
|
||||
remaining=remaining,
|
||||
daily_limit=max(daily_limit, 0.0),
|
||||
spent_today=spent_today,
|
||||
remaining_today=remaining_today,
|
||||
is_over_daily=spent_today > daily_limit and days_left > 0,
|
||||
is_over_budget=remaining < 0,
|
||||
is_expired=is_expired,
|
||||
selected=selected,
|
||||
)
|
||||
@@ -0,0 +1,213 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date
|
||||
|
||||
AMOUNT_RE = re.compile(
|
||||
r"^\s*(?P<amount>\d+(?:[.,]\d{1,2})?)\s*(?P<note>.*)?$",
|
||||
re.UNICODE,
|
||||
)
|
||||
NOTE_FIRST_RE = re.compile(
|
||||
r"^\s*(?P<note>.+?)\s+(?P<amount>\d+(?:[.,]\d{1,2})?)\s*$",
|
||||
re.UNICODE,
|
||||
)
|
||||
DATE_RE = re.compile(
|
||||
r"^(?P<d>\d{1,2})[./](?P<m>\d{1,2})(?:[./](?P<y>\d{2,4}))?$"
|
||||
)
|
||||
# Unambiguous date token: ISO, with year, slash, or zero-padded month (12.09)
|
||||
DATE_TOKEN_RE = re.compile(
|
||||
r"(?P<date>"
|
||||
r"\d{4}-\d{2}-\d{2}"
|
||||
r"|"
|
||||
r"\d{1,2}[./]\d{1,2}[./]\d{2,4}"
|
||||
r"|"
|
||||
r"\d{1,2}/\d{1,2}"
|
||||
r"|"
|
||||
r"\d{1,2}\.(?:0[1-9]|1[0-2])"
|
||||
r")"
|
||||
)
|
||||
ZA_DATE_RE = re.compile(
|
||||
r"(?i)(?:^|\s)за\s+(?P<date>"
|
||||
r"\d{4}-\d{2}-\d{2}"
|
||||
r"|"
|
||||
r"\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?"
|
||||
r")"
|
||||
r"(?=\s|$)"
|
||||
)
|
||||
|
||||
|
||||
def parse_amount(value: str) -> float:
|
||||
normalized = value.strip().replace(",", ".").replace(" ", "")
|
||||
amount = float(normalized)
|
||||
if amount <= 0:
|
||||
raise ValueError("Сумма должна быть больше нуля")
|
||||
return round(amount, 2)
|
||||
|
||||
|
||||
def parse_spent_date(value: str, today: date | None = None) -> date:
|
||||
"""Parse expense date. Bare DD.MM prefers current/past year (not future)."""
|
||||
day = today or date.today()
|
||||
raw = value.strip()
|
||||
|
||||
try:
|
||||
return date.fromisoformat(raw)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
match = DATE_RE.match(raw)
|
||||
if not match:
|
||||
raise ValueError("Дата в формате ДД.ММ или ДД.ММ.ГГГГ")
|
||||
|
||||
d = int(match.group("d"))
|
||||
m = int(match.group("m"))
|
||||
y_raw = match.group("y")
|
||||
if y_raw is None:
|
||||
try:
|
||||
candidate = date(day.year, m, d)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Некорректная дата") from exc
|
||||
if candidate > day:
|
||||
try:
|
||||
candidate = date(day.year - 1, m, d)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Некорректная дата") from exc
|
||||
return candidate
|
||||
|
||||
y = int(y_raw)
|
||||
if y < 100:
|
||||
y += 2000
|
||||
try:
|
||||
return date(y, m, d)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Некорректная дата") from exc
|
||||
|
||||
|
||||
def parse_end_date(value: str, today: date | None = None) -> date:
|
||||
"""Parse DD.MM, DD.MM.YYYY, YYYY-MM-DD (for budget end — may roll to next year)."""
|
||||
day = today or date.today()
|
||||
raw = value.strip()
|
||||
|
||||
try:
|
||||
return date.fromisoformat(raw)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
match = DATE_RE.match(raw)
|
||||
if not match:
|
||||
raise ValueError("Дата в формате ДД.ММ или ДД.ММ.ГГГГ")
|
||||
|
||||
d = int(match.group("d"))
|
||||
m = int(match.group("m"))
|
||||
y_raw = match.group("y")
|
||||
if y_raw is None:
|
||||
y = day.year
|
||||
candidate = date(y, m, d)
|
||||
if candidate < day:
|
||||
candidate = date(y + 1, m, d)
|
||||
return candidate
|
||||
|
||||
y = int(y_raw)
|
||||
if y < 100:
|
||||
y += 2000
|
||||
return date(y, m, d)
|
||||
|
||||
|
||||
def _parse_amount_and_note(raw: str) -> tuple[float, str | None]:
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
raise ValueError("Не понял трату. Примеры: 250, 250 кофе, кофе 250")
|
||||
|
||||
first_token = text.split()[0]
|
||||
if re.fullmatch(r"\d+(?:[.,]\d{1,2})?", first_token):
|
||||
match = AMOUNT_RE.match(text)
|
||||
if not match:
|
||||
raise ValueError("Не понял трату. Примеры: 250, 250 кофе, кофе 250")
|
||||
amount = parse_amount(match.group("amount"))
|
||||
note = (match.group("note") or "").strip() or None
|
||||
return amount, note
|
||||
|
||||
match = NOTE_FIRST_RE.match(text)
|
||||
if match:
|
||||
amount = parse_amount(match.group("amount"))
|
||||
note = match.group("note").strip() or None
|
||||
return amount, note
|
||||
|
||||
raise ValueError("Не понял трату. Примеры: 250, 250 кофе, кофе 250")
|
||||
|
||||
|
||||
def parse_expense_message(
|
||||
text: str,
|
||||
today: date | None = None,
|
||||
) -> tuple[float, str | None, date | None]:
|
||||
"""Parse expense text.
|
||||
|
||||
Examples:
|
||||
- 250 / 250 кофе / кофе 250
|
||||
- за 12.09 250 кофе
|
||||
- 250 кофе за 12.09
|
||||
- 12.09 250 кофе
|
||||
- 250 кофе 12.09
|
||||
"""
|
||||
raw = text.strip()
|
||||
if not raw:
|
||||
raise ValueError("Пустое сообщение")
|
||||
|
||||
spent_at: date | None = None
|
||||
body = raw
|
||||
|
||||
za_match = ZA_DATE_RE.search(raw)
|
||||
if za_match:
|
||||
spent_at = parse_spent_date(za_match.group("date"), today=today)
|
||||
body = (raw[: za_match.start()] + " " + raw[za_match.end() :]).strip()
|
||||
else:
|
||||
tokens = raw.split()
|
||||
if len(tokens) >= 2:
|
||||
first = tokens[0].rstrip(":")
|
||||
last = tokens[-1]
|
||||
if DATE_TOKEN_RE.fullmatch(first):
|
||||
spent_at = parse_spent_date(first, today=today)
|
||||
body = " ".join(tokens[1:])
|
||||
elif DATE_TOKEN_RE.fullmatch(last):
|
||||
spent_at = parse_spent_date(last, today=today)
|
||||
body = " ".join(tokens[:-1])
|
||||
|
||||
amount, note = _parse_amount_and_note(body)
|
||||
return amount, note, spent_at
|
||||
|
||||
|
||||
def format_money(amount: float, currency: str = "₽") -> str:
|
||||
sign = "-" if amount < 0 else ""
|
||||
return f"{sign}{abs(amount):,.2f} {currency}".replace(",", " ")
|
||||
|
||||
|
||||
def format_date(value: date) -> str:
|
||||
return value.strftime("%d.%m.%Y")
|
||||
|
||||
|
||||
def format_status(status) -> str:
|
||||
from bot.services.budget import BudgetStatus
|
||||
|
||||
assert isinstance(status, BudgetStatus)
|
||||
currency = "₽"
|
||||
active = "активен" if status.budget.is_active else "неактивен"
|
||||
selected = " · текущий" if status.selected else ""
|
||||
lines = [
|
||||
f"🏷 {status.budget.name} ({active}{selected})",
|
||||
f"📅 До {format_date(status.budget.end_date)} · дней осталось: {status.days_left}",
|
||||
f"💰 Бюджет: {format_money(status.budget.total_amount, currency)}",
|
||||
f"🧾 Потрачено: {format_money(status.total_spent, currency)}",
|
||||
f"🛡 Остаток: {format_money(status.remaining, currency)}",
|
||||
"",
|
||||
f"📊 Лимит на день: {format_money(status.daily_limit, currency)}",
|
||||
f"🛒 Сегодня: {format_money(status.spent_today, currency)}",
|
||||
f"✅ Ещё можно сегодня: {format_money(status.remaining_today, currency)}",
|
||||
]
|
||||
|
||||
if status.is_expired:
|
||||
lines.append("\n⚠️ Период бюджета закончился. Задай новый: /budget")
|
||||
elif status.is_over_budget:
|
||||
lines.append("\n🚨 Бюджет уже превышен.")
|
||||
elif status.is_over_daily:
|
||||
lines.append("\n⚠️ Сегодняшний лимит превышен — завтра лимит пересчитается по остатку.")
|
||||
|
||||
return "\n".join(lines)
|
||||
Reference in New Issue
Block a user