From da5ccf3b67e771f8de7c4da56d6293c75c7462e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Pe=C3=B1a?= Date: Sat, 25 Jul 2026 20:06:03 -0400 Subject: [PATCH 1/4] feat: manage categories -- edit built-ins, add your own, backfill Categories were effectively read-only: users could create new ones but could not touch the built-in catalog, so adding a keyword to e.g. Vivienda was impossible. Built-in categories are global rows shared by every user, so they cannot be edited in place. A user's edit now becomes a private CategoryOverride (rename) plus forked CategoryPattern rows -- once forked, only their keywords apply and the shared catalog is ignored for them. Reset drops both. Transferences stored their category as a built-in enum, so no user keyword could ever apply to them. They now point at the categories catalog with a category_id FK, backfilled from the enum, mirroring expenses. - PATCH /categories/{id}: rename and/or replace keywords - POST /categories/{id}/reset: back to catalog defaults - DELETE /categories/{id}: custom only; its transactions move to "Otros" - POST /categories/recategorize: re-apply keywords to stored transactions, leaving unmatched rows on their current category so manual picks survive - POST /categories also accepts a keyword list New /categories page (linked from settings and the navbar) to edit names and keyword chips, reset built-ins, delete custom ones and run the backfill. Migrations verified up/down/up on Postgres with legacy rows. --- CLAUDE.md | 2 +- ...4d7e1b9a250_add_user_category_overrides.py | 111 ++++ ...b8f1c07e42_add_transference_category_id.py | 130 +++++ src/api/server.py | 164 ++++-- src/db/categories.py | 407 +++++++++++++- src/db/models.py | 42 +- src/parsers/parser.py | 5 +- src/parsers/transaction_mapper.py | 4 +- tests/test_category_management.py | 449 ++++++++++++++++ tests/test_email_task.py | 26 +- .../components/categories/CategoryManager.tsx | 496 ++++++++++++++++++ web/app/components/expenses/ExpenseForm.tsx | 2 +- web/app/components/layout/Navbar.tsx | 6 + web/app/lib/api.ts | 25 + web/app/routes.ts | 1 + web/app/routes/categories.tsx | 20 + web/app/routes/profile.tsx | 20 + web/app/types/models.ts | 20 + 18 files changed, 1851 insertions(+), 79 deletions(-) create mode 100644 alembic/versions/c4d7e1b9a250_add_user_category_overrides.py create mode 100644 alembic/versions/d5b8f1c07e42_add_transference_category_id.py create mode 100644 tests/test_category_management.py create mode 100644 web/app/components/categories/CategoryManager.tsx create mode 100644 web/app/routes/categories.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 58b65f8..4af37c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ The reference deployment is live at https://finitum.app (Lightsail VPS, deployed - **Backend**: FastAPI (`src/api/server.py`), PostgreSQL + Alembic (`src/db/`, `alembic/`), Redis (dedupe + Gmail-confirmation capture). - **Ingestion**: `src/email_service/ingest.py` -- resolves user by `ingest_token` from the `u-@` recipient, HMAC-verifies `X-Finitum-Signature` (`INGEST_WEBHOOK_SECRET`), dedupes on `Message-ID`, auto-captures Gmail forwarding-confirmation links/codes into Redis for one-click setup. Worker lives in `infra/email-worker/`. - **Parsers**: fully data-driven. All bank logic lives in `src/parsers/regex.json` (per-bank: `remitents` sender allowlist, `subject` classification patterns, `body` extraction regexes). Engine: `src/parsers/parser.py` (`EmailParser`, `BankPatterns.from_json`). The bank is a per-user setting (`User.bank`); there is no content-based bank auto-detection. `GET /banks` derives the bank list from `regex.json` keys. -- **Categories**: keyword matching in `src/parsers/base.py` from root `categories.json`; slugs/labels registry in `src/category_catalog.py`; Spanish overrides in `category_labels.es.json`; per-user custom categories via `POST /categories` + `src/db/categories.py`. +- **Categories**: keyword matching in `src/parsers/base.py` from root `categories.json`; slugs/labels registry in `src/category_catalog.py`; Spanish overrides in `category_labels.es.json`. `categories.json` only seeds the shared catalog -- at runtime everything (including transference categorization) resolves through `src/db/categories.py` against the `categories` / `category_patterns` / `category_overrides` tables. Builtin categories are global rows, so a user editing one gets a private `CategoryOverride` (rename) plus forked `CategoryPattern` rows; `POST /categories/{id}/reset` drops both. `POST /categories/recategorize` re-applies keywords to stored expenses and transferences, leaving unmatched rows on their current category. UI lives at `web/app/routes/categories.tsx`. - **Frontend**: React Router v7 + TypeScript + Tailwind + Bun in `web/` (file routes under `web/app/routes/`). `profile.tsx` holds the up-to-date forwarding-setup UX; `home.tsx` and `guide.tsx` still carry stale OAuth-era messaging. ## Adding a bank (the core contributor flow) diff --git a/alembic/versions/c4d7e1b9a250_add_user_category_overrides.py b/alembic/versions/c4d7e1b9a250_add_user_category_overrides.py new file mode 100644 index 0000000..6d978a8 --- /dev/null +++ b/alembic/versions/c4d7e1b9a250_add_user_category_overrides.py @@ -0,0 +1,111 @@ +"""add per-user category overrides and pattern ownership + +Builtin categories are shared rows, so a user cannot edit them in place. This +migration adds: + +* ``category_patterns.user_id`` -- NULL for the shared catalog rows, set when + the keyword belongs to a user (their own category, or their fork of a + builtin one). +* ``category_overrides`` -- a user's rename of a builtin category and the flag + marking that they took over its keyword set. + +Revision ID: c4d7e1b9a250 +Revises: b3f1c2a4d5e6 +Create Date: 2026-07-25 + +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "c4d7e1b9a250" +down_revision: str | Sequence[str] | None = "b3f1c2a4d5e6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +LEGACY_PATTERN_INDEX = "ix_category_patterns_category_id_pattern" +PATTERN_INDEX = "ix_category_patterns_category_id_user_id_pattern" + + +def _index_names(table_name: str) -> set[str]: + inspector = sa.inspect(op.get_bind()) + return {index["name"] for index in inspector.get_indexes(table_name)} + + +def upgrade() -> None: + """Add pattern ownership and the per-user override table.""" + op.add_column("category_patterns", sa.Column("user_id", sa.Integer(), nullable=True)) + op.create_foreign_key( + "fk_category_patterns_user_id_users", + "category_patterns", + "users", + ["user_id"], + ["id"], + ) + op.create_index("ix_category_patterns_user_id", "category_patterns", ["user_id"], unique=False) + + # Keywords of a user-owned category belong to that user. + op.execute( + sa.text( + """ + UPDATE category_patterns + SET user_id = ( + SELECT categories.user_id + FROM categories + WHERE categories.id = category_patterns.category_id + ) + """ + ) + ) + + # A forked keyword set repeats builtin keywords for the same category, so + # uniqueness has to include the owner. + if LEGACY_PATTERN_INDEX in _index_names("category_patterns"): + op.drop_index(LEGACY_PATTERN_INDEX, table_name="category_patterns") + op.create_index( + PATTERN_INDEX, + "category_patterns", + ["category_id", "user_id", "pattern"], + unique=True, + ) + + op.create_table( + "category_overrides", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("category_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=True), + sa.Column("patterns_overridden", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["category_id"], ["categories.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "category_id", name="uq_category_overrides_user_category"), + ) + op.create_index("ix_category_overrides_user_id", "category_overrides", ["user_id"], unique=False) + op.create_index("ix_category_overrides_category_id", "category_overrides", ["category_id"], unique=False) + + +def downgrade() -> None: + """Drop overrides and pattern ownership, keeping only the shared catalog.""" + op.drop_index("ix_category_overrides_category_id", table_name="category_overrides") + op.drop_index("ix_category_overrides_user_id", table_name="category_overrides") + op.drop_table("category_overrides") + + # Forked keywords have no home in the old schema. + op.execute(sa.text("DELETE FROM category_patterns WHERE user_id IS NOT NULL")) + + if PATTERN_INDEX in _index_names("category_patterns"): + op.drop_index(PATTERN_INDEX, table_name="category_patterns") + op.drop_index("ix_category_patterns_user_id", table_name="category_patterns") + op.drop_constraint("fk_category_patterns_user_id_users", "category_patterns", type_="foreignkey") + op.drop_column("category_patterns", "user_id") + op.create_index( + LEGACY_PATTERN_INDEX, + "category_patterns", + ["category_id", "pattern"], + unique=True, + ) diff --git a/alembic/versions/d5b8f1c07e42_add_transference_category_id.py b/alembic/versions/d5b8f1c07e42_add_transference_category_id.py new file mode 100644 index 0000000..3252a14 --- /dev/null +++ b/alembic/versions/d5b8f1c07e42_add_transference_category_id.py @@ -0,0 +1,130 @@ +"""point transferences at the categories catalog + +Transferences stored their category as a builtin enum slug, so a user's own +category could never apply to them. This mirrors what expenses already do: +a ``category_id`` FK, backfilled from the legacy enum column. + +Revision ID: d5b8f1c07e42 +Revises: c4d7e1b9a250 +Create Date: 2026-07-25 + +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "d5b8f1c07e42" +down_revision: str | Sequence[str] | None = "c4d7e1b9a250" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +GENERAL_CATEGORY_SLUG = "general" + + +def _legacy_category_type() -> sa.types.TypeEngine: + """The pre-existing ``expensecategory`` enum, which is never dropped.""" + if op.get_bind().dialect.name == "postgresql": + return postgresql.ENUM(name="expensecategory", create_type=False) + return sa.String() + + +def _column_names(table_name: str) -> set[str]: + inspector = sa.inspect(op.get_bind()) + return {column["name"] for column in inspector.get_columns(table_name)} + + +def _global_categories_by_slug() -> dict[str, int]: + connection = op.get_bind() + metadata = sa.MetaData() + categories = sa.Table("categories", metadata, autoload_with=connection) + return { + row.slug: row.id + for row in connection.execute( + sa.select(categories.c.id, categories.c.slug).where(categories.c.user_id.is_(None)) + ) + } + + +def _normalize_legacy_category(value: object | None) -> str: + if value is None: + return GENERAL_CATEGORY_SLUG + normalized = str(value).split(".")[-1].strip().lower() + return normalized or GENERAL_CATEGORY_SLUG + + +def _backfill_transference_categories() -> None: + connection = op.get_bind() + metadata = sa.MetaData() + transferences = sa.Table("transferences", metadata, autoload_with=connection) + + categories_by_slug = _global_categories_by_slug() + default_category_id = categories_by_slug[GENERAL_CATEGORY_SLUG] + has_legacy_column = "category" in _column_names("transferences") + + if has_legacy_column: + rows = connection.execute( + sa.select(transferences.c.id, transferences.c.category, transferences.c.category_id) + ).all() + else: + rows = connection.execute(sa.select(transferences.c.id, transferences.c.category_id)).all() + + for row in rows: + if row.category_id is not None: + continue + slug = _normalize_legacy_category(getattr(row, "category", None)) + category_id = categories_by_slug.get(slug, default_category_id) + connection.execute( + transferences.update().where(transferences.c.id == row.id).values(category_id=category_id) + ) + + +def upgrade() -> None: + """Add transferences.category_id, backfilled from the legacy enum.""" + if "category_id" not in _column_names("transferences"): + op.add_column("transferences", sa.Column("category_id", sa.Integer(), nullable=True)) + + _backfill_transference_categories() + + with op.batch_alter_table("transferences") as batch_op: + batch_op.create_foreign_key( + "fk_transferences_category_id_categories", "categories", ["category_id"], ["id"] + ) + batch_op.create_index("ix_transferences_category_id", ["category_id"], unique=False) + batch_op.alter_column("category_id", existing_type=sa.Integer(), nullable=False) + if "category" in _column_names("transferences"): + batch_op.drop_column("category") + + +def downgrade() -> None: + """Restore the legacy enum column from the linked category slug.""" + if "category" not in _column_names("transferences"): + op.add_column("transferences", sa.Column("category", _legacy_category_type(), nullable=True)) + + connection = op.get_bind() + metadata = sa.MetaData() + transferences = sa.Table("transferences", metadata, autoload_with=connection) + categories = sa.Table("categories", metadata, autoload_with=connection) + + slugs_by_id = {row.id: row.slug for row in connection.execute(sa.select(categories.c.id, categories.c.slug))} + global_slugs = { + row.slug + for row in connection.execute(sa.select(categories.c.slug).where(categories.c.user_id.is_(None))) + } + for row in connection.execute(sa.select(transferences.c.id, transferences.c.category_id)): + # Custom categories have no enum member, so they fall back to general. + slug = slugs_by_id.get(row.category_id, GENERAL_CATEGORY_SLUG) + if slug not in global_slugs: + slug = GENERAL_CATEGORY_SLUG + connection.execute( + transferences.update().where(transferences.c.id == row.id).values(category=slug.upper()) + ) + + with op.batch_alter_table("transferences") as batch_op: + batch_op.drop_index("ix_transferences_category_id") + batch_op.drop_constraint("fk_transferences_category_id_categories", type_="foreignkey") + batch_op.drop_column("category_id") diff --git a/src/api/server.py b/src/api/server.py index adc14a9..6681b0d 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -1,6 +1,6 @@ import json import os -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Mapping from contextlib import asynccontextmanager from datetime import UTC, datetime from typing import TYPE_CHECKING, cast @@ -21,16 +21,23 @@ from api.jwt import Token, get_current_user, set_access_cookie, set_refresh_cookie from db.categories import ( + CategoryNotFoundError, + CategoryView, create_custom_category, - get_category_patterns, - get_global_category_by_slug, + delete_custom_category, + get_category_name_overrides, + get_category_view, get_visible_category, - list_categories_for_user, + list_category_views, + recategorize_transactions, + reset_builtin_category, + update_category, ) from db.models import ( Category, CategoryCreate, CategoryRead, + CategoryUpdate, Expense as DBExpense, ExpenseRead, Transference as DBTransference, @@ -112,19 +119,29 @@ async def lifespan(_app_service: FastAPI) -> AsyncGenerator[None]: # noqa: RUF0 redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT) -def serialize_category(category: Category, patterns: list[str] | None = None) -> CategoryRead: +def serialize_category(view: CategoryView) -> CategoryRead: return CategoryRead( - id=category.id, - slug=category.slug, - name=category.name_es, - name_en=category.name_en, - name_es=category.name_es, - is_custom=category.user_id is not None, - patterns=patterns or [], + id=view.id, + slug=view.slug, + name=view.name, + name_en=view.name_en, + name_es=view.name_es, + is_custom=view.is_custom, + is_modified=view.is_modified, + patterns=view.patterns, ) -def serialize_expense(expense: DBExpense, category: Category) -> ExpenseRead: +def category_display_name(category: Category, name_overrides: Mapping[int, str] | None = None) -> str: + """The category name as the current user renamed it, if they did.""" + if name_overrides and category.id is not None: + return name_overrides.get(category.id, category.name_es) + return category.name_es + + +def serialize_expense( + expense: DBExpense, category: Category, name_overrides: Mapping[int, str] | None = None +) -> ExpenseRead: return ExpenseRead( id=expense.id, user_id=expense.user_id, @@ -133,23 +150,27 @@ def serialize_expense(expense: DBExpense, category: Category) -> ExpenseRead: currency=expense.currency, category_id=expense.category_id, category_slug=category.slug, - category_name=category.name_es, + category_name=category_display_name(category, name_overrides), category_is_custom=category.user_id is not None, date=expense.date, description=expense.description, ) -def serialize_transference(transference: DBTransference, category: Category) -> TransferenceRead: +def serialize_transference( + transference: DBTransference, category: Category, name_overrides: Mapping[int, str] | None = None +) -> TransferenceRead: return TransferenceRead( id=transference.id, user_id=transference.user_id, recipient=transference.recipient, amount=transference.amount, currency=transference.currency, - category=transference.category, + category=category.slug, + category_id=transference.category_id, category_slug=category.slug, - category_name=category.name_es, + category_name=category_display_name(category, name_overrides), + category_is_custom=category.user_id is not None, date=transference.date, description=transference.description, ) @@ -408,13 +429,8 @@ def get_categories( current_user: User = Depends(get_current_user), session: Session = Depends(get_session), ) -> list[CategoryRead]: - categories = list_categories_for_user(session, require_user_id(current_user)) - category_ids = [category.id for category in categories if category.id is not None] - patterns_by_category_id = get_category_patterns(session, category_ids) - return [ - serialize_category(category, patterns_by_category_id.get(category.id or 0, [])) - for category in categories - ] + views = list_category_views(session, require_user_id(current_user)) + return [serialize_category(view) for view in views] @app.post("/categories", response_model=CategoryRead, status_code=status.HTTP_201_CREATED) @@ -423,12 +439,88 @@ def create_category( current_user: User = Depends(get_current_user), session: Session = Depends(get_session), ) -> CategoryRead: + user_id = require_user_id(current_user) try: - category = create_custom_category(session, require_user_id(current_user), category_data) + category = create_custom_category(session, user_id, category_data) except ValueError as exc: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc - patterns_by_category_id = get_category_patterns(session, [category.id] if category.id is not None else []) - return serialize_category(category, patterns_by_category_id.get(category.id or 0, [])) + return serialize_category(get_category_view(session, user_id, category)) + + +@app.post("/categories/recategorize") +def recategorize_categories( + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> JSONResponse: + """Re-apply the current keywords to already stored transactions. + + Transactions that match no keyword keep their category, so manual choices + survive the backfill. + """ + updated = recategorize_transactions(session, require_user_id(current_user)) + return JSONResponse( + content={ + "msg": "OK", + "expenses_updated": updated["expenses"], + "transferences_updated": updated["transferences"], + } + ) + + +@app.patch("/categories/{category_id}", response_model=CategoryRead) +def patch_category( + category_id: int, + category_data: CategoryUpdate, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> CategoryRead: + """Rename a category and/or replace its keywords. + + Builtin categories are shared, so edits are stored as a private override + for the current user only. + """ + user_id = require_user_id(current_user) + try: + category = update_category(session, user_id, category_id, category_data) + except CategoryNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + return serialize_category(get_category_view(session, user_id, category)) + + +@app.post("/categories/{category_id}/reset", response_model=CategoryRead) +def reset_category( + category_id: int, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> CategoryRead: + """Restore a builtin category to its default name and keywords.""" + user_id = require_user_id(current_user) + try: + category = reset_builtin_category(session, user_id, category_id) + except CategoryNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + return serialize_category(get_category_view(session, user_id, category)) + + +@app.delete("/categories/{category_id}") +def delete_category( + category_id: int, + current_user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> JSONResponse: + """Delete a custom category; its expenses fall back to the general one.""" + user_id = require_user_id(current_user) + try: + reassigned = delete_custom_category(session, user_id, category_id) + except CategoryNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + return JSONResponse(content={"msg": "OK", "reassigned_expenses": reassigned}) @app.get("/expenses", response_model=list[ExpenseRead]) @@ -448,7 +540,8 @@ def get_expenses( .limit(limit) .offset(offset) ).all() - return [serialize_expense(expense, category) for expense, category in expenses] + name_overrides = get_category_name_overrides(session, user_id) + return [serialize_expense(expense, category, name_overrides) for expense, category in expenses] @app.post("/expenses", response_model=ExpenseRead, status_code=status.HTTP_201_CREATED) @@ -477,7 +570,7 @@ def create_expense( session.add(new_expense) session.commit() session.refresh(new_expense) - return serialize_expense(new_expense, category) + return serialize_expense(new_expense, category, get_category_name_overrides(session, user_id)) @app.delete("/expenses/{id}") @@ -504,17 +597,18 @@ def get_transferences( """Get transferences for the current user, most recent first.""" user_id = require_user_id(current_user) transferences = session.exec( - select(DBTransference) + select(DBTransference, Category) + .join(Category, DBTransference.category_id == Category.id) .where(DBTransference.user_id == user_id) .order_by(DBTransference.date.desc()) .limit(limit) .offset(offset) ).all() - result = [] - for t in transferences: - category = get_global_category_by_slug(session, t.category.value) - result.append(serialize_transference(t, category)) - return result + name_overrides = get_category_name_overrides(session, user_id) + return [ + serialize_transference(transference, category, name_overrides) + for transference, category in transferences + ] @app.delete("/transferences/{id}") diff --git a/src/db/categories.py b/src/db/categories.py index df8b90a..a1ab70a 100644 --- a/src/db/categories.py +++ b/src/db/categories.py @@ -1,11 +1,38 @@ from __future__ import annotations import unicodedata +from dataclasses import dataclass, field from sqlmodel import Session, select -from category_catalog import get_builtin_category_definitions, slugify_category_name -from db.models import Category, CategoryCreate, CategoryPattern +from category_catalog import GENERAL_CATEGORY_SLUG, get_builtin_category_definitions, slugify_category_name +from db.models import ( + Category, + CategoryCreate, + CategoryOverride, + CategoryPattern, + CategoryUpdate, + Expense, + Transference, +) + + +@dataclass(frozen=True) +class CategoryView: + """A category as a single user sees it, with their overrides applied.""" + + id: int + slug: str + name: str + name_en: str + name_es: str + is_custom: bool + is_modified: bool + patterns: list[str] = field(default_factory=list) + + +class CategoryNotFoundError(LookupError): + """The category does not exist or is not visible to the user.""" def _sync_builtin_category_rows(session: Session, existing_by_slug: dict[str, Category]) -> bool: @@ -43,8 +70,13 @@ def _sync_builtin_patterns(session: Session, existing_by_slug: dict[str, Categor category_ids = [category.id for category in existing_by_slug.values() if category.id is not None] existing_patterns = [] if category_ids: + # Only the shared catalog rows count here: a user's forked keywords live + # alongside them and must not keep a builtin keyword from being seeded. existing_patterns = session.exec( - select(CategoryPattern).where(CategoryPattern.category_id.in_(category_ids)) + select(CategoryPattern).where( + CategoryPattern.category_id.in_(category_ids), + CategoryPattern.user_id.is_(None), + ) ).all() existing_pattern_keys = {(pattern.category_id, pattern.pattern) for pattern in existing_patterns} @@ -84,17 +116,74 @@ def _normalize_pattern(value: str) -> str: return " ".join(normalized.split()).upper() -def get_category_patterns(session: Session, category_ids: list[int]) -> dict[int, list[str]]: +def _clean_patterns(values: list[str]) -> list[str]: + """Trim, drop blanks and de-duplicate keywords, keeping the given order.""" + cleaned: list[str] = [] + seen: set[str] = set() + for value in values: + pattern = value.strip() + if not pattern: + continue + key = pattern.casefold() + if key in seen: + continue + seen.add(key) + cleaned.append(pattern) + return cleaned + + +def get_overrides_by_category_id(session: Session, user_id: int) -> dict[int, CategoryOverride]: + rows = session.exec(select(CategoryOverride).where(CategoryOverride.user_id == user_id)).all() + return {row.category_id: row for row in rows} + + +def get_category_name_overrides(session: Session, user_id: int) -> dict[int, str]: + """Renamed builtin categories, for serializing expenses and transferences.""" + overrides = get_overrides_by_category_id(session, user_id) + return {category_id: override.name for category_id, override in overrides.items() if override.name} + + +def _effective_name(category: Category, override: CategoryOverride | None) -> str: + if override is not None and override.name: + return override.name + return category.name_es + + +def _patterns_by_category_id( + session: Session, + user_id: int, + categories: list[Category], + overrides: dict[int, CategoryOverride], +) -> dict[int, list[str]]: + category_ids = [category.id for category in categories if category.id is not None] if not category_ids: return {} rows = session.exec(select(CategoryPattern).where(CategoryPattern.category_id.in_(category_ids))).all() - patterns_by_category_id: dict[int, list[str]] = {} + rows_by_category_id: dict[int, list[CategoryPattern]] = {} for row in rows: - patterns_by_category_id.setdefault(row.category_id, []).append(row.pattern) + rows_by_category_id.setdefault(row.category_id, []).append(row) - for patterns in patterns_by_category_id.values(): - patterns.sort(key=str.casefold) + patterns_by_category_id: dict[int, list[str]] = {} + for category in categories: + if category.id is None: + continue + override = overrides.get(category.id) + forked = override is not None and override.patterns_overridden + selected: list[str] = [] + for row in rows_by_category_id.get(category.id, []): + if category.user_id is not None: + # Private category: rows written before keyword ownership + # existed carry no user_id, so accept both. + keep = row.user_id in {None, user_id} + elif forked: + keep = row.user_id == user_id + else: + keep = row.user_id is None + if keep: + selected.append(row.pattern) + selected.sort(key=str.casefold) + patterns_by_category_id[category.id] = selected return patterns_by_category_id @@ -107,6 +196,46 @@ def list_categories_for_user(session: Session, user_id: int) -> list[Category]: return sorted(categories, key=lambda category: category.name_es.casefold()) +def _build_view(category: Category, override: CategoryOverride | None, patterns: list[str]) -> CategoryView: + is_custom = category.user_id is not None + return CategoryView( + id=category.id or 0, + slug=category.slug, + name=_effective_name(category, override), + name_en=category.name_en, + name_es=category.name_es, + is_custom=is_custom, + is_modified=not is_custom and override is not None, + patterns=patterns, + ) + + +def list_category_views(session: Session, user_id: int) -> list[CategoryView]: + """Every category the user can use, with their renames and keywords applied.""" + categories = list_categories_for_user(session, user_id) + overrides = get_overrides_by_category_id(session, user_id) + patterns_by_category_id = _patterns_by_category_id(session, user_id, categories, overrides) + views = [ + _build_view( + category, + overrides.get(category.id or 0), + patterns_by_category_id.get(category.id or 0, []), + ) + for category in categories + ] + return sorted(views, key=lambda view: view.name.casefold()) + + +def get_category_view(session: Session, user_id: int, category: Category) -> CategoryView: + overrides = get_overrides_by_category_id(session, user_id) + patterns_by_category_id = _patterns_by_category_id(session, user_id, [category], overrides) + return _build_view( + category, + overrides.get(category.id or 0), + patterns_by_category_id.get(category.id or 0, []), + ) + + def get_visible_category(session: Session, category_id: int, user_id: int) -> Category | None: sync_builtin_categories(session) return session.exec( @@ -126,37 +255,122 @@ def get_global_category_by_slug(session: Session, slug: str) -> Category: return category -def resolve_category_for_user_text(session: Session, user_id: int, text: str, fallback_slug: str) -> Category: - sync_builtin_categories(session) - normalized_text = _normalize_pattern(text) - if normalized_text: - rows = session.exec( - select(CategoryPattern, Category) - .join(Category, CategoryPattern.category_id == Category.id) - .where((Category.user_id.is_(None)) | (Category.user_id == user_id)) +def _visible_categories_with_patterns( + session: Session, user_id: int +) -> tuple[list[Category], dict[int, list[str]]]: + categories = list( + session.exec( + select(Category).where((Category.user_id.is_(None)) | (Category.user_id == user_id)) ).all() + ) + overrides = get_overrides_by_category_id(session, user_id) + return categories, _patterns_by_category_id(session, user_id, categories, overrides) + + +def _match_category_for_text( + text: str, + user_id: int, + categories: list[Category], + patterns_by_category_id: dict[int, list[str]], +) -> Category | None: + normalized_text = _normalize_pattern(text) + if not normalized_text: + return None - matches: list[tuple[int, int, Category]] = [] - for pattern, category in rows: - normalized_pattern = _normalize_pattern(pattern.pattern) + matches: list[tuple[int, int, Category]] = [] + for category in categories: + for pattern in patterns_by_category_id.get(category.id or 0, []): + normalized_pattern = _normalize_pattern(pattern) if normalized_pattern and normalized_pattern in normalized_text: matches.append((len(normalized_pattern), 1 if category.user_id == user_id else 0, category)) - if matches: - matches.sort(key=lambda row: (row[0], row[1]), reverse=True) - return matches[0][2] + if not matches: + return None + # Longest keyword wins; a private category breaks ties with a builtin one. + matches.sort(key=lambda row: (row[0], row[1]), reverse=True) + return matches[0][2] + + +def resolve_category_for_user_text(session: Session, user_id: int, text: str, fallback_slug: str) -> Category: + sync_builtin_categories(session) + categories, patterns_by_category_id = _visible_categories_with_patterns(session, user_id) + match = _match_category_for_text(text, user_id, categories, patterns_by_category_id) + if match is not None: + return match return get_global_category_by_slug(session, fallback_slug) +def recategorize_transactions(session: Session, user_id: int) -> dict[str, int]: + """Re-apply the user's keywords to their stored expenses and transferences. + + Only rows whose text matches a keyword move; anything unmatched keeps the + category it has, so manual assignments are never wiped by a backfill. + """ + sync_builtin_categories(session) + categories, patterns_by_category_id = _visible_categories_with_patterns(session, user_id) + + expenses_updated = 0 + for expense in session.exec(select(Expense).where(Expense.user_id == user_id)).all(): + match = _match_category_for_text(expense.commerce, user_id, categories, patterns_by_category_id) + if match is None or match.id is None or match.id == expense.category_id: + continue + expense.category_id = match.id + session.add(expense) + expenses_updated += 1 + + transferences_updated = 0 + for transference in session.exec(select(Transference).where(Transference.user_id == user_id)).all(): + match = _match_category_for_text(transference.recipient, user_id, categories, patterns_by_category_id) + if match is None or match.id is None or match.id == transference.category_id: + continue + transference.category_id = match.id + session.add(transference) + transferences_updated += 1 + + if expenses_updated or transferences_updated: + session.commit() + + return {"expenses": expenses_updated, "transferences": transferences_updated} + + +def _assert_name_available( + session: Session, + user_id: int, + name: str, + *, + exclude_category_id: int | None = None, +) -> None: + normalized = name.casefold() + categories = list_categories_for_user(session, user_id) + overrides = get_overrides_by_category_id(session, user_id) + for category in categories: + if category.id == exclude_category_id: + continue + if _effective_name(category, overrides.get(category.id or 0)).casefold() == normalized: + raise ValueError("Category already exists") + + +def _replace_patterns(session: Session, category: Category, user_id: int, patterns: list[str]) -> None: + """Swap the user's keyword rows for ``category``, never touching the catalog.""" + query = select(CategoryPattern).where(CategoryPattern.category_id == category.id) + if category.user_id is None: + # Builtin category: only this user's forked rows are ours to delete. + query = query.where(CategoryPattern.user_id == user_id) + + for row in session.exec(query).all(): + session.delete(row) + + for pattern in patterns: + session.add(CategoryPattern(category_id=category.id, user_id=user_id, pattern=pattern)) + + def create_custom_category(session: Session, user_id: int, payload: CategoryCreate) -> Category: normalized_name = payload.name.strip() if not normalized_name: raise ValueError("Category name is required") - existing_names = session.exec(select(Category).where(Category.user_id == user_id)).all() - if any(category.name_es.casefold() == normalized_name.casefold() for category in existing_names): - raise ValueError("Category already exists") + _assert_name_available(session, user_id, normalized_name) base_slug = slugify_category_name(normalized_name) slug = f"user-{user_id}-{base_slug}" @@ -175,10 +389,147 @@ def create_custom_category(session: Session, user_id: int, payload: CategoryCrea session.add(category) session.flush() - normalized_pattern = payload.pattern.strip() if payload.pattern else "" - if normalized_pattern: - session.add(CategoryPattern(category_id=category.id, pattern=normalized_pattern)) + patterns = _clean_patterns([*(payload.patterns or []), *([payload.pattern] if payload.pattern else [])]) + for pattern in patterns: + session.add(CategoryPattern(category_id=category.id, user_id=user_id, pattern=pattern)) session.commit() session.refresh(category) return category + + +def _apply_own_category_update( + session: Session, + category: Category, + user_id: int, + name: str | None, + patterns: list[str] | None, +) -> None: + if name is not None: + category.name_en = name + category.name_es = name + session.add(category) + if patterns is not None: + _replace_patterns(session, category, user_id, _clean_patterns(patterns)) + + +def _apply_builtin_category_override( + session: Session, + category: Category, + user_id: int, + name: str | None, + patterns: list[str] | None, +) -> None: + override = session.exec( + select(CategoryOverride).where( + CategoryOverride.user_id == user_id, + CategoryOverride.category_id == category.id, + ) + ).first() + if override is None: + override = CategoryOverride(user_id=user_id, category_id=category.id) + + if name is not None: + # Storing the builtin name back is the same as having no rename. + override.name = None if name == category.name_es else name + if patterns is not None: + override.patterns_overridden = True + _replace_patterns(session, category, user_id, _clean_patterns(patterns)) + + session.add(override) + + +def update_category(session: Session, user_id: int, category_id: int, payload: CategoryUpdate) -> Category: + """Rename a category and/or replace its keywords. + + A user's own category is edited in place. Builtin categories are shared + rows, so the change is stored as a per-user :class:`CategoryOverride` plus + forked keyword rows -- everybody else keeps the defaults. + """ + category = get_visible_category(session, category_id, user_id) + if category is None: + raise CategoryNotFoundError("Category not found") + + name: str | None = None + if payload.name is not None: + name = payload.name.strip() + if not name: + raise ValueError("Category name is required") + _assert_name_available(session, user_id, name, exclude_category_id=category.id) + + if payload.name is None and payload.patterns is None: + return category + + if category.user_id == user_id: + _apply_own_category_update(session, category, user_id, name, payload.patterns) + else: + _apply_builtin_category_override(session, category, user_id, name, payload.patterns) + + session.commit() + session.refresh(category) + return category + + +def reset_builtin_category(session: Session, user_id: int, category_id: int) -> Category: + """Drop the user's customization of a builtin category.""" + category = get_visible_category(session, category_id, user_id) + if category is None: + raise CategoryNotFoundError("Category not found") + if category.user_id is not None: + raise ValueError("Custom categories cannot be reset") + + overrides = session.exec( + select(CategoryOverride).where( + CategoryOverride.user_id == user_id, + CategoryOverride.category_id == category.id, + ) + ).all() + for override in overrides: + session.delete(override) + + forked_patterns = session.exec( + select(CategoryPattern).where( + CategoryPattern.category_id == category.id, + CategoryPattern.user_id == user_id, + ) + ).all() + for pattern in forked_patterns: + session.delete(pattern) + + session.commit() + session.refresh(category) + return category + + +def delete_custom_category(session: Session, user_id: int, category_id: int) -> int: + """Delete one of the user's own categories. + + Its transactions move to the general category. Returns how many moved. + """ + category = get_visible_category(session, category_id, user_id) + if category is None: + raise CategoryNotFoundError("Category not found") + if category.user_id != user_id: + raise ValueError("Builtin categories cannot be deleted") + + general_category = get_global_category_by_slug(session, GENERAL_CATEGORY_SLUG) + expenses = session.exec( + select(Expense).where(Expense.user_id == user_id, Expense.category_id == category.id) + ).all() + transferences = session.exec( + select(Transference).where(Transference.user_id == user_id, Transference.category_id == category.id) + ).all() + for transaction in [*expenses, *transferences]: + transaction.category_id = general_category.id + session.add(transaction) + + for pattern in session.exec(select(CategoryPattern).where(CategoryPattern.category_id == category.id)).all(): + session.delete(pattern) + for override in session.exec( + select(CategoryOverride).where(CategoryOverride.category_id == category.id) + ).all(): + session.delete(override) + + session.delete(category) + session.commit() + return len(expenses) + len(transferences) diff --git a/src/db/models.py b/src/db/models.py index 449c985..801a767 100644 --- a/src/db/models.py +++ b/src/db/models.py @@ -9,7 +9,7 @@ from pydantic import EmailStr, field_validator from sqlmodel import Field, Relationship, SQLModel, select, or_, Text, Column -from parsers.base import Currency, ExpenseCategory +from parsers.base import Currency from pwdlib import PasswordHash @@ -115,7 +115,7 @@ class Transference(SQLModel, table=True): recipient: str = Field(index=True) amount: float = Field() currency: Currency = Field(default=Currency.CLP) - category: ExpenseCategory = Field(default=ExpenseCategory.GENERAL) + category_id: int = Field(foreign_key="categories.id", index=True) date: datetime = Field(default_factory=minimum_date_factory) description: str | None = Field(default=None) @@ -136,12 +136,42 @@ class CategoryPattern(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) category_id: int = Field(foreign_key="categories.id") + # NULL for the builtin catalog rows shared by everyone. Set when the row + # belongs to a user: their own category, or their fork of a builtin one. + user_id: int | None = Field(default=None, foreign_key="users.id", index=True) pattern: str +class CategoryOverride(SQLModel, table=True): + """Per-user customization of a global (builtin) category. + + Builtin categories are shared rows, so a user cannot edit them in place. + An override carries the user's rename and marks whether they took over the + keyword set; once ``patterns_overridden`` is set, only that user's + ``CategoryPattern`` rows apply for them and the builtin ones are ignored. + """ + + __tablename__ = "category_overrides" + + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="users.id", index=True) + category_id: int = Field(foreign_key="categories.id", index=True) + name: str | None = Field(default=None) + patterns_overridden: bool = Field(default=False) + + class CategoryCreate(SQLModel): name: str + # ``pattern`` is the original single-keyword shape, kept for compatibility. pattern: str | None = None + patterns: list[str] | None = None + + +class CategoryUpdate(SQLModel): + """Partial update: omitted fields are left untouched.""" + + name: str | None = None + patterns: list[str] | None = None class CategoryRead(SQLModel): @@ -151,6 +181,9 @@ class CategoryRead(SQLModel): name_en: str name_es: str is_custom: bool + # True when the user customized this builtin category (renamed it or took + # over its keywords), so the UI can offer "reset to defaults". + is_modified: bool = False patterns: list[str] = [] model_config: ClassVar[dict[str, Any]] = {"from_attributes": True} @@ -187,9 +220,12 @@ class TransferenceRead(SQLModel): recipient: str amount: float currency: Currency - category: ExpenseCategory + # Kept for API compatibility: same value as ``category_slug``. + category: str + category_id: int category_slug: str category_name: str + category_is_custom: bool = False date: datetime description: str | None = None diff --git a/src/parsers/parser.py b/src/parsers/parser.py index 9aa2923..b2cb0dd 100644 --- a/src/parsers/parser.py +++ b/src/parsers/parser.py @@ -233,7 +233,10 @@ def save_expense( transference = parser.get_transference(msg) if not transference: return None - db_transference = transference_to_db_model(transference) + category = resolve_category_for_user_text( + session, user_id, transference.recipient, transference.category.value + ) + db_transference = transference_to_db_model(transference, category.id) db_transference.user_id = user_id session.add(db_transference) session.commit() diff --git a/src/parsers/transaction_mapper.py b/src/parsers/transaction_mapper.py index b0f54f5..c56e612 100644 --- a/src/parsers/transaction_mapper.py +++ b/src/parsers/transaction_mapper.py @@ -17,14 +17,14 @@ def expense_to_db_model(expense: Expense, category_id: int) -> DBExpense: ) -def transference_to_db_model(transference: Transference) -> DBTransference: +def transference_to_db_model(transference: Transference, category_id: int) -> DBTransference: """Convert to database model""" return DBTransference( recipient=transference.recipient, amount=transference.value, currency=transference.currency, - category=transference.category, + category_id=category_id, date=transference.date, description="Extracted from email", ) diff --git a/tests/test_category_management.py b/tests/test_category_management.py new file mode 100644 index 0000000..ac1bd14 --- /dev/null +++ b/tests/test_category_management.py @@ -0,0 +1,449 @@ +"""Tests for user-managed categories. + +Builtin categories are global rows shared by every user, so editing one is +stored as a private override plus a forked keyword set. These tests cover that +isolation, the reset path, deletion, and the backfill that re-applies keywords +to already stored transactions. +""" + +from collections.abc import Iterator +from datetime import UTC, datetime + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine, select + +from api import server +from api.jwt import get_current_user +from db.categories import ( + CategoryNotFoundError, + create_custom_category, + delete_custom_category, + get_global_category_by_slug, + list_category_views, + recategorize_transactions, + reset_builtin_category, + resolve_category_for_user_text, + update_category, +) +from db.models import ( + Category, + CategoryCreate, + CategoryPattern, + CategoryUpdate, + Expense, + Transference, + User, +) +from db.service import get_session + + +def create_user(session: Session, username: str, email: str) -> User: + user = User.create(username=username, email=email, password="password123") + session.add(user) + session.commit() + session.refresh(user) + return user + + +def view_by_slug(session: Session, user_id: int, slug: str): + return next(view for view in list_category_views(session, user_id) if view.slug == slug) + + +@pytest.fixture +def engine(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return engine + + +@pytest.fixture +def session(engine) -> Iterator[Session]: + with Session(engine) as session: + yield session + + +def test_adding_a_keyword_to_a_builtin_category_only_affects_that_user(session): + owner = create_user(session, "owner", "owner@example.com") + other = create_user(session, "other", "other@example.com") + housing = get_global_category_by_slug(session, "housing") + default_keywords = view_by_slug(session, owner.id, "housing").patterns + + update_category( + session, + owner.id, + housing.id, + CategoryUpdate(patterns=[*default_keywords, "JUNTA DE VECINOS"]), + ) + + owner_view = view_by_slug(session, owner.id, "housing") + other_view = view_by_slug(session, other.id, "housing") + + assert "JUNTA DE VECINOS" in owner_view.patterns + assert owner_view.is_modified is True + assert "JUNTA DE VECINOS" not in other_view.patterns + assert other_view.is_modified is False + assert other_view.patterns == default_keywords + + resolved = resolve_category_for_user_text( + session, owner.id, "PAGO JUNTA DE VECINOS", "general" + ) + assert resolved.slug == "housing" + assert resolve_category_for_user_text( + session, other.id, "PAGO JUNTA DE VECINOS", "general" + ).slug == "general" + + +def test_removing_a_builtin_keyword_stops_matching_for_that_user(session): + owner = create_user(session, "owner", "owner@example.com") + other = create_user(session, "other", "other@example.com") + food = get_global_category_by_slug(session, "food") + kept = [pattern for pattern in view_by_slug(session, owner.id, "food").patterns if pattern != "JUMBO"] + + update_category(session, owner.id, food.id, CategoryUpdate(patterns=kept)) + + assert "JUMBO" not in view_by_slug(session, owner.id, "food").patterns + assert "JUMBO" in view_by_slug(session, other.id, "food").patterns + assert resolve_category_for_user_text(session, owner.id, "COMPRA EN JUMBO", "general").slug == "general" + assert resolve_category_for_user_text(session, other.id, "COMPRA EN JUMBO", "general").slug == "food" + + +def test_renaming_a_builtin_category_is_private_to_the_user(session): + owner = create_user(session, "owner", "owner@example.com") + other = create_user(session, "other", "other@example.com") + housing = get_global_category_by_slug(session, "housing") + + update_category(session, owner.id, housing.id, CategoryUpdate(name="Casa")) + + assert view_by_slug(session, owner.id, "housing").name == "Casa" + assert view_by_slug(session, other.id, "housing").name == "Vivienda" + # The shared row keeps its catalog labels. + assert session.get(Category, housing.id).name_es == "Vivienda" + + +def test_builtin_keywords_are_not_reseeded_into_a_forked_category(session): + owner = create_user(session, "owner", "owner@example.com") + food = get_global_category_by_slug(session, "food") + + update_category(session, owner.id, food.id, CategoryUpdate(patterns=["PANADERIA"])) + # A later request re-runs the builtin sync; the fork must survive it. + list_category_views(session, owner.id) + + assert view_by_slug(session, owner.id, "food").patterns == ["PANADERIA"] + + +def test_reset_restores_builtin_name_and_keywords(session): + owner = create_user(session, "owner", "owner@example.com") + housing = get_global_category_by_slug(session, "housing") + default_keywords = view_by_slug(session, owner.id, "housing").patterns + + update_category(session, owner.id, housing.id, CategoryUpdate(name="Casa", patterns=["ARRIENDO DEPTO"])) + reset_builtin_category(session, owner.id, housing.id) + + restored = view_by_slug(session, owner.id, "housing") + assert restored.name == "Vivienda" + assert restored.patterns == default_keywords + assert restored.is_modified is False + assert session.exec(select(CategoryPattern).where(CategoryPattern.user_id == owner.id)).all() == [] + + +def test_reset_rejects_custom_categories(session): + owner = create_user(session, "owner", "owner@example.com") + category = create_custom_category(session, owner.id, CategoryCreate(name="Mascotas")) + + with pytest.raises(ValueError, match="cannot be reset"): + reset_builtin_category(session, owner.id, category.id) + + +def test_updating_a_custom_category_edits_it_in_place(session): + owner = create_user(session, "owner", "owner@example.com") + category = create_custom_category(session, owner.id, CategoryCreate(name="Mascotas", pattern="PETSHOP")) + + update_category(session, owner.id, category.id, CategoryUpdate(name="Mascotas y vet", patterns=["VET"])) + + view = next(view for view in list_category_views(session, owner.id) if view.id == category.id) + assert view.name == "Mascotas y vet" + assert view.patterns == ["VET"] + assert view.is_custom is True + assert resolve_category_for_user_text(session, owner.id, "PETSHOP CENTRO", "general").slug == "general" + + +def test_update_rejects_a_name_taken_by_another_visible_category(session): + owner = create_user(session, "owner", "owner@example.com") + category = create_custom_category(session, owner.id, CategoryCreate(name="Mascotas")) + + with pytest.raises(ValueError, match="Category already exists"): + update_category(session, owner.id, category.id, CategoryUpdate(name="Comida")) + + +def test_update_rejects_another_users_category(session): + owner = create_user(session, "owner", "owner@example.com") + other = create_user(session, "other", "other@example.com") + category = create_custom_category(session, owner.id, CategoryCreate(name="Mascotas")) + + with pytest.raises(CategoryNotFoundError): + update_category(session, other.id, category.id, CategoryUpdate(name="Robado")) + + +def test_creating_a_category_accepts_several_keywords(session): + owner = create_user(session, "owner", "owner@example.com") + + category = create_custom_category( + session, owner.id, CategoryCreate(name="Mascotas", patterns=["PETSHOP", " vet ", "petshop", ""]) + ) + + view = next(view for view in list_category_views(session, owner.id) if view.id == category.id) + assert view.patterns == ["PETSHOP", "vet"] + + +def test_deleting_a_custom_category_moves_its_transactions_to_general(session): + owner = create_user(session, "owner", "owner@example.com") + category = create_custom_category(session, owner.id, CategoryCreate(name="Mascotas")) + general = get_global_category_by_slug(session, "general") + + session.add( + Expense( + user_id=owner.id, + commerce="PETSHOP", + amount=1000, + category_id=category.id, + date=datetime.now(UTC), + ) + ) + session.add( + Transference( + user_id=owner.id, + recipient="PETSHOP", + amount=2000, + category_id=category.id, + date=datetime.now(UTC), + ) + ) + session.commit() + + reassigned = delete_custom_category(session, owner.id, category.id) + + assert reassigned == 2 + assert session.get(Category, category.id) is None + assert {expense.category_id for expense in session.exec(select(Expense)).all()} == {general.id} + assert {t.category_id for t in session.exec(select(Transference)).all()} == {general.id} + + +def test_deleting_a_builtin_category_is_rejected(session): + owner = create_user(session, "owner", "owner@example.com") + housing = get_global_category_by_slug(session, "housing") + + with pytest.raises(ValueError, match="cannot be deleted"): + delete_custom_category(session, owner.id, housing.id) + + +def test_recategorize_backfills_existing_transactions(session): + owner = create_user(session, "owner", "owner@example.com") + general = get_global_category_by_slug(session, "general") + housing = get_global_category_by_slug(session, "housing") + default_keywords = view_by_slug(session, owner.id, "housing").patterns + + session.add( + Expense( + user_id=owner.id, + commerce="PAGO JUNTA DE VECINOS", + amount=50000, + category_id=general.id, + date=datetime.now(UTC), + ) + ) + session.add( + Transference( + user_id=owner.id, + recipient="ADMIN JUNTA DE VECINOS", + amount=30000, + category_id=general.id, + date=datetime.now(UTC), + ) + ) + session.commit() + + update_category( + session, + owner.id, + housing.id, + CategoryUpdate(patterns=[*default_keywords, "JUNTA DE VECINOS"]), + ) + updated = recategorize_transactions(session, owner.id) + + assert updated == {"expenses": 1, "transferences": 1} + assert session.exec(select(Expense)).first().category_id == housing.id + assert session.exec(select(Transference)).first().category_id == housing.id + + +def test_recategorize_leaves_unmatched_transactions_alone(session): + owner = create_user(session, "owner", "owner@example.com") + category = create_custom_category(session, owner.id, CategoryCreate(name="Mascotas")) + + session.add( + Expense( + user_id=owner.id, + commerce="ALGO SIN PALABRA CLAVE", + amount=1000, + category_id=category.id, + date=datetime.now(UTC), + ) + ) + session.commit() + + updated = recategorize_transactions(session, owner.id) + + assert updated == {"expenses": 0, "transferences": 0} + assert session.exec(select(Expense)).first().category_id == category.id + + +def test_recategorize_ignores_other_users_transactions(session): + owner = create_user(session, "owner", "owner@example.com") + other = create_user(session, "other", "other@example.com") + general = get_global_category_by_slug(session, "general") + create_custom_category(session, owner.id, CategoryCreate(name="Mascotas", pattern="PETSHOP")) + + session.add( + Expense( + user_id=other.id, + commerce="PETSHOP CENTRO", + amount=1000, + category_id=general.id, + date=datetime.now(UTC), + ) + ) + session.commit() + + updated = recategorize_transactions(session, owner.id) + + assert updated == {"expenses": 0, "transferences": 0} + assert session.exec(select(Expense)).first().category_id == general.id + + +@pytest.fixture +def client(engine) -> Iterator[TestClient]: + with Session(engine) as setup_session: + user = create_user(setup_session, "api-user", "api@example.com") + user_id = user.id + + def override_session() -> Iterator[Session]: + with Session(engine) as session: + yield session + + def override_user() -> User: + with Session(engine) as session: + return session.get(User, user_id) + + server.app.dependency_overrides[get_session] = override_session + server.app.dependency_overrides[get_current_user] = override_user + test_client = TestClient(server.app) + test_client.user_id = user_id + yield test_client + server.app.dependency_overrides.clear() + + +def category_payload(client: TestClient, slug: str) -> dict: + response = client.get("/categories") + assert response.status_code == 200 + return next(category for category in response.json() if category["slug"] == slug) + + +def test_categories_endpoint_exposes_keywords_and_custom_flags(client): + housing = category_payload(client, "housing") + + assert housing["is_custom"] is False + assert housing["is_modified"] is False + assert "DIVIDENDO" in [pattern.upper() for pattern in housing["patterns"]] + + +def test_patch_category_persists_keywords_and_rename(client): + housing = category_payload(client, "housing") + + response = client.patch( + f"/categories/{housing['id']}", + json={"name": "Casa", "patterns": [*housing["patterns"], "JUNTA DE VECINOS"]}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["name"] == "Casa" + assert body["is_modified"] is True + assert "JUNTA DE VECINOS" in body["patterns"] + assert category_payload(client, "housing")["name"] == "Casa" + + +def test_reset_endpoint_restores_defaults(client): + housing = category_payload(client, "housing") + client.patch(f"/categories/{housing['id']}", json={"name": "Casa", "patterns": ["JUNTA DE VECINOS"]}) + + response = client.post(f"/categories/{housing['id']}/reset") + + assert response.status_code == 200 + assert response.json()["name"] == "Vivienda" + assert category_payload(client, "housing")["is_modified"] is False + + +def test_delete_endpoint_rejects_builtin_categories(client): + housing = category_payload(client, "housing") + + response = client.delete(f"/categories/{housing['id']}") + + assert response.status_code == 400 + + +def test_delete_endpoint_removes_a_custom_category(client): + created = client.post("/categories", json={"name": "Mascotas", "patterns": ["PETSHOP"]}) + assert created.status_code == 201 + + response = client.delete(f"/categories/{created.json()['id']}") + + assert response.status_code == 200 + assert response.json()["reassigned_expenses"] == 0 + assert all(category["slug"] != created.json()["slug"] for category in client.get("/categories").json()) + + +def test_recategorize_endpoint_reports_updated_rows(client, engine): + general_id = category_payload(client, "general")["id"] + with Session(engine) as session: + session.add( + Expense( + user_id=client.user_id, + commerce="PETSHOP CENTRO", + amount=1000, + category_id=general_id, + date=datetime.now(UTC), + ) + ) + session.commit() + + client.post("/categories", json={"name": "Mascotas", "patterns": ["PETSHOP"]}) + response = client.post("/categories/recategorize") + + assert response.status_code == 200 + assert response.json()["expenses_updated"] == 1 + assert client.get("/expenses").json()[0]["category_name"] == "Mascotas" + + +def test_expenses_use_the_users_renamed_category(client, engine): + housing = category_payload(client, "housing") + client.patch(f"/categories/{housing['id']}", json={"name": "Casa"}) + with Session(engine) as session: + session.add( + Expense( + user_id=client.user_id, + commerce="PAGO DIVIDENDO", + amount=1000, + category_id=housing["id"], + date=datetime.now(UTC), + ) + ) + session.commit() + + expenses = client.get("/expenses").json() + + assert expenses[0]["category_name"] == "Casa" diff --git a/tests/test_email_task.py b/tests/test_email_task.py index 2e297b9..ccf5fbf 100644 --- a/tests/test_email_task.py +++ b/tests/test_email_task.py @@ -403,12 +403,18 @@ def test_transference_with_valid_body_saves(self): subject = f.read() msg = Message(transference_remitent, subject, time_obj, body) - mock_session = MagicMock() + engine = create_engine("sqlite://") + SQLModel.metadata.create_all(engine) - result = save_message(1, parser, msg, mock_session) + # Saving a transference now resolves its category against the user's + # catalog, so this needs a real session rather than a mock. + with Session(engine) as session: + user = create_user(session, "transfer-body-user", "transfer-body@example.com") + result = save_message(user.id, parser, msg, session) + saved_transferences = session.exec(select(DbTransference)).all() - assert result is True - mock_session.add.assert_called_once() + assert result is True + assert len(saved_transferences) == 1 def test_transference_with_invalid_body_not_saved(self): """Test that transference with invalid body pattern is not saved.""" @@ -437,12 +443,16 @@ def test_santander_transference_any_body_saves(self): subject = f.read() msg = Message("mensajeria@santander.cl", subject, time_obj, body) - mock_session = MagicMock() + engine = create_engine("sqlite://") + SQLModel.metadata.create_all(engine) - result = save_message(1, parser, msg, mock_session) + with Session(engine) as session: + user = create_user(session, "santander-transfer-user", "santander-transfer@example.com") + result = save_message(user.id, parser, msg, session) + saved_transferences = session.exec(select(DbTransference)).all() - assert result is True - mock_session.add.assert_called_once() + assert result is True + assert len(saved_transferences) == 1 class TestUserBankIntegration: diff --git a/web/app/components/categories/CategoryManager.tsx b/web/app/components/categories/CategoryManager.tsx new file mode 100644 index 0000000..cdb06f7 --- /dev/null +++ b/web/app/components/categories/CategoryManager.tsx @@ -0,0 +1,496 @@ +import { useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; +import { categoriesApi } from '../../lib/api'; +import type { Category } from '../../types/models'; + +const KEYWORD_SEPARATOR = /[,\n]/; + +function parseKeywords(value: string): string[] { + return value + .split(KEYWORD_SEPARATOR) + .map((keyword) => keyword.trim()) + .filter(Boolean); +} + +function errorMessage(error: unknown, fallback: string): string { + const detail: unknown = axios.isAxiosError(error) ? error.response?.data?.detail : undefined; + if (typeof detail === 'string') { + return detail; + } + if (Array.isArray(detail)) { + // FastAPI validation errors arrive as a list of {loc, msg, type} objects. + return detail + .map((item) => + item && typeof item === 'object' && 'msg' in item + ? String((item as { msg: unknown }).msg) + : JSON.stringify(item), + ) + .join(', '); + } + return fallback; +} + +type KeywordEditorProps = { + keywords: string[]; + onChange: (keywords: string[]) => void; + inputId: string; +}; + +function KeywordEditor({ keywords, onChange, inputId }: KeywordEditorProps) { + const [draft, setDraft] = useState(''); + + const addDraft = () => { + const parsed = parseKeywords(draft); + if (!parsed.length) { + return; + } + const existing = new Set(keywords.map((keyword) => keyword.toLowerCase())); + const added = parsed.filter((keyword) => !existing.has(keyword.toLowerCase())); + onChange([...keywords, ...added]); + setDraft(''); + }; + + return ( +
+
+ {keywords.length === 0 && ( +

+ No keywords yet. Transactions will only land here if you pick it by hand. +

+ )} + {keywords.map((keyword) => ( + + {keyword} + + + ))} +
+
+ setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + addDraft(); + } + }} + type="text" + placeholder="Add a keyword, e.g. CONDOMINIO" + className="min-w-0 flex-1 px-3 py-2 bg-gray-700 border border-gray-600 text-white rounded focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder-gray-500" + /> + +
+

