From da1c7b5d63bc8f6bf92ccb8b423854004c7afa73 Mon Sep 17 00:00:00 2001 From: ZProger Date: Sun, 30 Aug 2026 19:49:21 +0300 Subject: [PATCH] feat: allow deleting default categories once they have no transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше стандартные категории нельзя было удалить вообще. Теперь можно — если у категории нет ни одной обычной транзакции, ни строки сплита с ней. Пользовательские категории удаляются как и раньше, без проверок. - delete_category() проверяет Transaction.category_id и TransactionSplit.category_id для стандартных категорий перед удалением. - Кнопка удаления в списке категорий больше не блокируется заранее для стандартных — бэкенд решает, бейдж «стандартная» остался чисто информационным. - При отказе (400) показывается понятное сообщение вместо тихого игнорирования клика. --- backend/app/api/routes/categories.py | 18 ++++- backend/tests/test_categories.py | 73 ++++++++++++++++++- .../components/categories/CategoryList.tsx | 4 +- frontend/src/lib/i18n.ts | 4 +- frontend/src/pages/CategoriesPage.tsx | 15 ++-- 5 files changed, 99 insertions(+), 15 deletions(-) diff --git a/backend/app/api/routes/categories.py b/backend/app/api/routes/categories.py index 60ee66d..b86c398 100644 --- a/backend/app/api/routes/categories.py +++ b/backend/app/api/routes/categories.py @@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_session from app.models.category import Category from app.models.enums import CategoryKind +from app.models.transaction import Transaction, TransactionSplit from app.schemas.category import CategoryCreate, CategoryRead, CategoryUpdate router = APIRouter(prefix="/categories", tags=["categories"]) @@ -75,6 +76,21 @@ async def delete_category(category_id: int, session: AsyncSession = Depends(get_ if category is None: raise HTTPException(status_code=404, detail="Category not found") if category.is_default: - raise HTTPException(status_code=400, detail="Default categories cannot be deleted") + # A default (seeded) category can be removed once it's unused — but + # never while transactions still point at it, or a whole year's + # worth of history would silently lose its category (the FK is + # ON DELETE SET NULL, so nothing would error, it would just vanish + # from every report). A custom category has no such guard: the user + # created it and can freely delete it, same as before. + has_transaction = ( + await session.execute(select(Transaction.id).where(Transaction.category_id == category_id).limit(1)) + ).first() + has_split = ( + await session.execute(select(TransactionSplit.id).where(TransactionSplit.category_id == category_id).limit(1)) + ).first() + if has_transaction is not None or has_split is not None: + raise HTTPException( + status_code=400, detail="Default categories can only be deleted once they have no transactions" + ) await session.delete(category) await session.commit() diff --git a/backend/tests/test_categories.py b/backend/tests/test_categories.py index 56a5022..a829721 100644 --- a/backend/tests/test_categories.py +++ b/backend/tests/test_categories.py @@ -1,8 +1,12 @@ """Category subcategories (parent_id): creation rules and the one-level-deep -invariant enforced in api/routes/categories.py. +invariant enforced in api/routes/categories.py. Also default-category +deletion: a seeded category can be removed once nothing references it, but +never while it still has transactions. """ from httpx import AsyncClient +from tests.helpers import txn_payload as _txn + async def test_create_subcategory_under_top_level_category(client: AsyncClient, categories): groceries = categories["Groceries"]["id"] @@ -52,8 +56,9 @@ async def test_a_category_with_subcategories_cannot_itself_become_a_subcategory( async def test_deleting_a_parent_leaves_children_as_top_level(client: AsyncClient, categories): - # A default category can't be deleted at all (separate rule) — use a - # fresh, non-default parent so this test isolates the parent/child rule. + # A default category with transactions can't be deleted (separate rule, + # tested below) — use a fresh, non-default parent so this test isolates + # the parent/child rule. parent = await client.post("/categories", json={"name": "Custom Parent", "kind": "expense", "color": "#e34948"}) parent_id = parent.json()["id"] created = await client.post( @@ -67,3 +72,65 @@ async def test_deleting_a_parent_leaves_children_as_top_level(client: AsyncClien refetched = await client.get("/categories") child = next(c for c in refetched.json() if c["id"] == child_id) assert child["parent_id"] is None + + +async def test_default_category_with_no_transactions_can_be_deleted(client: AsyncClient, categories): + groceries = categories["Groceries"]["id"] + assert categories["Groceries"]["is_default"] is True + + resp = await client.delete(f"/categories/{groceries}") + assert resp.status_code == 204 + + refetched = await client.get("/categories") + assert groceries not in [c["id"] for c in refetched.json()] + + +async def test_default_category_with_a_transaction_cannot_be_deleted(client: AsyncClient, account_id, categories): + groceries = categories["Groceries"]["id"] + await client.post("/transactions", json=_txn(account_id, category_id=groceries)) + + resp = await client.delete(f"/categories/{groceries}") + assert resp.status_code == 400 + + refetched = await client.get("/categories") + assert groceries in [c["id"] for c in refetched.json()] + + +async def test_default_category_used_only_by_a_split_line_cannot_be_deleted(client: AsyncClient, account_id, categories): + groceries = categories["Groceries"]["id"] + sweets = ( + await client.post( + "/categories", json={"name": "Sweets", "kind": "expense", "color": "#7a869a", "parent_id": groceries} + ) + ).json()["id"] + await client.post( + "/transactions", + json=_txn( + account_id, + amount="100.00", + category_id=None, + splits=[ + {"category_id": groceries, "amount": "70.00"}, + {"category_id": sweets, "amount": "30.00"}, + ], + ), + ) + + resp = await client.delete(f"/categories/{groceries}") + assert resp.status_code == 400 + + +async def test_default_category_becomes_deletable_once_its_transaction_is_removed( + client: AsyncClient, account_id, categories +): + groceries = categories["Groceries"]["id"] + created = await client.post("/transactions", json=_txn(account_id, category_id=groceries)) + txn_id = created.json()["id"] + + blocked = await client.delete(f"/categories/{groceries}") + assert blocked.status_code == 400 + + assert (await client.delete(f"/transactions/{txn_id}")).status_code == 204 + + resp = await client.delete(f"/categories/{groceries}") + assert resp.status_code == 204 diff --git a/frontend/src/components/categories/CategoryList.tsx b/frontend/src/components/categories/CategoryList.tsx index 85fe52d..0a6dc0e 100644 --- a/frontend/src/components/categories/CategoryList.tsx +++ b/frontend/src/components/categories/CategoryList.tsx @@ -49,10 +49,8 @@ function CategoryRow({ category, indented, onEdit, onDelete }: RowProps) { diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index e7f4118..e6d4f09 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -428,7 +428,7 @@ const ru = { "category.incomeSectionTitle": "Категории доходов", "category.empty": "Категорий пока нет.", "category.defaultBadge": "стандартная", - "category.defaultCannotDelete": "Стандартные категории нельзя удалить", + "category.defaultDeleteBlocked": "Стандартную категорию можно удалить только после того, как у неё не останется транзакций.", "category.confirmDelete": "Удалить категорию «{{name}}»?\n\nЕё транзакции останутся без категории.", "category.kind.expense": "Расход", "category.kind.income": "Доход", @@ -910,7 +910,7 @@ const en: Record = { "category.incomeSectionTitle": "Income categories", "category.empty": "No categories yet.", "category.defaultBadge": "default", - "category.defaultCannotDelete": "Default categories cannot be deleted", + "category.defaultDeleteBlocked": "A default category can only be deleted once it has no transactions left.", "category.confirmDelete": "Delete the category “{{name}}”?\n\nIts transactions will be left uncategorized.", "category.kind.expense": "Expense", "category.kind.income": "Income", diff --git a/frontend/src/pages/CategoriesPage.tsx b/frontend/src/pages/CategoriesPage.tsx index 98736bf..a0eca74 100644 --- a/frontend/src/pages/CategoriesPage.tsx +++ b/frontend/src/pages/CategoriesPage.tsx @@ -38,12 +38,15 @@ export function CategoriesPage() { setModalOpen(true); } - function handleDelete(category: Category) { - // Default categories are protected server-side too (400) — the list - // already disables the delete button for them, this is just a guard. - if (category.is_default) return; - if (window.confirm(t("category.confirmDelete", { name: category.name }))) { - deleteCategory.mutate(category.id); + async function handleDelete(category: Category) { + if (!window.confirm(t("category.confirmDelete", { name: category.name }))) return; + try { + await deleteCategory.mutateAsync(category.id); + } catch { + // The only way a delete 400s is a default category that still has + // transactions pointing at it (api/routes/categories.py) — a custom + // category has no such guard and always succeeds. + window.alert(t("category.defaultDeleteBlocked")); } }