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.api.deps import get_session
from app.models.category import Category from app.models.category import Category
from app.models.enums import CategoryKind from app.models.enums import CategoryKind
from app.models.transaction import Transaction, TransactionSplit
from app.schemas.category import CategoryCreate, CategoryRead, CategoryUpdate from app.schemas.category import CategoryCreate, CategoryRead, CategoryUpdate
router = APIRouter(prefix="/categories", tags=["categories"]) 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: if category is None:
raise HTTPException(status_code=404, detail="Category not found") raise HTTPException(status_code=404, detail="Category not found")
if category.is_default: 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.delete(category)
await session.commit() await session.commit()

View File

@@ -1,8 +1,12 @@
"""Category subcategories (parent_id): creation rules and the one-level-deep """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 httpx import AsyncClient
from tests.helpers import txn_payload as _txn
async def test_create_subcategory_under_top_level_category(client: AsyncClient, categories): async def test_create_subcategory_under_top_level_category(client: AsyncClient, categories):
groceries = categories["Groceries"]["id"] 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): 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 # A default category with transactions can't be deleted (separate rule,
# fresh, non-default parent so this test isolates the parent/child 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 = await client.post("/categories", json={"name": "Custom Parent", "kind": "expense", "color": "#e34948"})
parent_id = parent.json()["id"] parent_id = parent.json()["id"]
created = await client.post( 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") refetched = await client.get("/categories")
child = next(c for c in refetched.json() if c["id"] == child_id) child = next(c for c in refetched.json() if c["id"] == child_id)
assert child["parent_id"] is None 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

View File

@@ -49,10 +49,8 @@ function CategoryRow({ category, indented, onEdit, onDelete }: RowProps) {
<button <button
type="button" type="button"
aria-label={t("common.delete")} aria-label={t("common.delete")}
title={category.is_default ? t("category.defaultCannotDelete") : undefined}
disabled={category.is_default}
onClick={() => onDelete(category)} onClick={() => onDelete(category)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-text-muted" className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
> >
<Trash2 size={15} /> <Trash2 size={15} />
</button> </button>

View File

@@ -428,7 +428,7 @@ const ru = {
"category.incomeSectionTitle": "Категории доходов", "category.incomeSectionTitle": "Категории доходов",
"category.empty": "Категорий пока нет.", "category.empty": "Категорий пока нет.",
"category.defaultBadge": "стандартная", "category.defaultBadge": "стандартная",
"category.defaultCannotDelete": "Стандартные категории нельзя удалить", "category.defaultDeleteBlocked": "Стандартную категорию можно удалить только после того, как у неё не останется транзакций.",
"category.confirmDelete": "Удалить категорию «{{name}}»?\n\nЕё транзакции останутся без категории.", "category.confirmDelete": "Удалить категорию «{{name}}»?\n\nЕё транзакции останутся без категории.",
"category.kind.expense": "Расход", "category.kind.expense": "Расход",
"category.kind.income": "Доход", "category.kind.income": "Доход",
@@ -910,7 +910,7 @@ const en: Record<keyof typeof ru, string> = {
"category.incomeSectionTitle": "Income categories", "category.incomeSectionTitle": "Income categories",
"category.empty": "No categories yet.", "category.empty": "No categories yet.",
"category.defaultBadge": "default", "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.confirmDelete": "Delete the category “{{name}}”?\n\nIts transactions will be left uncategorized.",
"category.kind.expense": "Expense", "category.kind.expense": "Expense",
"category.kind.income": "Income", "category.kind.income": "Income",

View File

@@ -38,12 +38,15 @@ export function CategoriesPage() {
setModalOpen(true); setModalOpen(true);
} }
function handleDelete(category: Category) { async function handleDelete(category: Category) {
// Default categories are protected server-side too (400) — the list if (!window.confirm(t("category.confirmDelete", { name: category.name }))) return;
// already disables the delete button for them, this is just a guard. try {
if (category.is_default) return; await deleteCategory.mutateAsync(category.id);
if (window.confirm(t("category.confirmDelete", { name: category.name }))) { } catch {
deleteCategory.mutate(category.id); // 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"));
} }
} }