Skip to content

Commit 63f4303

Browse files
authored
Merge pull request #36 from RadCod3/feat/29-improve-transaction-search
Enhance transaction search functionality
2 parents 98d7a3d + 1541fed commit 63f4303

3 files changed

Lines changed: 178 additions & 10 deletions

File tree

src/lampyrid/clients/firefly.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,77 @@ async def search_accounts(self, req: SearchAccountRequest) -> AccountArray:
8888
r.raise_for_status()
8989
return AccountArray.model_validate(r.json())
9090

91+
@staticmethod
92+
def _sanitize_value(value: str) -> str:
93+
"""Escape and optionally quote a search value for Firefly III query syntax.
94+
95+
Escapes backslashes and double quotes, then wraps the value in double quotes
96+
if it contains whitespace or quote characters.
97+
98+
Args:
99+
value: The raw search value
100+
101+
Returns:
102+
Escaped and optionally quoted value safe for Firefly III queries
103+
"""
104+
# Escape backslashes first, then escape double quotes
105+
escaped = value.replace('\\', '\\\\').replace('"', '\\"')
106+
107+
# Quote if contains whitespace or quote characters
108+
if ' ' in value or '"' in value or "'" in value:
109+
return f'"{escaped}"'
110+
return escaped
111+
91112
async def search_transactions(self, req: SearchTransactionsRequest) -> TransactionArray:
92-
"""Search transactions by description or other text fields."""
113+
"""Search transactions using structured filters or raw query string."""
114+
# Build query string from structured fields
115+
query_parts = []
116+
117+
# Add raw query if provided
118+
if req.query:
119+
query_parts.append(req.query)
120+
121+
# Transaction type and amount filters
122+
if req.type:
123+
query_parts.append(f'type:{req.type}')
124+
if req.amount_equals is not None:
125+
query_parts.append(f'amount:{req.amount_equals}')
126+
if req.amount_more is not None:
127+
query_parts.append(f'more:{req.amount_more}')
128+
if req.amount_less is not None:
129+
query_parts.append(f'less:{req.amount_less}')
130+
131+
# Date filters
132+
if req.date_on:
133+
query_parts.append(f'date_on:{req.date_on}')
134+
if req.date_after:
135+
query_parts.append(f'date_after:{req.date_after}')
136+
if req.date_before:
137+
query_parts.append(f'date_before:{req.date_before}')
138+
139+
# Content filters
140+
if req.description_contains:
141+
query_parts.append(
142+
f'description_contains:{self._sanitize_value(req.description_contains)}'
143+
)
144+
145+
# Metadata filters
146+
if req.category:
147+
query_parts.append(f'category_is:{self._sanitize_value(req.category)}')
148+
if req.budget:
149+
query_parts.append(f'budget_is:{self._sanitize_value(req.budget)}')
150+
151+
# Account filters
152+
if req.account_contains:
153+
query_parts.append(f'account_contains:{self._sanitize_value(req.account_contains)}')
154+
if req.account_id is not None:
155+
query_parts.append(f'account_id:{req.account_id}')
156+
157+
# Combine all query parts with spaces (AND logic)
158+
final_query = ' '.join(query_parts)
159+
93160
params: Dict[str, Any] = {
94-
'query': req.query,
161+
'query': final_query,
95162
'page': req.page,
96163
'limit': req.limit,
97164
}

src/lampyrid/models/lampyrid_models.py

Lines changed: 108 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from datetime import date, datetime, timezone
22
from enum import Enum
3-
from typing import List, Optional
3+
from typing import List, Optional, Literal
44

5-
from pydantic import BaseModel, Field
5+
from pydantic import BaseModel, Field, model_validator
66

