diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 9134d11..d24bee3 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -17,6 +17,7 @@ from app.models import ( # noqa: F401 — registers metadata Goal, GoalContribution, RecurringTransaction, + Tag, Transaction, ) diff --git a/backend/alembic/versions/f6a4d8b2e017_add_tags.py b/backend/alembic/versions/f6a4d8b2e017_add_tags.py new file mode 100644 index 0000000..d256e54 --- /dev/null +++ b/backend/alembic/versions/f6a4d8b2e017_add_tags.py @@ -0,0 +1,41 @@ +"""add tags and transaction_tags + +Revision ID: f6a4d8b2e017 +Revises: 8e2f5a13c9d4 +Create Date: 2026-08-25 11:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f6a4d8b2e017' +down_revision: Union[str, None] = '8e2f5a13c9d4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'tags', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name'), + ) + op.create_table( + 'transaction_tags', + sa.Column('transaction_id', sa.Integer(), nullable=False), + sa.Column('tag_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['transaction_id'], ['transactions.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('transaction_id', 'tag_id'), + ) + + +def downgrade() -> None: + op.drop_table('transaction_tags') + op.drop_table('tags') diff --git a/backend/app/api/routes/tags.py b/backend/app/api/routes/tags.py new file mode 100644 index 0000000..ea5d0e7 --- /dev/null +++ b/backend/app/api/routes/tags.py @@ -0,0 +1,41 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_session +from app.models.tag import Tag +from app.schemas.tag import TagCreate, TagRead + +router = APIRouter(prefix="/tags", tags=["tags"]) + + +@router.get("", response_model=list[TagRead]) +async def list_tags(session: AsyncSession = Depends(get_session)) -> list[Tag]: + result = await session.execute(select(Tag).order_by(Tag.name)) + return list(result.scalars().all()) + + +@router.post("", response_model=TagRead, status_code=201) +async def create_tag(payload: TagCreate, session: AsyncSession = Depends(get_session)) -> Tag: + # Case-insensitive dedup — the frontend's tag picker creates tags + # on the fly as the user types, so "Georgia" and "georgia" typed on two + # different transactions should end up as the same tag, not two. + name = payload.name.strip() + existing = await session.execute(select(Tag).where(func.lower(Tag.name) == name.lower())) + tag = existing.scalar_one_or_none() + if tag is not None: + return tag + tag = Tag(name=name) + session.add(tag) + await session.commit() + await session.refresh(tag) + return tag + + +@router.delete("/{tag_id}", status_code=204) +async def delete_tag(tag_id: int, session: AsyncSession = Depends(get_session)) -> None: + tag = await session.get(Tag, tag_id) + if tag is None: + raise HTTPException(status_code=404, detail="Tag not found") + await session.delete(tag) + await session.commit() diff --git a/backend/app/api/routes/transactions.py b/backend/app/api/routes/transactions.py index ce3f9e8..4aca192 100644 --- a/backend/app/api/routes/transactions.py +++ b/backend/app/api/routes/transactions.py @@ -9,12 +9,24 @@ from sqlalchemy.orm import selectinload from app.api.deps import get_session from app.models.category import Category from app.models.enums import CategoryKind, TransactionType +from app.models.tag import Tag from app.models.transaction import Transaction from app.schemas.transaction import TransactionCreate, TransactionPage, TransactionRead, TransactionUpdate router = APIRouter(prefix="/transactions", tags=["transactions"]) -_EAGER = (selectinload(Transaction.account), selectinload(Transaction.category)) +_EAGER = (selectinload(Transaction.account), selectinload(Transaction.category), selectinload(Transaction.tags)) + + +async def _resolve_tags(session: AsyncSession, tag_ids: list[int]) -> list[Tag]: + if not tag_ids: + return [] + result = await session.execute(select(Tag).where(Tag.id.in_(tag_ids))) + tags = list(result.scalars().all()) + missing = set(tag_ids) - {tag.id for tag in tags} + if missing: + raise HTTPException(status_code=400, detail=f"Unknown tag id(s): {sorted(missing)}") + return tags _TYPE_TO_CATEGORY_KIND = { TransactionType.INCOME: CategoryKind.INCOME, @@ -50,6 +62,7 @@ async def list_transactions( end_date: date_ | None = Query(default=None), account_id: int | None = None, category_id: int | None = None, + tag_id: int | None = None, type: TransactionType | None = None, search: str | None = Query(default=None, min_length=1, max_length=255), sort: Literal["date_desc", "amount_desc", "amount_asc"] = Query(default="date_desc"), @@ -78,6 +91,9 @@ async def list_transactions( if category_id is not None: stmt = stmt.where(Transaction.category_id == category_id) count_stmt = count_stmt.where(Transaction.category_id == category_id) + if tag_id is not None: + stmt = stmt.where(Transaction.tags.any(Tag.id == tag_id)) + count_stmt = count_stmt.where(Transaction.tags.any(Tag.id == tag_id)) if type is not None: stmt = stmt.where(Transaction.type == type) count_stmt = count_stmt.where(Transaction.type == type) @@ -127,7 +143,9 @@ async def list_transaction_years(session: AsyncSession = Depends(get_session)) - @router.post("", response_model=TransactionRead, status_code=201) async def create_transaction(payload: TransactionCreate, session: AsyncSession = Depends(get_session)) -> Transaction: await _ensure_category_matches_type(session, payload.category_id, payload.type) - transaction = Transaction(**payload.model_dump()) + fields = payload.model_dump(exclude={"tag_ids"}) + transaction = Transaction(**fields) + transaction.tags = await _resolve_tags(session, payload.tag_ids) session.add(transaction) await session.commit() refreshed = await session.execute( @@ -140,15 +158,20 @@ async def create_transaction(payload: TransactionCreate, session: AsyncSession = async def update_transaction( transaction_id: int, payload: TransactionUpdate, session: AsyncSession = Depends(get_session) ) -> Transaction: - transaction = await session.get(Transaction, transaction_id) + # Eager-loads tags — assigning transaction.tags below would otherwise lazy + # -load the current collection first to diff against, which async + # SQLAlchemy can't do outside an explicit await (MissingGreenlet). + transaction = await session.get(Transaction, transaction_id, options=[selectinload(Transaction.tags)]) if transaction is None: raise HTTPException(status_code=404, detail="Transaction not found") - updates = payload.model_dump(exclude_unset=True) + updates = payload.model_dump(exclude_unset=True, exclude={"tag_ids"}) effective_type = updates.get("type", transaction.type) effective_category_id = updates.get("category_id", transaction.category_id) await _ensure_category_matches_type(session, effective_category_id, effective_type) for field, value in updates.items(): setattr(transaction, field, value) + if payload.tag_ids is not None: + transaction.tags = await _resolve_tags(session, payload.tag_ids) await session.commit() refreshed = await session.execute( select(Transaction).options(*_EAGER).where(Transaction.id == transaction_id) diff --git a/backend/app/main.py b/backend/app/main.py index e1f5a44..06f40cb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,6 +18,7 @@ from app.api.routes import ( recurring, reports, settings as settings_routes, + tags, transactions, ) from app.core.config import APP_VERSION, get_settings @@ -61,6 +62,7 @@ app.include_router(advice.router, prefix="/api") app.include_router(goals.router, prefix="/api") app.include_router(recurring.router, prefix="/api") app.include_router(cash_flow.router, prefix="/api") +app.include_router(tags.router, prefix="/api") @app.get("/api/health") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 25b533b..68d6a4b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -5,6 +5,7 @@ from app.models.category import Category from app.models.goal import Goal, GoalContribution from app.models.recurring import RecurringTransaction from app.models.settings import AppSettings +from app.models.tag import Tag from app.models.transaction import Transaction __all__ = [ @@ -17,5 +18,6 @@ __all__ = [ "Goal", "GoalContribution", "RecurringTransaction", + "Tag", "Transaction", ] diff --git a/backend/app/models/tag.py b/backend/app/models/tag.py new file mode 100644 index 0000000..7dfeabd --- /dev/null +++ b/backend/app/models/tag.py @@ -0,0 +1,26 @@ +"""A free-form, reusable label on transactions — orthogonal to Category: +a category answers "what kind of spend is this", a tag answers "which +event/project does it belong to" (e.g. "trip:georgia", "tax-deductible"). +A transaction can carry any number of tags, a tag can be reused across any +number of transactions — see transaction_tags below. +""" +from sqlalchemy import Column, ForeignKey, String, Table +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + +transaction_tags = Table( + "transaction_tags", + Base.metadata, + Column("transaction_id", ForeignKey("transactions.id", ondelete="CASCADE"), primary_key=True), + Column("tag_id", ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True), +) + + +class Tag(Base): + __tablename__ = "tags" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(50), nullable=False, unique=True) + + transactions: Mapped[list["Transaction"]] = relationship(secondary=transaction_tags, back_populates="tags") diff --git a/backend/app/models/transaction.py b/backend/app/models/transaction.py index 245c0b3..c51397a 100644 --- a/backend/app/models/transaction.py +++ b/backend/app/models/transaction.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base from app.models.enums import TransactionType from app.models.mixins import TimestampMixin +from app.models.tag import transaction_tags class Transaction(Base, TimestampMixin): @@ -33,3 +34,4 @@ class Transaction(Base, TimestampMixin): account: Mapped["Account"] = relationship(back_populates="transactions", foreign_keys=[account_id]) transfer_account: Mapped["Account | None"] = relationship(foreign_keys=[transfer_account_id]) category: Mapped["Category | None"] = relationship(back_populates="transactions") + tags: Mapped[list["Tag"]] = relationship(secondary=transaction_tags, back_populates="transactions") diff --git a/backend/app/schemas/backup.py b/backend/app/schemas/backup.py index d923cc5..91503e6 100644 --- a/backend/app/schemas/backup.py +++ b/backend/app/schemas/backup.py @@ -42,6 +42,13 @@ class CategoryBackup(BaseModel): parent_id: int | None = None +class TagBackup(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + + class TransactionBackup(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -55,6 +62,11 @@ class TransactionBackup(BaseModel): merchant: str | None notes: str | None date: date_ + # Defaulted so a backup exported before tags existed still imports + # cleanly under the same format version. Not a plain column — populated + # explicitly in build_backup() from the `tags` relationship, since + # from_attributes can't map a `tags` relationship to a `tag_ids` field. + tag_ids: list[int] = Field(default_factory=list) class AssetBackup(BaseModel): @@ -155,6 +167,9 @@ class BackupPayload(BaseModel): app_version: str accounts: list[AccountBackup] categories: list[CategoryBackup] + # Defaulted so a backup exported before tags existed still imports + # cleanly under the same format version. + tags: list[TagBackup] = Field(default_factory=list) transactions: list[TransactionBackup] assets: list[AssetBackup] asset_valuations: list[AssetValuationBackup] diff --git a/backend/app/schemas/tag.py b/backend/app/schemas/tag.py new file mode 100644 index 0000000..d37e9ad --- /dev/null +++ b/backend/app/schemas/tag.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, ConfigDict, Field + + +class TagCreate(BaseModel): + name: str = Field(min_length=1, max_length=50) + + +class TagRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str diff --git a/backend/app/schemas/transaction.py b/backend/app/schemas/transaction.py index 9c6d089..907b2bb 100644 --- a/backend/app/schemas/transaction.py +++ b/backend/app/schemas/transaction.py @@ -7,6 +7,7 @@ from app.core.text import capitalize_first_letter from app.models.enums import TransactionType from app.schemas.account import AccountRead from app.schemas.category import CategoryRead +from app.schemas.tag import TagRead class TransactionBase(BaseModel): @@ -41,7 +42,7 @@ class TransactionBase(BaseModel): class TransactionCreate(TransactionBase): - pass + tag_ids: list[int] = Field(default_factory=list) class TransactionUpdate(BaseModel): @@ -54,6 +55,8 @@ class TransactionUpdate(BaseModel): merchant: str | None = Field(default=None, max_length=150) notes: str | None = None date: date_ | None = None + # Omitted -> tags untouched; sent (even as []) -> replaces the full tag set. + tag_ids: list[int] | None = None @field_validator("description") @classmethod @@ -67,6 +70,7 @@ class TransactionRead(TransactionBase): id: int account: AccountRead category: CategoryRead | None = None + tags: list[TagRead] = Field(default_factory=list) class TransactionPage(BaseModel): diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py index 9c98e80..39fbd27 100644 --- a/backend/app/services/backup_service.py +++ b/backend/app/services/backup_service.py @@ -14,6 +14,7 @@ from datetime import datetime, timezone from fastapi import HTTPException from sqlalchemy import delete, select, text from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.core.config import APP_VERSION from app.models.account import Account @@ -23,6 +24,7 @@ from app.models.category import Category from app.models.goal import Goal, GoalContribution from app.models.recurring import RecurringTransaction from app.models.settings import AppSettings +from app.models.tag import Tag from app.models.transaction import Transaction from app.schemas.backup import ( AccountBackup, @@ -35,6 +37,7 @@ from app.schemas.backup import ( GoalBackup, GoalContributionBackup, RecurringTransactionBackup, + TagBackup, TransactionBackup, ) @@ -44,7 +47,8 @@ BACKUP_FORMAT_VERSION = 1 async def build_backup(session: AsyncSession) -> BackupPayload: accounts = (await session.execute(select(Account))).scalars().all() categories = (await session.execute(select(Category))).scalars().all() - transactions = (await session.execute(select(Transaction))).scalars().all() + tags = (await session.execute(select(Tag))).scalars().all() + transactions = (await session.execute(select(Transaction).options(selectinload(Transaction.tags)))).scalars().all() assets = (await session.execute(select(Asset))).scalars().all() valuations = (await session.execute(select(AssetValuation))).scalars().all() budgets = (await session.execute(select(Budget))).scalars().all() @@ -59,7 +63,13 @@ async def build_backup(session: AsyncSession) -> BackupPayload: app_version=APP_VERSION, accounts=[AccountBackup.model_validate(row) for row in accounts], categories=[CategoryBackup.model_validate(row) for row in categories], - transactions=[TransactionBackup.model_validate(row) for row in transactions], + tags=[TagBackup.model_validate(row) for row in tags], + transactions=[ + TransactionBackup.model_validate(row, from_attributes=True).model_copy( + update={"tag_ids": [tag.id for tag in row.tags]} + ) + for row in transactions + ], assets=[AssetBackup.model_validate(row) for row in assets], asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations], budgets=[BudgetBackup.model_validate(row) for row in budgets], @@ -74,6 +84,7 @@ def _validate_references(payload: BackupPayload) -> None: account_ids = {row.id for row in payload.accounts} category_ids = {row.id for row in payload.categories} asset_ids = {row.id for row in payload.assets} + tag_ids = {row.id for row in payload.tags} for c in payload.categories: if c.parent_id is not None and c.parent_id not in category_ids: @@ -88,6 +99,9 @@ def _validate_references(payload: BackupPayload) -> None: ) if t.category_id is not None and t.category_id not in category_ids: raise HTTPException(400, f"Transaction {t.id} references unknown category_id {t.category_id}") + for tag_id in t.tag_ids: + if tag_id not in tag_ids: + raise HTTPException(400, f"Transaction {t.id} references unknown tag_id {tag_id}") for v in payload.asset_valuations: if v.asset_id not in asset_ids: @@ -144,7 +158,10 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None: await session.execute(delete(GoalContribution)) await session.execute(delete(Goal)) await session.execute(delete(RecurringTransaction)) + # Deleting transactions cascades transaction_tags rows (ON DELETE + # CASCADE) — tags themselves still need their own delete. await session.execute(delete(Transaction)) + await session.execute(delete(Tag)) await session.execute(delete(Asset)) await session.execute(delete(Category)) await session.execute(delete(Account)) @@ -158,7 +175,22 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None: session.add_all(Account(**row.model_dump()) for row in payload.accounts) session.add_all(Category(**row.model_dump()) for row in categories_in_order) session.add_all(Asset(**row.model_dump()) for row in payload.assets) - session.add_all(Transaction(**row.model_dump()) for row in payload.transactions) + + # Tags and transactions are kept in id-keyed dicts (rather than a + # plain add_all) — transaction.tags is a relationship, not a column + # in model_dump(), so it has to be wired up from live ORM objects + # once everything is flushed and has real identities. + tags_by_id = {row.id: Tag(**row.model_dump()) for row in payload.tags} + session.add_all(tags_by_id.values()) + # tags=[] at construction keeps the collection "loaded" on the + # transient object — reassigning it after flush (below) would + # otherwise trigger an implicit lazy-load, which async SQLAlchemy + # can't do outside an explicit await (MissingGreenlet). + transactions_by_id = { + row.id: Transaction(**row.model_dump(exclude={"tag_ids"}), tags=[]) for row in payload.transactions + } + session.add_all(transactions_by_id.values()) + session.add_all(AssetValuation(**row.model_dump()) for row in payload.asset_valuations) session.add_all(Budget(**row.model_dump()) for row in payload.budgets) session.add_all(Goal(**row.model_dump()) for row in payload.goals) @@ -166,8 +198,15 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None: session.add_all(RecurringTransaction(**row.model_dump()) for row in payload.recurring_transactions) await session.flush() + for row in payload.transactions: + if row.tag_ids: + transactions_by_id[row.id].tags = [tags_by_id[tag_id] for tag_id in row.tag_ids] + if any(row.tag_ids for row in payload.transactions): + await session.flush() + await _reset_sequence(session, "accounts", payload.accounts) await _reset_sequence(session, "categories", payload.categories) + await _reset_sequence(session, "tags", payload.tags) await _reset_sequence(session, "assets", payload.assets) await _reset_sequence(session, "transactions", payload.transactions) await _reset_sequence(session, "asset_valuations", payload.asset_valuations) diff --git a/backend/tests/test_backup.py b/backend/tests/test_backup.py new file mode 100644 index 0000000..2c8f1f7 --- /dev/null +++ b/backend/tests/test_backup.py @@ -0,0 +1,64 @@ +"""Full-database backup export/import (services/backup_service.py) — only +the parts this change touched: subcategories (self-referential parent_id) +and tags (many-to-many) surviving a round trip. +""" +from httpx import AsyncClient + +from tests.helpers import txn_payload as _txn + + +async def test_backup_roundtrip_preserves_subcategories_and_tags(client: AsyncClient, account_id, categories): + parent = await client.post("/categories", json={"name": "Custom Parent", "kind": "expense", "color": "#e34948"}) + parent_id = parent.json()["id"] + child = await client.post( + "/categories", json={"name": "Custom Child", "kind": "expense", "color": "#e34948", "parent_id": parent_id} + ) + child_id = child.json()["id"] + + tag = (await client.post("/tags", json={"name": "Roundtrip"})).json()["id"] + created = await client.post("/transactions", json=_txn(account_id, category_id=child_id, tag_ids=[tag])) + txn_id = created.json()["id"] + + export_resp = await client.get("/backup/export") + assert export_resp.status_code == 200 + payload = export_resp.json() + + import_resp = await client.post("/backup/import", json=payload) + assert import_resp.status_code == 200, import_resp.text + + refetched_categories = {c["id"]: c for c in (await client.get("/categories")).json()} + assert refetched_categories[child_id]["parent_id"] == parent_id + + refetched_txn = next(t for t in (await client.get("/transactions")).json()["items"] if t["id"] == txn_id) + assert [t["id"] for t in refetched_txn["tags"]] == [tag] + + +async def test_backup_import_rejects_transaction_with_unknown_tag_id(client: AsyncClient, account_id, categories): + export_resp = await client.get("/backup/export") + payload = export_resp.json() + + created = await client.post( + "/transactions", json=_txn(account_id, category_id=categories["Groceries"]["id"], date="2026-01-01") + ) + payload["transactions"] = (await client.get("/transactions")).json()["items"] + # Reshape to the backup wire format (flat FKs, not nested account/category + # objects) and inject a tag_id that doesn't exist in payload["tags"]. + payload["transactions"] = [ + { + "id": t["id"], + "account_id": t["account_id"], + "category_id": t["category_id"], + "transfer_account_id": t["transfer_account_id"], + "type": t["type"], + "amount": t["amount"], + "description": t["description"], + "merchant": t["merchant"], + "notes": t["notes"], + "date": t["date"], + "tag_ids": [999999] if t["id"] == created.json()["id"] else [], + } + for t in payload["transactions"] + ] + + resp = await client.post("/backup/import", json=payload) + assert resp.status_code == 400 diff --git a/backend/tests/test_tags.py b/backend/tests/test_tags.py new file mode 100644 index 0000000..42866a0 --- /dev/null +++ b/backend/tests/test_tags.py @@ -0,0 +1,80 @@ +"""Tags: CRUD, case-insensitive dedup on create, and their use on +transactions (create/update with tag_ids, filtering by tag_id). +""" +from httpx import AsyncClient + +from tests.helpers import txn_payload as _txn + + +async def test_create_tag(client: AsyncClient): + resp = await client.post("/tags", json={"name": "Georgia Trip"}) + assert resp.status_code == 201 + assert resp.json()["name"] == "Georgia Trip" + + +async def test_create_tag_dedups_case_insensitively(client: AsyncClient): + first = await client.post("/tags", json={"name": "Tax Deductible"}) + second = await client.post("/tags", json={"name": "tax deductible"}) + + assert first.json()["id"] == second.json()["id"] + assert len((await client.get("/tags")).json()) == 1 + + +async def test_delete_tag(client: AsyncClient): + created = await client.post("/tags", json={"name": "Temp"}) + resp = await client.delete(f"/tags/{created.json()['id']}") + assert resp.status_code == 204 + assert (await client.get("/tags")).json() == [] + + +async def test_create_transaction_with_tags(client: AsyncClient, account_id, categories): + tag = await client.post("/tags", json={"name": "Georgia"}) + tag_id = tag.json()["id"] + + resp = await client.post( + "/transactions", + json=_txn(account_id, category_id=categories["Groceries"]["id"], tag_ids=[tag_id]), + ) + assert resp.status_code == 201 + assert [t["id"] for t in resp.json()["tags"]] == [tag_id] + + +async def test_update_transaction_replaces_tags(client: AsyncClient, account_id, categories): + tag_a = (await client.post("/tags", json={"name": "A"})).json()["id"] + tag_b = (await client.post("/tags", json={"name": "B"})).json()["id"] + created = await client.post( + "/transactions", json=_txn(account_id, category_id=categories["Groceries"]["id"], tag_ids=[tag_a]) + ) + txn_id = created.json()["id"] + + resp = await client.patch(f"/transactions/{txn_id}", json={"tag_ids": [tag_b]}) + assert [t["id"] for t in resp.json()["tags"]] == [tag_b] + + # Omitting tag_ids entirely leaves the existing tags untouched. + resp = await client.patch(f"/transactions/{txn_id}", json={"description": "renamed"}) + assert [t["id"] for t in resp.json()["tags"]] == [tag_b] + + # Sending an empty list clears them. + resp = await client.patch(f"/transactions/{txn_id}", json={"tag_ids": []}) + assert resp.json()["tags"] == [] + + +async def test_create_transaction_rejects_unknown_tag_id(client: AsyncClient, account_id, categories): + resp = await client.post( + "/transactions", + json=_txn(account_id, category_id=categories["Groceries"]["id"], tag_ids=[999999]), + ) + assert resp.status_code == 400 + + +async def test_filter_transactions_by_tag(client: AsyncClient, account_id, categories): + tag = (await client.post("/tags", json={"name": "Filtered"})).json()["id"] + await client.post( + "/transactions", json=_txn(account_id, category_id=categories["Groceries"]["id"], tag_ids=[tag]) + ) + await client.post("/transactions", json=_txn(account_id, category_id=categories["Groceries"]["id"])) + + resp = await client.get("/transactions", params={"tag_id": tag}) + body = resp.json() + assert body["total"] == 1 + assert body["items"][0]["tags"][0]["id"] == tag diff --git a/frontend/package.json b/frontend/package.json index 72e96cb..0f7e24e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "aurum-frontend", "private": true, - "version": "0.24.0", + "version": "0.25.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/tags.ts b/frontend/src/api/tags.ts new file mode 100644 index 0000000..cb1fe63 --- /dev/null +++ b/frontend/src/api/tags.ts @@ -0,0 +1,14 @@ +import { api } from "@/api/client"; +import type { Tag } from "@/types"; + +export function fetchTags() { + return api.get("/tags"); +} + +export function createTag(name: string) { + return api.post("/tags", { name }); +} + +export function deleteTag(id: number) { + return api.delete(`/tags/${id}`); +} diff --git a/frontend/src/api/transactions.ts b/frontend/src/api/transactions.ts index 3964649..bdee9e1 100644 --- a/frontend/src/api/transactions.ts +++ b/frontend/src/api/transactions.ts @@ -10,6 +10,7 @@ export interface TransactionFilters { end_date?: string; account_id?: number; category_id?: number; + tag_id?: number; type?: string; search?: string; sort?: TransactionSort; diff --git a/frontend/src/components/transactions/TagInput.tsx b/frontend/src/components/transactions/TagInput.tsx new file mode 100644 index 0000000..12ba615 --- /dev/null +++ b/frontend/src/components/transactions/TagInput.tsx @@ -0,0 +1,132 @@ +import { useRef, useState } from "react"; +import { X } from "lucide-react"; +import { useCreateTag, useTags } from "@/hooks/useTags"; +import { useTranslation } from "@/lib/i18n"; +import { cn } from "@/lib/utils"; +import type { Tag } from "@/types"; + +interface TagInputProps { + value: Tag[]; + onChange: (tags: Tag[]) => void; +} + +// Free-text, multi-select tag picker with inline create — types like an +// autocomplete, Enter either picks the top matching existing tag or creates +// a new one (the backend dedupes case-insensitively either way, see +// routes/tags.py). No dedicated tag-management page yet: renaming/deleting a +// tag globally isn't exposed here, only removing it from this transaction. +export function TagInput({ value, onChange }: TagInputProps) { + const { t } = useTranslation(); + const { data: allTags } = useTags(); + const createTag = useCreateTag(); + + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const inputRef = useRef(null); + + const selectedIds = new Set(value.map((tag) => tag.id)); + const trimmedQuery = query.trim(); + const suggestions = (allTags ?? []) + .filter((tag) => !selectedIds.has(tag.id)) + .filter((tag) => (trimmedQuery ? tag.name.toLowerCase().includes(trimmedQuery.toLowerCase()) : true)) + .slice(0, 8); + const exactMatch = (allTags ?? []).find((tag) => tag.name.toLowerCase() === trimmedQuery.toLowerCase()); + + function addTag(tag: Tag) { + onChange([...value, tag]); + setQuery(""); + setOpen(false); + inputRef.current?.focus(); + } + + function removeTag(tagId: number) { + onChange(value.filter((tag) => tag.id !== tagId)); + } + + async function commitQuery() { + if (!trimmedQuery) return; + if (exactMatch) { + addTag(exactMatch); + return; + } + const created = await createTag.mutateAsync(trimmedQuery); + addTag(created); + } + + function handleKeyDown(event: React.KeyboardEvent) { + if (event.key === "Enter") { + event.preventDefault(); + if (suggestions[0]) addTag(suggestions[0]); + else void commitQuery(); + } else if (event.key === "Escape") { + setOpen(false); + } else if (event.key === "Backspace" && !query && value.length > 0) { + removeTag(value[value.length - 1].id); + } + } + + return ( +
+
+ {value.map((tag) => ( + + {tag.name} + + + ))} + { + setQuery(event.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onBlur={() => setTimeout(() => setOpen(false), 100)} + onKeyDown={handleKeyDown} + /> +
+ + {open && (suggestions.length > 0 || (trimmedQuery && !exactMatch)) && ( +
    + {suggestions.map((tag) => ( +
  • + +
  • + ))} + {trimmedQuery && !exactMatch && ( +
  • + +
  • + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/transactions/TransactionFormModal.tsx b/frontend/src/components/transactions/TransactionFormModal.tsx index 60763a1..4b94a7f 100644 --- a/frontend/src/components/transactions/TransactionFormModal.tsx +++ b/frontend/src/components/transactions/TransactionFormModal.tsx @@ -2,12 +2,13 @@ import { useEffect, useState } from "react"; import { Dialog } from "@/components/ui/Dialog"; import { Button } from "@/components/ui/Button"; import { Input, Label, Select } from "@/components/ui/Input"; +import { TagInput } from "@/components/transactions/TagInput"; import { useAccounts } from "@/hooks/useAccounts"; import { useCategories } from "@/hooks/useCategories"; import { useCreateTransaction, useUpdateTransaction } from "@/hooks/useTransactions"; import { useTranslation } from "@/lib/i18n"; import { translateCategoryName } from "@/lib/categoryLabels"; -import type { Category, Transaction, TransactionInput, TransactionType } from "@/types"; +import type { Category, Tag, Transaction, TransactionInput, TransactionType } from "@/types"; interface TransactionFormModalProps { open: boolean; @@ -39,6 +40,7 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction const updateTransaction = useUpdateTransaction(); const [form, setForm] = useState(EMPTY_FORM); + const [tags, setTags] = useState([]); const [error, setError] = useState(null); useEffect(() => { @@ -55,8 +57,10 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction notes: transaction.notes ?? "", date: transaction.date, }); + setTags(transaction.tags); } else { setForm({ ...EMPTY_FORM, account_id: accounts?.[0] ? String(accounts[0].id) : "" }); + setTags([]); } setError(null); }, [open, transaction, accounts]); @@ -104,6 +108,7 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction merchant: form.merchant || null, notes: form.notes || null, date: form.date, + tag_ids: tags.map((tag) => tag.id), }; try { @@ -253,6 +258,11 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction /> +
+ + +
+ {error &&

{error}

}
diff --git a/frontend/src/components/transactions/TransactionsTable.tsx b/frontend/src/components/transactions/TransactionsTable.tsx index 8933efb..61c4153 100644 --- a/frontend/src/components/transactions/TransactionsTable.tsx +++ b/frontend/src/components/transactions/TransactionsTable.tsx @@ -53,6 +53,7 @@ export function TransactionsTable({ items, onEdit, onDelete, onJumpToMonth }: Tr {formatTransactionDate(tx.date, Boolean(onJumpToMonth))} · {tx.account.name} {isTransfer && tx.transfer_account_id ? ` ${t("transactions.transferSuffix")}` : ""} {tx.category ? ` · ${translateCategoryName(tx.category.name)}` : ""} + {tx.tags.length > 0 ? ` · ${tx.tags.map((tag) => tag.name).join(", ")}` : ""} diff --git a/frontend/src/hooks/useTags.ts b/frontend/src/hooks/useTags.ts new file mode 100644 index 0000000..cfcac08 --- /dev/null +++ b/frontend/src/hooks/useTags.ts @@ -0,0 +1,25 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createTag, deleteTag, fetchTags } from "@/api/tags"; + +export function useTags() { + return useQuery({ queryKey: ["tags"], queryFn: fetchTags }); +} + +export function useCreateTag() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (name: string) => createTag(name), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tags"] }), + }); +} + +export function useDeleteTag() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => deleteTag(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tags"] }); + queryClient.invalidateQueries({ queryKey: ["transactions"] }); + }, + }); +} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index dcae74b..7557ffd 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -138,6 +138,7 @@ const ru = { "transactions.income": "Доходы", "transactions.transfer": "Переводы", "transactions.allCategories": "Все категории", + "transactions.allTags": "Все теги", "transactions.failedToLoad": "Не удалось загрузить транзакции.", "transactions.noneFound": "Транзакций не найдено.", "transactions.searchPlaceholder": "Поиск по описанию, продавцу или заметке…", @@ -167,6 +168,10 @@ const ru = { "transactions.form.noCategory": "Без категории", "transactions.form.merchantLabel": "Продавец (необязательно)", "transactions.form.notesLabel": "Заметка (необязательно)", + "transactions.form.tagsLabel": "Теги (необязательно)", + "transactions.form.tagsPlaceholder": "Начните вводить, чтобы найти или создать тег…", + "transactions.form.removeTag": "Убрать тег «{{name}}»", + "transactions.form.createTag": "Создать тег «{{name}}»", "transactions.viewNote": "Посмотреть заметку", "transactions.noteDialogTitle": "Заметка", "transactions.form.errorSelectAccount": "Выберите счёт", @@ -501,6 +506,7 @@ const en: Record = { "transactions.income": "Income", "transactions.transfer": "Transfers", "transactions.allCategories": "All categories", + "transactions.allTags": "All tags", "transactions.failedToLoad": "Failed to load transactions.", "transactions.noneFound": "No transactions found.", "transactions.searchPlaceholder": "Search by description, merchant, or note…", @@ -530,6 +536,10 @@ const en: Record = { "transactions.form.noCategory": "No category", "transactions.form.merchantLabel": "Merchant (optional)", "transactions.form.notesLabel": "Note (optional)", + "transactions.form.tagsLabel": "Tags (optional)", + "transactions.form.tagsPlaceholder": "Type to find or create a tag…", + "transactions.form.removeTag": "Remove tag \"{{name}}\"", + "transactions.form.createTag": "Create tag \"{{name}}\"", "transactions.viewNote": "View note", "transactions.noteDialogTitle": "Note", "transactions.form.errorSelectAccount": "Select an account", diff --git a/frontend/src/pages/TransactionsPage.tsx b/frontend/src/pages/TransactionsPage.tsx index 064b6ae..c56f21c 100644 --- a/frontend/src/pages/TransactionsPage.tsx +++ b/frontend/src/pages/TransactionsPage.tsx @@ -10,6 +10,7 @@ import { TransactionsTable } from "@/components/transactions/TransactionsTable"; import { TransactionFormModal } from "@/components/transactions/TransactionFormModal"; import { useTransactions, useDeleteTransaction, useTransactionYears } from "@/hooks/useTransactions"; import { useCategories } from "@/hooks/useCategories"; +import { useTags } from "@/hooks/useTags"; import type { TransactionSort } from "@/api/transactions"; import { useTranslation } from "@/lib/i18n"; import { translateCategoryName } from "@/lib/categoryLabels"; @@ -44,6 +45,7 @@ export function TransactionsPage() { const [month, setMonth] = useState(() => parseMonthParam(searchParams.get("month"), now.getMonth() + 1)); const [type, setType] = useState(""); const [categoryId, setCategoryId] = useState(""); + const [tagId, setTagId] = useState(""); const [sort, setSort] = useState("date_desc"); const [page, setPage] = useState(1); @@ -64,6 +66,7 @@ export function TransactionsPage() { const [editingTransaction, setEditingTransaction] = useState(null); const { data: categories } = useCategories(); + const { data: tags } = useTags(); const { data: years } = useTransactionYears(); const { data, isLoading, isError } = useTransactions({ // A search looks for a purchase from an unknown month, so it must span @@ -73,6 +76,7 @@ export function TransactionsPage() { search: isSearching ? search : undefined, type: type || undefined, category_id: categoryId ? Number(categoryId) : undefined, + tag_id: tagId ? Number(tagId) : undefined, sort, page, page_size: PAGE_SIZE, @@ -193,6 +197,23 @@ export function TransactionsPage() { ))} + {tags && tags.length > 0 && ( + + )}