From 7ee14578dc47c4e17f38eee078ac629ccf4c4459 Mon Sep 17 00:00:00 2001 From: Radith Samarakoon Date: Sat, 31 Jan 2026 14:53:01 +0530 Subject: [PATCH 1/5] feat: implement financial analysis tools and service Implement comprehensive financial analysis capabilities, including: - Add insight API methods to FireflyClient for expense, income, and transfer data. - Implement InsightService to coordinate business logic and model transformations. - Create new MCP tools for detailed expense, income, and transfer analysis. - Provide a get_financial_summary tool for a high-level overview of net position. - Define structured Pydantic models for all insight requests and results. --- src/lampyrid/clients/firefly.py | 161 ++++++++++++++ src/lampyrid/models/lampyrid_models.py | 224 +++++++++++++++++++ src/lampyrid/services/__init__.py | 3 +- src/lampyrid/services/insights.py | 289 +++++++++++++++++++++++++ src/lampyrid/tools/__init__.py | 3 + src/lampyrid/tools/insights.py | 95 ++++++++ 6 files changed, 774 insertions(+), 1 deletion(-) create mode 100644 src/lampyrid/services/insights.py create mode 100644 src/lampyrid/tools/insights.py diff --git a/src/lampyrid/clients/firefly.py b/src/lampyrid/clients/firefly.py index 4d6e7e9..725e487 100644 --- a/src/lampyrid/clients/firefly.py +++ b/src/lampyrid/clients/firefly.py @@ -17,6 +17,9 @@ BudgetLimitArray, BudgetSingle, BudgetStore, + InsightGroup, + InsightTotal, + InsightTransfer, TransactionArray, TransactionSingle, TransactionStore, @@ -302,3 +305,161 @@ async def get_available_budgets( self._handle_api_error(r) r.raise_for_status() return AvailableBudgetArray.model_validate(r.json()) + + # ========================================================================= + # Insight API Methods + # ========================================================================= + + def _build_insight_params( + self, + start_date: date, + end_date: date, + account_ids: Optional[list[int]] = None, + ) -> Dict[str, Any]: + """Build common parameters for insight API calls.""" + params: Dict[str, Any] = { + 'start': start_date.strftime('%Y-%m-%d'), + 'end': end_date.strftime('%Y-%m-%d'), + } + if account_ids: + params['accounts[]'] = account_ids + return params + + # Expense Insight Methods + + async def get_expense_total( + self, + start_date: date, + end_date: date, + account_ids: Optional[list[int]] = None, + ) -> InsightTotal: + """Get total expenses for a period.""" + params = self._build_insight_params(start_date, end_date, account_ids) + r = await self._client.get('/api/v1/insight/expense/total', params=params) + self._handle_api_error(r) + r.raise_for_status() + return InsightTotal.model_validate(r.json()) + + async def get_expense_by_expense_account( + self, + start_date: date, + end_date: date, + account_ids: Optional[list[int]] = None, + ) -> InsightGroup: + """Get expenses grouped by expense account (vendor/payee).""" + params = self._build_insight_params(start_date, end_date, account_ids) + r = await self._client.get('/api/v1/insight/expense/expense', params=params) + self._handle_api_error(r) + r.raise_for_status() + return InsightGroup.model_validate(r.json()) + + async def get_expense_by_asset_account( + self, + start_date: date, + end_date: date, + account_ids: Optional[list[int]] = None, + ) -> InsightGroup: + """Get expenses grouped by asset account (source).""" + params = self._build_insight_params(start_date, end_date, account_ids) + r = await self._client.get('/api/v1/insight/expense/asset', params=params) + self._handle_api_error(r) + r.raise_for_status() + return InsightGroup.model_validate(r.json()) + + async def get_expense_by_budget( + self, + start_date: date, + end_date: date, + account_ids: Optional[list[int]] = None, + budget_ids: Optional[list[int]] = None, + ) -> InsightGroup: + """Get expenses grouped by budget.""" + params = self._build_insight_params(start_date, end_date, account_ids) + if budget_ids: + params['budgets[]'] = budget_ids + r = await self._client.get('/api/v1/insight/expense/budget', params=params) + self._handle_api_error(r) + r.raise_for_status() + return InsightGroup.model_validate(r.json()) + + async def get_expense_no_budget( + self, + start_date: date, + end_date: date, + account_ids: Optional[list[int]] = None, + ) -> InsightTotal: + """Get expenses without any budget assigned.""" + params = self._build_insight_params(start_date, end_date, account_ids) + r = await self._client.get('/api/v1/insight/expense/no-budget', params=params) + self._handle_api_error(r) + r.raise_for_status() + return InsightTotal.model_validate(r.json()) + + # Income Insight Methods + + async def get_income_total( + self, + start_date: date, + end_date: date, + account_ids: Optional[list[int]] = None, + ) -> InsightTotal: + """Get total income for a period.""" + params = self._build_insight_params(start_date, end_date, account_ids) + r = await self._client.get('/api/v1/insight/income/total', params=params) + self._handle_api_error(r) + r.raise_for_status() + return InsightTotal.model_validate(r.json()) + + async def get_income_by_revenue_account( + self, + start_date: date, + end_date: date, + account_ids: Optional[list[int]] = None, + ) -> InsightGroup: + """Get income grouped by revenue account (income source).""" + params = self._build_insight_params(start_date, end_date, account_ids) + r = await self._client.get('/api/v1/insight/income/revenue', params=params) + self._handle_api_error(r) + r.raise_for_status() + return InsightGroup.model_validate(r.json()) + + async def get_income_by_asset_account( + self, + start_date: date, + end_date: date, + account_ids: Optional[list[int]] = None, + ) -> InsightGroup: + """Get income grouped by asset account (receiving account).""" + params = self._build_insight_params(start_date, end_date, account_ids) + r = await self._client.get('/api/v1/insight/income/asset', params=params) + self._handle_api_error(r) + r.raise_for_status() + return InsightGroup.model_validate(r.json()) + + # Transfer Insight Methods + + async def get_transfer_total( + self, + start_date: date, + end_date: date, + account_ids: Optional[list[int]] = None, + ) -> InsightTotal: + """Get total transfers for a period.""" + params = self._build_insight_params(start_date, end_date, account_ids) + r = await self._client.get('/api/v1/insight/transfer/total', params=params) + self._handle_api_error(r) + r.raise_for_status() + return InsightTotal.model_validate(r.json()) + + async def get_transfer_by_asset_account( + self, + start_date: date, + end_date: date, + account_ids: Optional[list[int]] = None, + ) -> InsightTransfer: + """Get transfers grouped by asset account with in/out breakdown.""" + params = self._build_insight_params(start_date, end_date, account_ids) + r = await self._client.get('/api/v1/insight/transfer/asset', params=params) + self._handle_api_error(r) + r.raise_for_status() + return InsightTransfer.model_validate(r.json()) diff --git a/src/lampyrid/models/lampyrid_models.py b/src/lampyrid/models/lampyrid_models.py index cd33faf..f0befe0 100644 --- a/src/lampyrid/models/lampyrid_models.py +++ b/src/lampyrid/models/lampyrid_models.py @@ -773,3 +773,227 @@ class BulkUpdateTransactionsRequest(BaseModel): min_length=1, max_length=50, ) + + +# ============================================================================= +# Insight Models - Request and Response models for financial insights +# ============================================================================= + + +class GetExpenseInsightRequest(BaseModel): + """Request for expense insight analysis.""" + + model_config = ConfigDict(extra='forbid') + + start_date: date = Field(..., description='Start date for the analysis period (YYYY-MM-DD)') + end_date: date = Field(..., description='End date for the analysis period (YYYY-MM-DD)') + group_by: Optional[Literal['expense_account', 'asset_account', 'budget']] = Field( + None, + description=( + 'How to group expenses: expense_account (by vendor/payee), ' + 'asset_account (by source account), budget (by budget category). ' + 'If not specified, returns total only.' + ), + ) + account_ids: Optional[List[int]] = Field( + None, + description=( + 'Filter to specific account IDs. For expense_account grouping, these are expense ' + 'accounts. For asset_account grouping, these are asset accounts.' + ), + ) + budget_ids: Optional[List[int]] = Field( + None, + description='Filter to specific budget IDs. Only used when group_by is "budget".', + ) + include_unbudgeted: bool = Field( + True, + description=( + 'When group_by is "budget", include expenses not assigned to any budget ' + 'as a separate entry.' + ), + ) + + +class GetIncomeInsightRequest(BaseModel): + """Request for income insight analysis.""" + + model_config = ConfigDict(extra='forbid') + + start_date: date = Field(..., description='Start date for the analysis period (YYYY-MM-DD)') + end_date: date = Field(..., description='End date for the analysis period (YYYY-MM-DD)') + group_by: Optional[Literal['revenue_account', 'asset_account']] = Field( + None, + description=( + 'How to group income: revenue_account (by income source), ' + 'asset_account (by receiving account). ' + 'If not specified, returns total only.' + ), + ) + account_ids: Optional[List[int]] = Field( + None, + description='Filter to specific account IDs.', + ) + + +class GetTransferInsightRequest(BaseModel): + """Request for transfer insight analysis.""" + + model_config = ConfigDict(extra='forbid') + + start_date: date = Field(..., description='Start date for the analysis period (YYYY-MM-DD)') + end_date: date = Field(..., description='End date for the analysis period (YYYY-MM-DD)') + group_by: Optional[Literal['asset_account']] = Field( + None, + description=( + 'Group transfers by asset_account to see in/out breakdown per account. ' + 'If not specified, returns total only.' + ), + ) + account_ids: Optional[List[int]] = Field( + None, + description='Filter to specific asset account IDs.', + ) + + +class GetFinancialSummaryRequest(BaseModel): + """Request for complete financial summary.""" + + model_config = ConfigDict(extra='forbid') + + start_date: date = Field(..., description='Start date for the analysis period (YYYY-MM-DD)') + end_date: date = Field(..., description='End date for the analysis period (YYYY-MM-DD)') + account_ids: Optional[List[int]] = Field( + None, + description='Filter to specific account IDs for all calculations.', + ) + + +class InsightEntry(BaseModel): + """Single insight data point representing grouped financial data.""" + + id: Optional[str] = Field( + None, + description='Reference ID of the grouped entity (account ID, budget ID, etc.)', + ) + name: Optional[str] = Field( + None, + description='Display name of the grouped entity', + ) + amount: float = Field( + ..., + description='Total amount for this entry (negative for expenses, positive for income)', + ) + currency_code: str = Field( + ..., + description='Currency code (ISO 4217) for the amount', + ) + + +class TransferInsightEntry(InsightEntry): + """Transfer-specific insight with in/out breakdown per account.""" + + amount_in: float = Field( + ..., + description='Total amount transferred INTO this account', + ) + amount_out: float = Field( + ..., + description='Total amount transferred OUT OF this account', + ) + + +class ExpenseInsightResult(BaseModel): + """Result of expense insight analysis.""" + + entries: List[InsightEntry] = Field( + ..., + description='List of expense entries, grouped as requested', + ) + total_expenses: float = Field( + ..., + description='Total expenses for the period (as positive number)', + ) + currency_code: str = Field( + ..., + description='Primary currency code for the totals', + ) + start_date: date = Field(..., description='Start of the analysis period') + end_date: date = Field(..., description='End of the analysis period') + group_by: Optional[str] = Field( + None, + description='The grouping method used, if any', + ) + + +class IncomeInsightResult(BaseModel): + """Result of income insight analysis.""" + + entries: List[InsightEntry] = Field( + ..., + description='List of income entries, grouped as requested', + ) + total_income: float = Field( + ..., + description='Total income for the period (as positive number)', + ) + currency_code: str = Field( + ..., + description='Primary currency code for the totals', + ) + start_date: date = Field(..., description='Start of the analysis period') + end_date: date = Field(..., description='End of the analysis period') + group_by: Optional[str] = Field( + None, + description='The grouping method used, if any', + ) + + +class TransferInsightResult(BaseModel): + """Result of transfer insight analysis.""" + + entries: list[TransferInsightEntry] | list[InsightEntry] = Field( + ..., + description='List of transfer entries. When grouped by account, includes in/out breakdown.', + ) + total_transfers: float = Field( + ..., + description='Total transfer amount for the period', + ) + currency_code: str = Field( + ..., + description='Primary currency code for the totals', + ) + start_date: date = Field(..., description='Start of the analysis period') + end_date: date = Field(..., description='End of the analysis period') + group_by: Optional[str] = Field( + None, + description='The grouping method used, if any', + ) + + +class FinancialSummary(BaseModel): + """Complete financial summary with expense, income, and transfer totals.""" + + total_expenses: float = Field( + ..., + description='Total expenses for the period (as positive number)', + ) + total_income: float = Field( + ..., + description='Total income for the period (as positive number)', + ) + total_transfers: float = Field( + ..., + description='Total transfer amount for the period', + ) + net_position: float = Field( + ..., + description='Net financial position (income - expenses). Positive means net gain.', + ) + currency_code: str = Field( + ..., + description='Primary currency code for all amounts', + ) + start_date: date = Field(..., description='Start of the analysis period') + end_date: date = Field(..., description='End of the analysis period') diff --git a/src/lampyrid/services/__init__.py b/src/lampyrid/services/__init__.py index 42dadcd..2be1239 100644 --- a/src/lampyrid/services/__init__.py +++ b/src/lampyrid/services/__init__.py @@ -7,6 +7,7 @@ from .accounts import AccountService from .budgets import BudgetService +from .insights import InsightService from .transactions import TransactionService -__all__ = ['AccountService', 'BudgetService', 'TransactionService'] +__all__ = ['AccountService', 'BudgetService', 'InsightService', 'TransactionService'] diff --git a/src/lampyrid/services/insights.py b/src/lampyrid/services/insights.py new file mode 100644 index 0000000..af9a2d8 --- /dev/null +++ b/src/lampyrid/services/insights.py @@ -0,0 +1,289 @@ +"""Insight Service for LamPyrid. + +This service handles insight-related business logic and orchestrates +operations between the MCP tools and the Firefly III client. +""" + +import asyncio + +from ..clients.firefly import FireflyClient +from ..models.firefly_models import ( + InsightGroup, + InsightTotal, + InsightTransfer, +) +from ..models.lampyrid_models import ( + ExpenseInsightResult, + FinancialSummary, + GetExpenseInsightRequest, + GetFinancialSummaryRequest, + GetIncomeInsightRequest, + GetTransferInsightRequest, + IncomeInsightResult, + InsightEntry, + TransferInsightEntry, + TransferInsightResult, +) + + +class InsightService: + """Service for financial insight analysis. + + This service provides a high-level interface for insight operations, + handling response transformation, grouping logic, and multi-call + orchestration while delegating HTTP operations to the FireflyClient. + """ + + def __init__(self, client: FireflyClient) -> None: + """Initialize the insight service with a FireflyClient instance.""" + self._client = client + + def _entries_from_insight_group(self, insight: InsightGroup) -> list[InsightEntry]: + """Convert InsightGroup to list of InsightEntry.""" + entries = [] + for entry in insight.root: + entries.append( + InsightEntry( + id=entry.id, + name=entry.name, + amount=abs(entry.difference_float) if entry.difference_float else 0.0, + currency_code=entry.currency_code or 'USD', + ) + ) + return entries + + def _entries_from_insight_total( + self, insight: InsightTotal, name: str | None = None + ) -> list[InsightEntry]: + """Convert InsightTotal to list of InsightEntry.""" + entries = [] + for entry in insight.root: + entries.append( + InsightEntry( + id=None, + name=name, + amount=abs(entry.difference_float) if entry.difference_float else 0.0, + currency_code=entry.currency_code or 'USD', + ) + ) + return entries + + def _entries_from_insight_transfer( + self, insight: InsightTransfer + ) -> list[TransferInsightEntry]: + """Convert InsightTransfer to list of TransferInsightEntry.""" + entries = [] + for entry in insight.root: + entries.append( + TransferInsightEntry( + id=entry.id, + name=entry.name, + amount=abs(entry.difference_float) if entry.difference_float else 0.0, + amount_in=abs(entry.in_float) if entry.in_float else 0.0, + amount_out=abs(entry.out_float) if entry.out_float else 0.0, + currency_code=entry.currency_code or 'USD', + ) + ) + return entries + + def _get_total_and_currency( + self, entries: list[InsightEntry] | list[TransferInsightEntry] + ) -> tuple[float, str]: + """Calculate total amount and get primary currency from entries.""" + if not entries: + return 0.0, 'USD' + total = sum(e.amount for e in entries) + currency = entries[0].currency_code if entries else 'USD' + return total, currency + + async def get_expense_insight(self, req: GetExpenseInsightRequest) -> ExpenseInsightResult: + """Get expense insights with optional grouping. + + Args: + req: Request containing date range, grouping option, and filters + + Returns: + Expense insight result with entries and totals + + """ + entries: list[InsightEntry] = [] + group_by = req.group_by + + if group_by == 'expense_account': + insight = await self._client.get_expense_by_expense_account( + req.start_date, req.end_date, req.account_ids + ) + entries = self._entries_from_insight_group(insight) + + elif group_by == 'asset_account': + insight = await self._client.get_expense_by_asset_account( + req.start_date, req.end_date, req.account_ids + ) + entries = self._entries_from_insight_group(insight) + + elif group_by == 'budget': + # Get expenses by budget + insight = await self._client.get_expense_by_budget( + req.start_date, req.end_date, req.account_ids, req.budget_ids + ) + entries = self._entries_from_insight_group(insight) + + # Optionally include unbudgeted expenses + if req.include_unbudgeted: + no_budget_insight = await self._client.get_expense_no_budget( + req.start_date, req.end_date, req.account_ids + ) + unbudgeted_entries = self._entries_from_insight_total( + no_budget_insight, name='Unbudgeted' + ) + entries.extend(unbudgeted_entries) + + else: + # No grouping - return total only + insight = await self._client.get_expense_total( + req.start_date, req.end_date, req.account_ids + ) + entries = self._entries_from_insight_total(insight, name='Total Expenses') + + total, currency = self._get_total_and_currency(entries) + + return ExpenseInsightResult( + entries=entries, + total_expenses=total, + currency_code=currency, + start_date=req.start_date, + end_date=req.end_date, + group_by=group_by, + ) + + async def get_income_insight(self, req: GetIncomeInsightRequest) -> IncomeInsightResult: + """Get income insights with optional grouping. + + Args: + req: Request containing date range, grouping option, and filters + + Returns: + Income insight result with entries and totals + + """ + entries: list[InsightEntry] = [] + group_by = req.group_by + + if group_by == 'revenue_account': + insight = await self._client.get_income_by_revenue_account( + req.start_date, req.end_date, req.account_ids + ) + entries = self._entries_from_insight_group(insight) + + elif group_by == 'asset_account': + insight = await self._client.get_income_by_asset_account( + req.start_date, req.end_date, req.account_ids + ) + entries = self._entries_from_insight_group(insight) + + else: + # No grouping - return total only + insight = await self._client.get_income_total( + req.start_date, req.end_date, req.account_ids + ) + entries = self._entries_from_insight_total(insight, name='Total Income') + + total, currency = self._get_total_and_currency(entries) + + return IncomeInsightResult( + entries=entries, + total_income=total, + currency_code=currency, + start_date=req.start_date, + end_date=req.end_date, + group_by=group_by, + ) + + async def get_transfer_insight(self, req: GetTransferInsightRequest) -> TransferInsightResult: + """Get transfer insights with optional grouping. + + Args: + req: Request containing date range, grouping option, and filters + + Returns: + Transfer insight result with entries and totals + + """ + entries: list[TransferInsightEntry] | list[InsightEntry] = [] + group_by = req.group_by + + if group_by == 'asset_account': + insight = await self._client.get_transfer_by_asset_account( + req.start_date, req.end_date, req.account_ids + ) + entries = self._entries_from_insight_transfer(insight) + else: + # No grouping - return total only + insight = await self._client.get_transfer_total( + req.start_date, req.end_date, req.account_ids + ) + entries = self._entries_from_insight_total(insight, name='Total Transfers') + + total, currency = self._get_total_and_currency(entries) + + return TransferInsightResult( + entries=entries, + total_transfers=total, + currency_code=currency, + start_date=req.start_date, + end_date=req.end_date, + group_by=group_by, + ) + + async def get_financial_summary(self, req: GetFinancialSummaryRequest) -> FinancialSummary: + """Get complete financial summary with expense, income, and transfer totals. + + Makes parallel calls to all three total endpoints for efficiency. + + Args: + req: Request containing date range and optional account filter + + Returns: + Financial summary with totals and net position + + """ + # Make parallel calls to all total endpoints + expense_task = self._client.get_expense_total(req.start_date, req.end_date, req.account_ids) + income_task = self._client.get_income_total(req.start_date, req.end_date, req.account_ids) + transfer_task = self._client.get_transfer_total( + req.start_date, req.end_date, req.account_ids + ) + + expense_insight, income_insight, transfer_insight = await asyncio.gather( + expense_task, income_task, transfer_task + ) + + # Extract totals (use first entry as primary, sum if multiple currencies) + total_expenses = sum( + abs(e.difference_float) if e.difference_float else 0.0 for e in expense_insight.root + ) + total_income = sum( + abs(e.difference_float) if e.difference_float else 0.0 for e in income_insight.root + ) + total_transfers = sum( + abs(e.difference_float) if e.difference_float else 0.0 for e in transfer_insight.root + ) + + # Get primary currency from income (most likely to have data) + currency = 'USD' + if income_insight.root: + currency = income_insight.root[0].currency_code or 'USD' + elif expense_insight.root: + currency = expense_insight.root[0].currency_code or 'USD' + + net_position = total_income - total_expenses + + return FinancialSummary( + total_expenses=total_expenses, + total_income=total_income, + total_transfers=total_transfers, + net_position=net_position, + currency_code=currency, + start_date=req.start_date, + end_date=req.end_date, + ) diff --git a/src/lampyrid/tools/__init__.py b/src/lampyrid/tools/__init__.py index cf79c7f..8c53fc7 100644 --- a/src/lampyrid/tools/__init__.py +++ b/src/lampyrid/tools/__init__.py @@ -9,6 +9,7 @@ from ..clients.firefly import FireflyClient from .accounts import create_accounts_server from .budgets import create_budgets_server +from .insights import create_insights_server from .transactions import create_transactions_server @@ -24,8 +25,10 @@ async def compose_all_servers(mcp: FastMCP, client: FireflyClient) -> None: accounts_server = create_accounts_server(client) transactions_server = create_transactions_server(client) budgets_server = create_budgets_server(client) + insights_server = create_insights_server(client) # Import all servers into the main server without prefixes (static composition) await mcp.import_server(accounts_server) await mcp.import_server(transactions_server) await mcp.import_server(budgets_server) + await mcp.import_server(insights_server) diff --git a/src/lampyrid/tools/insights.py b/src/lampyrid/tools/insights.py new file mode 100644 index 0000000..8430fa9 --- /dev/null +++ b/src/lampyrid/tools/insights.py @@ -0,0 +1,95 @@ +"""Insight MCP Tools. + +This module provides MCP tools for financial insights and analytics +including expense, income, and transfer analysis with optional grouping. +""" + +from fastmcp import FastMCP + +from ..clients.firefly import FireflyClient +from ..models.lampyrid_models import ( + ExpenseInsightResult, + FinancialSummary, + GetExpenseInsightRequest, + GetFinancialSummaryRequest, + GetIncomeInsightRequest, + GetTransferInsightRequest, + IncomeInsightResult, + TransferInsightResult, +) +from ..services.insights import InsightService + + +def create_insights_server(client: FireflyClient) -> FastMCP: + """Create a standalone FastMCP server for insight analysis tools. + + Args: + client: The FireflyClient instance for API interactions + + Returns: + FastMCP server instance with insight tools registered + + """ + insight_service = InsightService(client) + + insights_mcp = FastMCP('insights') + + @insights_mcp.tool(tags={'insights', 'expenses', 'analysis'}) + async def get_expense_insight(req: GetExpenseInsightRequest) -> ExpenseInsightResult: + """Analyze expenses for a time period with optional grouping. + + Get total expenses or break them down by expense account (vendor/payee), + asset account (source), or budget. When grouping by budget, optionally + includes unbudgeted expenses as a separate entry. + + Use cases: + - See total spending for a month + - Identify top expense categories/vendors + - Track which accounts have the most outflow + - Analyze budget utilization vs unbudgeted spending + """ + return await insight_service.get_expense_insight(req) + + @insights_mcp.tool(tags={'insights', 'income', 'analysis'}) + async def get_income_insight(req: GetIncomeInsightRequest) -> IncomeInsightResult: + """Analyze income for a time period with optional grouping. + + Get total income or break it down by revenue account (source) + or asset account (receiving account). + + Use cases: + - See total income for a month + - Identify income sources + - Track which accounts receive the most income + """ + return await insight_service.get_income_insight(req) + + @insights_mcp.tool(tags={'insights', 'transfers', 'analysis'}) + async def get_transfer_insight(req: GetTransferInsightRequest) -> TransferInsightResult: + """Analyze transfers for a time period with optional account breakdown. + + Get total transfers or break them down by asset account with + in/out amounts for each account. + + Use cases: + - See total transfer activity + - Understand money flow between accounts + - Track savings/investment transfers + """ + return await insight_service.get_transfer_insight(req) + + @insights_mcp.tool(tags={'insights', 'summary', 'analysis'}) + async def get_financial_summary(req: GetFinancialSummaryRequest) -> FinancialSummary: + """Get a complete financial overview for a time period. + + Returns expense, income, and transfer totals along with net position + (income minus expenses). Makes parallel API calls for efficiency. + + Use cases: + - Quick financial health check + - Monthly/yearly overview + - Dashboard summary data + """ + return await insight_service.get_financial_summary(req) + + return insights_mcp From e187356c6d13e490056c6292bbb7122e1df44a7d Mon Sep 17 00:00:00 2001 From: Radith Samarakoon Date: Sat, 31 Jan 2026 15:11:06 +0530 Subject: [PATCH 2/5] test: add integration tests for insight tools - Implement comprehensive integration tests for expense, income, and transfer insights. - Add automated seed transaction generation and cleanup logic to conftest.py. - Introduce insight request factory fixtures for standardized test data. - Register the insights pytest marker in pyproject.toml. --- pyproject.toml | 1 + tests/conftest.py | 153 +++++++- tests/fixtures/insights.py | 105 ++++++ tests/integration/test_insights.py | 545 +++++++++++++++++++++++++++++ 4 files changed, 799 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/insights.py create mode 100644 tests/integration/test_insights.py diff --git a/pyproject.toml b/pyproject.toml index 186f274..4dda872 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ markers = [ "accounts: account-related tests", "transactions: transaction-related tests", "budgets: budget-related tests", + "insights: insight analysis tests", ] [tool.datamodel-codegen] diff --git a/tests/conftest.py b/tests/conftest.py index d60cbf7..a19d43c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,10 +4,11 @@ - FireflyClient instance configured for testing - Test accounts (asset, expense, revenue) - Test budgets +- Seed transactions for insight tests - Transaction cleanup utilities """ -from datetime import datetime, timezone +from datetime import date, datetime, timezone from pathlib import Path from typing import List @@ -26,6 +27,9 @@ from lampyrid.models.lampyrid_models import ( Account, Budget, + CreateDepositRequest, + CreateTransferRequest, + CreateWithdrawalRequest, ListAccountRequest, ListBudgetsRequest, ) @@ -44,7 +48,7 @@ from lampyrid.clients.firefly import FireflyClient # noqa: E402 from lampyrid.config import settings # noqa: E402 -from lampyrid.services import AccountService, BudgetService # noqa: E402 +from lampyrid.services import AccountService, BudgetService, TransactionService # noqa: E402 from lampyrid.tools import compose_all_servers # noqa: E402 # Global cache for test data created programmatically @@ -52,6 +56,7 @@ _cached_test_budgets: List[Budget] | None = None _created_account_ids: List[str] = [] # Track created accounts for cleanup _created_budget_ids: List[str] = [] # Track created budgets for cleanup +_seed_transaction_ids: List[str] = [] # Track seed transactions for cleanup @pytest.fixture(scope='session', autouse=True) @@ -135,7 +140,7 @@ async def _setup_test_data(): expense_store = AccountStore( name='Test Expense', type=ShortAccountTypeProperty.expense, - currency_code='EUR', + currency_code='USD', ) expense = await account_service.create_account(expense_store) _created_account_ids.append(expense.id) @@ -146,7 +151,7 @@ async def _setup_test_data(): expense2_store = AccountStore( name='Test Expense 2', type=ShortAccountTypeProperty.expense, - currency_code='EUR', + currency_code='USD', ) expense2 = await account_service.create_account(expense2_store) _created_account_ids.append(expense2.id) @@ -168,7 +173,7 @@ async def _setup_test_data(): revenue_store = AccountStore( name='Test Revenue', type=ShortAccountTypeProperty.revenue, - currency_code='EUR', + currency_code='USD', ) revenue = await account_service.create_account(revenue_store) _created_account_ids.append(revenue.id) @@ -194,6 +199,115 @@ async def _setup_test_data(): _cached_test_budgets.append(test_budget) + # Create seed transactions for insight tests + # These provide meaningful data for expense, income, and transfer analysis + if not _seed_transaction_ids: + transaction_service = TransactionService(client) + + # Use first day of current month for consistent date + today = date.today() + seed_date = datetime(today.year, today.month, 1, 12, 0, 0, tzinfo=timezone.utc) + + # Get account references + checking = _cached_test_accounts[0] # Test Checking + savings = _cached_test_accounts[1] # Test Savings + expense_account = _cached_test_accounts[2] # Test Expense + expense_account2 = _cached_test_accounts[3] # Test Expense 2 + revenue_account = _cached_test_accounts[4] # Test Revenue + budget = _cached_test_budgets[0] # Test Budget + + # Withdrawal 1: $50 from Checking to Test Expense (unbudgeted) + txn1 = await transaction_service.create_withdrawal( + CreateWithdrawalRequest( + amount=50.0, + description='Seed: Unbudgeted expense 1', + source_id=checking.id, + destination_id=expense_account.id, + date=seed_date, + ) + ) + assert txn1.id is not None + _seed_transaction_ids.append(txn1.id) + + # Withdrawal 2: $30 from Checking to Test Expense 2 (unbudgeted) + txn2 = await transaction_service.create_withdrawal( + CreateWithdrawalRequest( + amount=30.0, + description='Seed: Unbudgeted expense 2', + source_id=checking.id, + destination_id=expense_account2.id, + date=seed_date, + ) + ) + assert txn2.id is not None + _seed_transaction_ids.append(txn2.id) + + # Withdrawal 3: $25 from Savings to Test Expense (unbudgeted) + txn3 = await transaction_service.create_withdrawal( + CreateWithdrawalRequest( + amount=25.0, + description='Seed: Unbudgeted expense from savings', + source_id=savings.id, + destination_id=expense_account.id, + date=seed_date, + ) + ) + assert txn3.id is not None + _seed_transaction_ids.append(txn3.id) + + # Withdrawal 4: $40 from Checking to Test Expense (budgeted) + txn4 = await transaction_service.create_withdrawal( + CreateWithdrawalRequest( + amount=40.0, + description='Seed: Budgeted expense', + source_id=checking.id, + destination_id=expense_account.id, + budget_id=budget.id, + date=seed_date, + ) + ) + assert txn4.id is not None + _seed_transaction_ids.append(txn4.id) + + # Deposit 1: $200 from Test Revenue to Checking + txn5 = await transaction_service.create_deposit( + CreateDepositRequest( + amount=200.0, + description='Seed: Income to checking', + source_id=revenue_account.id, + destination_id=checking.id, + date=seed_date, + ) + ) + assert txn5.id is not None + _seed_transaction_ids.append(txn5.id) + + # Deposit 2: $100 from Test Revenue to Savings + txn6 = await transaction_service.create_deposit( + CreateDepositRequest( + amount=100.0, + description='Seed: Income to savings', + source_id=revenue_account.id, + destination_id=savings.id, + date=seed_date, + ) + ) + assert txn6.id is not None + _seed_transaction_ids.append(txn6.id) + + # Transfer: $75 from Checking to Savings + txn7 = await transaction_service.create_transfer( + CreateTransferRequest( + amount=75.0, + description='Seed: Transfer to savings', + source_id=checking.id, + destination_id=savings.id, + date=seed_date, + ) + ) + assert txn7.id is not None + _seed_transaction_ids.append(txn7.id) + finally: await client.aclose() @@ -378,3 +492,32 @@ async def test_create_transaction(firefly_client, transaction_cleanup): print(f'Cleaned up transaction: {transaction_id}') except Exception as e: print(f'Failed to cleanup transaction {transaction_id}: {e}') + + +@pytest.fixture(scope='session', autouse=True) +async def _cleanup_seed_transactions(): + """Cleanup seed transactions at session end. + + This fixture runs after all tests complete and removes the seed + transactions created for insight tests. + """ + # Yield control to let tests run + yield + + # Cleanup after all tests complete + if not _seed_transaction_ids: + return + + if not settings.firefly_base_url or not settings.firefly_token: + return + + client = FireflyClient() + try: + for transaction_id in _seed_transaction_ids: + try: + await client.delete_transaction(transaction_id) + print(f'Cleaned up seed transaction: {transaction_id}') + except Exception as e: + print(f'Failed to cleanup seed transaction {transaction_id}: {e}') + finally: + await client.aclose() diff --git a/tests/fixtures/insights.py b/tests/fixtures/insights.py new file mode 100644 index 0000000..ad1deb2 --- /dev/null +++ b/tests/fixtures/insights.py @@ -0,0 +1,105 @@ +"""Test data factories for insight-related tests.""" + +from datetime import date, timedelta +from typing import List, Literal + +from lampyrid.models.lampyrid_models import ( + GetExpenseInsightRequest, + GetFinancialSummaryRequest, + GetIncomeInsightRequest, + GetTransferInsightRequest, +) + + +def make_get_expense_insight_request( + start: date | None = None, + end: date | None = None, + group_by: Literal['expense_account', 'asset_account', 'budget'] | None = None, + account_ids: List[int] | None = None, + budget_ids: List[int] | None = None, + include_unbudgeted: bool = True, +) -> GetExpenseInsightRequest: + """Create a GetExpenseInsightRequest for testing.""" + if start is None: + # Default to current month + start = date.today().replace(day=1) + if end is None: + # Default to end of current month + next_month = start.replace(day=28) + timedelta(days=4) + end = next_month.replace(day=1) - timedelta(days=1) + + return GetExpenseInsightRequest( + start_date=start, + end_date=end, + group_by=group_by, + account_ids=account_ids, + budget_ids=budget_ids, + include_unbudgeted=include_unbudgeted, + ) + + +def make_get_income_insight_request( + start: date | None = None, + end: date | None = None, + group_by: Literal['revenue_account', 'asset_account'] | None = None, + account_ids: List[int] | None = None, +) -> GetIncomeInsightRequest: + """Create a GetIncomeInsightRequest for testing.""" + if start is None: + # Default to current month + start = date.today().replace(day=1) + if end is None: + # Default to end of current month + next_month = start.replace(day=28) + timedelta(days=4) + end = next_month.replace(day=1) - timedelta(days=1) + + return GetIncomeInsightRequest( + start_date=start, + end_date=end, + group_by=group_by, + account_ids=account_ids, + ) + + +def make_get_transfer_insight_request( + start: date | None = None, + end: date | None = None, + group_by: Literal['asset_account'] | None = None, + account_ids: List[int] | None = None, +) -> GetTransferInsightRequest: + """Create a GetTransferInsightRequest for testing.""" + if start is None: + # Default to current month + start = date.today().replace(day=1) + if end is None: + # Default to end of current month + next_month = start.replace(day=28) + timedelta(days=4) + end = next_month.replace(day=1) - timedelta(days=1) + + return GetTransferInsightRequest( + start_date=start, + end_date=end, + group_by=group_by, + account_ids=account_ids, + ) + + +def make_get_financial_summary_request( + start: date | None = None, + end: date | None = None, + account_ids: List[int] | None = None, +) -> GetFinancialSummaryRequest: + """Create a GetFinancialSummaryRequest for testing.""" + if start is None: + # Default to current month + start = date.today().replace(day=1) + if end is None: + # Default to end of current month + next_month = start.replace(day=28) + timedelta(days=4) + end = next_month.replace(day=1) - timedelta(days=1) + + return GetFinancialSummaryRequest( + start_date=start, + end_date=end, + account_ids=account_ids, + ) diff --git a/tests/integration/test_insights.py b/tests/integration/test_insights.py new file mode 100644 index 0000000..0a23eaf --- /dev/null +++ b/tests/integration/test_insights.py @@ -0,0 +1,545 @@ +"""Integration tests for insight analysis tools.""" + +from datetime import date, timedelta + +import pytest +from dirty_equals import IsDatetime, IsStr +from fastmcp import Client +from inline_snapshot import snapshot + +from lampyrid.models.lampyrid_models import ( + Account, + Budget, +) + + +def _get_current_month_dates() -> tuple[date, date]: + """Get start and end dates for the current month.""" + today = date.today() + start = today.replace(day=1) + # End of month + next_month = start.replace(day=28) + timedelta(days=4) + end = next_month.replace(day=1) - timedelta(days=1) + return start, end + + +# ============================================================================= +# Expense Insight Tests +# ============================================================================= + + +@pytest.mark.asyncio +@pytest.mark.insights +@pytest.mark.integration +async def test_get_expense_insight_total(mcp_client: Client): + """Test getting total expense insight without grouping.""" + start, end = _get_current_month_dates() + + result = await mcp_client.call_tool( + 'get_expense_insight', + { + 'req': { + 'start_date': start.isoformat(), + 'end_date': end.isoformat(), + } + }, + ) + insight = result.structured_content + + assert insight == snapshot( + { + 'entries': [ + { + 'id': None, + 'name': 'Total Expenses', + 'amount': 155.5, + 'currency_code': 'USD', + } + ], + 'total_expenses': 155.5, + 'currency_code': 'USD', + 'start_date': IsDatetime(iso_string=True), + 'end_date': IsDatetime(iso_string=True), + 'group_by': None, + } + ) + + +@pytest.mark.asyncio +@pytest.mark.insights +@pytest.mark.integration +async def test_get_expense_insight_by_expense_account(mcp_client: Client): + """Test getting expense insight grouped by expense account (vendor/payee).""" + start, end = _get_current_month_dates() + + result = await mcp_client.call_tool( + 'get_expense_insight', + { + 'req': { + 'start_date': start.isoformat(), + 'end_date': end.isoformat(), + 'group_by': 'expense_account', + } + }, + ) + insight = result.structured_content + + assert insight == snapshot( + { + 'entries': [ + { + 'id': IsStr(min_length=1), + 'name': 'Test Expense', + 'amount': 125.5, + 'currency_code': 'USD', + }, + {'id': '6', 'name': 'Test Expense 2', 'amount': 30.0, 'currency_code': 'USD'}, + ], + 'total_expenses': 155.5, + 'currency_code': 'USD', + 'start_date': IsDatetime(iso_string=True), + 'end_date': IsDatetime(iso_string=True), + 'group_by': 'expense_account', + } + ) + + +@pytest.mark.asyncio +@pytest.mark.insights +@pytest.mark.integration +async def test_get_expense_insight_by_asset_account( + mcp_client: Client, test_asset_account: Account +): + """Test getting expense insight grouped by asset account (source).""" + start, end = _get_current_month_dates() + + result = await mcp_client.call_tool( + 'get_expense_insight', + { + 'req': { + 'start_date': start.isoformat(), + 'end_date': end.isoformat(), + 'group_by': 'asset_account', + } + }, + ) + insight = result.structured_content + assert insight == snapshot( + { + 'entries': [ + { + 'id': IsStr(min_length=1), + 'name': 'Test Checking', + 'amount': 130.5, + 'currency_code': 'USD', + }, + {'id': '3', 'name': 'Test Savings', 'amount': 25.0, 'currency_code': 'USD'}, + ], + 'total_expenses': 155.5, + 'currency_code': 'USD', + 'start_date': IsDatetime(iso_string=True), + 'end_date': IsDatetime(iso_string=True), + 'group_by': 'asset_account', + } + ) + + +@pytest.mark.asyncio +@pytest.mark.insights +@pytest.mark.integration +async def test_get_expense_insight_by_budget(mcp_client: Client, test_budget: Budget): + """Test getting expense insight grouped by budget with unbudgeted expenses.""" + start, end = _get_current_month_dates() + + result = await mcp_client.call_tool( + 'get_expense_insight', + { + 'req': { + 'start_date': start.isoformat(), + 'end_date': end.isoformat(), + 'group_by': 'budget', + 'include_unbudgeted': True, + } + }, + ) + insight = result.structured_content + assert insight == snapshot( + { + 'entries': [ + {'id': '1', 'name': 'Test Budget', 'amount': 40.0, 'currency_code': 'USD'}, + {'id': None, 'name': 'Unbudgeted', 'amount': 115.5, 'currency_code': 'USD'}, + ], + 'total_expenses': 155.5, + 'currency_code': 'USD', + 'start_date': IsDatetime(iso_string=True), + 'end_date': IsDatetime(iso_string=True), + 'group_by': 'budget', + } + ) + + +@pytest.mark.asyncio +@pytest.mark.insights +@pytest.mark.integration +async def test_get_expense_insight_by_budget_without_unbudgeted( + mcp_client: Client, test_budget: Budget +): + """Test getting expense insight by budget without including unbudgeted expenses.""" + start, end = _get_current_month_dates() + + result = await mcp_client.call_tool( + 'get_expense_insight', + { + 'req': { + 'start_date': start.isoformat(), + 'end_date': end.isoformat(), + 'group_by': 'budget', + 'include_unbudgeted': False, + } + }, + ) + insight = result.structured_content + + assert insight == snapshot( + { + 'entries': [{'id': '1', 'name': 'Test Budget', 'amount': 40.0, 'currency_code': 'USD'}], + 'total_expenses': 40.0, + 'currency_code': 'USD', + 'start_date': IsDatetime(iso_string=True), + 'end_date': IsDatetime(iso_string=True), + 'group_by': 'budget', + } + ) + + +# ============================================================================= +# Income Insight Tests +# ============================================================================= + + +@pytest.mark.asyncio +@pytest.mark.insights +@pytest.mark.integration +async def test_get_income_insight_total(mcp_client: Client): + """Test getting total income insight without grouping.""" + start, end = _get_current_month_dates() + + result = await mcp_client.call_tool( + 'get_income_insight', + { + 'req': { + 'start_date': start.isoformat(), + 'end_date': end.isoformat(), + } + }, + ) + insight = result.structured_content + + assert insight == snapshot( + { + 'entries': [ + {'id': None, 'name': 'Total Income', 'amount': 300.0, 'currency_code': 'USD'} + ], + 'total_income': 300.0, + 'currency_code': 'USD', + 'start_date': IsDatetime(iso_string=True), + 'end_date': IsDatetime(iso_string=True), + 'group_by': None, + } + ) + + +@pytest.mark.asyncio +@pytest.mark.insights +@pytest.mark.integration +async def test_get_income_insight_by_revenue_account(mcp_client: Client): + """Test getting income insight grouped by revenue account (source).""" + start, end = _get_current_month_dates() + + result = await mcp_client.call_tool( + 'get_income_insight', + { + 'req': { + 'start_date': start.isoformat(), + 'end_date': end.isoformat(), + 'group_by': 'revenue_account', + } + }, + ) + insight = result.structured_content + + assert insight == snapshot( + { + 'entries': [ + {'id': '7', 'name': 'Test Revenue', 'amount': 300.0, 'currency_code': 'USD'} + ], + 'total_income': 300.0, + 'currency_code': 'USD', + 'start_date': IsDatetime(iso_string=True), + 'end_date': IsDatetime(iso_string=True), + 'group_by': 'revenue_account', + } + ) + + +@pytest.mark.asyncio +@pytest.mark.insights +@pytest.mark.integration +async def test_get_income_insight_by_asset_account(mcp_client: Client, test_asset_account: Account): + """Test getting income insight grouped by asset account (receiving account).""" + start, end = _get_current_month_dates() + + result = await mcp_client.call_tool( + 'get_income_insight', + { + 'req': { + 'start_date': start.isoformat(), + 'end_date': end.isoformat(), + 'group_by': 'asset_account', + } + }, + ) + insight = result.structured_content + + assert insight == snapshot( + { + 'entries': [ + {'id': '3', 'name': 'Test Savings', 'amount': 100.0, 'currency_code': 'USD'}, + {'id': '1', 'name': 'Test Checking', 'amount': 200.0, 'currency_code': 'USD'}, + ], + 'total_income': 300.0, + 'currency_code': 'USD', + 'start_date': IsDatetime(iso_string=True), + 'end_date': IsDatetime(iso_string=True), + 'group_by': 'asset_account', + } + ) + + +# ============================================================================= +# Transfer Insight Tests +# ============================================================================= + + +@pytest.mark.asyncio +@pytest.mark.insights +@pytest.mark.integration +async def test_get_transfer_insight_total(mcp_client: Client): + """Test getting total transfer insight without grouping.""" + start, end = _get_current_month_dates() + + result = await mcp_client.call_tool( + 'get_transfer_insight', + { + 'req': { + 'start_date': start.isoformat(), + 'end_date': end.isoformat(), + } + }, + ) + insight = result.structured_content + + assert insight == snapshot( + { + 'entries': [ + {'id': None, 'name': 'Total Transfers', 'amount': 75.0, 'currency_code': 'USD'} + ], + 'total_transfers': 75.0, + 'currency_code': 'USD', + 'start_date': IsDatetime(iso_string=True), + 'end_date': IsDatetime(iso_string=True), + 'group_by': None, + } + ) + + +@pytest.mark.asyncio +@pytest.mark.insights +@pytest.mark.integration +async def test_get_transfer_insight_by_asset_account( + mcp_client: Client, test_asset_account: Account +): + """Test getting transfer insight grouped by asset account with in/out breakdown.""" + start, end = _get_current_month_dates() + + result = await mcp_client.call_tool( + 'get_transfer_insight', + { + 'req': { + 'start_date': start.isoformat(), + 'end_date': end.isoformat(), + 'group_by': 'asset_account', + } + }, + ) + insight = result.structured_content + + assert insight == snapshot( + { + 'entries': [ + { + 'id': '1', + 'name': 'Test Checking', + 'amount': 75.0, + 'currency_code': 'USD', + 'amount_in': 0.0, + 'amount_out': 75.0, + }, + { + 'id': '3', + 'name': 'Test Savings', + 'amount': 75.0, + 'currency_code': 'USD', + 'amount_in': 75.0, + 'amount_out': 0.0, + }, + ], + 'total_transfers': 150.0, + 'currency_code': 'USD', + 'start_date': IsDatetime(iso_string=True), + 'end_date': IsDatetime(iso_string=True), + 'group_by': 'asset_account', + } + ) + + +# ============================================================================= +# Financial Summary Tests +# ============================================================================= + + +@pytest.mark.asyncio +@pytest.mark.insights +@pytest.mark.integration +async def test_get_financial_summary(mcp_client: Client): + """Test getting complete financial summary.""" + start, end = _get_current_month_dates() + + result = await mcp_client.call_tool( + 'get_financial_summary', + { + 'req': { + 'start_date': start.isoformat(), + 'end_date': end.isoformat(), + } + }, + ) + summary = result.structured_content + + assert summary == snapshot( + { + 'total_expenses': 155.5, + 'total_income': 300.0, + 'total_transfers': 75.0, + 'net_position': 144.5, + 'currency_code': 'USD', + 'start_date': IsDatetime(iso_string=True), + 'end_date': IsDatetime(iso_string=True), + } + ) + + +@pytest.mark.asyncio +@pytest.mark.insights +@pytest.mark.integration +async def test_get_financial_summary_with_account_filter( + mcp_client: Client, test_asset_account: Account +): + """Test getting financial summary filtered by specific account.""" + start, end = _get_current_month_dates() + + result = await mcp_client.call_tool( + 'get_financial_summary', + { + 'req': { + 'start_date': start.isoformat(), + 'end_date': end.isoformat(), + 'account_ids': [int(test_asset_account.id)], + } + }, + ) + summary = result.structured_content + + assert summary == snapshot( + { + 'total_expenses': 130.5, + 'total_income': 200.0, + 'total_transfers': 0.0, + 'net_position': 69.5, + 'currency_code': 'USD', + 'start_date': IsDatetime(iso_string=True), + 'end_date': IsDatetime(iso_string=True), + } + ) + + +# ============================================================================= +# Edge Case Tests +# ============================================================================= + + +@pytest.mark.asyncio +@pytest.mark.insights +@pytest.mark.integration +async def test_get_expense_insight_empty_period(mcp_client: Client): + """Test getting expense insight for a period with no data.""" + # Use a date range far in the past where there should be no data + start = date(2000, 1, 1) + end = date(2000, 1, 31) + + result = await mcp_client.call_tool( + 'get_expense_insight', + { + 'req': { + 'start_date': start.isoformat(), + 'end_date': end.isoformat(), + } + }, + ) + insight = result.structured_content + + assert insight == snapshot( + { + 'entries': [], + 'total_expenses': 0.0, + 'currency_code': 'USD', + 'start_date': '2000-01-01', + 'end_date': '2000-01-31', + 'group_by': None, + } + ) + + +@pytest.mark.asyncio +@pytest.mark.insights +@pytest.mark.integration +async def test_get_income_insight_with_account_filter( + mcp_client: Client, test_asset_account: Account +): + """Test getting income insight filtered by specific account.""" + start, end = _get_current_month_dates() + + result = await mcp_client.call_tool( + 'get_income_insight', + { + 'req': { + 'start_date': start.isoformat(), + 'end_date': end.isoformat(), + 'account_ids': [int(test_asset_account.id)], + } + }, + ) + insight = result.structured_content + + assert insight == snapshot( + { + 'entries': [ + {'id': None, 'name': 'Total Income', 'amount': 200.0, 'currency_code': 'USD'} + ], + 'total_income': 200.0, + 'currency_code': 'USD', + 'start_date': IsDatetime(iso_string=True), + 'end_date': IsDatetime(iso_string=True), + 'group_by': None, + } + ) From 00a9cd06595344523dfeebc0676f9f4bf9ca8747 Mon Sep 17 00:00:00 2001 From: Radith Samarakoon Date: Sat, 31 Jan 2026 15:11:51 +0530 Subject: [PATCH 3/5] chore: upgrade dependencies --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 8ed59c9..146b5f6 100644 --- a/uv.lock +++ b/uv.lock @@ -1159,11 +1159,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, ] [package.optional-dependencies] From 1d889b29783d884e793645f4e8f4db7930a12ea7 Mon Sep 17 00:00:00 2001 From: Radith Samarakoon Date: Sat, 31 Jan 2026 15:29:22 +0530 Subject: [PATCH 4/5] test: fix values in test snapshots --- tests/integration/test_insights.py | 50 +++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/tests/integration/test_insights.py b/tests/integration/test_insights.py index 0a23eaf..7336ebb 100644 --- a/tests/integration/test_insights.py +++ b/tests/integration/test_insights.py @@ -52,11 +52,11 @@ async def test_get_expense_insight_total(mcp_client: Client): { 'id': None, 'name': 'Total Expenses', - 'amount': 155.5, + 'amount': 145.0, 'currency_code': 'USD', } ], - 'total_expenses': 155.5, + 'total_expenses': 145.0, 'currency_code': 'USD', 'start_date': IsDatetime(iso_string=True), 'end_date': IsDatetime(iso_string=True), @@ -90,12 +90,17 @@ async def test_get_expense_insight_by_expense_account(mcp_client: Client): { 'id': IsStr(min_length=1), 'name': 'Test Expense', - 'amount': 125.5, + 'amount': 115.0, + 'currency_code': 'USD', + }, + { + 'id': IsStr(min_length=1), + 'name': 'Test Expense 2', + 'amount': 30.0, 'currency_code': 'USD', }, - {'id': '6', 'name': 'Test Expense 2', 'amount': 30.0, 'currency_code': 'USD'}, ], - 'total_expenses': 155.5, + 'total_expenses': 145.0, 'currency_code': 'USD', 'start_date': IsDatetime(iso_string=True), 'end_date': IsDatetime(iso_string=True), @@ -130,12 +135,17 @@ async def test_get_expense_insight_by_asset_account( { 'id': IsStr(min_length=1), 'name': 'Test Checking', - 'amount': 130.5, + 'amount': 120.0, + 'currency_code': 'USD', + }, + { + 'id': IsStr(min_length=1), + 'name': 'Test Savings', + 'amount': 25.0, 'currency_code': 'USD', }, - {'id': '3', 'name': 'Test Savings', 'amount': 25.0, 'currency_code': 'USD'}, ], - 'total_expenses': 155.5, + 'total_expenses': 145.0, 'currency_code': 'USD', 'start_date': IsDatetime(iso_string=True), 'end_date': IsDatetime(iso_string=True), @@ -166,10 +176,20 @@ async def test_get_expense_insight_by_budget(mcp_client: Client, test_budget: Bu assert insight == snapshot( { 'entries': [ - {'id': '1', 'name': 'Test Budget', 'amount': 40.0, 'currency_code': 'USD'}, - {'id': None, 'name': 'Unbudgeted', 'amount': 115.5, 'currency_code': 'USD'}, + { + 'id': IsStr(min_length=1), + 'name': 'Test Budget', + 'amount': 40.0, + 'currency_code': 'USD', + }, + { + 'id': None, + 'name': 'Unbudgeted', + 'amount': 105.0, + 'currency_code': 'USD', + }, ], - 'total_expenses': 155.5, + 'total_expenses': 145.0, 'currency_code': 'USD', 'start_date': IsDatetime(iso_string=True), 'end_date': IsDatetime(iso_string=True), @@ -428,10 +448,10 @@ async def test_get_financial_summary(mcp_client: Client): assert summary == snapshot( { - 'total_expenses': 155.5, + 'total_expenses': 145.0, 'total_income': 300.0, 'total_transfers': 75.0, - 'net_position': 144.5, + 'net_position': 155.0, 'currency_code': 'USD', 'start_date': IsDatetime(iso_string=True), 'end_date': IsDatetime(iso_string=True), @@ -462,10 +482,10 @@ async def test_get_financial_summary_with_account_filter( assert summary == snapshot( { - 'total_expenses': 130.5, + 'total_expenses': 120.0, 'total_income': 200.0, 'total_transfers': 0.0, - 'net_position': 69.5, + 'net_position': 80.0, 'currency_code': 'USD', 'start_date': IsDatetime(iso_string=True), 'end_date': IsDatetime(iso_string=True), From 09d95470d04c2a58453373946c4713e138b5c633 Mon Sep 17 00:00:00 2001 From: Radith Samarakoon Date: Sat, 31 Jan 2026 15:42:57 +0530 Subject: [PATCH 5/5] test: use flexible checks for account ids --- tests/integration/test_insights.py | 34 ++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/tests/integration/test_insights.py b/tests/integration/test_insights.py index 7336ebb..884eece 100644 --- a/tests/integration/test_insights.py +++ b/tests/integration/test_insights.py @@ -222,7 +222,14 @@ async def test_get_expense_insight_by_budget_without_unbudgeted( assert insight == snapshot( { - 'entries': [{'id': '1', 'name': 'Test Budget', 'amount': 40.0, 'currency_code': 'USD'}], + 'entries': [ + { + 'id': IsStr(min_length=1), + 'name': 'Test Budget', + 'amount': 40.0, + 'currency_code': 'USD', + } + ], 'total_expenses': 40.0, 'currency_code': 'USD', 'start_date': IsDatetime(iso_string=True), @@ -291,7 +298,12 @@ async def test_get_income_insight_by_revenue_account(mcp_client: Client): assert insight == snapshot( { 'entries': [ - {'id': '7', 'name': 'Test Revenue', 'amount': 300.0, 'currency_code': 'USD'} + { + 'id': IsStr(min_length=1), + 'name': 'Test Revenue', + 'amount': 300.0, + 'currency_code': 'USD', + } ], 'total_income': 300.0, 'currency_code': 'USD', @@ -324,8 +336,18 @@ async def test_get_income_insight_by_asset_account(mcp_client: Client, test_asse assert insight == snapshot( { 'entries': [ - {'id': '3', 'name': 'Test Savings', 'amount': 100.0, 'currency_code': 'USD'}, - {'id': '1', 'name': 'Test Checking', 'amount': 200.0, 'currency_code': 'USD'}, + { + 'id': IsStr(min_length=1), + 'name': 'Test Savings', + 'amount': 100.0, + 'currency_code': 'USD', + }, + { + 'id': IsStr(min_length=1), + 'name': 'Test Checking', + 'amount': 200.0, + 'currency_code': 'USD', + }, ], 'total_income': 300.0, 'currency_code': 'USD', @@ -398,7 +420,7 @@ async def test_get_transfer_insight_by_asset_account( { 'entries': [ { - 'id': '1', + 'id': IsStr(min_length=1), 'name': 'Test Checking', 'amount': 75.0, 'currency_code': 'USD', @@ -406,7 +428,7 @@ async def test_get_transfer_insight_by_asset_account( 'amount_out': 75.0, }, { - 'id': '3', + 'id': IsStr(min_length=1), 'name': 'Test Savings', 'amount': 75.0, 'currency_code': 'USD',