77
from .firefly_models import (
88
AccountRead,
@@ -282,19 +282,120 @@ class GetTransactionsRequest(BaseModel):
282282

283283

284284
class SearchTransactionsRequest(BaseModel):
285-
query: str = Field(
286-
...,
287-
description='Text to search for in transaction descriptions, account names, and other transaction fields',
285+
query: str | None = Field(
286+
None,
287+
description='Free-text search or raw Firefly III query string. Can be combined with structured filters below.',
288288
)
289-
page: Optional[int] = Field(
289+
290+
# Transaction type and amount filters
291+
type: Literal['withdrawal', 'deposit', 'transfer'] | None = Field(
292+
None,
293+
description='Transaction type to filter by',
294+
examples=['withdrawal', 'deposit', 'transfer'],
295+
)
296+
amount_equals: float | None = Field(
297+
None,
298+
description='Exact amount to match',
299+
examples=[123.45],
300+
)
301+
amount_more: float | None = Field(
302+
None,
303+
description='Minimum amount (inclusive)',
304+
examples=[100.00],
305+
)
306+
amount_less: float | None = Field(
307+
None,
308+
description='Maximum amount (inclusive)',
309+
examples=[50.00],
310+
)
311+
312+
# Date filters
313+
date_on: date | None = Field(
314+
None,
315+
description='Exact date match in YYYY-MM-DD format',
316+
examples=['2024-01-15'],
317+
)
318+
date_after: date | None = Field(
319+
None,
320+
description='From date (inclusive) in YYYY-MM-DD format',
321+
examples=['2024-01-01'],
322+
)
323+
date_before: date | None = Field(
324+
None,
325+
description='Until date (inclusive) in YYYY-MM-DD format',
326+
examples=['2024-12-31'],
327+
)
328+
329+
# Content filters
330+
description_contains: str | None = Field(
331+
None,
332+
description='Text to search for in transaction descriptions',
333+
examples=['groceries', 'coffee'],
334+
)
335+
336+
# Metadata filters
337+
category: str | None = Field(
338+
None,
339+
description='Category name to filter by (exact match)',
340+
examples=['Food', 'Transportation'],
341+
)
342+
budget: str | None = Field(
343+
None,
344+
description='Budget name to filter by (exact match)',
345+
examples=['Groceries', 'Dining Out'],
346+
)
347+
348+
# Account filters
349+
account_contains: str | None = Field(
350+
None,
351+
description='Text to search for in any account name (source or destination)',
352+
examples=['checking', 'savings'],
353+
)
354+
account_id: str | None = Field(
355+
None,
356+
description='Account ID to filter by (matches source or destination account)',
357+
examples=['123'],
358+
)
359+
360+
# Pagination
361+
page: int | None = Field(
290362
1,
291363
description='Page number to retrieve (1-based). Use for browsing large result sets.',
292364
ge=1,
293365
)
294-
limit: Optional[int] = Field(
366+
limit: int | None = Field(
295367
50, description='Maximum number of transactions to return per page (1-500)', ge=1, le=500
296368
)
297369

370+
@model_validator(mode='after')
371+
def validate_search_criteria(self):
372+
"""Ensure at least one search criterion is provided."""
373+
search_fields = [
374+
self.query,
375+
self.type,
376+
self.amount_equals,
377+
self.amount_more,
378+
self.amount_less,
379+
self.date_on,
380+
self.date_after,
381+
self.date_before,
382+
self.description_contains,
383+
self.category,
384+
self.budget,
385+
self.account_contains,
386+
self.account_id,
387+
]
388+
# Consider a field provided if: (a) it's not None and not a string, or
389+
# (b) it's a string and not empty/whitespace-only
390+
has_criteria = any(
391+
(field is not None and not isinstance(field, str))
392+
or (isinstance(field, str) and field.strip() != '')
393+
for field in search_fields
394+
)
395+
if not has_criteria:
396+
raise ValueError('At least one search criterion must be provided')
397+
return self
398+
298399

299400
class DeleteTransactionRequest(BaseModel):
300401
id: str = Field(..., description='Unique identifier of the transaction to permanently remove')

src/lampyrid/tools/transactions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ async def get_transactions(req: GetTransactionsRequest) -> TransactionListRespon
8181

8282
@transactions_mcp.tool(tags={'transactions', 'query'})
8383
async def search_transactions(req: SearchTransactionsRequest) -> TransactionListResponse:
84-
"""Find transactions by searching text content. Perfect for locating specific purchases, payments, or merchants by description."""
84+
"""Search transactions with powerful filtering options. Supports free-text search, type filtering (withdrawal/deposit/transfer), amount ranges, date ranges, categories, budgets, and account matching. All filters combine with AND logic for precise results."""
8585
transaction_array = await client.search_transactions(req)
8686

8787
return TransactionListResponse.from_transaction_array(

0 commit comments

Comments
 (0)