+ Separate several keywords with commas. Matching ignores case and accents. +

+
+ ); +} + +type CategoryRowProps = { + category: Category; + isEditing: boolean; + onEdit: () => void; + onCancel: () => void; + onSave: (payload: { name: string; keywords: string[] }) => void; + onReset: () => void; + onDelete: () => void; + isSaving: boolean; +}; + +function CategoryRow({ + category, + isEditing, + onEdit, + onCancel, + onSave, + onReset, + onDelete, + isSaving, +}: CategoryRowProps) { + const [name, setName] = useState(category.name); + const [keywords, setKeywords] = useState(category.patterns); + + const startEditing = () => { + setName(category.name); + setKeywords(category.patterns); + onEdit(); + }; + + if (!isEditing) { + return ( +
+
+
+
+ {category.name} + {category.is_custom && ( + + Custom + + )} + {category.is_modified && ( + + Edited + + )} +
+

+ {category.patterns.length + ? category.patterns.join(' · ') + : 'No keywords — never matched automatically'} +

+
+ +
+
+ ); + } + + return ( +
+
+
+ + setName(event.target.value)} + type="text" + className="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-white rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + {!category.is_custom && ( +

+ Built-in category — your changes stay private to your account. +

+ )} +
+ +
+ Keywords + +
+ +
+ + + {!category.is_custom && category.is_modified && ( + + )} + {category.is_custom && ( + + )} +
+
+
+ ); +} + +export function CategoryManager() { + const queryClient = useQueryClient(); + const [editingId, setEditingId] = useState(null); + const [filter, setFilter] = useState(''); + const [showCreator, setShowCreator] = useState(false); + const [newName, setNewName] = useState(''); + const [newKeywords, setNewKeywords] = useState([]); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + + const { data: categories = [], isLoading, error: loadError } = useQuery({ + queryKey: ['categories'], + queryFn: categoriesApi.getAll, + }); + + const refreshCategories = () => { + queryClient.invalidateQueries({ queryKey: ['categories'] }); + }; + + const refreshTransactions = () => { + queryClient.invalidateQueries({ queryKey: ['expenses'] }); + queryClient.invalidateQueries({ queryKey: ['transferences'] }); + }; + + const updateMutation = useMutation({ + mutationFn: ({ id, name, keywords }: { id: number; name: string; keywords: string[] }) => + categoriesApi.update(id, { name, patterns: keywords }), + onSuccess: (category) => { + setEditingId(null); + setError(null); + setStatus( + `Saved "${category.name}". Re-apply keywords below to update transactions you already have.`, + ); + refreshCategories(); + refreshTransactions(); + }, + onError: (mutationError: unknown) => { + setStatus(null); + setError(errorMessage(mutationError, 'Could not save the category')); + }, + }); + + const createMutation = useMutation({ + mutationFn: categoriesApi.create, + onSuccess: (category) => { + setNewName(''); + setNewKeywords([]); + setShowCreator(false); + setError(null); + setStatus(`Created "${category.name}".`); + refreshCategories(); + }, + onError: (mutationError: unknown) => { + setStatus(null); + setError(errorMessage(mutationError, 'Could not create the category')); + }, + }); + + const resetMutation = useMutation({ + mutationFn: categoriesApi.reset, + onSuccess: (category) => { + setEditingId(null); + setError(null); + setStatus(`"${category.name}" is back to its defaults.`); + refreshCategories(); + refreshTransactions(); + }, + onError: (mutationError: unknown) => { + setStatus(null); + setError(errorMessage(mutationError, 'Could not reset the category')); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: categoriesApi.remove, + onSuccess: (response) => { + setEditingId(null); + setError(null); + setStatus( + response.reassigned_expenses + ? `Category deleted. ${response.reassigned_expenses} transaction(s) moved to "Otros".` + : 'Category deleted.', + ); + refreshCategories(); + refreshTransactions(); + }, + onError: (mutationError: unknown) => { + setStatus(null); + setError(errorMessage(mutationError, 'Could not delete the category')); + }, + }); + + const recategorizeMutation = useMutation({ + mutationFn: categoriesApi.recategorize, + onSuccess: (response) => { + setError(null); + setStatus( + `Re-applied keywords: ${response.expenses_updated} expense(s) and ` + + `${response.transferences_updated} transfer(s) recategorized.`, + ); + refreshTransactions(); + }, + onError: (mutationError: unknown) => { + setStatus(null); + setError(errorMessage(mutationError, 'Could not re-apply the keywords')); + }, + }); + + const visibleCategories = useMemo(() => { + const needle = filter.trim().toLowerCase(); + if (!needle) { + return categories; + } + return categories.filter( + (category) => + category.name.toLowerCase().includes(needle) || + category.patterns.some((pattern) => pattern.toLowerCase().includes(needle)), + ); + }, [categories, filter]); + + const handleCreate = () => { + if (!newName.trim()) { + setError('Enter a name for the category'); + return; + } + createMutation.mutate({ name: newName.trim(), patterns: newKeywords }); + }; + + const handleDelete = (category: Category) => { + const confirmed = window.confirm( + `Delete "${category.name}"? Its transactions move to "Otros".`, + ); + if (confirmed) { + deleteMutation.mutate(category.id); + } + }; + + return ( +
+
+
+
+

Categories

+

+ Keywords decide where an imported transaction lands. Edit any category — including + the built-in ones — or add your own. +

+
+ +
+ + {status &&

{status}

} + {error &&

{error}

} + {loadError && ( +

Could not load your categories.

+ )} +
+ +
+
+ setFilter(event.target.value)} + type="search" + placeholder="Filter by name or keyword" + className="w-full sm:max-w-xs px-3 py-2 bg-gray-700 border border-gray-600 text-white rounded focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder-gray-500" + /> + +
+ + {showCreator && ( +
+
+ + setNewName(event.target.value)} + type="text" + placeholder="e.g. Pets" + className="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-white rounded focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder-gray-500" + /> +
+
+ Keywords + +
+ +
+ )} + + {isLoading ? ( +

Loading categories...

+ ) : visibleCategories.length === 0 ? ( +

No category matches "{filter}".

+ ) : ( +
+ {visibleCategories.map((category) => ( + { + setEditingId(category.id); + setStatus(null); + setError(null); + }} + onCancel={() => setEditingId(null)} + onSave={({ name, keywords }) => + updateMutation.mutate({ id: category.id, name, keywords }) + } + onReset={() => resetMutation.mutate(category.id)} + onDelete={() => handleDelete(category)} + isSaving={updateMutation.isPending} + /> + ))} +
+ )} +
+
+ ); +} diff --git a/web/app/components/expenses/ExpenseForm.tsx b/web/app/components/expenses/ExpenseForm.tsx index ac0e2ef..621ff3d 100644 --- a/web/app/components/expenses/ExpenseForm.tsx +++ b/web/app/components/expenses/ExpenseForm.tsx @@ -254,7 +254,7 @@ export function ExpenseForm() { {...register('description')} rows={3} placeholder="Notas adicionales..." - className="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-white rounded focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder-gray-400" + className="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-white rounded focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder-gray-400 min-h-20" /> diff --git a/web/app/components/layout/Navbar.tsx b/web/app/components/layout/Navbar.tsx index 02ac203..caf2c3b 100644 --- a/web/app/components/layout/Navbar.tsx +++ b/web/app/components/layout/Navbar.tsx @@ -38,6 +38,12 @@ export function Navbar() { > Profile + + Categories +
diff --git a/web/app/lib/api.ts b/web/app/lib/api.ts index 8a8bde4..73bb3ee 100644 --- a/web/app/lib/api.ts +++ b/web/app/lib/api.ts @@ -3,11 +3,14 @@ import type { Category, CreateCategoryRequest, CreateExpenseRequest, + DeleteCategoryResponse, Expense, GenericResponse, IngestAddress, IngestConfirmationResponse, + RecategorizeResponse, Transference, + UpdateCategoryRequest, User, UserLoginResponse, UserUpdate, @@ -126,6 +129,28 @@ export const categoriesApi = { const response = await api.post("/categories", payload); return response.data; }, + + update: async (id: number, payload: UpdateCategoryRequest): Promise => { + const response = await api.patch(`/categories/${id}`, payload); + return response.data; + }, + + /** Restore a built-in category to its default name and keywords. */ + reset: async (id: number): Promise => { + const response = await api.post(`/categories/${id}/reset`); + return response.data; + }, + + remove: async (id: number): Promise => { + const response = await api.delete(`/categories/${id}`); + return response.data; + }, + + /** Re-apply the current keywords to already stored transactions. */ + recategorize: async (): Promise => { + const response = await api.post("/categories/recategorize"); + return response.data; + }, }; // Forwarding ingestion API diff --git a/web/app/routes.ts b/web/app/routes.ts index 47def70..18873bc 100644 --- a/web/app/routes.ts +++ b/web/app/routes.ts @@ -11,5 +11,6 @@ export default [ layout("routes/protected.tsx", [ route("dashboard", "routes/dashboard.tsx"), route("profile", "routes/profile.tsx"), + route("categories", "routes/categories.tsx"), ]), ] satisfies RouteConfig; diff --git a/web/app/routes/categories.tsx b/web/app/routes/categories.tsx new file mode 100644 index 0000000..b09e43c --- /dev/null +++ b/web/app/routes/categories.tsx @@ -0,0 +1,20 @@ +import { Link } from 'react-router'; +import { Navbar } from '../components/layout/Navbar'; +import { CategoryManager } from '../components/categories/CategoryManager'; + +export default function CategoriesPage() { + return ( +
+ +
+
+ + ← Back to settings + +

Manage Categories

+
+ +
+
+ ); +} diff --git a/web/app/routes/profile.tsx b/web/app/routes/profile.tsx index 2cfe31c..c600cfa 100644 --- a/web/app/routes/profile.tsx +++ b/web/app/routes/profile.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Link } from "react-router"; import { Navbar } from "../components/layout/Navbar"; import { authApi, banksApi, ingestApi } from "../lib/api"; @@ -201,6 +202,25 @@ export default function ProfilePage() { )}
+ {/* Categories */} +
+
+
+

