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"));
}
}