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..bc5f605 --- /dev/null +++ b/alembic/versions/d5b8f1c07e42_add_transference_category_id.py @@ -0,0 +1,132 @@ +"""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: + # 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") 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..ed02a8d 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) + - matches: list[tuple[int, int, Category]] = [] - for pattern, category in rows: - normalized_pattern = _normalize_pattern(pattern.pattern) +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 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,176 @@ 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 _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, + 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: + 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) + + +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..18e2661 --- /dev/null +++ b/tests/test_category_management.py @@ -0,0 +1,480 @@ +"""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_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") + + 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/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 {