feat: add family debt accounting and income categories
This commit is contained in:
parent
1f071cdaa6
commit
490b0f7faa
9 changed files with 777 additions and 47 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# Финансовый Telegram-бот
|
||||
|
||||
Аккуратный бот для повседневного учёта личных финансов. Расход записывается одной строкой — `467 ярче` или `3000 бензин`. Доход — через `+ 85000 зарплата` или `доход 5000 подработка`.
|
||||
Аккуратный бот для повседневного учёта личных финансов. Расход записывается одной строкой — `467 ярче` или `3000 бензин`. Зарплата распознаётся по словам `зп` и `зарплата`, например `5000 ЗП`.
|
||||
|
||||
## Что уже работает
|
||||
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
- мгновенные уведомления супругу и в подключённый семейный Telegram-чат;
|
||||
- один общий вечерний отчёт в семейный чат;
|
||||
- общий долг: доходы автоматически уменьшают его, расходы увеличивают;
|
||||
- знак `-` у суммы принудительно уменьшает общий долг независимо от категории;
|
||||
- категории и подкатегории с возможностью добавлять собственные;
|
||||
- SQLite в режиме WAL и защита от повторного дневного отчёта;
|
||||
- готовый Docker-запуск на сервере.
|
||||
|
|
@ -152,12 +153,13 @@ docker compose logs -f finance-bot
|
|||
|
||||
После создания семейного бюджета откройте кнопку **«💳 Общий долг»** и укажите текущую сумму. Сделать это может только создатель бюджета, второй супруг видит результат.
|
||||
|
||||
Например, исходный долг составляет `530713`. После записи `+ 400000 зарплата` бот покажет долг `130713`. Следующая запись `5000 продукты` увеличит его до `135713`.
|
||||
Например, исходный долг составляет `530713`. После записи `400000 зарплата` бот покажет долг `130713`. Следующая запись `5000 продукты` увеличит его до `135713`. Запись `-5000 рабочий закуп` уменьшит долг на `5000`, хотя останется расходом в финансовой аналитике.
|
||||
|
||||
Расчёт ведётся по операциям после установки исходной суммы:
|
||||
|
||||
- каждый расход увеличивает долг;
|
||||
- каждый доход уменьшает долг;
|
||||
- сумма со знаком `-` уменьшает долг независимо от категории; сама сумма хранится положительной;
|
||||
- удаление ошибочной операции автоматически пересчитывает сумму;
|
||||
- если доходы превысят долг, бот покажет разницу как семейный резерв;
|
||||
- текущий долг или резерв отображается в подтверждениях, семейном чате и дневном отчёте.
|
||||
|
|
@ -166,7 +168,7 @@ docker compose logs -f finance-bot
|
|||
|
||||
### Категории и подкатегории
|
||||
|
||||
В семейном бюджете доступна кнопка **«🗂 Категории»**. Категории общие для обоих супругов: каждый участник может добавить новую основную категорию или подкатегорию.
|
||||
В семейном бюджете доступна кнопка **«🗂 Категории»**. Категории общие для обоих супругов: каждый участник может добавить новую основную категорию или подкатегорию. При создании основной категории выбирается тип **«Расход»** или **«Доход»**; подкатегория наследует тип родителя. Перенос операции в категорию другого типа меняет её тип и влияние на долг.
|
||||
|
||||
При создании бюджета бот автоматически добавляет базовое дерево, например:
|
||||
|
||||
|
|
|
|||
159
app/database.py
159
app/database.py
|
|
@ -69,6 +69,7 @@ class Database:
|
|||
icon TEXT NOT NULL DEFAULT '📁',
|
||||
parent_id INTEGER REFERENCES budget_categories(id) ON DELETE RESTRICT,
|
||||
system_key TEXT,
|
||||
kind TEXT NOT NULL DEFAULT 'expense' CHECK (kind IN ('expense', 'income')),
|
||||
created_by INTEGER REFERENCES users(user_id),
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (household_id, system_key)
|
||||
|
|
@ -90,6 +91,8 @@ class Database:
|
|||
amount_kopecks INTEGER NOT NULL CHECK (amount_kopecks > 0),
|
||||
description TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('expense', 'income')),
|
||||
debt_effect INTEGER NOT NULL DEFAULT 1 CHECK (debt_effect IN (-1, 1)),
|
||||
debt_effect_override INTEGER CHECK (debt_effect_override IN (-1, 1)),
|
||||
category TEXT NOT NULL,
|
||||
household_id INTEGER REFERENCES households(id),
|
||||
budget_category_id INTEGER REFERENCES budget_categories(id),
|
||||
|
|
@ -126,6 +129,17 @@ class Database:
|
|||
"ALTER TABLE transactions ADD COLUMN budget_category_id INTEGER "
|
||||
"REFERENCES budget_categories(id)"
|
||||
)
|
||||
if "debt_effect" not in columns:
|
||||
await db.execute(
|
||||
"ALTER TABLE transactions ADD COLUMN debt_effect INTEGER NOT NULL DEFAULT 1"
|
||||
)
|
||||
await db.execute(
|
||||
"UPDATE transactions SET debt_effect=CASE WHEN kind='income' THEN -1 ELSE 1 END"
|
||||
)
|
||||
if "debt_effect_override" not in columns:
|
||||
await db.execute(
|
||||
"ALTER TABLE transactions ADD COLUMN debt_effect_override INTEGER"
|
||||
)
|
||||
await db.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_transactions_household_date "
|
||||
"ON transactions(household_id, occurred_at)"
|
||||
|
|
@ -140,6 +154,22 @@ class Database:
|
|||
await db.execute("ALTER TABLE households ADD COLUMN debt_base_kopecks INTEGER")
|
||||
if "debt_started_at" not in household_columns:
|
||||
await db.execute("ALTER TABLE households ADD COLUMN debt_started_at TEXT")
|
||||
category_columns_cursor = await db.execute("PRAGMA table_info(budget_categories)")
|
||||
category_columns = {row[1] for row in await category_columns_cursor.fetchall()}
|
||||
if "kind" not in category_columns:
|
||||
await db.execute(
|
||||
"ALTER TABLE budget_categories ADD COLUMN kind TEXT NOT NULL DEFAULT 'expense'"
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE budget_categories
|
||||
SET kind=CASE
|
||||
WHEN system_key='income' OR system_key='salary'
|
||||
OR system_key LIKE 'salary_%' THEN 'income'
|
||||
ELSE 'expense'
|
||||
END
|
||||
"""
|
||||
)
|
||||
household_cursor = await db.execute("SELECT id FROM households")
|
||||
for household_row in await household_cursor.fetchall():
|
||||
await self._seed_default_categories(db, int(household_row[0]))
|
||||
|
|
@ -195,9 +225,17 @@ class Database:
|
|||
description: str,
|
||||
kind: TransactionKind,
|
||||
category: Category,
|
||||
debt_effect: int | None = None,
|
||||
debt_effect_override: int | None = None,
|
||||
budget_category_id: int | None = None,
|
||||
occurred_at: datetime,
|
||||
) -> Transaction:
|
||||
if debt_effect is None:
|
||||
debt_effect = -1 if kind is TransactionKind.INCOME else 1
|
||||
if debt_effect not in (-1, 1):
|
||||
raise ValueError("Влияние операции на долг должно быть -1 или 1")
|
||||
if debt_effect_override not in (None, -1, 1):
|
||||
raise ValueError("Переопределение влияния на долг должно быть -1, 1 или None")
|
||||
now = datetime.now(tz=occurred_at.tzinfo)
|
||||
async with aiosqlite.connect(self.path) as db:
|
||||
user_cursor = await db.execute(
|
||||
|
|
@ -215,15 +253,18 @@ class Database:
|
|||
cursor = await db.execute(
|
||||
"""
|
||||
INSERT INTO transactions (
|
||||
user_id, amount_kopecks, description, kind, category, household_id,
|
||||
user_id, amount_kopecks, description, kind, debt_effect,
|
||||
debt_effect_override, category, household_id,
|
||||
budget_category_id, occurred_at, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
amount_kopecks,
|
||||
description,
|
||||
kind.value,
|
||||
debt_effect,
|
||||
debt_effect_override,
|
||||
category.value,
|
||||
household_id,
|
||||
budget_category_id,
|
||||
|
|
@ -249,9 +290,14 @@ class Database:
|
|||
FROM transactions t JOIN users u ON u.user_id=t.user_id
|
||||
LEFT JOIN budget_categories c ON c.id=t.budget_category_id
|
||||
LEFT JOIN budget_categories p ON p.id=c.parent_id
|
||||
WHERE t.id=? AND t.user_id=?
|
||||
WHERE t.id=? AND (
|
||||
t.user_id=? OR EXISTS (
|
||||
SELECT 1 FROM household_members hm
|
||||
WHERE hm.user_id=? AND hm.household_id=t.household_id
|
||||
)
|
||||
)
|
||||
""",
|
||||
(transaction_id, user_id),
|
||||
(transaction_id, user_id, user_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return self._row_to_transaction(row) if row else None
|
||||
|
|
@ -556,13 +602,7 @@ class Database:
|
|||
cursor = await db.execute(
|
||||
"""
|
||||
SELECT h.debt_base_kopecks, h.debt_started_at,
|
||||
COALESCE(SUM(
|
||||
CASE
|
||||
WHEN t.kind='expense' THEN t.amount_kopecks
|
||||
WHEN t.kind='income' THEN -t.amount_kopecks
|
||||
ELSE 0
|
||||
END
|
||||
), 0)
|
||||
COALESCE(SUM(t.amount_kopecks * t.debt_effect), 0)
|
||||
FROM households h
|
||||
JOIN household_members hm ON hm.household_id=h.id
|
||||
LEFT JOIN transactions t
|
||||
|
|
@ -581,7 +621,8 @@ class Database:
|
|||
async with aiosqlite.connect(self.path) as db:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
SELECT c.id, c.name, c.icon, c.parent_id, p.name, p.icon, c.system_key
|
||||
SELECT c.id, c.name, c.icon, c.parent_id, p.name, p.icon,
|
||||
c.system_key, c.kind
|
||||
FROM household_members hm
|
||||
JOIN budget_categories c ON c.household_id=hm.household_id
|
||||
LEFT JOIN budget_categories p ON p.id=c.parent_id
|
||||
|
|
@ -600,6 +641,7 @@ class Database:
|
|||
parent_name=str(row[4]) if row[4] is not None else None,
|
||||
parent_icon=str(row[5]) if row[5] is not None else None,
|
||||
system_key=str(row[6]) if row[6] is not None else None,
|
||||
kind=str(row[7]),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
|
@ -621,12 +663,32 @@ class Database:
|
|||
row = await cursor.fetchone()
|
||||
return int(row[0]) if row else None
|
||||
|
||||
async def resolve_salary_category(self, user_id: int) -> int | None:
|
||||
async with aiosqlite.connect(self.path) as db:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
SELECT c.id
|
||||
FROM household_members hm
|
||||
JOIN households h ON h.id=hm.household_id
|
||||
JOIN budget_categories c ON c.household_id=h.id
|
||||
WHERE hm.user_id=?
|
||||
AND c.system_key=CASE
|
||||
WHEN h.created_by=? THEN 'salary_vitya'
|
||||
ELSE 'salary_yana'
|
||||
END
|
||||
""",
|
||||
(user_id, user_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row[0]) if row else None
|
||||
|
||||
async def create_budget_category(
|
||||
self,
|
||||
*,
|
||||
user_id: int,
|
||||
name: str,
|
||||
parent_id: int | None,
|
||||
kind: str = "expense",
|
||||
now: datetime,
|
||||
) -> BudgetCategory:
|
||||
clean_name = " ".join(name.split()).strip()
|
||||
|
|
@ -649,22 +711,26 @@ class Database:
|
|||
if parent_id is not None:
|
||||
parent_cursor = await db.execute(
|
||||
"""
|
||||
SELECT 1 FROM budget_categories
|
||||
SELECT kind FROM budget_categories
|
||||
WHERE id=? AND household_id=? AND parent_id IS NULL
|
||||
AND (system_key IS NULL OR system_key<>'income')
|
||||
""",
|
||||
(parent_id, household_id),
|
||||
)
|
||||
if await parent_cursor.fetchone() is None:
|
||||
parent_row = await parent_cursor.fetchone()
|
||||
if parent_row is None:
|
||||
raise ValueError("Основная категория не найдена")
|
||||
kind = str(parent_row[0])
|
||||
elif kind not in {"expense", "income"}:
|
||||
raise ValueError("Тип категории должен быть expense или income")
|
||||
try:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
INSERT INTO budget_categories(
|
||||
household_id, name, icon, parent_id, created_by, created_at
|
||||
) VALUES (?, ?, '📁', ?, ?, ?)
|
||||
household_id, name, icon, parent_id, kind, created_by, created_at
|
||||
) VALUES (?, ?, '📁', ?, ?, ?, ?)
|
||||
""",
|
||||
(household_id, clean_name, parent_id, user_id, now.isoformat()),
|
||||
(household_id, clean_name, parent_id, kind, user_id, now.isoformat()),
|
||||
)
|
||||
await db.commit()
|
||||
except aiosqlite.IntegrityError as exc:
|
||||
|
|
@ -682,13 +748,26 @@ class Database:
|
|||
cursor = await db.execute(
|
||||
"""
|
||||
UPDATE transactions
|
||||
SET budget_category_id=?
|
||||
WHERE id=? AND user_id=? AND kind='expense'
|
||||
SET budget_category_id=?,
|
||||
kind=(SELECT kind FROM budget_categories WHERE id=?),
|
||||
debt_effect=CASE
|
||||
WHEN debt_effect_override IS NOT NULL THEN debt_effect_override
|
||||
WHEN (SELECT kind FROM budget_categories WHERE id=?)='income' THEN -1
|
||||
ELSE 1
|
||||
END
|
||||
WHERE id=?
|
||||
AND (user_id=? OR EXISTS (
|
||||
SELECT 1 FROM household_members hm
|
||||
WHERE hm.user_id=? AND hm.household_id=transactions.household_id
|
||||
))
|
||||
AND household_id=(
|
||||
SELECT household_id FROM budget_categories WHERE id=?
|
||||
)
|
||||
""",
|
||||
(category_id, transaction_id, user_id, category_id),
|
||||
(
|
||||
category_id, category_id, category_id,
|
||||
transaction_id, user_id, user_id, category_id,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
|
@ -813,6 +892,7 @@ class Database:
|
|||
description=str(row["description"]),
|
||||
kind=TransactionKind(row["kind"]),
|
||||
category=Category(row["category"]),
|
||||
debt_effect=int(row["debt_effect"]),
|
||||
occurred_at=datetime.fromisoformat(row["occurred_at"]),
|
||||
author_name=str(row["author_name"]) if "author_name" in row.keys() else None,
|
||||
budget_category_id=(
|
||||
|
|
@ -849,13 +929,14 @@ class Database:
|
|||
created_at = datetime.now().astimezone().isoformat()
|
||||
for category in Category:
|
||||
icon, name = CATEGORY_META[category]
|
||||
kind = "income" if category is Category.INCOME else "expense"
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO budget_categories(
|
||||
household_id, name, icon, parent_id, system_key, created_at
|
||||
) VALUES (?, ?, ?, NULL, ?, ?)
|
||||
household_id, name, icon, parent_id, system_key, kind, created_at
|
||||
) VALUES (?, ?, ?, NULL, ?, ?, ?)
|
||||
""",
|
||||
(household_id, name, icon, category.value, created_at),
|
||||
(household_id, name, icon, category.value, kind, created_at),
|
||||
)
|
||||
parent_cursor = await db.execute(
|
||||
"SELECT id FROM budget_categories WHERE household_id=? AND system_key=?",
|
||||
|
|
@ -868,10 +949,36 @@ class Database:
|
|||
await db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO budget_categories(
|
||||
household_id, name, icon, parent_id, system_key, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
household_id, name, icon, parent_id, system_key, kind, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(household_id, sub_name, sub_icon, parent_id, system_key, created_at),
|
||||
(household_id, sub_name, sub_icon, parent_id, system_key, kind, created_at),
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO budget_categories(
|
||||
household_id, name, icon, parent_id, system_key, kind, created_at
|
||||
) VALUES (?, 'Зарплата', '💰', NULL, 'salary', 'income', ?)
|
||||
""",
|
||||
(household_id, created_at),
|
||||
)
|
||||
salary_cursor = await db.execute(
|
||||
"SELECT id FROM budget_categories WHERE household_id=? AND system_key='salary'",
|
||||
(household_id,),
|
||||
)
|
||||
salary_parent_id = int((await salary_cursor.fetchone())[0])
|
||||
for system_key, name in (
|
||||
("salary_yana", "Зарплата Яна"),
|
||||
("salary_vitya", "Зарплата Витя"),
|
||||
):
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO budget_categories(
|
||||
household_id, name, icon, parent_id, system_key, kind, created_at
|
||||
) VALUES (?, ?, '💵', ?, ?, 'income', ?)
|
||||
""",
|
||||
(household_id, name, salary_parent_id, system_key, created_at),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ class ParsedTransaction:
|
|||
amount_kopecks: int
|
||||
description: str
|
||||
kind: TransactionKind
|
||||
debt_effect: int
|
||||
debt_effect_override: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -35,6 +37,7 @@ class Transaction:
|
|||
description: str
|
||||
kind: TransactionKind
|
||||
category: Category
|
||||
debt_effect: int
|
||||
occurred_at: datetime
|
||||
author_name: str | None = None
|
||||
budget_category_id: int | None = None
|
||||
|
|
@ -53,6 +56,7 @@ class BudgetCategory:
|
|||
parent_name: str | None = None
|
||||
parent_icon: str | None = None
|
||||
system_key: str | None = None
|
||||
kind: str = "expense"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from app.keyboards import (
|
|||
RECENT,
|
||||
analytics_period_keyboard,
|
||||
category_management_keyboard,
|
||||
category_kind_keyboard,
|
||||
category_tree_keyboard,
|
||||
category_keyboard,
|
||||
debt_keyboard,
|
||||
|
|
@ -215,15 +216,30 @@ def create_router(database: Database, settings: Settings) -> Router:
|
|||
lines = ["🗂 <b>Категории семейного бюджета</b>", ""]
|
||||
for category in categories:
|
||||
prefix = " ↳ " if category.parent_id is not None else ""
|
||||
lines.append(f"{prefix}{escape(category.icon)} {escape(category.name)}")
|
||||
kind_icon = "↗️" if category.kind == "income" else "↘️"
|
||||
lines.append(f"{prefix}{kind_icon} {escape(category.icon)} {escape(category.name)}")
|
||||
await message.answer(
|
||||
"\n".join(lines), reply_markup=category_management_keyboard()
|
||||
)
|
||||
|
||||
@router.callback_query(F.data == "catmanage:add-main")
|
||||
async def ask_main_category_name(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
await state.set_state(CategorySetup.waiting_for_name)
|
||||
async def choose_main_category_kind(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
await state.set_data({"parent_id": None})
|
||||
if callback.message:
|
||||
await callback.message.answer(
|
||||
"Как операции этой категории влияют на бюджет и общий долг?",
|
||||
reply_markup=category_kind_keyboard(),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@router.callback_query(F.data.startswith("catmanage:kind:"))
|
||||
async def ask_main_category_name(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
kind = (callback.data or "").rsplit(":", maxsplit=1)[-1]
|
||||
if kind not in {"expense", "income"}:
|
||||
await callback.answer("Некорректный тип категории", show_alert=True)
|
||||
return
|
||||
await state.set_state(CategorySetup.waiting_for_name)
|
||||
await state.update_data(kind=kind)
|
||||
if callback.message:
|
||||
await callback.message.answer(
|
||||
"Введите название новой категории, например <code>Здоровье</code>.\n\n"
|
||||
|
|
@ -277,6 +293,7 @@ def create_router(database: Database, settings: Settings) -> Router:
|
|||
user_id=message.from_user.id,
|
||||
name=message.text or "",
|
||||
parent_id=data.get("parent_id"),
|
||||
kind=data.get("kind", "expense"),
|
||||
now=datetime.now(settings.timezone),
|
||||
)
|
||||
except (ValueError, FamilyJoinError) as exc:
|
||||
|
|
@ -428,7 +445,7 @@ def create_router(database: Database, settings: Settings) -> Router:
|
|||
async def choose_category(callback: CallbackQuery) -> None:
|
||||
transaction_id = _callback_id(callback.data)
|
||||
transaction = await database.get_transaction(transaction_id, callback.from_user.id)
|
||||
if transaction is None or transaction.kind is TransactionKind.INCOME:
|
||||
if transaction is None:
|
||||
await callback.answer("Запись не найдена", show_alert=True)
|
||||
return
|
||||
categories = await database.budget_categories(callback.from_user.id)
|
||||
|
|
@ -553,12 +570,19 @@ def create_router(database: Database, settings: Settings) -> Router:
|
|||
budget_category_id = await database.resolve_budget_category(
|
||||
message.from_user.id, category, subcategory_key
|
||||
)
|
||||
if parsed.kind is TransactionKind.INCOME and any(
|
||||
word in {"зп", "зарплата"}
|
||||
for word in parsed.description.casefold().replace("ё", "е").split()
|
||||
):
|
||||
budget_category_id = await database.resolve_salary_category(message.from_user.id)
|
||||
transaction = await database.add_transaction(
|
||||
user_id=message.from_user.id,
|
||||
amount_kopecks=parsed.amount_kopecks,
|
||||
description=parsed.description,
|
||||
kind=parsed.kind,
|
||||
category=category,
|
||||
debt_effect=parsed.debt_effect,
|
||||
debt_effect_override=parsed.debt_effect_override,
|
||||
budget_category_id=budget_category_id,
|
||||
occurred_at=datetime.now(settings.timezone),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -96,6 +96,18 @@ def category_management_keyboard() -> InlineKeyboardMarkup:
|
|||
)
|
||||
|
||||
|
||||
def category_kind_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="↘️ Расход", callback_data="catmanage:kind:expense"),
|
||||
InlineKeyboardButton(text="↗️ Доход", callback_data="catmanage:kind:income"),
|
||||
],
|
||||
[InlineKeyboardButton(text="Отмена", callback_data="catmanage:cancel")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def parent_category_keyboard(categories: list[BudgetCategory]) -> InlineKeyboardMarkup:
|
||||
parents = [category for category in categories if category.parent_id is None]
|
||||
return InlineKeyboardMarkup(
|
||||
|
|
@ -113,11 +125,11 @@ def parent_category_keyboard(categories: list[BudgetCategory]) -> InlineKeyboard
|
|||
|
||||
|
||||
def transaction_actions(transaction_id: int, *, is_expense: bool) -> InlineKeyboardMarkup:
|
||||
buttons: list[InlineKeyboardButton] = []
|
||||
if is_expense:
|
||||
buttons.append(
|
||||
# In a family budget, selecting a category may also change the operation type.
|
||||
# Keep this action available for both current expenses and current incomes.
|
||||
buttons: list[InlineKeyboardButton] = [
|
||||
InlineKeyboardButton(text="Изменить категорию", callback_data=f"choose:{transaction_id}")
|
||||
)
|
||||
]
|
||||
buttons.append(InlineKeyboardButton(text="Удалить", callback_data=f"delete:{transaction_id}"))
|
||||
return InlineKeyboardMarkup(inline_keyboard=[buttons])
|
||||
|
||||
|
|
|
|||
|
|
@ -30,9 +30,10 @@ WELCOME = """<b>Привет! Я помогу держать деньги под
|
|||
|
||||
<code>467 ярче</code>
|
||||
<code>3000 бензин</code>
|
||||
<code>+ 85000 зарплата</code>
|
||||
<code>85000 зарплата</code>
|
||||
<code>-5000 рабочий закуп</code>
|
||||
|
||||
Обычная запись — расход. Знак <b>+</b> или слово <b>доход</b> — поступление.
|
||||
Обычная запись — расход; слова <b>зп</b>/<b>зарплата</b> или <b>доход</b> — поступление. Знак <b>−</b> принудительно уменьшает общий долг.
|
||||
|
||||
Каждый вечер я пришлю короткий итог дня и месяца. А в разделе «Семья» можно подключить общий бюджет с супругом. Начнём?"""
|
||||
|
||||
|
|
@ -42,9 +43,10 @@ HELP_TEXT = """<b>Как записывать операции</b>
|
|||
Расход:
|
||||
<code>1250 продукты</code>
|
||||
<code>3 500 ремонт машины</code>
|
||||
<code>-5000 рабочий закуп</code> — расход, который уменьшит общий долг
|
||||
|
||||
Доход:
|
||||
<code>+ 75000 зарплата</code>
|
||||
<code>75000 зарплата</code>
|
||||
<code>доход 5000 подработка</code>
|
||||
|
||||
Можно вводить копейки: <code>199,90 кофе</code>.
|
||||
|
|
@ -53,7 +55,7 @@ HELP_TEXT = """<b>Как записывать операции</b>
|
|||
|
||||
В семейном режиме кнопка <b>«Общий долг»</b> включает автоматический расчёт: расходы увеличивают долг, доходы уменьшают.
|
||||
|
||||
Через <b>«Категории»</b> можно добавить свои категории и подкатегории. Кнопка «Изменить категорию» переносит любую запись, в том числе из «Другое»."""
|
||||
Через <b>«Категории»</b> можно добавить свои категории доходов и расходов и подкатегории. Кнопка «Изменить категорию» переносит любую запись, в том числе из «Другое»."""
|
||||
|
||||
|
||||
def transaction_confirmation(transaction: Transaction, debt: int | None = None) -> str:
|
||||
|
|
|
|||
|
|
@ -19,9 +19,22 @@ _TRANSACTION_RE = re.compile(
|
|||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
_DESCRIPTION_FIRST_RE = re.compile(
|
||||
r"^\s*(?P<sign>[+-])\s*(?P<description>зп|зарплата)\s+"
|
||||
r"(?P<amount>\d[\d ]*(?:[,.]\d{1,2})?)\s*"
|
||||
r"(?:₽|р\.?|руб(?:лей|ля|ль)?\.?)?\s*$",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
_SALARY_RE = re.compile(r"(?:^|\s)(?:зп|зарплата)(?:$|\s)", flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def parse_transaction(text: str) -> ParsedTransaction:
|
||||
match = _TRANSACTION_RE.match(text)
|
||||
description_first = False
|
||||
if not match:
|
||||
match = _DESCRIPTION_FIRST_RE.match(text)
|
||||
description_first = match is not None
|
||||
if not match:
|
||||
raise ParseError("Не удалось распознать сумму и описание")
|
||||
|
||||
|
|
@ -35,20 +48,24 @@ def parse_transaction(text: str) -> ParsedTransaction:
|
|||
|
||||
amount_kopecks = int((amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||||
description = " ".join(match.group("description").split())
|
||||
kind_word = (match.group("kind") or "").casefold()
|
||||
kind_word = "" if description_first else (match.group("kind") or "").casefold()
|
||||
sign = match.group("sign")
|
||||
if sign == "-" and kind_word in {"доход", "приход"}:
|
||||
raise ParseError("Доход не может быть отрицательным")
|
||||
is_salary = _SALARY_RE.search(description) is not None
|
||||
kind = (
|
||||
TransactionKind.INCOME
|
||||
if sign == "+" or kind_word in {"доход", "приход"}
|
||||
if is_salary or kind_word in {"доход", "приход"}
|
||||
else TransactionKind.EXPENSE
|
||||
)
|
||||
if sign == "-" and kind is TransactionKind.INCOME:
|
||||
raise ParseError("Доход не может быть отрицательным")
|
||||
debt_effect = -1 if sign == "-" or kind is TransactionKind.INCOME else 1
|
||||
|
||||
return ParsedTransaction(
|
||||
amount_kopecks=amount_kopecks,
|
||||
description=description,
|
||||
kind=kind,
|
||||
debt_effect=debt_effect,
|
||||
debt_effect_override=-1 if sign == "-" else None,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,6 @@ async def test_transaction_lifecycle_and_totals(tmp_path):
|
|||
) == {category_label(Category.CAR): 30_000}
|
||||
assert await database.category_totals(10) == {category_label(Category.CAR): 30_000}
|
||||
|
||||
# Другой пользователь не может изменить или удалить чужую запись.
|
||||
assert not await database.update_category(expense.id, 20, Category.OTHER)
|
||||
assert not await database.delete_transaction(expense.id, 20)
|
||||
assert await database.update_category(expense.id, 10, Category.OTHER)
|
||||
|
|
@ -255,3 +254,518 @@ async def test_existing_database_is_migrated_for_family_budget(tmp_path):
|
|||
cursor = await db.execute("PRAGMA table_info(households)")
|
||||
household_columns = {row[1] for row in await cursor.fetchall()}
|
||||
assert {"debt_base_kopecks", "debt_started_at"} <= household_columns
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_adds_debt_effect_and_kind_columns(tmp_path):
|
||||
import aiosqlite
|
||||
|
||||
path = tmp_path / "old.db"
|
||||
async with aiosqlite.connect(path) as db:
|
||||
await db.executescript(
|
||||
"""
|
||||
CREATE TABLE users (
|
||||
user_id INTEGER PRIMARY KEY, chat_id INTEGER NOT NULL, username TEXT,
|
||||
full_name TEXT NOT NULL, timezone TEXT NOT NULL, report_time TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
|
||||
amount_kopecks INTEGER NOT NULL, description TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, category TEXT NOT NULL,
|
||||
occurred_at TEXT NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE households (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, invite_code TEXT NOT NULL UNIQUE,
|
||||
created_by INTEGER NOT NULL, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE household_members (
|
||||
household_id INTEGER NOT NULL, user_id INTEGER NOT NULL UNIQUE,
|
||||
joined_at TEXT NOT NULL, PRIMARY KEY (household_id, user_id)
|
||||
);
|
||||
CREATE TABLE budget_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, household_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL, icon TEXT NOT NULL DEFAULT '📁',
|
||||
parent_id INTEGER, system_key TEXT, created_by INTEGER,
|
||||
created_at TEXT NOT NULL, UNIQUE (household_id, system_key)
|
||||
);
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO users VALUES (1, 1, NULL, 'Test', 'UTC', '21:00', '2026-01-01', '2026-01-01')"
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO households(invite_code, created_by, created_at) VALUES ('FIN-T1', 1, '2026-01-01')"
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO household_members VALUES (1, 1, '2026-01-01')"
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO transactions(user_id, amount_kopecks, description, kind, category, occurred_at, created_at) "
|
||||
"VALUES (1, 100000, 'бензин', 'expense', 'car', '2026-01-01', '2026-01-01')"
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO transactions(user_id, amount_kopecks, description, kind, category, occurred_at, created_at) "
|
||||
"VALUES (1, 500000, 'зарплата', 'income', 'income', '2026-01-02', '2026-01-02')"
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO budget_categories(household_id, name, icon, system_key, created_at) "
|
||||
"VALUES (1, 'Доход', '💚', 'income', '2026-01-01')"
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO budget_categories(household_id, name, icon, system_key, created_at) "
|
||||
"VALUES (1, 'Машина', '🚗', 'car', '2026-01-01')"
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
database = Database(path)
|
||||
await database.initialize()
|
||||
|
||||
async with aiosqlite.connect(path) as db:
|
||||
cursor = await db.execute("PRAGMA table_info(transactions)")
|
||||
columns = {row[1] for row in await cursor.fetchall()}
|
||||
assert "debt_effect" in columns
|
||||
|
||||
cursor = await db.execute("SELECT kind, debt_effect FROM transactions ORDER BY id")
|
||||
rows = await cursor.fetchall()
|
||||
assert rows[0] == ("expense", 1)
|
||||
assert rows[1] == ("income", -1)
|
||||
|
||||
cursor = await db.execute("PRAGMA table_info(budget_categories)")
|
||||
columns = {row[1] for row in await cursor.fetchall()}
|
||||
assert "kind" in columns
|
||||
|
||||
cursor = await db.execute("SELECT system_key, kind FROM budget_categories ORDER BY id")
|
||||
rows = await cursor.fetchall()
|
||||
assert rows[0] == ("income", "income")
|
||||
assert rows[1] == ("car", "expense")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_is_idempotent(tmp_path):
|
||||
database = Database(tmp_path / "finance.db")
|
||||
await database.initialize()
|
||||
await database.initialize()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_salary_categories_seeded(tmp_path):
|
||||
database = Database(tmp_path / "finance.db")
|
||||
await database.initialize()
|
||||
now = datetime(2026, 7, 13, 12, tzinfo=ZoneInfo("UTC"))
|
||||
await database.upsert_user(
|
||||
user_id=10, chat_id=10, username=None, full_name="Витя",
|
||||
timezone="UTC", report_time="21:00", now=now,
|
||||
)
|
||||
await database.create_family(10, now)
|
||||
categories = await database.budget_categories(10)
|
||||
|
||||
salary_root = next((c for c in categories if c.system_key == "salary"), None)
|
||||
assert salary_root is not None
|
||||
assert salary_root.name == "Зарплата"
|
||||
assert salary_root.kind == "income"
|
||||
assert salary_root.parent_id is None
|
||||
|
||||
salary_yana = next((c for c in categories if c.system_key == "salary_yana"), None)
|
||||
assert salary_yana is not None
|
||||
assert salary_yana.name == "Зарплата Яна"
|
||||
assert salary_yana.kind == "income"
|
||||
assert salary_yana.parent_id == salary_root.id
|
||||
|
||||
salary_vitya = next((c for c in categories if c.system_key == "salary_vitya"), None)
|
||||
assert salary_vitya is not None
|
||||
assert salary_vitya.name == "Зарплата Витя"
|
||||
assert salary_vitya.kind == "income"
|
||||
assert salary_vitya.parent_id == salary_root.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_salary_routing_owner_and_spouse(tmp_path):
|
||||
database = Database(tmp_path / "finance.db")
|
||||
await database.initialize()
|
||||
now = datetime(2026, 7, 13, 12, tzinfo=ZoneInfo("UTC"))
|
||||
await database.upsert_user(
|
||||
user_id=10, chat_id=10, username=None, full_name="Витя",
|
||||
timezone="UTC", report_time="21:00", now=now,
|
||||
)
|
||||
await database.upsert_user(
|
||||
user_id=20, chat_id=20, username=None, full_name="Яна",
|
||||
timezone="UTC", report_time="21:00", now=now,
|
||||
)
|
||||
family = await database.create_family(10, now)
|
||||
await database.request_family_join(20, family.invite_code, now)
|
||||
await database.resolve_family_join(
|
||||
owner_user_id=10, candidate_user_id=20, approve=True, now=now,
|
||||
)
|
||||
|
||||
owner_cat_id = await database.resolve_salary_category(10)
|
||||
assert owner_cat_id is not None
|
||||
owner_cat = next(c for c in await database.budget_categories(10) if c.id == owner_cat_id)
|
||||
assert owner_cat.system_key == "salary_vitya"
|
||||
|
||||
spouse_cat_id = await database.resolve_salary_category(20)
|
||||
assert spouse_cat_id is not None
|
||||
spouse_cat = next(c for c in await database.budget_categories(20) if c.id == spouse_cat_id)
|
||||
assert spouse_cat.system_key == "salary_yana"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_salary_routing_no_household_returns_none(tmp_path):
|
||||
database = Database(tmp_path / "finance.db")
|
||||
await database.initialize()
|
||||
now = datetime(2026, 7, 13, 12, tzinfo=ZoneInfo("UTC"))
|
||||
await database.upsert_user(
|
||||
user_id=10, chat_id=10, username=None, full_name="Одинокий",
|
||||
timezone="UTC", report_time="21:00", now=now,
|
||||
)
|
||||
assert await database.resolve_salary_category(10) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_negative_expense_decreases_debt(tmp_path):
|
||||
database = Database(tmp_path / "finance.db")
|
||||
await database.initialize()
|
||||
now = datetime(2026, 7, 13, 12, tzinfo=ZoneInfo("UTC"))
|
||||
await database.upsert_user(
|
||||
user_id=10, chat_id=10, username=None, full_name="Витя",
|
||||
timezone="UTC", report_time="21:00", now=now,
|
||||
)
|
||||
await database.create_family(10, now)
|
||||
await database.set_family_debt(10, 100_000, now)
|
||||
|
||||
tx = await database.add_transaction(
|
||||
user_id=10, amount_kopecks=50_000, description="рабочий закуп",
|
||||
kind=TransactionKind.EXPENSE, category=Category.OTHER,
|
||||
debt_effect=-1, occurred_at=now + timedelta(seconds=1),
|
||||
)
|
||||
assert tx.debt_effect == -1
|
||||
assert await database.family_debt(10) == 50_000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_positive_expense_increases_debt(tmp_path):
|
||||
database = Database(tmp_path / "finance.db")
|
||||
await database.initialize()
|
||||
now = datetime(2026, 7, 13, 12, tzinfo=ZoneInfo("UTC"))
|
||||
await database.upsert_user(
|
||||
user_id=10, chat_id=10, username=None, full_name="Витя",
|
||||
timezone="UTC", report_time="21:00", now=now,
|
||||
)
|
||||
await database.create_family(10, now)
|
||||
await database.set_family_debt(10, 100_000, now)
|
||||
|
||||
tx = await database.add_transaction(
|
||||
user_id=10, amount_kopecks=30_000, description="бензин",
|
||||
kind=TransactionKind.EXPENSE, category=Category.CAR,
|
||||
debt_effect=1, occurred_at=now + timedelta(seconds=1),
|
||||
)
|
||||
assert tx.debt_effect == 1
|
||||
assert await database.family_debt(10) == 130_000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_income_decreases_debt(tmp_path):
|
||||
database = Database(tmp_path / "finance.db")
|
||||
await database.initialize()
|
||||
now = datetime(2026, 7, 13, 12, tzinfo=ZoneInfo("UTC"))
|
||||
await database.upsert_user(
|
||||
user_id=10, chat_id=10, username=None, full_name="Витя",
|
||||
timezone="UTC", report_time="21:00", now=now,
|
||||
)
|
||||
await database.create_family(10, now)
|
||||
await database.set_family_debt(10, 100_000, now)
|
||||
|
||||
tx = await database.add_transaction(
|
||||
user_id=10, amount_kopecks=50_000, description="зарплата",
|
||||
kind=TransactionKind.INCOME, category=Category.INCOME,
|
||||
debt_effect=-1, occurred_at=now + timedelta(seconds=1),
|
||||
)
|
||||
assert tx.debt_effect == -1
|
||||
assert await database.family_debt(10) == 50_000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_recomputes_debt(tmp_path):
|
||||
database = Database(tmp_path / "finance.db")
|
||||
await database.initialize()
|
||||
now = datetime(2026, 7, 13, 12, tzinfo=ZoneInfo("UTC"))
|
||||
await database.upsert_user(
|
||||
user_id=10, chat_id=10, username=None, full_name="Витя",
|
||||
timezone="UTC", report_time="21:00", now=now,
|
||||
)
|
||||
await database.create_family(10, now)
|
||||
await database.set_family_debt(10, 100_000, now)
|
||||
|
||||
tx = await database.add_transaction(
|
||||
user_id=10, amount_kopecks=30_000, description="бензин",
|
||||
kind=TransactionKind.EXPENSE, category=Category.CAR,
|
||||
debt_effect=1, occurred_at=now + timedelta(seconds=1),
|
||||
)
|
||||
assert await database.family_debt(10) == 130_000
|
||||
|
||||
assert await database.delete_transaction(tx.id, 10)
|
||||
assert await database.family_debt(10) == 100_000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_negative_expense_restores_debt(tmp_path):
|
||||
database = Database(tmp_path / "finance.db")
|
||||
await database.initialize()
|
||||
now = datetime(2026, 7, 13, 12, tzinfo=ZoneInfo("UTC"))
|
||||
await database.upsert_user(
|
||||
user_id=10, chat_id=10, username=None, full_name="Витя",
|
||||
timezone="UTC", report_time="21:00", now=now,
|
||||
)
|
||||
await database.create_family(10, now)
|
||||
await database.set_family_debt(10, 100_000, now)
|
||||
|
||||
tx = await database.add_transaction(
|
||||
user_id=10, amount_kopecks=30_000, description="рабочий закуп",
|
||||
kind=TransactionKind.EXPENSE, category=Category.OTHER,
|
||||
debt_effect=-1, occurred_at=now + timedelta(seconds=1),
|
||||
)
|
||||
assert await database.family_debt(10) == 70_000
|
||||
|
||||
assert await database.delete_transaction(tx.id, 10)
|
||||
assert await database.family_debt(10) == 100_000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_budget_category_syncs_kind(tmp_path):
|
||||
database = Database(tmp_path / "finance.db")
|
||||
await database.initialize()
|
||||
now = datetime(2026, 7, 13, 12, tzinfo=ZoneInfo("UTC"))
|
||||
await database.upsert_user(
|
||||
user_id=10, chat_id=10, username=None, full_name="Витя",
|
||||
timezone="UTC", report_time="21:00", now=now,
|
||||
)
|
||||
await database.create_family(10, now)
|
||||
await database.set_family_debt(10, 100_000, now)
|
||||
|
||||
tx = await database.add_transaction(
|
||||
user_id=10, amount_kopecks=30_000, description="бензин",
|
||||
kind=TransactionKind.EXPENSE, category=Category.CAR,
|
||||
debt_effect=1, occurred_at=now + timedelta(seconds=1),
|
||||
)
|
||||
assert await database.family_debt(10) == 130_000
|
||||
|
||||
salary_cat = next(
|
||||
c for c in await database.budget_categories(10)
|
||||
if c.system_key == "salary_vitya"
|
||||
)
|
||||
changed = await database.update_transaction_budget_category(tx.id, 10, salary_cat.id)
|
||||
assert changed
|
||||
|
||||
updated_tx = await database.get_transaction(tx.id, 10)
|
||||
assert updated_tx.kind is TransactionKind.INCOME
|
||||
assert updated_tx.budget_category_name == "Зарплата Витя"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_category_kind_and_inherited_subcategory(tmp_path):
|
||||
database = Database(tmp_path / "finance.db")
|
||||
await database.initialize()
|
||||
now = datetime(2026, 7, 13, 12, tzinfo=ZoneInfo("UTC"))
|
||||
await database.upsert_user(
|
||||
user_id=10, chat_id=10, username=None, full_name="Витя",
|
||||
timezone="UTC", report_time="21:00", now=now,
|
||||
)
|
||||
await database.create_family(10, now)
|
||||
|
||||
income_cat = await database.create_budget_category(
|
||||
user_id=10, name="Инвестиции", parent_id=None, kind="income", now=now,
|
||||
)
|
||||
assert income_cat.kind == "income"
|
||||
|
||||
sub_cat = await database.create_budget_category(
|
||||
user_id=10, name="Дивиденды", parent_id=income_cat.id, now=now,
|
||||
)
|
||||
assert sub_cat.kind == "income"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expense_main_category_defaults_to_expense_kind(tmp_path):
|
||||
database = Database(tmp_path / "finance.db")
|
||||
await database.initialize()
|
||||
now = datetime(2026, 7, 13, 12, tzinfo=ZoneInfo("UTC"))
|
||||
await database.upsert_user(
|
||||
user_id=10, chat_id=10, username=None, full_name="Витя",
|
||||
timezone="UTC", report_time="21:00", now=now,
|
||||
)
|
||||
await database.create_family(10, now)
|
||||
|
||||
cat = await database.create_budget_category(
|
||||
user_id=10, name="Здоровье", parent_id=None, kind="expense", now=now,
|
||||
)
|
||||
assert cat.kind == "expense"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_category_change_income_to_expense_updates_debt(tmp_path):
|
||||
database = Database(tmp_path / "finance.db")
|
||||
await database.initialize()
|
||||
now = datetime(2026, 7, 13, 12, tzinfo=ZoneInfo("UTC"))
|
||||
await database.upsert_user(
|
||||
user_id=10, chat_id=10, username=None, full_name="Витя",
|
||||
timezone="UTC", report_time="21:00", now=now,
|
||||
)
|
||||
await database.create_family(10, now)
|
||||
await database.set_family_debt(10, 100_000, now)
|
||||
|
||||
tx = await database.add_transaction(
|
||||
user_id=10, amount_kopecks=50_000, description="зарплата",
|
||||
kind=TransactionKind.INCOME, category=Category.INCOME,
|
||||
debt_effect=-1, occurred_at=now + timedelta(seconds=1),
|
||||
)
|
||||
assert await database.family_debt(10) == 50_000
|
||||
|
||||
expense_cat = next(
|
||||
c for c in await database.budget_categories(10)
|
||||
if c.system_key == "car"
|
||||
)
|
||||
changed = await database.update_transaction_budget_category(tx.id, 10, expense_cat.id)
|
||||
assert changed
|
||||
|
||||
updated_tx = await database.get_transaction(tx.id, 10)
|
||||
assert updated_tx.kind is TransactionKind.EXPENSE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_category_change_expense_to_income_updates_kind(tmp_path):
|
||||
database = Database(tmp_path / "finance.db")
|
||||
await database.initialize()
|
||||
now = datetime(2026, 7, 13, 12, tzinfo=ZoneInfo("UTC"))
|
||||
await database.upsert_user(
|
||||
user_id=10, chat_id=10, username=None, full_name="Витя",
|
||||
timezone="UTC", report_time="21:00", now=now,
|
||||
)
|
||||
await database.create_family(10, now)
|
||||
|
||||
tx = await database.add_transaction(
|
||||
user_id=10, amount_kopecks=30_000, description="покупка",
|
||||
kind=TransactionKind.EXPENSE, category=Category.OTHER,
|
||||
debt_effect=1, occurred_at=now + timedelta(seconds=1),
|
||||
)
|
||||
|
||||
salary_cat = next(
|
||||
c for c in await database.budget_categories(10)
|
||||
if c.system_key == "salary_vitya"
|
||||
)
|
||||
changed = await database.update_transaction_budget_category(tx.id, 10, salary_cat.id)
|
||||
assert changed
|
||||
|
||||
updated_tx = await database.get_transaction(tx.id, 10)
|
||||
assert updated_tx.kind is TransactionKind.INCOME
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_budget_category_kind_visible_in_list(tmp_path):
|
||||
database = Database(tmp_path / "finance.db")
|
||||
await database.initialize()
|
||||
now = datetime(2026, 7, 13, 12, tzinfo=ZoneInfo("UTC"))
|
||||
await database.upsert_user(
|
||||
user_id=10, chat_id=10, username=None, full_name="Витя",
|
||||
timezone="UTC", report_time="21:00", now=now,
|
||||
)
|
||||
await database.create_family(10, now)
|
||||
|
||||
categories = await database.budget_categories(10)
|
||||
for cat in categories:
|
||||
assert cat.kind in ("expense", "income")
|
||||
|
||||
salary_root = next(c for c in categories if c.system_key == "salary")
|
||||
assert salary_root.kind == "income"
|
||||
|
||||
car = next(c for c in categories if c.system_key == "car")
|
||||
assert car.kind == "expense"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_transaction_default_debt_effect_from_kind(tmp_path):
|
||||
database = Database(tmp_path / "finance.db")
|
||||
await database.initialize()
|
||||
now = datetime(2026, 7, 13, 12, tzinfo=ZoneInfo("UTC"))
|
||||
await database.upsert_user(
|
||||
user_id=10, chat_id=10, username=None, full_name="Витя",
|
||||
timezone="UTC", report_time="21:00", now=now,
|
||||
)
|
||||
|
||||
exp = await database.add_transaction(
|
||||
user_id=10, amount_kopecks=10_000, description="кофе",
|
||||
kind=TransactionKind.EXPENSE, category=Category.PRODUCTS,
|
||||
occurred_at=now,
|
||||
)
|
||||
assert exp.debt_effect == 1
|
||||
|
||||
inc = await database.add_transaction(
|
||||
user_id=10, amount_kopecks=20_000, description="подработка",
|
||||
kind=TransactionKind.INCOME, category=Category.INCOME,
|
||||
occurred_at=now,
|
||||
)
|
||||
assert inc.debt_effect == -1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spouse_can_read_and_recategorize_family_transaction(tmp_path):
|
||||
database = Database(tmp_path / "finance.db")
|
||||
await database.initialize()
|
||||
now = datetime(2026, 7, 13, 12, tzinfo=ZoneInfo("UTC"))
|
||||
for user_id, name in ((10, "Витя"), (20, "Яна"), (30, "Посторонний")):
|
||||
await database.upsert_user(
|
||||
user_id=user_id, chat_id=user_id, username=None, full_name=name,
|
||||
timezone="UTC", report_time="21:00", now=now,
|
||||
)
|
||||
family = await database.create_family(10, now)
|
||||
await database.request_family_join(20, family.invite_code, now)
|
||||
await database.resolve_family_join(
|
||||
owner_user_id=10, candidate_user_id=20, approve=True, now=now,
|
||||
)
|
||||
tx = await database.add_transaction(
|
||||
user_id=10, amount_kopecks=10_000, description="покупка",
|
||||
kind=TransactionKind.EXPENSE, category=Category.OTHER, occurred_at=now,
|
||||
)
|
||||
salary = next(
|
||||
category for category in await database.budget_categories(20)
|
||||
if category.system_key == "salary_yana"
|
||||
)
|
||||
|
||||
spouse_view = await database.get_transaction(tx.id, 20)
|
||||
assert spouse_view is not None
|
||||
assert spouse_view.id == tx.id
|
||||
assert await database.get_transaction(tx.id, 30) is None
|
||||
assert not await database.update_transaction_budget_category(tx.id, 30, salary.id)
|
||||
assert await database.update_transaction_budget_category(tx.id, 20, salary.id)
|
||||
updated = await database.get_transaction(tx.id, 10)
|
||||
assert updated is not None
|
||||
assert updated.kind is TransactionKind.INCOME
|
||||
assert updated.debt_effect == -1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_minus_keeps_reducing_debt_after_recategorization(tmp_path):
|
||||
database = Database(tmp_path / "finance.db")
|
||||
await database.initialize()
|
||||
now = datetime(2026, 7, 13, 12, tzinfo=ZoneInfo("UTC"))
|
||||
await database.upsert_user(
|
||||
user_id=10, chat_id=10, username=None, full_name="Витя",
|
||||
timezone="UTC", report_time="21:00", now=now,
|
||||
)
|
||||
await database.create_family(10, now)
|
||||
await database.set_family_debt(10, 100_000, now)
|
||||
tx = await database.add_transaction(
|
||||
user_id=10, amount_kopecks=20_000, description="рабочий закуп",
|
||||
kind=TransactionKind.EXPENSE, category=Category.OTHER,
|
||||
debt_effect=-1, debt_effect_override=-1,
|
||||
occurred_at=now + timedelta(seconds=1),
|
||||
)
|
||||
car = next(
|
||||
category for category in await database.budget_categories(10)
|
||||
if category.system_key == "car"
|
||||
)
|
||||
|
||||
assert await database.update_transaction_budget_category(tx.id, 10, car.id)
|
||||
updated = await database.get_transaction(tx.id, 10)
|
||||
assert updated is not None
|
||||
assert updated.kind is TransactionKind.EXPENSE
|
||||
assert updated.debt_effect == -1
|
||||
assert await database.family_debt(10) == 80_000
|
||||
|
|
|
|||
|
|
@ -39,3 +39,51 @@ def test_format_money():
|
|||
)
|
||||
def test_parse_money_amount(text, kopecks):
|
||||
assert parse_money_amount(text) == kopecks
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "kopecks", "description", "kind", "debt_effect"),
|
||||
[
|
||||
("-5000 ЗП", 500_000, "ЗП", TransactionKind.INCOME, -1),
|
||||
("-5000 зарплата", 500_000, "зарплата", TransactionKind.INCOME, -1),
|
||||
("-5000 рабочий закуп", 500_000, "рабочий закуп", TransactionKind.EXPENSE, -1),
|
||||
("-3000 бензин", 300_000, "бензин", TransactionKind.EXPENSE, -1),
|
||||
("3000 бензин", 300_000, "бензин", TransactionKind.EXPENSE, 1),
|
||||
("+3000 бензин", 300_000, "бензин", TransactionKind.EXPENSE, 1),
|
||||
("+ 5000 зарплата", 500_000, "зарплата", TransactionKind.INCOME, -1),
|
||||
("5000 зарплата", 500_000, "зарплата", TransactionKind.INCOME, -1),
|
||||
("доход 5000 подработка", 500_000, "подработка", TransactionKind.INCOME, -1),
|
||||
],
|
||||
)
|
||||
def test_parse_transaction_debt_effect(text, kopecks, description, kind, debt_effect):
|
||||
parsed = parse_transaction(text)
|
||||
assert parsed.amount_kopecks == kopecks
|
||||
assert parsed.description == description
|
||||
assert parsed.kind is kind
|
||||
assert parsed.debt_effect == debt_effect
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "kind"),
|
||||
[
|
||||
("5000 ЗП", TransactionKind.INCOME),
|
||||
("5000 зарплата", TransactionKind.INCOME),
|
||||
("+ ЗП 5000", TransactionKind.INCOME),
|
||||
("расход 5000 зарплата", TransactionKind.INCOME),
|
||||
],
|
||||
)
|
||||
def test_salary_recognised_as_income(text, kind):
|
||||
parsed = parse_transaction(text)
|
||||
assert parsed.kind is kind
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["- доход 500 премия", "- приход 1000 подработка"])
|
||||
def test_rejects_negative_income_conflict(text):
|
||||
with pytest.raises(ParseError, match="Доход не может быть отрицательным"):
|
||||
parse_transaction(text)
|
||||
|
||||
|
||||
def test_amount_kopecks_always_positive():
|
||||
parsed = parse_transaction("-5000 ЗП")
|
||||
assert parsed.amount_kopecks > 0
|
||||
assert parsed.amount_kopecks == 500_000
|
||||
|
|
|
|||
Loading…
Reference in a new issue