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,
GoalContribution,
RecurringTransaction,
Tag,
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.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)

View File

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

View File

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

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

View File

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

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.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):

View File

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

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