Categories

+

+ Edit the keywords that categorize your transactions, rename built-in categories + or add your own. +

+
+ + Manage categories + +
+
+ {/* Google Sign-in Status */}

Google Sign-in

diff --git a/web/app/types/models.ts b/web/app/types/models.ts index 76595cd..6452fbc 100644 --- a/web/app/types/models.ts +++ b/web/app/types/models.ts @@ -97,12 +97,32 @@ export interface Category { name_en: string; name_es: string; is_custom: boolean; + /** A built-in category the user renamed or whose keywords they replaced. */ + is_modified: boolean; patterns: string[]; } export interface CreateCategoryRequest { name: string; + /** Legacy single-keyword field, still accepted by the API. */ pattern?: string; + patterns?: string[]; +} + +export interface UpdateCategoryRequest { + name?: string; + patterns?: string[]; +} + +export interface DeleteCategoryResponse { + msg: string; + reassigned_expenses: number; +} + +export interface RecategorizeResponse { + msg: string; + expenses_updated: number; + transferences_updated: number; } export interface GenericResponse { From 63404d703397a78ea6e6756fed307221f00331b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Pe=C3=B1a?= Date: Sun, 26 Jul 2026 16:29:08 -0400 Subject: [PATCH 2/4] fix: only fork builtin keywords when they actually change The UI always posts both name and patterns, so a pure rename set patterns_overridden=True and silently opted the user out of any keyword later seeded into the builtin catalog. Compare the submitted set against the catalog: identical means stay on the shared catalog (dropping an existing fork), and an override row with no rename and no fork is deleted so the category stops reading as edited. --- src/db/categories.py | 33 +++++++++++++++++++++++++++++-- tests/test_category_management.py | 31 +++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/db/categories.py b/src/db/categories.py index a1ab70a..ed02a8d 100644 --- a/src/db/categories.py +++ b/src/db/categories.py @@ -413,6 +413,20 @@ def _apply_own_category_update( _replace_patterns(session, category, user_id, _clean_patterns(patterns)) +def _catalog_patterns(session: Session, category_id: int) -> list[str]: + rows = session.exec( + select(CategoryPattern).where( + CategoryPattern.category_id == category_id, + CategoryPattern.user_id.is_(None), + ) + ).all() + return [row.pattern for row in rows] + + +def _same_keyword_set(left: list[str], right: list[str]) -> bool: + return {value.casefold() for value in left} == {value.casefold() for value in right} + + def _apply_builtin_category_override( session: Session, category: Category, @@ -432,9 +446,24 @@ def _apply_builtin_category_override( if name is not None: # Storing the builtin name back is the same as having no rename. override.name = None if name == category.name_es else name + if patterns is not None: - override.patterns_overridden = True - _replace_patterns(session, category, user_id, _clean_patterns(patterns)) + cleaned = _clean_patterns(patterns) + if _same_keyword_set(cleaned, _catalog_patterns(session, category.id)): + # Submitting the catalog keywords unchanged (a rename-only save, or + # an edit undone) must not fork: stay on the shared catalog so + # keywords added to the builtin later still reach this user. + override.patterns_overridden = False + _replace_patterns(session, category, user_id, []) + else: + override.patterns_overridden = True + _replace_patterns(session, category, user_id, cleaned) + + if override.name is None and not override.patterns_overridden: + # Nothing left to override; drop the row so the category reads as unedited. + if override.id is not None: + session.delete(override) + return session.add(override) diff --git a/tests/test_category_management.py b/tests/test_category_management.py index ac1bd14..18e2661 100644 --- a/tests/test_category_management.py +++ b/tests/test_category_management.py @@ -126,6 +126,37 @@ def test_renaming_a_builtin_category_is_private_to_the_user(session): assert session.get(Category, housing.id).name_es == "Vivienda" +def test_renaming_without_touching_keywords_does_not_fork_them(session): + """A rename-only save still posts the current keywords; that must not fork.""" + owner = create_user(session, "owner", "owner@example.com") + housing = get_global_category_by_slug(session, "housing") + current_keywords = view_by_slug(session, owner.id, "housing").patterns + + update_category(session, owner.id, housing.id, CategoryUpdate(name="Casa", patterns=current_keywords)) + # A keyword added to the shared catalog later must still reach this user. + session.add(CategoryPattern(category_id=housing.id, pattern="GASTO COMUN")) + session.commit() + + view = view_by_slug(session, owner.id, "housing") + assert view.name == "Casa" + assert "GASTO COMUN" in view.patterns + assert resolve_category_for_user_text(session, owner.id, "PAGO GASTO COMUN", "general").slug == "housing" + + +def test_restoring_the_catalog_keywords_drops_the_fork(session): + owner = create_user(session, "owner", "owner@example.com") + housing = get_global_category_by_slug(session, "housing") + default_keywords = view_by_slug(session, owner.id, "housing").patterns + + update_category(session, owner.id, housing.id, CategoryUpdate(patterns=["JUNTA DE VECINOS"])) + update_category(session, owner.id, housing.id, CategoryUpdate(patterns=default_keywords)) + + view = view_by_slug(session, owner.id, "housing") + assert view.is_modified is False + assert view.patterns == default_keywords + assert session.exec(select(CategoryPattern).where(CategoryPattern.user_id == owner.id)).all() == [] + + def test_builtin_keywords_are_not_reseeded_into_a_forked_category(session): owner = create_user(session, "owner", "owner@example.com") food = get_global_category_by_slug(session, "food") From d9ca6dcfe87d076667dc6e15cb5bb91e4d27075c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Pe=C3=B1a?= Date: Sun, 26 Jul 2026 16:58:51 -0400 Subject: [PATCH 3/4] fix: restore NOT NULL on transferences.category when downgrading The reversed schema dropped the constraint the initial migration created. Every row is backfilled just above, so the original nullability can come back and down/up stops drifting. --- alembic/versions/d5b8f1c07e42_add_transference_category_id.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/alembic/versions/d5b8f1c07e42_add_transference_category_id.py b/alembic/versions/d5b8f1c07e42_add_transference_category_id.py index 3252a14..bc5f605 100644 --- a/alembic/versions/d5b8f1c07e42_add_transference_category_id.py +++ b/alembic/versions/d5b8f1c07e42_add_transference_category_id.py @@ -125,6 +125,8 @@ def downgrade() -> None: ) with op.batch_alter_table("transferences") as batch_op: + # Every row was just backfilled, so the original NOT NULL can come back. + batch_op.alter_column("category", existing_type=_legacy_category_type(), nullable=False) batch_op.drop_index("ix_transferences_category_id") batch_op.drop_constraint("fk_transferences_category_id_categories", type_="foreignkey") batch_op.drop_column("category_id") From c385f464f77a81c97e78a4a7aca17e93f7de689b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Pe=C3=B1a?= Date: Sun, 26 Jul 2026 16:58:56 -0400 Subject: [PATCH 4/4] chore: drop unrelated textarea CSS tweak from this branch Belongs in its own change, not in the categories feature. --- web/app/components/expenses/ExpenseForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/components/expenses/ExpenseForm.tsx b/web/app/components/expenses/ExpenseForm.tsx index 621ff3d..ac0e2ef 100644 --- a/web/app/components/expenses/ExpenseForm.tsx +++ b/web/app/components/expenses/ExpenseForm.tsx @@ -254,7 +254,7 @@ export function ExpenseForm() { {...register('description')} rows={3} placeholder="Notas adicionales..." - className="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-white rounded focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder-gray-400 min-h-20" + className="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-white rounded focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder-gray-400" />