diff --git a/README.md b/README.md index f476c00..8d5a078 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,11 @@ Caddy автоматически получает TLS-сертификат. WebA - `GET /api/healthz` — проверка состояния; - `GET /api/leaderboard?limit=100` — общий рейтинг; - `GET /api/leaderboard?user_ids=1,2,3` — рейтинг выбранных пользователей; +- `GET /api/leaderboard?category=Продукты` — рейтинг по категории; +- `GET /api/categories` — категории, встречающиеся у участников; - `GET /api/users/{id}/summary` — публичная сводка пользователя; +- `GET /api/users/{id}/categories` — сохранённые категории пользователя; +- `GET /api/users/{id}/transactions` — активные записи пользователя; - `GET /api/users/{id}/friends` — Telegram ID друзей; - `GET /api/avatars/{id}` — сохранённый аватар; - `GET /api/docs` — OpenAPI UI. diff --git a/app/bot.py b/app/bot.py index debc983..58e2eb7 100644 --- a/app/bot.py +++ b/app/bot.py @@ -23,11 +23,25 @@ def webapp_url_for_user(settings: Settings, user_id: int) -> str: return f"{settings.webapp_url}?user_id={user_id}&mode=keyboard" -def parse_transaction(raw: str) -> TransactionInput: +def decode_payload(raw: str) -> dict[str, Any]: try: - payload: dict[str, Any] = json.loads(raw) + payload = json.loads(raw) except (json.JSONDecodeError, TypeError) as exc: raise ValueError("Не удалось прочитать данные") from exc + if not isinstance(payload, dict): + raise ValueError("Неизвестный формат данных") + return payload + + +def parse_category_name(value: Any) -> str: + category = str(value or "").strip() + if not CATEGORY_PATTERN.fullmatch(category): + raise ValueError("Категория должна содержать от 1 до 40 символов") + return category + + +def parse_transaction(raw: str) -> TransactionInput: + payload = decode_payload(raw) if payload.get("type") != "transaction" or payload.get("v") != 1: raise ValueError("Неизвестный формат данных") @@ -36,9 +50,7 @@ def parse_transaction(raw: str) -> TransactionInput: if kind not in {"income", "expense"}: raise ValueError("Некорректный тип операции") - category = str(payload.get("category", "")).strip() - if not CATEGORY_PATTERN.fullmatch(category): - raise ValueError("Категория должна содержать от 1 до 40 символов") + category = parse_category_name(payload.get("category")) note = str(payload.get("note", "")).strip() or None if note and len(note) > 160: @@ -61,6 +73,26 @@ def parse_transaction(raw: str) -> TransactionInput: ) +def parse_category(raw: str) -> tuple[str, str]: + payload = decode_payload(raw) + if payload.get("type") != "category_create" or payload.get("v") != 1: + raise ValueError("Неизвестный формат данных") + kind = payload.get("kind") + if kind not in {"income", "expense"}: + raise ValueError("Некорректный тип категории") + return kind, parse_category_name(payload.get("category")) + + +def parse_cancellation(raw: str) -> int: + payload = decode_payload(raw) + if payload.get("type") != "transaction_cancel" or payload.get("v") != 1: + raise ValueError("Неизвестный формат данных") + transaction_id = payload.get("transaction_id") + if not isinstance(transaction_id, int) or transaction_id <= 0: + raise ValueError("Некорректный ID записи") + return transaction_id + + class TelegramBotService: def __init__(self, settings: Settings, database: Database): self.settings = settings @@ -115,8 +147,31 @@ class TelegramBotService: return self._save_user(user) try: - item = parse_transaction(message.web_app_data.data) - self.database.add_transaction(user.id, item) + raw = message.web_app_data.data + action = decode_payload(raw).get("type") + if action == "transaction": + item = parse_transaction(raw) + self.database.add_transaction(user.id, item) + operation = "Доход" if item.kind == "income" else "Расход" + response = ( + f"{operation} сохранён в категории «{item.category}». " + "Откройте приложение снова, чтобы увидеть обновлённую статистику." + ) + elif action == "category_create": + kind, category = parse_category(raw) + created = self.database.add_category(user.id, kind, category) + response = ( + f"Категория «{category}» добавлена." + if created + else f"Категория «{category}» уже существует." + ) + elif action == "transaction_cancel": + transaction_id = parse_cancellation(raw) + if not self.database.cancel_transaction(user.id, transaction_id): + raise ValueError("Запись не найдена или уже отменена") + response = "Запись отменена и больше не участвует в расчётах." + else: + raise ValueError("Неизвестный формат данных") except ValueError as exc: self.bot.send_message( message.chat.id, @@ -125,10 +180,9 @@ class TelegramBotService: ) return - operation = "Доход" if item.kind == "income" else "Расход" self.bot.send_message( message.chat.id, - f"{operation} сохранён в категории «{item.category}». Откройте приложение снова, чтобы увидеть обновлённую статистику.", + response, reply_markup=self._keyboard(user.id), ) diff --git a/app/db.py b/app/db.py index 3309fe2..feadea1 100644 --- a/app/db.py +++ b/app/db.py @@ -33,7 +33,8 @@ CREATE TABLE IF NOT EXISTS transactions ( category TEXT NOT NULL, note TEXT, occurred_at TEXT NOT NULL, - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + cancelled_at TEXT ); CREATE INDEX IF NOT EXISTS idx_transactions_user_id @@ -47,6 +48,14 @@ CREATE TABLE IF NOT EXISTS friendships ( CHECK (user_id_low < user_id_high) ); +CREATE TABLE IF NOT EXISTS user_categories ( + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('income', 'expense')), + name TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, kind, name) +); + CREATE TABLE IF NOT EXISTS app_meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL @@ -95,6 +104,22 @@ class Database: self.path.parent.mkdir(parents=True, exist_ok=True) with closing(self.connect()) as connection: connection.executescript(SCHEMA) + columns = { + row["name"] + for row in connection.execute("PRAGMA table_info(transactions)") + } + if "cancelled_at" not in columns: + connection.execute( + "ALTER TABLE transactions ADD COLUMN cancelled_at TEXT" + ) + connection.execute( + """ + INSERT OR IGNORE INTO user_categories (user_id, kind, name, created_at) + SELECT user_id, kind, category, MIN(created_at) + FROM transactions + GROUP BY user_id, kind, category + """ + ) connection.commit() def upsert_user( @@ -149,6 +174,13 @@ class Database: def add_transaction(self, user_id: int, item: TransactionInput) -> int: with closing(self.connect()) as connection: + connection.execute( + """ + INSERT OR IGNORE INTO user_categories (user_id, kind, name, created_at) + VALUES (?, ?, ?, ?) + """, + (user_id, item.kind, item.category, utc_now()), + ) cursor = connection.execute( """ INSERT INTO transactions ( @@ -169,6 +201,70 @@ class Database: connection.commit() return int(cursor.lastrowid) + def add_category(self, user_id: int, kind: str, name: str) -> bool: + with closing(self.connect()) as connection: + cursor = connection.execute( + """ + INSERT OR IGNORE INTO user_categories (user_id, kind, name, created_at) + VALUES (?, ?, ?, ?) + """, + (user_id, kind, name, utc_now()), + ) + connection.commit() + return cursor.rowcount > 0 + + def cancel_transaction(self, user_id: int, transaction_id: int) -> bool: + with closing(self.connect()) as connection: + cursor = connection.execute( + """ + UPDATE transactions + SET cancelled_at = ? + WHERE id = ? AND user_id = ? AND cancelled_at IS NULL + """, + (utc_now(), transaction_id, user_id), + ) + connection.commit() + return cursor.rowcount > 0 + + def transactions(self, user_id: int, limit: int = 50) -> list[dict[str, Any]]: + with closing(self.connect()) as connection: + rows = connection.execute( + """ + SELECT id, kind, amount_cents, category, note, occurred_at + FROM transactions + WHERE user_id = ? AND cancelled_at IS NULL + ORDER BY occurred_at DESC, id DESC + LIMIT ? + """, + (user_id, limit), + ).fetchall() + return [dict(row) for row in rows] + + def user_categories(self, user_id: int) -> list[dict[str, str]]: + with closing(self.connect()) as connection: + rows = connection.execute( + """ + SELECT kind, name + FROM user_categories + WHERE user_id = ? + ORDER BY kind, name COLLATE NOCASE + """, + (user_id,), + ).fetchall() + return [dict(row) for row in rows] + + def global_categories(self) -> list[dict[str, str]]: + with closing(self.connect()) as connection: + rows = connection.execute( + """ + SELECT kind, name + FROM user_categories + GROUP BY kind, name + ORDER BY name COLLATE NOCASE, kind + """ + ).fetchall() + return [dict(row) for row in rows] + def add_friendship(self, first_user_id: int, second_user_id: int) -> bool: if first_user_id == second_user_id: return False @@ -232,7 +328,8 @@ class Database: COALESCE(SUM(CASE WHEN t.kind = 'income' THEN t.amount_cents ELSE 0 END), 0) AS income_cents, COALESCE(SUM(CASE WHEN t.kind = 'expense' THEN t.amount_cents ELSE 0 END), 0) AS expense_cents FROM users u - LEFT JOIN transactions t ON t.user_id = u.user_id + LEFT JOIN transactions t + ON t.user_id = u.user_id AND t.cancelled_at IS NULL WHERE u.user_id = ? GROUP BY u.user_id """, @@ -244,7 +341,7 @@ class Database: """ SELECT kind, category, SUM(amount_cents) AS amount_cents FROM transactions - WHERE user_id = ? + WHERE user_id = ? AND cancelled_at IS NULL GROUP BY kind, category ORDER BY amount_cents DESC """, @@ -253,16 +350,27 @@ class Database: return self._serialize_totals(dict(row), categories) def leaderboard( - self, *, user_ids: Iterable[int] | None = None, limit: int = 100 + self, + *, + user_ids: Iterable[int] | None = None, + categories: Iterable[str] | None = None, + limit: int = 100, ) -> list[dict[str, Any]]: ids = list(dict.fromkeys(user_ids or [])) + category_names = list(dict.fromkeys(categories or [])) where = "" - params: list[Any] = [] + where_params: list[Any] = [] if ids: placeholders = ",".join("?" for _ in ids) where = f"WHERE u.user_id IN ({placeholders})" - params.extend(ids) - params.append(limit) + where_params.extend(ids) + join_conditions = ["t.user_id = u.user_id", "t.cancelled_at IS NULL"] + join_params: list[Any] = [] + if category_names: + placeholders = ",".join("?" for _ in category_names) + join_conditions.append(f"t.category IN ({placeholders})") + join_params.extend(category_names) + params = [*join_params, *where_params, limit] with closing(self.connect()) as connection: rows = connection.execute( f""" @@ -274,7 +382,7 @@ class Database: COALESCE(SUM(CASE WHEN t.kind = 'income' THEN t.amount_cents ELSE 0 END), 0) AS income_cents, COALESCE(SUM(CASE WHEN t.kind = 'expense' THEN t.amount_cents ELSE 0 END), 0) AS expense_cents FROM users u - LEFT JOIN transactions t ON t.user_id = u.user_id + LEFT JOIN transactions t ON {' AND '.join(join_conditions)} {where} GROUP BY u.user_id ORDER BY diff --git a/app/main.py b/app/main.py index 2272b17..48bebe0 100644 --- a/app/main.py +++ b/app/main.py @@ -75,6 +75,23 @@ def user_friends(user_id: int) -> dict[str, list[int]]: return {"user_ids": database.friend_ids(user_id)} +@app.get("/api/users/{user_id}/categories") +def user_categories(user_id: int) -> dict[str, list[dict[str, str]]]: + return {"items": database.user_categories(user_id)} + + +@app.get("/api/users/{user_id}/transactions") +def user_transactions( + user_id: int, limit: int = Query(default=50, ge=1, le=100) +) -> dict[str, list[dict]]: + return {"items": database.transactions(user_id, limit)} + + +@app.get("/api/categories") +def global_categories() -> dict[str, list[dict[str, str]]]: + return {"items": database.global_categories()} + + def parse_user_ids(raw: str | None) -> list[int] | None: if not raw: return None @@ -100,9 +117,23 @@ def leaderboard( user_ids: str | None = Query( default=None, description="Telegram user ID через запятую" ), + category: list[str] | None = Query( + default=None, description="Один или несколько фильтров по категории" + ), limit: int = Query(default=100, ge=1, le=100), ) -> dict[str, list[dict]]: - return {"items": database.leaderboard(user_ids=parse_user_ids(user_ids), limit=limit)} + categories = None + if category: + categories = list(dict.fromkeys(item.strip() for item in category if item.strip())) + if len(categories) > 20 or any(len(item) > 40 for item in categories): + raise HTTPException(status_code=422, detail="Некорректный фильтр категорий") + return { + "items": database.leaderboard( + user_ids=parse_user_ids(user_ids), + categories=categories, + limit=limit, + ) + } @app.get("/api/avatars/{user_id}", response_class=FileResponse) diff --git a/static/app.js b/static/app.js index 4d6e021..6d481b2 100644 --- a/static/app.js +++ b/static/app.js @@ -9,7 +9,14 @@ const user = tgUser || (previewUserId ? { id: previewUserId } : null); const userId = Number(user?.id) || 0; - const state = { kind: "income", board: "all", summary: null, config: null }; + const state = { + kind: "income", + board: "all", + categoryFilter: "", + summary: null, + config: null, + customCategories: { income: [], expense: [] }, + }; const categories = { income: ["Зарплата", "Фриланс", "Подарок", "Продажа", "Инвестиции", "Другое"], expense: ["Продукты", "Жильё", "Транспорт", "Здоровье", "Развлечения", "Покупки", "Другое"], @@ -57,7 +64,9 @@ function fillCategories() { const select = $("#category"); - select.innerHTML = categories[state.kind].map((category) => ``).join(""); + const items = [...new Set([...categories[state.kind], ...state.customCategories[state.kind]])]; + select.innerHTML = items.map((category) => ``).join("") + + ''; $("#customCategoryWrap").classList.add("hidden"); $("#customCategory").required = false; } @@ -72,11 +81,12 @@ }); }); $("#category").addEventListener("change", (event) => { - const custom = event.target.value === "Другое"; + const custom = event.target.value === "__new__"; $("#customCategoryWrap").classList.toggle("hidden", !custom); $("#customCategory").required = custom; if (custom) $("#customCategory").focus(); }); + $("#saveCategoryButton").addEventListener("click", saveCategory); $("#transactionForm").addEventListener("submit", submitTransaction); } @@ -84,7 +94,7 @@ event.preventDefault(); const amount = Number($("#amount").value); const selected = $("#category").value; - const category = selected === "Другое" ? $("#customCategory").value.trim() : selected; + const category = selected === "__new__" ? $("#customCategory").value.trim() : selected; if (!Number.isFinite(amount) || amount <= 0 || !category) { showToast("Проверьте сумму и категорию"); return; @@ -98,22 +108,47 @@ note: $("#note").value.trim().slice(0, 160), occurred_at: new Date().toISOString(), }; - const encoded = JSON.stringify(payload); - if (encoded.length > 4096) { - showToast("Запись получилась слишком длинной"); + sendTelegramPayload(payload); + } + + function saveCategory() { + const category = $("#customCategory").value.trim(); + if (!category) { + showToast("Введите название категории"); return; } + sendTelegramPayload({ + v: 1, + type: "category_create", + kind: state.kind, + category: category.slice(0, 40), + }); + } + + function canSendViaTelegram() { const isTelegramClient = Boolean(tg?.platform && tg.platform !== "unknown"); if (!tg?.sendData || !isTelegramClient || !keyboardLaunch) { showToast("Отправьте /start боту и откройте приложение кнопкой под сообщением"); - return; + return false; } + return true; + } + + function sendTelegramPayload(payload) { + const encoded = JSON.stringify(payload); + if (encoded.length > 4096) { + showToast("Запись получилась слишком длинной"); + return false; + } + if (!canSendViaTelegram()) return false; try { tg.HapticFeedback?.notificationOccurred("success"); tg.sendData(encoded); + return true; } catch (error) { console.error("Telegram sendData failed", error); showToast("Telegram не принял запись. Обновите приложение и попробуйте снова"); + return false; } } @@ -129,11 +164,27 @@ state.summary = await fetchJson(`../api/users/${userId}/summary`); setIdentity(state.summary); renderSummary(state.summary); + await loadTransactions(); } catch (error) { console.warn("Summary is not available", error); } } + async function loadUserCategories() { + if (!userId) return; + try { + const data = await fetchJson(`../api/users/${userId}/categories`); + for (const item of data.items || []) { + if (state.customCategories[item.kind] && !state.customCategories[item.kind].includes(item.name)) { + state.customCategories[item.kind].push(item.name); + } + } + fillCategories(); + } catch (error) { + console.warn("User categories are not available", error); + } + } + function renderSummary(summary) { $("#balanceValue").textContent = money.format(summary.balance_cents / 100); $("#incomeValue").textContent = money.format(summary.income_cents / 100); @@ -164,10 +215,82 @@ return element.innerHTML; } + async function loadTransactions() { + if (!userId) return; + try { + const data = await fetchJson(`../api/users/${userId}/transactions?limit=30`); + renderTransactions(data.items || []); + } catch (error) { + console.warn("Transactions are not available", error); + } + } + + function renderTransactions(items) { + const list = $("#transactionList"); + if (!items.length) { + list.className = "transaction-list empty-state"; + list.textContent = "Записей пока нет."; + return; + } + list.className = "transaction-list"; + list.innerHTML = items.map((item) => { + const income = item.kind === "income"; + const date = new Date(item.occurred_at).toLocaleDateString("ru-RU", { day: "numeric", month: "short" }); + const detail = [date, item.note].filter(Boolean).map(escapeHtml).join(" · "); + return `
+ ${income ? "↗" : "↘"} + ${escapeHtml(item.category)}${detail} + + ${income ? "+" : "−"}${money.format(item.amount_cents / 100)} + + +
`; + }).join(""); + list.querySelectorAll(".cancel-button").forEach((button) => { + button.addEventListener("click", () => cancelTransaction(Number(button.dataset.transactionId))); + }); + } + + function cancelTransaction(transactionId) { + const send = (confirmed) => { + if (!confirmed) return; + sendTelegramPayload({ v: 1, type: "transaction_cancel", transaction_id: transactionId }); + }; + if (tg?.showConfirm && tg.isVersionAtLeast?.("6.2")) { + try { + tg.showConfirm("Отменить запись? Она перестанет участвовать в расчётах.", send); + return; + } catch (error) { + console.warn("Telegram confirmation failed", error); + } + } + send(window.confirm("Отменить запись? Она перестанет участвовать в расчётах.")); + } + function bindLeaderboard() { $("#allBoardButton").addEventListener("click", () => switchBoard("all")); $("#friendsBoardButton").addEventListener("click", () => switchBoard("friends")); $("#shareButton").addEventListener("click", shareInvite); + $("#leaderboardCategory").addEventListener("change", (event) => { + state.categoryFilter = event.target.value; + loadLeaderboard(); + }); + loadLeaderboardCategories(); + } + + async function loadLeaderboardCategories() { + try { + const data = await fetchJson("../api/categories"); + const names = [...new Set([ + ...categories.income, + ...categories.expense, + ...(data.items || []).map((item) => item.name), + ])].sort((a, b) => a.localeCompare(b, "ru")); + $("#leaderboardCategory").innerHTML = '' + + names.map((name) => ``).join(""); + } catch (error) { + console.warn("Leaderboard categories are not available", error); + } } function switchBoard(board) { @@ -191,6 +314,9 @@ url = `../api/leaderboard?user_ids=${encodeURIComponent(ids.join(","))}`; $("#friendHint").classList.toggle("hidden", friends.user_ids.length > 0); } + if (state.categoryFilter) { + url += `&category=${encodeURIComponent(state.categoryFilter)}`; + } const data = await fetchJson(url); renderLeaderboard(data.items || []); } catch (error) { @@ -246,4 +372,5 @@ bindLeaderboard(); fillCategories(); loadSummary(); + loadUserCategories(); })(); diff --git a/static/index.html b/static/index.html index 3f56ea5..0e5f7cc 100644 --- a/static/index.html +++ b/static/index.html @@ -8,7 +8,7 @@ Пожитки - +
@@ -52,6 +52,14 @@
Добавьте первую операцию — категории появятся здесь.
+ +
+

Последние записи

+ можно отменить +
+
+ Записей пока нет. +
@@ -77,6 +85,7 @@
diff --git a/static/styles.css b/static/styles.css index e099de7..c62b2cb 100644 --- a/static/styles.css +++ b/static/styles.css @@ -81,6 +81,19 @@ h2 { margin: 0; font-size: 23px; letter-spacing: -.025em; } .category-item .category-bar { grid-column: 1 / -1; height: 3px; overflow: hidden; border-radius: 4px; background: var(--line); } .category-bar i { display: block; height: 100%; background: var(--accent); } .category-item.expense-item .category-bar i { background: var(--danger); } +.history-heading { margin-top: 28px; } +.transaction-list { overflow: hidden; border: 1px solid var(--line); border-radius: 21px; background: var(--surface); } +.transaction-row { display: grid; grid-template-columns: 40px 1fr auto; align-items: center; gap: 11px; padding: 13px 14px; border-bottom: 1px solid var(--line); } +.transaction-row:last-child { border-bottom: 0; } +.transaction-kind { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 13px; color: var(--accent); background: color-mix(in srgb, var(--accent) 13%, transparent); font-size: 19px; } +.transaction-row.expense-row .transaction-kind { color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, transparent); } +.transaction-copy { min-width: 0; } +.transaction-copy strong, .transaction-copy small { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.transaction-copy strong { font-size: 13px; } +.transaction-copy small { margin-top: 3px; color: var(--muted); font-size: 10px; } +.transaction-actions { display: grid; justify-items: end; gap: 5px; } +.transaction-actions strong { font-size: 13px; } +.cancel-button { padding: 2px 0; border: 0; background: transparent; color: var(--danger); font-size: 10px; } .add-heading { margin: 5px 3px 24px; } .transaction-form { display: grid; gap: 18px; } @@ -96,12 +109,15 @@ h2 { margin: 0; font-size: 23px; letter-spacing: -.025em; } .amount-field input { height: 78px; padding-right: 54px; font-size: 35px; font-weight: 750; letter-spacing: -.04em; } .amount-field b { position: absolute; right: 18px; top: 50%; transform: translateY(-50%); color: var(--muted); font-size: 24px; } .primary-button { min-height: 54px; margin-top: 3px; border: 0; border-radius: 16px; background: var(--accent); color: var(--accent-text); font-weight: 750; box-shadow: 0 10px 25px color-mix(in srgb, var(--accent) 28%, transparent); } +.secondary-button { min-height: 44px; border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--line)); border-radius: 14px; background: color-mix(in srgb, var(--accent) 9%, var(--surface)); color: var(--accent); font-weight: 700; } .form-hint { margin: -8px 15px 0; color: var(--muted); font-size: 10px; line-height: 1.45; text-align: center; } .hidden { display: none !important; } .board-heading { align-items: center; margin-top: 5px; margin-bottom: 20px; } .icon-button { width: 42px; height: 42px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); color: var(--link); font-size: 21px; } .board-switch { margin-bottom: 13px; } +.leader-filter { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 12px; margin: 0 3px 13px; color: var(--muted); font-size: 11px; font-weight: 700; } +.leader-filter select { width: 100%; min-height: 40px; padding: 0 12px; border: 1px solid var(--line); border-radius: 12px; outline: none; background: var(--surface); } .leaderboard-list { overflow: hidden; min-height: 100px; border: 1px solid var(--line); border-radius: 22px; background: var(--surface); } .leaderboard-list.loading { padding: 30px; color: var(--muted); text-align: center; font-size: 13px; } .leader-row { display: grid; grid-template-columns: 25px 42px 1fr auto; align-items: center; gap: 10px; padding: 13px 14px; border-bottom: 1px solid var(--line); } diff --git a/tests/test_core.py b/tests/test_core.py index 7ab764b..cc072a5 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2,7 +2,12 @@ from pathlib import Path import pytest -from app.bot import parse_transaction, webapp_url_for_user +from app.bot import ( + parse_cancellation, + parse_category, + parse_transaction, + webapp_url_for_user, +) from app.config import Settings from app.db import Database @@ -58,8 +63,26 @@ def test_filtered_leaderboard(tmp_path: Path) -> None: for user_id in (1, 2, 3): add_user(database, user_id, str(user_id)) + database.add_transaction( + 1, + parse_transaction( + '{"v":1,"type":"transaction","kind":"income","amount":"100","category":"Работа"}' + ), + ) + database.add_transaction( + 3, + parse_transaction( + '{"v":1,"type":"transaction","kind":"expense","amount":"40","category":"Еда"}' + ), + ) + result = database.leaderboard(user_ids=[1, 3]) assert [item["user_id"] for item in result] == [1, 3] + filtered = database.leaderboard(user_ids=[1, 3], categories=["Еда"]) + assert filtered[0]["user_id"] == 1 + assert filtered[0]["balance_cents"] == 0 + assert filtered[1]["user_id"] == 3 + assert filtered[1]["balance_cents"] == -4_000 @pytest.mark.parametrize( @@ -85,3 +108,31 @@ def test_personalized_keyboard_webapp_url(tmp_path: Path) -> None: assert webapp_url_for_user(settings, 123456) == ( "https://example.com/app/?user_id=123456&mode=keyboard" ) + + +def test_custom_category_and_cancellation(tmp_path: Path) -> None: + database = make_database(tmp_path) + add_user(database, 10, "Владелец") + add_user(database, 11, "Другой") + kind, name = parse_category( + '{"v":1,"type":"category_create","kind":"expense","category":"Подписки"}' + ) + assert database.add_category(10, kind, name) + assert not database.add_category(10, kind, name) + assert database.user_categories(10) == [{"kind": "expense", "name": "Подписки"}] + + transaction_id = database.add_transaction( + 10, + parse_transaction( + '{"v":1,"type":"transaction","kind":"expense","amount":"499","category":"Подписки"}' + ), + ) + assert parse_cancellation( + f'{{"v":1,"type":"transaction_cancel","transaction_id":{transaction_id}}}' + ) == transaction_id + assert not database.cancel_transaction(11, transaction_id) + assert database.cancel_transaction(10, transaction_id) + assert not database.cancel_transaction(10, transaction_id) + assert database.transactions(10) == [] + assert database.summary(10)["expense_cents"] == 0 + assert database.user_categories(10) == [{"kind": "expense", "name": "Подписки"}]