feat: allow deleting default categories once they have no transactions

Раньше стандартные категории нельзя было удалить вообще. Теперь можно —
если у категории нет ни одной обычной транзакции, ни строки сплита с
ней. Пользовательские категории удаляются как и раньше, без проверок.

- delete_category() проверяет Transaction.category_id и
  TransactionSplit.category_id для стандартных категорий перед
  удалением.
- Кнопка удаления в списке категорий больше не блокируется заранее для
  стандартных — бэкенд решает, бейдж «стандартная» остался чисто
  информационным.
- При отказе (400) показывается понятное сообщение вместо тихого
  игнорирования клика.
This commit is contained in:
ZProger
2026-08-30 19:49:21 +03:00
parent dbae7938da
commit da1c7b5d63
5 changed files with 99 additions and 15 deletions

View File

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

View File

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