feat: add search to Transactions tab, spanning all history

Lets the user find a past purchase by keyword instead of paging through
months manually — search hits description/merchant/notes and ignores the
month filter, with a "jump to month" action on each result to view it in
its normal monthly context.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ZProger
2026-08-20 23:00:44 +03:00
parent b9ff3ab404
commit 2b2e01a8e2
8 changed files with 182 additions and 23 deletions

View File

@@ -2,7 +2,7 @@ from datetime import date as date_
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -51,6 +51,7 @@ async def list_transactions(
account_id: int | None = None,
category_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"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=200),
@@ -80,6 +81,18 @@ async def list_transactions(
if type is not None:
stmt = stmt.where(Transaction.type == type)
count_stmt = count_stmt.where(Transaction.type == type)
if search is not None:
# Lets the user find a transaction from any period by keyword (e.g. an
# item bought months ago) without knowing which month to look in first —
# matches description, merchant, and notes so any of those fields can surface it.
pattern = f"%{search.strip()}%"
search_clause = or_(
Transaction.description.ilike(pattern),
Transaction.merchant.ilike(pattern),
Transaction.notes.ilike(pattern),
)
stmt = stmt.where(search_clause)
count_stmt = count_stmt.where(search_clause)
total = (await session.execute(count_stmt)).scalar_one()

View File

@@ -111,6 +111,57 @@ async def test_list_filters_by_explicit_date_range(client: AsyncClient, account_
assert body["items"][0]["date"] == "2024-06-15"
async def test_search_matches_description_across_any_period_ignoring_month_filter(
client: AsyncClient, account_id, categories
):
"""The point of search is finding a purchase from an unknown month without
already knowing which month to filter by, so it must not be scoped by the
year/month params even when the caller happens to also pass them."""
category_id = categories["Groceries"]["id"]
for txn_date, description in [
("2021-03-12", "PlayStation 5 console"),
("2024-11-02", "grocery run"),
("2026-08-16", "current month coffee"),
]:
resp = await client.post(
"/transactions", json=_txn(account_id, category_id=category_id, date=txn_date, description=description)
)
assert resp.status_code == 201
resp = await client.get("/transactions", params={"search": "playstation"})
body = resp.json()
assert body["total"] == 1
assert body["items"][0]["description"] == "PlayStation 5 console"
async def test_search_matches_merchant_and_notes(client: AsyncClient, account_id, categories):
category_id = categories["Groceries"]["id"]
await client.post(
"/transactions",
json=_txn(account_id, category_id=category_id, description="misc", merchant="Best Buy"),
)
await client.post(
"/transactions",
json=_txn(account_id, category_id=category_id, description="misc", notes="bought for a birthday gift"),
)
merchant_resp = await client.get("/transactions", params={"search": "best buy"})
assert merchant_resp.json()["total"] == 1
notes_resp = await client.get("/transactions", params={"search": "birthday"})
assert notes_resp.json()["total"] == 1
async def test_search_with_no_matches_returns_empty(client: AsyncClient, account_id, categories):
category_id = categories["Groceries"]["id"]
await client.post("/transactions", json=_txn(account_id, category_id=category_id, description="groceries"))
resp = await client.get("/transactions", params={"search": "nonexistent item xyz"})
body = resp.json()
assert body["total"] == 0
assert body["items"] == []
async def test_pagination_covers_every_item_with_no_overlap(client: AsyncClient, account_id, categories):
category_id = categories["Groceries"]["id"]
for day in range(1, 26):