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