diff --git a/README.md b/README.md index 2fa91c2..640b749 100644 --- a/README.md +++ b/README.md @@ -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 ### Категории и подкатегории -В семейном бюджете доступна кнопка **«🗂 Категории»**. Категории общие для обоих супругов: каждый участник может добавить новую основную категорию или подкатегорию. +В семейном бюджете доступна кнопка **«🗂 Категории»**. Категории общие для обоих супругов: каждый участник может добавить новую основную категорию или подкатегорию. При создании основной категории выбирается тип **«Расход»** или **«Доход»**; подкатегория наследует тип родителя. Перенос операции в категорию другого типа меняет её тип и влияние на долг. При создании бюджета бот автоматически добавляет базовое дерево, например: diff --git a/app/database.py b/app/database.py index 41d9415..3fb4da8 100644 --- a/app/database.py +++ b/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,12 +949,38 @@ 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 def _scope_clause(alias: str) -> str: return f"""( diff --git a/app/domain.py b/app/domain.py index dba44e6..33b591c 100644 --- a/app/domain.py +++ b/app/domain.py @@ -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: diff --git a/app/handlers.py b/app/handlers.py index d4e463a..e329686 100644 --- a/app/handlers.py +++ b/app/handlers.py @@ -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 = ["🗂 Категории семейного бюджета", ""] 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( "Введите название новой категории, например Здоровье.\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), ) diff --git a/app/keyboards.py b/app/keyboards.py index 584eb3b..6a88c8c 100644 --- a/app/keyboards.py +++ b/app/keyboards.py @@ -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( - InlineKeyboardButton(text="Изменить категорию", callback_data=f"choose:{transaction_id}") - ) + # 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]) diff --git a/app/messages.py b/app/messages.py index 1bf7ec3..ea2a1cb 100644 --- a/app/messages.py +++ b/app/messages.py @@ -30,9 +30,10 @@ WELCOME = """Привет! Я помогу держать деньги под 467 ярче 3000 бензин -+ 85000 зарплата +85000 зарплата +-5000 рабочий закуп -Обычная запись — расход. Знак + или слово доход — поступление. +Обычная запись — расход; слова зп/зарплата или доход — поступление. Знак принудительно уменьшает общий долг. Каждый вечер я пришлю короткий итог дня и месяца. А в разделе «Семья» можно подключить общий бюджет с супругом. Начнём?""" @@ -42,9 +43,10 @@ HELP_TEXT = """Как записывать операции Расход: 1250 продукты 3 500 ремонт машины +-5000 рабочий закуп — расход, который уменьшит общий долг Доход: -+ 75000 зарплата +75000 зарплата доход 5000 подработка Можно вводить копейки: 199,90 кофе. @@ -53,7 +55,7 @@ HELP_TEXT = """Как записывать операции В семейном режиме кнопка «Общий долг» включает автоматический расчёт: расходы увеличивают долг, доходы уменьшают. -Через «Категории» можно добавить свои категории и подкатегории. Кнопка «Изменить категорию» переносит любую запись, в том числе из «Другое».""" +Через «Категории» можно добавить свои категории доходов и расходов и подкатегории. Кнопка «Изменить категорию» переносит любую запись, в том числе из «Другое».""" def transaction_confirmation(transaction: Transaction, debt: int | None = None) -> str: diff --git a/app/parser.py b/app/parser.py index af0a3af..e1bc6ce 100644 --- a/app/parser.py +++ b/app/parser.py @@ -19,9 +19,22 @@ _TRANSACTION_RE = re.compile( flags=re.IGNORECASE, ) +_DESCRIPTION_FIRST_RE = re.compile( + r"^\s*(?P[+-])\s*(?Pзп|зарплата)\s+" + r"(?P\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, ) diff --git a/tests/test_database.py b/tests/test_database.py index 56c9b61..b0af700 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -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 diff --git a/tests/test_parser.py b/tests/test_parser.py index 4f3ece4..a4598c6 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -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