feat: add free-form tags on transactions

Third of the four ideas picked from the Maybe/Sure feature audit. Tags are
orthogonal to categories (category = what kind of spend, tag = which
event/project) — many-to-many, created inline while typing in the
transaction form, filterable from the Transactions page.

Also adds tests/test_backup.py, the first test coverage the backup
export/import path has ever had, and fixes a MissingGreenlet bug it
surfaced in restore_backup's tag-wiring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ZProger
2026-08-25 02:38:04 +03:00
parent 09a0e383e0
commit 94782ee073
24 changed files with 585 additions and 10 deletions

View File

@@ -17,6 +17,7 @@ from app.models import ( # noqa: F401 — registers metadata
Goal, Goal,
GoalContribution, GoalContribution,
RecurringTransaction, RecurringTransaction,
Tag,
Transaction, Transaction,
) )

View File

@@ -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')

View File

@@ -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()

View File

@@ -9,12 +9,24 @@ from sqlalchemy.orm import selectinload
from app.api.deps import get_session from app.api.deps import get_session
from app.models.category import Category from app.models.category import Category
from app.models.enums import CategoryKind, TransactionType from app.models.enums import CategoryKind, TransactionType
from app.models.tag import Tag
from app.models.transaction import Transaction from app.models.transaction import Transaction
from app.schemas.transaction import TransactionCreate, TransactionPage, TransactionRead, TransactionUpdate from app.schemas.transaction import TransactionCreate, TransactionPage, TransactionRead, TransactionUpdate
router = APIRouter(prefix="/transactions", tags=["transactions"]) 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 = { _TYPE_TO_CATEGORY_KIND = {
TransactionType.INCOME: CategoryKind.INCOME, TransactionType.INCOME: CategoryKind.INCOME,
@@ -50,6 +62,7 @@ async def list_transactions(
end_date: date_ | None = Query(default=None), end_date: date_ | None = Query(default=None),
account_id: int | None = None, account_id: int | None = None,
category_id: int | None = None, category_id: int | None = None,
tag_id: int | None = None,
type: TransactionType | None = None, type: TransactionType | None = None,
search: str | None = Query(default=None, min_length=1, max_length=255), search: str | None = Query(default=None, min_length=1, max_length=255),
sort: Literal["date_desc", "amount_desc", "amount_asc"] = Query(default="date_desc"), 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: if category_id is not None:
stmt = stmt.where(Transaction.category_id == category_id) stmt = stmt.where(Transaction.category_id == category_id)
count_stmt = count_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: if type is not None:
stmt = stmt.where(Transaction.type == type) stmt = stmt.where(Transaction.type == type)
count_stmt = count_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) @router.post("", response_model=TransactionRead, status_code=201)
async def create_transaction(payload: TransactionCreate, session: AsyncSession = Depends(get_session)) -> Transaction: async def create_transaction(payload: TransactionCreate, session: AsyncSession = Depends(get_session)) -> Transaction:
await _ensure_category_matches_type(session, payload.category_id, payload.type) 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) session.add(transaction)
await session.commit() await session.commit()
refreshed = await session.execute( refreshed = await session.execute(
@@ -140,15 +158,20 @@ async def create_transaction(payload: TransactionCreate, session: AsyncSession =
async def update_transaction( async def update_transaction(
transaction_id: int, payload: TransactionUpdate, session: AsyncSession = Depends(get_session) transaction_id: int, payload: TransactionUpdate, session: AsyncSession = Depends(get_session)
) -> Transaction: ) -> 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: if transaction is None:
raise HTTPException(status_code=404, detail="Transaction not found") 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_type = updates.get("type", transaction.type)
effective_category_id = updates.get("category_id", transaction.category_id) effective_category_id = updates.get("category_id", transaction.category_id)
await _ensure_category_matches_type(session, effective_category_id, effective_type) await _ensure_category_matches_type(session, effective_category_id, effective_type)
for field, value in updates.items(): for field, value in updates.items():
setattr(transaction, field, value) setattr(transaction, field, value)
if payload.tag_ids is not None:
transaction.tags = await _resolve_tags(session, payload.tag_ids)
await session.commit() await session.commit()
refreshed = await session.execute( refreshed = await session.execute(
select(Transaction).options(*_EAGER).where(Transaction.id == transaction_id) select(Transaction).options(*_EAGER).where(Transaction.id == transaction_id)

View File

@@ -18,6 +18,7 @@ from app.api.routes import (
recurring, recurring,
reports, reports,
settings as settings_routes, settings as settings_routes,
tags,
transactions, transactions,
) )
from app.core.config import APP_VERSION, get_settings 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(goals.router, prefix="/api")
app.include_router(recurring.router, prefix="/api") app.include_router(recurring.router, prefix="/api")
app.include_router(cash_flow.router, prefix="/api") app.include_router(cash_flow.router, prefix="/api")
app.include_router(tags.router, prefix="/api")
@app.get("/api/health") @app.get("/api/health")

View File

@@ -5,6 +5,7 @@ from app.models.category import Category
from app.models.goal import Goal, GoalContribution from app.models.goal import Goal, GoalContribution
from app.models.recurring import RecurringTransaction from app.models.recurring import RecurringTransaction
from app.models.settings import AppSettings from app.models.settings import AppSettings
from app.models.tag import Tag
from app.models.transaction import Transaction from app.models.transaction import Transaction
__all__ = [ __all__ = [
@@ -17,5 +18,6 @@ __all__ = [
"Goal", "Goal",
"GoalContribution", "GoalContribution",
"RecurringTransaction", "RecurringTransaction",
"Tag",
"Transaction", "Transaction",
] ]

26
backend/app/models/tag.py Normal file
View File

@@ -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")

View File

@@ -7,6 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base from app.db.base import Base
from app.models.enums import TransactionType from app.models.enums import TransactionType
from app.models.mixins import TimestampMixin from app.models.mixins import TimestampMixin
from app.models.tag import transaction_tags
class Transaction(Base, TimestampMixin): class Transaction(Base, TimestampMixin):
@@ -33,3 +34,4 @@ class Transaction(Base, TimestampMixin):
account: Mapped["Account"] = relationship(back_populates="transactions", foreign_keys=[account_id]) account: Mapped["Account"] = relationship(back_populates="transactions", foreign_keys=[account_id])
transfer_account: Mapped["Account | None"] = relationship(foreign_keys=[transfer_account_id]) transfer_account: Mapped["Account | None"] = relationship(foreign_keys=[transfer_account_id])
category: Mapped["Category | None"] = relationship(back_populates="transactions") category: Mapped["Category | None"] = relationship(back_populates="transactions")
tags: Mapped[list["Tag"]] = relationship(secondary=transaction_tags, back_populates="transactions")

View File

@@ -42,6 +42,13 @@ class CategoryBackup(BaseModel):
parent_id: int | None = None parent_id: int | None = None
class TagBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
class TransactionBackup(BaseModel): class TransactionBackup(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -55,6 +62,11 @@ class TransactionBackup(BaseModel):
merchant: str | None merchant: str | None
notes: str | None notes: str | None
date: date_ 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): class AssetBackup(BaseModel):
@@ -155,6 +167,9 @@ class BackupPayload(BaseModel):
app_version: str app_version: str
accounts: list[AccountBackup] accounts: list[AccountBackup]
categories: list[CategoryBackup] 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] transactions: list[TransactionBackup]
assets: list[AssetBackup] assets: list[AssetBackup]
asset_valuations: list[AssetValuationBackup] asset_valuations: list[AssetValuationBackup]

View File

@@ -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

View File

@@ -7,6 +7,7 @@ from app.core.text import capitalize_first_letter
from app.models.enums import TransactionType from app.models.enums import TransactionType
from app.schemas.account import AccountRead from app.schemas.account import AccountRead
from app.schemas.category import CategoryRead from app.schemas.category import CategoryRead
from app.schemas.tag import TagRead
class TransactionBase(BaseModel): class TransactionBase(BaseModel):
@@ -41,7 +42,7 @@ class TransactionBase(BaseModel):
class TransactionCreate(TransactionBase): class TransactionCreate(TransactionBase):
pass tag_ids: list[int] = Field(default_factory=list)
class TransactionUpdate(BaseModel): class TransactionUpdate(BaseModel):
@@ -54,6 +55,8 @@ class TransactionUpdate(BaseModel):
merchant: str | None = Field(default=None, max_length=150) merchant: str | None = Field(default=None, max_length=150)
notes: str | None = None notes: str | None = None
date: date_ | 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") @field_validator("description")
@classmethod @classmethod
@@ -67,6 +70,7 @@ class TransactionRead(TransactionBase):
id: int id: int
account: AccountRead account: AccountRead
category: CategoryRead | None = None category: CategoryRead | None = None
tags: list[TagRead] = Field(default_factory=list)
class TransactionPage(BaseModel): class TransactionPage(BaseModel):

View File

@@ -14,6 +14,7 @@ from datetime import datetime, timezone
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import delete, select, text from sqlalchemy import delete, select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.config import APP_VERSION from app.core.config import APP_VERSION
from app.models.account import Account 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.goal import Goal, GoalContribution
from app.models.recurring import RecurringTransaction from app.models.recurring import RecurringTransaction
from app.models.settings import AppSettings from app.models.settings import AppSettings
from app.models.tag import Tag
from app.models.transaction import Transaction from app.models.transaction import Transaction
from app.schemas.backup import ( from app.schemas.backup import (
AccountBackup, AccountBackup,
@@ -35,6 +37,7 @@ from app.schemas.backup import (
GoalBackup, GoalBackup,
GoalContributionBackup, GoalContributionBackup,
RecurringTransactionBackup, RecurringTransactionBackup,
TagBackup,
TransactionBackup, TransactionBackup,
) )
@@ -44,7 +47,8 @@ BACKUP_FORMAT_VERSION = 1
async def build_backup(session: AsyncSession) -> BackupPayload: async def build_backup(session: AsyncSession) -> BackupPayload:
accounts = (await session.execute(select(Account))).scalars().all() accounts = (await session.execute(select(Account))).scalars().all()
categories = (await session.execute(select(Category))).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() assets = (await session.execute(select(Asset))).scalars().all()
valuations = (await session.execute(select(AssetValuation))).scalars().all() valuations = (await session.execute(select(AssetValuation))).scalars().all()
budgets = (await session.execute(select(Budget))).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, app_version=APP_VERSION,
accounts=[AccountBackup.model_validate(row) for row in accounts], accounts=[AccountBackup.model_validate(row) for row in accounts],
categories=[CategoryBackup.model_validate(row) for row in categories], 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], assets=[AssetBackup.model_validate(row) for row in assets],
asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations], asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations],
budgets=[BudgetBackup.model_validate(row) for row in budgets], 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} account_ids = {row.id for row in payload.accounts}
category_ids = {row.id for row in payload.categories} category_ids = {row.id for row in payload.categories}
asset_ids = {row.id for row in payload.assets} asset_ids = {row.id for row in payload.assets}
tag_ids = {row.id for row in payload.tags}
for c in payload.categories: for c in payload.categories:
if c.parent_id is not None and c.parent_id not in category_ids: 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: 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}") 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: for v in payload.asset_valuations:
if v.asset_id not in asset_ids: 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(GoalContribution))
await session.execute(delete(Goal)) await session.execute(delete(Goal))
await session.execute(delete(RecurringTransaction)) 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(Transaction))
await session.execute(delete(Tag))
await session.execute(delete(Asset)) await session.execute(delete(Asset))
await session.execute(delete(Category)) await session.execute(delete(Category))
await session.execute(delete(Account)) 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(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(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(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(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(Budget(**row.model_dump()) for row in payload.budgets)
session.add_all(Goal(**row.model_dump()) for row in payload.goals) 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) session.add_all(RecurringTransaction(**row.model_dump()) for row in payload.recurring_transactions)
await session.flush() 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, "accounts", payload.accounts)
await _reset_sequence(session, "categories", payload.categories) 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, "assets", payload.assets)
await _reset_sequence(session, "transactions", payload.transactions) await _reset_sequence(session, "transactions", payload.transactions)
await _reset_sequence(session, "asset_valuations", payload.asset_valuations) await _reset_sequence(session, "asset_valuations", payload.asset_valuations)

View File

@@ -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

View File

@@ -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

View File

@@ -1,7 +1,7 @@
{ {
"name": "aurum-frontend", "name": "aurum-frontend",
"private": true, "private": true,
"version": "0.24.0", "version": "0.25.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

14
frontend/src/api/tags.ts Normal file
View File

@@ -0,0 +1,14 @@
import { api } from "@/api/client";
import type { Tag } from "@/types";
export function fetchTags() {
return api.get<Tag[]>("/tags");
}
export function createTag(name: string) {
return api.post<Tag>("/tags", { name });
}
export function deleteTag(id: number) {
return api.delete<void>(`/tags/${id}`);
}

View File

@@ -10,6 +10,7 @@ export interface TransactionFilters {
end_date?: string; end_date?: string;
account_id?: number; account_id?: number;
category_id?: number; category_id?: number;
tag_id?: number;
type?: string; type?: string;
search?: string; search?: string;
sort?: TransactionSort; sort?: TransactionSort;

View File

@@ -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<HTMLInputElement>(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<HTMLInputElement>) {
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 (
<div className="relative">
<div className="flex flex-wrap items-center gap-1.5 rounded-lg border border-border bg-surface-1 px-2 py-1.5 focus-within:border-series-1">
{value.map((tag) => (
<span
key={tag.id}
className="flex items-center gap-1 rounded-full bg-surface-2 py-0.5 pl-2 pr-1 text-xs text-text-secondary"
>
{tag.name}
<button
type="button"
aria-label={t("transactions.form.removeTag", { name: tag.name })}
onClick={() => removeTag(tag.id)}
className="rounded-full p-0.5 text-text-muted hover:bg-surface-1 hover:text-text-primary"
>
<X size={11} />
</button>
</span>
))}
<input
ref={inputRef}
className="h-6 min-w-[8ch] flex-1 border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
value={query}
placeholder={value.length === 0 ? t("transactions.form.tagsPlaceholder") : ""}
onChange={(event) => {
setQuery(event.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 100)}
onKeyDown={handleKeyDown}
/>
</div>
{open && (suggestions.length > 0 || (trimmedQuery && !exactMatch)) && (
<ul className="absolute z-10 mt-1 w-full overflow-hidden rounded-lg border border-border bg-surface-1 shadow-lg">
{suggestions.map((tag) => (
<li key={tag.id}>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => addTag(tag)}
className={cn("block w-full px-3 py-1.5 text-left text-sm text-text-primary hover:bg-surface-2")}
>
{tag.name}
</button>
</li>
))}
{trimmedQuery && !exactMatch && (
<li>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => void commitQuery()}
className="block w-full px-3 py-1.5 text-left text-sm text-series-1 hover:bg-surface-2"
>
{t("transactions.form.createTag", { name: trimmedQuery })}
</button>
</li>
)}
</ul>
)}
</div>
);
}

View File

@@ -2,12 +2,13 @@ import { useEffect, useState } from "react";
import { Dialog } from "@/components/ui/Dialog"; import { Dialog } from "@/components/ui/Dialog";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Input, Label, Select } from "@/components/ui/Input"; import { Input, Label, Select } from "@/components/ui/Input";
import { TagInput } from "@/components/transactions/TagInput";
import { useAccounts } from "@/hooks/useAccounts"; import { useAccounts } from "@/hooks/useAccounts";
import { useCategories } from "@/hooks/useCategories"; import { useCategories } from "@/hooks/useCategories";
import { useCreateTransaction, useUpdateTransaction } from "@/hooks/useTransactions"; import { useCreateTransaction, useUpdateTransaction } from "@/hooks/useTransactions";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import { translateCategoryName } from "@/lib/categoryLabels"; import { translateCategoryName } from "@/lib/categoryLabels";
import type { Category, Transaction, TransactionInput, TransactionType } from "@/types"; import type { Category, Tag, Transaction, TransactionInput, TransactionType } from "@/types";
interface TransactionFormModalProps { interface TransactionFormModalProps {
open: boolean; open: boolean;
@@ -39,6 +40,7 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
const updateTransaction = useUpdateTransaction(); const updateTransaction = useUpdateTransaction();
const [form, setForm] = useState(EMPTY_FORM); const [form, setForm] = useState(EMPTY_FORM);
const [tags, setTags] = useState<Tag[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -55,8 +57,10 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
notes: transaction.notes ?? "", notes: transaction.notes ?? "",
date: transaction.date, date: transaction.date,
}); });
setTags(transaction.tags);
} else { } else {
setForm({ ...EMPTY_FORM, account_id: accounts?.[0] ? String(accounts[0].id) : "" }); setForm({ ...EMPTY_FORM, account_id: accounts?.[0] ? String(accounts[0].id) : "" });
setTags([]);
} }
setError(null); setError(null);
}, [open, transaction, accounts]); }, [open, transaction, accounts]);
@@ -104,6 +108,7 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
merchant: form.merchant || null, merchant: form.merchant || null,
notes: form.notes || null, notes: form.notes || null,
date: form.date, date: form.date,
tag_ids: tags.map((tag) => tag.id),
}; };
try { try {
@@ -253,6 +258,11 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
/> />
</div> </div>
<div>
<Label htmlFor="transaction-tags">{t("transactions.form.tagsLabel")}</Label>
<TagInput value={tags} onChange={setTags} />
</div>
{error && <p className="text-sm text-danger">{error}</p>} {error && <p className="text-sm text-danger">{error}</p>}
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">

View File

@@ -53,6 +53,7 @@ export function TransactionsTable({ items, onEdit, onDelete, onJumpToMonth }: Tr
{formatTransactionDate(tx.date, Boolean(onJumpToMonth))} · {tx.account.name} {formatTransactionDate(tx.date, Boolean(onJumpToMonth))} · {tx.account.name}
{isTransfer && tx.transfer_account_id ? ` ${t("transactions.transferSuffix")}` : ""} {isTransfer && tx.transfer_account_id ? ` ${t("transactions.transferSuffix")}` : ""}
{tx.category ? ` · ${translateCategoryName(tx.category.name)}` : ""} {tx.category ? ` · ${translateCategoryName(tx.category.name)}` : ""}
{tx.tags.length > 0 ? ` · ${tx.tags.map((tag) => tag.name).join(", ")}` : ""}
</span> </span>
</span> </span>

View File

@@ -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"] });
},
});
}

View File

@@ -138,6 +138,7 @@ const ru = {
"transactions.income": "Доходы", "transactions.income": "Доходы",
"transactions.transfer": "Переводы", "transactions.transfer": "Переводы",
"transactions.allCategories": "Все категории", "transactions.allCategories": "Все категории",
"transactions.allTags": "Все теги",
"transactions.failedToLoad": "Не удалось загрузить транзакции.", "transactions.failedToLoad": "Не удалось загрузить транзакции.",
"transactions.noneFound": "Транзакций не найдено.", "transactions.noneFound": "Транзакций не найдено.",
"transactions.searchPlaceholder": "Поиск по описанию, продавцу или заметке…", "transactions.searchPlaceholder": "Поиск по описанию, продавцу или заметке…",
@@ -167,6 +168,10 @@ const ru = {
"transactions.form.noCategory": "Без категории", "transactions.form.noCategory": "Без категории",
"transactions.form.merchantLabel": "Продавец (необязательно)", "transactions.form.merchantLabel": "Продавец (необязательно)",
"transactions.form.notesLabel": "Заметка (необязательно)", "transactions.form.notesLabel": "Заметка (необязательно)",
"transactions.form.tagsLabel": "Теги (необязательно)",
"transactions.form.tagsPlaceholder": "Начните вводить, чтобы найти или создать тег…",
"transactions.form.removeTag": "Убрать тег «{{name}}»",
"transactions.form.createTag": "Создать тег «{{name}}»",
"transactions.viewNote": "Посмотреть заметку", "transactions.viewNote": "Посмотреть заметку",
"transactions.noteDialogTitle": "Заметка", "transactions.noteDialogTitle": "Заметка",
"transactions.form.errorSelectAccount": "Выберите счёт", "transactions.form.errorSelectAccount": "Выберите счёт",
@@ -501,6 +506,7 @@ const en: Record<keyof typeof ru, string> = {
"transactions.income": "Income", "transactions.income": "Income",
"transactions.transfer": "Transfers", "transactions.transfer": "Transfers",
"transactions.allCategories": "All categories", "transactions.allCategories": "All categories",
"transactions.allTags": "All tags",
"transactions.failedToLoad": "Failed to load transactions.", "transactions.failedToLoad": "Failed to load transactions.",
"transactions.noneFound": "No transactions found.", "transactions.noneFound": "No transactions found.",
"transactions.searchPlaceholder": "Search by description, merchant, or note…", "transactions.searchPlaceholder": "Search by description, merchant, or note…",
@@ -530,6 +536,10 @@ const en: Record<keyof typeof ru, string> = {
"transactions.form.noCategory": "No category", "transactions.form.noCategory": "No category",
"transactions.form.merchantLabel": "Merchant (optional)", "transactions.form.merchantLabel": "Merchant (optional)",
"transactions.form.notesLabel": "Note (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.viewNote": "View note",
"transactions.noteDialogTitle": "Note", "transactions.noteDialogTitle": "Note",
"transactions.form.errorSelectAccount": "Select an account", "transactions.form.errorSelectAccount": "Select an account",

View File

@@ -10,6 +10,7 @@ import { TransactionsTable } from "@/components/transactions/TransactionsTable";
import { TransactionFormModal } from "@/components/transactions/TransactionFormModal"; import { TransactionFormModal } from "@/components/transactions/TransactionFormModal";
import { useTransactions, useDeleteTransaction, useTransactionYears } from "@/hooks/useTransactions"; import { useTransactions, useDeleteTransaction, useTransactionYears } from "@/hooks/useTransactions";
import { useCategories } from "@/hooks/useCategories"; import { useCategories } from "@/hooks/useCategories";
import { useTags } from "@/hooks/useTags";
import type { TransactionSort } from "@/api/transactions"; import type { TransactionSort } from "@/api/transactions";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import { translateCategoryName } from "@/lib/categoryLabels"; import { translateCategoryName } from "@/lib/categoryLabels";
@@ -44,6 +45,7 @@ export function TransactionsPage() {
const [month, setMonth] = useState(() => parseMonthParam(searchParams.get("month"), now.getMonth() + 1)); const [month, setMonth] = useState(() => parseMonthParam(searchParams.get("month"), now.getMonth() + 1));
const [type, setType] = useState<TransactionType | "">(""); const [type, setType] = useState<TransactionType | "">("");
const [categoryId, setCategoryId] = useState<string>(""); const [categoryId, setCategoryId] = useState<string>("");
const [tagId, setTagId] = useState<string>("");
const [sort, setSort] = useState<TransactionSort>("date_desc"); const [sort, setSort] = useState<TransactionSort>("date_desc");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -64,6 +66,7 @@ export function TransactionsPage() {
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null); const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
const { data: categories } = useCategories(); const { data: categories } = useCategories();
const { data: tags } = useTags();
const { data: years } = useTransactionYears(); const { data: years } = useTransactionYears();
const { data, isLoading, isError } = useTransactions({ const { data, isLoading, isError } = useTransactions({
// A search looks for a purchase from an unknown month, so it must span // 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, search: isSearching ? search : undefined,
type: type || undefined, type: type || undefined,
category_id: categoryId ? Number(categoryId) : undefined, category_id: categoryId ? Number(categoryId) : undefined,
tag_id: tagId ? Number(tagId) : undefined,
sort, sort,
page, page,
page_size: PAGE_SIZE, page_size: PAGE_SIZE,
@@ -193,6 +197,23 @@ export function TransactionsPage() {
</option> </option>
))} ))}
</Select> </Select>
{tags && tags.length > 0 && (
<Select
value={tagId}
onChange={(event) => {
setTagId(event.target.value);
setPage(1);
}}
className="sm:w-48"
>
<option value="">{t("transactions.allTags")}</option>
{tags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name}
</option>
))}
</Select>
)}
<Select <Select
value={sort} value={sort}
onChange={(event) => { onChange={(event) => {

View File

@@ -57,6 +57,11 @@ export interface CategoryUpdateInput {
parent_id?: number | null; parent_id?: number | null;
} }
export interface Tag {
id: number;
name: string;
}
export interface Transaction { export interface Transaction {
id: number; id: number;
account_id: number; account_id: number;
@@ -70,6 +75,7 @@ export interface Transaction {
date: string; date: string;
account: Account; account: Account;
category: Category | null; category: Category | null;
tags: Tag[];
} }
export interface TransactionPage { export interface TransactionPage {
@@ -89,6 +95,9 @@ export interface TransactionInput {
merchant?: string | null; merchant?: string | null;
notes?: string | null; notes?: string | null;
date: string; date: string;
// Omitted -> tags untouched on update; sent (even as []) -> replaces the
// full tag set. Always sent on create (defaults to []).
tag_ids?: number[];
} }
export interface RecurringTransaction { export interface RecurringTransaction {