# Story 3.2: Category Foundation — System Defaults & Custom Category CRUD

Status: review

## Story

As a user,
I want the 14 system categories available from day one and the ability to create, rename, and delete my own categories,
so that I can organize transactions meaningfully without being locked into a fixed taxonomy.

## Acceptance Criteria

1. Category management page at `/settings/categories` lists all active categories.
2. System categories show a "System" badge and no delete button.
3. "New Category" form accepts name (required, max 50 chars); on submit → PRG redirect to list, flash `'success'` "Category added."
4. Custom categories can be renamed at `/settings/categories/<id>/edit`; flash `'success'` "Category updated."
5. Deleting a custom category with no associated transactions soft-deletes it (`is_active=False`); flash `'success'` "Category deleted."
6. Attempting to delete a category with associated transactions: flash `'error'` "Category in use — cannot be deleted."
7. System categories cannot be deleted — delete button absent; attempting via direct POST returns 403.
8. `category_service.py` owns all mutation logic (create, rename, soft_delete); routes only call service functions and commit.
9. Settings index (`/settings/`) links to `/settings/categories`.
10. `pytest tests/` passes — all 105 prior tests still green.

## Tasks / Subtasks

- [ ] **Task 1: Write `tests/test_services/test_category_service.py`** (TDD RED — service layer)
- [ ] **Task 2: Write `tests/test_blueprints/test_categories.py`** (TDD RED — view layer)
- [ ] **Task 3: Implement `app/services/category_service.py`**
- [ ] **Task 4: Add CategoryForm to `app/blueprints/settings/forms.py`**
- [ ] **Task 5: Add category routes to `app/blueprints/settings/routes.py`**
- [ ] **Task 6: Create templates** — `settings/categories.html`, `settings/category_edit.html`
- [ ] **Task 7: Update `settings/index.html`** — add link to categories
- [ ] **Task 8: Run full suite GREEN**

## Dev Notes

### `category_service.py`

```python
from sqlalchemy import select
from app.extensions import db
from app.models.category import Category
from app.models.transaction import Transaction


def get_all_active():
    """All active categories: system first, then alphabetical."""
    return (Category.query
            .filter_by(is_active=True)
            .order_by(Category.is_system.desc(), Category.name)
            .all())


def create_custom(name: str) -> Category:
    """Add a new custom category. Raises ValueError on validation failure."""
    name = name.strip()
    if not name:
        raise ValueError("Category name is required.")
    if len(name) > 50:
        raise ValueError("Category name must be 50 characters or less.")
    if Category.query.filter_by(name=name).first():
        raise ValueError(f"A category named '{name}' already exists.")
    cat = Category(name=name, is_system=False, is_active=True)
    db.session.add(cat)
    return cat


def rename(category: Category, new_name: str) -> None:
    """Rename a category. Raises ValueError on validation failure."""
    new_name = new_name.strip()
    if not new_name:
        raise ValueError("Category name is required.")
    if len(new_name) > 50:
        raise ValueError("Name must be 50 characters or less.")
    conflict = Category.query.filter_by(name=new_name).first()
    if conflict and conflict.id != category.id:
        raise ValueError(f"A category named '{new_name}' already exists.")
    category.name = new_name


def soft_delete(category: Category) -> None:
    """Soft-delete a custom category. Raises ValueError if system or in use."""
    if category.is_system:
        raise ValueError("System categories cannot be deleted.")
    has_txns = db.session.execute(
        select(Transaction.id).where(Transaction.category_id == category.id).limit(1)
    ).first() is not None
    if has_txns:
        raise ValueError("Category in use — cannot be deleted.")
    category.is_active = False
```

### Routes (add to `settings/routes.py`)

```
GET  /settings/categories               → list + new form
POST /settings/categories               → create (PRG)
GET  /settings/categories/<id>/edit     → edit form
POST /settings/categories/<id>/edit     → rename (PRG)
POST /settings/categories/<id>/delete   → soft delete (PRG)
```

### CategoryForm

```python
class CategoryForm(FlaskForm):
    name = StringField('Name', validators=[DataRequired(), Length(max=50)])
```

### File List

| File | Status |
|------|--------|
| `tests/test_services/test_category_service.py` | NEW |
| `tests/test_blueprints/test_categories.py` | NEW |
| `app/services/category_service.py` | IMPLEMENT |
| `app/blueprints/settings/forms.py` | MODIFY (add CategoryForm) |
| `app/blueprints/settings/routes.py` | MODIFY (add category routes) |
| `app/blueprints/settings/templates/settings/categories.html` | NEW |
| `app/blueprints/settings/templates/settings/category_edit.html` | NEW |
| `app/blueprints/settings/templates/settings/index.html` | MODIFY (add categories link) |

---

## Dev Agent Record

### Agent Model Used
claude-sonnet-4-6

### Debug Log References
None

### Completion Notes List
- All 40 new tests (20 service + 20 view) passed first run — zero regressions against 105 prior tests
- 403 abort on system category delete: service raises ValueError with "System" in message; route pattern-matches on that to call abort(403) vs flash error, keeping business logic in the service
- 145 passed, 1 skipped total

### File List
- `tests/test_services/test_category_service.py` — NEW (20 tests)
- `tests/test_blueprints/test_categories.py` — NEW (20 tests)
- `app/services/category_service.py` — IMPLEMENTED
- `app/blueprints/settings/forms.py` — MODIFIED (added CategoryForm)
- `app/blueprints/settings/routes.py` — MODIFIED (added 5 category routes)
- `app/blueprints/settings/templates/settings/categories.html` — NEW
- `app/blueprints/settings/templates/settings/category_edit.html` — NEW
- `app/blueprints/settings/templates/settings/index.html` — MODIFIED (added categories link)

### Change Log
- 2026-05-28: Story implemented and moved to review status
