Add custom categories filters and cancellations
This commit is contained in:
parent
96e7068755
commit
f596bcf491
8 changed files with 434 additions and 28 deletions
|
|
@ -34,7 +34,11 @@ Caddy автоматически получает TLS-сертификат. WebA
|
||||||
- `GET /api/healthz` — проверка состояния;
|
- `GET /api/healthz` — проверка состояния;
|
||||||
- `GET /api/leaderboard?limit=100` — общий рейтинг;
|
- `GET /api/leaderboard?limit=100` — общий рейтинг;
|
||||||
- `GET /api/leaderboard?user_ids=1,2,3` — рейтинг выбранных пользователей;
|
- `GET /api/leaderboard?user_ids=1,2,3` — рейтинг выбранных пользователей;
|
||||||
|
- `GET /api/leaderboard?category=Продукты` — рейтинг по категории;
|
||||||
|
- `GET /api/categories` — категории, встречающиеся у участников;
|
||||||
- `GET /api/users/{id}/summary` — публичная сводка пользователя;
|
- `GET /api/users/{id}/summary` — публичная сводка пользователя;
|
||||||
|
- `GET /api/users/{id}/categories` — сохранённые категории пользователя;
|
||||||
|
- `GET /api/users/{id}/transactions` — активные записи пользователя;
|
||||||
- `GET /api/users/{id}/friends` — Telegram ID друзей;
|
- `GET /api/users/{id}/friends` — Telegram ID друзей;
|
||||||
- `GET /api/avatars/{id}` — сохранённый аватар;
|
- `GET /api/avatars/{id}` — сохранённый аватар;
|
||||||
- `GET /api/docs` — OpenAPI UI.
|
- `GET /api/docs` — OpenAPI UI.
|
||||||
|
|
|
||||||
72
app/bot.py
72
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"
|
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:
|
try:
|
||||||
payload: dict[str, Any] = json.loads(raw)
|
payload = json.loads(raw)
|
||||||
except (json.JSONDecodeError, TypeError) as exc:
|
except (json.JSONDecodeError, TypeError) as exc:
|
||||||
raise ValueError("Не удалось прочитать данные") from 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:
|
if payload.get("type") != "transaction" or payload.get("v") != 1:
|
||||||
raise ValueError("Неизвестный формат данных")
|
raise ValueError("Неизвестный формат данных")
|
||||||
|
|
@ -36,9 +50,7 @@ def parse_transaction(raw: str) -> TransactionInput:
|
||||||
if kind not in {"income", "expense"}:
|
if kind not in {"income", "expense"}:
|
||||||
raise ValueError("Некорректный тип операции")
|
raise ValueError("Некорректный тип операции")
|
||||||
|
|
||||||
category = str(payload.get("category", "")).strip()
|
category = parse_category_name(payload.get("category"))
|
||||||
if not CATEGORY_PATTERN.fullmatch(category):
|
|
||||||
raise ValueError("Категория должна содержать от 1 до 40 символов")
|
|
||||||
|
|
||||||
note = str(payload.get("note", "")).strip() or None
|
note = str(payload.get("note", "")).strip() or None
|
||||||
if note and len(note) > 160:
|
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:
|
class TelegramBotService:
|
||||||
def __init__(self, settings: Settings, database: Database):
|
def __init__(self, settings: Settings, database: Database):
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
|
|
@ -115,8 +147,31 @@ class TelegramBotService:
|
||||||
return
|
return
|
||||||
self._save_user(user)
|
self._save_user(user)
|
||||||
try:
|
try:
|
||||||
item = parse_transaction(message.web_app_data.data)
|
raw = message.web_app_data.data
|
||||||
self.database.add_transaction(user.id, item)
|
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:
|
except ValueError as exc:
|
||||||
self.bot.send_message(
|
self.bot.send_message(
|
||||||
message.chat.id,
|
message.chat.id,
|
||||||
|
|
@ -125,10 +180,9 @@ class TelegramBotService:
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
operation = "Доход" if item.kind == "income" else "Расход"
|
|
||||||
self.bot.send_message(
|
self.bot.send_message(
|
||||||
message.chat.id,
|
message.chat.id,
|
||||||
f"{operation} сохранён в категории «{item.category}». Откройте приложение снова, чтобы увидеть обновлённую статистику.",
|
response,
|
||||||
reply_markup=self._keyboard(user.id),
|
reply_markup=self._keyboard(user.id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
124
app/db.py
124
app/db.py
|
|
@ -33,7 +33,8 @@ CREATE TABLE IF NOT EXISTS transactions (
|
||||||
category TEXT NOT NULL,
|
category TEXT NOT NULL,
|
||||||
note TEXT,
|
note TEXT,
|
||||||
occurred_at TEXT NOT NULL,
|
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
|
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)
|
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 (
|
CREATE TABLE IF NOT EXISTS app_meta (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
|
|
@ -95,6 +104,22 @@ class Database:
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with closing(self.connect()) as connection:
|
with closing(self.connect()) as connection:
|
||||||
connection.executescript(SCHEMA)
|
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()
|
connection.commit()
|
||||||
|
|
||||||
def upsert_user(
|
def upsert_user(
|
||||||
|
|
@ -149,6 +174,13 @@ class Database:
|
||||||
|
|
||||||
def add_transaction(self, user_id: int, item: TransactionInput) -> int:
|
def add_transaction(self, user_id: int, item: TransactionInput) -> int:
|
||||||
with closing(self.connect()) as connection:
|
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(
|
cursor = connection.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO transactions (
|
INSERT INTO transactions (
|
||||||
|
|
@ -169,6 +201,70 @@ class Database:
|
||||||
connection.commit()
|
connection.commit()
|
||||||
return int(cursor.lastrowid)
|
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:
|
def add_friendship(self, first_user_id: int, second_user_id: int) -> bool:
|
||||||
if first_user_id == second_user_id:
|
if first_user_id == second_user_id:
|
||||||
return False
|
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 = '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
|
COALESCE(SUM(CASE WHEN t.kind = 'expense' THEN t.amount_cents ELSE 0 END), 0) AS expense_cents
|
||||||
FROM users u
|
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 = ?
|
WHERE u.user_id = ?
|
||||||
GROUP BY u.user_id
|
GROUP BY u.user_id
|
||||||
""",
|
""",
|
||||||
|
|
@ -244,7 +341,7 @@ class Database:
|
||||||
"""
|
"""
|
||||||
SELECT kind, category, SUM(amount_cents) AS amount_cents
|
SELECT kind, category, SUM(amount_cents) AS amount_cents
|
||||||
FROM transactions
|
FROM transactions
|
||||||
WHERE user_id = ?
|
WHERE user_id = ? AND cancelled_at IS NULL
|
||||||
GROUP BY kind, category
|
GROUP BY kind, category
|
||||||
ORDER BY amount_cents DESC
|
ORDER BY amount_cents DESC
|
||||||
""",
|
""",
|
||||||
|
|
@ -253,16 +350,27 @@ class Database:
|
||||||
return self._serialize_totals(dict(row), categories)
|
return self._serialize_totals(dict(row), categories)
|
||||||
|
|
||||||
def leaderboard(
|
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]]:
|
) -> list[dict[str, Any]]:
|
||||||
ids = list(dict.fromkeys(user_ids or []))
|
ids = list(dict.fromkeys(user_ids or []))
|
||||||
|
category_names = list(dict.fromkeys(categories or []))
|
||||||
where = ""
|
where = ""
|
||||||
params: list[Any] = []
|
where_params: list[Any] = []
|
||||||
if ids:
|
if ids:
|
||||||
placeholders = ",".join("?" for _ in ids)
|
placeholders = ",".join("?" for _ in ids)
|
||||||
where = f"WHERE u.user_id IN ({placeholders})"
|
where = f"WHERE u.user_id IN ({placeholders})"
|
||||||
params.extend(ids)
|
where_params.extend(ids)
|
||||||
params.append(limit)
|
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:
|
with closing(self.connect()) as connection:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
f"""
|
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 = '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
|
COALESCE(SUM(CASE WHEN t.kind = 'expense' THEN t.amount_cents ELSE 0 END), 0) AS expense_cents
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN transactions t ON t.user_id = u.user_id
|
LEFT JOIN transactions t ON {' AND '.join(join_conditions)}
|
||||||
{where}
|
{where}
|
||||||
GROUP BY u.user_id
|
GROUP BY u.user_id
|
||||||
ORDER BY
|
ORDER BY
|
||||||
|
|
|
||||||
33
app/main.py
33
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)}
|
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:
|
def parse_user_ids(raw: str | None) -> list[int] | None:
|
||||||
if not raw:
|
if not raw:
|
||||||
return None
|
return None
|
||||||
|
|
@ -100,9 +117,23 @@ def leaderboard(
|
||||||
user_ids: str | None = Query(
|
user_ids: str | None = Query(
|
||||||
default=None, description="Telegram user ID через запятую"
|
default=None, description="Telegram user ID через запятую"
|
||||||
),
|
),
|
||||||
|
category: list[str] | None = Query(
|
||||||
|
default=None, description="Один или несколько фильтров по категории"
|
||||||
|
),
|
||||||
limit: int = Query(default=100, ge=1, le=100),
|
limit: int = Query(default=100, ge=1, le=100),
|
||||||
) -> dict[str, list[dict]]:
|
) -> 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)
|
@app.get("/api/avatars/{user_id}", response_class=FileResponse)
|
||||||
|
|
|
||||||
143
static/app.js
143
static/app.js
|
|
@ -9,7 +9,14 @@
|
||||||
const user = tgUser || (previewUserId ? { id: previewUserId } : null);
|
const user = tgUser || (previewUserId ? { id: previewUserId } : null);
|
||||||
const userId = Number(user?.id) || 0;
|
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 = {
|
const categories = {
|
||||||
income: ["Зарплата", "Фриланс", "Подарок", "Продажа", "Инвестиции", "Другое"],
|
income: ["Зарплата", "Фриланс", "Подарок", "Продажа", "Инвестиции", "Другое"],
|
||||||
expense: ["Продукты", "Жильё", "Транспорт", "Здоровье", "Развлечения", "Покупки", "Другое"],
|
expense: ["Продукты", "Жильё", "Транспорт", "Здоровье", "Развлечения", "Покупки", "Другое"],
|
||||||
|
|
@ -57,7 +64,9 @@
|
||||||
|
|
||||||
function fillCategories() {
|
function fillCategories() {
|
||||||
const select = $("#category");
|
const select = $("#category");
|
||||||
select.innerHTML = categories[state.kind].map((category) => `<option value="${category}">${category}</option>`).join("");
|
const items = [...new Set([...categories[state.kind], ...state.customCategories[state.kind]])];
|
||||||
|
select.innerHTML = items.map((category) => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")
|
||||||
|
+ '<option value="__new__">+ Новая категория…</option>';
|
||||||
$("#customCategoryWrap").classList.add("hidden");
|
$("#customCategoryWrap").classList.add("hidden");
|
||||||
$("#customCategory").required = false;
|
$("#customCategory").required = false;
|
||||||
}
|
}
|
||||||
|
|
@ -72,11 +81,12 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
$("#category").addEventListener("change", (event) => {
|
$("#category").addEventListener("change", (event) => {
|
||||||
const custom = event.target.value === "Другое";
|
const custom = event.target.value === "__new__";
|
||||||
$("#customCategoryWrap").classList.toggle("hidden", !custom);
|
$("#customCategoryWrap").classList.toggle("hidden", !custom);
|
||||||
$("#customCategory").required = custom;
|
$("#customCategory").required = custom;
|
||||||
if (custom) $("#customCategory").focus();
|
if (custom) $("#customCategory").focus();
|
||||||
});
|
});
|
||||||
|
$("#saveCategoryButton").addEventListener("click", saveCategory);
|
||||||
$("#transactionForm").addEventListener("submit", submitTransaction);
|
$("#transactionForm").addEventListener("submit", submitTransaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,7 +94,7 @@
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const amount = Number($("#amount").value);
|
const amount = Number($("#amount").value);
|
||||||
const selected = $("#category").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) {
|
if (!Number.isFinite(amount) || amount <= 0 || !category) {
|
||||||
showToast("Проверьте сумму и категорию");
|
showToast("Проверьте сумму и категорию");
|
||||||
return;
|
return;
|
||||||
|
|
@ -98,22 +108,47 @@
|
||||||
note: $("#note").value.trim().slice(0, 160),
|
note: $("#note").value.trim().slice(0, 160),
|
||||||
occurred_at: new Date().toISOString(),
|
occurred_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
const encoded = JSON.stringify(payload);
|
sendTelegramPayload(payload);
|
||||||
if (encoded.length > 4096) {
|
}
|
||||||
showToast("Запись получилась слишком длинной");
|
|
||||||
|
function saveCategory() {
|
||||||
|
const category = $("#customCategory").value.trim();
|
||||||
|
if (!category) {
|
||||||
|
showToast("Введите название категории");
|
||||||
return;
|
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");
|
const isTelegramClient = Boolean(tg?.platform && tg.platform !== "unknown");
|
||||||
if (!tg?.sendData || !isTelegramClient || !keyboardLaunch) {
|
if (!tg?.sendData || !isTelegramClient || !keyboardLaunch) {
|
||||||
showToast("Отправьте /start боту и откройте приложение кнопкой под сообщением");
|
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 {
|
try {
|
||||||
tg.HapticFeedback?.notificationOccurred("success");
|
tg.HapticFeedback?.notificationOccurred("success");
|
||||||
tg.sendData(encoded);
|
tg.sendData(encoded);
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Telegram sendData failed", error);
|
console.error("Telegram sendData failed", error);
|
||||||
showToast("Telegram не принял запись. Обновите приложение и попробуйте снова");
|
showToast("Telegram не принял запись. Обновите приложение и попробуйте снова");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,11 +164,27 @@
|
||||||
state.summary = await fetchJson(`../api/users/${userId}/summary`);
|
state.summary = await fetchJson(`../api/users/${userId}/summary`);
|
||||||
setIdentity(state.summary);
|
setIdentity(state.summary);
|
||||||
renderSummary(state.summary);
|
renderSummary(state.summary);
|
||||||
|
await loadTransactions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Summary is not available", 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) {
|
function renderSummary(summary) {
|
||||||
$("#balanceValue").textContent = money.format(summary.balance_cents / 100);
|
$("#balanceValue").textContent = money.format(summary.balance_cents / 100);
|
||||||
$("#incomeValue").textContent = money.format(summary.income_cents / 100);
|
$("#incomeValue").textContent = money.format(summary.income_cents / 100);
|
||||||
|
|
@ -164,10 +215,82 @@
|
||||||
return element.innerHTML;
|
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 `<div class="transaction-row ${income ? "" : "expense-row"}">
|
||||||
|
<span class="transaction-kind">${income ? "↗" : "↘"}</span>
|
||||||
|
<span class="transaction-copy"><strong>${escapeHtml(item.category)}</strong><small>${detail}</small></span>
|
||||||
|
<span class="transaction-actions">
|
||||||
|
<strong>${income ? "+" : "−"}${money.format(item.amount_cents / 100)}</strong>
|
||||||
|
<button class="cancel-button" type="button" data-transaction-id="${item.id}">Отменить</button>
|
||||||
|
</span>
|
||||||
|
</div>`;
|
||||||
|
}).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() {
|
function bindLeaderboard() {
|
||||||
$("#allBoardButton").addEventListener("click", () => switchBoard("all"));
|
$("#allBoardButton").addEventListener("click", () => switchBoard("all"));
|
||||||
$("#friendsBoardButton").addEventListener("click", () => switchBoard("friends"));
|
$("#friendsBoardButton").addEventListener("click", () => switchBoard("friends"));
|
||||||
$("#shareButton").addEventListener("click", shareInvite);
|
$("#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 = '<option value="">Все категории</option>'
|
||||||
|
+ names.map((name) => `<option value="${escapeHtml(name)}">${escapeHtml(name)}</option>`).join("");
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Leaderboard categories are not available", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchBoard(board) {
|
function switchBoard(board) {
|
||||||
|
|
@ -191,6 +314,9 @@
|
||||||
url = `../api/leaderboard?user_ids=${encodeURIComponent(ids.join(","))}`;
|
url = `../api/leaderboard?user_ids=${encodeURIComponent(ids.join(","))}`;
|
||||||
$("#friendHint").classList.toggle("hidden", friends.user_ids.length > 0);
|
$("#friendHint").classList.toggle("hidden", friends.user_ids.length > 0);
|
||||||
}
|
}
|
||||||
|
if (state.categoryFilter) {
|
||||||
|
url += `&category=${encodeURIComponent(state.categoryFilter)}`;
|
||||||
|
}
|
||||||
const data = await fetchJson(url);
|
const data = await fetchJson(url);
|
||||||
renderLeaderboard(data.items || []);
|
renderLeaderboard(data.items || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -246,4 +372,5 @@
|
||||||
bindLeaderboard();
|
bindLeaderboard();
|
||||||
fillCategories();
|
fillCategories();
|
||||||
loadSummary();
|
loadSummary();
|
||||||
|
loadUserCategories();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<title>Пожитки</title>
|
<title>Пожитки</title>
|
||||||
<link rel="stylesheet" href="./styles.css" />
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
<script src="./telegram-web-app.js"></script>
|
<script src="./telegram-web-app.js"></script>
|
||||||
<script src="./app.js?v=2" defer></script>
|
<script src="./app.js?v=3" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="shell">
|
<main class="shell">
|
||||||
|
|
@ -52,6 +52,14 @@
|
||||||
<div id="categoryList" class="category-list empty-state">
|
<div id="categoryList" class="category-list empty-state">
|
||||||
Добавьте первую операцию — категории появятся здесь.
|
Добавьте первую операцию — категории появятся здесь.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title history-heading">
|
||||||
|
<h2>Последние записи</h2>
|
||||||
|
<span>можно отменить</span>
|
||||||
|
</div>
|
||||||
|
<div id="transactionList" class="transaction-list empty-state">
|
||||||
|
Записей пока нет.
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="addView" class="view" aria-labelledby="addTab">
|
<section id="addView" class="view" aria-labelledby="addTab">
|
||||||
|
|
@ -77,6 +85,7 @@
|
||||||
<label id="customCategoryWrap" class="hidden">
|
<label id="customCategoryWrap" class="hidden">
|
||||||
<span>Своя категория</span>
|
<span>Своя категория</span>
|
||||||
<input id="customCategory" maxlength="40" placeholder="Например, Фриланс" />
|
<input id="customCategory" maxlength="40" placeholder="Например, Фриланс" />
|
||||||
|
<button id="saveCategoryButton" class="secondary-button" type="button">Сохранить только категорию</button>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
|
|
@ -98,6 +107,12 @@
|
||||||
<button id="allBoardButton" class="active" type="button">Все</button>
|
<button id="allBoardButton" class="active" type="button">Все</button>
|
||||||
<button id="friendsBoardButton" type="button">Друзья</button>
|
<button id="friendsBoardButton" type="button">Друзья</button>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="leader-filter">
|
||||||
|
<span>Категория</span>
|
||||||
|
<select id="leaderboardCategory">
|
||||||
|
<option value="">Все категории</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<div id="leaderboardList" class="leaderboard-list loading">Загружаем рейтинг…</div>
|
<div id="leaderboardList" class="leaderboard-list loading">Загружаем рейтинг…</div>
|
||||||
<p id="friendHint" class="friend-hint hidden">Поделитесь ссылкой: когда друг запустит бота, он появится здесь.</p>
|
<p id="friendHint" class="friend-hint hidden">Поделитесь ссылкой: когда друг запустит бота, он появится здесь.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -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-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-bar i { display: block; height: 100%; background: var(--accent); }
|
||||||
.category-item.expense-item .category-bar i { background: var(--danger); }
|
.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; }
|
.add-heading { margin: 5px 3px 24px; }
|
||||||
.transaction-form { display: grid; gap: 18px; }
|
.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 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; }
|
.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); }
|
.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; }
|
.form-hint { margin: -8px 15px 0; color: var(--muted); font-size: 10px; line-height: 1.45; text-align: center; }
|
||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
.board-heading { align-items: center; margin-top: 5px; margin-bottom: 20px; }
|
.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; }
|
.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; }
|
.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 { 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; }
|
.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); }
|
.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); }
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,12 @@ from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
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.config import Settings
|
||||||
from app.db import Database
|
from app.db import Database
|
||||||
|
|
||||||
|
|
@ -58,8 +63,26 @@ def test_filtered_leaderboard(tmp_path: Path) -> None:
|
||||||
for user_id in (1, 2, 3):
|
for user_id in (1, 2, 3):
|
||||||
add_user(database, user_id, str(user_id))
|
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])
|
result = database.leaderboard(user_ids=[1, 3])
|
||||||
assert [item["user_id"] for item in result] == [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(
|
@pytest.mark.parametrize(
|
||||||
|
|
@ -85,3 +108,31 @@ def test_personalized_keyboard_webapp_url(tmp_path: Path) -> None:
|
||||||
assert webapp_url_for_user(settings, 123456) == (
|
assert webapp_url_for_user(settings, 123456) == (
|
||||||
"https://example.com/app/?user_id=123456&mode=keyboard"
|
"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": "Подписки"}]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue