From 490b0f7faa58483db04fde965279d75c1de1521a Mon Sep 17 00:00:00 2001
From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com>
Date: Fri, 17 Jul 2026 12:54:37 +0700
Subject: [PATCH] feat: add family debt accounting and income categories
---
README.md | 8 +-
app/database.py | 159 ++++++++++---
app/domain.py | 4 +
app/handlers.py | 32 ++-
app/keyboards.py | 22 +-
app/messages.py | 10 +-
app/parser.py | 25 +-
tests/test_database.py | 516 ++++++++++++++++++++++++++++++++++++++++-
tests/test_parser.py | 48 ++++
9 files changed, 777 insertions(+), 47 deletions(-)
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