feat: add family debt accounting and income categories

This commit is contained in:
ochenstarik-ui 2026-07-17 12:54:37 +07:00
parent 1f071cdaa6
commit 490b0f7faa
9 changed files with 777 additions and 47 deletions

View file

@ -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
### Категории и подкатегории
В семейном бюджете доступна кнопка **«🗂 Категории»**. Категории общие для обоих супругов: каждый участник может добавить новую основную категорию или подкатегорию.
В семейном бюджете доступна кнопка **«🗂 Категории»**. Категории общие для обоих супругов: каждый участник может добавить новую основную категорию или подкатегорию. При создании основной категории выбирается тип **«Расход»** или **«Доход»**; подкатегория наследует тип родителя. Перенос операции в категорию другого типа меняет её тип и влияние на долг.
При создании бюджета бот автоматически добавляет базовое дерево, например:

View file

@ -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

View file

@ -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:

View file

@ -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),
)

View file

@ -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])

View file

@ -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:

View file

@ -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,
)

View file

@ -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

View file

@ -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