feat(proj): init

This commit is contained in:
vl.arkhangelskii
2026-09-21 04:06:43 +03:00
commit c956b94983
1076 changed files with 50876 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Package marker
+1
View File
@@ -0,0 +1 @@
"""HTTP clients for talking to the Please Pay Me API."""
+444
View File
@@ -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,
)
+47
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
# Package marker
+243
View File
@@ -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
+400
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
# Package marker
+781
View File
@@ -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,
)
+12
View File
@@ -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()
+53
View File
@@ -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
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
# Package marker
+397
View File
@@ -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,
)
+213
View File
@@ -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)