From a8e70bcea9aeef430a3d2abf260b8307a3db6750 Mon Sep 17 00:00:00 2001 From: Radith Samarakoon Date: Sat, 17 Jan 2026 16:24:24 +0530 Subject: [PATCH 01/12] chore: Add 'ty' dev dependency --- pyproject.toml | 1 + uv.lock | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index c1735be..2a109f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dev = [ "pytest-cov>=7.0.0", "python-dotenv>=1.2.1", "ruff>=0.14.11", + "ty>=0.0.12", ] [build-system] diff --git a/uv.lock b/uv.lock index 3e9693c..a09c82d 100644 --- a/uv.lock +++ b/uv.lock @@ -702,6 +702,7 @@ dev = [ { name = "pytest-cov" }, { name = "python-dotenv" }, { name = "ruff" }, + { name = "ty" }, ] [package.metadata] @@ -722,6 +723,7 @@ dev = [ { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "ruff", specifier = ">=0.14.11" }, + { name = "ty", specifier = ">=0.0.12" }, ] [[package]] @@ -1475,6 +1477,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/c4/09985a03dba389d4fe16a9014147a7b02fa76ef3519bf5846462a485876d/starlette-0.51.0-py3-none-any.whl", hash = "sha256:fb460a3d6fd3c958d729fdd96aee297f89a51b0181f16401fe8fd4cb6129165d", size = 74133, upload-time = "2026-01-10T20:23:13.445Z" }, ] +[[package]] +name = "ty" +version = "0.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/78/ba1a4ad403c748fbba8be63b7e774a90e80b67192f6443d624c64fe4aaab/ty-0.0.12.tar.gz", hash = "sha256:cd01810e106c3b652a01b8f784dd21741de9fdc47bd595d02c122a7d5cefeee7", size = 4981303, upload-time = "2026-01-14T22:30:48.537Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/8f/c21314d074dda5fb13d3300fa6733fd0d8ff23ea83a721818740665b6314/ty-0.0.12-py3-none-linux_armv6l.whl", hash = "sha256:eb9da1e2c68bd754e090eab39ed65edf95168d36cbeb43ff2bd9f86b4edd56d1", size = 9614164, upload-time = "2026-01-14T22:30:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/09/28/f8a4d944d13519d70c486e8f96d6fa95647ac2aa94432e97d5cfec1f42f6/ty-0.0.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c181f42aa19b0ed7f1b0c2d559980b1f1d77cc09419f51c8321c7ddf67758853", size = 9542337, upload-time = "2026-01-14T22:30:05.687Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9c/f576e360441de7a8201daa6dc4ebc362853bc5305e059cceeb02ebdd9a48/ty-0.0.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1f829e1eecd39c3e1b032149db7ae6a3284f72fc36b42436e65243a9ed1173db", size = 8909582, upload-time = "2026-01-14T22:30:46.089Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/0898e494032a5d8af3060733d12929e3e7716db6c75eac63fa125730a3e7/ty-0.0.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f45162e7826e1789cf3374627883cdeb0d56b82473a0771923e4572928e90be3", size = 9384932, upload-time = "2026-01-14T22:30:13.769Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1a/b35b6c697008a11d4cedfd34d9672db2f0a0621ec80ece109e13fca4dfef/ty-0.0.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d11fec40b269bec01e751b2337d1c7ffa959a2c2090a950d7e21c2792442cccd", size = 9453140, upload-time = "2026-01-14T22:30:11.131Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/71c9edbc79a3c88a0711324458f29c7dbf6c23452c6e760dc25725483064/ty-0.0.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09d99e37e761a4d2651ad9d5a610d11235fbcbf35dc6d4bc04abf54e7cf894f1", size = 9960680, upload-time = "2026-01-14T22:30:33.621Z" }, + { url = "https://files.pythonhosted.org/packages/0e/75/39375129f62dd22f6ad5a99cd2a42fd27d8b91b235ce2db86875cdad397d/ty-0.0.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d9ca0cdb17bd37397da7b16a7cd23423fc65c3f9691e453ad46c723d121225a1", size = 10904518, upload-time = "2026-01-14T22:30:08.464Z" }, + { url = "https://files.pythonhosted.org/packages/32/5e/26c6d88fafa11a9d31ca9f4d12989f57782ec61e7291d4802d685b5be118/ty-0.0.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcf2757b905e7eddb7e456140066335b18eb68b634a9f72d6f54a427ab042c64", size = 10525001, upload-time = "2026-01-14T22:30:16.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a5/2f0b91894af13187110f9ad7ee926d86e4e6efa755c9c88a820ed7f84c85/ty-0.0.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:00cf34c1ebe1147efeda3021a1064baa222c18cdac114b7b050bbe42deb4ca80", size = 10307103, upload-time = "2026-01-14T22:30:41.221Z" }, + { url = "https://files.pythonhosted.org/packages/4b/77/13d0410827e4bc713ebb7fdaf6b3590b37dcb1b82e0a81717b65548f2442/ty-0.0.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb3a655bd869352e9a22938d707631ac9fbca1016242b1f6d132d78f347c851", size = 10072737, upload-time = "2026-01-14T22:30:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dd/fc36d8bac806c74cf04b4ca735bca14d19967ca84d88f31e121767880df1/ty-0.0.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4658e282c7cb82be304052f8f64f9925f23c3c4f90eeeb32663c74c4b095d7ba", size = 9368726, upload-time = "2026-01-14T22:30:18.683Z" }, + { url = "https://files.pythonhosted.org/packages/54/70/9e8e461647550f83e2fe54bc632ccbdc17a4909644783cdbdd17f7296059/ty-0.0.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c167d838eaaa06e03bb66a517f75296b643d950fbd93c1d1686a187e5a8dbd1f", size = 9454704, upload-time = "2026-01-14T22:30:22.759Z" }, + { url = "https://files.pythonhosted.org/packages/04/9b/6292cf7c14a0efeca0539cf7d78f453beff0475cb039fbea0eb5d07d343d/ty-0.0.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2956e0c9ab7023533b461d8a0e6b2ea7b78e01a8dde0688e8234d0fce10c4c1c", size = 9649829, upload-time = "2026-01-14T22:30:31.234Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/472a5d2013371e4870886cff791c94abdf0b92d43d305dd0f8e06b6ff719/ty-0.0.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c6a3fd7479580009f21002f3828320621d8a82d53b7ba36993234e3ccad58c8", size = 10162814, upload-time = "2026-01-14T22:30:36.174Z" }, + { url = "https://files.pythonhosted.org/packages/31/e9/2ecbe56826759845a7c21d80aa28187865ea62bc9757b056f6cbc06f78ed/ty-0.0.12-py3-none-win32.whl", hash = "sha256:a91c24fd75c0f1796d8ede9083e2c0ec96f106dbda73a09fe3135e075d31f742", size = 9140115, upload-time = "2026-01-14T22:30:38.903Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6d/d9531eff35a5c0ec9dbc10231fac21f9dd6504814048e81d6ce1c84dc566/ty-0.0.12-py3-none-win_amd64.whl", hash = "sha256:df151894be55c22d47068b0f3b484aff9e638761e2267e115d515fcc9c5b4a4b", size = 9884532, upload-time = "2026-01-14T22:30:25.112Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f3/20b49e75967023b123a221134548ad7000f9429f13fdcdda115b4c26305f/ty-0.0.12-py3-none-win_arm64.whl", hash = "sha256:cea99d334b05629de937ce52f43278acf155d3a316ad6a35356635f886be20ea", size = 9313974, upload-time = "2026-01-14T22:30:27.44Z" }, +] + [[package]] name = "typeguard" version = "4.4.4" From 2624010e303998d09b690a25d2ae7d7cef52e050 Mon Sep 17 00:00:00 2001 From: Radith Samarakoon Date: Sat, 17 Jan 2026 17:35:36 +0530 Subject: [PATCH 02/12] refactor(services): introduce service layer between tools and clients - Add new services module with AccountService, BudgetService, and TransactionService classes - Refactor FireflyClient methods to return raw API models instead of converted ones - Update MCP tools to use service classes for business logic and model conversions - Update test fixtures to use new service layer for test data setup --- src/lampyrid/clients/firefly.py | 468 ++++++-------------------- src/lampyrid/services/__init__.py | 12 + src/lampyrid/services/accounts.py | 83 +++++ src/lampyrid/services/budgets.py | 209 ++++++++++++ src/lampyrid/services/transactions.py | 354 +++++++++++++++++++ src/lampyrid/tools/accounts.py | 28 +- src/lampyrid/tools/budgets.py | 21 +- src/lampyrid/tools/transactions.py | 40 +-- tests/conftest.py | 78 ++--- 9 files changed, 825 insertions(+), 468 deletions(-) create mode 100644 src/lampyrid/services/__init__.py create mode 100644 src/lampyrid/services/accounts.py create mode 100644 src/lampyrid/services/budgets.py create mode 100644 src/lampyrid/services/transactions.py diff --git a/src/lampyrid/clients/firefly.py b/src/lampyrid/clients/firefly.py index c235c63..9cbc652 100644 --- a/src/lampyrid/clients/firefly.py +++ b/src/lampyrid/clients/firefly.py @@ -1,5 +1,6 @@ import logging -from typing import Any, Dict, List +from datetime import date +from typing import Any, Dict, Optional import httpx @@ -16,37 +17,9 @@ BudgetStore, TransactionArray, TransactionSingle, - TransactionSplitStore, - TransactionSplitUpdate, TransactionStore, - TransactionTypeProperty, TransactionUpdate, ) -from ..models.lampyrid_models import ( - Account, - AvailableBudget, - Budget, - BudgetSpending, - BudgetSummary, - BulkUpdateTransactionsRequest, - CreateBulkTransactionsRequest, - CreateDepositRequest, - CreateTransferRequest, - CreateWithdrawalRequest, - DeleteTransactionRequest, - GetAccountRequest, - GetAvailableBudgetRequest, - GetBudgetRequest, - GetBudgetSpendingRequest, - GetBudgetSummaryRequest, - GetTransactionRequest, - GetTransactionsRequest, - ListBudgetsRequest, - SearchAccountRequest, - SearchTransactionsRequest, - Transaction, - UpdateTransactionRequest, -) logger = logging.getLogger(__name__) @@ -80,6 +53,7 @@ def _handle_api_error( Args: response: The HTTP response object payload: The request payload that was sent (optional, for POST/PUT requests) + """ if response.status_code >= 400: logger.error( @@ -97,20 +71,19 @@ async def list_accounts( r.raise_for_status() return AccountArray.model_validate(r.json()) - async def get_account(self, req: GetAccountRequest) -> Account: + async def get_account(self, account_id: str) -> AccountSingle: """Get a single account by ID.""" - r = await self._client.get(f'/api/v1/accounts/{req.id}') + r = await self._client.get(f'/api/v1/accounts/{account_id}') self._handle_api_error(r) r.raise_for_status() - account_single = AccountSingle.model_validate(r.json()) - return Account.from_account_read(account_single.data) + return AccountSingle.model_validate(r.json()) - async def search_accounts(self, req: SearchAccountRequest) -> AccountArray: + async def search_accounts(self, query: str, type: AccountTypeFilter) -> AccountArray: r = await self._client.get( '/api/v1/search/accounts', params={ - 'query': req.query, - 'type': req.type.value, + 'query': query, + 'type': type.value, 'field': 'name', 'limit': 50, 'page': 1, @@ -120,12 +93,11 @@ async def search_accounts(self, req: SearchAccountRequest) -> AccountArray: r.raise_for_status() return AccountArray.model_validate(r.json()) - async def create_account(self, account_store: AccountStore) -> Account: + async def create_account(self, account_store: AccountStore) -> AccountSingle: r = await self._client.post('/api/v1/accounts', json=self._serialize_model(account_store)) self._handle_api_error(r) r.raise_for_status() - account_single = AccountSingle.model_validate(r.json()) - return Account.from_account_read(account_single.data) + return AccountSingle.model_validate(r.json()) @staticmethod def _sanitize_value(value: str) -> str: @@ -139,6 +111,7 @@ def _sanitize_value(value: str) -> str: Returns: Escaped and optionally quoted value safe for Firefly III queries + """ # Escape backslashes first, then escape double quotes escaped = value.replace('\\', '\\\\').replace('"', '\\"') @@ -148,58 +121,14 @@ def _sanitize_value(value: str) -> str: return f'"{escaped}"' return escaped - async def search_transactions(self, req: SearchTransactionsRequest) -> TransactionArray: - """Search transactions using structured filters or raw query string.""" - # Build query string from structured fields - query_parts = [] - - # Add raw query if provided - if req.query: - query_parts.append(req.query) - - # Transaction type and amount filters - if req.type: - query_parts.append(f'type:{req.type}') - if req.amount_equals is not None: - query_parts.append(f'amount:{req.amount_equals}') - if req.amount_more is not None: - query_parts.append(f'more:{req.amount_more}') - if req.amount_less is not None: - query_parts.append(f'less:{req.amount_less}') - - # Date filters - if req.date_on: - query_parts.append(f'date_on:{req.date_on}') - if req.date_after: - query_parts.append(f'date_after:{req.date_after}') - if req.date_before: - query_parts.append(f'date_before:{req.date_before}') - - # Content filters - if req.description_contains: - query_parts.append( - f'description_contains:{self._sanitize_value(req.description_contains)}' - ) - - # Metadata filters - if req.category: - query_parts.append(f'category_is:{self._sanitize_value(req.category)}') - if req.budget: - query_parts.append(f'budget_is:{self._sanitize_value(req.budget)}') - - # Account filters - if req.account_contains: - query_parts.append(f'account_contains:{self._sanitize_value(req.account_contains)}') - if req.account_id is not None: - query_parts.append(f'account_id:{req.account_id}') - - # Combine all query parts with spaces (AND logic) - final_query = ' '.join(query_parts) - + async def search_transactions( + self, query: str, page: int = 1, limit: int = 50 + ) -> TransactionArray: + """Search transactions using a query string.""" params: Dict[str, Any] = { - 'query': final_query, - 'page': req.page, - 'limit': req.limit, + 'query': query, + 'page': page, + 'limit': limit, } r = await self._client.get('/api/v1/search/transactions', params=params) @@ -207,338 +136,145 @@ async def search_transactions(self, req: SearchTransactionsRequest) -> Transacti r.raise_for_status() return TransactionArray.model_validate(r.json()) - async def create_withdrawal(self, withdrawal: CreateWithdrawalRequest) -> Transaction: - trx = TransactionSplitStore( - amount=str(withdrawal.amount), - description=withdrawal.description, - type=TransactionTypeProperty.withdrawal, - date=withdrawal.date, - source_id=withdrawal.source_id, - destination_name=withdrawal.destination_name, - budget_id=withdrawal.budget_id, - budget_name=withdrawal.budget_name, - ) - trx_store = TransactionStore(transactions=[trx]) - payload = self._serialize_model(trx_store) - r = await self._client.post('/api/v1/transactions', json=payload) - self._handle_api_error(r, payload) - r.raise_for_status() - res = TransactionSingle.model_validate(r.json()) - return Transaction.from_transaction_single(res) - - async def create_deposit(self, deposit: CreateDepositRequest) -> Transaction: - trx = TransactionSplitStore( - amount=str(deposit.amount), - description=deposit.description, - type=TransactionTypeProperty.deposit, - date=deposit.date, - source_name=deposit.source_name, - destination_id=deposit.destination_id, - ) - trx_store = TransactionStore(transactions=[trx]) - payload = self._serialize_model(trx_store) - r = await self._client.post('/api/v1/transactions', json=payload) - self._handle_api_error(r, payload) - r.raise_for_status() - res = TransactionSingle.model_validate(r.json()) - return Transaction.from_transaction_single(res) - - async def create_transfer(self, transfer: CreateTransferRequest) -> Transaction: - trx = TransactionSplitStore( - amount=str(transfer.amount), - description=transfer.description, - type=TransactionTypeProperty.transfer, - date=transfer.date, - source_id=transfer.source_id, - destination_id=transfer.destination_id, - ) - trx_store = TransactionStore(transactions=[trx]) - payload = self._serialize_model(trx_store) + async def create_transaction(self, transaction_store: TransactionStore) -> TransactionSingle: + """Create a transaction with the given store data.""" + payload = self._serialize_model(transaction_store) r = await self._client.post('/api/v1/transactions', json=payload) self._handle_api_error(r, payload) r.raise_for_status() - res = TransactionSingle.model_validate(r.json()) - return Transaction.from_transaction_single(res) - - async def create_bulk_transactions( - self, req: CreateBulkTransactionsRequest - ) -> List[Transaction]: - """Create multiple transactions using individual API calls.""" - created_transactions: List[Transaction] = [] - - for transaction in req.transactions: - trx_split = transaction.to_transaction_split_store() - trx_store = TransactionStore( - transactions=[trx_split], - apply_rules=False, - fire_webhooks=True, - error_if_duplicate_hash=False, - ) - payload = self._serialize_model(trx_store) - r = await self._client.post('/api/v1/transactions', json=payload) - self._handle_api_error(r, payload) - r.raise_for_status() - res = TransactionSingle.model_validate(r.json()) - created_transactions.append(Transaction.from_transaction_single(res)) - - return created_transactions - - async def update_transaction(self, req: UpdateTransactionRequest) -> Transaction: - """Update an existing transaction with new values.""" - # Build the update payload with only provided fields using explicit parameters - update_kwargs = {} - - if req.amount is not None: - update_kwargs['amount'] = str(req.amount) - if req.description is not None: - update_kwargs['description'] = req.description - if req.date is not None: - update_kwargs['date'] = req.date - if req.source_id is not None: - update_kwargs['source_id'] = req.source_id - if req.destination_id is not None: - update_kwargs['destination_id'] = req.destination_id - if req.budget_id is not None: - update_kwargs['budget_id'] = req.budget_id - if req.category_name is not None: - update_kwargs['category_name'] = req.category_name - - trx_split_update = TransactionSplitUpdate(**update_kwargs) - - trx_update = TransactionUpdate( - apply_rules=False, fire_webhooks=True, group_title=None, transactions=[trx_split_update] - ) - - payload = self._serialize_model(trx_update, exclude_unset=True) - r = await self._client.put(f'/api/v1/transactions/{req.transaction_id}', json=payload) + return TransactionSingle.model_validate(r.json()) + + async def update_transaction( + self, transaction_id: str, transaction_update: TransactionUpdate + ) -> TransactionSingle: + """Update an existing transaction.""" + payload = self._serialize_model(transaction_update, exclude_unset=True) + r = await self._client.put(f'/api/v1/transactions/{transaction_id}', json=payload) self._handle_api_error(r, payload) r.raise_for_status() - transaction_single = TransactionSingle.model_validate(r.json()) - return Transaction.from_transaction_single(transaction_single) - - async def bulk_update_transactions( - self, req: BulkUpdateTransactionsRequest - ) -> List[Transaction]: - """Update multiple transactions using individual API calls.""" - updated_transactions: List[Transaction] = [] - - for update_req in req.updates: - try: - updated_transaction = await self.update_transaction(update_req) - updated_transactions.append(updated_transaction) - except Exception as e: - # Re-raise with transaction ID context - raise Exception( - f'Failed to update transaction {update_req.transaction_id}: {e}' - ) from e - - return updated_transactions - - async def get_transactions(self, req: GetTransactionsRequest) -> TransactionArray: - """Get transactions with optional time range and type filtering.""" + return TransactionSingle.model_validate(r.json()) + + async def get_transactions( + self, + page: int = 1, + limit: int = 50, + start_date: Optional[date] = None, + end_date: Optional[date] = None, + transaction_type: Optional[str] = None, + ) -> TransactionArray: + """Get transactions with optional filtering.""" params: Dict[str, Any] = { - 'page': req.page, - 'limit': req.limit, + 'page': page, + 'limit': limit, } - if req.start_date: - params['start'] = req.start_date.strftime('%Y-%m-%d') + if start_date: + params['start'] = start_date.strftime('%Y-%m-%d') - if req.end_date: - params['end'] = req.end_date.strftime('%Y-%m-%d') + if end_date: + params['end'] = end_date.strftime('%Y-%m-%d') - if req.transaction_type: - params['type'] = req.transaction_type.value + if transaction_type: + params['type'] = transaction_type r = await self._client.get('/api/v1/transactions', params=params) self._handle_api_error(r) r.raise_for_status() return TransactionArray.model_validate(r.json()) - async def get_account_transactions(self, req: GetTransactionsRequest) -> TransactionArray: - """Get transactions for a specific account with optional time range filtering.""" + async def get_account_transactions( + self, + account_id: str, + page: int = 1, + limit: int = 50, + start_date: Optional[date] = None, + end_date: Optional[date] = None, + transaction_type: Optional[str] = None, + ) -> TransactionArray: + """Get transactions for a specific account.""" params: Dict[str, Any] = { - 'page': req.page, - 'limit': req.limit, + 'page': page, + 'limit': limit, } - if req.start_date: - params['start'] = req.start_date.strftime('%Y-%m-%d') + if start_date: + params['start'] = start_date.strftime('%Y-%m-%d') - if req.end_date: - params['end'] = req.end_date.strftime('%Y-%m-%d') + if end_date: + params['end'] = end_date.strftime('%Y-%m-%d') - if req.transaction_type: - params['type'] = req.transaction_type.value + if transaction_type: + params['type'] = transaction_type - if req.account_id is None: - raise ValueError('account_id must be provided for account transactions retrieval.') - - r = await self._client.get(f'/api/v1/accounts/{req.account_id}/transactions', params=params) + r = await self._client.get(f'/api/v1/accounts/{account_id}/transactions', params=params) self._handle_api_error(r) r.raise_for_status() return TransactionArray.model_validate(r.json()) - async def get_transaction(self, req: GetTransactionRequest) -> Transaction: + async def get_transaction(self, transaction_id: str) -> TransactionSingle: """Get a single transaction by ID.""" - r = await self._client.get(f'/api/v1/transactions/{req.id}') + r = await self._client.get(f'/api/v1/transactions/{transaction_id}') self._handle_api_error(r) r.raise_for_status() - transaction_single = TransactionSingle.model_validate(r.json()) - return Transaction.from_transaction_single(transaction_single) + return TransactionSingle.model_validate(r.json()) - async def delete_transaction(self, req: DeleteTransactionRequest) -> bool: + async def delete_transaction(self, transaction_id: str) -> bool: """Delete a transaction by ID.""" - r = await self._client.delete(f'/api/v1/transactions/{req.id}') + r = await self._client.delete(f'/api/v1/transactions/{transaction_id}') self._handle_api_error(r) r.raise_for_status() return r.status_code == 204 - async def list_budgets(self, req: ListBudgetsRequest) -> BudgetArray: - """List all budgets.""" + async def get_budgets(self) -> BudgetArray: + """Get all budgets.""" r = await self._client.get('/api/v1/budgets') self._handle_api_error(r) r.raise_for_status() - budget_array = BudgetArray.model_validate(r.json()) - if req.active is not None: - budget_array.data = [x for x in budget_array.data if x.attributes.active == req.active] - return budget_array + return BudgetArray.model_validate(r.json()) - async def get_budget(self, req: GetBudgetRequest) -> Budget: + async def get_budget(self, budget_id: str) -> BudgetSingle: """Get a single budget by ID.""" - r = await self._client.get(f'/api/v1/budgets/{req.id}') + r = await self._client.get(f'/api/v1/budgets/{budget_id}') self._handle_api_error(r) r.raise_for_status() - budget_single = BudgetSingle.model_validate(r.json()) - return Budget.from_budget_read(budget_single.data) + return BudgetSingle.model_validate(r.json()) - async def get_budget_spending(self, req: GetBudgetSpendingRequest) -> BudgetSpending: - """Get budget spending data for a specific budget and period.""" + async def get_budget_limits( + self, budget_id: str, start_date: Optional[date] = None, end_date: Optional[date] = None + ) -> BudgetLimitArray: + """Get budget limits for a specific budget and period.""" params: Dict[str, Any] = {} - if req.start_date: - params['start'] = req.start_date.strftime('%Y-%m-%d') - if req.end_date: - params['end'] = req.end_date.strftime('%Y-%m-%d') - - # Get budget info first - budget_r = await self._client.get(f'/api/v1/budgets/{req.budget_id}') - self._handle_api_error(budget_r) - budget_r.raise_for_status() - budget_single = BudgetSingle.model_validate(budget_r.json()) - budget_name = budget_single.data.attributes.name - - # Get spending data from budget limits endpoint - spending_r = await self._client.get( - f'/api/v1/budgets/{req.budget_id}/limits', params=params - ) - self._handle_api_error(spending_r) - spending_r.raise_for_status() - limits_array = BudgetLimitArray.model_validate(spending_r.json()) - - # Calculate spending from limits data - spent = 0.0 - budgeted = None - - for limit in limits_array.data: - if limit.attributes.spent: - for spent_entry in limit.attributes.spent: - if spent_entry.sum: - spent += abs(float(spent_entry.sum)) - - # amount is still a string field - if limit.attributes.amount: - if budgeted is None: - budgeted = 0.0 - budgeted += float(limit.attributes.amount) - - remaining = (budgeted - spent) if budgeted is not None else None - percentage_spent = (spent / budgeted * 100) if budgeted and budgeted > 0 else None - - return BudgetSpending( - budget_id=req.budget_id, - budget_name=budget_name, - spent=spent, - budgeted=budgeted, - remaining=remaining, - percentage_spent=percentage_spent, - ) - - async def get_budget_summary(self, req: GetBudgetSummaryRequest) -> BudgetSummary: - """Get summary of all budgets with spending information.""" - # Get all budgets - budgets_r = await self._client.get('/api/v1/budgets') - self._handle_api_error(budgets_r) - budgets_r.raise_for_status() - budgets_array = BudgetArray.model_validate(budgets_r.json()) - - budget_spendings: list[BudgetSpending] = [] - total_spent = 0.0 - total_budgeted = 0.0 - - for budget in budgets_array.data: - spending_req = GetBudgetSpendingRequest( - budget_id=budget.id, - start_date=req.start_date, - end_date=req.end_date, - ) - budget_spending = await self.get_budget_spending(spending_req) - budget_spendings.append(budget_spending) - - total_spent += budget_spending.spent - if budget_spending.budgeted: - total_budgeted += budget_spending.budgeted + if start_date: + params['start'] = start_date.strftime('%Y-%m-%d') + if end_date: + params['end'] = end_date.strftime('%Y-%m-%d') - total_remaining = total_budgeted - total_spent if total_budgeted > 0 else None - - return BudgetSummary( - budgets=budget_spendings, - total_budgeted=total_budgeted if total_budgeted > 0 else None, - total_spent=total_spent, - total_remaining=total_remaining, - available_budget=None, # Would need additional API call to get available budget - ) + r = await self._client.get(f'/api/v1/budgets/{budget_id}/limits', params=params) + self._handle_api_error(r) + r.raise_for_status() + return BudgetLimitArray.model_validate(r.json()) - async def create_budget(self, budget_store: BudgetStore) -> Budget: + async def create_budget(self, budget_store: BudgetStore) -> BudgetSingle: """Create a new budget.""" payload = self._serialize_model(budget_store) r = await self._client.post('/api/v1/budgets', json=payload) self._handle_api_error(r, payload) r.raise_for_status() - budget_single = BudgetSingle.model_validate(r.json()) - return Budget.from_budget_read(budget_single.data) + return BudgetSingle.model_validate(r.json()) - async def get_available_budget(self, req: GetAvailableBudgetRequest) -> AvailableBudget: - """Get available budget for a period.""" + async def get_available_budgets( + self, start_date: Optional[date] = None, end_date: Optional[date] = None + ) -> AvailableBudgetArray: + """Get available budgets for a period.""" params: Dict[str, Any] = {} - if req.start_date: - params['start'] = req.start_date.strftime('%Y-%m-%d') - if req.end_date: - params['end'] = req.end_date.strftime('%Y-%m-%d') + if start_date: + params['start'] = start_date.strftime('%Y-%m-%d') + if end_date: + params['end'] = end_date.strftime('%Y-%m-%d') r = await self._client.get('/api/v1/available-budgets', params=params) self._handle_api_error(r) r.raise_for_status() - available_array = AvailableBudgetArray.model_validate(r.json()) - - # Parse the available budget data - if available_array.data: - first_budget = available_array.data[0] - return AvailableBudget( - amount=float(first_budget.attributes.amount), - currency_code=first_budget.attributes.currency_code or 'USD', - start_date=req.start_date or first_budget.attributes.start.date(), - end_date=req.end_date or first_budget.attributes.end.date(), - ) - else: - # Return default if no data available - from datetime import date - - today = date.today() - return AvailableBudget( - amount=0.0, - currency_code='USD', - start_date=req.start_date or today.replace(day=1), - end_date=req.end_date or today, - ) + return AvailableBudgetArray.model_validate(r.json()) diff --git a/src/lampyrid/services/__init__.py b/src/lampyrid/services/__init__.py new file mode 100644 index 0000000..42dadcd --- /dev/null +++ b/src/lampyrid/services/__init__.py @@ -0,0 +1,12 @@ +"""Services layer for LamPyrid. + +This module contains business logic services that orchestrate operations +between the MCP tools and the Firefly III client. Each service handles +domain-specific business logic, aggregations, and multi-call operations. +""" + +from .accounts import AccountService +from .budgets import BudgetService +from .transactions import TransactionService + +__all__ = ['AccountService', 'BudgetService', 'TransactionService'] diff --git a/src/lampyrid/services/accounts.py b/src/lampyrid/services/accounts.py new file mode 100644 index 0000000..aa17372 --- /dev/null +++ b/src/lampyrid/services/accounts.py @@ -0,0 +1,83 @@ +"""Account Service for LamPyrid. + +This service handles account-related business logic and orchestrates +operations between the MCP tools and the Firefly III client. +""" + +from typing import List + +from ..clients.firefly import FireflyClient +from ..models.firefly_models import AccountStore +from ..models.lampyrid_models import ( + Account, + GetAccountRequest, + ListAccountRequest, + SearchAccountRequest, +) + + +class AccountService: + """Service for managing Firefly III accounts. + + This service provides a high-level interface for account operations, + handling model conversion and business logic while delegating + HTTP operations to the FireflyClient. + """ + + def __init__(self, client: FireflyClient) -> None: + """Initialize the account service with a FireflyClient instance.""" + self._client = client + + async def list_accounts(self, req: ListAccountRequest) -> List[Account]: + """List accounts with optional type filtering. + + Args: + req: Request containing account type filter + + Returns: + List of accounts matching the filter criteria + + """ + account_array = await self._client.list_accounts(type=req.type) + + return [Account.from_account_read(account_read) for account_read in account_array.data] + + async def get_account(self, req: GetAccountRequest) -> Account: + """Get detailed information for a single account. + + Args: + req: Request containing the account ID + + Returns: + Account details including balance and metadata + + """ + account_single = await self._client.get_account(req.id) + return Account.from_account_read(account_single.data) + + async def search_accounts(self, req: SearchAccountRequest) -> List[Account]: + """Search accounts by name with optional type filtering. + + Args: + req: Request containing search query and type filter + + Returns: + List of accounts matching the search criteria + + """ + account_array = await self._client.search_accounts(req.query, req.type) + + return [Account.from_account_read(account_read) for account_read in account_array.data] + + async def create_account(self, account_store: AccountStore) -> Account: + """Create a new account. + + Args: + account_store: Account data for creation + + Returns: + Created account details + + """ + account_single = await self._client.create_account(account_store) + return Account.from_account_read(account_single.data) diff --git a/src/lampyrid/services/budgets.py b/src/lampyrid/services/budgets.py new file mode 100644 index 0000000..615242d --- /dev/null +++ b/src/lampyrid/services/budgets.py @@ -0,0 +1,209 @@ +"""Budget Service for LamPyrid. + +This service handles budget-related business logic and orchestrates +operations between the MCP tools and the Firefly III client. +""" + +from datetime import date +from typing import List + +from ..clients.firefly import FireflyClient +from ..models.firefly_models import ( + BudgetStore, +) +from ..models.lampyrid_models import ( + AvailableBudget, + Budget, + BudgetSpending, + BudgetSummary, + GetAvailableBudgetRequest, + GetBudgetRequest, + GetBudgetSpendingRequest, + GetBudgetSummaryRequest, + ListBudgetsRequest, +) + + +class BudgetService: + """Service for managing Firefly III budgets. + + This service provides a high-level interface for budget operations, + handling spending calculations, aggregations, and multi-call orchestration + while delegating HTTP operations to the FireflyClient. + """ + + def __init__(self, client: FireflyClient) -> None: + """Initialize the budget service with a FireflyClient instance.""" + self._client = client + + async def list_budgets(self, req: ListBudgetsRequest) -> List[Budget]: + """List budgets with optional active status filtering. + + Args: + req: Request containing active status filter + + Returns: + List of budgets matching the filter criteria + + """ + budget_array = await self._client.get_budgets() + + # Apply active filter if provided + budgets_data = budget_array.data + if req.active is not None: + budgets_data = [x for x in budgets_data if x.attributes.active == req.active] + + return [Budget.from_budget_read(budget_read) for budget_read in budgets_data] + + async def get_budget(self, req: GetBudgetRequest) -> Budget: + """Get detailed information for a single budget. + + Args: + req: Request containing the budget ID + + Returns: + Budget details + + """ + budget_single = await self._client.get_budget(req.id) + return Budget.from_budget_read(budget_single.data) + + async def get_budget_spending(self, req: GetBudgetSpendingRequest) -> BudgetSpending: + """Get spending analysis for a specific budget and time period. + + This method orchestrates multiple API calls to calculate budget spending, + aggregating data from budget limits and performing calculations for + spent amounts, remaining budget, and percentage used. + + Args: + req: Request containing budget ID and time period + + Returns: + Budget spending analysis with calculations + + """ + # Get budget info first + budget_single = await self._client.get_budget(req.budget_id) + budget_name = budget_single.data.attributes.name + + # Get spending data from budget limits endpoint + limits_array = await self._client.get_budget_limits( + req.budget_id, req.start_date, req.end_date + ) + + # Calculate spending from limits data + spent = 0.0 + budgeted = None + + for limit in limits_array.data: + if limit.attributes.spent: + for spent_entry in limit.attributes.spent: + if spent_entry.sum: + spent += abs(float(spent_entry.sum)) + + # amount is still a string field + if limit.attributes.amount: + if budgeted is None: + budgeted = 0.0 + budgeted += float(limit.attributes.amount) + + remaining = (budgeted - spent) if budgeted is not None else None + percentage_spent = (spent / budgeted * 100) if budgeted and budgeted > 0 else None + + return BudgetSpending( + budget_id=req.budget_id, + budget_name=budget_name, + spent=spent, + budgeted=budgeted, + remaining=remaining, + percentage_spent=percentage_spent, + ) + + async def get_budget_summary(self, req: GetBudgetSummaryRequest) -> BudgetSummary: + """Get comprehensive summary of all budgets with spending information. + + This method orchestrates multiple API calls to aggregate spending data + across all budgets, calculating totals and providing a comprehensive + budget overview. + + Args: + req: Request containing time period for analysis + + Returns: + Comprehensive budget summary with totals + + """ + # Get all budgets + budgets_array = await self._client.get_budgets() + + budget_spendings: list[BudgetSpending] = [] + total_spent = 0.0 + total_budgeted = 0.0 + + for budget in budgets_array.data: + spending_req = GetBudgetSpendingRequest( + budget_id=budget.id, + start_date=req.start_date, + end_date=req.end_date, + ) + budget_spending = await self.get_budget_spending(spending_req) + budget_spendings.append(budget_spending) + + total_spent += budget_spending.spent + if budget_spending.budgeted: + total_budgeted += budget_spending.budgeted + + total_remaining = total_budgeted - total_spent if total_budgeted > 0 else None + + return BudgetSummary( + budgets=budget_spendings, + total_budgeted=total_budgeted if total_budgeted > 0 else None, + total_spent=total_spent, + total_remaining=total_remaining, + available_budget=None, # Would need additional API call to get available budget + ) + + async def get_available_budget(self, req: GetAvailableBudgetRequest) -> AvailableBudget: + """Get available budget amount for a specified period. + + Args: + req: Request containing time period for available budget + + Returns: + Available budget information + + """ + + available_array = await self._client.get_available_budgets(req.start_date, req.end_date) + + # Parse the available budget data + if available_array.data: + first_budget = available_array.data[0] + return AvailableBudget( + amount=float(first_budget.attributes.amount), + currency_code=first_budget.attributes.currency_code or 'USD', + start_date=req.start_date or first_budget.attributes.start.date(), + end_date=req.end_date or first_budget.attributes.end.date(), + ) + else: + # Return default if no data available + today = date.today() + return AvailableBudget( + amount=0.0, + currency_code='USD', + start_date=req.start_date or today.replace(day=1), + end_date=req.end_date or today, + ) + + async def create_budget(self, budget_store: BudgetStore) -> Budget: + """Create a new budget. + + Args: + budget_store: Budget data for creation + + Returns: + Created budget details + + """ + budget_single = await self._client.create_budget(budget_store) + return Budget.from_budget_read(budget_single.data) diff --git a/src/lampyrid/services/transactions.py b/src/lampyrid/services/transactions.py new file mode 100644 index 0000000..9638848 --- /dev/null +++ b/src/lampyrid/services/transactions.py @@ -0,0 +1,354 @@ +"""Transaction Service for LamPyrid. + +This service handles transaction-related business logic and orchestrates +operations between the MCP tools and the Firefly III client. +""" + +from typing import List + +from ..clients.firefly import FireflyClient +from ..models.firefly_models import ( + TransactionSplitStore, + TransactionSplitUpdate, + TransactionStore, + TransactionTypeProperty, + TransactionUpdate, +) +from ..models.lampyrid_models import ( + BulkUpdateTransactionsRequest, + CreateBulkTransactionsRequest, + CreateDepositRequest, + CreateTransferRequest, + CreateWithdrawalRequest, + DeleteTransactionRequest, + GetTransactionRequest, + GetTransactionsRequest, + SearchTransactionsRequest, + Transaction, + TransactionListResponse, + UpdateTransactionRequest, +) + + +class TransactionService: + """Service for managing Firefly III transactions. + + This service provides a high-level interface for transaction operations, + handling bulk operations, model conversion, and business logic while + delegating HTTP operations to the FireflyClient. + """ + + def __init__(self, client: FireflyClient) -> None: + """Initialize the transaction service with a FireflyClient instance.""" + self._client = client + + async def create_withdrawal(self, req: CreateWithdrawalRequest) -> Transaction: + """Create a withdrawal transaction. + + Args: + req: Withdrawal transaction details + + Returns: + Created transaction details + + """ + + trx = TransactionSplitStore( + amount=str(req.amount), + description=req.description, + type=TransactionTypeProperty.withdrawal, + date=req.date, + source_id=req.source_id, + destination_name=req.destination_name, + budget_id=req.budget_id, + budget_name=req.budget_name, + ) + trx_store = TransactionStore( + transactions=[trx], + apply_rules=False, + fire_webhooks=True, + group_title=None, + error_if_duplicate_hash=False, + ) + transaction_single = await self._client.create_transaction(trx_store) + return Transaction.from_transaction_single(transaction_single) + + async def create_deposit(self, req: CreateDepositRequest) -> Transaction: + """Create a deposit transaction. + + Args: + req: Deposit transaction details + + Returns: + Created transaction details + + """ + + trx = TransactionSplitStore( + amount=str(req.amount), + description=req.description, + type=TransactionTypeProperty.deposit, + date=req.date, + source_name=req.source_name, + destination_id=req.destination_id, + ) + trx_store = TransactionStore( + transactions=[trx], + apply_rules=False, + fire_webhooks=True, + group_title=None, + error_if_duplicate_hash=False, + ) + transaction_single = await self._client.create_transaction(trx_store) + return Transaction.from_transaction_single(transaction_single) + + async def create_transfer(self, req: CreateTransferRequest) -> Transaction: + """Create a transfer transaction. + + Args: + req: Transfer transaction details + + Returns: + Created transaction details + + """ + + trx = TransactionSplitStore( + amount=str(req.amount), + description=req.description, + type=TransactionTypeProperty.transfer, + date=req.date, + source_id=req.source_id, + destination_id=req.destination_id, + ) + trx_store = TransactionStore( + transactions=[trx], + apply_rules=False, + fire_webhooks=True, + group_title=None, + error_if_duplicate_hash=False, + ) + transaction_single = await self._client.create_transaction(trx_store) + return Transaction.from_transaction_single(transaction_single) + + async def create_bulk_transactions( + self, req: CreateBulkTransactionsRequest + ) -> List[Transaction]: + """Create multiple transactions in a single operation. + + This method orchestrates the creation of multiple transactions, + handling the business logic for bulk operations while delegating + the individual HTTP requests to the FireflyClient. + + Args: + req: Request containing multiple transaction details + + Returns: + List of created transactions + + """ + created_transactions: List[Transaction] = [] + + for transaction in req.transactions: + trx_split = transaction.to_transaction_split_store() + trx_store = TransactionStore( + transactions=[trx_split], + apply_rules=False, + fire_webhooks=True, + group_title=None, + error_if_duplicate_hash=False, + ) + transaction_single = await self._client.create_transaction(trx_store) + created_transactions.append(Transaction.from_transaction_single(transaction_single)) + + return created_transactions + + async def get_transaction(self, req: GetTransactionRequest) -> Transaction: + """Get detailed information for a single transaction. + + Args: + req: Request containing the transaction ID + + Returns: + Transaction details + + """ + transaction_single = await self._client.get_transaction(req.id) + return Transaction.from_transaction_single(transaction_single) + + async def get_transactions(self, req: GetTransactionsRequest) -> TransactionListResponse: + """Get transactions with optional filtering and pagination. + + Args: + req: Request containing filter criteria and pagination parameters + + Returns: + Paginated list of transactions + + """ + if req.account_id is not None: + transaction_array = await self._client.get_account_transactions( + account_id=req.account_id, + page=req.page or 1, + limit=req.limit or 50, + start_date=req.start_date, + end_date=req.end_date, + transaction_type=req.transaction_type.value if req.transaction_type else None, + ) + else: + transaction_array = await self._client.get_transactions( + page=req.page or 1, + limit=req.limit or 50, + start_date=req.start_date, + end_date=req.end_date, + transaction_type=req.transaction_type.value if req.transaction_type else None, + ) + + return TransactionListResponse.from_transaction_array( + transaction_array, current_page=req.page or 1, per_page=req.limit or 50 + ) + + async def search_transactions(self, req: SearchTransactionsRequest) -> TransactionListResponse: + """Search transactions with advanced filtering. + + Args: + req: Request containing search criteria and filters + + Returns: + Paginated list of matching transactions + + """ + # Build query string from structured fields + query_parts = [] + + # Add raw query if provided + if req.query: + query_parts.append(req.query) + + # Transaction type and amount filters + if req.type: + query_parts.append(f'type:{req.type}') + if req.amount_equals is not None: + query_parts.append(f'amount:{req.amount_equals}') + if req.amount_more is not None: + query_parts.append(f'more:{req.amount_more}') + if req.amount_less is not None: + query_parts.append(f'less:{req.amount_less}') + + # Date filters + if req.date_on: + query_parts.append(f'date_on:{req.date_on}') + if req.date_after: + query_parts.append(f'date_after:{req.date_after}') + if req.date_before: + query_parts.append(f'date_before:{req.date_before}') + + # Content filters + if req.description_contains: + query_parts.append(f'description_contains:"{req.description_contains}"') + + # Metadata filters + if req.category: + query_parts.append(f'category_is:"{req.category}"') + if req.budget: + query_parts.append(f'budget_is:"{req.budget}"') + + # Account filters + if req.account_contains: + query_parts.append(f'account_contains:"{req.account_contains}"') + if req.account_id is not None: + query_parts.append(f'account_id:{req.account_id}') + + # Combine all query parts with spaces (AND logic) + final_query = ' '.join(query_parts) + + transaction_array = await self._client.search_transactions( + query=final_query, page=req.page or 1, limit=req.limit or 50 + ) + + return TransactionListResponse.from_transaction_array( + transaction_array, current_page=req.page or 1, per_page=req.limit or 50 + ) + + async def update_transaction(self, req: UpdateTransactionRequest) -> Transaction: + """Update an existing transaction. + + Args: + req: Request containing updated transaction details + + Returns: + Updated transaction details + + """ + + # Build the update payload with only provided fields using explicit parameters + update_kwargs = {} + + if req.amount is not None: + update_kwargs['amount'] = str(req.amount) + if req.description is not None: + update_kwargs['description'] = req.description + if req.date is not None: + update_kwargs['date'] = req.date + if req.source_id is not None: + update_kwargs['source_id'] = req.source_id + if req.destination_id is not None: + update_kwargs['destination_id'] = req.destination_id + if req.budget_id is not None: + update_kwargs['budget_id'] = req.budget_id + if req.category_name is not None: + update_kwargs['category_name'] = req.category_name + + trx_split_update = TransactionSplitUpdate(**update_kwargs) + + transaction_update = TransactionUpdate( + apply_rules=False, fire_webhooks=True, group_title=None, transactions=[trx_split_update] + ) + + transaction_single = await self._client.update_transaction( + req.transaction_id, transaction_update + ) + return Transaction.from_transaction_single(transaction_single) + + async def bulk_update_transactions( + self, req: BulkUpdateTransactionsRequest + ) -> List[Transaction]: + """Update multiple transactions in a single operation. + + This method orchestrates the update of multiple transactions, + handling the business logic for bulk operations while delegating + the individual HTTP requests to the FireflyClient. + + Args: + req: Request containing multiple transaction updates + + Returns: + List of updated transactions + + """ + updated_transactions: List[Transaction] = [] + + for update_req in req.updates: + try: + updated_transaction = await self.update_transaction(update_req) + updated_transactions.append(updated_transaction) + except Exception as e: + # Re-raise with transaction ID context + raise Exception( + f'Failed to update transaction {update_req.transaction_id}: {e}' + ) from e + + return updated_transactions + + async def delete_transaction(self, req: DeleteTransactionRequest) -> bool: + """Delete a transaction. + + Args: + req: Request containing the transaction ID to delete + + Returns: + True if deletion was successful + + """ + result = await self._client.delete_transaction(req.id) + return result diff --git a/src/lampyrid/tools/accounts.py b/src/lampyrid/tools/accounts.py index 86e8eec..b3ddba9 100644 --- a/src/lampyrid/tools/accounts.py +++ b/src/lampyrid/tools/accounts.py @@ -1,5 +1,4 @@ -""" -Account Management MCP Tools. +"""Account Management MCP Tools. This module provides MCP tools for managing Firefly III accounts including listing, searching, and retrieving account details. @@ -16,9 +15,11 @@ ListAccountRequest, SearchAccountRequest, ) +from ..services.accounts import AccountService def create_accounts_server(client: FireflyClient) -> FastMCP: + account_service = AccountService(client) """ Create a standalone FastMCP server for account management tools. @@ -32,29 +33,20 @@ def create_accounts_server(client: FireflyClient) -> FastMCP: @accounts_mcp.tool(tags={'accounts'}) async def list_accounts(req: ListAccountRequest) -> List[Account]: - """Retrieve accounts from Firefly III. Use 'asset' for checking/savings accounts, 'expense' for spending accounts, 'revenue' for income sources. Essential for finding account IDs before creating transactions.""" - account_list = await client.list_accounts(type=req.type) - - accounts: List[Account] = [ - Account.from_account_read(account_read) for account_read in account_list.data - ] - - return accounts + """Retrieve accounts from Firefly III. Use 'asset' for checking/savings accounts, 'expense' + for spending accounts, 'revenue' for income sources. Essential for finding account IDs + before creating transactions. + """ + return await account_service.list_accounts(req) @accounts_mcp.tool(tags={'accounts'}) async def get_account(req: GetAccountRequest) -> Account: """Retrieve detailed account information including current balance and currency. Use this to verify account details before transactions.""" - return await client.get_account(req) + return await account_service.get_account(req) @accounts_mcp.tool(tags={'accounts'}) async def search_accounts(req: SearchAccountRequest) -> List[Account]: """Find accounts by partial name matching. Useful when you know the account name but not the ID. Supports filtering by account type.""" - account_list = await client.search_accounts(req) - - accounts: List[Account] = [ - Account.from_account_read(account_read) for account_read in account_list.data - ] - - return accounts + return await account_service.search_accounts(req) return accounts_mcp diff --git a/src/lampyrid/tools/budgets.py b/src/lampyrid/tools/budgets.py index 2b9035a..c431d05 100644 --- a/src/lampyrid/tools/budgets.py +++ b/src/lampyrid/tools/budgets.py @@ -1,5 +1,4 @@ -""" -Budget Management MCP Tools. +"""Budget Management MCP Tools. This module provides MCP tools for managing Firefly III budgets including listing, retrieving, and analyzing budget performance and spending. @@ -21,9 +20,11 @@ GetBudgetSummaryRequest, ListBudgetsRequest, ) +from ..services.budgets import BudgetService def create_budgets_server(client: FireflyClient) -> FastMCP: + budget_service = BudgetService(client) """ Create a standalone FastMCP server for budget management tools. @@ -38,32 +39,26 @@ def create_budgets_server(client: FireflyClient) -> FastMCP: @budgets_mcp.tool(tags={'budgets'}) async def list_budgets(req: ListBudgetsRequest) -> List[Budget]: """Retrieve your budgets for expense tracking and financial planning. Filter by active status to see current or all budgets.""" - budget_array = await client.list_budgets(req) - - budgets: List[Budget] = [ - Budget.from_budget_read(budget_read) for budget_read in budget_array.data - ] - - return budgets + return await budget_service.list_budgets(req) @budgets_mcp.tool(tags={'budgets'}) async def get_budget(req: GetBudgetRequest) -> Budget: """Retrieve detailed budget information including name, status, and notes. Use this to verify budget details before assigning transactions.""" - return await client.get_budget(req) + return await budget_service.get_budget(req) @budgets_mcp.tool(tags={'budgets', 'analysis'}) async def get_budget_spending(req: GetBudgetSpendingRequest) -> BudgetSpending: """Analyze spending against a budget including amount spent, remaining budget, and percentage used. Essential for budget monitoring and overspending alerts.""" - return await client.get_budget_spending(req) + return await budget_service.get_budget_spending(req) @budgets_mcp.tool(tags={'budgets', 'analysis'}) async def get_budget_summary(req: GetBudgetSummaryRequest) -> BudgetSummary: """Comprehensive overview of all budget performance with totals and spending analysis. Perfect for monthly reviews and financial dashboards.""" - return await client.get_budget_summary(req) + return await budget_service.get_budget_summary(req) @budgets_mcp.tool(tags={'budgets', 'analysis'}) async def get_available_budget(req: GetAvailableBudgetRequest) -> AvailableBudget: """Check unallocated budget available for new budgets or unexpected expenses. Shows money set aside but not assigned to specific budgets.""" - return await client.get_available_budget(req) + return await budget_service.get_available_budget(req) return budgets_mcp diff --git a/src/lampyrid/tools/transactions.py b/src/lampyrid/tools/transactions.py index 9b669be..c8197ab 100644 --- a/src/lampyrid/tools/transactions.py +++ b/src/lampyrid/tools/transactions.py @@ -1,5 +1,4 @@ -""" -Transaction Management MCP Tools. +"""Transaction Management MCP Tools. This module provides MCP tools for managing Firefly III transactions including creating, retrieving, searching, updating, and deleting transactions. @@ -24,9 +23,11 @@ TransactionListResponse, UpdateTransactionRequest, ) +from ..services.transactions import TransactionService def create_transactions_server(client: FireflyClient) -> FastMCP: + transaction_service = TransactionService(client) """ Create a standalone FastMCP server for transaction management tools. @@ -40,67 +41,58 @@ def create_transactions_server(client: FireflyClient) -> FastMCP: @transactions_mcp.tool(tags={'transactions', 'create'}) async def create_withdrawal(req: CreateWithdrawalRequest) -> Transaction: - """Record expenses and spending. Money leaves your asset accounts to pay for goods, services, or cash withdrawals. Can be assigned to budgets for expense tracking.""" - transaction = await client.create_withdrawal(req) + """Record expenses and spending. Money leaves your asset accounts to pay for goods, + services,or cash withdrawals. Can be assigned to budgets for expense tracking. + """ + transaction = await transaction_service.create_withdrawal(req) return transaction @transactions_mcp.tool(tags={'transactions', 'create'}) async def create_deposit(req: CreateDepositRequest) -> Transaction: """Record income and money received. Represents salary, refunds, gifts, or any money coming into your asset accounts from external sources.""" - transaction = await client.create_deposit(req) + transaction = await transaction_service.create_deposit(req) return transaction @transactions_mcp.tool(tags={'transactions', 'create'}) async def create_transfer(req: CreateTransferRequest) -> Transaction: """Move money between your own accounts. Use for transferring to savings, paying credit cards from checking, or consolidating funds.""" - transaction = await client.create_transfer(req) + transaction = await transaction_service.create_transfer(req) return transaction @transactions_mcp.tool(tags={'transactions', 'create', 'bulk'}) async def create_bulk_transactions(req: CreateBulkTransactionsRequest) -> List[Transaction]: """Efficiently create multiple transactions in one operation. Ideal for importing transaction batches, recording monthly bills, or processing CSV data.""" - transactions = await client.create_bulk_transactions(req) + transactions = await transaction_service.create_bulk_transactions(req) return transactions @transactions_mcp.tool(tags={'transactions', 'query'}) async def get_transaction(req: GetTransactionRequest) -> Transaction: """Retrieve complete transaction details. Use this to verify transaction information before updates or to examine specific transactions.""" - return await client.get_transaction(req) + return await transaction_service.get_transaction(req) @transactions_mcp.tool(tags={'transactions', 'query'}) async def get_transactions(req: GetTransactionsRequest) -> TransactionListResponse: """Retrieve transaction history with flexible filtering and pagination. Essential for financial analysis, spending pattern review, and account activity monitoring.""" - if req.account_id is not None: - transaction_array = await client.get_account_transactions(req) - else: - transaction_array = await client.get_transactions(req) - - return TransactionListResponse.from_transaction_array( - transaction_array, current_page=req.page or 1, per_page=req.limit or 50 - ) + return await transaction_service.get_transactions(req) @transactions_mcp.tool(tags={'transactions', 'query'}) async def search_transactions(req: SearchTransactionsRequest) -> TransactionListResponse: """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.""" - transaction_array = await client.search_transactions(req) - - return TransactionListResponse.from_transaction_array( - transaction_array, current_page=req.page or 1, per_page=req.limit or 50 - ) + return await transaction_service.search_transactions(req) @transactions_mcp.tool(tags={'transactions', 'manage'}) async def delete_transaction(req: DeleteTransactionRequest) -> bool: """Permanently remove a transaction. Use to correct mistakes, remove duplicates, or delete test data. This action cannot be undone.""" - return await client.delete_transaction(req) + return await transaction_service.delete_transaction(req) @transactions_mcp.tool(tags={'transactions', 'manage'}) async def update_transaction(req: UpdateTransactionRequest) -> Transaction: """Modify transaction details such as amounts, descriptions, dates, accounts, or budget assignments. Useful for correcting imported data or updating incomplete information.""" - return await client.update_transaction(req) + return await transaction_service.update_transaction(req) @transactions_mcp.tool(tags={'transactions', 'manage', 'bulk'}) async def bulk_update_transactions(req: BulkUpdateTransactionsRequest) -> List[Transaction]: """Efficiently update multiple transactions in one operation. Ideal for batch account changes, budget reassignments, or correcting imported data.""" - return await client.bulk_update_transactions(req) + return await transaction_service.bulk_update_transactions(req) return transactions_mcp diff --git a/tests/conftest.py b/tests/conftest.py index 6f4081a..9f319b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,4 @@ -""" -Test configuration and shared fixtures for LamPyrid integration tests. +"""Test configuration and shared fixtures for LamPyrid integration tests. This module provides pytest fixtures for: - FireflyClient instance configured for testing @@ -27,7 +26,7 @@ from lampyrid.models.lampyrid_models import ( Account, Budget, - DeleteTransactionRequest, + ListAccountRequest, ListBudgetsRequest, ) @@ -45,6 +44,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.tools import compose_all_servers # noqa: E402 # Global cache for test data created programmatically @@ -56,11 +56,9 @@ @pytest.fixture(scope='session', autouse=True) async def _setup_test_data(): - """ - Autouse fixture to create test accounts and budget at session start. + """Autouse fixture to create test accounts and budget at session start. This ensures test data exists before any tests run. """ - global _cached_test_accounts, _cached_test_budgets test_env_path = Path(__file__).parent / '.env.test' @@ -71,16 +69,17 @@ async def _setup_test_data(): return # Skip setup if no config client = FireflyClient() + account_service = AccountService(client) + budget_service = BudgetService(client) try: # Create test accounts if _cached_test_accounts is None or len(_cached_test_accounts) < 2: _cached_test_accounts = [] - account_array = await client.list_accounts(page=1, type=AccountTypeFilter.asset) - existing_accounts = [ - Account.from_account_read(account_read) for account_read in account_array.data - ] + existing_accounts = await account_service.list_accounts( + ListAccountRequest(type=AccountTypeFilter.asset) + ) checking = None savings = None @@ -99,7 +98,7 @@ async def _setup_test_data(): opening_balance='1000.00', opening_balance_date=datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc), ) - checking = await client.create_account(checking_store) + checking = await account_service.create_account(checking_store) _created_account_ids.append(checking.id) _cached_test_accounts.append(checking) @@ -113,16 +112,15 @@ async def _setup_test_data(): opening_balance='500.00', opening_balance_date=datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc), ) - savings = await client.create_account(savings_store) + savings = await account_service.create_account(savings_store) _created_account_ids.append(savings.id) _cached_test_accounts.append(savings) # Create expense account for withdrawal tests - expense_accounts = await client.list_accounts(page=1, type=AccountTypeFilter.expense) - existing_expense = [ - Account.from_account_read(account_read) for account_read in expense_accounts.data - ] + existing_expense = await account_service.list_accounts( + ListAccountRequest(type=AccountTypeFilter.expense) + ) expense = None for account in existing_expense: @@ -136,16 +134,15 @@ async def _setup_test_data(): type=ShortAccountTypeProperty.expense, currency_code='EUR', ) - expense = await client.create_account(expense_store) + expense = await account_service.create_account(expense_store) _created_account_ids.append(expense.id) _cached_test_accounts.append(expense) # Create revenue account for deposit tests - revenue_accounts = await client.list_accounts(page=1, type=AccountTypeFilter.revenue) - existing_revenue = [ - Account.from_account_read(account_read) for account_read in revenue_accounts.data - ] + existing_revenue = await account_service.list_accounts( + ListAccountRequest(type=AccountTypeFilter.revenue) + ) revenue = None for account in existing_revenue: @@ -159,7 +156,7 @@ async def _setup_test_data(): type=ShortAccountTypeProperty.revenue, currency_code='EUR', ) - revenue = await client.create_account(revenue_store) + revenue = await account_service.create_account(revenue_store) _created_account_ids.append(revenue.id) _cached_test_accounts.append(revenue) @@ -168,20 +165,17 @@ async def _setup_test_data(): if _cached_test_budgets is None: _cached_test_budgets = [] - budget_array = await client.list_budgets(ListBudgetsRequest(active=True)) - existing_budgets = [ - Budget.from_budget_read(budget_read) for budget_read in budget_array.data - ] + budget_array = await budget_service.list_budgets(ListBudgetsRequest(active=True)) test_budget = None - for budget in existing_budgets: + for budget in budget_array: if 'test budget' in budget.name.lower(): test_budget = budget break if test_budget is None: budget_store = BudgetStore(name='Test Budget', active=True) - test_budget = await client.create_budget(budget_store) + test_budget = await budget_service.create_budget(budget_store) _created_budget_ids.append(test_budget.id) _cached_test_budgets.append(test_budget) @@ -192,14 +186,12 @@ async def _setup_test_data(): @pytest.fixture(scope='function') async def firefly_client(): - """ - Create a FireflyClient instance for testing. + """Create a FireflyClient instance for testing. This fixture is function-scoped to avoid event loop conflicts. The client reads configuration from the global settings object which loads from environment variables (.env.test file). """ - # Validate that required settings are present if not settings.firefly_base_url or not settings.firefly_token: raise RuntimeError( @@ -219,8 +211,7 @@ async def firefly_client(): @pytest.fixture(scope='function') async def mcp_client(firefly_client: FireflyClient): - """ - Create a FastMCP Client for testing tools. + """Create a FastMCP Client for testing tools. This fixture uses in-memory transport to test the full MCP stack: MCP Protocol -> Tool Functions -> FireflyClient -> Firefly III API @@ -229,7 +220,6 @@ async def mcp_client(firefly_client: FireflyClient): All domain-specific tools (accounts, transactions, budgets) are composed into the server. """ - # Create a new FastMCP server for testing mcp = FastMCP('lampyrid-test') @@ -243,8 +233,7 @@ async def mcp_client(firefly_client: FireflyClient): @pytest.fixture(scope='session') def test_asset_account() -> Account: - """ - Get the first test asset account (Test Checking). + """Get the first test asset account (Test Checking). The account is created by the autouse _setup_test_data fixture. """ if _cached_test_accounts is None or len(_cached_test_accounts) == 0: @@ -254,8 +243,7 @@ def test_asset_account() -> Account: @pytest.fixture(scope='session') def test_second_asset_account() -> Account: - """ - Get the second test asset account (Test Savings) for transfer testing. + """Get the second test asset account (Test Savings) for transfer testing. The account is created by the autouse _setup_test_data fixture. """ if _cached_test_accounts is None or len(_cached_test_accounts) < 2: @@ -265,8 +253,7 @@ def test_second_asset_account() -> Account: @pytest.fixture(scope='session') def test_expense_account() -> str: - """ - Get expense account name for withdrawal testing. + """Get expense account name for withdrawal testing. For withdrawals, we only need the destination name (expense account), not the full account object. """ @@ -276,8 +263,7 @@ def test_expense_account() -> str: @pytest.fixture(scope='session') def test_revenue_account() -> str: - """ - Get revenue account name for deposit testing. + """Get revenue account name for deposit testing. For deposits, we only need the source name (revenue account), not the full account object. """ @@ -287,8 +273,7 @@ def test_revenue_account() -> str: @pytest.fixture(scope='session') def test_budget() -> Budget: - """ - Get the test budget. + """Get the test budget. The budget is created by the autouse _setup_test_data fixture. """ if _cached_test_budgets is None or len(_cached_test_budgets) == 0: @@ -298,8 +283,7 @@ def test_budget() -> Budget: @pytest.fixture async def transaction_cleanup(firefly_client: FireflyClient): - """ - Fixture to track and cleanup transactions created during tests. + """Fixture to track and cleanup transactions created during tests. Usage: @pytest.mark.asyncio @@ -318,7 +302,7 @@ async def test_create_transaction(firefly_client, transaction_cleanup): for transaction_id in created_transaction_ids: try: - await firefly_client.delete_transaction(DeleteTransactionRequest(id=transaction_id)) + await firefly_client.delete_transaction(transaction_id) print(f'Cleaned up transaction: {transaction_id}') except Exception as e: print(f'Failed to cleanup transaction {transaction_id}: {e}') From 18269cc9c6a1e95c5c58691a6039834ed1a95beb Mon Sep 17 00:00:00 2001 From: Radith Samarakoon Date: Sat, 17 Jan 2026 18:18:19 +0530 Subject: [PATCH 03/12] style: Convert tabs to spaces and add module docstrings - Changed indentation style from tabs to spaces across all Python files - Added docstrings to modules in src/lampyrid/ and test files - Updated pyproject.toml ruff configuration for space-based indentation --- pyproject.toml | 13 +- run.py | 4 +- src/lampyrid/__init__.py | 1 + src/lampyrid/__main__.py | 27 +- src/lampyrid/clients/firefly.py | 540 +- src/lampyrid/config.py | 262 +- src/lampyrid/models/__init__.py | 1 + src/lampyrid/models/firefly_models.py | 8012 ++++++++++++------------ src/lampyrid/models/lampyrid_models.py | 1070 ++-- src/lampyrid/server.py | 116 +- src/lampyrid/services/accounts.py | 96 +- src/lampyrid/services/budgets.py | 353 +- src/lampyrid/services/transactions.py | 672 +- src/lampyrid/tools/__init__.py | 35 +- src/lampyrid/tools/accounts.py | 78 +- src/lampyrid/tools/budgets.py | 111 +- src/lampyrid/tools/transactions.py | 197 +- src/lampyrid/utils.py | 37 +- tests/conftest.py | 458 +- tests/fixtures/__init__.py | 1 - tests/fixtures/accounts.py | 24 +- tests/fixtures/budgets.py | 78 +- tests/fixtures/transactions.py | 266 +- tests/integration/test_accounts.py | 382 +- tests/integration/test_budgets.py | 232 +- tests/integration/test_transactions.py | 1104 ++-- tests/verify_setup.py | 111 +- 27 files changed, 7223 insertions(+), 7058 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2a109f9..ad5c6ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,11 +36,20 @@ line-length = 100 [tool.ruff.format] quote-style = "single" -indent-style = "tab" +indent-style = "space" docstring-code-format = true [tool.ruff.lint] -extend-select = ["I"] +# "I" (isort), "D" (pydocstyle), "E" (pycodestyle) +extend-select = ["I", "D", "E"] +ignore = [ + "D203", # 1 blank line required before class docstring (conflicts with D211) + "D213", # Multi-line docstring summary should start at the second line (conflicts with D212) +] + +[tool.ruff.lint.per-file-ignores] +"src/lampyrid/models/firefly_models.py" = ["D100", "D101", "E501"] +"tests/**/*.py" = ["D104"] [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/run.py b/run.py index be5cecc..63c53f9 100644 --- a/run.py +++ b/run.py @@ -1,4 +1,6 @@ +"""Entry point for running the LamPyrid MCP server.""" + from lampyrid import __main__ if __name__ == '__main__': - __main__.main() + __main__.main() diff --git a/src/lampyrid/__init__.py b/src/lampyrid/__init__.py index e69de29..b4f4d14 100644 --- a/src/lampyrid/__init__.py +++ b/src/lampyrid/__init__.py @@ -0,0 +1 @@ +"""LamPyrid MCP server package.""" diff --git a/src/lampyrid/__main__.py b/src/lampyrid/__main__.py index 6becbfc..315c126 100644 --- a/src/lampyrid/__main__.py +++ b/src/lampyrid/__main__.py @@ -1,20 +1,23 @@ +"""Main entry point for the LamPyrid MCP server.""" + from .config import settings from .server import mcp def main() -> None: - # Support both stdio (for local development) and http (for containerized deployment) - # Configuration is managed through settings (from .env or environment variables) - if settings.mcp_transport == 'http': - # HTTP mode for containerized deployment - mcp.run(transport='streamable-http', host=settings.mcp_host, port=settings.mcp_port) - elif settings.mcp_transport == 'sse': - # SSE mode for real-time updates - mcp.run(transport='sse', host=settings.mcp_host, port=settings.mcp_port) - else: - # Default stdio mode for local development - mcp.run(transport='stdio') + """Initialize and run the MCP server based on configuration settings.""" + # Support both stdio (for local development) and http (for containerized deployment) + # Configuration is managed through settings (from .env or environment variables) + if settings.mcp_transport == 'http': + # HTTP mode for containerized deployment + mcp.run(transport='streamable-http', host=settings.mcp_host, port=settings.mcp_port) + elif settings.mcp_transport == 'sse': + # SSE mode for real-time updates + mcp.run(transport='sse', host=settings.mcp_host, port=settings.mcp_port) + else: + # Default stdio mode for local development + mcp.run(transport='stdio') if __name__ == '__main__': - main() + main() diff --git a/src/lampyrid/clients/firefly.py b/src/lampyrid/clients/firefly.py index 9cbc652..68391cf 100644 --- a/src/lampyrid/clients/firefly.py +++ b/src/lampyrid/clients/firefly.py @@ -1,3 +1,5 @@ +"""HTTP client for interacting with the Firefly III API.""" + import logging from datetime import date from typing import Any, Dict, Optional @@ -6,275 +8,281 @@ from ..config import settings from ..models.firefly_models import ( - AccountArray, - AccountSingle, - AccountStore, - AccountTypeFilter, - AvailableBudgetArray, - BudgetArray, - BudgetLimitArray, - BudgetSingle, - BudgetStore, - TransactionArray, - TransactionSingle, - TransactionStore, - TransactionUpdate, + AccountArray, + AccountSingle, + AccountStore, + AccountTypeFilter, + AvailableBudgetArray, + BudgetArray, + BudgetLimitArray, + BudgetSingle, + BudgetStore, + TransactionArray, + TransactionSingle, + TransactionStore, + TransactionUpdate, ) logger = logging.getLogger(__name__) class FireflyClient: - def __init__(self) -> None: - base = str(settings.firefly_base_url).rstrip('/') - self._client = httpx.AsyncClient( - base_url=base, - headers={ - 'Authorization': f'Bearer {settings.firefly_token}', - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - timeout=30.0, - ) - - def _serialize_model(self, model: Any, exclude_unset: bool = False) -> Dict[str, Any]: - """Serialize a Pydantic model to dict, excluding None values by default. - - Firefly III API rejects None values for many fields, so we exclude them. - Use exclude_unset=True for update operations to only send changed fields. - """ - return model.model_dump(mode='json', exclude_none=True, exclude_unset=exclude_unset) - - def _handle_api_error( - self, response: httpx.Response, payload: Dict[str, Any] | None = None - ) -> None: - """Log detailed error information for API errors. - - Args: - response: The HTTP response object - payload: The request payload that was sent (optional, for POST/PUT requests) - - """ - if response.status_code >= 400: - logger.error( - f'Firefly III API error ({response.status_code}): {response.text}', - ) - if payload: - logger.error(f'Request payload: {payload}') - logger.error(f'Request URL: {response.request.url}') - - async def list_accounts( - self, page: int = 1, type: AccountTypeFilter = AccountTypeFilter.all - ) -> AccountArray: - r = await self._client.get('/api/v1/accounts', params={'page': page, 'type': type.value}) - self._handle_api_error(r) - r.raise_for_status() - return AccountArray.model_validate(r.json()) - - async def get_account(self, account_id: str) -> AccountSingle: - """Get a single account by ID.""" - r = await self._client.get(f'/api/v1/accounts/{account_id}') - self._handle_api_error(r) - r.raise_for_status() - return AccountSingle.model_validate(r.json()) - - async def search_accounts(self, query: str, type: AccountTypeFilter) -> AccountArray: - r = await self._client.get( - '/api/v1/search/accounts', - params={ - 'query': query, - 'type': type.value, - 'field': 'name', - 'limit': 50, - 'page': 1, - }, - ) - self._handle_api_error(r) - r.raise_for_status() - return AccountArray.model_validate(r.json()) - - async def create_account(self, account_store: AccountStore) -> AccountSingle: - r = await self._client.post('/api/v1/accounts', json=self._serialize_model(account_store)) - self._handle_api_error(r) - r.raise_for_status() - return AccountSingle.model_validate(r.json()) - - @staticmethod - def _sanitize_value(value: str) -> str: - """Escape and optionally quote a search value for Firefly III query syntax. - - Escapes backslashes and double quotes, then wraps the value in double quotes - if it contains whitespace or quote characters. - - Args: - value: The raw search value - - Returns: - Escaped and optionally quoted value safe for Firefly III queries - - """ - # Escape backslashes first, then escape double quotes - escaped = value.replace('\\', '\\\\').replace('"', '\\"') - - # Quote if contains whitespace or quote characters - if ' ' in value or '"' in value or "'" in value: - return f'"{escaped}"' - return escaped - - async def search_transactions( - self, query: str, page: int = 1, limit: int = 50 - ) -> TransactionArray: - """Search transactions using a query string.""" - params: Dict[str, Any] = { - 'query': query, - 'page': page, - 'limit': limit, - } - - r = await self._client.get('/api/v1/search/transactions', params=params) - self._handle_api_error(r) - r.raise_for_status() - return TransactionArray.model_validate(r.json()) - - async def create_transaction(self, transaction_store: TransactionStore) -> TransactionSingle: - """Create a transaction with the given store data.""" - payload = self._serialize_model(transaction_store) - r = await self._client.post('/api/v1/transactions', json=payload) - self._handle_api_error(r, payload) - r.raise_for_status() - return TransactionSingle.model_validate(r.json()) - - async def update_transaction( - self, transaction_id: str, transaction_update: TransactionUpdate - ) -> TransactionSingle: - """Update an existing transaction.""" - payload = self._serialize_model(transaction_update, exclude_unset=True) - r = await self._client.put(f'/api/v1/transactions/{transaction_id}', json=payload) - self._handle_api_error(r, payload) - r.raise_for_status() - return TransactionSingle.model_validate(r.json()) - - async def get_transactions( - self, - page: int = 1, - limit: int = 50, - start_date: Optional[date] = None, - end_date: Optional[date] = None, - transaction_type: Optional[str] = None, - ) -> TransactionArray: - """Get transactions with optional filtering.""" - params: Dict[str, Any] = { - 'page': page, - 'limit': limit, - } - - if start_date: - params['start'] = start_date.strftime('%Y-%m-%d') - - if end_date: - params['end'] = end_date.strftime('%Y-%m-%d') - - if transaction_type: - params['type'] = transaction_type - - r = await self._client.get('/api/v1/transactions', params=params) - self._handle_api_error(r) - r.raise_for_status() - return TransactionArray.model_validate(r.json()) - - async def get_account_transactions( - self, - account_id: str, - page: int = 1, - limit: int = 50, - start_date: Optional[date] = None, - end_date: Optional[date] = None, - transaction_type: Optional[str] = None, - ) -> TransactionArray: - """Get transactions for a specific account.""" - params: Dict[str, Any] = { - 'page': page, - 'limit': limit, - } - - if start_date: - params['start'] = start_date.strftime('%Y-%m-%d') - - if end_date: - params['end'] = end_date.strftime('%Y-%m-%d') - - if transaction_type: - params['type'] = transaction_type - - r = await self._client.get(f'/api/v1/accounts/{account_id}/transactions', params=params) - self._handle_api_error(r) - r.raise_for_status() - return TransactionArray.model_validate(r.json()) - - async def get_transaction(self, transaction_id: str) -> TransactionSingle: - """Get a single transaction by ID.""" - r = await self._client.get(f'/api/v1/transactions/{transaction_id}') - self._handle_api_error(r) - r.raise_for_status() - return TransactionSingle.model_validate(r.json()) - - async def delete_transaction(self, transaction_id: str) -> bool: - """Delete a transaction by ID.""" - r = await self._client.delete(f'/api/v1/transactions/{transaction_id}') - self._handle_api_error(r) - r.raise_for_status() - return r.status_code == 204 - - async def get_budgets(self) -> BudgetArray: - """Get all budgets.""" - r = await self._client.get('/api/v1/budgets') - self._handle_api_error(r) - r.raise_for_status() - return BudgetArray.model_validate(r.json()) - - async def get_budget(self, budget_id: str) -> BudgetSingle: - """Get a single budget by ID.""" - r = await self._client.get(f'/api/v1/budgets/{budget_id}') - self._handle_api_error(r) - r.raise_for_status() - return BudgetSingle.model_validate(r.json()) - - async def get_budget_limits( - self, budget_id: str, start_date: Optional[date] = None, end_date: Optional[date] = None - ) -> BudgetLimitArray: - """Get budget limits for a specific budget and period.""" - params: Dict[str, Any] = {} - - if start_date: - params['start'] = start_date.strftime('%Y-%m-%d') - if end_date: - params['end'] = end_date.strftime('%Y-%m-%d') - - r = await self._client.get(f'/api/v1/budgets/{budget_id}/limits', params=params) - self._handle_api_error(r) - r.raise_for_status() - return BudgetLimitArray.model_validate(r.json()) - - async def create_budget(self, budget_store: BudgetStore) -> BudgetSingle: - """Create a new budget.""" - payload = self._serialize_model(budget_store) - r = await self._client.post('/api/v1/budgets', json=payload) - self._handle_api_error(r, payload) - r.raise_for_status() - return BudgetSingle.model_validate(r.json()) - - async def get_available_budgets( - self, start_date: Optional[date] = None, end_date: Optional[date] = None - ) -> AvailableBudgetArray: - """Get available budgets for a period.""" - params: Dict[str, Any] = {} - - if start_date: - params['start'] = start_date.strftime('%Y-%m-%d') - if end_date: - params['end'] = end_date.strftime('%Y-%m-%d') - - r = await self._client.get('/api/v1/available-budgets', params=params) - self._handle_api_error(r) - r.raise_for_status() - return AvailableBudgetArray.model_validate(r.json()) + """HTTP client for interacting with the Firefly III API.""" + + def __init__(self) -> None: + """Initialize the Firefly III API client with authentication headers.""" + base = str(settings.firefly_base_url).rstrip('/') + self._client = httpx.AsyncClient( + base_url=base, + headers={ + 'Authorization': f'Bearer {settings.firefly_token}', + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + timeout=30.0, + ) + + def _serialize_model(self, model: Any, exclude_unset: bool = False) -> Dict[str, Any]: + """Serialize a Pydantic model to dict, excluding None values by default. + + Firefly III API rejects None values for many fields, so we exclude them. + Use exclude_unset=True for update operations to only send changed fields. + """ + return model.model_dump(mode='json', exclude_none=True, exclude_unset=exclude_unset) + + def _handle_api_error( + self, response: httpx.Response, payload: Dict[str, Any] | None = None + ) -> None: + """Log detailed error information for API errors. + + Args: + response: The HTTP response object + payload: The request payload that was sent (optional, for POST/PUT requests) + + """ + if response.status_code >= 400: + logger.error( + f'Firefly III API error ({response.status_code}): {response.text}', + ) + if payload: + logger.error(f'Request payload: {payload}') + logger.error(f'Request URL: {response.request.url}') + + async def list_accounts( + self, page: int = 1, type: AccountTypeFilter = AccountTypeFilter.all + ) -> AccountArray: + """List accounts with optional pagination and type filtering.""" + r = await self._client.get('/api/v1/accounts', params={'page': page, 'type': type.value}) + self._handle_api_error(r) + r.raise_for_status() + return AccountArray.model_validate(r.json()) + + async def get_account(self, account_id: str) -> AccountSingle: + """Get a single account by ID.""" + r = await self._client.get(f'/api/v1/accounts/{account_id}') + self._handle_api_error(r) + r.raise_for_status() + return AccountSingle.model_validate(r.json()) + + async def search_accounts(self, query: str, type: AccountTypeFilter) -> AccountArray: + """Search accounts by name with optional type filtering.""" + r = await self._client.get( + '/api/v1/search/accounts', + params={ + 'query': query, + 'type': type.value, + 'field': 'name', + 'limit': 50, + 'page': 1, + }, + ) + self._handle_api_error(r) + r.raise_for_status() + return AccountArray.model_validate(r.json()) + + async def create_account(self, account_store: AccountStore) -> AccountSingle: + """Create a new account in Firefly III.""" + r = await self._client.post('/api/v1/accounts', json=self._serialize_model(account_store)) + self._handle_api_error(r) + r.raise_for_status() + return AccountSingle.model_validate(r.json()) + + @staticmethod + def _sanitize_value(value: str) -> str: + """Escape and optionally quote a search value for Firefly III query syntax. + + Escapes backslashes and double quotes, then wraps the value in double quotes + if it contains whitespace or quote characters. + + Args: + value: The raw search value + + Returns: + Escaped and optionally quoted value safe for Firefly III queries + + """ + # Escape backslashes first, then escape double quotes + escaped = value.replace('\\', '\\\\').replace('"', '\\"') + + # Quote if contains whitespace or quote characters + if ' ' in value or '"' in value or "'" in value: + return f'"{escaped}"' + return escaped + + async def search_transactions( + self, query: str, page: int = 1, limit: int = 50 + ) -> TransactionArray: + """Search transactions using a query string.""" + params: Dict[str, Any] = { + 'query': query, + 'page': page, + 'limit': limit, + } + + r = await self._client.get('/api/v1/search/transactions', params=params) + self._handle_api_error(r) + r.raise_for_status() + return TransactionArray.model_validate(r.json()) + + async def create_transaction(self, transaction_store: TransactionStore) -> TransactionSingle: + """Create a transaction with the given store data.""" + payload = self._serialize_model(transaction_store) + r = await self._client.post('/api/v1/transactions', json=payload) + self._handle_api_error(r, payload) + r.raise_for_status() + return TransactionSingle.model_validate(r.json()) + + async def update_transaction( + self, transaction_id: str, transaction_update: TransactionUpdate + ) -> TransactionSingle: + """Update an existing transaction.""" + payload = self._serialize_model(transaction_update, exclude_unset=True) + r = await self._client.put(f'/api/v1/transactions/{transaction_id}', json=payload) + self._handle_api_error(r, payload) + r.raise_for_status() + return TransactionSingle.model_validate(r.json()) + + async def get_transactions( + self, + page: int = 1, + limit: int = 50, + start_date: Optional[date] = None, + end_date: Optional[date] = None, + transaction_type: Optional[str] = None, + ) -> TransactionArray: + """Get transactions with optional filtering.""" + params: Dict[str, Any] = { + 'page': page, + 'limit': limit, + } + + if start_date: + params['start'] = start_date.strftime('%Y-%m-%d') + + if end_date: + params['end'] = end_date.strftime('%Y-%m-%d') + + if transaction_type: + params['type'] = transaction_type + + r = await self._client.get('/api/v1/transactions', params=params) + self._handle_api_error(r) + r.raise_for_status() + return TransactionArray.model_validate(r.json()) + + async def get_account_transactions( + self, + account_id: str, + page: int = 1, + limit: int = 50, + start_date: Optional[date] = None, + end_date: Optional[date] = None, + transaction_type: Optional[str] = None, + ) -> TransactionArray: + """Get transactions for a specific account.""" + params: Dict[str, Any] = { + 'page': page, + 'limit': limit, + } + + if start_date: + params['start'] = start_date.strftime('%Y-%m-%d') + + if end_date: + params['end'] = end_date.strftime('%Y-%m-%d') + + if transaction_type: + params['type'] = transaction_type + + r = await self._client.get(f'/api/v1/accounts/{account_id}/transactions', params=params) + self._handle_api_error(r) + r.raise_for_status() + return TransactionArray.model_validate(r.json()) + + async def get_transaction(self, transaction_id: str) -> TransactionSingle: + """Get a single transaction by ID.""" + r = await self._client.get(f'/api/v1/transactions/{transaction_id}') + self._handle_api_error(r) + r.raise_for_status() + return TransactionSingle.model_validate(r.json()) + + async def delete_transaction(self, transaction_id: str) -> bool: + """Delete a transaction by ID.""" + r = await self._client.delete(f'/api/v1/transactions/{transaction_id}') + self._handle_api_error(r) + r.raise_for_status() + return r.status_code == 204 + + async def get_budgets(self) -> BudgetArray: + """Get all budgets.""" + r = await self._client.get('/api/v1/budgets') + self._handle_api_error(r) + r.raise_for_status() + return BudgetArray.model_validate(r.json()) + + async def get_budget(self, budget_id: str) -> BudgetSingle: + """Get a single budget by ID.""" + r = await self._client.get(f'/api/v1/budgets/{budget_id}') + self._handle_api_error(r) + r.raise_for_status() + return BudgetSingle.model_validate(r.json()) + + async def get_budget_limits( + self, budget_id: str, start_date: Optional[date] = None, end_date: Optional[date] = None + ) -> BudgetLimitArray: + """Get budget limits for a specific budget and period.""" + params: Dict[str, Any] = {} + + if start_date: + params['start'] = start_date.strftime('%Y-%m-%d') + if end_date: + params['end'] = end_date.strftime('%Y-%m-%d') + + r = await self._client.get(f'/api/v1/budgets/{budget_id}/limits', params=params) + self._handle_api_error(r) + r.raise_for_status() + return BudgetLimitArray.model_validate(r.json()) + + async def create_budget(self, budget_store: BudgetStore) -> BudgetSingle: + """Create a new budget.""" + payload = self._serialize_model(budget_store) + r = await self._client.post('/api/v1/budgets', json=payload) + self._handle_api_error(r, payload) + r.raise_for_status() + return BudgetSingle.model_validate(r.json()) + + async def get_available_budgets( + self, start_date: Optional[date] = None, end_date: Optional[date] = None + ) -> AvailableBudgetArray: + """Get available budgets for a period.""" + params: Dict[str, Any] = {} + + if start_date: + params['start'] = start_date.strftime('%Y-%m-%d') + if end_date: + params['end'] = end_date.strftime('%Y-%m-%d') + + r = await self._client.get('/api/v1/available-budgets', params=params) + self._handle_api_error(r) + r.raise_for_status() + return AvailableBudgetArray.model_validate(r.json()) diff --git a/src/lampyrid/config.py b/src/lampyrid/config.py index 5ad8627..f597e45 100644 --- a/src/lampyrid/config.py +++ b/src/lampyrid/config.py @@ -1,3 +1,5 @@ +"""Configuration settings for the LamPyrid application.""" + from pathlib import Path from typing import Literal, Optional, Self @@ -6,134 +8,144 @@ class Settings(BaseSettings): - """Application settings loaded from environment variables or .env file.""" - - model_config = SettingsConfigDict( - env_file='.env', - env_file_encoding='utf-8', - extra='ignore', - case_sensitive=False, # Allow FIREFLY_BASE_URL or firefly_base_url - ) - - # Firefly III Configuration (Required) - firefly_base_url: HttpUrl = Field( - description='Firefly III instance URL (e.g., https://firefly.example.com)' - ) - firefly_token: str = Field( - min_length=1, - description='Personal access token for Firefly III API authentication', - ) - - # Logging Configuration (Optional) - logging_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = Field( - default='INFO', - description='Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL', - ) - - # MCP Server Configuration (Optional - for transport settings) - mcp_transport: str = Field( - default='stdio', - description='MCP transport mode: stdio (default), http, or sse', - ) - mcp_host: str = Field( - default='0.0.0.0', - description='Host to bind the MCP server to (for http/sse transports)', - ) - mcp_port: int = Field( - default=3000, - ge=1, - le=65535, - description='Port to bind the MCP server to (for http/sse transports)', - ) - - # Google OAuth Configuration (Optional - all three required together for remote auth) - google_client_id: Optional[str] = Field( - default=None, description='Google OAuth 2.0 client ID from Google Cloud Console' - ) - google_client_secret: Optional[str] = Field( - default=None, description='Google OAuth 2.0 client secret from Google Cloud Console' - ) - server_base_url: Optional[HttpUrl] = Field( - default=None, - description="This server's base URL (e.g., http://localhost:8000 for development)", - ) - - # OAuth Token Storage Configuration (Optional - for persistent authentication) - jwt_signing_key: Optional[str] = Field( - default=None, - description='JWT signing key for OAuth tokens. Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"', - ) - oauth_storage_encryption_key: Optional[str] = Field( - default=None, - description='Fernet encryption key for OAuth token storage. Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"', - ) - oauth_storage_path: Path = Field( - default=Path.home() / '.local' / 'share' / 'lampyrid' / 'oauth', - description='Path for persistent OAuth token storage (default: ~/.local/share/lampyrid/oauth)', - ) - - @model_validator(mode='after') - def validate_google_oauth_settings(self) -> Self: - """Ensure Google OAuth settings are all provided together or all absent.""" - oauth_fields = [self.google_client_id, self.google_client_secret, self.server_base_url] - provided = [f for f in oauth_fields if f is not None] - - # If some but not all are provided, raise an error - if 0 < len(provided) < 3: - raise ValueError( - 'Google OAuth configuration is incomplete. All three fields must be provided together: ' - 'GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and SERVER_BASE_URL. ' - f'Currently provided: {len(provided)}/3' - ) - - return self - - @property - def is_auth_enabled(self) -> bool: - """Check if Google OAuth authentication is enabled.""" - return all([self.google_client_id, self.google_client_secret, self.server_base_url]) - - @property - def is_token_persistence_enabled(self) -> bool: - """Check if OAuth token persistence is enabled.""" - return all([self.jwt_signing_key, self.oauth_storage_encryption_key]) + """Application settings loaded from environment variables or .env file.""" + + model_config = SettingsConfigDict( + env_file='.env', + env_file_encoding='utf-8', + extra='ignore', + case_sensitive=False, # Allow FIREFLY_BASE_URL or firefly_base_url + ) + + # Firefly III Configuration (Required) + firefly_base_url: HttpUrl = Field( + description='Firefly III instance URL (e.g., https://firefly.example.com)' + ) + firefly_token: str = Field( + min_length=1, + description='Personal access token for Firefly III API authentication', + ) + + # Logging Configuration (Optional) + logging_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = Field( + default='INFO', + description='Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL', + ) + + # MCP Server Configuration (Optional - for transport settings) + mcp_transport: str = Field( + default='stdio', + description='MCP transport mode: stdio (default), http, or sse', + ) + mcp_host: str = Field( + default='0.0.0.0', + description='Host to bind the MCP server to (for http/sse transports)', + ) + mcp_port: int = Field( + default=3000, + ge=1, + le=65535, + description='Port to bind the MCP server to (for http/sse transports)', + ) + + # Google OAuth Configuration (Optional - all three required together for remote auth) + google_client_id: Optional[str] = Field( + default=None, description='Google OAuth 2.0 client ID from Google Cloud Console' + ) + google_client_secret: Optional[str] = Field( + default=None, description='Google OAuth 2.0 client secret from Google Cloud Console' + ) + server_base_url: Optional[HttpUrl] = Field( + default=None, + description="This server's base URL (e.g., http://localhost:8000 for development)", + ) + + # OAuth Token Storage Configuration (Optional - for persistent authentication) + jwt_signing_key: Optional[str] = Field( + default=None, + description=( + 'JWT signing key for OAuth tokens. Generate with: ' + 'python -c "import secrets; print(secrets.token_urlsafe(32))"' + ), + ) + oauth_storage_encryption_key: Optional[str] = Field( + default=None, + description=( + 'Fernet encryption key for OAuth token storage. ' + 'Generate with: python -c "from cryptography.fernet import Fernet; ' + 'print(Fernet.generate_key().decode())"' + ), + ) + oauth_storage_path: Path = Field( + default=Path.home() / '.local' / 'share' / 'lampyrid' / 'oauth', + description=( + 'Path for persistent OAuth token storage (default: ~/.local/share/lampyrid/oauth)' + ), + ) + + @model_validator(mode='after') + def validate_google_oauth_settings(self) -> Self: + """Ensure Google OAuth settings are all provided together or all absent.""" + oauth_fields = [self.google_client_id, self.google_client_secret, self.server_base_url] + provided = [f for f in oauth_fields if f is not None] + + # If some but not all are provided, raise an error + if 0 < len(provided) < 3: + raise ValueError( + 'Google OAuth configuration is incomplete. ' + 'All three fields must be provided together: ' + 'GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and SERVER_BASE_URL. ' + f'Currently provided: {len(provided)}/3' + ) + + return self + + @property + def is_auth_enabled(self) -> bool: + """Check if Google OAuth authentication is enabled.""" + return all([self.google_client_id, self.google_client_secret, self.server_base_url]) + + @property + def is_token_persistence_enabled(self) -> bool: + """Check if OAuth token persistence is enabled.""" + return all([self.jwt_signing_key, self.oauth_storage_encryption_key]) def _init_settings() -> Settings: - """Initialize settings with user-friendly error handling.""" - try: - return Settings.model_validate({}) - except Exception as e: - import sys - - from pydantic_core import ValidationError - - if isinstance(e, ValidationError): - print( - '\n❌ Configuration Error: Missing required environment variables\n', - file=sys.stderr, - ) - print('The following required settings are missing or invalid:\n', file=sys.stderr) - - for error in e.errors(): - field = str(error['loc'][0]) if error['loc'] else 'unknown' - error_type = error['type'] - - if error_type == 'missing': - print(f' • {field.upper()}: This field is required', file=sys.stderr) - else: - msg = error.get('msg', 'Invalid value') - print(f' • {field.upper()}: {msg}', file=sys.stderr) - - print('\nPlease set these variables in your .env file or environment.', file=sys.stderr) - print('Example .env file:\n', file=sys.stderr) - print(' FIREFLY_BASE_URL=https://firefly.example.com', file=sys.stderr) - print(' FIREFLY_TOKEN=your_token_here', file=sys.stderr) - print('\nSee .env.example for a complete configuration template.', file=sys.stderr) - sys.exit(1) - else: - # Re-raise unexpected errors - raise + """Initialize settings with user-friendly error handling.""" + try: + return Settings.model_validate({}) + except Exception as e: + import sys + + from pydantic_core import ValidationError + + if isinstance(e, ValidationError): + print( + '\n❌ Configuration Error: Missing required environment variables\n', + file=sys.stderr, + ) + print('The following required settings are missing or invalid:\n', file=sys.stderr) + + for error in e.errors(): + field = str(error['loc'][0]) if error['loc'] else 'unknown' + error_type = error['type'] + + if error_type == 'missing': + print(f' • {field.upper()}: This field is required', file=sys.stderr) + else: + msg = error.get('msg', 'Invalid value') + print(f' • {field.upper()}: {msg}', file=sys.stderr) + + print('\nPlease set these variables in your .env file or environment.', file=sys.stderr) + print('Example .env file:\n', file=sys.stderr) + print(' FIREFLY_BASE_URL=https://firefly.example.com', file=sys.stderr) + print(' FIREFLY_TOKEN=your_token_here', file=sys.stderr) + print('\nSee .env.example for a complete configuration template.', file=sys.stderr) + sys.exit(1) + else: + # Re-raise unexpected errors + raise # Initialize settings - will fail gracefully with clear errors if required settings are missing diff --git a/src/lampyrid/models/__init__.py b/src/lampyrid/models/__init__.py index e69de29..503322c 100644 --- a/src/lampyrid/models/__init__.py +++ b/src/lampyrid/models/__init__.py @@ -0,0 +1 @@ +"""MCP models package.""" diff --git a/src/lampyrid/models/firefly_models.py b/src/lampyrid/models/firefly_models.py index 4bc19f9..a8fd7a6 100644 --- a/src/lampyrid/models/firefly_models.py +++ b/src/lampyrid/models/firefly_models.py @@ -12,4813 +12,4813 @@ class AutocompleteAccount(BaseModel): - id: str = Field(..., examples=['2']) - name: str = Field( - ..., - description='Name of the account found by an auto-complete search.', - examples=['Checking Account'], - ) - name_with_balance: str = Field( - ..., - description="Asset accounts and liabilities have a second field with the given date's account balance in the account currency or primary currency.", - examples=['Checking Account ($123.45)'], - ) - active: bool | None = Field(None, description='Is the bill active or not?', examples=[True]) - type: str = Field( - ..., - description='Account type of the account found by the auto-complete search.', - examples=['Asset account'], - ) - currency_id: str = Field( - ..., - description='ID for the currency used by this account. If the user prefers amounts converted to their primary currency, this primary currency is used instead.', - examples=['12'], - ) - currency_name: str = Field( - ..., - description='Currency name for the currency used by this account. If the user prefers amounts converted to their primary currency, this primary currency is used instead.', - examples=['Euro'], - ) - currency_code: str = Field( - ..., - description='Currency code for the currency used by this account. If the user prefers amounts converted to their primary currency, this primary currency is used instead.', - examples=['EUR'], - ) - currency_symbol: str = Field( - ..., - description='Currency symbol for the currency used by this account. If the user prefers amounts converted to their primary currency, this primary currency is used instead.', - examples=['$'], - ) - currency_decimal_places: int = Field( - ..., - description='Number of decimal places for the currency used by this account. If the user prefers amounts converted to their primary currency, this primary currency is used instead.', - examples=[2], - ) - account_currency_id: str | None = Field( - None, - description='ID for the currency used by this account. Even if "convertToPrimary" is on, the account currency ID is displayed here.', - examples=['2'], - ) - account_currency_name: str | None = Field( - None, - description='Name for the currency used by this account. Even if "convertToPrimary" is on, the account currency name is displayed here.', - examples=['US Dollar'], - ) - account_currency_code: str | None = Field( - None, - description='Code for the currency used by this account. Even if "convertToPrimary" is on, the account currency code is displayed here.', - examples=['USD'], - ) - account_currency_symbol: str | None = Field( - None, - description='Code for the currency used by this account. Even if "convertToPrimary" is on, the account currency code is displayed here.', - examples=['$'], - ) - account_currency_decimal_places: int | None = Field( - None, - description='Number of decimal places for the currency used by this account. Even if "convertToPrimary" is on, the account currency code is displayed here.', - examples=[2], - ) + id: str = Field(..., examples=['2']) + name: str = Field( + ..., + description='Name of the account found by an auto-complete search.', + examples=['Checking Account'], + ) + name_with_balance: str = Field( + ..., + description="Asset accounts and liabilities have a second field with the given date's account balance in the account currency or primary currency.", + examples=['Checking Account ($123.45)'], + ) + active: bool | None = Field(None, description='Is the bill active or not?', examples=[True]) + type: str = Field( + ..., + description='Account type of the account found by the auto-complete search.', + examples=['Asset account'], + ) + currency_id: str = Field( + ..., + description='ID for the currency used by this account. If the user prefers amounts converted to their primary currency, this primary currency is used instead.', + examples=['12'], + ) + currency_name: str = Field( + ..., + description='Currency name for the currency used by this account. If the user prefers amounts converted to their primary currency, this primary currency is used instead.', + examples=['Euro'], + ) + currency_code: str = Field( + ..., + description='Currency code for the currency used by this account. If the user prefers amounts converted to their primary currency, this primary currency is used instead.', + examples=['EUR'], + ) + currency_symbol: str = Field( + ..., + description='Currency symbol for the currency used by this account. If the user prefers amounts converted to their primary currency, this primary currency is used instead.', + examples=['$'], + ) + currency_decimal_places: int = Field( + ..., + description='Number of decimal places for the currency used by this account. If the user prefers amounts converted to their primary currency, this primary currency is used instead.', + examples=[2], + ) + account_currency_id: str | None = Field( + None, + description='ID for the currency used by this account. Even if "convertToPrimary" is on, the account currency ID is displayed here.', + examples=['2'], + ) + account_currency_name: str | None = Field( + None, + description='Name for the currency used by this account. Even if "convertToPrimary" is on, the account currency name is displayed here.', + examples=['US Dollar'], + ) + account_currency_code: str | None = Field( + None, + description='Code for the currency used by this account. Even if "convertToPrimary" is on, the account currency code is displayed here.', + examples=['USD'], + ) + account_currency_symbol: str | None = Field( + None, + description='Code for the currency used by this account. Even if "convertToPrimary" is on, the account currency code is displayed here.', + examples=['$'], + ) + account_currency_decimal_places: int | None = Field( + None, + description='Number of decimal places for the currency used by this account. Even if "convertToPrimary" is on, the account currency code is displayed here.', + examples=[2], + ) class AutocompleteBill(BaseModel): - id: str = Field(..., examples=['2']) - name: str = Field( - ..., - description='Name of the bill found by an auto-complete search.', - examples=['Yearly bill'], - ) - active: bool | None = Field(None, description='Is the bill active or not?', examples=[True]) + id: str = Field(..., examples=['2']) + name: str = Field( + ..., + description='Name of the bill found by an auto-complete search.', + examples=['Yearly bill'], + ) + active: bool | None = Field(None, description='Is the bill active or not?', examples=[True]) class AutocompleteBudget(BaseModel): - id: str = Field(..., examples=['2']) - name: str = Field( - ..., - description='Name of the budget found by an auto-complete search.', - examples=['Groceries'], - ) - active: bool | None = Field(None, description='Is the budget active or not?', examples=[True]) + id: str = Field(..., examples=['2']) + name: str = Field( + ..., + description='Name of the budget found by an auto-complete search.', + examples=['Groceries'], + ) + active: bool | None = Field(None, description='Is the budget active or not?', examples=[True]) class AutocompleteCategory(BaseModel): - id: str = Field(..., examples=['2']) - name: str = Field( - ..., - description='Name of the category found by an auto-complete search.', - examples=['Category X'], - ) + id: str = Field(..., examples=['2']) + name: str = Field( + ..., + description='Name of the category found by an auto-complete search.', + examples=['Category X'], + ) class AutocompleteCurrency(BaseModel): - id: str = Field(..., examples=['2']) - name: str = Field(..., description='Currency name.', examples=['Currency name']) - code: str = Field(..., description='Currency code.', examples=['EUR']) - symbol: str = Field(..., examples=['$']) - decimal_places: int = Field(..., examples=[2]) + id: str = Field(..., examples=['2']) + name: str = Field(..., description='Currency name.', examples=['Currency name']) + code: str = Field(..., description='Currency code.', examples=['EUR']) + symbol: str = Field(..., examples=['$']) + decimal_places: int = Field(..., examples=[2]) class AutocompleteCurrencyCode(BaseModel): - id: str = Field(..., examples=['2']) - name: str = Field( - ..., - description='Currency name with the code between brackets.', - examples=['Currency name (XCN)'], - ) - code: str = Field(..., description='Currency code.', examples=['EUR']) - symbol: str = Field(..., examples=['$']) - decimal_places: int = Field(..., examples=[2]) + id: str = Field(..., examples=['2']) + name: str = Field( + ..., + description='Currency name with the code between brackets.', + examples=['Currency name (XCN)'], + ) + code: str = Field(..., description='Currency code.', examples=['EUR']) + symbol: str = Field(..., examples=['$']) + decimal_places: int = Field(..., examples=[2]) class AutocompleteObjectGroup(BaseModel): - id: str = Field(..., examples=['2']) - title: str = Field( - ..., - description='Title of the object group found by an auto-complete search.', - examples=['Object Group one'], - ) - name: str = Field( - ..., - description='Title of the object group found by an auto-complete search.', - examples=['Object Group one'], - ) + id: str = Field(..., examples=['2']) + title: str = Field( + ..., + description='Title of the object group found by an auto-complete search.', + examples=['Object Group one'], + ) + name: str = Field( + ..., + description='Title of the object group found by an auto-complete search.', + examples=['Object Group one'], + ) class AutocompletePiggy(BaseModel): - id: str = Field(..., examples=['2']) - name: str = Field( - ..., - description='Name of the piggy bank found by an auto-complete search.', - examples=['New couch'], - ) - currency_id: str | None = Field( - None, - description="Currency ID for this piggy bank. This will always be the currency of the piggy bank, never the user's primary currency.", - examples=['12'], - ) - currency_code: str | None = Field( - None, - description="Currency code for this piggy bank. This will always be the currency of the piggy bank, never the user's primary currency.", - examples=['EUR'], - ) - currency_symbol: str | None = Field(None, examples=['$']) - currency_name: str | None = Field( - None, - description="Currency name for the currency used by this piggy bank. This will always be the currency of the piggy bank, never the user's primary currency.", - examples=['Euro'], - ) - currency_decimal_places: int | None = Field( - None, - description="Number of decimal places for the currency used by this piggy bank. This will always be the currency of the piggy bank, never the user's primary currency.", - examples=[2], - ) - object_group_id: str | None = Field( - None, - description='The group ID of the group this object is part of. NULL if no group.', - examples=['5'], - ) - object_group_title: str | None = Field( - None, - description='The name of the group. NULL if no group.', - examples=['Example Group'], - ) + id: str = Field(..., examples=['2']) + name: str = Field( + ..., + description='Name of the piggy bank found by an auto-complete search.', + examples=['New couch'], + ) + currency_id: str | None = Field( + None, + description="Currency ID for this piggy bank. This will always be the currency of the piggy bank, never the user's primary currency.", + examples=['12'], + ) + currency_code: str | None = Field( + None, + description="Currency code for this piggy bank. This will always be the currency of the piggy bank, never the user's primary currency.", + examples=['EUR'], + ) + currency_symbol: str | None = Field(None, examples=['$']) + currency_name: str | None = Field( + None, + description="Currency name for the currency used by this piggy bank. This will always be the currency of the piggy bank, never the user's primary currency.", + examples=['Euro'], + ) + currency_decimal_places: int | None = Field( + None, + description="Number of decimal places for the currency used by this piggy bank. This will always be the currency of the piggy bank, never the user's primary currency.", + examples=[2], + ) + object_group_id: str | None = Field( + None, + description='The group ID of the group this object is part of. NULL if no group.', + examples=['5'], + ) + object_group_title: str | None = Field( + None, + description='The name of the group. NULL if no group.', + examples=['Example Group'], + ) class AutocompletePiggyBalance(BaseModel): - id: str = Field(..., examples=['2']) - name: str = Field( - ..., - description='Name of the piggy bank found by an auto-complete search.', - examples=['New couch'], - ) - name_with_balance: str | None = Field( - None, - description='Name of the piggy bank found by an auto-complete search, including the currently saved amount and the target amount.', - examples=['New couch ($234.56 / $600)'], - ) - currency_id: str | None = Field( - None, - description="Currency ID for the currency used by this piggy bank. This will always be the piggy bank's currency, never the primary currency.", - examples=['12'], - ) - currency_code: str | None = Field( - None, - description="Currency code for the currency used by this piggy bank. This will always be the piggy bank's currency, never the primary currency.", - examples=['EUR'], - ) - currency_symbol: str | None = Field( - None, - description="Currency symbol for the currency used by this piggy bank. This will always be the piggy bank's currency, never the primary currency.", - examples=['$'], - ) - currency_decimal_places: int | None = Field( - None, - description="Currency decimal places for the currency used by this piggy bank. This will always be the piggy bank's currency, never the primary currency.", - examples=[2], - ) - object_group_id: str | None = Field( - None, - description='The group ID of the group this object is part of. NULL if no group.', - examples=['5'], - ) - object_group_title: str | None = Field( - None, - description='The name of the group. NULL if no group.', - examples=['Example Group'], - ) + id: str = Field(..., examples=['2']) + name: str = Field( + ..., + description='Name of the piggy bank found by an auto-complete search.', + examples=['New couch'], + ) + name_with_balance: str | None = Field( + None, + description='Name of the piggy bank found by an auto-complete search, including the currently saved amount and the target amount.', + examples=['New couch ($234.56 / $600)'], + ) + currency_id: str | None = Field( + None, + description="Currency ID for the currency used by this piggy bank. This will always be the piggy bank's currency, never the primary currency.", + examples=['12'], + ) + currency_code: str | None = Field( + None, + description="Currency code for the currency used by this piggy bank. This will always be the piggy bank's currency, never the primary currency.", + examples=['EUR'], + ) + currency_symbol: str | None = Field( + None, + description="Currency symbol for the currency used by this piggy bank. This will always be the piggy bank's currency, never the primary currency.", + examples=['$'], + ) + currency_decimal_places: int | None = Field( + None, + description="Currency decimal places for the currency used by this piggy bank. This will always be the piggy bank's currency, never the primary currency.", + examples=[2], + ) + object_group_id: str | None = Field( + None, + description='The group ID of the group this object is part of. NULL if no group.', + examples=['5'], + ) + object_group_title: str | None = Field( + None, + description='The name of the group. NULL if no group.', + examples=['Example Group'], + ) class AutocompleteRecurrence(BaseModel): - id: str = Field(..., examples=['2']) - name: str = Field( - ..., - description='Name of the recurrence found by an auto-complete search.', - examples=['Yearly bill'], - ) - description: str | None = Field( - None, - description='Description of the recurrence found by auto-complete.', - examples=['Should trigger daily.'], - ) - active: bool | None = Field( - None, description='Is the recurring transaction active or not?', examples=[True] - ) + id: str = Field(..., examples=['2']) + name: str = Field( + ..., + description='Name of the recurrence found by an auto-complete search.', + examples=['Yearly bill'], + ) + description: str | None = Field( + None, + description='Description of the recurrence found by auto-complete.', + examples=['Should trigger daily.'], + ) + active: bool | None = Field( + None, description='Is the recurring transaction active or not?', examples=[True] + ) class AutocompleteRule(BaseModel): - id: str = Field(..., examples=['2']) - name: str = Field( - ..., - description='Name of the rule found by an auto-complete search.', - examples=['Rule one'], - ) - description: str | None = Field( - None, - description='Description of the rule found by auto-complete.', - examples=['Useful rule.'], - ) - active: bool | None = Field(None, description='Is the bill active or not?', examples=[True]) + id: str = Field(..., examples=['2']) + name: str = Field( + ..., + description='Name of the rule found by an auto-complete search.', + examples=['Rule one'], + ) + description: str | None = Field( + None, + description='Description of the rule found by auto-complete.', + examples=['Useful rule.'], + ) + active: bool | None = Field(None, description='Is the bill active or not?', examples=[True]) class AutocompleteRuleGroup(BaseModel): - id: str = Field(..., examples=['2']) - name: str = Field( - ..., - description='Name of the rule group found by an auto-complete search.', - examples=['Rule group one'], - ) - description: str | None = Field( - None, - description='Description of the rule group found by auto-complete.', - examples=['Some rule group.'], - ) - active: bool | None = Field(None, description='Is the bill active or not?', examples=[True]) + id: str = Field(..., examples=['2']) + name: str = Field( + ..., + description='Name of the rule group found by an auto-complete search.', + examples=['Rule group one'], + ) + description: str | None = Field( + None, + description='Description of the rule group found by auto-complete.', + examples=['Some rule group.'], + ) + active: bool | None = Field(None, description='Is the bill active or not?', examples=[True]) class AutocompleteTag(BaseModel): - id: str = Field(..., examples=['2']) - name: str = Field( - ..., - description='Name of the tag found by an auto-complete search.', - examples=['too-expensive-tag-example'], - ) - tag: str = Field( - ..., - description='Name of the tag found by an auto-complete search.', - examples=['too-expensive-tag-example'], - ) + id: str = Field(..., examples=['2']) + name: str = Field( + ..., + description='Name of the tag found by an auto-complete search.', + examples=['too-expensive-tag-example'], + ) + tag: str = Field( + ..., + description='Name of the tag found by an auto-complete search.', + examples=['too-expensive-tag-example'], + ) class AutocompleteTransaction(BaseModel): - id: str = Field( - ..., - description='The ID of a transaction journal (basically a single split).', - examples=['2'], - ) - transaction_group_id: str | None = Field( - None, description='The ID of the underlying transaction group.', examples=['2'] - ) - name: str = Field(..., description='Transaction description', examples=['Transaction']) - description: str = Field(..., description='Transaction description', examples=['Transaction']) + id: str = Field( + ..., + description='The ID of a transaction journal (basically a single split).', + examples=['2'], + ) + transaction_group_id: str | None = Field( + None, description='The ID of the underlying transaction group.', examples=['2'] + ) + name: str = Field(..., description='Transaction description', examples=['Transaction']) + description: str = Field(..., description='Transaction description', examples=['Transaction']) class AutocompleteTransactionID(BaseModel): - id: str = Field( - ..., - description='The ID of a transaction journal (basically a single split).', - examples=['2'], - ) - transaction_group_id: str | None = Field( - None, description='The ID of the underlying transaction group.', examples=['2'] - ) - name: str = Field( - ..., - description='Transaction description with ID in the name.', - examples=['#12: Transaction'], - ) - description: str = Field( - ..., - description='Transaction description with ID in the name.', - examples=['#12: Transaction'], - ) + id: str = Field( + ..., + description='The ID of a transaction journal (basically a single split).', + examples=['2'], + ) + transaction_group_id: str | None = Field( + None, description='The ID of the underlying transaction group.', examples=['2'] + ) + name: str = Field( + ..., + description='Transaction description with ID in the name.', + examples=['#12: Transaction'], + ) + description: str = Field( + ..., + description='Transaction description with ID in the name.', + examples=['#12: Transaction'], + ) class AutocompleteTransactionType(BaseModel): - id: str = Field(..., examples=['2']) - name: str = Field( - ..., - description='Type of the object found by an auto-complete search.', - examples=['Withdrawal'], - ) - type: str = Field( - ..., - description='Name of the object found by an auto-complete search.', - examples=['Withdrawal'], - ) + id: str = Field(..., examples=['2']) + name: str = Field( + ..., + description='Type of the object found by an auto-complete search.', + examples=['Withdrawal'], + ) + type: str = Field( + ..., + description='Name of the object found by an auto-complete search.', + examples=['Withdrawal'], + ) class ChartDataPoint(BaseModel): - key: str | None = Field( - None, - description="The key is the label of the value, so for example: '2018-01-01' => 13 or 'Groceries' => -123.", - examples=['value'], - ) + key: str | None = Field( + None, + description="The key is the label of the value, so for example: '2018-01-01' => 13 or 'Groceries' => -123.", + examples=['value'], + ) class Entries(BaseModel): - pass + pass class PcEntries(BaseModel): - pass + pass class DataDestroyObject(Enum): - not_assets_liabilities = 'not_assets_liabilities' - budgets = 'budgets' - bills = 'bills' - piggy_banks = 'piggy_banks' - rules = 'rules' - recurring = 'recurring' - categories = 'categories' - tags = 'tags' - object_groups = 'object_groups' - accounts = 'accounts' - asset_accounts = 'asset_accounts' - expense_accounts = 'expense_accounts' - revenue_accounts = 'revenue_accounts' - liabilities = 'liabilities' - transactions = 'transactions' - withdrawals = 'withdrawals' - deposits = 'deposits' - transfers = 'transfers' + not_assets_liabilities = 'not_assets_liabilities' + budgets = 'budgets' + bills = 'bills' + piggy_banks = 'piggy_banks' + rules = 'rules' + recurring = 'recurring' + categories = 'categories' + tags = 'tags' + object_groups = 'object_groups' + accounts = 'accounts' + asset_accounts = 'asset_accounts' + expense_accounts = 'expense_accounts' + revenue_accounts = 'revenue_accounts' + liabilities = 'liabilities' + transactions = 'transactions' + withdrawals = 'withdrawals' + deposits = 'deposits' + transfers = 'transfers' class AccountSearchFieldFilter(Enum): - all = 'all' - iban = 'iban' - name = 'name' - number = 'number' - id = 'id' + all = 'all' + iban = 'iban' + name = 'name' + number = 'number' + id = 'id' class ConfigValueFilter(Enum): - configuration_is_demo_site = 'configuration.is_demo_site' - configuration_permission_update_check = 'configuration.permission_update_check' - configuration_last_update_check = 'configuration.last_update_check' - configuration_single_user_mode = 'configuration.single_user_mode' - firefly_version = 'firefly.version' - firefly_default_location = 'firefly.default_location' - firefly_account_to_transaction = 'firefly.account_to_transaction' - firefly_allowed_opposing_types = 'firefly.allowed_opposing_types' - firefly_accountRoles = 'firefly.accountRoles' - firefly_valid_liabilities = 'firefly.valid_liabilities' - firefly_interest_periods = 'firefly.interest_periods' - firefly_enable_external_map = 'firefly.enable_external_map' - firefly_expected_source_types = 'firefly.expected_source_types' - app_timezone = 'app.timezone' - firefly_bill_periods = 'firefly.bill_periods' - firefly_credit_card_types = 'firefly.credit_card_types' - firefly_languages = 'firefly.languages' - firefly_valid_view_ranges = 'firefly.valid_view_ranges' - cer_enabled = 'cer.enabled' - firefly_preselected_accounts = 'firefly.preselected_accounts' - firefly_rule_actions = 'firefly.rule-actions' - firefly_context_rule_actions = 'firefly.context-rule-actions' - search_operators = 'search.operators' - webhook_triggers = 'webhook.triggers' - webhook_responses = 'webhook.responses' - webhook_deliveries = 'webhook.deliveries' + configuration_is_demo_site = 'configuration.is_demo_site' + configuration_permission_update_check = 'configuration.permission_update_check' + configuration_last_update_check = 'configuration.last_update_check' + configuration_single_user_mode = 'configuration.single_user_mode' + firefly_version = 'firefly.version' + firefly_default_location = 'firefly.default_location' + firefly_account_to_transaction = 'firefly.account_to_transaction' + firefly_allowed_opposing_types = 'firefly.allowed_opposing_types' + firefly_accountRoles = 'firefly.accountRoles' + firefly_valid_liabilities = 'firefly.valid_liabilities' + firefly_interest_periods = 'firefly.interest_periods' + firefly_enable_external_map = 'firefly.enable_external_map' + firefly_expected_source_types = 'firefly.expected_source_types' + app_timezone = 'app.timezone' + firefly_bill_periods = 'firefly.bill_periods' + firefly_credit_card_types = 'firefly.credit_card_types' + firefly_languages = 'firefly.languages' + firefly_valid_view_ranges = 'firefly.valid_view_ranges' + cer_enabled = 'cer.enabled' + firefly_preselected_accounts = 'firefly.preselected_accounts' + firefly_rule_actions = 'firefly.rule-actions' + firefly_context_rule_actions = 'firefly.context-rule-actions' + search_operators = 'search.operators' + webhook_triggers = 'webhook.triggers' + webhook_responses = 'webhook.responses' + webhook_deliveries = 'webhook.deliveries' class ConfigValueUpdateFilter(Enum): - configuration_is_demo_site = 'configuration.is_demo_site' - configuration_permission_update_check = 'configuration.permission_update_check' - configuration_last_update_check = 'configuration.last_update_check' - configuration_single_user_mode = 'configuration.single_user_mode' + configuration_is_demo_site = 'configuration.is_demo_site' + configuration_permission_update_check = 'configuration.permission_update_check' + configuration_last_update_check = 'configuration.last_update_check' + configuration_single_user_mode = 'configuration.single_user_mode' class ExportFileFilter(Enum): - csv = 'csv' + csv = 'csv' class InsightGroupEntry(BaseModel): - id: str | None = Field( - None, - description='This ID is a reference to the original object.', - examples=['123'], - ) - name: str | None = Field( - None, description='This is the name of the object.', examples=['Land lord'] - ) - difference: str | None = Field( - None, - description='The amount spent or earned between start date and end date, a number defined as a string, for this object and all asset accounts.', - examples=['-123.45'], - ) - difference_float: float | None = Field( - None, - description='The amount spent or earned between start date and end date, a number as a float, for this object and all asset accounts. May have rounding errors.', - examples=[-123.45], - ) - currency_id: str | None = Field( - None, - description='The currency ID of the expenses listed for this account.', - examples=['5'], - ) - currency_code: str | None = Field( - None, - description='The currency code of the expenses listed for this account.', - examples=['EUR'], - ) + id: str | None = Field( + None, + description='This ID is a reference to the original object.', + examples=['123'], + ) + name: str | None = Field( + None, description='This is the name of the object.', examples=['Land lord'] + ) + difference: str | None = Field( + None, + description='The amount spent or earned between start date and end date, a number defined as a string, for this object and all asset accounts.', + examples=['-123.45'], + ) + difference_float: float | None = Field( + None, + description='The amount spent or earned between start date and end date, a number as a float, for this object and all asset accounts. May have rounding errors.', + examples=[-123.45], + ) + currency_id: str | None = Field( + None, + description='The currency ID of the expenses listed for this account.', + examples=['5'], + ) + currency_code: str | None = Field( + None, + description='The currency code of the expenses listed for this account.', + examples=['EUR'], + ) class InsightTotalEntry(BaseModel): - difference: str | None = Field( - None, - description='The amount spent between start date and end date, defined as a string, for this expense account and all asset accounts.', - examples=['123.45'], - ) - difference_float: float | None = Field( - None, - description='The amount spent between start date and end date, defined as a string, for this expense account and all asset accounts. This number is a float (double) and may have rounding errors.', - examples=[123.45], - ) - currency_id: str | None = Field( - None, - description='The currency ID of the expenses listed for this expense account.', - examples=['5'], - ) - currency_code: str | None = Field( - None, - description='The currency code of the expenses listed for this expense account.', - examples=['EUR'], - ) + difference: str | None = Field( + None, + description='The amount spent between start date and end date, defined as a string, for this expense account and all asset accounts.', + examples=['123.45'], + ) + difference_float: float | None = Field( + None, + description='The amount spent between start date and end date, defined as a string, for this expense account and all asset accounts. This number is a float (double) and may have rounding errors.', + examples=[123.45], + ) + currency_id: str | None = Field( + None, + description='The currency ID of the expenses listed for this expense account.', + examples=['5'], + ) + currency_code: str | None = Field( + None, + description='The currency code of the expenses listed for this expense account.', + examples=['EUR'], + ) class InsightTransferEntry(BaseModel): - id: str | None = Field( - None, - description='This ID is a reference to the original object.', - examples=['123'], - ) - name: str | None = Field( - None, description='This is the name of the object.', examples=['Land lord'] - ) - difference: str | None = Field( - None, - description='The total amount transferred between start date and end date, a number defined as a string, for this asset account.', - examples=['-123.45'], - ) - difference_float: float | None = Field( - None, - description='The total amount transferred between start date and end date, a number as a float, for this asset account. May have rounding errors.', - examples=[-123.45], - ) - in_: str | None = Field( - None, - alias='in', - description='The total amount transferred TO this account between start date and end date, a number defined as a string, for this asset account.', - examples=['123.45'], - ) - in_float: float | None = Field( - None, - description='The total amount transferred FROM this account between start date and end date, a number as a float, for this asset account. May have rounding errors.', - examples=[123.45], - ) - out: str | None = Field( - None, - description='The total amount transferred FROM this account between start date and end date, a number defined as a string, for this asset account.', - examples=['123.45'], - ) - out_float: float | None = Field( - None, - description='The total amount transferred TO this account between start date and end date, a number as a float, for this asset account. May have rounding errors.', - examples=[123.45], - ) - currency_id: str | None = Field( - None, - description='The currency ID of the expenses listed for this account.', - examples=['5'], - ) - currency_code: str | None = Field( - None, - description='The currency code of the expenses listed for this account.', - examples=['EUR'], - ) + id: str | None = Field( + None, + description='This ID is a reference to the original object.', + examples=['123'], + ) + name: str | None = Field( + None, description='This is the name of the object.', examples=['Land lord'] + ) + difference: str | None = Field( + None, + description='The total amount transferred between start date and end date, a number defined as a string, for this asset account.', + examples=['-123.45'], + ) + difference_float: float | None = Field( + None, + description='The total amount transferred between start date and end date, a number as a float, for this asset account. May have rounding errors.', + examples=[-123.45], + ) + in_: str | None = Field( + None, + alias='in', + description='The total amount transferred TO this account between start date and end date, a number defined as a string, for this asset account.', + examples=['123.45'], + ) + in_float: float | None = Field( + None, + description='The total amount transferred FROM this account between start date and end date, a number as a float, for this asset account. May have rounding errors.', + examples=[123.45], + ) + out: str | None = Field( + None, + description='The total amount transferred FROM this account between start date and end date, a number defined as a string, for this asset account.', + examples=['123.45'], + ) + out_float: float | None = Field( + None, + description='The total amount transferred TO this account between start date and end date, a number as a float, for this asset account. May have rounding errors.', + examples=[123.45], + ) + currency_id: str | None = Field( + None, + description='The currency ID of the expenses listed for this account.', + examples=['5'], + ) + currency_code: str | None = Field( + None, + description='The currency code of the expenses listed for this account.', + examples=['EUR'], + ) class ArrayEntryWithCurrencyAndSum(BaseModel): - currency_id: str | None = Field(None, examples=['5']) - currency_code: str | None = Field(None, examples=['USD']) - currency_symbol: str | None = Field(None, examples=['$']) - currency_decimal_places: int | None = Field( - None, description='Number of decimals supported by the currency', examples=[2] - ) - sum: str | None = Field( - None, - description='The amount earned, spent or transferred.', - examples=['123.45'], - ) + currency_id: str | None = Field(None, examples=['5']) + currency_code: str | None = Field(None, examples=['USD']) + currency_symbol: str | None = Field(None, examples=['$']) + currency_decimal_places: int | None = Field( + None, description='Number of decimals supported by the currency', examples=[2] + ) + sum: str | None = Field( + None, + description='The amount earned, spent or transferred.', + examples=['123.45'], + ) class AttachmentUpdate(BaseModel): - filename: str | None = Field(None, examples=['file.pdf']) - title: str | None = Field(None, examples=['Some PDF file']) - notes: str | None = Field(None, examples=['Some notes']) + filename: str | None = Field(None, examples=['file.pdf']) + title: str | None = Field(None, examples=['Some PDF file']) + notes: str | None = Field(None, examples=['Some notes']) class AvailableBudgetProperties(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - object_has_currency_setting: bool | None = Field( - None, - description="Indicates whether the object has a currency setting. If false, the object uses the administration's primary currency.", - examples=[True], - ) - currency_id: str | None = Field( - None, - description='The currency ID of the currency associated with this object.', - examples=['5'], - ) - currency_name: str | None = Field( - None, - description='The currency name of the currency associated with this object.', - examples=['Euro'], - ) - currency_code: str | None = Field( - None, - description='The currency code of the currency associated with this object.', - examples=['EUR'], - ) - currency_symbol: str | None = Field(None, examples=['$']) - currency_decimal_places: int | None = Field(None, examples=[2]) - primary_currency_id: str | None = Field( - None, - description="The currency ID of the administration's primary currency.", - examples=['5'], - ) - primary_currency_name: str | None = Field( - None, - description="The currency name of the administration's primary currency.", - examples=['Euro'], - ) - primary_currency_code: str | None = Field( - None, - description="The currency code of the administration's primary currency.", - examples=['EUR'], - ) - primary_currency_symbol: str | None = Field( - None, - description="The currency symbol of the administration's primary currency.", - examples=['$'], - ) - primary_currency_decimal_places: int | None = Field( - None, - description="The currency decimal places of the administration's primary currency.", - examples=[2], - ) - amount: str | None = Field( - None, - description='The amount of this available budget in the currency of this available budget.', - examples=['123.45'], - ) - pc_amount: str | None = Field( - None, - description='The amount of this available budget in the primary currency (pc) of this administration.', - examples=['123.45'], - ) - start: AwareDatetime | None = Field( - None, - description='Start date of the available budget.', - examples=['2025-12-01T00:00:00+00:00'], - ) - end: AwareDatetime | None = Field( - None, - description='End date of the available budget.', - examples=['2025-12-31T23:59:59+00:00'], - ) - spent_in_budgets: list[ArrayEntryWithCurrencyAndSum] | None = None - pc_spent_in_budgets: list[ArrayEntryWithCurrencyAndSum] | None = Field( - None, - description='The amount spent in budgets in the primary currency (pc) of this administration.\n', - ) - spent_outside_budgets: list[ArrayEntryWithCurrencyAndSum] | None = None - pc_spent_outside_budgets: list[ArrayEntryWithCurrencyAndSum] | None = Field( - None, - description='The amount spent outside of budgets in the primary currency (pc) of this administration.\n', - ) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + object_has_currency_setting: bool | None = Field( + None, + description="Indicates whether the object has a currency setting. If false, the object uses the administration's primary currency.", + examples=[True], + ) + currency_id: str | None = Field( + None, + description='The currency ID of the currency associated with this object.', + examples=['5'], + ) + currency_name: str | None = Field( + None, + description='The currency name of the currency associated with this object.', + examples=['Euro'], + ) + currency_code: str | None = Field( + None, + description='The currency code of the currency associated with this object.', + examples=['EUR'], + ) + currency_symbol: str | None = Field(None, examples=['$']) + currency_decimal_places: int | None = Field(None, examples=[2]) + primary_currency_id: str | None = Field( + None, + description="The currency ID of the administration's primary currency.", + examples=['5'], + ) + primary_currency_name: str | None = Field( + None, + description="The currency name of the administration's primary currency.", + examples=['Euro'], + ) + primary_currency_code: str | None = Field( + None, + description="The currency code of the administration's primary currency.", + examples=['EUR'], + ) + primary_currency_symbol: str | None = Field( + None, + description="The currency symbol of the administration's primary currency.", + examples=['$'], + ) + primary_currency_decimal_places: int | None = Field( + None, + description="The currency decimal places of the administration's primary currency.", + examples=[2], + ) + amount: str | None = Field( + None, + description='The amount of this available budget in the currency of this available budget.', + examples=['123.45'], + ) + pc_amount: str | None = Field( + None, + description='The amount of this available budget in the primary currency (pc) of this administration.', + examples=['123.45'], + ) + start: AwareDatetime | None = Field( + None, + description='Start date of the available budget.', + examples=['2025-12-01T00:00:00+00:00'], + ) + end: AwareDatetime | None = Field( + None, + description='End date of the available budget.', + examples=['2025-12-31T23:59:59+00:00'], + ) + spent_in_budgets: list[ArrayEntryWithCurrencyAndSum] | None = None + pc_spent_in_budgets: list[ArrayEntryWithCurrencyAndSum] | None = Field( + None, + description='The amount spent in budgets in the primary currency (pc) of this administration.\n', + ) + spent_outside_budgets: list[ArrayEntryWithCurrencyAndSum] | None = None + pc_spent_outside_budgets: list[ArrayEntryWithCurrencyAndSum] | None = Field( + None, + description='The amount spent outside of budgets in the primary currency (pc) of this administration.\n', + ) class AvailableBudgetRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['available_budgets']) - id: str = Field(..., examples=['2']) - attributes: AvailableBudgetProperties + type: str = Field(..., description='Immutable value', examples=['available_budgets']) + id: str = Field(..., examples=['2']) + attributes: AvailableBudgetProperties class AvailableBudgetSingle(BaseModel): - data: AvailableBudgetRead + data: AvailableBudgetRead class PaidDate(BaseModel): - transaction_group_id: str | None = Field( - None, - description='Transaction group ID of the transaction linked to this subscription.', - examples=['123'], - ) - transaction_journal_id: str | None = Field( - None, - description='Transaction journal ID of the transaction linked to this subscription.', - examples=['123'], - ) - date: AwareDatetime | None = Field( - None, - description='Date the bill was paid.', - examples=['2025-12-01T00:00:00+00:00'], - ) - subscription_id: str | None = Field( - None, description='ID of this subscription.', examples=['123'] - ) - currency_id: str | None = Field( - None, - description='The currency ID of the currency associated with this object.', - examples=['5'], - ) - currency_name: str | None = Field( - None, - description='The currency name of the currency associated with this object.', - examples=['Euro'], - ) - currency_code: str | None = Field( - None, - description='The currency code of the currency associated with this object.', - examples=['EUR'], - ) - currency_symbol: str | None = Field(None, examples=['$']) - currency_decimal_places: int | None = Field(None, examples=[2]) - primary_currency_id: str | None = Field( - None, - description="The currency ID of the administration's primary currency.", - examples=['5'], - ) - primary_currency_name: str | None = Field( - None, - description="The currency name of the administration's primary currency.", - examples=['Euro'], - ) - primary_currency_code: str | None = Field( - None, - description="The currency code of the administration's primary currency.", - examples=['EUR'], - ) - primary_currency_symbol: str | None = Field( - None, - description="The currency symbol of the administration's primary currency.", - examples=['$'], - ) - primary_currency_decimal_places: int | None = Field( - None, - description="The currency decimal places of the administration's primary currency.", - examples=[2], - ) - amount: str | None = Field( - None, - description="The amount that was paid for this subscription in the subscription's currency.", - examples=['123.45'], - ) - pc_amount: str | None = Field( - None, - description="The amount that was paid for this subscription in the administration's primary currency.", - examples=['123.45'], - ) - foreign_amount: str | None = Field( - None, - description="The foreign amount that was paid for this subscription in the subscription's currency.", - examples=['123.45'], - ) - pc_foreign_amount: str | None = Field( - None, - description="The foreign amount that was paid for this subscription in the administration's primary currency.", - examples=['123.45'], - ) + transaction_group_id: str | None = Field( + None, + description='Transaction group ID of the transaction linked to this subscription.', + examples=['123'], + ) + transaction_journal_id: str | None = Field( + None, + description='Transaction journal ID of the transaction linked to this subscription.', + examples=['123'], + ) + date: AwareDatetime | None = Field( + None, + description='Date the bill was paid.', + examples=['2025-12-01T00:00:00+00:00'], + ) + subscription_id: str | None = Field( + None, description='ID of this subscription.', examples=['123'] + ) + currency_id: str | None = Field( + None, + description='The currency ID of the currency associated with this object.', + examples=['5'], + ) + currency_name: str | None = Field( + None, + description='The currency name of the currency associated with this object.', + examples=['Euro'], + ) + currency_code: str | None = Field( + None, + description='The currency code of the currency associated with this object.', + examples=['EUR'], + ) + currency_symbol: str | None = Field(None, examples=['$']) + currency_decimal_places: int | None = Field(None, examples=[2]) + primary_currency_id: str | None = Field( + None, + description="The currency ID of the administration's primary currency.", + examples=['5'], + ) + primary_currency_name: str | None = Field( + None, + description="The currency name of the administration's primary currency.", + examples=['Euro'], + ) + primary_currency_code: str | None = Field( + None, + description="The currency code of the administration's primary currency.", + examples=['EUR'], + ) + primary_currency_symbol: str | None = Field( + None, + description="The currency symbol of the administration's primary currency.", + examples=['$'], + ) + primary_currency_decimal_places: int | None = Field( + None, + description="The currency decimal places of the administration's primary currency.", + examples=[2], + ) + amount: str | None = Field( + None, + description="The amount that was paid for this subscription in the subscription's currency.", + examples=['123.45'], + ) + pc_amount: str | None = Field( + None, + description="The amount that was paid for this subscription in the administration's primary currency.", + examples=['123.45'], + ) + foreign_amount: str | None = Field( + None, + description="The foreign amount that was paid for this subscription in the subscription's currency.", + examples=['123.45'], + ) + pc_foreign_amount: str | None = Field( + None, + description="The foreign amount that was paid for this subscription in the administration's primary currency.", + examples=['123.45'], + ) class BudgetLimitProperties(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - start: AwareDatetime | None = Field( - None, - description='Start date of the budget limit.', - examples=['2025-12-01T00:00:00+00:00'], - ) - end: AwareDatetime | None = Field( - None, - description='End date of the budget limit.', - examples=['2025-12-31T23:59:59+00:00'], - ) - budget_id: str | None = Field( - None, description='The budget ID of the associated budget.', examples=['23'] - ) - object_has_currency_setting: bool | None = Field( - None, - description="Indicates whether the object has a currency setting. If false, the object uses the administration's primary currency.", - examples=[True], - ) - currency_id: str | None = Field( - None, - description='The currency ID of the currency associated with this object.', - examples=['5'], - ) - currency_name: str | None = Field( - None, - description='The currency name of the currency associated with this object.', - examples=['Euro'], - ) - currency_code: str | None = Field( - None, - description='The currency code of the currency associated with this object.', - examples=['EUR'], - ) - currency_symbol: str | None = Field(None, examples=['$']) - currency_decimal_places: int | None = Field(None, examples=[2]) - primary_currency_id: str | None = Field( - None, - description="The currency ID of the administration's primary currency.", - examples=['5'], - ) - primary_currency_name: str | None = Field( - None, - description="The currency name of the administration's primary currency.", - examples=['Euro'], - ) - primary_currency_code: str | None = Field( - None, - description="The currency code of the administration's primary currency.", - examples=['EUR'], - ) - primary_currency_symbol: str | None = Field( - None, - description="The currency symbol of the administration's primary currency.", - examples=['$'], - ) - primary_currency_decimal_places: int | None = Field( - None, - description="The currency decimal places of the administration's primary currency.", - examples=[2], - ) - amount: str | None = Field(None, examples=['123.45']) - pc_amount: str | None = Field( - None, - description="The amount of this budget limit in the user's primary currency, if the original amount is in a different currency.", - examples=['123.45'], - ) - period: str | None = Field( - None, - description='Period of the budget limit. Only used when auto-generated by auto-budget.', - examples=['monthly'], - ) - spent: list[ArrayEntryWithCurrencyAndSum] | None = Field( - None, - description='Amount(s) spent in the currencies in the database for this budget limit.', - ) - pc_spent: list[ArrayEntryWithCurrencyAndSum] | None = Field( - None, - description='Amount(s) spent in the primary currency in the database for this budget limit.', - ) - notes: str | None = Field( - None, - description='Some notes for this specific budget limit.', - examples=['Some example notes'], - ) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + start: AwareDatetime | None = Field( + None, + description='Start date of the budget limit.', + examples=['2025-12-01T00:00:00+00:00'], + ) + end: AwareDatetime | None = Field( + None, + description='End date of the budget limit.', + examples=['2025-12-31T23:59:59+00:00'], + ) + budget_id: str | None = Field( + None, description='The budget ID of the associated budget.', examples=['23'] + ) + object_has_currency_setting: bool | None = Field( + None, + description="Indicates whether the object has a currency setting. If false, the object uses the administration's primary currency.", + examples=[True], + ) + currency_id: str | None = Field( + None, + description='The currency ID of the currency associated with this object.', + examples=['5'], + ) + currency_name: str | None = Field( + None, + description='The currency name of the currency associated with this object.', + examples=['Euro'], + ) + currency_code: str | None = Field( + None, + description='The currency code of the currency associated with this object.', + examples=['EUR'], + ) + currency_symbol: str | None = Field(None, examples=['$']) + currency_decimal_places: int | None = Field(None, examples=[2]) + primary_currency_id: str | None = Field( + None, + description="The currency ID of the administration's primary currency.", + examples=['5'], + ) + primary_currency_name: str | None = Field( + None, + description="The currency name of the administration's primary currency.", + examples=['Euro'], + ) + primary_currency_code: str | None = Field( + None, + description="The currency code of the administration's primary currency.", + examples=['EUR'], + ) + primary_currency_symbol: str | None = Field( + None, + description="The currency symbol of the administration's primary currency.", + examples=['$'], + ) + primary_currency_decimal_places: int | None = Field( + None, + description="The currency decimal places of the administration's primary currency.", + examples=[2], + ) + amount: str | None = Field(None, examples=['123.45']) + pc_amount: str | None = Field( + None, + description="The amount of this budget limit in the user's primary currency, if the original amount is in a different currency.", + examples=['123.45'], + ) + period: str | None = Field( + None, + description='Period of the budget limit. Only used when auto-generated by auto-budget.', + examples=['monthly'], + ) + spent: list[ArrayEntryWithCurrencyAndSum] | None = Field( + None, + description='Amount(s) spent in the currencies in the database for this budget limit.', + ) + pc_spent: list[ArrayEntryWithCurrencyAndSum] | None = Field( + None, + description='Amount(s) spent in the primary currency in the database for this budget limit.', + ) + notes: str | None = Field( + None, + description='Some notes for this specific budget limit.', + examples=['Some example notes'], + ) class BudgetLimitRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['budget_limits']) - id: str = Field(..., examples=['2']) - attributes: BudgetLimitProperties + type: str = Field(..., description='Immutable value', examples=['budget_limits']) + id: str = Field(..., examples=['2']) + attributes: BudgetLimitProperties class BudgetLimitSingle(BaseModel): - data: BudgetLimitRead + data: BudgetLimitRead class BudgetLimitStore(BaseModel): - currency_id: str | None = Field( - None, - description="Use either currency_id or currency_code. Defaults to the user's primary currency.", - examples=['5'], - ) - currency_code: str | None = Field( - None, - description="Use either currency_id or currency_code. Defaults to the user's primary currency.", - examples=['EUR'], - ) - budget_id: str = Field( - ..., description='The budget ID of the associated budget.', examples=['23'] - ) - start: date_aliased = Field( - ..., description='Start date of the budget limit.', examples=['2025-12-01'] - ) - period: str | None = Field( - None, - description='Period of the budget limit. Only used when auto-generated by auto-budget.', - examples=['monthly'], - ) - end: date_aliased = Field( - ..., description='End date of the budget limit.', examples=['2025-12-31'] - ) - amount: str = Field(..., examples=['123.45']) - notes: str | None = Field( - None, - description='Some notes for this specific budget limit.', - examples=['Some example notes'], - ) - fire_webhooks: bool | None = Field( - True, - description='Whether or not to fire the webhooks that are related to this event.', - examples=[True], - ) + currency_id: str | None = Field( + None, + description="Use either currency_id or currency_code. Defaults to the user's primary currency.", + examples=['5'], + ) + currency_code: str | None = Field( + None, + description="Use either currency_id or currency_code. Defaults to the user's primary currency.", + examples=['EUR'], + ) + budget_id: str = Field( + ..., description='The budget ID of the associated budget.', examples=['23'] + ) + start: date_aliased = Field( + ..., description='Start date of the budget limit.', examples=['2025-12-01'] + ) + period: str | None = Field( + None, + description='Period of the budget limit. Only used when auto-generated by auto-budget.', + examples=['monthly'], + ) + end: date_aliased = Field( + ..., description='End date of the budget limit.', examples=['2025-12-31'] + ) + amount: str = Field(..., examples=['123.45']) + notes: str | None = Field( + None, + description='Some notes for this specific budget limit.', + examples=['Some example notes'], + ) + fire_webhooks: bool | None = Field( + True, + description='Whether or not to fire the webhooks that are related to this event.', + examples=[True], + ) class BudgetLimitUpdate(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - start: AwareDatetime | None = Field( - None, - description='Start date of the budget limit.', - examples=['2025-12-01T00:00:00+00:00'], - ) - end: AwareDatetime | None = Field( - None, - description='End date of the budget limit.', - examples=['2025-12-31T23:59:59+00:00'], - ) - budget_id: str | None = Field( - None, description='The budget ID of the associated budget.', examples=['23'] - ) - object_has_currency_setting: bool | None = Field( - None, - description="Indicates whether the object has a currency setting. If false, the object uses the administration's primary currency.", - examples=[True], - ) - currency_id: str | None = Field( - None, - description='The currency ID of the currency associated with this object.', - examples=['5'], - ) - currency_name: str | None = Field( - None, - description='The currency name of the currency associated with this object.', - examples=['Euro'], - ) - currency_code: str | None = Field( - None, - description='The currency code of the currency associated with this object.', - examples=['EUR'], - ) - currency_symbol: str | None = Field(None, examples=['$']) - currency_decimal_places: int | None = Field(None, examples=[2]) - primary_currency_id: str | None = Field( - None, - description="The currency ID of the administration's primary currency.", - examples=['5'], - ) - primary_currency_name: str | None = Field( - None, - description="The currency name of the administration's primary currency.", - examples=['Euro'], - ) - primary_currency_code: str | None = Field( - None, - description="The currency code of the administration's primary currency.", - examples=['EUR'], - ) - primary_currency_symbol: str | None = Field( - None, - description="The currency symbol of the administration's primary currency.", - examples=['$'], - ) - primary_currency_decimal_places: int | None = Field( - None, - description="The currency decimal places of the administration's primary currency.", - examples=[2], - ) - period: str | None = Field( - None, - description='Period of the budget limit. Only used when auto-generated by auto-budget.', - examples=['monthly'], - ) - amount: str | None = Field(None, examples=['123.45']) - pc_amount: str | None = Field( - None, - description="The amount of this budget limit in the user's primary currency, if the original amount is in a different currency.", - examples=['123.45'], - ) - spent: str | None = Field( - None, - description='Will be in the primary currency if this is turned on by the user.', - examples=['-1012.12'], - ) - notes: str | None = Field( - None, - description='Some notes for this specific budget limit.', - examples=['Some example notes'], - ) - fire_webhooks: bool | None = Field( - True, - description='Whether or not to fire the webhooks that are related to this event.', - examples=[True], - ) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + start: AwareDatetime | None = Field( + None, + description='Start date of the budget limit.', + examples=['2025-12-01T00:00:00+00:00'], + ) + end: AwareDatetime | None = Field( + None, + description='End date of the budget limit.', + examples=['2025-12-31T23:59:59+00:00'], + ) + budget_id: str | None = Field( + None, description='The budget ID of the associated budget.', examples=['23'] + ) + object_has_currency_setting: bool | None = Field( + None, + description="Indicates whether the object has a currency setting. If false, the object uses the administration's primary currency.", + examples=[True], + ) + currency_id: str | None = Field( + None, + description='The currency ID of the currency associated with this object.', + examples=['5'], + ) + currency_name: str | None = Field( + None, + description='The currency name of the currency associated with this object.', + examples=['Euro'], + ) + currency_code: str | None = Field( + None, + description='The currency code of the currency associated with this object.', + examples=['EUR'], + ) + currency_symbol: str | None = Field(None, examples=['$']) + currency_decimal_places: int | None = Field(None, examples=[2]) + primary_currency_id: str | None = Field( + None, + description="The currency ID of the administration's primary currency.", + examples=['5'], + ) + primary_currency_name: str | None = Field( + None, + description="The currency name of the administration's primary currency.", + examples=['Euro'], + ) + primary_currency_code: str | None = Field( + None, + description="The currency code of the administration's primary currency.", + examples=['EUR'], + ) + primary_currency_symbol: str | None = Field( + None, + description="The currency symbol of the administration's primary currency.", + examples=['$'], + ) + primary_currency_decimal_places: int | None = Field( + None, + description="The currency decimal places of the administration's primary currency.", + examples=[2], + ) + period: str | None = Field( + None, + description='Period of the budget limit. Only used when auto-generated by auto-budget.', + examples=['monthly'], + ) + amount: str | None = Field(None, examples=['123.45']) + pc_amount: str | None = Field( + None, + description="The amount of this budget limit in the user's primary currency, if the original amount is in a different currency.", + examples=['123.45'], + ) + spent: str | None = Field( + None, + description='Will be in the primary currency if this is turned on by the user.', + examples=['-1012.12'], + ) + notes: str | None = Field( + None, + description='Some notes for this specific budget limit.', + examples=['Some example notes'], + ) + fire_webhooks: bool | None = Field( + True, + description='Whether or not to fire the webhooks that are related to this event.', + examples=[True], + ) class CategoryProperties(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - name: str = Field(..., examples=['Lunch']) - notes: str | None = Field(None, examples=['Some example notes']) - object_has_currency_setting: bool | None = Field( - None, - description='This object never has its own currency setting, so this value is always false.', - examples=[False], - ) - primary_currency_id: str | None = Field( - None, - description="The currency ID of the administration's primary currency.", - examples=['5'], - ) - primary_currency_name: str | None = Field( - None, - description="The currency name of the administration's primary currency.", - examples=['Euro'], - ) - primary_currency_code: str | None = Field( - None, - description="The currency code of the administration's primary currency.", - examples=['EUR'], - ) - primary_currency_symbol: str | None = Field( - None, - description="The currency symbol of the administration's primary currency.", - examples=['$'], - ) - primary_currency_decimal_places: int | None = Field( - None, - description="The currency decimal places of the administration's primary currency.", - examples=[2], - ) - spent: list[ArrayEntryWithCurrencyAndSum] | None = Field( - None, - description='Amount(s) spent in the currencies in the database for this category. ONLY present when start and date are set.', - ) - pc_spent: list[ArrayEntryWithCurrencyAndSum] | None = Field( - None, - description='Amount(s) spent in the primary currency in the database for this category. ONLY present when start and date are set. ', - ) - earned: list[ArrayEntryWithCurrencyAndSum] | None = Field( - None, - description='Amount(s) earned in the currencies in the database for this category. ONLY present when start and date are set.', - ) - pc_earned: list[ArrayEntryWithCurrencyAndSum] | None = Field( - None, - description='Amount(s) earned in the primary currency in the database for this category. ONLY present when start and date are set. ', - ) - transferred: list[ArrayEntryWithCurrencyAndSum] | None = Field( - None, - description='Amount(s) transferred in the currencies in the database for this category. ONLY present when start and date are set. ', - ) - pc_transferred: list[ArrayEntryWithCurrencyAndSum] | None = Field( - None, - description='Amount(s) transferred in primary currency in the database for this category. ONLY present when start and date are set. ', - ) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + name: str = Field(..., examples=['Lunch']) + notes: str | None = Field(None, examples=['Some example notes']) + object_has_currency_setting: bool | None = Field( + None, + description='This object never has its own currency setting, so this value is always false.', + examples=[False], + ) + primary_currency_id: str | None = Field( + None, + description="The currency ID of the administration's primary currency.", + examples=['5'], + ) + primary_currency_name: str | None = Field( + None, + description="The currency name of the administration's primary currency.", + examples=['Euro'], + ) + primary_currency_code: str | None = Field( + None, + description="The currency code of the administration's primary currency.", + examples=['EUR'], + ) + primary_currency_symbol: str | None = Field( + None, + description="The currency symbol of the administration's primary currency.", + examples=['$'], + ) + primary_currency_decimal_places: int | None = Field( + None, + description="The currency decimal places of the administration's primary currency.", + examples=[2], + ) + spent: list[ArrayEntryWithCurrencyAndSum] | None = Field( + None, + description='Amount(s) spent in the currencies in the database for this category. ONLY present when start and date are set.', + ) + pc_spent: list[ArrayEntryWithCurrencyAndSum] | None = Field( + None, + description='Amount(s) spent in the primary currency in the database for this category. ONLY present when start and date are set. ', + ) + earned: list[ArrayEntryWithCurrencyAndSum] | None = Field( + None, + description='Amount(s) earned in the currencies in the database for this category. ONLY present when start and date are set.', + ) + pc_earned: list[ArrayEntryWithCurrencyAndSum] | None = Field( + None, + description='Amount(s) earned in the primary currency in the database for this category. ONLY present when start and date are set. ', + ) + transferred: list[ArrayEntryWithCurrencyAndSum] | None = Field( + None, + description='Amount(s) transferred in the currencies in the database for this category. ONLY present when start and date are set. ', + ) + pc_transferred: list[ArrayEntryWithCurrencyAndSum] | None = Field( + None, + description='Amount(s) transferred in primary currency in the database for this category. ONLY present when start and date are set. ', + ) class CategoryStore(BaseModel): - name: str = Field(..., examples=['Lunch']) - notes: str | None = Field(None, examples=['Some example notes']) + name: str = Field(..., examples=['Lunch']) + notes: str | None = Field(None, examples=['Some example notes']) class CategoryUpdate(BaseModel): - name: str = Field(..., examples=['Lunch']) - notes: str | None = Field(None, examples=['Some example notes']) + name: str = Field(..., examples=['Lunch']) + notes: str | None = Field(None, examples=['Some example notes']) class CurrencyExchangeProperties(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - from_currency_id: str | None = Field( - None, - description='Base currency ID for this exchange rate entry.', - examples=['12'], - ) - from_currency_name: str | None = Field( - None, - description='Base currency name for this exchange rate entry.', - examples=['Euro'], - ) - from_currency_code: str | None = Field( - None, - description='Base currency code for this exchange rate entry.', - examples=['EUR'], - ) - from_currency_symbol: str | None = Field( - None, - description='Base currency symbol for this exchange rate entry.', - examples=['$'], - ) - from_currency_decimal_places: int | None = Field( - None, - description='Base currency decimal places for this exchange rate entry.', - examples=[2], - ) - to_currency_id: str | None = Field( - None, - description='Destination currency ID for this exchange rate entry.', - examples=['12'], - ) - to_currency_name: str | None = Field( - None, - description='Destination currency name for this exchange rate entry.', - examples=['EUR'], - ) - to_currency_code: str | None = Field( - None, - description='Destination currency code for this exchange rate entry.', - examples=['EUR'], - ) - to_currency_symbol: str | None = Field( - None, - description='Destination currency symbol for this exchange rate entry.', - examples=['$'], - ) - to_currency_decimal_places: int | None = Field( - None, - description='Destination currency decimal places for this exchange rate entry.', - examples=[2], - ) - rate: str | None = Field( - None, - description="The actual exchange rate. How many 'to' currency will you get for 1 'from' currency?", - examples=['1.10340'], - ) - date: AwareDatetime | None = Field( - None, - description='Date and time of the exchange rate.', - examples=['2025-12-01T00:00:00+00:00'], - ) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + from_currency_id: str | None = Field( + None, + description='Base currency ID for this exchange rate entry.', + examples=['12'], + ) + from_currency_name: str | None = Field( + None, + description='Base currency name for this exchange rate entry.', + examples=['Euro'], + ) + from_currency_code: str | None = Field( + None, + description='Base currency code for this exchange rate entry.', + examples=['EUR'], + ) + from_currency_symbol: str | None = Field( + None, + description='Base currency symbol for this exchange rate entry.', + examples=['$'], + ) + from_currency_decimal_places: int | None = Field( + None, + description='Base currency decimal places for this exchange rate entry.', + examples=[2], + ) + to_currency_id: str | None = Field( + None, + description='Destination currency ID for this exchange rate entry.', + examples=['12'], + ) + to_currency_name: str | None = Field( + None, + description='Destination currency name for this exchange rate entry.', + examples=['EUR'], + ) + to_currency_code: str | None = Field( + None, + description='Destination currency code for this exchange rate entry.', + examples=['EUR'], + ) + to_currency_symbol: str | None = Field( + None, + description='Destination currency symbol for this exchange rate entry.', + examples=['$'], + ) + to_currency_decimal_places: int | None = Field( + None, + description='Destination currency decimal places for this exchange rate entry.', + examples=[2], + ) + rate: str | None = Field( + None, + description="The actual exchange rate. How many 'to' currency will you get for 1 'from' currency?", + examples=['1.10340'], + ) + date: AwareDatetime | None = Field( + None, + description='Date and time of the exchange rate.', + examples=['2025-12-01T00:00:00+00:00'], + ) class CurrencyExchangeRateStore(BaseModel): - date: date_aliased = Field( - ..., - description='The date to which the exchange rate is applicable.', - examples=['2025-12-01'], - ) - from_: str = Field(..., alias='from', description='The base currency code.', examples=['USD']) - to: str = Field(..., description='The destination currency code.', examples=['EUR']) - rate: str | None = Field( - None, - description='The exchange rate from the base currency to the destination currency.', - examples=['2.3456'], - ) + date: date_aliased = Field( + ..., + description='The date to which the exchange rate is applicable.', + examples=['2025-12-01'], + ) + from_: str = Field(..., alias='from', description='The base currency code.', examples=['USD']) + to: str = Field(..., description='The destination currency code.', examples=['EUR']) + rate: str | None = Field( + None, + description='The exchange rate from the base currency to the destination currency.', + examples=['2.3456'], + ) class CurrencyExchangeRateStoreByDate(BaseModel): - from_: str = Field(..., alias='from', description="The 'from'-currency", examples=['EUR']) - rates: dict[str, str] = Field( - ..., - description="The actual entries for this data set. They 'key' value is 'to' currency. The value is the exchange rate.", - examples=[{'USD': '1.2345', 'GBP': '6.3456'}], - ) + from_: str = Field(..., alias='from', description="The 'from'-currency", examples=['EUR']) + rates: dict[str, str] = Field( + ..., + description="The actual entries for this data set. They 'key' value is 'to' currency. The value is the exchange rate.", + examples=[{'USD': '1.2345', 'GBP': '6.3456'}], + ) class CurrencyExchangeRateStoreByPair(RootModel[dict[str, str]]): - root: dict[str, str] + root: dict[str, str] class CurrencyExchangeRateUpdate(BaseModel): - date: date_aliased = Field( - ..., - description='The date to which the exchange rate is applicable.', - examples=['2025-12-01'], - ) - rate: str = Field( - ..., - description='The exchange rate from the base currency to the destination currency.', - examples=['2.3456'], - ) - from_: str | None = Field( - None, alias='from', description='The base currency code.', examples=['USD'] - ) - to: str | None = Field(None, description='The destination currency code.', examples=['EUR']) + date: date_aliased = Field( + ..., + description='The date to which the exchange rate is applicable.', + examples=['2025-12-01'], + ) + rate: str = Field( + ..., + description='The exchange rate from the base currency to the destination currency.', + examples=['2.3456'], + ) + from_: str | None = Field( + None, alias='from', description='The base currency code.', examples=['USD'] + ) + to: str | None = Field(None, description='The destination currency code.', examples=['EUR']) class CurrencyExchangeRateUpdateNoDate(BaseModel): - rate: str = Field( - ..., - description='The exchange rate from the base currency to the destination currency.', - examples=['2.3456'], - ) + rate: str = Field( + ..., + description='The exchange rate from the base currency to the destination currency.', + examples=['2.3456'], + ) class ObjectGroup(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - title: str = Field(..., examples=['My object group']) - order: int = Field(..., description='Order of the object group', examples=[1]) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + title: str = Field(..., examples=['My object group']) + order: int = Field(..., description='Order of the object group', examples=[1]) class ObjectGroupUpdate(BaseModel): - title: str = Field(..., examples=['My object group']) - order: int | None = Field(None, description='Order of the object group', examples=[1]) + title: str = Field(..., examples=['My object group']) + order: int | None = Field(None, description='Order of the object group', examples=[1]) class Field0(BaseModel): - rel: str | None = Field(None, examples=['self']) - uri: str | None = Field(None, examples=['/OBJECTS/1']) + rel: str | None = Field(None, examples=['self']) + uri: str | None = Field(None, examples=['/OBJECTS/1']) class ObjectLink(BaseModel): - self: AnyUrl | None = Field(None, examples=['https://demo.firefly-iii.org/api/v1/OBJECTS/1']) - field_0: Field0 | None = Field(None, alias='0') + self: AnyUrl | None = Field(None, examples=['https://demo.firefly-iii.org/api/v1/OBJECTS/1']) + field_0: Field0 | None = Field(None, alias='0') class PageLink(BaseModel): - self: AnyUrl | None = Field( - None, examples=['https://demo.firefly-iii.org/api/v1/OBJECT?&page=4'] - ) - first: AnyUrl | None = Field( - None, examples=['https://demo.firefly-iii.org/api/v1/OBJECT?&page=1'] - ) - next: AnyUrl | None = Field( - None, examples=['https://demo.firefly-iii.org/api/v1/OBJECT?&page=3'] - ) - prev: AnyUrl | None = Field( - None, examples=['https://demo.firefly-iii.org/api/v1/OBJECT?&page=2'] - ) - last: AnyUrl | None = Field( - None, examples=['https://demo.firefly-iii.org/api/v1/OBJECT?&page=12'] - ) + self: AnyUrl | None = Field( + None, examples=['https://demo.firefly-iii.org/api/v1/OBJECT?&page=4'] + ) + first: AnyUrl | None = Field( + None, examples=['https://demo.firefly-iii.org/api/v1/OBJECT?&page=1'] + ) + next: AnyUrl | None = Field( + None, examples=['https://demo.firefly-iii.org/api/v1/OBJECT?&page=3'] + ) + prev: AnyUrl | None = Field( + None, examples=['https://demo.firefly-iii.org/api/v1/OBJECT?&page=2'] + ) + last: AnyUrl | None = Field( + None, examples=['https://demo.firefly-iii.org/api/v1/OBJECT?&page=12'] + ) class PiggyBankAccountRead(BaseModel): - account_id: str | None = Field(None, description='The ID of the account.', examples=['3']) - name: str | None = Field(None, examples=['Checking account']) - current_amount: str | None = Field(None, examples=['123.45']) - pc_current_amount: str | None = Field( - None, - description='If convertToPrimary is on, this will show the amount in the primary currency.', - examples=['123.45'], - ) + account_id: str | None = Field(None, description='The ID of the account.', examples=['3']) + name: str | None = Field(None, examples=['Checking account']) + current_amount: str | None = Field(None, examples=['123.45']) + pc_current_amount: str | None = Field( + None, + description='If convertToPrimary is on, this will show the amount in the primary currency.', + examples=['123.45'], + ) class PiggyBankAccountStore(BaseModel): - id: str = Field(..., description='The ID of the account.', examples=['3']) - name: str | None = Field( - None, description='The name of the account.', examples=['Checking account'] - ) - current_amount: str | None = Field( - None, description='The amount saved currently.', examples=['123.45'] - ) + id: str = Field(..., description='The ID of the account.', examples=['3']) + name: str | None = Field( + None, description='The name of the account.', examples=['Checking account'] + ) + current_amount: str | None = Field( + None, description='The amount saved currently.', examples=['123.45'] + ) class PiggyBankAccountUpdate(BaseModel): - account_id: str | None = Field(None, description='The ID of the account.', examples=['3']) - name: str | None = Field( - None, description='The name of the account.', examples=['Checking account'] - ) - current_amount: str | None = Field( - None, description='The amount saved currently.', examples=['123.45'] - ) + account_id: str | None = Field(None, description='The ID of the account.', examples=['3']) + name: str | None = Field( + None, description='The name of the account.', examples=['Checking account'] + ) + current_amount: str | None = Field( + None, description='The amount saved currently.', examples=['123.45'] + ) class PiggyBankProperties(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - name: str = Field(..., examples=['New digital camera']) - percentage: int | None = Field( - None, - description='The percentage of the target amount that has been saved, if a target amount is set.', - examples=[12], - ) - start_date: AwareDatetime | None = Field( - None, - description='The date you started with this piggy bank.', - examples=['2025-12-01T00:00:00+00:00'], - ) - target_date: AwareDatetime | None = Field( - None, - description='The date you intend to finish saving money.', - examples=['2025-12-01T00:00:00+00:00'], - ) - order: int | None = Field(None, examples=[5]) - active: bool | None = Field(None, examples=[True]) - notes: str | None = Field(None, examples=['Some notes']) - object_group_id: str | None = Field( - None, - description='The group ID of the group this object is part of. NULL if no group.', - examples=['5'], - ) - object_group_order: int | None = Field( - None, - description='The order of the group. At least 1, for the highest sorting.', - examples=[5], - ) - object_group_title: str | None = Field( - None, - description='The name of the group. NULL if no group.', - examples=['Example Group'], - ) - accounts: list[PiggyBankAccountRead] | None = None - object_has_currency_setting: bool | None = Field( - None, - description="Indicates whether the object has a currency setting. If false, the object uses the administration's primary currency.", - examples=[True], - ) - currency_id: str | None = Field( - None, - description='The currency ID of the currency associated with this object.', - examples=['5'], - ) - currency_name: str | None = Field( - None, - description='The currency name of the currency associated with this object.', - examples=['Euro'], - ) - currency_code: str | None = Field( - None, - description='The currency code of the currency associated with this object.', - examples=['EUR'], - ) - currency_symbol: str | None = Field(None, examples=['$']) - currency_decimal_places: int | None = Field(None, examples=[2]) - primary_currency_id: str | None = Field( - None, - description="The currency ID of the administration's primary currency.", - examples=['5'], - ) - primary_currency_name: str | None = Field( - None, - description="The currency name of the administration's primary currency.", - examples=['Euro'], - ) - primary_currency_code: str | None = Field( - None, - description="The currency code of the administration's primary currency.", - examples=['EUR'], - ) - primary_currency_symbol: str | None = Field( - None, - description="The currency symbol of the administration's primary currency.", - examples=['$'], - ) - primary_currency_decimal_places: int | None = Field( - None, - description="The currency decimal places of the administration's primary currency.", - examples=[2], - ) - target_amount: str = Field(..., examples=['123.45']) - pc_target_amount: str | None = Field( - None, - description='The target amount in the primary currency of the administration.', - examples=['123.45'], - ) - current_amount: str | None = Field(None, examples=['123.45']) - pc_current_amount: str | None = Field( - None, - description='The current amount in the primary currency of the administration.', - examples=['123.45'], - ) - left_to_save: str | None = Field(None, examples=['700.00']) - pc_left_to_save: str | None = Field(None, examples=['700.00']) - save_per_month: str | None = Field(None, examples=['12.45']) - pc_save_per_month: str | None = Field(None, examples=['12.45']) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + name: str = Field(..., examples=['New digital camera']) + percentage: int | None = Field( + None, + description='The percentage of the target amount that has been saved, if a target amount is set.', + examples=[12], + ) + start_date: AwareDatetime | None = Field( + None, + description='The date you started with this piggy bank.', + examples=['2025-12-01T00:00:00+00:00'], + ) + target_date: AwareDatetime | None = Field( + None, + description='The date you intend to finish saving money.', + examples=['2025-12-01T00:00:00+00:00'], + ) + order: int | None = Field(None, examples=[5]) + active: bool | None = Field(None, examples=[True]) + notes: str | None = Field(None, examples=['Some notes']) + object_group_id: str | None = Field( + None, + description='The group ID of the group this object is part of. NULL if no group.', + examples=['5'], + ) + object_group_order: int | None = Field( + None, + description='The order of the group. At least 1, for the highest sorting.', + examples=[5], + ) + object_group_title: str | None = Field( + None, + description='The name of the group. NULL if no group.', + examples=['Example Group'], + ) + accounts: list[PiggyBankAccountRead] | None = None + object_has_currency_setting: bool | None = Field( + None, + description="Indicates whether the object has a currency setting. If false, the object uses the administration's primary currency.", + examples=[True], + ) + currency_id: str | None = Field( + None, + description='The currency ID of the currency associated with this object.', + examples=['5'], + ) + currency_name: str | None = Field( + None, + description='The currency name of the currency associated with this object.', + examples=['Euro'], + ) + currency_code: str | None = Field( + None, + description='The currency code of the currency associated with this object.', + examples=['EUR'], + ) + currency_symbol: str | None = Field(None, examples=['$']) + currency_decimal_places: int | None = Field(None, examples=[2]) + primary_currency_id: str | None = Field( + None, + description="The currency ID of the administration's primary currency.", + examples=['5'], + ) + primary_currency_name: str | None = Field( + None, + description="The currency name of the administration's primary currency.", + examples=['Euro'], + ) + primary_currency_code: str | None = Field( + None, + description="The currency code of the administration's primary currency.", + examples=['EUR'], + ) + primary_currency_symbol: str | None = Field( + None, + description="The currency symbol of the administration's primary currency.", + examples=['$'], + ) + primary_currency_decimal_places: int | None = Field( + None, + description="The currency decimal places of the administration's primary currency.", + examples=[2], + ) + target_amount: str = Field(..., examples=['123.45']) + pc_target_amount: str | None = Field( + None, + description='The target amount in the primary currency of the administration.', + examples=['123.45'], + ) + current_amount: str | None = Field(None, examples=['123.45']) + pc_current_amount: str | None = Field( + None, + description='The current amount in the primary currency of the administration.', + examples=['123.45'], + ) + left_to_save: str | None = Field(None, examples=['700.00']) + pc_left_to_save: str | None = Field(None, examples=['700.00']) + save_per_month: str | None = Field(None, examples=['12.45']) + pc_save_per_month: str | None = Field(None, examples=['12.45']) class PiggyBankStore(BaseModel): - name: str = Field(..., examples=['New digital camera']) - accounts: list[PiggyBankAccountStore] | None = None - target_amount: str = Field(..., examples=['123.45']) - current_amount: str | None = Field(None, examples=['123.45']) - start_date: date_aliased = Field( - ..., - description='The date you started with this piggy bank.', - examples=['2025-12-01'], - ) - target_date: date_aliased | None = Field( - None, - description='The date you intend to finish saving money.', - examples=['2025-12-31'], - ) - order: int | None = Field(None, examples=[5]) - active: bool | None = Field(None, examples=[True]) - notes: str | None = Field(None, examples=['Some notes']) - object_group_id: str | None = Field( - None, - description='The group ID of the group this object is part of. NULL if no group.', - examples=['5'], - ) - object_group_title: str | None = Field( - None, - description='The name of the group. NULL if no group.', - examples=['Example Group'], - ) + name: str = Field(..., examples=['New digital camera']) + accounts: list[PiggyBankAccountStore] | None = None + target_amount: str = Field(..., examples=['123.45']) + current_amount: str | None = Field(None, examples=['123.45']) + start_date: date_aliased = Field( + ..., + description='The date you started with this piggy bank.', + examples=['2025-12-01'], + ) + target_date: date_aliased | None = Field( + None, + description='The date you intend to finish saving money.', + examples=['2025-12-31'], + ) + order: int | None = Field(None, examples=[5]) + active: bool | None = Field(None, examples=[True]) + notes: str | None = Field(None, examples=['Some notes']) + object_group_id: str | None = Field( + None, + description='The group ID of the group this object is part of. NULL if no group.', + examples=['5'], + ) + object_group_title: str | None = Field( + None, + description='The name of the group. NULL if no group.', + examples=['Example Group'], + ) class PiggyBankUpdate(BaseModel): - name: str | None = Field(None, examples=['New digital camera']) - accounts: list[PiggyBankAccountUpdate] | None = None - currency_id: str | None = Field(None, examples=['5']) - currency_code: str | None = Field(None, examples=['USD']) - target_amount: str | None = Field(None, examples=['123.45']) - start_date: date_aliased | None = Field( - None, - description='The date you started with this piggy bank.', - examples=['2025-12-01'], - ) - target_date: date_aliased | None = Field( - None, - description='The date you intend to finish saving money.', - examples=['2025-12-31'], - ) - order: int | None = Field(None, examples=[5]) - active: bool | None = Field(None, examples=[True]) - notes: str | None = Field(None, examples=['Some notes']) - object_group_id: str | None = Field( - None, - description='The group ID of the group this object is part of. NULL if no group.', - examples=['5'], - ) - object_group_title: str | None = Field( - None, - description='The name of the group. NULL if no group.', - examples=['Example Group'], - ) + name: str | None = Field(None, examples=['New digital camera']) + accounts: list[PiggyBankAccountUpdate] | None = None + currency_id: str | None = Field(None, examples=['5']) + currency_code: str | None = Field(None, examples=['USD']) + target_amount: str | None = Field(None, examples=['123.45']) + start_date: date_aliased | None = Field( + None, + description='The date you started with this piggy bank.', + examples=['2025-12-01'], + ) + target_date: date_aliased | None = Field( + None, + description='The date you intend to finish saving money.', + examples=['2025-12-31'], + ) + order: int | None = Field(None, examples=[5]) + active: bool | None = Field(None, examples=[True]) + notes: str | None = Field(None, examples=['Some notes']) + object_group_id: str | None = Field( + None, + description='The group ID of the group this object is part of. NULL if no group.', + examples=['5'], + ) + object_group_title: str | None = Field( + None, + description='The name of the group. NULL if no group.', + examples=['Example Group'], + ) class PiggyBankEventProperties(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - amount: str | None = Field(None, examples=['123.45']) - pc_amount: str | None = Field(None, examples=['123.45']) - object_has_currency_setting: bool | None = Field( - None, - description="Indicates whether the object has a currency setting. If false, the object uses the administration's primary currency.", - examples=[True], - ) - currency_id: str | None = Field( - None, - description='The currency ID of the currency associated with this object.', - examples=['5'], - ) - currency_name: str | None = Field( - None, - description='The currency name of the currency associated with this object.', - examples=['Euro'], - ) - currency_code: str | None = Field( - None, - description='The currency code of the currency associated with this object.', - examples=['EUR'], - ) - currency_symbol: str | None = Field(None, examples=['$']) - currency_decimal_places: int | None = Field(None, examples=[2]) - primary_currency_id: str | None = Field( - None, - description="The currency ID of the administration's primary currency.", - examples=['5'], - ) - primary_currency_name: str | None = Field( - None, - description="The currency name of the administration's primary currency.", - examples=['Euro'], - ) - primary_currency_code: str | None = Field( - None, - description="The currency code of the administration's primary currency.", - examples=['EUR'], - ) - primary_currency_symbol: str | None = Field( - None, - description="The currency symbol of the administration's primary currency.", - examples=['$'], - ) - primary_currency_decimal_places: int | None = Field( - None, - description="The currency decimal places of the administration's primary currency.", - examples=[2], - ) - transaction_journal_id: str | None = Field( - None, description='The journal associated with the event.', examples=['4291'] - ) - transaction_group_id: str | None = Field( - None, - description='The transaction group associated with the event.', - examples=['4291'], - ) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + amount: str | None = Field(None, examples=['123.45']) + pc_amount: str | None = Field(None, examples=['123.45']) + object_has_currency_setting: bool | None = Field( + None, + description="Indicates whether the object has a currency setting. If false, the object uses the administration's primary currency.", + examples=[True], + ) + currency_id: str | None = Field( + None, + description='The currency ID of the currency associated with this object.', + examples=['5'], + ) + currency_name: str | None = Field( + None, + description='The currency name of the currency associated with this object.', + examples=['Euro'], + ) + currency_code: str | None = Field( + None, + description='The currency code of the currency associated with this object.', + examples=['EUR'], + ) + currency_symbol: str | None = Field(None, examples=['$']) + currency_decimal_places: int | None = Field(None, examples=[2]) + primary_currency_id: str | None = Field( + None, + description="The currency ID of the administration's primary currency.", + examples=['5'], + ) + primary_currency_name: str | None = Field( + None, + description="The currency name of the administration's primary currency.", + examples=['Euro'], + ) + primary_currency_code: str | None = Field( + None, + description="The currency code of the administration's primary currency.", + examples=['EUR'], + ) + primary_currency_symbol: str | None = Field( + None, + description="The currency symbol of the administration's primary currency.", + examples=['$'], + ) + primary_currency_decimal_places: int | None = Field( + None, + description="The currency decimal places of the administration's primary currency.", + examples=[2], + ) + transaction_journal_id: str | None = Field( + None, description='The journal associated with the event.', examples=['4291'] + ) + transaction_group_id: str | None = Field( + None, + description='The transaction group associated with the event.', + examples=['4291'], + ) class RecurrenceTransactionStore(BaseModel): - description: str = Field(..., examples=['Rent for the current month']) - amount: str = Field(..., description='Amount of the transaction.', examples=['123.45']) - foreign_amount: str | None = Field( - None, description='Foreign amount of the transaction.', examples=['123.45'] - ) - currency_id: str | None = Field( - None, - description='Submit either a currency_id or a currency_code.', - examples=['3'], - ) - currency_code: str | None = Field( - None, - description='Submit either a currency_id or a currency_code.', - examples=['EUR'], - ) - foreign_currency_id: str | None = Field( - None, - description='Submit either a foreign_currency_id or a foreign_currency_code, or neither.', - examples=['17'], - ) - foreign_currency_code: str | None = Field( - None, - description='Submit either a foreign_currency_id or a foreign_currency_code, or neither.', - examples=['GBP'], - ) - budget_id: str | None = Field( - None, description='The budget ID for this transaction.', examples=['4'] - ) - category_id: str | None = Field( - None, description='Category ID for this transaction.', examples=['211'] - ) - source_id: str = Field(..., description='ID of the source account.', examples=['913']) - destination_id: str = Field(..., description='ID of the destination account.', examples=['258']) - tags: list[str] | None = Field(None, description='Array of tags.', examples=[None]) - piggy_bank_id: str | None = Field(None, description='Optional.', examples=['123']) - bill_id: str | None = Field(None, description='Optional.', examples=['123']) + description: str = Field(..., examples=['Rent for the current month']) + amount: str = Field(..., description='Amount of the transaction.', examples=['123.45']) + foreign_amount: str | None = Field( + None, description='Foreign amount of the transaction.', examples=['123.45'] + ) + currency_id: str | None = Field( + None, + description='Submit either a currency_id or a currency_code.', + examples=['3'], + ) + currency_code: str | None = Field( + None, + description='Submit either a currency_id or a currency_code.', + examples=['EUR'], + ) + foreign_currency_id: str | None = Field( + None, + description='Submit either a foreign_currency_id or a foreign_currency_code, or neither.', + examples=['17'], + ) + foreign_currency_code: str | None = Field( + None, + description='Submit either a foreign_currency_id or a foreign_currency_code, or neither.', + examples=['GBP'], + ) + budget_id: str | None = Field( + None, description='The budget ID for this transaction.', examples=['4'] + ) + category_id: str | None = Field( + None, description='Category ID for this transaction.', examples=['211'] + ) + source_id: str = Field(..., description='ID of the source account.', examples=['913']) + destination_id: str = Field(..., description='ID of the destination account.', examples=['258']) + tags: list[str] | None = Field(None, description='Array of tags.', examples=[None]) + piggy_bank_id: str | None = Field(None, description='Optional.', examples=['123']) + bill_id: str | None = Field(None, description='Optional.', examples=['123']) class RecurrenceTransactionUpdate(BaseModel): - id: str = Field( - ..., - examples=[ - 'ID of the recurring transaction. Not to be confused with the ID of the recurrence itself. Is marked as REQUIRED but can be skipped when there is only ONE transaction.' - ], - ) - description: str | None = Field(None, examples=['Rent for the current month']) - amount: str | None = Field(None, description='Amount of the transaction.', examples=['123.45']) - foreign_amount: str | None = Field( - None, description='Foreign amount of the transaction.', examples=['123.45'] - ) - currency_id: str | None = Field( - None, - description='Submit either a currency_id or a currency_code.', - examples=['3'], - ) - currency_code: str | None = Field( - None, - description='Submit either a currency_id or a currency_code.', - examples=['EUR'], - ) - foreign_currency_id: str | None = Field( - None, - description='Submit either a foreign_currency_id or a foreign_currency_code, or neither.', - examples=['17'], - ) - budget_id: str | None = Field( - None, description='The budget ID for this transaction.', examples=['4'] - ) - category_id: str | None = Field( - None, description='Category ID for this transaction.', examples=['211'] - ) - source_id: str | None = Field( - None, - description='ID of the source account. Submit either this or source_name.', - examples=['913'], - ) - destination_id: str | None = Field( - None, - description='ID of the destination account. Submit either this or destination_name.', - examples=['258'], - ) - tags: list[str] | None = Field(None, description='Array of tags.', examples=[None]) - piggy_bank_id: str | None = Field(None, examples=['123']) - bill_id: str | None = Field(None, description='Optional.', examples=['123']) + id: str = Field( + ..., + examples=[ + 'ID of the recurring transaction. Not to be confused with the ID of the recurrence itself. Is marked as REQUIRED but can be skipped when there is only ONE transaction.' + ], + ) + description: str | None = Field(None, examples=['Rent for the current month']) + amount: str | None = Field(None, description='Amount of the transaction.', examples=['123.45']) + foreign_amount: str | None = Field( + None, description='Foreign amount of the transaction.', examples=['123.45'] + ) + currency_id: str | None = Field( + None, + description='Submit either a currency_id or a currency_code.', + examples=['3'], + ) + currency_code: str | None = Field( + None, + description='Submit either a currency_id or a currency_code.', + examples=['EUR'], + ) + foreign_currency_id: str | None = Field( + None, + description='Submit either a foreign_currency_id or a foreign_currency_code, or neither.', + examples=['17'], + ) + budget_id: str | None = Field( + None, description='The budget ID for this transaction.', examples=['4'] + ) + category_id: str | None = Field( + None, description='Category ID for this transaction.', examples=['211'] + ) + source_id: str | None = Field( + None, + description='ID of the source account. Submit either this or source_name.', + examples=['913'], + ) + destination_id: str | None = Field( + None, + description='ID of the destination account. Submit either this or destination_name.', + examples=['258'], + ) + tags: list[str] | None = Field(None, description='Array of tags.', examples=[None]) + piggy_bank_id: str | None = Field(None, examples=['123']) + bill_id: str | None = Field(None, description='Optional.', examples=['123']) class RuleGroup(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - title: str = Field(..., examples=['Default rule group']) - description: str | None = Field(None, examples=['Description of this rule group']) - order: int | None = Field(None, examples=[4]) - active: bool | None = Field(None, examples=[True]) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + title: str = Field(..., examples=['Default rule group']) + description: str | None = Field(None, examples=['Description of this rule group']) + order: int | None = Field(None, examples=[4]) + active: bool | None = Field(None, examples=[True]) class RuleGroupStore(BaseModel): - title: str = Field(..., examples=['Default rule group']) - description: str | None = Field(None, examples=['Description of this rule group']) - order: int | None = Field(None, examples=[4]) - active: bool | None = Field(None, examples=[True]) + title: str = Field(..., examples=['Default rule group']) + description: str | None = Field(None, examples=['Description of this rule group']) + order: int | None = Field(None, examples=[4]) + active: bool | None = Field(None, examples=[True]) class RuleGroupUpdate(BaseModel): - title: str | None = Field(None, examples=['Default rule group']) - description: str | None = Field(None, examples=['Description of this rule group']) - order: int | None = Field(None, examples=[4]) - active: bool | None = Field(None, examples=[True]) + title: str | None = Field(None, examples=['Default rule group']) + description: str | None = Field(None, examples=['Description of this rule group']) + order: int | None = Field(None, examples=[4]) + active: bool | None = Field(None, examples=[True]) class TagModel(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - tag: str = Field(..., description='The tag', examples=['expensive']) - date: date_aliased | None = Field( - None, - description='The date to which the tag is applicable.', - examples=['2025-12-01'], - ) - description: str | None = Field(None, examples=['Tag for expensive stuff']) - latitude: float | None = Field( - None, - description="Latitude of the tag's location, if applicable. Can be used to draw a map.", - examples=[51.983333], - ) - longitude: float | None = Field( - None, - description="Latitude of the tag's location, if applicable. Can be used to draw a map.", - examples=[5.916667], - ) - zoom_level: int | None = Field( - None, - description='Zoom level for the map, if drawn. This to set the box right. Unfortunately this is a proprietary value because each map provider has different zoom levels.', - examples=[6], - ) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + tag: str = Field(..., description='The tag', examples=['expensive']) + date: date_aliased | None = Field( + None, + description='The date to which the tag is applicable.', + examples=['2025-12-01'], + ) + description: str | None = Field(None, examples=['Tag for expensive stuff']) + latitude: float | None = Field( + None, + description="Latitude of the tag's location, if applicable. Can be used to draw a map.", + examples=[51.983333], + ) + longitude: float | None = Field( + None, + description="Latitude of the tag's location, if applicable. Can be used to draw a map.", + examples=[5.916667], + ) + zoom_level: int | None = Field( + None, + description='Zoom level for the map, if drawn. This to set the box right. Unfortunately this is a proprietary value because each map provider has different zoom levels.', + examples=[6], + ) class TagModelStore(BaseModel): - tag: str = Field(..., description='The tag', examples=['expensive']) - date: date_aliased | None = Field( - None, - description='The date to which the tag is applicable.', - examples=['2025-12-01'], - ) - description: str | None = Field(None, examples=['Tag for expensive stuff']) - latitude: float | None = Field( - None, - description="Latitude of the tag's location, if applicable. Can be used to draw a map.", - examples=[51.983333], - ) - longitude: float | None = Field( - None, - description="Latitude of the tag's location, if applicable. Can be used to draw a map.", - examples=[5.916667], - ) - zoom_level: int | None = Field( - None, - description='Zoom level for the map, if drawn. This to set the box right. Unfortunately this is a proprietary value because each map provider has different zoom levels.', - examples=[6], - ) + tag: str = Field(..., description='The tag', examples=['expensive']) + date: date_aliased | None = Field( + None, + description='The date to which the tag is applicable.', + examples=['2025-12-01'], + ) + description: str | None = Field(None, examples=['Tag for expensive stuff']) + latitude: float | None = Field( + None, + description="Latitude of the tag's location, if applicable. Can be used to draw a map.", + examples=[51.983333], + ) + longitude: float | None = Field( + None, + description="Latitude of the tag's location, if applicable. Can be used to draw a map.", + examples=[5.916667], + ) + zoom_level: int | None = Field( + None, + description='Zoom level for the map, if drawn. This to set the box right. Unfortunately this is a proprietary value because each map provider has different zoom levels.', + examples=[6], + ) class TagModelUpdate(BaseModel): - tag: str | None = Field(None, description='The tag', examples=['expensive']) - date: date_aliased | None = Field( - None, - description='The date to which the tag is applicable.', - examples=['2025-12-01'], - ) - description: str | None = Field(None, examples=['Tag for expensive stuff']) - latitude: float | None = Field( - None, - description="Latitude of the tag's location, if applicable. Can be used to draw a map.", - examples=[51.983333], - ) - longitude: float | None = Field( - None, - description="Latitude of the tag's location, if applicable. Can be used to draw a map.", - examples=[5.916667], - ) - zoom_level: int | None = Field( - None, - description='Zoom level for the map, if drawn. This to set the box right. Unfortunately this is a proprietary value because each map provider has different zoom levels.', - examples=[6], - ) + tag: str | None = Field(None, description='The tag', examples=['expensive']) + date: date_aliased | None = Field( + None, + description='The date to which the tag is applicable.', + examples=['2025-12-01'], + ) + description: str | None = Field(None, examples=['Tag for expensive stuff']) + latitude: float | None = Field( + None, + description="Latitude of the tag's location, if applicable. Can be used to draw a map.", + examples=[51.983333], + ) + longitude: float | None = Field( + None, + description="Latitude of the tag's location, if applicable. Can be used to draw a map.", + examples=[5.916667], + ) + zoom_level: int | None = Field( + None, + description='Zoom level for the map, if drawn. This to set the box right. Unfortunately this is a proprietary value because each map provider has different zoom levels.', + examples=[6], + ) class CurrencyProperties(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - enabled: bool | None = Field(True, description='Defaults to true', examples=[True]) - primary: bool | None = Field(None, description='Is the primary currency?', examples=[False]) - code: str = Field(..., examples=['AMS']) - name: str = Field(..., examples=['Ankh-Morpork dollar']) - symbol: str = Field(..., examples=['AM$']) - decimal_places: int | None = Field(None, description='Supports 0-16 decimals.', examples=[2]) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + enabled: bool | None = Field(True, description='Defaults to true', examples=[True]) + primary: bool | None = Field(None, description='Is the primary currency?', examples=[False]) + code: str = Field(..., examples=['AMS']) + name: str = Field(..., examples=['Ankh-Morpork dollar']) + symbol: str = Field(..., examples=['AM$']) + decimal_places: int | None = Field(None, description='Supports 0-16 decimals.', examples=[2]) class CurrencyStore(BaseModel): - enabled: bool | None = Field(True, description='Defaults to true', examples=[True]) - primary: bool | None = Field( - None, - description='Make this currency the primary currency for the current administration. You can set this value to FALSE, in which case nothing will change to the primary currency. If you set it to TRUE, the current primary currency will no longer be the primary currency.', - examples=[True], - ) - code: str = Field(..., examples=['AMS']) - name: str = Field(..., examples=['Ankh-Morpork dollar']) - symbol: str = Field(..., examples=['AM$']) - decimal_places: int | None = Field(None, description='Supports 0-16 decimals.', examples=[2]) + enabled: bool | None = Field(True, description='Defaults to true', examples=[True]) + primary: bool | None = Field( + None, + description='Make this currency the primary currency for the current administration. You can set this value to FALSE, in which case nothing will change to the primary currency. If you set it to TRUE, the current primary currency will no longer be the primary currency.', + examples=[True], + ) + code: str = Field(..., examples=['AMS']) + name: str = Field(..., examples=['Ankh-Morpork dollar']) + symbol: str = Field(..., examples=['AM$']) + decimal_places: int | None = Field(None, description='Supports 0-16 decimals.', examples=[2]) class Primary(Enum): - boolean_True = True + boolean_True = True class CurrencyUpdate(BaseModel): - enabled: bool | None = Field(None, description='If the currency is enabled', examples=[True]) - primary: Primary | None = Field( - None, - description='If the currency must be the primary for the user. You can only submit TRUE. Submitting FALSE will not drop this currency as the primary currency, because then the system would be without one.', - examples=[True], - ) - code: str | None = Field(None, description='The currency code', examples=['AMS']) - name: str | None = Field( - None, description='The currency name', examples=['Ankh-Morpork dollar'] - ) - symbol: str | None = Field(None, description='The currency symbol', examples=['AM$']) - decimal_places: int | None = Field( - None, - description='How many decimals to use when displaying this currency. Between 0 and 16.', - examples=[2], - ) + enabled: bool | None = Field(None, description='If the currency is enabled', examples=[True]) + primary: Primary | None = Field( + None, + description='If the currency must be the primary for the user. You can only submit TRUE. Submitting FALSE will not drop this currency as the primary currency, because then the system would be without one.', + examples=[True], + ) + code: str | None = Field(None, description='The currency code', examples=['AMS']) + name: str | None = Field( + None, description='The currency name', examples=['Ankh-Morpork dollar'] + ) + symbol: str | None = Field(None, description='The currency symbol', examples=['AM$']) + decimal_places: int | None = Field( + None, + description='How many decimals to use when displaying this currency. Between 0 and 16.', + examples=[2], + ) class TransactionLink(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - link_type_id: str = Field( - ..., - description='The link type ID to use. You can also use the link_type_name field.', - examples=['5'], - ) - link_type_name: str | None = Field( - None, - description='The link type name to use. You can also use the link_type_id field.', - examples=['Is paid by'], - ) - inward_id: str = Field( - ..., - description="The inward transaction transaction_journal_id for the link. This becomes the 'is paid by' transaction of the set.", - examples=['131'], - ) - outward_id: str = Field( - ..., - description="The outward transaction transaction_journal_id for the link. This becomes the 'pays for' transaction of the set.", - examples=['131'], - ) - notes: str | None = Field( - None, description='Optional. Some notes.', examples=['Some example notes'] - ) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + link_type_id: str = Field( + ..., + description='The link type ID to use. You can also use the link_type_name field.', + examples=['5'], + ) + link_type_name: str | None = Field( + None, + description='The link type name to use. You can also use the link_type_id field.', + examples=['Is paid by'], + ) + inward_id: str = Field( + ..., + description="The inward transaction transaction_journal_id for the link. This becomes the 'is paid by' transaction of the set.", + examples=['131'], + ) + outward_id: str = Field( + ..., + description="The outward transaction transaction_journal_id for the link. This becomes the 'pays for' transaction of the set.", + examples=['131'], + ) + notes: str | None = Field( + None, description='Optional. Some notes.', examples=['Some example notes'] + ) class TransactionLinkStore(BaseModel): - link_type_id: str = Field( - ..., - description='The link type ID to use. You can also use the link_type_name field.', - examples=['5'], - ) - link_type_name: str | None = Field( - None, - description='The link type name to use. You can also use the link_type_id field.', - examples=['Is paid by'], - ) - inward_id: str = Field( - ..., - description="The inward transaction transaction_journal_id for the link. This becomes the 'is paid by' transaction of the set.", - examples=['131'], - ) - outward_id: str = Field( - ..., - description="The outward transaction transaction_journal_id for the link. This becomes the 'pays for' transaction of the set.", - examples=['131'], - ) - notes: str | None = Field( - None, description='Optional. Some notes.', examples=['Some example notes'] - ) + link_type_id: str = Field( + ..., + description='The link type ID to use. You can also use the link_type_name field.', + examples=['5'], + ) + link_type_name: str | None = Field( + None, + description='The link type name to use. You can also use the link_type_id field.', + examples=['Is paid by'], + ) + inward_id: str = Field( + ..., + description="The inward transaction transaction_journal_id for the link. This becomes the 'is paid by' transaction of the set.", + examples=['131'], + ) + outward_id: str = Field( + ..., + description="The outward transaction transaction_journal_id for the link. This becomes the 'pays for' transaction of the set.", + examples=['131'], + ) + notes: str | None = Field( + None, description='Optional. Some notes.', examples=['Some example notes'] + ) class TransactionLinkUpdate(BaseModel): - link_type_id: str | None = Field( - None, - description='The link type ID to use. Use this field OR use the link_type_name field.', - examples=['5'], - ) - link_type_name: str | None = Field( - None, - description='The link type name to use. Use this field OR use the link_type_id field.', - examples=['Is paid by'], - ) - inward_id: str | None = Field( - None, - description="The inward transaction transaction_journal_id for the link. This becomes the 'is paid by' transaction of the set.", - examples=['131'], - ) - outward_id: str | None = Field( - None, - description="The outward transaction transaction_journal_id for the link. This becomes the 'pays for' transaction of the set.", - examples=['131'], - ) - notes: str | None = Field( - None, - description='Optional. Some notes. If you submit an empty string the current notes will be removed', - examples=['Some example notes'], - ) + link_type_id: str | None = Field( + None, + description='The link type ID to use. Use this field OR use the link_type_name field.', + examples=['5'], + ) + link_type_name: str | None = Field( + None, + description='The link type name to use. Use this field OR use the link_type_id field.', + examples=['Is paid by'], + ) + inward_id: str | None = Field( + None, + description="The inward transaction transaction_journal_id for the link. This becomes the 'is paid by' transaction of the set.", + examples=['131'], + ) + outward_id: str | None = Field( + None, + description="The outward transaction transaction_journal_id for the link. This becomes the 'pays for' transaction of the set.", + examples=['131'], + ) + notes: str | None = Field( + None, + description='Optional. Some notes. If you submit an empty string the current notes will be removed', + examples=['Some example notes'], + ) class LinkType(BaseModel): - name: str = Field(..., examples=['Paid']) - inward: str = Field(..., examples=['is (partially) paid for by']) - outward: str = Field(..., examples=['(partially) pays for']) - editable: bool | None = Field(None, examples=[False]) + name: str = Field(..., examples=['Paid']) + inward: str = Field(..., examples=['is (partially) paid for by']) + outward: str = Field(..., examples=['(partially) pays for']) + editable: bool | None = Field(None, examples=[False]) class LinkTypeUpdate(BaseModel): - name: str | None = Field(None, examples=['Paid']) - inward: str | None = Field(None, examples=['is (partially) paid for by']) - outward: str | None = Field(None, examples=['(partially) pays for']) + name: str | None = Field(None, examples=['Paid']) + inward: str | None = Field(None, examples=['is (partially) paid for by']) + outward: str | None = Field(None, examples=['(partially) pays for']) class UserGroupReadRole(Enum): - ro = 'ro' - mng_trx = 'mng_trx' - mng_meta = 'mng_meta' - read_budgets = 'read_budgets' - read_piggies = 'read_piggies' - read_subscriptions = 'read_subscriptions' - read_rules = 'read_rules' - read_recurring = 'read_recurring' - read_webhooks = 'read_webhooks' - read_currencies = 'read_currencies' - mng_budgets = 'mng_budgets' - mng_piggies = 'mng_piggies' - mng_subscriptions = 'mng_subscriptions' - mng_rules = 'mng_rules' - mng_recurring = 'mng_recurring' - mng_webhooks = 'mng_webhooks' - mng_currencies = 'mng_currencies' - view_reports = 'view_reports' - view_memberships = 'view_memberships' - full = 'full' - owner = 'owner' + ro = 'ro' + mng_trx = 'mng_trx' + mng_meta = 'mng_meta' + read_budgets = 'read_budgets' + read_piggies = 'read_piggies' + read_subscriptions = 'read_subscriptions' + read_rules = 'read_rules' + read_recurring = 'read_recurring' + read_webhooks = 'read_webhooks' + read_currencies = 'read_currencies' + mng_budgets = 'mng_budgets' + mng_piggies = 'mng_piggies' + mng_subscriptions = 'mng_subscriptions' + mng_rules = 'mng_rules' + mng_recurring = 'mng_recurring' + mng_webhooks = 'mng_webhooks' + mng_currencies = 'mng_currencies' + view_reports = 'view_reports' + view_memberships = 'view_memberships' + full = 'full' + owner = 'owner' class UserGroupUpdate(BaseModel): - title: str = Field( - ..., - description='A descriptive title for the user group.', - examples=['New user group title'], - ) - primary_currency_id: str | None = Field( - None, - description="Use either primary_currency_id or primary_currency_code. This will set the primary currency for the user group ('financial administration').", - examples=['1'], - ) - primary_currency_code: str | None = Field( - None, - description="Use either primary_currency_id or primary_currency_code. This will set the primary currency for the user group ('financial administration').", - examples=['EUR'], - ) + title: str = Field( + ..., + description='A descriptive title for the user group.', + examples=['New user group title'], + ) + primary_currency_id: str | None = Field( + None, + description="Use either primary_currency_id or primary_currency_code. This will set the primary currency for the user group ('financial administration').", + examples=['1'], + ) + primary_currency_code: str | None = Field( + None, + description="Use either primary_currency_id or primary_currency_code. This will set the primary currency for the user group ('financial administration').", + examples=['EUR'], + ) class WebhookDelivery(Enum): - JSON = 'JSON' + JSON = 'JSON' class WebhookDeliveryArray(RootModel[list[WebhookDelivery]]): - root: list[WebhookDelivery] = Field(..., examples=[['JSON']], max_length=1, min_length=1) + root: list[WebhookDelivery] = Field(..., examples=[['JSON']], max_length=1, min_length=1) class WebhookResponse(Enum): - TRANSACTIONS = 'TRANSACTIONS' - ACCOUNTS = 'ACCOUNTS' - BUDGET = 'BUDGET' - RELEVANT = 'RELEVANT' - NONE = 'NONE' + TRANSACTIONS = 'TRANSACTIONS' + ACCOUNTS = 'ACCOUNTS' + BUDGET = 'BUDGET' + RELEVANT = 'RELEVANT' + NONE = 'NONE' class WebhookResponseArray(RootModel[list[WebhookResponse]]): - root: list[WebhookResponse] = Field( - ..., examples=[['TRANSACTIONS']], max_length=1, min_length=1 - ) + root: list[WebhookResponse] = Field( + ..., examples=[['TRANSACTIONS']], max_length=1, min_length=1 + ) class WebhookTrigger(Enum): - ANY = 'ANY' - STORE_TRANSACTION = 'STORE_TRANSACTION' - UPDATE_TRANSACTION = 'UPDATE_TRANSACTION' - DESTROY_TRANSACTION = 'DESTROY_TRANSACTION' - STORE_BUDGET = 'STORE_BUDGET' - UPDATE_BUDGET = 'UPDATE_BUDGET' - DESTROY_BUDGET = 'DESTROY_BUDGET' - STORE_UPDATE_BUDGET_LIMIT = 'STORE_UPDATE_BUDGET_LIMIT' + ANY = 'ANY' + STORE_TRANSACTION = 'STORE_TRANSACTION' + UPDATE_TRANSACTION = 'UPDATE_TRANSACTION' + DESTROY_TRANSACTION = 'DESTROY_TRANSACTION' + STORE_BUDGET = 'STORE_BUDGET' + UPDATE_BUDGET = 'UPDATE_BUDGET' + DESTROY_BUDGET = 'DESTROY_BUDGET' + STORE_UPDATE_BUDGET_LIMIT = 'STORE_UPDATE_BUDGET_LIMIT' class WebhookTriggerArray(RootModel[list[WebhookTrigger]]): - root: list[WebhookTrigger] = Field( - ..., - examples=[['STORE_TRANSACTION', 'UPDATE_TRANSACTION']], - max_length=3, - min_length=1, - ) + root: list[WebhookTrigger] = Field( + ..., + examples=[['STORE_TRANSACTION', 'UPDATE_TRANSACTION']], + max_length=3, + min_length=1, + ) class WebhookUpdate(BaseModel): - active: bool | None = Field( - None, - description='Boolean to indicate if the webhook is active', - examples=[False], - ) - title: str | None = Field( - None, - description='A title for the webhook for easy recognition.', - examples=['Update magic mirror on new transaction'], - ) - secret: str | None = Field( - None, - description="A 24-character secret for the webhook. It's generated by Firefly III when saving a new webhook. If you submit a new secret through the PUT endpoint it will generate a new secret for the selected webhook, a new secret bearing no relation to whatever you just submitted.", - examples=['iMLZLtLx2JHWhK9Dtyuoqyir'], - ) - triggers: WebhookTriggerArray | None = None - responses: WebhookResponseArray | None = None - deliveries: WebhookDeliveryArray | None = None - url: str | None = Field( - None, - description='The URL of the webhook. Has to start with `https`.', - examples=['https://example.com'], - ) + active: bool | None = Field( + None, + description='Boolean to indicate if the webhook is active', + examples=[False], + ) + title: str | None = Field( + None, + description='A title for the webhook for easy recognition.', + examples=['Update magic mirror on new transaction'], + ) + secret: str | None = Field( + None, + description="A 24-character secret for the webhook. It's generated by Firefly III when saving a new webhook. If you submit a new secret through the PUT endpoint it will generate a new secret for the selected webhook, a new secret bearing no relation to whatever you just submitted.", + examples=['iMLZLtLx2JHWhK9Dtyuoqyir'], + ) + triggers: WebhookTriggerArray | None = None + responses: WebhookResponseArray | None = None + deliveries: WebhookDeliveryArray | None = None + url: str | None = Field( + None, + description='The URL of the webhook. Has to start with `https`.', + examples=['https://example.com'], + ) class WebhookAttempt(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - webhook_message_id: str | None = Field( - None, - description='The ID of the webhook message this attempt belongs to.', - examples=['5'], - ) - status_code: int | None = Field( - None, description='The HTTP status code of the error, if any.', examples=[404] - ) - logs: str | None = Field( - None, - description='Internal log for this attempt. May contain sensitive user data.', - examples=['Page not found'], - ) - response: str | None = Field( - None, - description='Webhook receiver response for this attempt, if any. May contain sensitive user data.', - examples=['Page not found'], - ) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + webhook_message_id: str | None = Field( + None, + description='The ID of the webhook message this attempt belongs to.', + examples=['5'], + ) + status_code: int | None = Field( + None, description='The HTTP status code of the error, if any.', examples=[404] + ) + logs: str | None = Field( + None, + description='Internal log for this attempt. May contain sensitive user data.', + examples=['Page not found'], + ) + response: str | None = Field( + None, + description='Webhook receiver response for this attempt, if any. May contain sensitive user data.', + examples=['Page not found'], + ) class WebhookMessage(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - sent: bool | None = Field(None, description='If this message is sent yet.', examples=[False]) - errored: bool | None = Field( - None, description='If this message has errored out.', examples=[False] - ) - webhook_id: str | None = Field( - None, - description='The ID of the webhook this message belongs to.', - examples=['5'], - ) - uuid: str | None = Field( - None, - description='Long UUID string for identification of this webhook message.', - examples=['7a344c02-5b52-46b1-90e6-a437431dcf07'], - ) - message: str | None = Field( - None, - description='The actual message that is sent or will be sent as JSON string.', - examples=['{some:message}'], - ) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + sent: bool | None = Field(None, description='If this message is sent yet.', examples=[False]) + errored: bool | None = Field( + None, description='If this message has errored out.', examples=[False] + ) + webhook_id: str | None = Field( + None, + description='The ID of the webhook this message belongs to.', + examples=['5'], + ) + uuid: str | None = Field( + None, + description='Long UUID string for identification of this webhook message.', + examples=['7a344c02-5b52-46b1-90e6-a437431dcf07'], + ) + message: str | None = Field( + None, + description='The actual message that is sent or will be sent as JSON string.', + examples=['{some:message}'], + ) class AttachableType(Enum): - Account = 'Account' - Budget = 'Budget' - Bill = 'Bill' - TransactionJournal = 'TransactionJournal' - PiggyBank = 'PiggyBank' - Tag = 'Tag' + Account = 'Account' + Budget = 'Budget' + Bill = 'Bill' + TransactionJournal = 'TransactionJournal' + PiggyBank = 'PiggyBank' + Tag = 'Tag' class AutoBudgetPeriodEnum(Enum): - daily = 'daily' - weekly = 'weekly' - monthly = 'monthly' - quarterly = 'quarterly' - half_year = 'half-year' - yearly = 'yearly' + daily = 'daily' + weekly = 'weekly' + monthly = 'monthly' + quarterly = 'quarterly' + half_year = 'half-year' + yearly = 'yearly' class AutoBudgetPeriod(RootModel[AutoBudgetPeriodEnum | None]): - root: AutoBudgetPeriodEnum | None = Field( - None, description='Period for the auto budget', examples=['monthly'] - ) + root: AutoBudgetPeriodEnum | None = Field( + None, description='Period for the auto budget', examples=['monthly'] + ) class AutoBudgetTypeEnum(Enum): - reset = 'reset' - rollover = 'rollover' - none = 'none' + reset = 'reset' + rollover = 'rollover' + none = 'none' class AutoBudgetType(RootModel[AutoBudgetTypeEnum | None]): - root: AutoBudgetTypeEnum | None = Field( - None, - description='The type of auto-budget that Firefly III must create.', - examples=['reset'], - ) + root: AutoBudgetTypeEnum | None = Field( + None, + description='The type of auto-budget that Firefly III must create.', + examples=['reset'], + ) class BillRepeatFrequency(Enum): - weekly = 'weekly' - monthly = 'monthly' - quarterly = 'quarterly' - half_year = 'half-year' - yearly = 'yearly' + weekly = 'weekly' + monthly = 'monthly' + quarterly = 'quarterly' + half_year = 'half-year' + yearly = 'yearly' class RecurrenceRepetitionType(Enum): - daily = 'daily' - weekly = 'weekly' - ndom = 'ndom' - monthly = 'monthly' - yearly = 'yearly' + daily = 'daily' + weekly = 'weekly' + ndom = 'ndom' + monthly = 'monthly' + yearly = 'yearly' class RecurrenceTransactionType(Enum): - withdrawal = 'withdrawal' - transfer = 'transfer' - deposit = 'deposit' + withdrawal = 'withdrawal' + transfer = 'transfer' + deposit = 'deposit' class RuleActionKeyword(Enum): - user_action = 'user_action' - set_category = 'set_category' - clear_category = 'clear_category' - set_budget = 'set_budget' - clear_budget = 'clear_budget' - add_tag = 'add_tag' - remove_tag = 'remove_tag' - remove_all_tags = 'remove_all_tags' - set_description = 'set_description' - append_description = 'append_description' - prepend_description = 'prepend_description' - set_source_account = 'set_source_account' - set_destination_account = 'set_destination_account' - set_notes = 'set_notes' - append_notes = 'append_notes' - prepend_notes = 'prepend_notes' - clear_notes = 'clear_notes' - link_to_bill = 'link_to_bill' - convert_withdrawal = 'convert_withdrawal' - convert_deposit = 'convert_deposit' - convert_transfer = 'convert_transfer' - delete_transaction = 'delete_transaction' + user_action = 'user_action' + set_category = 'set_category' + clear_category = 'clear_category' + set_budget = 'set_budget' + clear_budget = 'clear_budget' + add_tag = 'add_tag' + remove_tag = 'remove_tag' + remove_all_tags = 'remove_all_tags' + set_description = 'set_description' + append_description = 'append_description' + prepend_description = 'prepend_description' + set_source_account = 'set_source_account' + set_destination_account = 'set_destination_account' + set_notes = 'set_notes' + append_notes = 'append_notes' + prepend_notes = 'prepend_notes' + clear_notes = 'clear_notes' + link_to_bill = 'link_to_bill' + convert_withdrawal = 'convert_withdrawal' + convert_deposit = 'convert_deposit' + convert_transfer = 'convert_transfer' + delete_transaction = 'delete_transaction' class RuleTriggerKeyword(Enum): - from_account_starts = 'from_account_starts' - from_account_ends = 'from_account_ends' - from_account_is = 'from_account_is' - from_account_contains = 'from_account_contains' - to_account_starts = 'to_account_starts' - to_account_ends = 'to_account_ends' - to_account_is = 'to_account_is' - to_account_contains = 'to_account_contains' - amount_less = 'amount_less' - amount_exactly = 'amount_exactly' - amount_more = 'amount_more' - description_starts = 'description_starts' - description_ends = 'description_ends' - description_contains = 'description_contains' - description_is = 'description_is' - transaction_type = 'transaction_type' - category_is = 'category_is' - budget_is = 'budget_is' - tag_is = 'tag_is' - currency_is = 'currency_is' - has_attachments = 'has_attachments' - has_no_category = 'has_no_category' - has_any_category = 'has_any_category' - has_no_budget = 'has_no_budget' - has_any_budget = 'has_any_budget' - has_no_tag = 'has_no_tag' - has_any_tag = 'has_any_tag' - notes_contains = 'notes_contains' - notes_start = 'notes_start' - notes_end = 'notes_end' - notes_are = 'notes_are' - no_notes = 'no_notes' - any_notes = 'any_notes' - source_account_is = 'source_account_is' - destination_account_is = 'destination_account_is' - source_account_starts = 'source_account_starts' + from_account_starts = 'from_account_starts' + from_account_ends = 'from_account_ends' + from_account_is = 'from_account_is' + from_account_contains = 'from_account_contains' + to_account_starts = 'to_account_starts' + to_account_ends = 'to_account_ends' + to_account_is = 'to_account_is' + to_account_contains = 'to_account_contains' + amount_less = 'amount_less' + amount_exactly = 'amount_exactly' + amount_more = 'amount_more' + description_starts = 'description_starts' + description_ends = 'description_ends' + description_contains = 'description_contains' + description_is = 'description_is' + transaction_type = 'transaction_type' + category_is = 'category_is' + budget_is = 'budget_is' + tag_is = 'tag_is' + currency_is = 'currency_is' + has_attachments = 'has_attachments' + has_no_category = 'has_no_category' + has_any_category = 'has_any_category' + has_no_budget = 'has_no_budget' + has_any_budget = 'has_any_budget' + has_no_tag = 'has_no_tag' + has_any_tag = 'has_any_tag' + notes_contains = 'notes_contains' + notes_start = 'notes_start' + notes_end = 'notes_end' + notes_are = 'notes_are' + no_notes = 'no_notes' + any_notes = 'any_notes' + source_account_is = 'source_account_is' + destination_account_is = 'destination_account_is' + source_account_starts = 'source_account_starts' class RuleTriggerType(Enum): - store_journal = 'store-journal' - update_journal = 'update-journal' - manual_activation = 'manual-activation' + store_journal = 'store-journal' + update_journal = 'update-journal' + manual_activation = 'manual-activation' class StringArrayItem(RootModel[str]): - root: str = Field(..., description='The actual preference content.', examples=['EUR']) + root: str = Field(..., description='The actual preference content.', examples=['EUR']) class UserBlockedCodePropertyEnum(Enum): - email_changed = 'email_changed' + email_changed = 'email_changed' class UserBlockedCodeProperty(RootModel[UserBlockedCodePropertyEnum | None]): - root: UserBlockedCodePropertyEnum | None = Field( - None, - description='If you say the user must be blocked, this will be the reason code.', - examples=['email_changed'], - ) + root: UserBlockedCodePropertyEnum | None = Field( + None, + description='If you say the user must be blocked, this will be the reason code.', + examples=['email_changed'], + ) class UserRolePropertyEnum(Enum): - owner = 'owner' - demo = 'demo' + owner = 'owner' + demo = 'demo' class UserRoleProperty(RootModel[UserRolePropertyEnum | None]): - root: UserRolePropertyEnum | None = Field( - None, - description='Role for the user. Can be empty or omitted.', - examples=['owner'], - ) + root: UserRolePropertyEnum | None = Field( + None, + description='Role for the user. Can be empty or omitted.', + examples=['owner'], + ) class BasicSummaryEntry(BaseModel): - key: str | None = Field( - None, - description='This is a reference to the type of info shared, not influenced by translations or user preferences. The EUR value is a reference to the currency code. Possibilities are: balance-in-ABC, spent-in-ABC, earned-in-ABC, bills-paid-in-ABC, bills-unpaid-in-ABC, left-to-spend-in-ABC and net-worth-in-ABC.', - examples=['balance-in-EUR'], - ) - title: str | None = Field( - None, - description='A translated title for the information shared.', - examples=['Balance ($)'], - ) - monetary_value: float | None = Field( - None, description='The amount as a float.', examples=[123.45] - ) - currency_id: str | None = Field( - None, description='The currency ID of the associated currency.', examples=['5'] - ) - currency_code: str | None = Field(None, examples=['EUR']) - currency_symbol: str | None = Field(None, examples=['$']) - currency_decimal_places: int | None = Field( - None, - description='Number of decimals for the associated currency.', - examples=[2], - ) - no_available_budgets: bool | None = Field( - None, - description='True if there are no available budgets available.', - examples=[False], - ) - value_parsed: str | None = Field( - None, - description='The amount formatted according to the users locale', - examples=['$ 12.45'], - ) - local_icon: str | None = Field( - None, - description='Reference to a font-awesome icon without the fa- part.', - examples=['balance-scale'], - ) - sub_title: str | None = Field( - None, - description='A short explanation of the amounts origin. Already formatted according to the locale of the user or translated, if relevant.', - examples=['$20 + $-40'], - ) + key: str | None = Field( + None, + description='This is a reference to the type of info shared, not influenced by translations or user preferences. The EUR value is a reference to the currency code. Possibilities are: balance-in-ABC, spent-in-ABC, earned-in-ABC, bills-paid-in-ABC, bills-unpaid-in-ABC, left-to-spend-in-ABC and net-worth-in-ABC.', + examples=['balance-in-EUR'], + ) + title: str | None = Field( + None, + description='A translated title for the information shared.', + examples=['Balance ($)'], + ) + monetary_value: float | None = Field( + None, description='The amount as a float.', examples=[123.45] + ) + currency_id: str | None = Field( + None, description='The currency ID of the associated currency.', examples=['5'] + ) + currency_code: str | None = Field(None, examples=['EUR']) + currency_symbol: str | None = Field(None, examples=['$']) + currency_decimal_places: int | None = Field( + None, + description='Number of decimals for the associated currency.', + examples=[2], + ) + no_available_budgets: bool | None = Field( + None, + description='True if there are no available budgets available.', + examples=[False], + ) + value_parsed: str | None = Field( + None, + description='The amount formatted according to the users locale', + examples=['$ 12.45'], + ) + local_icon: str | None = Field( + None, + description='Reference to a font-awesome icon without the fa- part.', + examples=['balance-scale'], + ) + sub_title: str | None = Field( + None, + description='A short explanation of the amounts origin. Already formatted according to the locale of the user or translated, if relevant.', + examples=['$20 + $-40'], + ) class CronResultRow(BaseModel): - job_fired: bool | None = Field( - None, - description='This value tells you if this specific cron job actually fired. It may not fire. Some cron jobs\nonly fire every 24 hours, for example.\n', - examples=[True], - ) - job_succeeded: bool | None = Field( - None, - description='This value tells you if this specific cron job actually did something. The job may fire but not\nchange anything.\n', - examples=[True], - ) - job_errored: bool | None = Field( - None, - description='If the cron job ran into some kind of an error, this value will be true.', - examples=[False], - ) - message: str | None = Field( - None, - description='If the cron job ran into some kind of an error, this value will be the error message. The success message\nif the job actually ran OK.\n', - examples=['Cron result message'], - ) + job_fired: bool | None = Field( + None, + description='This value tells you if this specific cron job actually fired. It may not fire. Some cron jobs\nonly fire every 24 hours, for example.\n', + examples=[True], + ) + job_succeeded: bool | None = Field( + None, + description='This value tells you if this specific cron job actually did something. The job may fire but not\nchange anything.\n', + examples=[True], + ) + job_errored: bool | None = Field( + None, + description='If the cron job ran into some kind of an error, this value will be true.', + examples=[False], + ) + message: str | None = Field( + None, + description='If the cron job ran into some kind of an error, this value will be the error message. The success message\nif the job actually ran OK.\n', + examples=['Cron result message'], + ) class Data(BaseModel): - version: str | None = Field(None, examples=['6.4.14']) - api_version: str | None = Field( - None, description='Same value as the version field.', examples=['6.4.14'] - ) - php_version: str | None = Field(None, examples=['8.1.5']) - os: str | None = Field(None, examples=['Linux']) - driver: str | None = Field(None, examples=['mysql']) + version: str | None = Field(None, examples=['6.4.14']) + api_version: str | None = Field( + None, description='Same value as the version field.', examples=['6.4.14'] + ) + php_version: str | None = Field(None, examples=['8.1.5']) + os: str | None = Field(None, examples=['Linux']) + driver: str | None = Field(None, examples=['mysql']) class SystemInfo(BaseModel): - data: Data | None = None + data: Data | None = None class AccountTypeFilter(Enum): - all = 'all' - asset = 'asset' - cash = 'cash' - expense = 'expense' - revenue = 'revenue' - special = 'special' - hidden = 'hidden' - liability = 'liability' - liabilities = 'liabilities' - Default_account = 'Default account' - Cash_account = 'Cash account' - Asset_account = 'Asset account' - Expense_account = 'Expense account' - Revenue_account = 'Revenue account' - Initial_balance_account = 'Initial balance account' - Beneficiary_account = 'Beneficiary account' - Import_account = 'Import account' - Reconciliation_account = 'Reconciliation account' - Loan = 'Loan' - Debt = 'Debt' - Mortgage = 'Mortgage' + all = 'all' + asset = 'asset' + cash = 'cash' + expense = 'expense' + revenue = 'revenue' + special = 'special' + hidden = 'hidden' + liability = 'liability' + liabilities = 'liabilities' + Default_account = 'Default account' + Cash_account = 'Cash account' + Asset_account = 'Asset account' + Expense_account = 'Expense account' + Revenue_account = 'Revenue account' + Initial_balance_account = 'Initial balance account' + Beneficiary_account = 'Beneficiary account' + Import_account = 'Import account' + Reconciliation_account = 'Reconciliation account' + Loan = 'Loan' + Debt = 'Debt' + Mortgage = 'Mortgage' class TransactionTypeFilter(Enum): - all = 'all' - withdrawal = 'withdrawal' - withdrawals = 'withdrawals' - expense = 'expense' - deposit = 'deposit' - deposits = 'deposits' - income = 'income' - transfer = 'transfer' - transfers = 'transfers' - opening_balance = 'opening_balance' - reconciliation = 'reconciliation' - special = 'special' - specials = 'specials' - default = 'default' + all = 'all' + withdrawal = 'withdrawal' + withdrawals = 'withdrawals' + expense = 'expense' + deposit = 'deposit' + deposits = 'deposits' + income = 'income' + transfer = 'transfer' + transfers = 'transfers' + opening_balance = 'opening_balance' + reconciliation = 'reconciliation' + special = 'special' + specials = 'specials' + default = 'default' class Pagination(BaseModel): - total: int | None = Field(None, examples=[3]) - count: int | None = Field(None, examples=[20]) - per_page: int | None = Field(None, examples=[100]) - current_page: int | None = Field(None, examples=[1]) - total_pages: int | None = Field(None, examples=[1]) + total: int | None = Field(None, examples=[3]) + count: int | None = Field(None, examples=[20]) + per_page: int | None = Field(None, examples=[100]) + current_page: int | None = Field(None, examples=[1]) + total_pages: int | None = Field(None, examples=[1]) class Meta(BaseModel): - pagination: Pagination | None = None + pagination: Pagination | None = None class AccountRolePropertyEnum(Enum): - defaultAsset = 'defaultAsset' - sharedAsset = 'sharedAsset' - savingAsset = 'savingAsset' - ccAsset = 'ccAsset' - cashWalletAsset = 'cashWalletAsset' + defaultAsset = 'defaultAsset' + sharedAsset = 'sharedAsset' + savingAsset = 'savingAsset' + ccAsset = 'ccAsset' + cashWalletAsset = 'cashWalletAsset' class AccountRoleProperty(RootModel[AccountRolePropertyEnum | None]): - root: AccountRolePropertyEnum | None = Field( - None, - description='Is only mandatory when the type is asset.', - examples=['defaultAsset'], - ) + root: AccountRolePropertyEnum | None = Field( + None, + description='Is only mandatory when the type is asset.', + examples=['defaultAsset'], + ) class AccountTypeProperty(Enum): - Default_account = 'Default account' - Cash_account = 'Cash account' - Asset_account = 'Asset account' - Expense_account = 'Expense account' - Revenue_account = 'Revenue account' - Initial_balance_account = 'Initial balance account' - Beneficiary_account = 'Beneficiary account' - Import_account = 'Import account' - Reconciliation_account = 'Reconciliation account' - Loan = 'Loan' - Debt = 'Debt' - Mortgage = 'Mortgage' + Default_account = 'Default account' + Cash_account = 'Cash account' + Asset_account = 'Asset account' + Expense_account = 'Expense account' + Revenue_account = 'Revenue account' + Initial_balance_account = 'Initial balance account' + Beneficiary_account = 'Beneficiary account' + Import_account = 'Import account' + Reconciliation_account = 'Reconciliation account' + Loan = 'Loan' + Debt = 'Debt' + Mortgage = 'Mortgage' class ChartDatasetPeriodProperty(Enum): - field_1D = '1D' - field_1W = '1W' - field_1M = '1M' - field_3M = '3M' - field_1Y = '1Y' - custom = 'custom' + field_1D = '1D' + field_1W = '1W' + field_1M = '1M' + field_3M = '3M' + field_1Y = '1Y' + custom = 'custom' class CreditCardTypePropertyEnum(Enum): - monthlyFull = 'monthlyFull' + monthlyFull = 'monthlyFull' class CreditCardTypeProperty(RootModel[CreditCardTypePropertyEnum | None]): - root: CreditCardTypePropertyEnum | None = Field( - None, - description='Mandatory when the account_role is ccAsset. Can only be monthlyFull or null.', - examples=['monthlyFull'], - ) + root: CreditCardTypePropertyEnum | None = Field( + None, + description='Mandatory when the account_role is ccAsset. Can only be monthlyFull or null.', + examples=['monthlyFull'], + ) class InterestPeriodPropertyEnum(Enum): - weekly = 'weekly' - monthly = 'monthly' - quarterly = 'quarterly' - half_year = 'half-year' - yearly = 'yearly' + weekly = 'weekly' + monthly = 'monthly' + quarterly = 'quarterly' + half_year = 'half-year' + yearly = 'yearly' class InterestPeriodProperty(RootModel[InterestPeriodPropertyEnum | None]): - root: InterestPeriodPropertyEnum | None = Field( - None, - description='Mandatory when type is liability. Period over which the interest is calculated.', - examples=['monthly'], - ) + root: InterestPeriodPropertyEnum | None = Field( + None, + description='Mandatory when type is liability. Period over which the interest is calculated.', + examples=['monthly'], + ) class LiabilityDirectionPropertyEnum(Enum): - credit = 'credit' - debit = 'debit' + credit = 'credit' + debit = 'debit' class LiabilityDirectionProperty(RootModel[LiabilityDirectionPropertyEnum | None]): - root: LiabilityDirectionPropertyEnum | None = Field( - None, - description="'credit' indicates somebody owes you the liability. 'debit' Indicates you owe this debt yourself. Works only for liabilities.", - examples=['credit'], - ) + root: LiabilityDirectionPropertyEnum | None = Field( + None, + description="'credit' indicates somebody owes you the liability. 'debit' Indicates you owe this debt yourself. Works only for liabilities.", + examples=['credit'], + ) class LiabilityTypePropertyEnum(Enum): - loan = 'loan' - debt = 'debt' - mortgage = 'mortgage' + loan = 'loan' + debt = 'debt' + mortgage = 'mortgage' class LiabilityTypeProperty(RootModel[LiabilityTypePropertyEnum | None]): - root: LiabilityTypePropertyEnum | None = Field( - None, - description='Mandatory when type is liability. Specifies the exact type.', - examples=['loan'], - ) + root: LiabilityTypePropertyEnum | None = Field( + None, + description='Mandatory when type is liability. Specifies the exact type.', + examples=['loan'], + ) class ShortAccountTypeProperty(Enum): - asset = 'asset' - expense = 'expense' - import_ = 'import' - revenue = 'revenue' - cash = 'cash' - liability = 'liability' - liabilities = 'liabilities' - initial_balance = 'initial-balance' - reconciliation = 'reconciliation' + asset = 'asset' + expense = 'expense' + import_ = 'import' + revenue = 'revenue' + cash = 'cash' + liability = 'liability' + liabilities = 'liabilities' + initial_balance = 'initial-balance' + reconciliation = 'reconciliation' class TransactionTypeProperty(Enum): - withdrawal = 'withdrawal' - deposit = 'deposit' - transfer = 'transfer' - reconciliation = 'reconciliation' - opening_balance = 'opening balance' + withdrawal = 'withdrawal' + deposit = 'deposit' + transfer = 'transfer' + reconciliation = 'reconciliation' + opening_balance = 'opening balance' class BadRequestResponse(BaseModel): - message: str | None = Field(None, examples=['Bad Request']) - exception: str | None = Field(None, examples=['BadRequestHttpException']) + message: str | None = Field(None, examples=['Bad Request']) + exception: str | None = Field(None, examples=['BadRequestHttpException']) class InternalExceptionResponse(BaseModel): - message: str | None = Field(None, examples=['Internal Exception']) - exception: str | None = Field(None, examples=['InternalException']) + message: str | None = Field(None, examples=['Internal Exception']) + exception: str | None = Field(None, examples=['InternalException']) class NotFoundResponse(BaseModel): - message: str | None = Field(None, examples=['Resource not found']) - exception: str | None = Field(None, examples=['NotFoundHttpException']) + message: str | None = Field(None, examples=['Resource not found']) + exception: str | None = Field(None, examples=['NotFoundHttpException']) class UnauthenticatedResponse(BaseModel): - message: str | None = Field(None, examples=['Unauthenticated']) - exception: str | None = Field(None, examples=['AuthenticationException']) + message: str | None = Field(None, examples=['Unauthenticated']) + exception: str | None = Field(None, examples=['AuthenticationException']) class Errors(BaseModel): - email: list[str] | None = None - force: list[str] | None = None - blocked: list[str] | None = None - field: list[str] | None = None - role: list[str] | None = None - blocked_code: list[str] | None = None - name: list[str] | None = None - type: list[str] | None = None - iban: list[str] | None = None - start: list[str] | None = None - end: list[str] | None = None - date: list[str] | None = None + email: list[str] | None = None + force: list[str] | None = None + blocked: list[str] | None = None + field: list[str] | None = None + role: list[str] | None = None + blocked_code: list[str] | None = None + name: list[str] | None = None + type: list[str] | None = None + iban: list[str] | None = None + start: list[str] | None = None + end: list[str] | None = None + date: list[str] | None = None class ValidationErrorResponse(BaseModel): - message: str | None = Field(None, examples=['The given data was invalid.']) - errors: Errors | None = None + message: str | None = Field(None, examples=['The given data was invalid.']) + errors: Errors | None = None class AutocompleteAccountArray(RootModel[list[AutocompleteAccount]]): - root: list[AutocompleteAccount] + root: list[AutocompleteAccount] class AutocompleteBillArray(RootModel[list[AutocompleteBill]]): - root: list[AutocompleteBill] + root: list[AutocompleteBill] class AutocompleteBudgetArray(RootModel[list[AutocompleteBudget]]): - root: list[AutocompleteBudget] + root: list[AutocompleteBudget] class AutocompleteCategoryArray(RootModel[list[AutocompleteCategory]]): - root: list[AutocompleteCategory] + root: list[AutocompleteCategory] class AutocompleteCurrencyArray(RootModel[list[AutocompleteCurrency]]): - root: list[AutocompleteCurrency] + root: list[AutocompleteCurrency] class AutocompleteCurrencyCodeArray(RootModel[list[AutocompleteCurrencyCode]]): - root: list[AutocompleteCurrencyCode] + root: list[AutocompleteCurrencyCode] class AutocompleteObjectGroupArray(RootModel[list[AutocompleteObjectGroup]]): - root: list[AutocompleteObjectGroup] + root: list[AutocompleteObjectGroup] class AutocompletePiggyArray(RootModel[list[AutocompletePiggy]]): - root: list[AutocompletePiggy] + root: list[AutocompletePiggy] class AutocompletePiggyBalanceArray(RootModel[list[AutocompletePiggyBalance]]): - root: list[AutocompletePiggyBalance] + root: list[AutocompletePiggyBalance] class AutocompleteRecurrenceArray(RootModel[list[AutocompleteRecurrence]]): - root: list[AutocompleteRecurrence] + root: list[AutocompleteRecurrence] class AutocompleteRuleArray(RootModel[list[AutocompleteRule]]): - root: list[AutocompleteRule] + root: list[AutocompleteRule] class AutocompleteRuleGroupArray(RootModel[list[AutocompleteRuleGroup]]): - root: list[AutocompleteRuleGroup] + root: list[AutocompleteRuleGroup] class AutocompleteTagArray(RootModel[list[AutocompleteTag]]): - root: list[AutocompleteTag] + root: list[AutocompleteTag] class AutocompleteTransactionArray(RootModel[list[AutocompleteTransaction]]): - root: list[AutocompleteTransaction] + root: list[AutocompleteTransaction] class AutocompleteTransactionIDArray(RootModel[list[AutocompleteTransactionID]]): - root: list[AutocompleteTransactionID] + root: list[AutocompleteTransactionID] class AutocompleteTransactionTypeArray(RootModel[list[AutocompleteTransactionType]]): - root: list[AutocompleteTransactionType] + root: list[AutocompleteTransactionType] class AvailableBudgetArray(BaseModel): - data: list[AvailableBudgetRead] - meta: Meta + data: list[AvailableBudgetRead] + meta: Meta class BudgetLimitArray(BaseModel): - data: list[BudgetLimitRead] - meta: Meta + data: list[BudgetLimitRead] + meta: Meta class ChartDataSet(BaseModel): - label: str | None = Field( - None, - description='This is the title of the current set. It can refer to an account, a budget or another object (by name).', - examples=['Checking account'], - ) - currency_id: str | None = Field( - None, - description='The currency ID of the currency associated with this object.', - examples=['5'], - ) - currency_name: str | None = Field( - None, - description='The currency name of the currency associated with this object.', - examples=['Euro'], - ) - currency_code: str | None = Field( - None, - description='The currency code of the currency associated with this object.', - examples=['EUR'], - ) - currency_symbol: str | None = Field(None, examples=['$']) - currency_decimal_places: int | None = Field(None, examples=[2]) - primary_currency_id: str | None = Field( - None, - description="The currency ID of the administration's primary currency.", - examples=['5'], - ) - primary_currency_name: str | None = Field( - None, - description="The currency name of the administration's primary currency.", - examples=['Euro'], - ) - primary_currency_code: str | None = Field( - None, - description="The currency code of the administration's primary currency.", - examples=['EUR'], - ) - primary_currency_symbol: str | None = Field( - None, - description="The currency symbol of the administration's primary currency.", - examples=['$'], - ) - primary_currency_decimal_places: int | None = Field( - None, - description="The currency decimal places of the administration's primary currency.", - examples=[2], - ) - date: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - start_date: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - end_date: AwareDatetime | None = Field(None, examples=['2025-12-31T23:59:59+00:00']) - type: str | None = Field( - None, - description='Indicated the type of chart that is expected to be rendered. You can safely ignore this if you want.', - examples=['line'], - ) - period: ChartDatasetPeriodProperty | None = None - yAxisID: int | None = Field( - None, - description='Used to indicate the Y axis for this data set. Is usually between 0 and 1 (left and right side of the chart).', - examples=[0], - ) - entries: list[ChartDataPoint] | Entries | None = Field( - None, - description="The actual entries for this data set. They 'key' value is the label for the data point. The value is the actual (numerical) value.", - ) - pc_entries: list[ChartDataPoint] | PcEntries | None = Field( - None, - description="The actual entries for this data set. They 'key' value is the label for the data point. The value is the actual (numerical) value.", - ) + label: str | None = Field( + None, + description='This is the title of the current set. It can refer to an account, a budget or another object (by name).', + examples=['Checking account'], + ) + currency_id: str | None = Field( + None, + description='The currency ID of the currency associated with this object.', + examples=['5'], + ) + currency_name: str | None = Field( + None, + description='The currency name of the currency associated with this object.', + examples=['Euro'], + ) + currency_code: str | None = Field( + None, + description='The currency code of the currency associated with this object.', + examples=['EUR'], + ) + currency_symbol: str | None = Field(None, examples=['$']) + currency_decimal_places: int | None = Field(None, examples=[2]) + primary_currency_id: str | None = Field( + None, + description="The currency ID of the administration's primary currency.", + examples=['5'], + ) + primary_currency_name: str | None = Field( + None, + description="The currency name of the administration's primary currency.", + examples=['Euro'], + ) + primary_currency_code: str | None = Field( + None, + description="The currency code of the administration's primary currency.", + examples=['EUR'], + ) + primary_currency_symbol: str | None = Field( + None, + description="The currency symbol of the administration's primary currency.", + examples=['$'], + ) + primary_currency_decimal_places: int | None = Field( + None, + description="The currency decimal places of the administration's primary currency.", + examples=[2], + ) + date: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + start_date: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + end_date: AwareDatetime | None = Field(None, examples=['2025-12-31T23:59:59+00:00']) + type: str | None = Field( + None, + description='Indicated the type of chart that is expected to be rendered. You can safely ignore this if you want.', + examples=['line'], + ) + period: ChartDatasetPeriodProperty | None = None + yAxisID: int | None = Field( + None, + description='Used to indicate the Y axis for this data set. Is usually between 0 and 1 (left and right side of the chart).', + examples=[0], + ) + entries: list[ChartDataPoint] | Entries | None = Field( + None, + description="The actual entries for this data set. They 'key' value is the label for the data point. The value is the actual (numerical) value.", + ) + pc_entries: list[ChartDataPoint] | PcEntries | None = Field( + None, + description="The actual entries for this data set. They 'key' value is the label for the data point. The value is the actual (numerical) value.", + ) class ChartLine(RootModel[list[ChartDataSet]]): - root: list[ChartDataSet] + root: list[ChartDataSet] class InsightGroup(RootModel[list[InsightGroupEntry]]): - root: list[InsightGroupEntry] + root: list[InsightGroupEntry] class InsightTotal(RootModel[list[InsightTotalEntry]]): - root: list[InsightTotalEntry] + root: list[InsightTotalEntry] class InsightTransfer(RootModel[list[InsightTransferEntry]]): - root: list[InsightTransferEntry] + root: list[InsightTransferEntry] class CategoryRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['categories']) - id: str = Field(..., examples=['2']) - attributes: CategoryProperties + type: str = Field(..., description='Immutable value', examples=['categories']) + id: str = Field(..., examples=['2']) + attributes: CategoryProperties class CategorySingle(BaseModel): - data: CategoryRead + data: CategoryRead class CurrencyRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['currencies']) - id: str = Field(..., examples=['2']) - attributes: CurrencyProperties + type: str = Field(..., description='Immutable value', examples=['currencies']) + id: str = Field(..., examples=['2']) + attributes: CurrencyProperties class CurrencySingle(BaseModel): - data: CurrencyRead + data: CurrencyRead class LinkTypeRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['link_types']) - id: str = Field(..., examples=['2']) - attributes: LinkType - links: ObjectLink + type: str = Field(..., description='Immutable value', examples=['link_types']) + id: str = Field(..., examples=['2']) + attributes: LinkType + links: ObjectLink class LinkTypeSingle(BaseModel): - data: LinkTypeRead + data: LinkTypeRead class ObjectGroupRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['object_groups']) - id: str = Field(..., examples=['2']) - attributes: ObjectGroup + type: str = Field(..., description='Immutable value', examples=['object_groups']) + id: str = Field(..., examples=['2']) + attributes: ObjectGroup class ObjectGroupSingle(BaseModel): - data: ObjectGroupRead + data: ObjectGroupRead class PiggyBankEventRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['piggy_bank_events']) - id: str = Field(..., examples=['2']) - attributes: PiggyBankEventProperties - links: ObjectLink + type: str = Field(..., description='Immutable value', examples=['piggy_bank_events']) + id: str = Field(..., examples=['2']) + attributes: PiggyBankEventProperties + links: ObjectLink class PiggyBankRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['piggy_banks']) - id: str = Field(..., examples=['2']) - attributes: PiggyBankProperties - links: ObjectLink + type: str = Field(..., description='Immutable value', examples=['piggy_banks']) + id: str = Field(..., examples=['2']) + attributes: PiggyBankProperties + links: ObjectLink class PiggyBankSingle(BaseModel): - data: PiggyBankRead + data: PiggyBankRead class RuleGroupRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['rules_group']) - id: str = Field(..., examples=['2']) - attributes: RuleGroup - links: ObjectLink + type: str = Field(..., description='Immutable value', examples=['rules_group']) + id: str = Field(..., examples=['2']) + attributes: RuleGroup + links: ObjectLink class RuleGroupSingle(BaseModel): - data: RuleGroupRead + data: RuleGroupRead class TagRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['tags']) - id: str = Field(..., examples=['2']) - attributes: TagModel - links: ObjectLink + type: str = Field(..., description='Immutable value', examples=['tags']) + id: str = Field(..., examples=['2']) + attributes: TagModel + links: ObjectLink class TagSingle(BaseModel): - data: TagRead + data: TagRead class TransactionLinkRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['transactionLinks']) - id: str = Field(..., examples=['2']) - attributes: TransactionLink - links: ObjectLink + type: str = Field(..., description='Immutable value', examples=['transactionLinks']) + id: str = Field(..., examples=['2']) + attributes: TransactionLink + links: ObjectLink class TransactionLinkSingle(BaseModel): - data: TransactionLinkRead + data: TransactionLinkRead class WebhookAttemptRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['webhook_attempts']) - id: str = Field(..., examples=['2']) - attributes: WebhookAttempt + type: str = Field(..., description='Immutable value', examples=['webhook_attempts']) + id: str = Field(..., examples=['2']) + attributes: WebhookAttempt class WebhookAttemptSingle(BaseModel): - data: WebhookAttemptRead + data: WebhookAttemptRead class WebhookMessageRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['webhook_messages']) - id: str = Field(..., examples=['2']) - attributes: WebhookMessage + type: str = Field(..., description='Immutable value', examples=['webhook_messages']) + id: str = Field(..., examples=['2']) + attributes: WebhookMessage class WebhookMessageSingle(BaseModel): - data: WebhookMessageRead + data: WebhookMessageRead class AccountProperties(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - active: bool | None = Field(True, examples=[False]) - order: int | None = Field( - None, - description='Order of the account. Is NULL if account is not asset or liability.', - examples=[1], - ) - name: str = Field(..., examples=['My checking account']) - type: ShortAccountTypeProperty - account_role: AccountRoleProperty | None = None - object_group_id: str | None = Field( - None, - description='The group ID of the group this object is part of. NULL if no group.', - examples=['5'], - ) - object_group_order: int | None = Field( - None, - description='The order of the group. At least 1, for the highest sorting.', - examples=[5], - ) - object_group_title: str | None = Field( - None, - description='The name of the group. NULL if no group.', - examples=['Example Group'], - ) - object_has_currency_setting: bool | None = Field( - None, - description="Indicates whether the account has a currency setting. If false, the account uses the administration's primary currency. Asset accounts and liability accounts always have a currency setting, while expense and revenue accounts do not.", - examples=[True], - ) - currency_id: str | None = Field( - None, - description='The currency ID of the currency associated with this object.', - examples=['5'], - ) - currency_name: str | None = Field( - None, - description='The currency name of the currency associated with this object.', - examples=['Euro'], - ) - currency_code: str | None = Field( - None, - description='The currency code of the currency associated with this object.', - examples=['EUR'], - ) - currency_symbol: str | None = Field(None, examples=['$']) - currency_decimal_places: int | None = Field(None, examples=[2]) - primary_currency_id: str | None = Field( - None, - description="The currency ID of the administration's primary currency.", - examples=['5'], - ) - primary_currency_name: str | None = Field( - None, - description="The currency name of the administration's primary currency.", - examples=['Euro'], - ) - primary_currency_code: str | None = Field( - None, - description="The currency code of the administration's primary currency.", - examples=['EUR'], - ) - primary_currency_symbol: str | None = Field( - None, - description="The currency symbol of the administration's primary currency.", - examples=['$'], - ) - primary_currency_decimal_places: int | None = Field( - None, - description="The currency decimal places of the administration's primary currency.", - examples=[2], - ) - current_balance: str | None = Field( - None, - description="The current balance of the account in the account's currency. If the account has no currency, this is the balance in the administration's primary currency. Either way, the `currency_*` fields reflect the currency used.", - examples=['123.45'], - ) - pc_current_balance: str | None = Field( - None, - description="The current balance of the account in the administration's primary currency. The `primary_currency_*` fields reflect the currency used. This field is NULL if the user does have 'convert to primary' set to true in their settings.", - examples=['123.45'], - ) - balance_difference: str | None = Field( - None, - description='If you submit a start AND end date, this will be the difference between those two moments.', - examples=['123.45'], - ) - pc_balance_difference: str | None = Field( - None, - description="If you submit a start AND end date, this will be the difference in the currency of the account or the administration's primary currency between those two moments.", - examples=['123.45'], - ) - opening_balance: str | None = Field( - None, - description="Represents the opening balance, the initial amount this account holds in the currency of the account or the administration's primary currency if the account has no currency. Either way, the `currency_*` fields reflect the currency used.", - examples=['-1012.12'], - ) - pc_opening_balance: str | None = Field( - None, - description="The opening balance of the account in the administration's primary currency (pc). The `primary_currency_*` fields reflect the currency used. This field is NULL if the user does have 'convert to primary' set to true in their settings.", - examples=['-1012.12'], - ) - virtual_balance: str | None = Field( - None, - description="The virtual balance of the account in the account's currency or the administration's primary currency if the account has no currency.", - examples=['123.45'], - ) - pc_virtual_balance: str | None = Field( - None, - description="The virtual balance of the account in the administration's primary currency (pc). The `primary_currency_*` fields reflect the currency used. This field is NULL if the user does have 'convert to primary' set to true in their settings.", - examples=['123.45'], - ) - debt_amount: str | None = Field( - None, - description="In liability accounts (loans, debts and mortgages), this is the amount of debt in the account's currency (see the `currency_*` fields). In asset accounts, this is NULL.", - examples=['1012.12'], - ) - pc_debt_amount: str | None = Field( - None, - description="In liability accounts (loans, debts and mortgages), this is the amount of debt in the administration's primary currency (see the `currency_*` fields. In asset accounts, this is NULL.", - examples=['1012.12'], - ) - current_balance_date: AwareDatetime | None = Field( - None, - description="The timestamp for this date is always 23:59:59, to indicate it's the balance at the very END of that particular day.", - examples=['2025-12-31T23:59:59+00:00'], - ) - notes: str | None = Field(None, examples=['Some example notes']) - monthly_payment_date: AwareDatetime | None = Field( - None, - description='Mandatory when the account_role is ccAsset. Moment at which CC payment installments are asked for by the bank.', - examples=['2025-12-01T00:00:00+00:00'], - ) - credit_card_type: CreditCardTypeProperty | None = None - account_number: str | None = Field(None, examples=['7009312345678']) - iban: str | None = Field(None, examples=['GB98MIDL07009312345678']) - bic: str | None = Field(None, examples=['BOFAUS3N']) - opening_balance_date: AwareDatetime | None = Field( - None, - description='Represents the date of the opening balance.', - examples=['2025-12-01T00:00:00+00:00'], - ) - liability_type: LiabilityTypeProperty | None = None - liability_direction: LiabilityDirectionProperty | None = None - interest: str | None = Field( - None, - description='Mandatory when type is liability. Interest percentage.', - examples=['5.3'], - ) - interest_period: InterestPeriodProperty | None = None - include_net_worth: bool | None = Field(True, examples=[True]) - longitude: float | None = Field( - None, - description="Latitude of the accounts's location, if applicable. Can be used to draw a map.", - examples=[5.916667], - ) - latitude: float | None = Field( - None, - description="Latitude of the accounts's location, if applicable. Can be used to draw a map.", - examples=[51.983333], - ) - zoom_level: int | None = Field( - None, - description='Zoom level for the map, if drawn. This to set the box right. Unfortunately this is a proprietary value because each map provider has different zoom levels.', - examples=[6], - ) - last_activity: AwareDatetime | None = Field( - None, - description='Last activity of the account.', - examples=['2025-12-01T00:00:00+00:00'], - ) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + active: bool | None = Field(True, examples=[False]) + order: int | None = Field( + None, + description='Order of the account. Is NULL if account is not asset or liability.', + examples=[1], + ) + name: str = Field(..., examples=['My checking account']) + type: ShortAccountTypeProperty + account_role: AccountRoleProperty | None = None + object_group_id: str | None = Field( + None, + description='The group ID of the group this object is part of. NULL if no group.', + examples=['5'], + ) + object_group_order: int | None = Field( + None, + description='The order of the group. At least 1, for the highest sorting.', + examples=[5], + ) + object_group_title: str | None = Field( + None, + description='The name of the group. NULL if no group.', + examples=['Example Group'], + ) + object_has_currency_setting: bool | None = Field( + None, + description="Indicates whether the account has a currency setting. If false, the account uses the administration's primary currency. Asset accounts and liability accounts always have a currency setting, while expense and revenue accounts do not.", + examples=[True], + ) + currency_id: str | None = Field( + None, + description='The currency ID of the currency associated with this object.', + examples=['5'], + ) + currency_name: str | None = Field( + None, + description='The currency name of the currency associated with this object.', + examples=['Euro'], + ) + currency_code: str | None = Field( + None, + description='The currency code of the currency associated with this object.', + examples=['EUR'], + ) + currency_symbol: str | None = Field(None, examples=['$']) + currency_decimal_places: int | None = Field(None, examples=[2]) + primary_currency_id: str | None = Field( + None, + description="The currency ID of the administration's primary currency.", + examples=['5'], + ) + primary_currency_name: str | None = Field( + None, + description="The currency name of the administration's primary currency.", + examples=['Euro'], + ) + primary_currency_code: str | None = Field( + None, + description="The currency code of the administration's primary currency.", + examples=['EUR'], + ) + primary_currency_symbol: str | None = Field( + None, + description="The currency symbol of the administration's primary currency.", + examples=['$'], + ) + primary_currency_decimal_places: int | None = Field( + None, + description="The currency decimal places of the administration's primary currency.", + examples=[2], + ) + current_balance: str | None = Field( + None, + description="The current balance of the account in the account's currency. If the account has no currency, this is the balance in the administration's primary currency. Either way, the `currency_*` fields reflect the currency used.", + examples=['123.45'], + ) + pc_current_balance: str | None = Field( + None, + description="The current balance of the account in the administration's primary currency. The `primary_currency_*` fields reflect the currency used. This field is NULL if the user does have 'convert to primary' set to true in their settings.", + examples=['123.45'], + ) + balance_difference: str | None = Field( + None, + description='If you submit a start AND end date, this will be the difference between those two moments.', + examples=['123.45'], + ) + pc_balance_difference: str | None = Field( + None, + description="If you submit a start AND end date, this will be the difference in the currency of the account or the administration's primary currency between those two moments.", + examples=['123.45'], + ) + opening_balance: str | None = Field( + None, + description="Represents the opening balance, the initial amount this account holds in the currency of the account or the administration's primary currency if the account has no currency. Either way, the `currency_*` fields reflect the currency used.", + examples=['-1012.12'], + ) + pc_opening_balance: str | None = Field( + None, + description="The opening balance of the account in the administration's primary currency (pc). The `primary_currency_*` fields reflect the currency used. This field is NULL if the user does have 'convert to primary' set to true in their settings.", + examples=['-1012.12'], + ) + virtual_balance: str | None = Field( + None, + description="The virtual balance of the account in the account's currency or the administration's primary currency if the account has no currency.", + examples=['123.45'], + ) + pc_virtual_balance: str | None = Field( + None, + description="The virtual balance of the account in the administration's primary currency (pc). The `primary_currency_*` fields reflect the currency used. This field is NULL if the user does have 'convert to primary' set to true in their settings.", + examples=['123.45'], + ) + debt_amount: str | None = Field( + None, + description="In liability accounts (loans, debts and mortgages), this is the amount of debt in the account's currency (see the `currency_*` fields). In asset accounts, this is NULL.", + examples=['1012.12'], + ) + pc_debt_amount: str | None = Field( + None, + description="In liability accounts (loans, debts and mortgages), this is the amount of debt in the administration's primary currency (see the `currency_*` fields. In asset accounts, this is NULL.", + examples=['1012.12'], + ) + current_balance_date: AwareDatetime | None = Field( + None, + description="The timestamp for this date is always 23:59:59, to indicate it's the balance at the very END of that particular day.", + examples=['2025-12-31T23:59:59+00:00'], + ) + notes: str | None = Field(None, examples=['Some example notes']) + monthly_payment_date: AwareDatetime | None = Field( + None, + description='Mandatory when the account_role is ccAsset. Moment at which CC payment installments are asked for by the bank.', + examples=['2025-12-01T00:00:00+00:00'], + ) + credit_card_type: CreditCardTypeProperty | None = None + account_number: str | None = Field(None, examples=['7009312345678']) + iban: str | None = Field(None, examples=['GB98MIDL07009312345678']) + bic: str | None = Field(None, examples=['BOFAUS3N']) + opening_balance_date: AwareDatetime | None = Field( + None, + description='Represents the date of the opening balance.', + examples=['2025-12-01T00:00:00+00:00'], + ) + liability_type: LiabilityTypeProperty | None = None + liability_direction: LiabilityDirectionProperty | None = None + interest: str | None = Field( + None, + description='Mandatory when type is liability. Interest percentage.', + examples=['5.3'], + ) + interest_period: InterestPeriodProperty | None = None + include_net_worth: bool | None = Field(True, examples=[True]) + longitude: float | None = Field( + None, + description="Latitude of the accounts's location, if applicable. Can be used to draw a map.", + examples=[5.916667], + ) + latitude: float | None = Field( + None, + description="Latitude of the accounts's location, if applicable. Can be used to draw a map.", + examples=[51.983333], + ) + zoom_level: int | None = Field( + None, + description='Zoom level for the map, if drawn. This to set the box right. Unfortunately this is a proprietary value because each map provider has different zoom levels.', + examples=[6], + ) + last_activity: AwareDatetime | None = Field( + None, + description='Last activity of the account.', + examples=['2025-12-01T00:00:00+00:00'], + ) class AccountRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['accounts']) - id: str = Field(..., examples=['2']) - attributes: AccountProperties + type: str = Field(..., description='Immutable value', examples=['accounts']) + id: str = Field(..., examples=['2']) + attributes: AccountProperties class AccountSingle(BaseModel): - data: AccountRead + data: AccountRead class AccountStore(BaseModel): - name: str = Field(..., examples=['My checking account']) - type: ShortAccountTypeProperty - iban: str | None = Field(None, examples=['GB98MIDL07009312345678']) - bic: str | None = Field(None, examples=['BOFAUS3N']) - account_number: str | None = Field(None, examples=['7009312345678']) - opening_balance: str | None = Field( - None, - description='Represents the opening balance, the initial amount this account holds.', - examples=['-1012.12'], - ) - opening_balance_date: AwareDatetime | None = Field( - None, - description='Represents the date of the opening balance.', - examples=['2025-12-01T00:00:00+00:00'], - ) - virtual_balance: str | None = Field(None, examples=['123.45']) - currency_id: str | None = Field( - None, - description="Use either currency_id or currency_code. Defaults to the user's financial administration's currency.", - examples=['12'], - ) - currency_code: str | None = Field( - None, - description="Use either currency_id or currency_code. Defaults to the user's financial administration's currency.", - examples=['EUR'], - ) - active: bool | None = Field(True, description='If omitted, defaults to true.', examples=[False]) - order: int | None = Field(None, description='Order of the account', examples=[1]) - include_net_worth: bool | None = Field( - True, description='If omitted, defaults to true.', examples=[True] - ) - account_role: AccountRoleProperty | None = None - credit_card_type: CreditCardTypeProperty | None = None - monthly_payment_date: AwareDatetime | None = Field( - None, - description='Mandatory when the account_role is ccAsset. Moment at which CC payment installments are asked for by the bank.', - examples=['2025-12-01T00:00:00+00:00'], - ) - liability_type: LiabilityTypeProperty | None = None - liability_direction: LiabilityDirectionProperty | None = None - interest: str | None = Field( - '0', - description='Mandatory when type is liability. Interest percentage.', - examples=['5.3'], - ) - interest_period: InterestPeriodProperty | None = None - notes: str | None = Field(None, examples=['Some example notes']) - latitude: float | None = Field( - None, - description="Latitude of the accounts's location, if applicable. Can be used to draw a map.", - examples=[51.983333], - ) - longitude: float | None = Field( - None, - description="Latitude of the accounts's location, if applicable. Can be used to draw a map.", - examples=[5.916667], - ) - zoom_level: int | None = Field( - None, - description='Zoom level for the map, if drawn. This to set the box right. Unfortunately this is a proprietary value because each map provider has different zoom levels.', - examples=[6], - ) + name: str = Field(..., examples=['My checking account']) + type: ShortAccountTypeProperty + iban: str | None = Field(None, examples=['GB98MIDL07009312345678']) + bic: str | None = Field(None, examples=['BOFAUS3N']) + account_number: str | None = Field(None, examples=['7009312345678']) + opening_balance: str | None = Field( + None, + description='Represents the opening balance, the initial amount this account holds.', + examples=['-1012.12'], + ) + opening_balance_date: AwareDatetime | None = Field( + None, + description='Represents the date of the opening balance.', + examples=['2025-12-01T00:00:00+00:00'], + ) + virtual_balance: str | None = Field(None, examples=['123.45']) + currency_id: str | None = Field( + None, + description="Use either currency_id or currency_code. Defaults to the user's financial administration's currency.", + examples=['12'], + ) + currency_code: str | None = Field( + None, + description="Use either currency_id or currency_code. Defaults to the user's financial administration's currency.", + examples=['EUR'], + ) + active: bool | None = Field(True, description='If omitted, defaults to true.', examples=[False]) + order: int | None = Field(None, description='Order of the account', examples=[1]) + include_net_worth: bool | None = Field( + True, description='If omitted, defaults to true.', examples=[True] + ) + account_role: AccountRoleProperty | None = None + credit_card_type: CreditCardTypeProperty | None = None + monthly_payment_date: AwareDatetime | None = Field( + None, + description='Mandatory when the account_role is ccAsset. Moment at which CC payment installments are asked for by the bank.', + examples=['2025-12-01T00:00:00+00:00'], + ) + liability_type: LiabilityTypeProperty | None = None + liability_direction: LiabilityDirectionProperty | None = None + interest: str | None = Field( + '0', + description='Mandatory when type is liability. Interest percentage.', + examples=['5.3'], + ) + interest_period: InterestPeriodProperty | None = None + notes: str | None = Field(None, examples=['Some example notes']) + latitude: float | None = Field( + None, + description="Latitude of the accounts's location, if applicable. Can be used to draw a map.", + examples=[51.983333], + ) + longitude: float | None = Field( + None, + description="Latitude of the accounts's location, if applicable. Can be used to draw a map.", + examples=[5.916667], + ) + zoom_level: int | None = Field( + None, + description='Zoom level for the map, if drawn. This to set the box right. Unfortunately this is a proprietary value because each map provider has different zoom levels.', + examples=[6], + ) class AccountUpdate(BaseModel): - name: str = Field(..., examples=['My checking account']) - iban: str | None = Field(None, examples=['GB98MIDL07009312345678']) - bic: str | None = Field(None, examples=['BOFAUS3N']) - account_number: str | None = Field(None, examples=['7009312345678']) - opening_balance: str | None = Field(None, examples=['-1012.12']) - opening_balance_date: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - virtual_balance: str | None = Field(None, examples=['123.45']) - currency_id: str | None = Field( - None, - description="Use either currency_id or currency_code. Defaults to the user's financial administration's currency.", - examples=['12'], - ) - currency_code: str | None = Field( - None, - description="Use either currency_id or currency_code. Defaults to the user's financial administration's currency.", - examples=['EUR'], - ) - active: bool | None = Field(True, description='If omitted, defaults to true.', examples=[False]) - order: int | None = Field(None, description='Order of the account', examples=[1]) - include_net_worth: bool | None = Field( - True, description='If omitted, defaults to true.', examples=[True] - ) - account_role: AccountRoleProperty | None = None - credit_card_type: CreditCardTypeProperty | None = None - monthly_payment_date: AwareDatetime | None = Field( - None, - description='Mandatory when the account_role is ccAsset. Moment at which CC payment installments are asked for by the bank.', - examples=['2025-12-01T00:00:00+00:00'], - ) - liability_type: LiabilityTypeProperty | None = None - interest: str | None = Field( - None, - description='Mandatory when type is liability. Interest percentage.', - examples=['5.3'], - ) - interest_period: InterestPeriodProperty | None = None - notes: str | None = Field(None, examples=['Some example notes']) - latitude: float | None = Field( - None, - description="Latitude of the account's location, if applicable. Can be used to draw a map. If omitted, the existing location will be kept. If submitted as NULL, the current location will be removed.", - examples=[51.983333], - ) - longitude: float | None = Field( - None, - description="Latitude of the account's location, if applicable. Can be used to draw a map. If omitted, the existing location will be kept. If submitted as NULL, the current location will be removed.", - examples=[5.916667], - ) - zoom_level: int | None = Field( - None, - description='Zoom level for the map, if drawn. This to set the box right. Unfortunately this is a proprietary value because each map provider has different zoom levels. If omitted, the existing location will be kept. If submitted as NULL, the current location will be removed.', - examples=[6], - ) + name: str = Field(..., examples=['My checking account']) + iban: str | None = Field(None, examples=['GB98MIDL07009312345678']) + bic: str | None = Field(None, examples=['BOFAUS3N']) + account_number: str | None = Field(None, examples=['7009312345678']) + opening_balance: str | None = Field(None, examples=['-1012.12']) + opening_balance_date: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + virtual_balance: str | None = Field(None, examples=['123.45']) + currency_id: str | None = Field( + None, + description="Use either currency_id or currency_code. Defaults to the user's financial administration's currency.", + examples=['12'], + ) + currency_code: str | None = Field( + None, + description="Use either currency_id or currency_code. Defaults to the user's financial administration's currency.", + examples=['EUR'], + ) + active: bool | None = Field(True, description='If omitted, defaults to true.', examples=[False]) + order: int | None = Field(None, description='Order of the account', examples=[1]) + include_net_worth: bool | None = Field( + True, description='If omitted, defaults to true.', examples=[True] + ) + account_role: AccountRoleProperty | None = None + credit_card_type: CreditCardTypeProperty | None = None + monthly_payment_date: AwareDatetime | None = Field( + None, + description='Mandatory when the account_role is ccAsset. Moment at which CC payment installments are asked for by the bank.', + examples=['2025-12-01T00:00:00+00:00'], + ) + liability_type: LiabilityTypeProperty | None = None + interest: str | None = Field( + None, + description='Mandatory when type is liability. Interest percentage.', + examples=['5.3'], + ) + interest_period: InterestPeriodProperty | None = None + notes: str | None = Field(None, examples=['Some example notes']) + latitude: float | None = Field( + None, + description="Latitude of the account's location, if applicable. Can be used to draw a map. If omitted, the existing location will be kept. If submitted as NULL, the current location will be removed.", + examples=[51.983333], + ) + longitude: float | None = Field( + None, + description="Latitude of the account's location, if applicable. Can be used to draw a map. If omitted, the existing location will be kept. If submitted as NULL, the current location will be removed.", + examples=[5.916667], + ) + zoom_level: int | None = Field( + None, + description='Zoom level for the map, if drawn. This to set the box right. Unfortunately this is a proprietary value because each map provider has different zoom levels. If omitted, the existing location will be kept. If submitted as NULL, the current location will be removed.', + examples=[6], + ) class AttachmentProperties(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - attachable_type: AttachableType | None = None - attachable_id: str | None = Field( - None, - description='ID of the model this attachment is linked to.', - examples=['134'], - ) - hash: str | None = Field( - None, - description='Hash of the file for basic duplicate detection.', - examples=['0c3f95f34370baa88f9fd9a671fea305'], - ) - filename: str | None = Field(None, examples=['file.pdf']) - download_url: str | None = Field( - None, examples=['https://demo.firefly-iii.org/api/v1/attachments/191/download'] - ) - upload_url: str | None = Field( - None, examples=['https://demo.firefly-iii.org/api/v1/attachments/191/download'] - ) - title: str | None = Field(None, examples=['Some PDF file']) - notes: str | None = Field(None, examples=['Some notes']) - mime: str | None = Field(None, examples=['application/pdf']) - size: int | None = Field(None, examples=[48211]) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + attachable_type: AttachableType | None = None + attachable_id: str | None = Field( + None, + description='ID of the model this attachment is linked to.', + examples=['134'], + ) + hash: str | None = Field( + None, + description='Hash of the file for basic duplicate detection.', + examples=['0c3f95f34370baa88f9fd9a671fea305'], + ) + filename: str | None = Field(None, examples=['file.pdf']) + download_url: str | None = Field( + None, examples=['https://demo.firefly-iii.org/api/v1/attachments/191/download'] + ) + upload_url: str | None = Field( + None, examples=['https://demo.firefly-iii.org/api/v1/attachments/191/download'] + ) + title: str | None = Field(None, examples=['Some PDF file']) + notes: str | None = Field(None, examples=['Some notes']) + mime: str | None = Field(None, examples=['application/pdf']) + size: int | None = Field(None, examples=[48211]) class AttachmentRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['attachments']) - id: str = Field(..., examples=['2']) - attributes: AttachmentProperties - links: ObjectLink + type: str = Field(..., description='Immutable value', examples=['attachments']) + id: str = Field(..., examples=['2']) + attributes: AttachmentProperties + links: ObjectLink class AttachmentSingle(BaseModel): - data: AttachmentRead + data: AttachmentRead class AttachmentStore(BaseModel): - filename: str = Field(..., examples=['file.pdf']) - attachable_type: AttachableType - attachable_id: str = Field( - ..., - description='ID of the model this attachment is linked to.', - examples=['134'], - ) - title: str | None = Field(None, examples=['Some PDF file']) - notes: str | None = Field(None, examples=['Some notes']) + filename: str = Field(..., examples=['file.pdf']) + attachable_type: AttachableType + attachable_id: str = Field( + ..., + description='ID of the model this attachment is linked to.', + examples=['134'], + ) + title: str | None = Field(None, examples=['Some PDF file']) + notes: str | None = Field(None, examples=['Some notes']) class BillProperties(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - name: str | None = Field(None, description='The name of the subscription.', examples=['Rent']) - object_has_currency_setting: bool | None = Field( - None, - description="Indicates whether the object has a currency setting. If false, the object uses the administration's primary currency.", - examples=[True], - ) - currency_id: str | None = Field( - None, - description='The currency ID of the currency associated with this object.', - examples=['5'], - ) - currency_name: str | None = Field( - None, - description='The currency name of the currency associated with this object.', - examples=['Euro'], - ) - currency_code: str | None = Field( - None, - description='The currency code of the currency associated with this object.', - examples=['EUR'], - ) - currency_symbol: str | None = Field(None, examples=['$']) - currency_decimal_places: int | None = Field(None, examples=[2]) - primary_currency_id: str | None = Field( - None, - description="The currency ID of the administration's primary currency.", - examples=['5'], - ) - primary_currency_name: str | None = Field( - None, - description="The currency name of the administration's primary currency.", - examples=['Euro'], - ) - primary_currency_code: str | None = Field( - None, - description="The currency code of the administration's primary currency.", - examples=['EUR'], - ) - primary_currency_symbol: str | None = Field( - None, - description="The currency symbol of the administration's primary currency.", - examples=['$'], - ) - primary_currency_decimal_places: int | None = Field( - None, - description="The currency decimal places of the administration's primary currency.", - examples=[2], - ) - amount_min: str | None = Field( - None, - description="The minimum amount that is expected for this subscription in the subscription's currency.", - examples=['123.45'], - ) - pc_amount_min: str | None = Field( - None, - description="The minimum amount that is expected for this subscription in the administration's primary currency.", - examples=['123.45'], - ) - amount_max: str | None = Field( - None, - description="The maximum amount that is expected for this subscription in the subscription's currency.", - examples=['123.45'], - ) - pc_amount_max: str | None = Field( - None, - description="The maximum amount that is expected for this subscription in the administration's primary currency.", - examples=['123.45'], - ) - amount_avg: str | None = Field( - None, - description="The average amount that is expected for this subscription in the subscription's currency.", - examples=['123.45'], - ) - pc_amount_avg: str | None = Field( - None, - description="The average amount that is expected for this subscription in the administration's primary currency.", - examples=['123.45'], - ) - date: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - end_date: AwareDatetime | None = Field( - None, - description='The date after which this subscription is no longer valid or applicable', - examples=['2025-12-31T23:59:59+00:00'], - ) - extension_date: AwareDatetime | None = Field( - None, - description='The date before which the subscription must be renewed (or cancelled)', - examples=['2025-12-31T23:59:59+00:00'], - ) - repeat_freq: BillRepeatFrequency | None = None - skip: int | None = Field( - None, - description='How often the subscription will be skipped. 1 means a bi-monthly subscription.', - examples=[0], - ) - active: bool | None = Field(None, description='If the subscription is active.', examples=[True]) - order: int | None = Field(None, description='Order of the subscription.', examples=[1]) - notes: str | None = Field(None, examples=['Some example notes']) - object_group_id: str | None = Field( - None, - description='The group ID of the group this object is part of. NULL if no group.', - examples=['5'], - ) - object_group_order: int | None = Field( - None, - description='The order of the group. At least 1, for the highest sorting.', - examples=[5], - ) - object_group_title: str | None = Field( - None, - description='The name of the group. NULL if no group.', - examples=['Example Group'], - ) - paid_dates: list[PaidDate] | None = Field( - None, description='Array of past transactions when the subscription was paid.' - ) - pay_dates: list[AwareDatetime] | None = Field( - None, - description='Array of future dates when the bill is expected to be paid. Autogenerated.', - ) - next_expected_match: AwareDatetime | None = Field( - None, - description='When the subscription is expected to be due.', - examples=['2025-12-01T00:00:00+00:00'], - ) - next_expected_match_diff: str | None = Field( - None, - description='Formatted (locally) when the subscription is due.', - examples=['today'], - ) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + name: str | None = Field(None, description='The name of the subscription.', examples=['Rent']) + object_has_currency_setting: bool | None = Field( + None, + description="Indicates whether the object has a currency setting. If false, the object uses the administration's primary currency.", + examples=[True], + ) + currency_id: str | None = Field( + None, + description='The currency ID of the currency associated with this object.', + examples=['5'], + ) + currency_name: str | None = Field( + None, + description='The currency name of the currency associated with this object.', + examples=['Euro'], + ) + currency_code: str | None = Field( + None, + description='The currency code of the currency associated with this object.', + examples=['EUR'], + ) + currency_symbol: str | None = Field(None, examples=['$']) + currency_decimal_places: int | None = Field(None, examples=[2]) + primary_currency_id: str | None = Field( + None, + description="The currency ID of the administration's primary currency.", + examples=['5'], + ) + primary_currency_name: str | None = Field( + None, + description="The currency name of the administration's primary currency.", + examples=['Euro'], + ) + primary_currency_code: str | None = Field( + None, + description="The currency code of the administration's primary currency.", + examples=['EUR'], + ) + primary_currency_symbol: str | None = Field( + None, + description="The currency symbol of the administration's primary currency.", + examples=['$'], + ) + primary_currency_decimal_places: int | None = Field( + None, + description="The currency decimal places of the administration's primary currency.", + examples=[2], + ) + amount_min: str | None = Field( + None, + description="The minimum amount that is expected for this subscription in the subscription's currency.", + examples=['123.45'], + ) + pc_amount_min: str | None = Field( + None, + description="The minimum amount that is expected for this subscription in the administration's primary currency.", + examples=['123.45'], + ) + amount_max: str | None = Field( + None, + description="The maximum amount that is expected for this subscription in the subscription's currency.", + examples=['123.45'], + ) + pc_amount_max: str | None = Field( + None, + description="The maximum amount that is expected for this subscription in the administration's primary currency.", + examples=['123.45'], + ) + amount_avg: str | None = Field( + None, + description="The average amount that is expected for this subscription in the subscription's currency.", + examples=['123.45'], + ) + pc_amount_avg: str | None = Field( + None, + description="The average amount that is expected for this subscription in the administration's primary currency.", + examples=['123.45'], + ) + date: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + end_date: AwareDatetime | None = Field( + None, + description='The date after which this subscription is no longer valid or applicable', + examples=['2025-12-31T23:59:59+00:00'], + ) + extension_date: AwareDatetime | None = Field( + None, + description='The date before which the subscription must be renewed (or cancelled)', + examples=['2025-12-31T23:59:59+00:00'], + ) + repeat_freq: BillRepeatFrequency | None = None + skip: int | None = Field( + None, + description='How often the subscription will be skipped. 1 means a bi-monthly subscription.', + examples=[0], + ) + active: bool | None = Field(None, description='If the subscription is active.', examples=[True]) + order: int | None = Field(None, description='Order of the subscription.', examples=[1]) + notes: str | None = Field(None, examples=['Some example notes']) + object_group_id: str | None = Field( + None, + description='The group ID of the group this object is part of. NULL if no group.', + examples=['5'], + ) + object_group_order: int | None = Field( + None, + description='The order of the group. At least 1, for the highest sorting.', + examples=[5], + ) + object_group_title: str | None = Field( + None, + description='The name of the group. NULL if no group.', + examples=['Example Group'], + ) + paid_dates: list[PaidDate] | None = Field( + None, description='Array of past transactions when the subscription was paid.' + ) + pay_dates: list[AwareDatetime] | None = Field( + None, + description='Array of future dates when the bill is expected to be paid. Autogenerated.', + ) + next_expected_match: AwareDatetime | None = Field( + None, + description='When the subscription is expected to be due.', + examples=['2025-12-01T00:00:00+00:00'], + ) + next_expected_match_diff: str | None = Field( + None, + description='Formatted (locally) when the subscription is due.', + examples=['today'], + ) class BillRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['bills']) - id: str = Field(..., examples=['2']) - attributes: BillProperties + type: str = Field(..., description='Immutable value', examples=['bills']) + id: str = Field(..., examples=['2']) + attributes: BillProperties class BillSingle(BaseModel): - data: BillRead + data: BillRead class BillStore(BaseModel): - currency_id: str | None = Field( - None, description='Use either currency_id or currency_code', examples=['5'] - ) - currency_code: str | None = Field( - None, description='Use either currency_id or currency_code', examples=['EUR'] - ) - name: str = Field(..., examples=['Rent']) - amount_min: str = Field(..., examples=['123.45']) - amount_max: str = Field(..., examples=['123.45']) - date: AwareDatetime = Field(..., examples=['2025-12-01T00:00:00+00:00']) - end_date: AwareDatetime | None = Field( - None, - description='The date after which this bill is no longer valid or applicable', - examples=['2025-12-31T23:59:59+00:00'], - ) - extension_date: AwareDatetime | None = Field( - None, - description='The date before which the bill must be renewed (or cancelled)', - examples=['2025-12-31T23:59:59+00:00'], - ) - repeat_freq: BillRepeatFrequency - skip: int | None = Field( - None, - description='How often the bill must be skipped. 1 means a bi-monthly bill.', - examples=[0], - ) - active: bool | None = Field(None, description='If the bill is active.', examples=[True]) - notes: str | None = Field(None, examples=['Some example notes']) - object_group_id: str | None = Field( - None, - description='The group ID of the group this object is part of. NULL if no group.', - examples=['5'], - ) - object_group_title: str | None = Field( - None, - description='The name of the group. NULL if no group.', - examples=['Example Group'], - ) + currency_id: str | None = Field( + None, description='Use either currency_id or currency_code', examples=['5'] + ) + currency_code: str | None = Field( + None, description='Use either currency_id or currency_code', examples=['EUR'] + ) + name: str = Field(..., examples=['Rent']) + amount_min: str = Field(..., examples=['123.45']) + amount_max: str = Field(..., examples=['123.45']) + date: AwareDatetime = Field(..., examples=['2025-12-01T00:00:00+00:00']) + end_date: AwareDatetime | None = Field( + None, + description='The date after which this bill is no longer valid or applicable', + examples=['2025-12-31T23:59:59+00:00'], + ) + extension_date: AwareDatetime | None = Field( + None, + description='The date before which the bill must be renewed (or cancelled)', + examples=['2025-12-31T23:59:59+00:00'], + ) + repeat_freq: BillRepeatFrequency + skip: int | None = Field( + None, + description='How often the bill must be skipped. 1 means a bi-monthly bill.', + examples=[0], + ) + active: bool | None = Field(None, description='If the bill is active.', examples=[True]) + notes: str | None = Field(None, examples=['Some example notes']) + object_group_id: str | None = Field( + None, + description='The group ID of the group this object is part of. NULL if no group.', + examples=['5'], + ) + object_group_title: str | None = Field( + None, + description='The name of the group. NULL if no group.', + examples=['Example Group'], + ) class BillUpdate(BaseModel): - currency_id: str | None = Field( - None, description='Use either currency_id or currency_code', examples=['5'] - ) - currency_code: str | None = Field( - None, description='Use either currency_id or currency_code', examples=['EUR'] - ) - name: str = Field(..., examples=['Rent']) - amount_min: str | None = Field(None, examples=['123.45']) - amount_max: str | None = Field(None, examples=['123.45']) - date: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - end_date: AwareDatetime | None = Field( - None, - description='The date after which this bill is no longer valid or applicable', - examples=['2025-12-31T23:59:59+00:00'], - ) - extension_date: AwareDatetime | None = Field( - None, - description='The date before which the bill must be renewed (or cancelled)', - examples=['2025-12-31T23:59:59+00:00'], - ) - repeat_freq: BillRepeatFrequency | None = None - skip: int | None = Field( - None, - description='How often the bill must be skipped. 1 means a bi-monthly bill.', - examples=[0], - ) - active: bool | None = Field(None, description='If the bill is active.', examples=[True]) - notes: str | None = Field(None, examples=['Some example notes']) - object_group_id: str | None = Field( - None, - description='The group ID of the group this object is part of. NULL if no group.', - examples=['5'], - ) - object_group_title: str | None = Field( - None, - description='The name of the group. NULL if no group.', - examples=['Example Group'], - ) + currency_id: str | None = Field( + None, description='Use either currency_id or currency_code', examples=['5'] + ) + currency_code: str | None = Field( + None, description='Use either currency_id or currency_code', examples=['EUR'] + ) + name: str = Field(..., examples=['Rent']) + amount_min: str | None = Field(None, examples=['123.45']) + amount_max: str | None = Field(None, examples=['123.45']) + date: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + end_date: AwareDatetime | None = Field( + None, + description='The date after which this bill is no longer valid or applicable', + examples=['2025-12-31T23:59:59+00:00'], + ) + extension_date: AwareDatetime | None = Field( + None, + description='The date before which the bill must be renewed (or cancelled)', + examples=['2025-12-31T23:59:59+00:00'], + ) + repeat_freq: BillRepeatFrequency | None = None + skip: int | None = Field( + None, + description='How often the bill must be skipped. 1 means a bi-monthly bill.', + examples=[0], + ) + active: bool | None = Field(None, description='If the bill is active.', examples=[True]) + notes: str | None = Field(None, examples=['Some example notes']) + object_group_id: str | None = Field( + None, + description='The group ID of the group this object is part of. NULL if no group.', + examples=['5'], + ) + object_group_title: str | None = Field( + None, + description='The name of the group. NULL if no group.', + examples=['Example Group'], + ) class BudgetProperties(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - active: bool | None = Field(None, examples=[False]) - name: str = Field(..., examples=['Bills']) - order: int | None = Field(None, examples=[5]) - notes: str | None = Field(None, examples=['Some notes']) - auto_budget_type: AutoBudgetType | None = None - auto_budget_period: AutoBudgetPeriod | None = None - object_group_id: str | None = Field( - None, - description='The group ID of the group this object is part of. NULL if no group.', - examples=['5'], - ) - object_group_order: int | None = Field( - None, - description='The order of the group. At least 1, for the highest sorting.', - examples=[5], - ) - object_group_title: str | None = Field( - None, - description='The name of the group. NULL if no group.', - examples=['Example Group'], - ) - object_has_currency_setting: bool | None = Field( - None, - description="Indicates whether the object has a currency setting. If false, the object uses the administration's primary currency.", - examples=[True], - ) - currency_id: str | None = Field( - None, - description='The currency ID of the currency associated with this object.', - examples=['5'], - ) - currency_name: str | None = Field( - None, - description='The currency name of the currency associated with this object.', - examples=['Euro'], - ) - currency_code: str | None = Field( - None, - description='The currency code of the currency associated with this object.', - examples=['EUR'], - ) - currency_symbol: str | None = Field(None, examples=['$']) - currency_decimal_places: int | None = Field(None, examples=[2]) - primary_currency_id: str | None = Field( - None, - description="The currency ID of the administration's primary currency.", - examples=['5'], - ) - primary_currency_name: str | None = Field( - None, - description="The currency name of the administration's primary currency.", - examples=['Euro'], - ) - primary_currency_code: str | None = Field( - None, - description="The currency code of the administration's primary currency.", - examples=['EUR'], - ) - primary_currency_symbol: str | None = Field( - None, - description="The currency symbol of the administration's primary currency.", - examples=['$'], - ) - primary_currency_decimal_places: int | None = Field( - None, - description="The currency decimal places of the administration's primary currency.", - examples=[2], - ) - auto_budget_amount: str | None = Field( - None, - description='The amount for the auto-budget, if set.', - examples=['-1012.12'], - ) - pc_auto_budget_amount: str | None = Field( - None, - description='The amount for the auto-budget, if set in the primary currency of the administration.', - examples=['-1012.12'], - ) - spent: list[ArrayEntryWithCurrencyAndSum] | None = Field( - None, - description='Information on how much was spent in this budget. Is only filled in when the start and end date are submitted.', - ) - pc_spent: list[ArrayEntryWithCurrencyAndSum] | None = Field( - None, - description='Information on how much was spent in this budget. Is only filled in when the start and end date are submitted. It is converted to the primary currency of the administration.', - ) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + active: bool | None = Field(None, examples=[False]) + name: str = Field(..., examples=['Bills']) + order: int | None = Field(None, examples=[5]) + notes: str | None = Field(None, examples=['Some notes']) + auto_budget_type: AutoBudgetType | None = None + auto_budget_period: AutoBudgetPeriod | None = None + object_group_id: str | None = Field( + None, + description='The group ID of the group this object is part of. NULL if no group.', + examples=['5'], + ) + object_group_order: int | None = Field( + None, + description='The order of the group. At least 1, for the highest sorting.', + examples=[5], + ) + object_group_title: str | None = Field( + None, + description='The name of the group. NULL if no group.', + examples=['Example Group'], + ) + object_has_currency_setting: bool | None = Field( + None, + description="Indicates whether the object has a currency setting. If false, the object uses the administration's primary currency.", + examples=[True], + ) + currency_id: str | None = Field( + None, + description='The currency ID of the currency associated with this object.', + examples=['5'], + ) + currency_name: str | None = Field( + None, + description='The currency name of the currency associated with this object.', + examples=['Euro'], + ) + currency_code: str | None = Field( + None, + description='The currency code of the currency associated with this object.', + examples=['EUR'], + ) + currency_symbol: str | None = Field(None, examples=['$']) + currency_decimal_places: int | None = Field(None, examples=[2]) + primary_currency_id: str | None = Field( + None, + description="The currency ID of the administration's primary currency.", + examples=['5'], + ) + primary_currency_name: str | None = Field( + None, + description="The currency name of the administration's primary currency.", + examples=['Euro'], + ) + primary_currency_code: str | None = Field( + None, + description="The currency code of the administration's primary currency.", + examples=['EUR'], + ) + primary_currency_symbol: str | None = Field( + None, + description="The currency symbol of the administration's primary currency.", + examples=['$'], + ) + primary_currency_decimal_places: int | None = Field( + None, + description="The currency decimal places of the administration's primary currency.", + examples=[2], + ) + auto_budget_amount: str | None = Field( + None, + description='The amount for the auto-budget, if set.', + examples=['-1012.12'], + ) + pc_auto_budget_amount: str | None = Field( + None, + description='The amount for the auto-budget, if set in the primary currency of the administration.', + examples=['-1012.12'], + ) + spent: list[ArrayEntryWithCurrencyAndSum] | None = Field( + None, + description='Information on how much was spent in this budget. Is only filled in when the start and end date are submitted.', + ) + pc_spent: list[ArrayEntryWithCurrencyAndSum] | None = Field( + None, + description='Information on how much was spent in this budget. Is only filled in when the start and end date are submitted. It is converted to the primary currency of the administration.', + ) class BudgetRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['budgets']) - id: str = Field(..., examples=['2']) - attributes: BudgetProperties + type: str = Field(..., description='Immutable value', examples=['budgets']) + id: str = Field(..., examples=['2']) + attributes: BudgetProperties class BudgetSingle(BaseModel): - data: BudgetRead + data: BudgetRead class BudgetStore(BaseModel): - name: str = Field(..., examples=['Bills']) - active: bool | None = Field(None, examples=[False]) - order: int | None = Field(None, examples=[5]) - notes: str | None = Field(None, examples=['Some notes']) - fire_webhooks: bool | None = Field( - True, - description='Whether or not to fire the webhooks that are related to this event.', - examples=[True], - ) - auto_budget_type: AutoBudgetType | None = None - auto_budget_currency_id: str | None = Field( - None, - description="Use either currency_id or currency_code. Defaults to the user's financial administration's currency.", - examples=['12'], - ) - auto_budget_currency_code: str | None = Field( - None, - description="Use either currency_id or currency_code. Defaults to the user's financial administration's currency.", - examples=['EUR'], - ) - auto_budget_amount: str | None = Field(None, examples=['-1012.12']) - auto_budget_period: AutoBudgetPeriod | None = None + name: str = Field(..., examples=['Bills']) + active: bool | None = Field(None, examples=[False]) + order: int | None = Field(None, examples=[5]) + notes: str | None = Field(None, examples=['Some notes']) + fire_webhooks: bool | None = Field( + True, + description='Whether or not to fire the webhooks that are related to this event.', + examples=[True], + ) + auto_budget_type: AutoBudgetType | None = None + auto_budget_currency_id: str | None = Field( + None, + description="Use either currency_id or currency_code. Defaults to the user's financial administration's currency.", + examples=['12'], + ) + auto_budget_currency_code: str | None = Field( + None, + description="Use either currency_id or currency_code. Defaults to the user's financial administration's currency.", + examples=['EUR'], + ) + auto_budget_amount: str | None = Field(None, examples=['-1012.12']) + auto_budget_period: AutoBudgetPeriod | None = None class BudgetUpdate(BaseModel): - name: str = Field(..., examples=['Bills']) - active: bool | None = Field(None, examples=[False]) - order: int | None = Field(None, examples=[5]) - notes: str | None = Field(None, examples=['Some notes']) - fire_webhooks: bool | None = Field( - True, - description='Whether or not to fire the webhooks that are related to this event.', - examples=[True], - ) - auto_budget_type: AutoBudgetType | None = None - auto_budget_currency_id: str | None = Field( - None, - description="Use either currency_id or currency_code. Defaults to the user's financial administration's currency.", - examples=['12'], - ) - auto_budget_currency_code: str | None = Field( - None, - description="Use either currency_id or currency_code. Defaults to the user's financial administration's currency.", - examples=['EUR'], - ) - auto_budget_amount: str | None = Field(None, examples=['-1012.12']) - auto_budget_period: AutoBudgetPeriod | None = None + name: str = Field(..., examples=['Bills']) + active: bool | None = Field(None, examples=[False]) + order: int | None = Field(None, examples=[5]) + notes: str | None = Field(None, examples=['Some notes']) + fire_webhooks: bool | None = Field( + True, + description='Whether or not to fire the webhooks that are related to this event.', + examples=[True], + ) + auto_budget_type: AutoBudgetType | None = None + auto_budget_currency_id: str | None = Field( + None, + description="Use either currency_id or currency_code. Defaults to the user's financial administration's currency.", + examples=['12'], + ) + auto_budget_currency_code: str | None = Field( + None, + description="Use either currency_id or currency_code. Defaults to the user's financial administration's currency.", + examples=['EUR'], + ) + auto_budget_amount: str | None = Field(None, examples=['-1012.12']) + auto_budget_period: AutoBudgetPeriod | None = None class CurrencyExchangeRateRead(BaseModel): - type: str | None = Field( - None, description='Immutable value', examples=['currency_exchange_rates'] - ) - id: str | None = Field(None, examples=['2']) - attributes: CurrencyExchangeProperties | None = None - links: ObjectLink | None = None + type: str | None = Field( + None, description='Immutable value', examples=['currency_exchange_rates'] + ) + id: str | None = Field(None, examples=['2']) + attributes: CurrencyExchangeProperties | None = None + links: ObjectLink | None = None class CurrencyExchangeRateSingle(BaseModel): - data: CurrencyExchangeRateRead + data: CurrencyExchangeRateRead class RecurrenceRepetition(BaseModel): - id: str | None = Field(None, examples=['2']) - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - type: RecurrenceRepetitionType - moment: str = Field( - ..., - description="Information that defined the type of repetition.\n- For 'daily', this is empty.\n- For 'weekly', it is day of the week between 1 and 7 (Monday - Sunday).\n- For 'ndom', it is '1,2' or '4,5' or something else, where the first number is the week in the month, and the second number is the day in the week (between 1 and 7). '2,3' means: the 2nd Wednesday of the month\n- For 'monthly' it is the day of the month (1 - 31)\n- For yearly, it is a full date, ie '2025-12-01'. The year you use does not matter.\n", - examples=['3'], - ) - skip: int | None = Field( - None, - description='How many occurrences to skip. 0 means skip nothing. 1 means every other.', - examples=[0], - ) - weekend: int | None = Field( - None, - description='How to respond when the recurring transaction falls in the weekend. Possible values:\n1. Do nothing, just create it\n2. Create no transaction.\n3. Skip to the previous Friday.\n4. Skip to the next Monday.\n', - examples=[1], - ) - description: str | None = Field( - None, - description='Auto-generated repetition description.', - examples=['Every week on Friday'], - ) - occurrences: list[AwareDatetime] | None = Field( - None, - description='Array of future dates when the repetition will apply to. Auto generated.', - ) + id: str | None = Field(None, examples=['2']) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + type: RecurrenceRepetitionType + moment: str = Field( + ..., + description="Information that defined the type of repetition.\n- For 'daily', this is empty.\n- For 'weekly', it is day of the week between 1 and 7 (Monday - Sunday).\n- For 'ndom', it is '1,2' or '4,5' or something else, where the first number is the week in the month, and the second number is the day in the week (between 1 and 7). '2,3' means: the 2nd Wednesday of the month\n- For 'monthly' it is the day of the month (1 - 31)\n- For yearly, it is a full date, ie '2025-12-01'. The year you use does not matter.\n", + examples=['3'], + ) + skip: int | None = Field( + None, + description='How many occurrences to skip. 0 means skip nothing. 1 means every other.', + examples=[0], + ) + weekend: int | None = Field( + None, + description='How to respond when the recurring transaction falls in the weekend. Possible values:\n1. Do nothing, just create it\n2. Create no transaction.\n3. Skip to the previous Friday.\n4. Skip to the next Monday.\n', + examples=[1], + ) + description: str | None = Field( + None, + description='Auto-generated repetition description.', + examples=['Every week on Friday'], + ) + occurrences: list[AwareDatetime] | None = Field( + None, + description='Array of future dates when the repetition will apply to. Auto generated.', + ) class RecurrenceRepetitionStore(BaseModel): - type: RecurrenceRepetitionType - moment: str = Field( - ..., - description="Information that defined the type of repetition.\n- For 'daily', this is empty.\n- For 'weekly', it is day of the week between 1 and 7 (Monday - Sunday).\n- For 'ndom', it is '1,2' or '4,5' or something else, where the first number is the week in the month, and the second number is the day in the week (between 1 and 7). '2,3' means: the 2nd Wednesday of the month\n- For 'monthly' it is the day of the month (1 - 31)\n- For yearly, it is a full date, ie '2025-12-01'. The year you use does not matter.\n", - examples=['3'], - ) - skip: int | None = Field( - None, - description='How many occurrences to skip. 0 means skip nothing. 1 means every other.', - examples=[0], - ) - weekend: int | None = Field( - None, - description='How to respond when the recurring transaction falls in the weekend. Possible values:\n1. Do nothing, just create it\n2. Create no transaction.\n3. Skip to the previous Friday.\n4. Skip to the next Monday.\n', - examples=[1], - ) + type: RecurrenceRepetitionType + moment: str = Field( + ..., + description="Information that defined the type of repetition.\n- For 'daily', this is empty.\n- For 'weekly', it is day of the week between 1 and 7 (Monday - Sunday).\n- For 'ndom', it is '1,2' or '4,5' or something else, where the first number is the week in the month, and the second number is the day in the week (between 1 and 7). '2,3' means: the 2nd Wednesday of the month\n- For 'monthly' it is the day of the month (1 - 31)\n- For yearly, it is a full date, ie '2025-12-01'. The year you use does not matter.\n", + examples=['3'], + ) + skip: int | None = Field( + None, + description='How many occurrences to skip. 0 means skip nothing. 1 means every other.', + examples=[0], + ) + weekend: int | None = Field( + None, + description='How to respond when the recurring transaction falls in the weekend. Possible values:\n1. Do nothing, just create it\n2. Create no transaction.\n3. Skip to the previous Friday.\n4. Skip to the next Monday.\n', + examples=[1], + ) class RecurrenceRepetitionUpdate(BaseModel): - type: RecurrenceRepetitionType | None = None - moment: str | None = Field( - None, - description="Information that defined the type of repetition.\n- For 'daily', this is empty.\n- For 'weekly', it is day of the week between 1 and 7 (Monday - Sunday).\n- For 'ndom', it is '1,2' or '4,5' or something else, where the first number is the week in the month, and the second number is the day in the week (between 1 and 7). '2,3' means: the 2nd Wednesday of the month\n- For 'monthly' it is the day of the month (1 - 31)\n- For yearly, it is a full date, ie '2025-12-01'. The year you use does not matter.\n", - examples=['3'], - ) - skip: int | None = Field( - None, - description='How many occurrences to skip. 0 means skip nothing. 1 means every other.', - examples=[0], - ) - weekend: int | None = Field( - None, - description='How to respond when the recurring transaction falls in the weekend. Possible values:\n1. Do nothing, just create it\n2. Create no transaction.\n3. Skip to the previous Friday.\n4. Skip to the next Monday.\n', - examples=[1], - ) + type: RecurrenceRepetitionType | None = None + moment: str | None = Field( + None, + description="Information that defined the type of repetition.\n- For 'daily', this is empty.\n- For 'weekly', it is day of the week between 1 and 7 (Monday - Sunday).\n- For 'ndom', it is '1,2' or '4,5' or something else, where the first number is the week in the month, and the second number is the day in the week (between 1 and 7). '2,3' means: the 2nd Wednesday of the month\n- For 'monthly' it is the day of the month (1 - 31)\n- For yearly, it is a full date, ie '2025-12-01'. The year you use does not matter.\n", + examples=['3'], + ) + skip: int | None = Field( + None, + description='How many occurrences to skip. 0 means skip nothing. 1 means every other.', + examples=[0], + ) + weekend: int | None = Field( + None, + description='How to respond when the recurring transaction falls in the weekend. Possible values:\n1. Do nothing, just create it\n2. Create no transaction.\n3. Skip to the previous Friday.\n4. Skip to the next Monday.\n', + examples=[1], + ) class RecurrenceTransaction(BaseModel): - id: str | None = Field( - None, - examples=[ - 'ID of the recurring transaction. Not to be confused with the ID of the recurrence itself.' - ], - ) - description: str = Field(..., examples=['Rent for the current month']) - object_has_currency_setting: bool | None = Field( - None, - description="Indicates whether the object has a currency setting. If false, the object uses the administration's primary currency.", - examples=[True], - ) - currency_id: str | None = Field( - None, - description='The currency ID of the currency associated with this object.', - examples=['5'], - ) - currency_name: str | None = Field( - None, - description='The currency name of the currency associated with this object.', - examples=['Euro'], - ) - currency_code: str | None = Field( - None, - description='The currency code of the currency associated with this object.', - examples=['EUR'], - ) - currency_symbol: str | None = Field(None, examples=['$']) - currency_decimal_places: int | None = Field(None, examples=[2]) - primary_currency_id: str | None = Field( - None, - description="The currency ID of the administration's primary currency.", - examples=['5'], - ) - primary_currency_name: str | None = Field( - None, - description="The currency name of the administration's primary currency.", - examples=['Euro'], - ) - primary_currency_code: str | None = Field( - None, - description="The currency code of the administration's primary currency.", - examples=['EUR'], - ) - primary_currency_symbol: str | None = Field( - None, - description="The currency symbol of the administration's primary currency.", - examples=['$'], - ) - primary_currency_decimal_places: int | None = Field( - None, - description="The currency decimal places of the administration's primary currency.", - examples=[2], - ) - amount: str = Field(..., description='Amount of the transaction.', examples=['123.45']) - pc_amount: str | None = Field( - None, - description='Amount of the transaction in primary currency.', - examples=['123.45'], - ) - foreign_amount: str | None = Field( - None, description='Foreign amount of the transaction.', examples=['123.45'] - ) - pc_foreign_amount: str | None = Field( - None, description='Foreign amount of the transaction.', examples=['123.45'] - ) - foreign_currency_id: str | None = Field(None, examples=['17']) - foreign_currency_name: str | None = Field(None, examples=['British Pound']) - foreign_currency_code: str | None = Field(None, examples=['GBP']) - foreign_currency_symbol: str | None = Field(None, examples=['$']) - foreign_currency_decimal_places: int | None = Field( - None, description='Number of decimals in the currency', examples=[2] - ) - budget_id: str | None = Field( - None, description='The budget ID for this transaction.', examples=['4'] - ) - budget_name: str | None = Field( - None, - description='The name of the budget to be used. If the budget name is unknown, the ID will be used or the value will be ignored.', - examples=['Groceries'], - ) - category_id: str | None = Field( - None, description='Category ID for this transaction.', examples=['211'] - ) - category_name: str | None = Field( - None, description='Category name for this transaction.', examples=['Bills'] - ) - source_id: str | None = Field( - None, - description='ID of the source account. Submit either this or source_name.', - examples=['913'], - ) - source_name: str | None = Field( - None, - description='Name of the source account. Submit either this or source_id.', - examples=['Checking account'], - ) - source_iban: str | None = Field(None, examples=['NL02ABNA0123456789']) - source_type: AccountTypeProperty | None = None - destination_id: str | None = Field( - None, - description='ID of the destination account. Submit either this or destination_name.', - examples=['258'], - ) - destination_name: str | None = Field( - None, - description='Name of the destination account. Submit either this or destination_id.', - examples=['Buy and Large'], - ) - destination_iban: str | None = Field(None, examples=['NL02ABNA0123456789']) - destination_type: AccountTypeProperty | None = None - tags: list[str] | None = Field(None, description='Array of tags.', examples=[None]) - piggy_bank_id: str | None = Field(None, examples=['123']) - piggy_bank_name: str | None = None - subscription_id: str | None = Field(None, examples=['123']) - subscription_name: str | None = None + id: str | None = Field( + None, + examples=[ + 'ID of the recurring transaction. Not to be confused with the ID of the recurrence itself.' + ], + ) + description: str = Field(..., examples=['Rent for the current month']) + object_has_currency_setting: bool | None = Field( + None, + description="Indicates whether the object has a currency setting. If false, the object uses the administration's primary currency.", + examples=[True], + ) + currency_id: str | None = Field( + None, + description='The currency ID of the currency associated with this object.', + examples=['5'], + ) + currency_name: str | None = Field( + None, + description='The currency name of the currency associated with this object.', + examples=['Euro'], + ) + currency_code: str | None = Field( + None, + description='The currency code of the currency associated with this object.', + examples=['EUR'], + ) + currency_symbol: str | None = Field(None, examples=['$']) + currency_decimal_places: int | None = Field(None, examples=[2]) + primary_currency_id: str | None = Field( + None, + description="The currency ID of the administration's primary currency.", + examples=['5'], + ) + primary_currency_name: str | None = Field( + None, + description="The currency name of the administration's primary currency.", + examples=['Euro'], + ) + primary_currency_code: str | None = Field( + None, + description="The currency code of the administration's primary currency.", + examples=['EUR'], + ) + primary_currency_symbol: str | None = Field( + None, + description="The currency symbol of the administration's primary currency.", + examples=['$'], + ) + primary_currency_decimal_places: int | None = Field( + None, + description="The currency decimal places of the administration's primary currency.", + examples=[2], + ) + amount: str = Field(..., description='Amount of the transaction.', examples=['123.45']) + pc_amount: str | None = Field( + None, + description='Amount of the transaction in primary currency.', + examples=['123.45'], + ) + foreign_amount: str | None = Field( + None, description='Foreign amount of the transaction.', examples=['123.45'] + ) + pc_foreign_amount: str | None = Field( + None, description='Foreign amount of the transaction.', examples=['123.45'] + ) + foreign_currency_id: str | None = Field(None, examples=['17']) + foreign_currency_name: str | None = Field(None, examples=['British Pound']) + foreign_currency_code: str | None = Field(None, examples=['GBP']) + foreign_currency_symbol: str | None = Field(None, examples=['$']) + foreign_currency_decimal_places: int | None = Field( + None, description='Number of decimals in the currency', examples=[2] + ) + budget_id: str | None = Field( + None, description='The budget ID for this transaction.', examples=['4'] + ) + budget_name: str | None = Field( + None, + description='The name of the budget to be used. If the budget name is unknown, the ID will be used or the value will be ignored.', + examples=['Groceries'], + ) + category_id: str | None = Field( + None, description='Category ID for this transaction.', examples=['211'] + ) + category_name: str | None = Field( + None, description='Category name for this transaction.', examples=['Bills'] + ) + source_id: str | None = Field( + None, + description='ID of the source account. Submit either this or source_name.', + examples=['913'], + ) + source_name: str | None = Field( + None, + description='Name of the source account. Submit either this or source_id.', + examples=['Checking account'], + ) + source_iban: str | None = Field(None, examples=['NL02ABNA0123456789']) + source_type: AccountTypeProperty | None = None + destination_id: str | None = Field( + None, + description='ID of the destination account. Submit either this or destination_name.', + examples=['258'], + ) + destination_name: str | None = Field( + None, + description='Name of the destination account. Submit either this or destination_id.', + examples=['Buy and Large'], + ) + destination_iban: str | None = Field(None, examples=['NL02ABNA0123456789']) + destination_type: AccountTypeProperty | None = None + tags: list[str] | None = Field(None, description='Array of tags.', examples=[None]) + piggy_bank_id: str | None = Field(None, examples=['123']) + piggy_bank_name: str | None = None + subscription_id: str | None = Field(None, examples=['123']) + subscription_name: str | None = None class RuleAction(BaseModel): - id: str | None = Field(None, examples=['2']) - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - type: RuleActionKeyword - value: str = Field( - ..., - description='The accompanying value the action will set, change or update. Can be empty, but for some types this value is mandatory.', - examples=['Daily groceries'], - ) - order: int | None = Field(None, description='Order of the action', examples=[5]) - active: bool | None = Field( - True, description='If the action is active. Defaults to true.', examples=[True] - ) - stop_processing: bool | None = Field( - False, - description='When true, other actions will not be fired after this action has fired. Defaults to false.', - examples=[False], - ) + id: str | None = Field(None, examples=['2']) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + type: RuleActionKeyword + value: str = Field( + ..., + description='The accompanying value the action will set, change or update. Can be empty, but for some types this value is mandatory.', + examples=['Daily groceries'], + ) + order: int | None = Field(None, description='Order of the action', examples=[5]) + active: bool | None = Field( + True, description='If the action is active. Defaults to true.', examples=[True] + ) + stop_processing: bool | None = Field( + False, + description='When true, other actions will not be fired after this action has fired. Defaults to false.', + examples=[False], + ) class RuleActionStore(BaseModel): - type: RuleActionKeyword - value: str = Field( - ..., - description='The accompanying value the action will set, change or update. Can be empty, but for some types this value is mandatory.', - examples=['Daily groceries'], - ) - order: int | None = Field(None, description='Order of the action', examples=[5]) - active: bool | None = Field( - True, description='If the action is active. Defaults to true.', examples=[True] - ) - stop_processing: bool | None = Field( - False, - description='When true, other actions will not be fired after this action has fired. Defaults to false.', - examples=[False], - ) + type: RuleActionKeyword + value: str = Field( + ..., + description='The accompanying value the action will set, change or update. Can be empty, but for some types this value is mandatory.', + examples=['Daily groceries'], + ) + order: int | None = Field(None, description='Order of the action', examples=[5]) + active: bool | None = Field( + True, description='If the action is active. Defaults to true.', examples=[True] + ) + stop_processing: bool | None = Field( + False, + description='When true, other actions will not be fired after this action has fired. Defaults to false.', + examples=[False], + ) class RuleActionUpdate(BaseModel): - type: RuleActionKeyword | None = None - value: str | None = Field( - None, - description='The accompanying value the action will set, change or update. Can be empty, but for some types this value is mandatory.', - examples=['Daily groceries'], - ) - order: int | None = Field(None, description='Order of the action', examples=[5]) - active: bool | None = Field(None, description='If the action is active.', examples=[True]) - stop_processing: bool | None = Field( - None, - description='When true, other actions will not be fired after this action has fired.', - examples=[False], - ) + type: RuleActionKeyword | None = None + value: str | None = Field( + None, + description='The accompanying value the action will set, change or update. Can be empty, but for some types this value is mandatory.', + examples=['Daily groceries'], + ) + order: int | None = Field(None, description='Order of the action', examples=[5]) + active: bool | None = Field(None, description='If the action is active.', examples=[True]) + stop_processing: bool | None = Field( + None, + description='When true, other actions will not be fired after this action has fired.', + examples=[False], + ) class RuleTrigger(BaseModel): - id: str | None = Field(None, examples=['2']) - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - type: RuleTriggerKeyword - value: str = Field( - ..., - description='The accompanying value the trigger responds to. This value is often mandatory, but this depends on the trigger.', - examples=['tag1'], - ) - prohibited: bool | None = Field( - False, - description="If 'prohibited' is true, this rule trigger will be negated. 'Description is' will become 'Description is NOT' etc.", - examples=[False], - ) - order: int | None = Field(None, description='Order of the trigger', examples=[5]) - active: bool | None = Field( - True, description='If the trigger is active. Defaults to true.', examples=[True] - ) - stop_processing: bool | None = Field( - False, - description='When true, other triggers will not be checked if this trigger was triggered. Defaults to false.', - examples=[False], - ) + id: str | None = Field(None, examples=['2']) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + type: RuleTriggerKeyword + value: str = Field( + ..., + description='The accompanying value the trigger responds to. This value is often mandatory, but this depends on the trigger.', + examples=['tag1'], + ) + prohibited: bool | None = Field( + False, + description="If 'prohibited' is true, this rule trigger will be negated. 'Description is' will become 'Description is NOT' etc.", + examples=[False], + ) + order: int | None = Field(None, description='Order of the trigger', examples=[5]) + active: bool | None = Field( + True, description='If the trigger is active. Defaults to true.', examples=[True] + ) + stop_processing: bool | None = Field( + False, + description='When true, other triggers will not be checked if this trigger was triggered. Defaults to false.', + examples=[False], + ) class RuleTriggerStore(BaseModel): - type: RuleTriggerKeyword - value: str = Field( - ..., - description='The accompanying value the trigger responds to. This value is often mandatory, but this depends on the trigger.', - examples=['tag1'], - ) - order: int | None = Field(None, description='Order of the trigger', examples=[5]) - active: bool | None = Field( - True, description='If the trigger is active. Defaults to true.', examples=[True] - ) - prohibited: bool | None = Field( - False, - description="If 'prohibited' is true, this rule trigger will be negated. 'Description is' will become 'Description is NOT' etc.", - examples=[False], - ) - stop_processing: bool | None = Field( - False, - description='When true, other triggers will not be checked if this trigger was triggered. Defaults to false.', - examples=[False], - ) + type: RuleTriggerKeyword + value: str = Field( + ..., + description='The accompanying value the trigger responds to. This value is often mandatory, but this depends on the trigger.', + examples=['tag1'], + ) + order: int | None = Field(None, description='Order of the trigger', examples=[5]) + active: bool | None = Field( + True, description='If the trigger is active. Defaults to true.', examples=[True] + ) + prohibited: bool | None = Field( + False, + description="If 'prohibited' is true, this rule trigger will be negated. 'Description is' will become 'Description is NOT' etc.", + examples=[False], + ) + stop_processing: bool | None = Field( + False, + description='When true, other triggers will not be checked if this trigger was triggered. Defaults to false.', + examples=[False], + ) class RuleTriggerUpdate(BaseModel): - type: RuleTriggerKeyword | None = None - value: str | None = Field( - None, - description="The accompanying value the trigger responds to. This value is often mandatory, but this depends on the trigger. If the rule trigger is something like 'has any tag', submit the string 'true'.", - examples=['tag1'], - ) - order: int | None = Field(None, description='Order of the trigger', examples=[5]) - active: bool | None = Field(None, description='If the trigger is active.', examples=[True]) - stop_processing: bool | None = Field( - None, - description='When true, other triggers will not be checked if this trigger was triggered.', - examples=[False], - ) + type: RuleTriggerKeyword | None = None + value: str | None = Field( + None, + description="The accompanying value the trigger responds to. This value is often mandatory, but this depends on the trigger. If the rule trigger is something like 'has any tag', submit the string 'true'.", + examples=['tag1'], + ) + order: int | None = Field(None, description='Order of the trigger', examples=[5]) + active: bool | None = Field(None, description='If the trigger is active.', examples=[True]) + stop_processing: bool | None = Field( + None, + description='When true, other triggers will not be checked if this trigger was triggered.', + examples=[False], + ) class TransactionSplit(BaseModel): - user: str | None = Field(None, description='User ID', examples=['3']) - transaction_journal_id: str | None = Field( - None, - description='ID of the underlying transaction journal. Each transaction consists of a transaction group (see the top ID) and one or more journals\nmaking up the splits of the transaction.\n', - examples=['10421'], - ) - type: TransactionTypeProperty - date: AwareDatetime = Field( - ..., - description='Date of the transaction', - examples=['2025-12-01T00:00:00+00:00'], - ) - order: int | None = Field( - None, - description='Order of this entry in the list of transactions.', - examples=[0], - ) - object_has_currency_setting: bool | None = Field( - None, - description='Indicates whether the transaction has a currency setting. For transactions this is always true.', - examples=[True], - ) - currency_id: str | None = Field( - None, - description='Currency ID for the currency of this transaction.', - examples=['12'], - ) - currency_code: str | None = Field( - None, - description='Currency code for the currency of this transaction.', - examples=['EUR'], - ) - currency_symbol: str | None = Field( - None, - description='Currency symbol for the currency of this transaction.', - examples=['$'], - ) - currency_name: str | None = Field( - None, - description='Currency name for the currency of this transaction.', - examples=['Euro'], - ) - currency_decimal_places: int | None = Field( - None, description='Number of decimals used in this currency.', examples=[2] - ) - foreign_currency_id: str | None = Field( - None, - description='Currency ID of the foreign currency, if this transaction has a foreign amount.', - examples=['17'], - ) - foreign_currency_code: str | None = Field( - None, - description='Currency code of the foreign currency. Default is NULL.', - examples=['USD'], - ) - foreign_currency_symbol: str | None = Field(None, examples=['$']) - foreign_currency_decimal_places: int | None = Field( - None, description='Number of decimals in the foreign currency.', examples=[2] - ) - primary_currency_id: str | None = Field( - None, - description='Returns the primary currency ID of the administration. This currency is used as the currency for all `pc_*` amount and balance fields of this account.', - examples=['12'], - ) - primary_currency_code: str | None = Field( - None, - description='Returns the primary currency code of the administration. This currency is used as the currency for all `pc_*` amount and balance fields of this account.', - examples=['EUR'], - ) - primary_currency_symbol: str | None = Field( - None, description='See the other `primary_*` fields.', examples=['$'] - ) - primary_currency_decimal_places: int | None = Field( - None, description='See the other `primary_*` fields.', examples=[2] - ) - amount: str = Field(..., description='Amount of the transaction.', examples=['123.45']) - pc_amount: str | None = Field( - None, - description="Amount of the transaction in the primary currency of this administration. The `primary_currency_*` fields reflect the currency used. This field is NULL if the user does have 'convert to primary' set to true in their settings.", - examples=['123.45'], - ) - foreign_amount: str | None = Field( - None, - description='The amount in the set foreign currency. May be NULL if the transaction does not have a foreign amount.', - examples=['123.45'], - ) - pc_foreign_amount: str | None = Field( - None, - description="Foreign amount of the transaction in the primary currency of this administration. The `primary_currency_*` fields reflect the currency used. This field is NULL if the user does have 'convert to primary' set to true in their settings.", - examples=['123.45'], - ) - source_balance_after: str | None = Field( - None, - description="The balance of the source account. This is the balance in the account's currency which may be different from this transaction, and is not provided in this model.", - examples=['123.45'], - ) - pc_source_balance_after: str | None = Field( - None, - description="The balance of the source account in the primary currency of this administration. The `primary_currency_*` fields reflect the currency used. This field is NULL if the user does have 'convert to primary' set to true in their settings.", - examples=['123.45'], - ) - destination_balance_after: str | None = Field( - None, - description="The balance of the destination account. This is the balance in the account's currency which may be different from this transaction, and is not provided in this model.", - examples=['123.45'], - ) - pc_destination_balance_after: str | None = Field( - None, - description="The balance of the destination account in the primary currency of this administration. The `primary_currency_*` fields reflect the currency used. This field is NULL if the user does have 'convert to primary' set to true in their settings.", - examples=['123.45'], - ) - description: str = Field( - ..., description='Description of the transaction.', examples=['Vegetables'] - ) - source_id: str = Field( - ..., - description='ID of the source account. For a withdrawal or a transfer, this must always be an asset account. For deposits, this must be a revenue account.', - examples=['2'], - ) - source_name: str | None = Field( - None, - description='Name of the source account. For a withdrawal or a transfer, this must always be an asset account. For deposits, this must be a revenue account. Can be used instead of the source_id. If the transaction is a deposit, the source_name can be filled in freely: the account will be created based on the name.', - examples=['Checking account'], - ) - source_iban: str | None = Field(None, examples=['NL02ABNA0123456789']) - source_type: AccountTypeProperty | None = None - destination_id: str = Field( - ..., - description='ID of the destination account. For a deposit or a transfer, this must always be an asset account. For withdrawals this must be an expense account.', - examples=['2'], - ) - destination_name: str | None = Field( - None, - description='Name of the destination account. You can submit the name instead of the ID. For everything except transfers, the account will be auto-generated if unknown, so submitting a name is enough.', - examples=['Buy and Large'], - ) - destination_iban: str | None = Field(None, examples=['NL02ABNA0123456789']) - destination_type: AccountTypeProperty | None = None - budget_id: str | None = Field( - None, description='The budget ID for this transaction.', examples=['4'] - ) - budget_name: str | None = Field( - None, description='The name of the budget used.', examples=['Groceries'] - ) - category_id: str | None = Field( - None, description='The category ID for this transaction.', examples=['43'] - ) - category_name: str | None = Field( - None, - description='The name of the category to be used. If the category is unknown, it will be created. If the ID and the name point to different categories, the ID overrules the name.', - examples=['Groceries'], - ) - bill_id: str | None = Field( - None, - description='The associated subscription ID for this transaction. `bill` refers to the OLD name for subscriptions and this field will be removed.', - examples=['111'], - ) - bill_name: str | None = Field( - None, - description='The associated subscription name for this transaction. `bill` refers to the OLD name for subscriptions and this field will be removed.', - examples=['Monthly rent'], - ) - subscription_id: str | None = Field( - None, - description='The associated subscription ID for this transaction.', - examples=['111'], - ) - subscription_name: str | None = Field( - None, - description='The associated subscription name for this transaction.', - examples=['Monthly rent'], - ) - reconciled: bool | None = Field( - None, - description='If the transaction has been reconciled already. When you set this, the amount can no longer be edited by the user.', - examples=[False], - ) - notes: str | None = Field(None, examples=['Some example notes']) - tags: list[str] | None = Field(None, description='Array of tags.', examples=[None]) - internal_reference: str | None = Field( - None, description='Reference to internal reference of other systems.' - ) - external_id: str | None = Field(None, description='Reference to external ID in other systems.') - external_url: str | None = Field(None, description='External, custom URL for this transaction.') - original_source: str | None = Field( - None, - description='System generated identifier for original creator of transaction.', - ) - recurrence_id: str | None = Field( - None, description='Reference to recurrence that made the transaction.' - ) - recurrence_total: int | None = Field( - None, - description='Total number of transactions expected to be created by this recurrence repetition. Will be 0 if infinite.', - examples=[0], - ) - recurrence_count: int | None = Field( - None, - description='The # of the current transaction created under this recurrence.', - examples=[12], - ) - import_hash_v2: str | None = Field( - None, - description='Hash value of original import transaction (for duplicate detection).', - ) - sepa_cc: str | None = Field(None, description='SEPA Clearing Code') - sepa_ct_op: str | None = Field(None, description='SEPA Opposing Account Identifier') - sepa_ct_id: str | None = Field(None, description='SEPA end-to-end Identifier') - sepa_db: str | None = Field(None, description='SEPA mandate identifier') - sepa_country: str | None = Field(None, description='SEPA Country') - sepa_ep: str | None = Field(None, description='SEPA External Purpose indicator') - sepa_ci: str | None = Field(None, description='SEPA Creditor Identifier') - sepa_batch_id: str | None = Field(None, description='SEPA Batch ID') - interest_date: AwareDatetime | None = None - book_date: AwareDatetime | None = None - process_date: AwareDatetime | None = None - due_date: AwareDatetime | None = None - payment_date: AwareDatetime | None = None - invoice_date: AwareDatetime | None = None - latitude: float | None = Field( - None, - description="Latitude of the transaction's location, if applicable. Can be used to draw a map.", - examples=[51.983333], - ) - longitude: float | None = Field( - None, - description="Latitude of the transaction's location, if applicable. Can be used to draw a map.", - examples=[5.916667], - ) - zoom_level: int | None = Field( - None, - description='Zoom level for the map, if drawn. This to set the box right. Unfortunately this is a proprietary value because each map provider has different zoom levels.', - examples=[6], - ) - has_attachments: bool | None = Field( - None, description='If the transaction has attachments.', examples=[False] - ) + user: str | None = Field(None, description='User ID', examples=['3']) + transaction_journal_id: str | None = Field( + None, + description='ID of the underlying transaction journal. Each transaction consists of a transaction group (see the top ID) and one or more journals\nmaking up the splits of the transaction.\n', + examples=['10421'], + ) + type: TransactionTypeProperty + date: AwareDatetime = Field( + ..., + description='Date of the transaction', + examples=['2025-12-01T00:00:00+00:00'], + ) + order: int | None = Field( + None, + description='Order of this entry in the list of transactions.', + examples=[0], + ) + object_has_currency_setting: bool | None = Field( + None, + description='Indicates whether the transaction has a currency setting. For transactions this is always true.', + examples=[True], + ) + currency_id: str | None = Field( + None, + description='Currency ID for the currency of this transaction.', + examples=['12'], + ) + currency_code: str | None = Field( + None, + description='Currency code for the currency of this transaction.', + examples=['EUR'], + ) + currency_symbol: str | None = Field( + None, + description='Currency symbol for the currency of this transaction.', + examples=['$'], + ) + currency_name: str | None = Field( + None, + description='Currency name for the currency of this transaction.', + examples=['Euro'], + ) + currency_decimal_places: int | None = Field( + None, description='Number of decimals used in this currency.', examples=[2] + ) + foreign_currency_id: str | None = Field( + None, + description='Currency ID of the foreign currency, if this transaction has a foreign amount.', + examples=['17'], + ) + foreign_currency_code: str | None = Field( + None, + description='Currency code of the foreign currency. Default is NULL.', + examples=['USD'], + ) + foreign_currency_symbol: str | None = Field(None, examples=['$']) + foreign_currency_decimal_places: int | None = Field( + None, description='Number of decimals in the foreign currency.', examples=[2] + ) + primary_currency_id: str | None = Field( + None, + description='Returns the primary currency ID of the administration. This currency is used as the currency for all `pc_*` amount and balance fields of this account.', + examples=['12'], + ) + primary_currency_code: str | None = Field( + None, + description='Returns the primary currency code of the administration. This currency is used as the currency for all `pc_*` amount and balance fields of this account.', + examples=['EUR'], + ) + primary_currency_symbol: str | None = Field( + None, description='See the other `primary_*` fields.', examples=['$'] + ) + primary_currency_decimal_places: int | None = Field( + None, description='See the other `primary_*` fields.', examples=[2] + ) + amount: str = Field(..., description='Amount of the transaction.', examples=['123.45']) + pc_amount: str | None = Field( + None, + description="Amount of the transaction in the primary currency of this administration. The `primary_currency_*` fields reflect the currency used. This field is NULL if the user does have 'convert to primary' set to true in their settings.", + examples=['123.45'], + ) + foreign_amount: str | None = Field( + None, + description='The amount in the set foreign currency. May be NULL if the transaction does not have a foreign amount.', + examples=['123.45'], + ) + pc_foreign_amount: str | None = Field( + None, + description="Foreign amount of the transaction in the primary currency of this administration. The `primary_currency_*` fields reflect the currency used. This field is NULL if the user does have 'convert to primary' set to true in their settings.", + examples=['123.45'], + ) + source_balance_after: str | None = Field( + None, + description="The balance of the source account. This is the balance in the account's currency which may be different from this transaction, and is not provided in this model.", + examples=['123.45'], + ) + pc_source_balance_after: str | None = Field( + None, + description="The balance of the source account in the primary currency of this administration. The `primary_currency_*` fields reflect the currency used. This field is NULL if the user does have 'convert to primary' set to true in their settings.", + examples=['123.45'], + ) + destination_balance_after: str | None = Field( + None, + description="The balance of the destination account. This is the balance in the account's currency which may be different from this transaction, and is not provided in this model.", + examples=['123.45'], + ) + pc_destination_balance_after: str | None = Field( + None, + description="The balance of the destination account in the primary currency of this administration. The `primary_currency_*` fields reflect the currency used. This field is NULL if the user does have 'convert to primary' set to true in their settings.", + examples=['123.45'], + ) + description: str = Field( + ..., description='Description of the transaction.', examples=['Vegetables'] + ) + source_id: str = Field( + ..., + description='ID of the source account. For a withdrawal or a transfer, this must always be an asset account. For deposits, this must be a revenue account.', + examples=['2'], + ) + source_name: str | None = Field( + None, + description='Name of the source account. For a withdrawal or a transfer, this must always be an asset account. For deposits, this must be a revenue account. Can be used instead of the source_id. If the transaction is a deposit, the source_name can be filled in freely: the account will be created based on the name.', + examples=['Checking account'], + ) + source_iban: str | None = Field(None, examples=['NL02ABNA0123456789']) + source_type: AccountTypeProperty | None = None + destination_id: str = Field( + ..., + description='ID of the destination account. For a deposit or a transfer, this must always be an asset account. For withdrawals this must be an expense account.', + examples=['2'], + ) + destination_name: str | None = Field( + None, + description='Name of the destination account. You can submit the name instead of the ID. For everything except transfers, the account will be auto-generated if unknown, so submitting a name is enough.', + examples=['Buy and Large'], + ) + destination_iban: str | None = Field(None, examples=['NL02ABNA0123456789']) + destination_type: AccountTypeProperty | None = None + budget_id: str | None = Field( + None, description='The budget ID for this transaction.', examples=['4'] + ) + budget_name: str | None = Field( + None, description='The name of the budget used.', examples=['Groceries'] + ) + category_id: str | None = Field( + None, description='The category ID for this transaction.', examples=['43'] + ) + category_name: str | None = Field( + None, + description='The name of the category to be used. If the category is unknown, it will be created. If the ID and the name point to different categories, the ID overrules the name.', + examples=['Groceries'], + ) + bill_id: str | None = Field( + None, + description='The associated subscription ID for this transaction. `bill` refers to the OLD name for subscriptions and this field will be removed.', + examples=['111'], + ) + bill_name: str | None = Field( + None, + description='The associated subscription name for this transaction. `bill` refers to the OLD name for subscriptions and this field will be removed.', + examples=['Monthly rent'], + ) + subscription_id: str | None = Field( + None, + description='The associated subscription ID for this transaction.', + examples=['111'], + ) + subscription_name: str | None = Field( + None, + description='The associated subscription name for this transaction.', + examples=['Monthly rent'], + ) + reconciled: bool | None = Field( + None, + description='If the transaction has been reconciled already. When you set this, the amount can no longer be edited by the user.', + examples=[False], + ) + notes: str | None = Field(None, examples=['Some example notes']) + tags: list[str] | None = Field(None, description='Array of tags.', examples=[None]) + internal_reference: str | None = Field( + None, description='Reference to internal reference of other systems.' + ) + external_id: str | None = Field(None, description='Reference to external ID in other systems.') + external_url: str | None = Field(None, description='External, custom URL for this transaction.') + original_source: str | None = Field( + None, + description='System generated identifier for original creator of transaction.', + ) + recurrence_id: str | None = Field( + None, description='Reference to recurrence that made the transaction.' + ) + recurrence_total: int | None = Field( + None, + description='Total number of transactions expected to be created by this recurrence repetition. Will be 0 if infinite.', + examples=[0], + ) + recurrence_count: int | None = Field( + None, + description='The # of the current transaction created under this recurrence.', + examples=[12], + ) + import_hash_v2: str | None = Field( + None, + description='Hash value of original import transaction (for duplicate detection).', + ) + sepa_cc: str | None = Field(None, description='SEPA Clearing Code') + sepa_ct_op: str | None = Field(None, description='SEPA Opposing Account Identifier') + sepa_ct_id: str | None = Field(None, description='SEPA end-to-end Identifier') + sepa_db: str | None = Field(None, description='SEPA mandate identifier') + sepa_country: str | None = Field(None, description='SEPA Country') + sepa_ep: str | None = Field(None, description='SEPA External Purpose indicator') + sepa_ci: str | None = Field(None, description='SEPA Creditor Identifier') + sepa_batch_id: str | None = Field(None, description='SEPA Batch ID') + interest_date: AwareDatetime | None = None + book_date: AwareDatetime | None = None + process_date: AwareDatetime | None = None + due_date: AwareDatetime | None = None + payment_date: AwareDatetime | None = None + invoice_date: AwareDatetime | None = None + latitude: float | None = Field( + None, + description="Latitude of the transaction's location, if applicable. Can be used to draw a map.", + examples=[51.983333], + ) + longitude: float | None = Field( + None, + description="Latitude of the transaction's location, if applicable. Can be used to draw a map.", + examples=[5.916667], + ) + zoom_level: int | None = Field( + None, + description='Zoom level for the map, if drawn. This to set the box right. Unfortunately this is a proprietary value because each map provider has different zoom levels.', + examples=[6], + ) + has_attachments: bool | None = Field( + None, description='If the transaction has attachments.', examples=[False] + ) class TransactionSplitStore(BaseModel): - type: TransactionTypeProperty - date: AwareDatetime = Field( - ..., - description='Date of the transaction', - examples=['2025-12-01T00:00:00+00:00'], - ) - amount: str = Field(..., description='Amount of the transaction.', examples=['123.45']) - description: str = Field( - ..., description='Description of the transaction.', examples=['Vegetables'] - ) - order: int | None = Field( - None, - description='Order of this entry in the list of transactions.', - examples=[0], - ) - currency_id: str | None = Field( - None, - description="Currency ID. Default is the source account's currency, or the user's financial administration's currency. The value you submit may be overruled by the source or destination account.", - examples=['12'], - ) - currency_code: str | None = Field( - None, - description="Currency code. Default is the source account's currency, or the user's financial administration's primary currency. The value you submit may be overruled by the source or destination account.", - examples=['EUR'], - ) - foreign_amount: str | None = Field( - None, description='The amount in a foreign currency.', examples=['123.45'] - ) - foreign_currency_id: str | None = Field( - None, - description='Currency ID of the foreign currency. Default is null. Is required when you submit a foreign amount.', - examples=['17'], - ) - foreign_currency_code: str | None = Field( - None, - description='Currency code of the foreign currency. Default is NULL. Can be used instead of the foreign_currency_id, but this or the ID is required when submitting a foreign amount.', - examples=['USD'], - ) - budget_id: str | None = Field( - None, description='The budget ID for this transaction.', examples=['4'] - ) - budget_name: str | None = Field( - None, - description='The name of the budget to be used. If the budget name is unknown, the ID will be used or the value will be ignored.', - examples=['Groceries'], - ) - category_id: str | None = Field( - None, description='The category ID for this transaction.', examples=['43'] - ) - category_name: str | None = Field( - None, - description='The name of the category to be used. If the category is unknown, it will be created. If the ID and the name point to different categories, the ID overrules the name.', - examples=['Groceries'], - ) - source_id: str | None = Field( - None, - description='ID of the source account. For a withdrawal or a transfer, this must always be an asset account. For deposits, this must be a revenue account.', - examples=['2'], - ) - source_name: str | None = Field( - None, - description='Name of the source account. For a withdrawal or a transfer, this must always be an asset account. For deposits, this must be a revenue account. Can be used instead of the source_id. If the transaction is a deposit, the source_name can be filled in freely: the account will be created based on the name.', - examples=['Checking account'], - ) - destination_id: str | None = Field( - None, - description='ID of the destination account. For a deposit or a transfer, this must always be an asset account. For withdrawals this must be an expense account.', - examples=['2'], - ) - destination_name: str | None = Field( - None, - description='Name of the destination account. You can submit the name instead of the ID. For everything except transfers, the account will be auto-generated if unknown, so submitting a name is enough.', - examples=['Buy and Large'], - ) - reconciled: bool | None = Field( - None, - description='If the transaction has been reconciled already. When you set this, the amount can no longer be edited by the user.', - examples=[False], - ) - piggy_bank_id: int | None = Field( - None, description='Optional. Use either this or the piggy_bank_name' - ) - piggy_bank_name: str | None = Field( - None, description='Optional. Use either this or the piggy_bank_id' - ) - bill_id: str | None = Field( - None, description='Optional. Use either this or the bill_name', examples=['112'] - ) - bill_name: str | None = Field( - None, - description='Optional. Use either this or the bill_id', - examples=['Monthly rent'], - ) - tags: list[str] | None = Field(None, description='Array of tags.', examples=[None]) - notes: str | None = Field(None, examples=['Some example notes']) - internal_reference: str | None = Field( - None, description='Reference to internal reference of other systems.' - ) - external_id: str | None = Field(None, description='Reference to external ID in other systems.') - external_url: str | None = Field(None, description='External, custom URL for this transaction.') - sepa_cc: str | None = Field(None, description='SEPA Clearing Code') - sepa_ct_op: str | None = Field(None, description='SEPA Opposing Account Identifier') - sepa_ct_id: str | None = Field(None, description='SEPA end-to-end Identifier') - sepa_db: str | None = Field(None, description='SEPA mandate identifier') - sepa_country: str | None = Field(None, description='SEPA Country') - sepa_ep: str | None = Field(None, description='SEPA External Purpose indicator') - sepa_ci: str | None = Field(None, description='SEPA Creditor Identifier') - sepa_batch_id: str | None = Field(None, description='SEPA Batch ID') - interest_date: AwareDatetime | None = None - book_date: AwareDatetime | None = None - process_date: AwareDatetime | None = None - due_date: AwareDatetime | None = None - payment_date: AwareDatetime | None = None - invoice_date: AwareDatetime | None = None + type: TransactionTypeProperty + date: AwareDatetime = Field( + ..., + description='Date of the transaction', + examples=['2025-12-01T00:00:00+00:00'], + ) + amount: str = Field(..., description='Amount of the transaction.', examples=['123.45']) + description: str = Field( + ..., description='Description of the transaction.', examples=['Vegetables'] + ) + order: int | None = Field( + None, + description='Order of this entry in the list of transactions.', + examples=[0], + ) + currency_id: str | None = Field( + None, + description="Currency ID. Default is the source account's currency, or the user's financial administration's currency. The value you submit may be overruled by the source or destination account.", + examples=['12'], + ) + currency_code: str | None = Field( + None, + description="Currency code. Default is the source account's currency, or the user's financial administration's primary currency. The value you submit may be overruled by the source or destination account.", + examples=['EUR'], + ) + foreign_amount: str | None = Field( + None, description='The amount in a foreign currency.', examples=['123.45'] + ) + foreign_currency_id: str | None = Field( + None, + description='Currency ID of the foreign currency. Default is null. Is required when you submit a foreign amount.', + examples=['17'], + ) + foreign_currency_code: str | None = Field( + None, + description='Currency code of the foreign currency. Default is NULL. Can be used instead of the foreign_currency_id, but this or the ID is required when submitting a foreign amount.', + examples=['USD'], + ) + budget_id: str | None = Field( + None, description='The budget ID for this transaction.', examples=['4'] + ) + budget_name: str | None = Field( + None, + description='The name of the budget to be used. If the budget name is unknown, the ID will be used or the value will be ignored.', + examples=['Groceries'], + ) + category_id: str | None = Field( + None, description='The category ID for this transaction.', examples=['43'] + ) + category_name: str | None = Field( + None, + description='The name of the category to be used. If the category is unknown, it will be created. If the ID and the name point to different categories, the ID overrules the name.', + examples=['Groceries'], + ) + source_id: str | None = Field( + None, + description='ID of the source account. For a withdrawal or a transfer, this must always be an asset account. For deposits, this must be a revenue account.', + examples=['2'], + ) + source_name: str | None = Field( + None, + description='Name of the source account. For a withdrawal or a transfer, this must always be an asset account. For deposits, this must be a revenue account. Can be used instead of the source_id. If the transaction is a deposit, the source_name can be filled in freely: the account will be created based on the name.', + examples=['Checking account'], + ) + destination_id: str | None = Field( + None, + description='ID of the destination account. For a deposit or a transfer, this must always be an asset account. For withdrawals this must be an expense account.', + examples=['2'], + ) + destination_name: str | None = Field( + None, + description='Name of the destination account. You can submit the name instead of the ID. For everything except transfers, the account will be auto-generated if unknown, so submitting a name is enough.', + examples=['Buy and Large'], + ) + reconciled: bool | None = Field( + None, + description='If the transaction has been reconciled already. When you set this, the amount can no longer be edited by the user.', + examples=[False], + ) + piggy_bank_id: int | None = Field( + None, description='Optional. Use either this or the piggy_bank_name' + ) + piggy_bank_name: str | None = Field( + None, description='Optional. Use either this or the piggy_bank_id' + ) + bill_id: str | None = Field( + None, description='Optional. Use either this or the bill_name', examples=['112'] + ) + bill_name: str | None = Field( + None, + description='Optional. Use either this or the bill_id', + examples=['Monthly rent'], + ) + tags: list[str] | None = Field(None, description='Array of tags.', examples=[None]) + notes: str | None = Field(None, examples=['Some example notes']) + internal_reference: str | None = Field( + None, description='Reference to internal reference of other systems.' + ) + external_id: str | None = Field(None, description='Reference to external ID in other systems.') + external_url: str | None = Field(None, description='External, custom URL for this transaction.') + sepa_cc: str | None = Field(None, description='SEPA Clearing Code') + sepa_ct_op: str | None = Field(None, description='SEPA Opposing Account Identifier') + sepa_ct_id: str | None = Field(None, description='SEPA end-to-end Identifier') + sepa_db: str | None = Field(None, description='SEPA mandate identifier') + sepa_country: str | None = Field(None, description='SEPA Country') + sepa_ep: str | None = Field(None, description='SEPA External Purpose indicator') + sepa_ci: str | None = Field(None, description='SEPA Creditor Identifier') + sepa_batch_id: str | None = Field(None, description='SEPA Batch ID') + interest_date: AwareDatetime | None = None + book_date: AwareDatetime | None = None + process_date: AwareDatetime | None = None + due_date: AwareDatetime | None = None + payment_date: AwareDatetime | None = None + invoice_date: AwareDatetime | None = None class TransactionSplitUpdate(BaseModel): - transaction_journal_id: str | None = Field( - None, - description='Transaction journal ID of current transaction (split).', - examples=['123'], - ) - type: TransactionTypeProperty | None = None - date: AwareDatetime | None = Field( - None, - description='Date of the transaction', - examples=['2025-12-01T00:00:00+00:00'], - ) - amount: str | None = Field(None, description='Amount of the transaction.', examples=['123.45']) - description: str | None = Field( - None, description='Description of the transaction.', examples=['Vegetables'] - ) - order: int | None = Field( - None, - description='Order of this entry in the list of transactions.', - examples=[0], - ) - currency_id: str | None = Field( - None, - description="Currency ID. Default is the source account's currency, or the user's financial administration's primary currency. Can be used instead of currency_code.", - examples=['12'], - ) - currency_code: str | None = Field( - None, - description="Currency code. Default is the source account's currency, or the user's financial administration's primary currency. Can be used instead of currency_id.", - examples=['EUR'], - ) - currency_symbol: str | None = Field(None, examples=['$']) - currency_name: str | None = Field(None, examples=['Euro']) - currency_decimal_places: int | None = Field( - None, description='Number of decimals used in this currency.', examples=[2] - ) - foreign_amount: str | None = Field( - None, description='The amount in a foreign currency.', examples=['123.45'] - ) - foreign_currency_id: str | None = Field( - None, - description='Currency ID of the foreign currency. Default is null. Is required when you submit a foreign amount.', - examples=['17'], - ) - foreign_currency_code: str | None = Field( - None, - description='Currency code of the foreign currency. Default is NULL. Can be used instead of the foreign_currency_id, but this or the ID is required when submitting a foreign amount.', - examples=['USD'], - ) - foreign_currency_symbol: str | None = Field(None, examples=['$']) - foreign_currency_decimal_places: int | None = Field( - None, description='Number of decimals in the currency', examples=[2] - ) - budget_id: str | None = Field( - None, description='The budget ID for this transaction.', examples=['4'] - ) - budget_name: str | None = Field( - None, - description='The name of the budget to be used. If the budget name is unknown, the ID will be used or the value will be ignored.', - examples=['Groceries'], - ) - category_id: str | None = Field( - None, description='The category ID for this transaction.', examples=['43'] - ) - category_name: str | None = Field( - None, - description='The name of the category to be used. If the category is unknown, it will be created. If the ID and the name point to different categories, the ID overrules the name.', - examples=['Groceries'], - ) - source_id: str | None = Field( - None, - description='ID of the source account. For a withdrawal or a transfer, this must always be an asset account. For deposits, this must be a revenue account.', - examples=['2'], - ) - source_name: str | None = Field( - None, - description='Name of the source account. For a withdrawal or a transfer, this must always be an asset account. For deposits, this must be a revenue account. Can be used instead of the source_id. If the transaction is a deposit, the source_name can be filled in freely: the account will be created based on the name.', - examples=['Checking account'], - ) - source_iban: str | None = Field(None, examples=['NL02ABNA0123456789']) - destination_id: str | None = Field( - None, - description='ID of the destination account. For a deposit or a transfer, this must always be an asset account. For withdrawals this must be an expense account.', - examples=['2'], - ) - destination_name: str | None = Field( - None, - description='Name of the destination account. You can submit the name instead of the ID. For everything except transfers, the account will be auto-generated if unknown, so submitting a name is enough.', - examples=['Buy and Large'], - ) - destination_iban: str | None = Field(None, examples=['NL02ABNA0123456789']) - reconciled: bool | None = Field( - None, - description='If the transaction has been reconciled already. When you set this, the amount can no longer be edited by the user.', - examples=[False], - ) - bill_id: str | None = Field( - None, description='Optional. Use either this or the bill_name', examples=['111'] - ) - bill_name: str | None = Field( - None, - description='Optional. Use either this or the bill_id', - examples=['Monthly rent'], - ) - tags: list[str] | None = Field(None, description='Array of tags.', examples=[None]) - notes: str | None = Field(None, examples=['Some example notes']) - internal_reference: str | None = Field( - None, description='Reference to internal reference of other systems.' - ) - external_id: str | None = Field(None, description='Reference to external ID in other systems.') - external_url: str | None = Field(None, description='External, custom URL for this transaction.') - sepa_cc: str | None = Field(None, description='SEPA Clearing Code') - sepa_ct_op: str | None = Field(None, description='SEPA Opposing Account Identifier') - sepa_ct_id: str | None = Field(None, description='SEPA end-to-end Identifier') - sepa_db: str | None = Field(None, description='SEPA mandate identifier') - sepa_country: str | None = Field(None, description='SEPA Country') - sepa_ep: str | None = Field(None, description='SEPA External Purpose indicator') - sepa_ci: str | None = Field(None, description='SEPA Creditor Identifier') - sepa_batch_id: str | None = Field(None, description='SEPA Batch ID') - interest_date: AwareDatetime | None = None - book_date: AwareDatetime | None = None - process_date: AwareDatetime | None = None - due_date: AwareDatetime | None = None - payment_date: AwareDatetime | None = None - invoice_date: AwareDatetime | None = None + transaction_journal_id: str | None = Field( + None, + description='Transaction journal ID of current transaction (split).', + examples=['123'], + ) + type: TransactionTypeProperty | None = None + date: AwareDatetime | None = Field( + None, + description='Date of the transaction', + examples=['2025-12-01T00:00:00+00:00'], + ) + amount: str | None = Field(None, description='Amount of the transaction.', examples=['123.45']) + description: str | None = Field( + None, description='Description of the transaction.', examples=['Vegetables'] + ) + order: int | None = Field( + None, + description='Order of this entry in the list of transactions.', + examples=[0], + ) + currency_id: str | None = Field( + None, + description="Currency ID. Default is the source account's currency, or the user's financial administration's primary currency. Can be used instead of currency_code.", + examples=['12'], + ) + currency_code: str | None = Field( + None, + description="Currency code. Default is the source account's currency, or the user's financial administration's primary currency. Can be used instead of currency_id.", + examples=['EUR'], + ) + currency_symbol: str | None = Field(None, examples=['$']) + currency_name: str | None = Field(None, examples=['Euro']) + currency_decimal_places: int | None = Field( + None, description='Number of decimals used in this currency.', examples=[2] + ) + foreign_amount: str | None = Field( + None, description='The amount in a foreign currency.', examples=['123.45'] + ) + foreign_currency_id: str | None = Field( + None, + description='Currency ID of the foreign currency. Default is null. Is required when you submit a foreign amount.', + examples=['17'], + ) + foreign_currency_code: str | None = Field( + None, + description='Currency code of the foreign currency. Default is NULL. Can be used instead of the foreign_currency_id, but this or the ID is required when submitting a foreign amount.', + examples=['USD'], + ) + foreign_currency_symbol: str | None = Field(None, examples=['$']) + foreign_currency_decimal_places: int | None = Field( + None, description='Number of decimals in the currency', examples=[2] + ) + budget_id: str | None = Field( + None, description='The budget ID for this transaction.', examples=['4'] + ) + budget_name: str | None = Field( + None, + description='The name of the budget to be used. If the budget name is unknown, the ID will be used or the value will be ignored.', + examples=['Groceries'], + ) + category_id: str | None = Field( + None, description='The category ID for this transaction.', examples=['43'] + ) + category_name: str | None = Field( + None, + description='The name of the category to be used. If the category is unknown, it will be created. If the ID and the name point to different categories, the ID overrules the name.', + examples=['Groceries'], + ) + source_id: str | None = Field( + None, + description='ID of the source account. For a withdrawal or a transfer, this must always be an asset account. For deposits, this must be a revenue account.', + examples=['2'], + ) + source_name: str | None = Field( + None, + description='Name of the source account. For a withdrawal or a transfer, this must always be an asset account. For deposits, this must be a revenue account. Can be used instead of the source_id. If the transaction is a deposit, the source_name can be filled in freely: the account will be created based on the name.', + examples=['Checking account'], + ) + source_iban: str | None = Field(None, examples=['NL02ABNA0123456789']) + destination_id: str | None = Field( + None, + description='ID of the destination account. For a deposit or a transfer, this must always be an asset account. For withdrawals this must be an expense account.', + examples=['2'], + ) + destination_name: str | None = Field( + None, + description='Name of the destination account. You can submit the name instead of the ID. For everything except transfers, the account will be auto-generated if unknown, so submitting a name is enough.', + examples=['Buy and Large'], + ) + destination_iban: str | None = Field(None, examples=['NL02ABNA0123456789']) + reconciled: bool | None = Field( + None, + description='If the transaction has been reconciled already. When you set this, the amount can no longer be edited by the user.', + examples=[False], + ) + bill_id: str | None = Field( + None, description='Optional. Use either this or the bill_name', examples=['111'] + ) + bill_name: str | None = Field( + None, + description='Optional. Use either this or the bill_id', + examples=['Monthly rent'], + ) + tags: list[str] | None = Field(None, description='Array of tags.', examples=[None]) + notes: str | None = Field(None, examples=['Some example notes']) + internal_reference: str | None = Field( + None, description='Reference to internal reference of other systems.' + ) + external_id: str | None = Field(None, description='Reference to external ID in other systems.') + external_url: str | None = Field(None, description='External, custom URL for this transaction.') + sepa_cc: str | None = Field(None, description='SEPA Clearing Code') + sepa_ct_op: str | None = Field(None, description='SEPA Opposing Account Identifier') + sepa_ct_id: str | None = Field(None, description='SEPA end-to-end Identifier') + sepa_db: str | None = Field(None, description='SEPA mandate identifier') + sepa_country: str | None = Field(None, description='SEPA Country') + sepa_ep: str | None = Field(None, description='SEPA External Purpose indicator') + sepa_ci: str | None = Field(None, description='SEPA Creditor Identifier') + sepa_batch_id: str | None = Field(None, description='SEPA Batch ID') + interest_date: AwareDatetime | None = None + book_date: AwareDatetime | None = None + process_date: AwareDatetime | None = None + due_date: AwareDatetime | None = None + payment_date: AwareDatetime | None = None + invoice_date: AwareDatetime | None = None class User(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - email: EmailStr = Field( - ..., - description='The new users email address.', - examples=['james@firefly-iii.org'], - ) - blocked: bool | None = Field( - None, - description='Boolean to indicate if the user is blocked.', - examples=[False], - ) - blocked_code: UserBlockedCodeProperty | None = None - role: UserRoleProperty | None = None + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + email: EmailStr = Field( + ..., + description='The new users email address.', + examples=['james@firefly-iii.org'], + ) + blocked: bool | None = Field( + None, + description='Boolean to indicate if the user is blocked.', + examples=[False], + ) + blocked_code: UserBlockedCodeProperty | None = None + role: UserRoleProperty | None = None class UserGroupReadMembers(BaseModel): - user_id: str | None = Field(None, description='The ID of the member.', examples=['5']) - user_email: EmailStr | None = Field( - None, - description='The email address of the member', - examples=['james@firefly-iii.org'], - ) - you: bool | None = Field(None, description='Is this you? (the current user)', examples=[False]) - roles: list[UserGroupReadRole] | None = None + user_id: str | None = Field(None, description='The ID of the member.', examples=['5']) + user_email: EmailStr | None = Field( + None, + description='The email address of the member', + examples=['james@firefly-iii.org'], + ) + you: bool | None = Field(None, description='Is this you? (the current user)', examples=[False]) + roles: list[UserGroupReadRole] | None = None class WebhookProperties(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - active: bool | None = Field( - None, - description='Boolean to indicate if the webhook is active', - examples=[False], - ) - title: str = Field( - ..., - description='A title for the webhook for easy recognition.', - examples=['Update magic mirror on new transaction'], - ) - secret: str | None = Field( - None, - description="A 24-character secret for the webhook. It's generated by Firefly III when saving a new webhook. If you submit a new secret through the PUT endpoint it will generate a new secret for the selected webhook, a new secret bearing no relation to whatever you just submitted.", - examples=['iMLZLtLx2JHWhK9Dtyuoqyir'], - ) - triggers: WebhookTriggerArray | None = None - responses: WebhookResponseArray | None = None - deliveries: WebhookDeliveryArray | None = None - url: str = Field( - ..., - description='The URL of the webhook. Has to start with `https`.', - examples=['https://example.com'], - ) + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + active: bool | None = Field( + None, + description='Boolean to indicate if the webhook is active', + examples=[False], + ) + title: str = Field( + ..., + description='A title for the webhook for easy recognition.', + examples=['Update magic mirror on new transaction'], + ) + secret: str | None = Field( + None, + description="A 24-character secret for the webhook. It's generated by Firefly III when saving a new webhook. If you submit a new secret through the PUT endpoint it will generate a new secret for the selected webhook, a new secret bearing no relation to whatever you just submitted.", + examples=['iMLZLtLx2JHWhK9Dtyuoqyir'], + ) + triggers: WebhookTriggerArray | None = None + responses: WebhookResponseArray | None = None + deliveries: WebhookDeliveryArray | None = None + url: str = Field( + ..., + description='The URL of the webhook. Has to start with `https`.', + examples=['https://example.com'], + ) class WebhookStore(BaseModel): - active: bool | None = Field( - None, - description='Boolean to indicate if the webhook is active', - examples=[False], - ) - title: str = Field( - ..., - description='A title for the webhook for easy recognition.', - examples=['Update magic mirror on new transaction'], - ) - triggers: WebhookTriggerArray | None = None - responses: WebhookResponseArray | None = None - deliveries: WebhookDeliveryArray | None = None - url: str = Field( - ..., - description='The URL of the webhook. Has to start with `https`.', - examples=['https://example.com'], - ) + active: bool | None = Field( + None, + description='Boolean to indicate if the webhook is active', + examples=[False], + ) + title: str = Field( + ..., + description='A title for the webhook for easy recognition.', + examples=['Update magic mirror on new transaction'], + ) + triggers: WebhookTriggerArray | None = None + responses: WebhookResponseArray | None = None + deliveries: WebhookDeliveryArray | None = None + url: str = Field( + ..., + description='The URL of the webhook. Has to start with `https`.', + examples=['https://example.com'], + ) class PolymorphicProperty(RootModel[bool | str | dict[str, Any] | list[StringArrayItem]]): - root: bool | str | dict[str, Any] | list[StringArrayItem] + root: bool | str | dict[str, Any] | list[StringArrayItem] class BasicSummary(RootModel[dict[str, BasicSummaryEntry]]): - root: dict[str, BasicSummaryEntry] + root: dict[str, BasicSummaryEntry] class Configuration(BaseModel): - title: ConfigValueFilter - value: PolymorphicProperty - editable: bool = Field( - ..., - description='If this config variable can be edited by the user', - examples=[True], - ) + title: ConfigValueFilter + value: PolymorphicProperty + editable: bool = Field( + ..., + description='If this config variable can be edited by the user', + examples=[True], + ) class ConfigurationUpdate(BaseModel): - value: PolymorphicProperty + value: PolymorphicProperty class CronResult(BaseModel): - recurring_transactions: CronResultRow | None = None - auto_budgets: CronResultRow | None = None - telemetry: CronResultRow | None = None + recurring_transactions: CronResultRow | None = None + auto_budgets: CronResultRow | None = None + telemetry: CronResultRow | None = None class AccountArray(BaseModel): - data: list[AccountRead] - meta: Meta + data: list[AccountRead] + meta: Meta class AttachmentArray(BaseModel): - data: list[AttachmentRead] - meta: Meta + data: list[AttachmentRead] + meta: Meta class BillArray(BaseModel): - data: list[BillRead] - meta: Meta + data: list[BillRead] + meta: Meta class BudgetArray(BaseModel): - data: list[BudgetRead] - meta: Meta + data: list[BudgetRead] + meta: Meta class CategoryArray(BaseModel): - data: list[CategoryRead] - meta: Meta + data: list[CategoryRead] + meta: Meta class ConfigurationArray(RootModel[list[Configuration]]): - root: list[Configuration] + root: list[Configuration] class CurrencyArray(BaseModel): - data: list[CurrencyRead] - meta: Meta - links: PageLink + data: list[CurrencyRead] + meta: Meta + links: PageLink class CurrencyExchangeRateArray(BaseModel): - data: list[CurrencyExchangeRateRead] - meta: Meta - links: PageLink + data: list[CurrencyExchangeRateRead] + meta: Meta + links: PageLink class LinkTypeArray(BaseModel): - data: list[LinkTypeRead] - meta: Meta - links: PageLink + data: list[LinkTypeRead] + meta: Meta + links: PageLink class ObjectGroupArray(BaseModel): - data: list[ObjectGroupRead] - meta: Meta + data: list[ObjectGroupRead] + meta: Meta class PiggyBankArray(BaseModel): - data: list[PiggyBankRead] - meta: Meta - links: PageLink + data: list[PiggyBankRead] + meta: Meta + links: PageLink class PiggyBankEventArray(BaseModel): - data: list[PiggyBankEventRead] - meta: Meta - links: PageLink + data: list[PiggyBankEventRead] + meta: Meta + links: PageLink class RuleGroupArray(BaseModel): - data: list[RuleGroupRead] - meta: Meta - links: PageLink + data: list[RuleGroupRead] + meta: Meta + links: PageLink class TagArray(BaseModel): - data: list[TagRead] - meta: Meta - links: PageLink + data: list[TagRead] + meta: Meta + links: PageLink class TransactionLinkArray(BaseModel): - data: list[TransactionLinkRead] - meta: Meta - links: PageLink + data: list[TransactionLinkRead] + meta: Meta + links: PageLink class WebhookAttemptArray(BaseModel): - data: list[WebhookAttemptRead] - meta: Meta + data: list[WebhookAttemptRead] + meta: Meta class WebhookMessageArray(BaseModel): - data: list[WebhookMessageRead] - meta: Meta + data: list[WebhookMessageRead] + meta: Meta class ConfigurationSingle(BaseModel): - data: Configuration + data: Configuration class UserRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['users']) - id: str = Field(..., examples=['2']) - attributes: User - links: ObjectLink + type: str = Field(..., description='Immutable value', examples=['users']) + id: str = Field(..., examples=['2']) + attributes: User + links: ObjectLink class WebhookRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['webhooks']) - id: str = Field(..., examples=['2']) - attributes: WebhookProperties - links: ObjectLink + type: str = Field(..., description='Immutable value', examples=['webhooks']) + id: str = Field(..., examples=['2']) + attributes: WebhookProperties + links: ObjectLink class WebhookSingle(BaseModel): - data: WebhookRead + data: WebhookRead class Preference(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - name: str = Field(..., examples=['currencyPreference']) - data: PolymorphicProperty + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + name: str = Field(..., examples=['currencyPreference']) + data: PolymorphicProperty class PreferenceUpdate(BaseModel): - data: PolymorphicProperty + data: PolymorphicProperty class RecurrenceProperties(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - type: RecurrenceTransactionType | None = None - title: str | None = Field(None, examples=['Rent']) - description: str | None = Field( - None, - description='Not to be confused with the description of the actual transaction(s) being created.', - examples=['Recurring transaction for the monthly rent'], - ) - first_date: date_aliased | None = Field( - None, - description='First time the recurring transaction will fire. Must be after today.', - examples=['2025-12-31'], - ) - latest_date: date_aliased | None = Field( - None, - description='Last time the recurring transaction has fired.', - examples=['2025-12-01'], - ) - repeat_until: date_aliased | None = Field( - None, - description='Date until the recurring transaction can fire. Use either this field or repetitions.', - examples=['2025-12-31'], - ) - apply_rules: bool | None = Field( - None, - description='Whether or not to fire the rules after the creation of a transaction.', - examples=[True], - ) - active: bool | None = Field( - None, description='If the recurrence is even active.', examples=[True] - ) - nr_of_repetitions: int | None = Field( - None, - description='Max number of created transactions. Use either this field or repeat_until.', - examples=[5], - ) - notes: str | None = Field(None, examples=['Some notes']) - repetitions: list[RecurrenceRepetition] | None = None - transactions: list[RecurrenceTransaction] | None = None + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + type: RecurrenceTransactionType | None = None + title: str | None = Field(None, examples=['Rent']) + description: str | None = Field( + None, + description='Not to be confused with the description of the actual transaction(s) being created.', + examples=['Recurring transaction for the monthly rent'], + ) + first_date: date_aliased | None = Field( + None, + description='First time the recurring transaction will fire. Must be after today.', + examples=['2025-12-31'], + ) + latest_date: date_aliased | None = Field( + None, + description='Last time the recurring transaction has fired.', + examples=['2025-12-01'], + ) + repeat_until: date_aliased | None = Field( + None, + description='Date until the recurring transaction can fire. Use either this field or repetitions.', + examples=['2025-12-31'], + ) + apply_rules: bool | None = Field( + None, + description='Whether or not to fire the rules after the creation of a transaction.', + examples=[True], + ) + active: bool | None = Field( + None, description='If the recurrence is even active.', examples=[True] + ) + nr_of_repetitions: int | None = Field( + None, + description='Max number of created transactions. Use either this field or repeat_until.', + examples=[5], + ) + notes: str | None = Field(None, examples=['Some notes']) + repetitions: list[RecurrenceRepetition] | None = None + transactions: list[RecurrenceTransaction] | None = None class RecurrenceStore(BaseModel): - type: RecurrenceTransactionType - title: str = Field(..., examples=['Rent']) - description: str | None = Field( - None, - description='Not to be confused with the description of the actual transaction(s) being created.', - examples=['Recurring transaction for the monthly rent'], - ) - first_date: date_aliased = Field( - ..., - description='First time the recurring transaction will fire. Must be after today.', - examples=['2025-12-31'], - ) - repeat_until: date_aliased = Field( - ..., - description='Date until the recurring transaction can fire. Use either this field or repetitions.', - examples=['2025-12-31'], - ) - nr_of_repetitions: int | None = Field( - None, - description='Max number of created transactions. Use either this field or repeat_until.', - examples=[5], - ) - apply_rules: bool | None = Field( - None, - description='Whether or not to fire the rules after the creation of a transaction.', - examples=[True], - ) - active: bool | None = Field( - None, description='If the recurrence is even active.', examples=[True] - ) - notes: str | None = Field(None, examples=['Some notes']) - repetitions: list[RecurrenceRepetitionStore] - transactions: list[RecurrenceTransactionStore] + type: RecurrenceTransactionType + title: str = Field(..., examples=['Rent']) + description: str | None = Field( + None, + description='Not to be confused with the description of the actual transaction(s) being created.', + examples=['Recurring transaction for the monthly rent'], + ) + first_date: date_aliased = Field( + ..., + description='First time the recurring transaction will fire. Must be after today.', + examples=['2025-12-31'], + ) + repeat_until: date_aliased = Field( + ..., + description='Date until the recurring transaction can fire. Use either this field or repetitions.', + examples=['2025-12-31'], + ) + nr_of_repetitions: int | None = Field( + None, + description='Max number of created transactions. Use either this field or repeat_until.', + examples=[5], + ) + apply_rules: bool | None = Field( + None, + description='Whether or not to fire the rules after the creation of a transaction.', + examples=[True], + ) + active: bool | None = Field( + None, description='If the recurrence is even active.', examples=[True] + ) + notes: str | None = Field(None, examples=['Some notes']) + repetitions: list[RecurrenceRepetitionStore] + transactions: list[RecurrenceTransactionStore] class RecurrenceUpdate(BaseModel): - title: str | None = Field(None, examples=['Rent']) - description: str | None = Field( - None, - description='Not to be confused with the description of the actual transaction(s) being created.', - examples=['Recurring transaction for the monthly rent'], - ) - first_date: date_aliased | None = Field( - None, - description='First time the recurring transaction will fire.', - examples=['2025-12-31'], - ) - repeat_until: date_aliased | None = Field( - None, - description="Date until when the recurring transaction can fire. After that date, it's basically inactive. Use either this field or repetitions.", - examples=['2025-12-31'], - ) - nr_of_repetitions: int | None = Field( - None, - description='Max number of created transactions. Use either this field or repeat_until.', - examples=[5], - ) - apply_rules: bool | None = Field( - None, - description='Whether or not to fire the rules after the creation of a transaction.', - examples=[True], - ) - active: bool | None = Field( - None, description='If the recurrence is even active.', examples=[True] - ) - notes: str | None = Field(None, examples=['Some notes']) - repetitions: list[RecurrenceRepetitionUpdate] | None = None - transactions: list[RecurrenceTransactionUpdate] | None = None + title: str | None = Field(None, examples=['Rent']) + description: str | None = Field( + None, + description='Not to be confused with the description of the actual transaction(s) being created.', + examples=['Recurring transaction for the monthly rent'], + ) + first_date: date_aliased | None = Field( + None, + description='First time the recurring transaction will fire.', + examples=['2025-12-31'], + ) + repeat_until: date_aliased | None = Field( + None, + description="Date until when the recurring transaction can fire. After that date, it's basically inactive. Use either this field or repetitions.", + examples=['2025-12-31'], + ) + nr_of_repetitions: int | None = Field( + None, + description='Max number of created transactions. Use either this field or repeat_until.', + examples=[5], + ) + apply_rules: bool | None = Field( + None, + description='Whether or not to fire the rules after the creation of a transaction.', + examples=[True], + ) + active: bool | None = Field( + None, description='If the recurrence is even active.', examples=[True] + ) + notes: str | None = Field(None, examples=['Some notes']) + repetitions: list[RecurrenceRepetitionUpdate] | None = None + transactions: list[RecurrenceTransactionUpdate] | None = None class Rule(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - title: str = Field(..., examples=['First rule title.']) - description: str | None = Field(None, examples=['First rule description']) - rule_group_id: str = Field( - ..., - description='ID of the rule group under which the rule must be stored. Either this field or rule_group_title is mandatory.', - examples=['81'], - ) - rule_group_title: str | None = Field( - None, - description='Title of the rule group under which the rule must be stored. Either this field or rule_group_id is mandatory.', - examples=['New rule group'], - ) - order: int | None = Field(None, examples=[5]) - trigger: RuleTriggerType - active: bool | None = Field( - True, - description='Whether or not the rule is even active. Default is true.', - examples=[True], - ) - strict: bool | None = Field( - None, - description='If the rule is set to be strict, ALL triggers must hit in order for the rule to fire. Otherwise, just one is enough. Default value is true.', - examples=[True], - ) - stop_processing: bool | None = Field( - False, - description='If this value is true and the rule is triggered, other rules after this one in the group will be skipped. Default value is false.', - examples=[False], - ) - triggers: list[RuleTrigger] - actions: list[RuleAction] + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + title: str = Field(..., examples=['First rule title.']) + description: str | None = Field(None, examples=['First rule description']) + rule_group_id: str = Field( + ..., + description='ID of the rule group under which the rule must be stored. Either this field or rule_group_title is mandatory.', + examples=['81'], + ) + rule_group_title: str | None = Field( + None, + description='Title of the rule group under which the rule must be stored. Either this field or rule_group_id is mandatory.', + examples=['New rule group'], + ) + order: int | None = Field(None, examples=[5]) + trigger: RuleTriggerType + active: bool | None = Field( + True, + description='Whether or not the rule is even active. Default is true.', + examples=[True], + ) + strict: bool | None = Field( + None, + description='If the rule is set to be strict, ALL triggers must hit in order for the rule to fire. Otherwise, just one is enough. Default value is true.', + examples=[True], + ) + stop_processing: bool | None = Field( + False, + description='If this value is true and the rule is triggered, other rules after this one in the group will be skipped. Default value is false.', + examples=[False], + ) + triggers: list[RuleTrigger] + actions: list[RuleAction] class RuleStore(BaseModel): - title: str = Field(..., examples=['First rule title.']) - description: str | None = Field(None, examples=['First rule description']) - rule_group_id: str = Field( - ..., - description='ID of the rule group under which the rule must be stored. Either this field or rule_group_title is mandatory.', - examples=['81'], - ) - rule_group_title: str | None = Field( - None, - description='Title of the rule group under which the rule must be stored. Either this field or rule_group_id is mandatory.', - examples=['New rule group'], - ) - order: int | None = Field(None, examples=[5]) - trigger: RuleTriggerType - active: bool | None = Field( - True, - description='Whether or not the rule is even active. Default is true.', - examples=[True], - ) - strict: bool | None = Field( - True, - description='If the rule is set to be strict, ALL triggers must hit in order for the rule to fire. Otherwise, just one is enough. Default value is true.', - examples=[True], - ) - stop_processing: bool | None = Field( - None, - description='If this value is true and the rule is triggered, other rules after this one in the group will be skipped. Default value is false.', - examples=[False], - ) - triggers: list[RuleTriggerStore] - actions: list[RuleActionStore] + title: str = Field(..., examples=['First rule title.']) + description: str | None = Field(None, examples=['First rule description']) + rule_group_id: str = Field( + ..., + description='ID of the rule group under which the rule must be stored. Either this field or rule_group_title is mandatory.', + examples=['81'], + ) + rule_group_title: str | None = Field( + None, + description='Title of the rule group under which the rule must be stored. Either this field or rule_group_id is mandatory.', + examples=['New rule group'], + ) + order: int | None = Field(None, examples=[5]) + trigger: RuleTriggerType + active: bool | None = Field( + True, + description='Whether or not the rule is even active. Default is true.', + examples=[True], + ) + strict: bool | None = Field( + True, + description='If the rule is set to be strict, ALL triggers must hit in order for the rule to fire. Otherwise, just one is enough. Default value is true.', + examples=[True], + ) + stop_processing: bool | None = Field( + None, + description='If this value is true and the rule is triggered, other rules after this one in the group will be skipped. Default value is false.', + examples=[False], + ) + triggers: list[RuleTriggerStore] + actions: list[RuleActionStore] class RuleUpdate(BaseModel): - title: str | None = Field(None, examples=['First rule title.']) - description: str | None = Field(None, examples=['First rule description']) - rule_group_id: str | None = Field( - None, - description='ID of the rule group under which the rule must be stored. Either this field or rule_group_title is mandatory.', - examples=['81'], - ) - order: int | None = Field(None, examples=[5]) - trigger: RuleTriggerType | None = None - active: bool | None = Field( - True, - description='Whether or not the rule is even active. Default is true.', - examples=[True], - ) - strict: bool | None = Field( - None, - description='If the rule is set to be strict, ALL triggers must hit in order for the rule to fire. Otherwise, just one is enough. Default value is true.', - examples=[True], - ) - stop_processing: bool | None = Field( - False, - description='If this value is true and the rule is triggered, other rules after this one in the group will be skipped. Default value is false.', - examples=[False], - ) - triggers: list[RuleTriggerUpdate] | None = None - actions: list[RuleActionUpdate] | None = None + title: str | None = Field(None, examples=['First rule title.']) + description: str | None = Field(None, examples=['First rule description']) + rule_group_id: str | None = Field( + None, + description='ID of the rule group under which the rule must be stored. Either this field or rule_group_title is mandatory.', + examples=['81'], + ) + order: int | None = Field(None, examples=[5]) + trigger: RuleTriggerType | None = None + active: bool | None = Field( + True, + description='Whether or not the rule is even active. Default is true.', + examples=[True], + ) + strict: bool | None = Field( + None, + description='If the rule is set to be strict, ALL triggers must hit in order for the rule to fire. Otherwise, just one is enough. Default value is true.', + examples=[True], + ) + stop_processing: bool | None = Field( + False, + description='If this value is true and the rule is triggered, other rules after this one in the group will be skipped. Default value is false.', + examples=[False], + ) + triggers: list[RuleTriggerUpdate] | None = None + actions: list[RuleActionUpdate] | None = None class Transaction(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - user: str | None = Field(None, description='User ID', examples=['3']) - group_title: str | None = Field( - None, - description='Title of the transaction if it has been split in more than one piece. Empty otherwise.', - examples=['Split transaction title.'], - ) - transactions: list[TransactionSplit] + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + user: str | None = Field(None, description='User ID', examples=['3']) + group_title: str | None = Field( + None, + description='Title of the transaction if it has been split in more than one piece. Empty otherwise.', + examples=['Split transaction title.'], + ) + transactions: list[TransactionSplit] class TransactionStore(BaseModel): - error_if_duplicate_hash: bool | None = Field( - None, - description='Break if the submitted transaction exists already.', - examples=[False], - ) - apply_rules: bool | None = Field( - None, - description='Whether or not to apply rules when submitting transaction.', - examples=[False], - ) - fire_webhooks: bool | None = Field( - True, - description='Whether or not to fire the webhooks that are related to this event.', - examples=[True], - ) - group_title: str | None = Field( - None, - description='Title of the transaction if it has been split in more than one piece. Empty otherwise.', - examples=['Split transaction title.'], - ) - transactions: list[TransactionSplitStore] + error_if_duplicate_hash: bool | None = Field( + None, + description='Break if the submitted transaction exists already.', + examples=[False], + ) + apply_rules: bool | None = Field( + None, + description='Whether or not to apply rules when submitting transaction.', + examples=[False], + ) + fire_webhooks: bool | None = Field( + True, + description='Whether or not to fire the webhooks that are related to this event.', + examples=[True], + ) + group_title: str | None = Field( + None, + description='Title of the transaction if it has been split in more than one piece. Empty otherwise.', + examples=['Split transaction title.'], + ) + transactions: list[TransactionSplitStore] class TransactionUpdate(BaseModel): - apply_rules: bool | None = Field( - None, - description='Whether or not to apply rules when submitting transaction.', - examples=[False], - ) - fire_webhooks: bool | None = Field( - True, - description='Whether or not to fire the webhooks that are related to this event.', - examples=[True], - ) - group_title: str | None = Field( - None, - description='Title of the transaction if it has been split in more than one piece. Empty otherwise.', - examples=['Split transaction title.'], - ) - transactions: list[TransactionSplitUpdate] | None = None + apply_rules: bool | None = Field( + None, + description='Whether or not to apply rules when submitting transaction.', + examples=[False], + ) + fire_webhooks: bool | None = Field( + True, + description='Whether or not to fire the webhooks that are related to this event.', + examples=[True], + ) + group_title: str | None = Field( + None, + description='Title of the transaction if it has been split in more than one piece. Empty otherwise.', + examples=['Split transaction title.'], + ) + transactions: list[TransactionSplitUpdate] | None = None class UserGroupReadAttributes(BaseModel): - created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) - in_use: bool | None = Field( - None, - description="Is this user group ('financial administration') currently the active administration?", - examples=[False], - ) - can_see_members: bool | None = Field( - None, - description='Can the current user see the members of this user group?', - examples=[True], - ) - title: str | None = Field( - None, - description="Title of the user group. By default, it is the same as the user's email address.", - examples=['demo@firefly'], - ) - primary_currency_id: str | None = Field( - None, - description='Returns the primary currency ID of the user group.', - examples=['12'], - ) - primary_currency_code: str | None = Field( - None, - description='Returns the primary currency code of the user group.', - examples=['EUR'], - ) - primary_currency_symbol: str | None = Field( - None, - description='Returns the primary currency symbol of the user group.', - examples=['$'], - ) - primary_currency_decimal_places: int | None = Field( - None, - description='Returns the primary currency decimal places of the user group.', - examples=[2], - ) - members: list[UserGroupReadMembers] | None = None + created_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + updated_at: AwareDatetime | None = Field(None, examples=['2025-12-01T00:00:00+00:00']) + in_use: bool | None = Field( + None, + description="Is this user group ('financial administration') currently the active administration?", + examples=[False], + ) + can_see_members: bool | None = Field( + None, + description='Can the current user see the members of this user group?', + examples=[True], + ) + title: str | None = Field( + None, + description="Title of the user group. By default, it is the same as the user's email address.", + examples=['demo@firefly'], + ) + primary_currency_id: str | None = Field( + None, + description='Returns the primary currency ID of the user group.', + examples=['12'], + ) + primary_currency_code: str | None = Field( + None, + description='Returns the primary currency code of the user group.', + examples=['EUR'], + ) + primary_currency_symbol: str | None = Field( + None, + description='Returns the primary currency symbol of the user group.', + examples=['$'], + ) + primary_currency_decimal_places: int | None = Field( + None, + description='Returns the primary currency decimal places of the user group.', + examples=[2], + ) + members: list[UserGroupReadMembers] | None = None class UserSingle(BaseModel): - data: UserRead + data: UserRead class UserArray(BaseModel): - data: list[UserRead] - meta: Meta - links: PageLink + data: list[UserRead] + meta: Meta + links: PageLink class WebhookArray(BaseModel): - data: list[WebhookRead] - meta: Meta - links: PageLink + data: list[WebhookRead] + meta: Meta + links: PageLink class PreferenceRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['preferences']) - id: str = Field(..., examples=['2']) - attributes: Preference + type: str = Field(..., description='Immutable value', examples=['preferences']) + id: str = Field(..., examples=['2']) + attributes: Preference class PreferenceSingle(BaseModel): - data: PreferenceRead + data: PreferenceRead class RecurrenceRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['recurrences']) - id: str = Field(..., examples=['2']) - attributes: RecurrenceProperties - links: ObjectLink + type: str = Field(..., description='Immutable value', examples=['recurrences']) + id: str = Field(..., examples=['2']) + attributes: RecurrenceProperties + links: ObjectLink class RecurrenceSingle(BaseModel): - data: RecurrenceRead + data: RecurrenceRead class RuleRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['rules']) - id: str = Field(..., examples=['2']) - attributes: Rule - links: ObjectLink + type: str = Field(..., description='Immutable value', examples=['rules']) + id: str = Field(..., examples=['2']) + attributes: Rule + links: ObjectLink class RuleSingle(BaseModel): - data: RuleRead + data: RuleRead class TransactionRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['transactions']) - id: str = Field(..., examples=['2']) - attributes: Transaction - links: ObjectLink + type: str = Field(..., description='Immutable value', examples=['transactions']) + id: str = Field(..., examples=['2']) + attributes: Transaction + links: ObjectLink class TransactionSingle(BaseModel): - data: TransactionRead + data: TransactionRead class UserGroupRead(BaseModel): - type: str = Field(..., description='Immutable value', examples=['user_groups']) - id: str = Field(..., examples=['2']) - attributes: UserGroupReadAttributes - links: ObjectLink + type: str = Field(..., description='Immutable value', examples=['user_groups']) + id: str = Field(..., examples=['2']) + attributes: UserGroupReadAttributes + links: ObjectLink class UserGroupSingle(BaseModel): - data: UserGroupRead + data: UserGroupRead class PreferenceArray(BaseModel): - data: list[PreferenceRead] - meta: Meta - links: PageLink + data: list[PreferenceRead] + meta: Meta + links: PageLink class RecurrenceArray(BaseModel): - data: list[RecurrenceRead] - meta: Meta - links: PageLink + data: list[RecurrenceRead] + meta: Meta + links: PageLink class RuleArray(BaseModel): - data: list[RuleRead] - meta: Meta - links: PageLink + data: list[RuleRead] + meta: Meta + links: PageLink class TransactionArray(BaseModel): - data: list[TransactionRead] - meta: Meta - links: PageLink + data: list[TransactionRead] + meta: Meta + links: PageLink class UserGroupArray(BaseModel): - data: list[UserGroupRead] - meta: Meta - links: PageLink + data: list[UserGroupRead] + meta: Meta + links: PageLink diff --git a/src/lampyrid/models/lampyrid_models.py b/src/lampyrid/models/lampyrid_models.py index db80915..640c0d9 100644 --- a/src/lampyrid/models/lampyrid_models.py +++ b/src/lampyrid/models/lampyrid_models.py @@ -1,592 +1,662 @@ +"""Simplified models for MCP tool interfaces with budget support.""" + from datetime import date, datetime, timezone from typing import List, Literal, Optional from pydantic import BaseModel, Field, model_validator from .firefly_models import ( - AccountRead, - AccountTypeFilter, - BudgetRead, - ShortAccountTypeProperty, - TransactionArray, - TransactionRead, - TransactionSingle, - TransactionSplitStore, - TransactionTypeFilter, - TransactionTypeProperty, + AccountRead, + AccountTypeFilter, + BudgetRead, + ShortAccountTypeProperty, + TransactionArray, + TransactionRead, + TransactionSingle, + TransactionSplitStore, + TransactionTypeFilter, + TransactionTypeProperty, ) def utc_now(): - """Return current UTC time with timezone info""" - return datetime.now(timezone.utc) + """Return current UTC time with timezone info.""" + return datetime.now(timezone.utc) class Account(BaseModel): - id: str = Field(..., description='Unique identifier for the account', examples=['2']) - name: str = Field(..., description='Display name of the account', examples=['Cash']) - type: ShortAccountTypeProperty = Field( - ..., description='Type of the account', examples=['asset'] - ) - currency_code: Optional[str] = Field( - None, description='Currency code (ISO 4217) for the account', examples=['GBP'] - ) - current_balance: Optional[float] = Field( - None, description='Current account balance as a decimal number', examples=[1000.0] - ) - - @classmethod - def from_account_read(cls, account_read: 'AccountRead') -> 'Account': - """Create an Account instance from a Firefly AccountRead object.""" - return cls( - id=account_read.id, - name=account_read.attributes.name, - type=account_read.attributes.type, - currency_code=account_read.attributes.currency_code, - current_balance=( - float(account_read.attributes.current_balance) - if account_read.attributes.current_balance - else None - ), - ) + """Simplified account model for MCP responses.""" + + id: str = Field(..., description='Unique identifier for the account', examples=['2']) + name: str = Field(..., description='Display name of the account', examples=['Cash']) + type: ShortAccountTypeProperty = Field( + ..., description='Type of the account', examples=['asset'] + ) + currency_code: Optional[str] = Field( + None, description='Currency code (ISO 4217) for the account', examples=['GBP'] + ) + current_balance: Optional[float] = Field( + None, description='Current account balance as a decimal number', examples=[1000.0] + ) + + @classmethod + def from_account_read(cls, account_read: 'AccountRead') -> 'Account': + """Create an Account instance from a Firefly AccountRead object.""" + return cls( + id=account_read.id, + name=account_read.attributes.name, + type=account_read.attributes.type, + currency_code=account_read.attributes.currency_code, + current_balance=( + float(account_read.attributes.current_balance) + if account_read.attributes.current_balance + else None + ), + ) class Budget(BaseModel): - id: str = Field(..., description='Unique identifier for the budget', examples=['2']) - name: str = Field(..., description='Display name of the budget', examples=['Groceries']) - active: Optional[bool] = Field( - None, - description='Whether this budget is currently active for new transactions', - examples=[True], - ) - notes: Optional[str] = Field( - None, - description='Optional notes or description about this budget', - examples=['Monthly grocery budget'], - ) - order: Optional[int] = Field( - None, description='Display order for sorting budgets', examples=[1] - ) - - @classmethod - def from_budget_read(cls, budget_read: 'BudgetRead') -> 'Budget': - """Create a Budget instance from a Firefly BudgetRead object.""" - return cls( - id=budget_read.id, - name=budget_read.attributes.name, - active=budget_read.attributes.active, - notes=budget_read.attributes.notes, - order=budget_read.attributes.order, - ) + """Simplified budget model for MCP responses.""" + + id: str = Field(..., description='Unique identifier for the budget', examples=['2']) + name: str = Field(..., description='Display name of the budget', examples=['Groceries']) + active: Optional[bool] = Field( + None, + description='Whether this budget is currently active for new transactions', + examples=[True], + ) + notes: Optional[str] = Field( + None, + description='Optional notes or description about this budget', + examples=['Monthly grocery budget'], + ) + order: Optional[int] = Field( + None, description='Display order for sorting budgets', examples=[1] + ) + + @classmethod + def from_budget_read(cls, budget_read: 'BudgetRead') -> 'Budget': + """Create a Budget instance from a Firefly BudgetRead object.""" + return cls( + id=budget_read.id, + name=budget_read.attributes.name, + active=budget_read.attributes.active, + notes=budget_read.attributes.notes, + order=budget_read.attributes.order, + ) class Transaction(BaseModel): - id: Optional[str] = Field(None, description='Transaction ID') - amount: float = Field(..., description='Amount of the transaction') - description: str = Field(..., description='Description of the transaction') - type: TransactionTypeProperty = Field(..., description='Type of the transaction') - date: datetime = Field( - default_factory=datetime.now, description='Date and time of the transaction' - ) - source_id: Optional[str] = Field( - None, - description='ID of the source account. For a withdrawal or a transfer, this must always be an asset account. For deposits, this must be a revenue account.', - ) - destination_id: Optional[str] = Field( - None, - description='ID of the destination account. For a deposit or a transfer, this must always be an asset account. For withdrawals this must be an expense account.', - ) - source_name: Optional[str] = Field(None, description='Source account name') - destination_name: Optional[str] = Field(None, description='Destination account name') - currency_code: Optional[str] = Field(None, description='Currency code') - budget_id: Optional[str] = Field(None, description='ID of the budget for this transaction') - budget_name: Optional[str] = Field(None, description='Name of the budget for this transaction') - - @classmethod - def from_transaction_single(cls, trx: TransactionSingle) -> 'Transaction': - inner_trx = trx.data.attributes.transactions[0] - return cls( - id=trx.data.id, - amount=float(inner_trx.amount), - description=inner_trx.description, - type=inner_trx.type, - date=inner_trx.date, - source_id=inner_trx.source_id, - destination_id=inner_trx.destination_id, - source_name=inner_trx.source_name, - destination_name=inner_trx.destination_name, - currency_code=inner_trx.currency_code, - budget_id=inner_trx.budget_id, - budget_name=inner_trx.budget_name, - ) - - @classmethod - def from_transaction_read(cls, transaction_read: TransactionRead) -> 'Transaction': - """Create a Transaction from a Firefly TransactionRead object.""" - first_trx = transaction_read.attributes.transactions[0] - return cls( - id=transaction_read.id, - description=first_trx.description, - amount=float(first_trx.amount), - date=first_trx.date, - type=first_trx.type, - source_id=first_trx.source_id, - destination_id=first_trx.destination_id, - source_name=first_trx.source_name, - destination_name=first_trx.destination_name, - currency_code=first_trx.currency_code, - budget_id=first_trx.budget_id, - budget_name=first_trx.budget_name, - ) - - def to_transaction_split_store(self) -> TransactionSplitStore: - return TransactionSplitStore( - type=self.type, - date=self.date, - amount=str(self.amount), - description=self.description, - source_id=self.source_id, - destination_id=self.destination_id, - source_name=self.source_name, - destination_name=self.destination_name, - budget_id=self.budget_id, - budget_name=self.budget_name, - ) + """Simplified transaction model for MCP responses.""" + + id: Optional[str] = Field(None, description='Transaction ID') + amount: float = Field(..., description='Amount of the transaction') + description: str = Field(..., description='Description of the transaction') + type: TransactionTypeProperty = Field(..., description='Type of the transaction') + date: datetime = Field( + default_factory=datetime.now, description='Date and time of the transaction' + ) + source_id: Optional[str] = Field( + None, + description=( + 'ID of the source account. For withdrawal/transfer, must be asset account. ' + 'For deposits, must be revenue account.' + ), + ) + destination_id: Optional[str] = Field( + None, + description=( + 'ID of the destination account. For deposit/transfer, must be asset account. ' + 'For withdrawals, must be expense account.' + ), + ) + source_name: Optional[str] = Field(None, description='Source account name') + destination_name: Optional[str] = Field(None, description='Destination account name') + currency_code: Optional[str] = Field(None, description='Currency code') + budget_id: Optional[str] = Field(None, description='ID of the budget for this transaction') + budget_name: Optional[str] = Field(None, description='Name of the budget for this transaction') + + @classmethod + def from_transaction_single(cls, trx: TransactionSingle) -> 'Transaction': + """Create a Transaction instance from a Firefly III TransactionSingle response.""" + inner_trx = trx.data.attributes.transactions[0] + return cls( + id=trx.data.id, + amount=float(inner_trx.amount), + description=inner_trx.description, + type=inner_trx.type, + date=inner_trx.date, + source_id=inner_trx.source_id, + destination_id=inner_trx.destination_id, + source_name=inner_trx.source_name, + destination_name=inner_trx.destination_name, + currency_code=inner_trx.currency_code, + budget_id=inner_trx.budget_id, + budget_name=inner_trx.budget_name, + ) + + @classmethod + def from_transaction_read(cls, transaction_read: TransactionRead) -> 'Transaction': + """Create a Transaction from a Firefly TransactionRead object.""" + first_trx = transaction_read.attributes.transactions[0] + return cls( + id=transaction_read.id, + description=first_trx.description, + amount=float(first_trx.amount), + date=first_trx.date, + type=first_trx.type, + source_id=first_trx.source_id, + destination_id=first_trx.destination_id, + source_name=first_trx.source_name, + destination_name=first_trx.destination_name, + currency_code=first_trx.currency_code, + budget_id=first_trx.budget_id, + budget_name=first_trx.budget_name, + ) + + def to_transaction_split_store(self) -> TransactionSplitStore: + """Convert this transaction to a Firefly III TransactionSplitStore for API requests.""" + return TransactionSplitStore( + type=self.type, + date=self.date, + amount=str(self.amount), + description=self.description, + source_id=self.source_id, + destination_id=self.destination_id, + source_name=self.source_name, + destination_name=self.destination_name, + budget_id=self.budget_id, + budget_name=self.budget_name, + ) class ListAccountRequest(BaseModel): - type: AccountTypeFilter = Field( - ..., - description='Account type: asset (your accounts), expense (spending categories), revenue (income sources), liability (debts), or all', - ) + """Request model for listing accounts.""" + + type: AccountTypeFilter = Field( + ..., + description=( + 'Account type: asset (your accounts), expense (spending categories), ' + 'revenue (income sources), liability (debts), or all' + ), + ) class SearchAccountRequest(BaseModel): - query: str = Field( - ..., - description='Text to search for in account names (supports partial matching)', - min_length=1, - ) - type: AccountTypeFilter = Field( - AccountTypeFilter.all, - description='Limit search to specific account type (asset, expense, revenue, liability, or all)', - ) + """Request model for searching accounts.""" + + query: str = Field( + ..., + description='Text to search for in account names (supports partial matching)', + min_length=1, + ) + type: AccountTypeFilter = Field( + AccountTypeFilter.all, + description=( + 'Limit search to specific account type (asset, expense, revenue, liability, or all)' + ), + ) class GetAccountRequest(BaseModel): - id: str = Field( - ..., description='Unique identifier of the account (from list_accounts or search_accounts)' - ) + """Request model for getting a single account.""" + + id: str = Field( + ..., description='Unique identifier of the account (from list_accounts or search_accounts)' + ) class CreateWithdrawalRequest(BaseModel): - amount: float = Field( - ..., description='Amount to withdraw as positive number (e.g., 25.50 for $25.50 expense)' - ) - description: str = Field( - ..., description='What this expense was for (e.g., "Grocery shopping at Whole Foods")' - ) - date: datetime = Field( - default_factory=utc_now, - description='When the expense occurred (defaults to current time if not specified)', - ) - source_id: str = Field( - ..., - description='ID of your account the money comes from (checking, savings, cash, etc.). Must be an asset account you own.', - ) - destination_name: Optional[str] = Field( - default=None, - description='Where the money went ("Groceries", "Gas Station", "ATM"). Creates expense account if new. Leave blank for cash withdrawals.', - ) - budget_id: Optional[str] = Field( - None, description='Budget to track this expense against (from list_budgets)' - ) - budget_name: Optional[str] = Field( - None, - description='Name of budget if ID is unknown. Will use ID if both provided.', - ) + """Request model for creating a withdrawal transaction.""" + + amount: float = Field( + ..., description='Amount to withdraw as positive number (e.g., 25.50 for $25.50 expense)' + ) + description: str = Field( + ..., description='What this expense was for (e.g., "Grocery shopping at Whole Foods")' + ) + date: datetime = Field( + default_factory=utc_now, + description='When the expense occurred (defaults to current time if not specified)', + ) + source_id: str = Field( + ..., + description=( + 'ID of your account the money comes from (checking, savings, cash, etc.). ' + 'Must be an asset account you own.' + ), + ) + destination_name: Optional[str] = Field( + default=None, + description=( + 'Where the money went ("Groceries", "Gas Station", "ATM"). ' + 'Creates expense account if new. Leave blank for cash withdrawals.' + ), + ) + budget_id: Optional[str] = Field( + None, description='Budget to track this expense against (from list_budgets)' + ) + budget_name: Optional[str] = Field( + None, + description='Name of budget if ID is unknown. Will use ID if both provided.', + ) class CreateDepositRequest(BaseModel): - amount: float = Field( - ..., description='Amount received as positive number (e.g., 2500.00 for $2500 salary)' - ) - description: str = Field( - ..., description='What this income was for (e.g., "Monthly salary", "Freelance payment")' - ) - date: datetime = Field( - default_factory=utc_now, - description='When the income was received (defaults to current time if not specified)', - ) - source_name: Optional[str] = Field( - default=None, - description='Where the money came from ("Employer", "Client Name", "Gift"). Creates revenue account if new.', - ) - destination_id: str = Field( - ..., - description='ID of your account receiving the money (checking, savings, etc.). Must be an asset account you own.', - ) + """Request model for creating a deposit transaction.""" + + amount: float = Field( + ..., description='Amount received as positive number (e.g., 2500.00 for $2500 salary)' + ) + description: str = Field( + ..., description='What this income was for (e.g., "Monthly salary", "Freelance payment")' + ) + date: datetime = Field( + default_factory=utc_now, + description='When the income was received (defaults to current time if not specified)', + ) + source_name: Optional[str] = Field( + default=None, + description=( + 'Where the money came from ("Employer", "Client Name", "Gift"). ' + 'Creates revenue account if new.' + ), + ) + destination_id: str = Field( + ..., + description=( + 'ID of your account receiving the money (checking, savings, etc.). ' + 'Must be an asset account you own.' + ), + ) class CreateTransferRequest(BaseModel): - amount: float = Field( - ..., description='Amount to move as positive number (e.g., 500.00 to move $500)' - ) - description: str = Field( - ..., - description='Purpose of the transfer (e.g., "Transfer to savings", "Credit card payment")', - ) - date: datetime = Field( - default_factory=utc_now, - description='When the transfer occurred (defaults to current time if not specified)', - ) - source_id: str = Field( - ..., - description='ID of your account the money comes from. Must be an asset account you own.', - ) - destination_id: str = Field( - ..., - description='ID of your account receiving the money. Must be an asset account you own.', - ) + """Request model for creating a transfer transaction.""" + + amount: float = Field( + ..., description='Amount to move as positive number (e.g., 500.00 to move $500)' + ) + description: str = Field( + ..., + description='Purpose of the transfer (e.g., "Transfer to savings", "Credit card payment")', + ) + date: datetime = Field( + default_factory=utc_now, + description='When the transfer occurred (defaults to current time if not specified)', + ) + source_id: str = Field( + ..., + description='ID of your account the money comes from. Must be an asset account you own.', + ) + destination_id: str = Field( + ..., + description='ID of your account receiving the money. Must be an asset account you own.', + ) class GetTransactionsRequest(BaseModel): - account_id: Optional[str] = Field( - None, - description='Optional account ID to filter results. When provided, only transactions involving this ' - 'specific account (as source or destination) are returned. When omitted or None, ' - 'transactions for all accounts are returned (subject to other filters).', - ) - start_date: Optional[date] = Field( - None, - description='Start date for filtering transactions (YYYY-MM-DD format). If not specified, returns recent transactions.', - ) - end_date: Optional[date] = Field( - None, - description='End date for filtering transactions (YYYY-MM-DD format). If not specified, returns up to current date.', - ) - transaction_type: Optional[TransactionTypeFilter] = Field( - None, - description='Filter by transaction type: withdrawal (expenses), deposit (income), or transfer (between accounts)', - ) - page: Optional[int] = Field( - 1, - description='Page number to retrieve (1-based). Use for browsing large result sets.', - ge=1, - ) - limit: Optional[int] = Field( - 50, description='Maximum number of transactions to return per page (1-500)', ge=1, le=500 - ) + """Request model for retrieving transactions.""" + + account_id: Optional[str] = Field( + None, + description=( + 'Optional account ID to filter results. When provided, only transactions ' + 'involving this specific account (as source or destination) are returned. ' + 'When omitted or None, transactions for all accounts are returned.' + ), + ) + start_date: Optional[date] = Field( + None, + description='Start date for filtering transactions (YYYY-MM-DD). ' + 'If not specified, returns recent transactions.', + ) + end_date: Optional[date] = Field( + None, + description='End date for filtering transactions (YYYY-MM-DD). ' + 'If not specified, returns up to current date.', + ) + transaction_type: Optional[TransactionTypeFilter] = Field( + None, + description='Filter by transaction type: withdrawal (expenses), deposit (income), ' + 'transfer (accounts)', + ) + page: Optional[int] = Field( + 1, + description='Page number to retrieve (1-based). Use for browsing large result sets.', + ge=1, + ) + limit: Optional[int] = Field( + 50, description='Maximum number of transactions to return per page (1-500)', ge=1, le=500 + ) class SearchTransactionsRequest(BaseModel): - query: str | None = Field( - None, - description='Free-text search or raw Firefly III query string. Can be combined with structured filters below.', - ) - - # Transaction type and amount filters - type: Literal['withdrawal', 'deposit', 'transfer'] | None = Field( - None, - description='Transaction type to filter by', - examples=['withdrawal', 'deposit', 'transfer'], - ) - amount_equals: float | None = Field( - None, - description='Exact amount to match', - examples=[123.45], - ) - amount_more: float | None = Field( - None, - description='Minimum amount (inclusive)', - examples=[100.00], - ) - amount_less: float | None = Field( - None, - description='Maximum amount (inclusive)', - examples=[50.00], - ) - - # Date filters - date_on: date | None = Field( - None, - description='Exact date match in YYYY-MM-DD format', - examples=['2024-01-15'], - ) - date_after: date | None = Field( - None, - description='From date (inclusive) in YYYY-MM-DD format', - examples=['2024-01-01'], - ) - date_before: date | None = Field( - None, - description='Until date (inclusive) in YYYY-MM-DD format', - examples=['2024-12-31'], - ) - - # Content filters - description_contains: str | None = Field( - None, - description='Text to search for in transaction descriptions', - examples=['groceries', 'coffee'], - ) - - # Metadata filters - category: str | None = Field( - None, - description='Category name to filter by (exact match)', - examples=['Food', 'Transportation'], - ) - budget: str | None = Field( - None, - description='Budget name to filter by (exact match)', - examples=['Groceries', 'Dining Out'], - ) - - # Account filters - account_contains: str | None = Field( - None, - description='Text to search for in any account name (source or destination)', - examples=['checking', 'savings'], - ) - account_id: str | None = Field( - None, - description='Account ID to filter by (matches source or destination account)', - examples=['123'], - ) - - # Pagination - page: int | None = Field( - 1, - description='Page number to retrieve (1-based). Use for browsing large result sets.', - ge=1, - ) - limit: int | None = Field( - 50, description='Maximum number of transactions to return per page (1-500)', ge=1, le=500 - ) - - @model_validator(mode='after') - def validate_search_criteria(self): - """Ensure at least one search criterion is provided.""" - search_fields = [ - self.query, - self.type, - self.amount_equals, - self.amount_more, - self.amount_less, - self.date_on, - self.date_after, - self.date_before, - self.description_contains, - self.category, - self.budget, - self.account_contains, - self.account_id, - ] - # Consider a field provided if: (a) it's not None and not a string, or - # (b) it's a string and not empty/whitespace-only - has_criteria = any( - (field is not None and not isinstance(field, str)) - or (isinstance(field, str) and field.strip() != '') - for field in search_fields - ) - if not has_criteria: - raise ValueError('At least one search criterion must be provided') - return self + """Request model for searching transactions.""" + + query: str | None = Field( + None, + description=( + 'Free-text search or raw Firefly III query string. ' + 'Can be combined with structured filters below.' + ), + ) + + # Transaction type and amount filters + type: Literal['withdrawal', 'deposit', 'transfer'] | None = Field( + None, + description='Transaction type to filter by', + examples=['withdrawal', 'deposit', 'transfer'], + ) + amount_equals: float | None = Field( + None, + description='Exact amount to match', + examples=[123.45], + ) + amount_more: float | None = Field( + None, + description='Minimum amount (inclusive)', + examples=[100.00], + ) + amount_less: float | None = Field( + None, + description='Maximum amount (inclusive)', + examples=[50.00], + ) + + # Date filters + date_on: date | None = Field( + None, + description='Exact date match in YYYY-MM-DD format', + examples=['2024-01-15'], + ) + date_after: date | None = Field( + None, + description='From date (inclusive) in YYYY-MM-DD format', + examples=['2024-01-01'], + ) + date_before: date | None = Field( + None, + description='Until date (inclusive) in YYYY-MM-DD format', + examples=['2024-12-31'], + ) + + # Content filters + description_contains: str | None = Field( + None, + description='Text to search for in transaction descriptions', + examples=['groceries', 'coffee'], + ) + + # Metadata filters + category: str | None = Field( + None, + description='Category name to filter by (exact match)', + examples=['Food', 'Transportation'], + ) + budget: str | None = Field( + None, + description='Budget name to filter by (exact match)', + examples=['Groceries', 'Dining Out'], + ) + + # Account filters + account_contains: str | None = Field( + None, + description='Text to search for in any account name (source or destination)', + examples=['checking', 'savings'], + ) + account_id: str | None = Field( + None, + description='Account ID to filter by (matches source or destination account)', + examples=['123'], + ) + + # Pagination + page: int | None = Field( + 1, + description='Page number to retrieve (1-based). Use for browsing large result sets.', + ge=1, + ) + limit: int | None = Field( + 50, description='Maximum number of transactions to return per page (1-500)', ge=1, le=500 + ) + + @model_validator(mode='after') + def validate_search_criteria(self): + """Ensure at least one search criterion is provided.""" + search_fields = [ + self.query, + self.type, + self.amount_equals, + self.amount_more, + self.amount_less, + self.date_on, + self.date_after, + self.date_before, + self.description_contains, + self.category, + self.budget, + self.account_contains, + self.account_id, + ] + # Consider a field provided if: (a) it's not None and not a string, or + # (b) it's a string and not empty/whitespace-only + has_criteria = any( + (field is not None and not isinstance(field, str)) + or (isinstance(field, str) and field.strip() != '') + for field in search_fields + ) + if not has_criteria: + raise ValueError('At least one search criterion must be provided') + return self class DeleteTransactionRequest(BaseModel): - id: str = Field(..., description='Unique identifier of the transaction to permanently remove') + """Request model for deleting a transaction.""" + + id: str = Field(..., description='Unique identifier of the transaction to permanently remove') class GetTransactionRequest(BaseModel): - id: str = Field(..., description='Unique identifier of the transaction to get details for') + """Request model for getting a single transaction.""" + + id: str = Field(..., description='Unique identifier of the transaction to get details for') class TransactionListResponse(BaseModel): - """Response model for transaction listings.""" - - transactions: List[Transaction] = Field( - ..., description='Array of transaction objects matching the request' - ) - total_count: Optional[int] = Field( - None, - description='Total transactions available across all pages (if pagination metadata available)', - ) - current_page: int = Field(..., description='Current page number in the result set') - per_page: int = Field(..., description='Number of transactions included in this page') - - @classmethod - def from_transaction_array( - cls, transaction_array: TransactionArray, current_page: int, per_page: int - ) -> 'TransactionListResponse': - """Create a TransactionListResponse from a Firefly TransactionArray.""" - transactions = [ - Transaction.from_transaction_read(trx_read) for trx_read in transaction_array.data - ] - return cls( - transactions=transactions, - total_count=transaction_array.meta.pagination.total - if transaction_array.meta.pagination - else None, - current_page=current_page, - per_page=per_page, - ) + """Response model for transaction listings.""" + + transactions: List[Transaction] = Field( + ..., description='Array of transaction objects matching the request' + ) + total_count: Optional[int] = Field( + None, + description=( + 'Total transactions available across all pages (if pagination metadata available)' + ), + ) + current_page: int = Field(..., description='Current page number in the result set') + per_page: int = Field(..., description='Number of transactions included in this page') + + @classmethod + def from_transaction_array( + cls, transaction_array: TransactionArray, current_page: int, per_page: int + ) -> 'TransactionListResponse': + """Create a TransactionListResponse from a Firefly TransactionArray.""" + transactions = [ + Transaction.from_transaction_read(trx_read) for trx_read in transaction_array.data + ] + return cls( + transactions=transactions, + total_count=transaction_array.meta.pagination.total + if transaction_array.meta.pagination + else None, + current_page=current_page, + per_page=per_page, + ) class ListBudgetsRequest(BaseModel): - """Request for listing budgets.""" + """Request for listing budgets.""" - active: Optional[bool] = Field( - None, - description='Show only active budgets (true), inactive budgets (false), or all budgets (not specified)', - ) + active: Optional[bool] = Field( + None, + description='Show only active budgets (true), inactive (false), or all budgets ' + '(not specified)', + ) class GetBudgetRequest(BaseModel): - """Request for getting a single budget by ID.""" + """Request for getting a single budget by ID.""" - id: str = Field(..., description='Unique identifier of the budget to get details for') + id: str = Field(..., description='Unique identifier of the budget to get details for') class BudgetSpending(BaseModel): - """Budget spending information for a specific period.""" - - budget_id: str = Field(..., description='Unique identifier of the budget') - budget_name: str = Field(..., description='Display name of the budget') - spent: float = Field( - ..., description='Total amount spent from this budget in the specified period' - ) - budgeted: Optional[float] = Field( - None, description='Amount allocated to this budget for the period' - ) - remaining: Optional[float] = Field( - None, description='Money left in this budget (budgeted minus spent)' - ) - percentage_spent: Optional[float] = Field( - None, - description='Percentage of allocated budget used (0-100+, can exceed 100 if overspent)', - ) + """Budget spending information for a specific period.""" + + budget_id: str = Field(..., description='Unique identifier of the budget') + budget_name: str = Field(..., description='Display name of the budget') + spent: float = Field( + ..., description='Total amount spent from this budget in the specified period' + ) + budgeted: Optional[float] = Field( + None, description='Amount allocated to this budget for the period' + ) + remaining: Optional[float] = Field( + None, description='Money left in this budget (budgeted minus spent)' + ) + percentage_spent: Optional[float] = Field( + None, + description='Percentage of allocated budget used (0-100+, can exceed 100 if overspent)', + ) class GetBudgetSpendingRequest(BaseModel): - """Request for getting budget spending data.""" + """Request for getting budget spending data.""" - budget_id: str = Field( - ..., description='Unique identifier of the budget to analyze spending for' - ) - start_date: Optional[date] = Field( - None, description='Start date for spending period (YYYY-MM-DD), inclusive' - ) - end_date: Optional[date] = Field( - None, description='End date for spending period (YYYY-MM-DD), inclusive' - ) + budget_id: str = Field( + ..., description='Unique identifier of the budget to analyze spending for' + ) + start_date: Optional[date] = Field( + None, description='Start date for spending period (YYYY-MM-DD), inclusive' + ) + end_date: Optional[date] = Field( + None, description='End date for spending period (YYYY-MM-DD), inclusive' + ) class BudgetSummary(BaseModel): - """Summary of all budgets with spending information.""" + """Summary of all budgets with spending information.""" - budgets: List[BudgetSpending] = Field(..., description='Spending analysis for each budget') - total_budgeted: Optional[float] = Field(None, description='Sum of all allocated budget amounts') - total_spent: float = Field(..., description='Sum of all spending across budgets') - total_remaining: Optional[float] = Field( - None, description='Total money left across all budgets' - ) - available_budget: Optional[float] = Field( - None, - description='Unallocated money available for new budgets or unexpected expenses', - ) + budgets: List[BudgetSpending] = Field(..., description='Spending analysis for each budget') + total_budgeted: Optional[float] = Field(None, description='Sum of all allocated budget amounts') + total_spent: float = Field(..., description='Sum of all spending across budgets') + total_remaining: Optional[float] = Field( + None, description='Total money left across all budgets' + ) + available_budget: Optional[float] = Field( + None, + description='Unallocated money available for new budgets or unexpected expenses', + ) class GetBudgetSummaryRequest(BaseModel): - """Request for getting budget summary.""" + """Request for getting budget summary.""" - start_date: Optional[date] = Field( - None, description='Start date for summary period (YYYY-MM-DD), inclusive' - ) - end_date: Optional[date] = Field( - None, description='End date for summary period (YYYY-MM-DD), inclusive' - ) + start_date: Optional[date] = Field( + None, description='Start date for summary period (YYYY-MM-DD), inclusive' + ) + end_date: Optional[date] = Field( + None, description='End date for summary period (YYYY-MM-DD), inclusive' + ) class AvailableBudget(BaseModel): - """Available budget information for a period.""" + """Available budget information for a period.""" - amount: float = Field(..., description='Total unallocated budget available for the period') - currency_code: str = Field(..., description='Currency code (ISO 4217) for the budget amount') - start_date: date = Field(..., description='Beginning of the budget period this amount covers') - end_date: date = Field(..., description='End of the budget period this amount covers') + amount: float = Field(..., description='Total unallocated budget available for the period') + currency_code: str = Field(..., description='Currency code (ISO 4217) for the budget amount') + start_date: date = Field(..., description='Beginning of the budget period this amount covers') + end_date: date = Field(..., description='End of the budget period this amount covers') class GetAvailableBudgetRequest(BaseModel): - """Request for getting available budget.""" + """Request for getting available budget.""" - start_date: Optional[date] = Field( - None, - description='Start date for budget analysis (YYYY-MM-DD format). Defaults to beginning of current month.', - ) - end_date: Optional[date] = Field( - None, - description='End date for budget analysis (YYYY-MM-DD format). Defaults to end of current month.', - ) + start_date: Optional[date] = Field( + None, + description='Start date for budget analysis (YYYY-MM-DD). Defaults to ' + 'beginning of current month.', + ) + end_date: Optional[date] = Field( + None, + description=( + 'End date for budget analysis (YYYY-MM-DD format). Defaults to end of current month.' + ), + ) class CreateBulkTransactionsRequest(BaseModel): - """Create multiple transactions in one operation.""" - - transactions: List[Transaction] = Field( - ..., - description='List of transactions to create (can be mixed types: withdrawals, deposits, transfers)', - min_length=1, - max_length=100, - ) - - @model_validator(mode='after') - def validate_transactions(self): - """Ensure transactions are only of allowed types.""" - for trx in self.transactions: - if trx.type not in { - TransactionTypeProperty.withdrawal, - TransactionTypeProperty.deposit, - TransactionTypeProperty.transfer, - }: - raise ValueError( - f'Invalid transaction type: {trx.type}. Only withdrawal, deposit, and transfer are allowed.' - ) - return self + """Create multiple transactions in one operation.""" + + transactions: List[Transaction] = Field( + ..., + description=( + 'List of transactions to create (can be mixed types: withdrawals, deposits, transfers)' + ), + min_length=1, + max_length=100, + ) + + @model_validator(mode='after') + def validate_transactions(self): + """Ensure transactions are only of allowed types.""" + for trx in self.transactions: + if trx.type not in { + TransactionTypeProperty.withdrawal, + TransactionTypeProperty.deposit, + TransactionTypeProperty.transfer, + }: + raise ValueError( + f'Invalid transaction type: {trx.type}. ' + f'Only withdrawal, deposit, and transfer are allowed.' + ) + return self class UpdateTransactionRequest(BaseModel): - """Update an existing transaction.""" - - transaction_id: str = Field(..., description='Unique identifier of the transaction to modify') - amount: Optional[float] = Field(None, description='New transaction amount (positive number)') - description: Optional[str] = Field( - None, description='New description for what the transaction was for' - ) - date: Optional[datetime] = Field( - None, description='New date/time when the transaction occurred' - ) - source_id: Optional[str] = Field( - None, description='New source account ID (where money comes from)' - ) - destination_id: Optional[str] = Field( - None, description='New destination account ID (where money goes to)' - ) - budget_id: Optional[str] = Field( - None, description='New budget ID to assign, or None to remove budget assignment' - ) - category_name: Optional[str] = Field( - None, description='New category name for transaction classification' - ) + """Update an existing transaction.""" + + transaction_id: str = Field(..., description='Unique identifier of the transaction to modify') + amount: Optional[float] = Field(None, description='New transaction amount (positive number)') + description: Optional[str] = Field( + None, description='New description for what the transaction was for' + ) + date: Optional[datetime] = Field( + None, description='New date/time when the transaction occurred' + ) + source_id: Optional[str] = Field( + None, description='New source account ID (where money comes from)' + ) + destination_id: Optional[str] = Field( + None, description='New destination account ID (where money goes to)' + ) + budget_id: Optional[str] = Field( + None, description='New budget ID to assign, or None to remove budget assignment' + ) + category_name: Optional[str] = Field( + None, description='New category name for transaction classification' + ) class BulkUpdateTransactionsRequest(BaseModel): - """Update multiple transactions in one operation.""" - - updates: List[UpdateTransactionRequest] = Field( - ..., - description='Array of transaction modifications to apply in a single operation', - min_length=1, - max_length=50, - ) + """Update multiple transactions in one operation.""" + + updates: List[UpdateTransactionRequest] = Field( + ..., + description='Array of transaction modifications to apply in a single operation', + min_length=1, + max_length=50, + ) diff --git a/src/lampyrid/server.py b/src/lampyrid/server.py index e2942bf..7035d2d 100644 --- a/src/lampyrid/server.py +++ b/src/lampyrid/server.py @@ -1,3 +1,5 @@ +"""MCP server initialization and configuration.""" + import asyncio from typing import Optional @@ -18,74 +20,74 @@ def _create_auth_provider() -> Optional[AuthProvider]: - """ - Create Google authentication provider if credentials are configured. - - If token persistence is enabled, initializes encrypted disk storage for OAuth tokens. - Otherwise, tokens are stored in memory and lost on server restart. - - Returns: - GoogleProvider if all required credentials are present, None otherwise - """ - if settings.is_auth_enabled: - # Initialize persistent token storage if encryption keys are configured - client_storage = None - if settings.is_token_persistence_enabled: - # Create storage directory if it doesn't exist - settings.oauth_storage_path.mkdir(parents=True, exist_ok=True) - - # Initialize disk storage with Fernet encryption - disk_store = DiskStore(directory=settings.oauth_storage_path) - client_storage = FernetEncryptionWrapper( - key_value=disk_store, - fernet=Fernet(settings.oauth_storage_encryption_key), # ty:ignore[invalid-argument-type] - ) - - return GoogleProvider( - client_id=settings.google_client_id, # ty:ignore[invalid-argument-type] - client_secret=settings.google_client_secret, # ty:ignore[invalid-argument-type] - base_url=str(settings.server_base_url), - required_scopes=[ - 'openid', - 'https://www.googleapis.com/auth/userinfo.email', - ], - jwt_signing_key=settings.jwt_signing_key, # ty:ignore[invalid-argument-type] - client_storage=client_storage, - ) - return None + """Create Google authentication provider if credentials are configured. + + If token persistence is enabled, initializes encrypted disk storage for OAuth tokens. + Otherwise, tokens are stored in memory and lost on server restart. + + Returns: + GoogleProvider if all required credentials are present, None otherwise + + """ + if settings.is_auth_enabled: + # Initialize persistent token storage if encryption keys are configured + client_storage = None + if settings.is_token_persistence_enabled: + # Create storage directory if it doesn't exist + settings.oauth_storage_path.mkdir(parents=True, exist_ok=True) + + # Initialize disk storage with Fernet encryption + disk_store = DiskStore(directory=settings.oauth_storage_path) + client_storage = FernetEncryptionWrapper( + key_value=disk_store, + fernet=Fernet(settings.oauth_storage_encryption_key), # ty:ignore[invalid-argument-type] + ) + + return GoogleProvider( + client_id=settings.google_client_id, # ty:ignore[invalid-argument-type] + client_secret=settings.google_client_secret, # ty:ignore[invalid-argument-type] + base_url=str(settings.server_base_url), + required_scopes=[ + 'openid', + 'https://www.googleapis.com/auth/userinfo.email', + ], + jwt_signing_key=settings.jwt_signing_key, # ty:ignore[invalid-argument-type] + client_storage=client_storage, + ) + return None def _initialize_server() -> FastMCP: - """ - Initialize and configure the FastMCP server with all domain servers. + """Initialize and configure the FastMCP server with all domain servers. + + This function: + 1. Creates the main FastMCP server with authentication and icons + 2. Composes domain-specific servers (accounts, transactions, budgets) using static composition + 3. Registers custom HTTP routes - This function: - 1. Creates the main FastMCP server with authentication and icons - 2. Composes domain-specific servers (accounts, transactions, budgets) using static composition - 3. Registers custom HTTP routes + Returns: + Fully configured FastMCP server instance - Returns: - Fully configured FastMCP server instance - """ - # Initialize FastMCP with optional authentication - auth_provider = _create_auth_provider() + """ + # Initialize FastMCP with optional authentication + auth_provider = _create_auth_provider() - # Load favicon icon - favicon_icon = Icon(src=Image(path=str(get_assets_path('favicon.png'))).to_data_uri()) + # Load favicon icon + favicon_icon = Icon(src=Image(path=str(get_assets_path('favicon.png'))).to_data_uri()) - server = FastMCP('lampyrid', auth=auth_provider, icons=[favicon_icon]) - client = FireflyClient() + server = FastMCP('lampyrid', auth=auth_provider, icons=[favicon_icon]) + client = FireflyClient() - # Configure logging - configure_logging(level=settings.logging_level) + # Configure logging + configure_logging(level=settings.logging_level) - # Compose all domain servers using static composition (import_server) - asyncio.run(compose_all_servers(server, client)) + # Compose all domain servers using static composition (import_server) + asyncio.run(compose_all_servers(server, client)) - # Register custom HTTP routes - register_custom_routes(server) + # Register custom HTTP routes + register_custom_routes(server) - return server + return server # Create the main MCP server instance diff --git a/src/lampyrid/services/accounts.py b/src/lampyrid/services/accounts.py index aa17372..53a591d 100644 --- a/src/lampyrid/services/accounts.py +++ b/src/lampyrid/services/accounts.py @@ -9,75 +9,75 @@ from ..clients.firefly import FireflyClient from ..models.firefly_models import AccountStore from ..models.lampyrid_models import ( - Account, - GetAccountRequest, - ListAccountRequest, - SearchAccountRequest, + Account, + GetAccountRequest, + ListAccountRequest, + SearchAccountRequest, ) class AccountService: - """Service for managing Firefly III accounts. + """Service for managing Firefly III accounts. - This service provides a high-level interface for account operations, - handling model conversion and business logic while delegating - HTTP operations to the FireflyClient. - """ + This service provides a high-level interface for account operations, + handling model conversion and business logic while delegating + HTTP operations to the FireflyClient. + """ - def __init__(self, client: FireflyClient) -> None: - """Initialize the account service with a FireflyClient instance.""" - self._client = client + def __init__(self, client: FireflyClient) -> None: + """Initialize the account service with a FireflyClient instance.""" + self._client = client - async def list_accounts(self, req: ListAccountRequest) -> List[Account]: - """List accounts with optional type filtering. + async def list_accounts(self, req: ListAccountRequest) -> List[Account]: + """List accounts with optional type filtering. - Args: - req: Request containing account type filter + Args: + req: Request containing account type filter - Returns: - List of accounts matching the filter criteria + Returns: + List of accounts matching the filter criteria - """ - account_array = await self._client.list_accounts(type=req.type) + """ + account_array = await self._client.list_accounts(type=req.type) - return [Account.from_account_read(account_read) for account_read in account_array.data] + return [Account.from_account_read(account_read) for account_read in account_array.data] - async def get_account(self, req: GetAccountRequest) -> Account: - """Get detailed information for a single account. + async def get_account(self, req: GetAccountRequest) -> Account: + """Get detailed information for a single account. - Args: - req: Request containing the account ID + Args: + req: Request containing the account ID - Returns: - Account details including balance and metadata + Returns: + Account details including balance and metadata - """ - account_single = await self._client.get_account(req.id) - return Account.from_account_read(account_single.data) + """ + account_single = await self._client.get_account(req.id) + return Account.from_account_read(account_single.data) - async def search_accounts(self, req: SearchAccountRequest) -> List[Account]: - """Search accounts by name with optional type filtering. + async def search_accounts(self, req: SearchAccountRequest) -> List[Account]: + """Search accounts by name with optional type filtering. - Args: - req: Request containing search query and type filter + Args: + req: Request containing search query and type filter - Returns: - List of accounts matching the search criteria + Returns: + List of accounts matching the search criteria - """ - account_array = await self._client.search_accounts(req.query, req.type) + """ + account_array = await self._client.search_accounts(req.query, req.type) - return [Account.from_account_read(account_read) for account_read in account_array.data] + return [Account.from_account_read(account_read) for account_read in account_array.data] - async def create_account(self, account_store: AccountStore) -> Account: - """Create a new account. + async def create_account(self, account_store: AccountStore) -> Account: + """Create a new account. - Args: - account_store: Account data for creation + Args: + account_store: Account data for creation - Returns: - Created account details + Returns: + Created account details - """ - account_single = await self._client.create_account(account_store) - return Account.from_account_read(account_single.data) + """ + account_single = await self._client.create_account(account_store) + return Account.from_account_read(account_single.data) diff --git a/src/lampyrid/services/budgets.py b/src/lampyrid/services/budgets.py index 615242d..a1cbeb3 100644 --- a/src/lampyrid/services/budgets.py +++ b/src/lampyrid/services/budgets.py @@ -9,201 +9,200 @@ from ..clients.firefly import FireflyClient from ..models.firefly_models import ( - BudgetStore, + BudgetStore, ) from ..models.lampyrid_models import ( - AvailableBudget, - Budget, - BudgetSpending, - BudgetSummary, - GetAvailableBudgetRequest, - GetBudgetRequest, - GetBudgetSpendingRequest, - GetBudgetSummaryRequest, - ListBudgetsRequest, + AvailableBudget, + Budget, + BudgetSpending, + BudgetSummary, + GetAvailableBudgetRequest, + GetBudgetRequest, + GetBudgetSpendingRequest, + GetBudgetSummaryRequest, + ListBudgetsRequest, ) class BudgetService: - """Service for managing Firefly III budgets. + """Service for managing Firefly III budgets. - This service provides a high-level interface for budget operations, - handling spending calculations, aggregations, and multi-call orchestration - while delegating HTTP operations to the FireflyClient. - """ + This service provides a high-level interface for budget operations, + handling spending calculations, aggregations, and multi-call orchestration + while delegating HTTP operations to the FireflyClient. + """ - def __init__(self, client: FireflyClient) -> None: - """Initialize the budget service with a FireflyClient instance.""" - self._client = client + def __init__(self, client: FireflyClient) -> None: + """Initialize the budget service with a FireflyClient instance.""" + self._client = client - async def list_budgets(self, req: ListBudgetsRequest) -> List[Budget]: - """List budgets with optional active status filtering. + async def list_budgets(self, req: ListBudgetsRequest) -> List[Budget]: + """List budgets with optional active status filtering. - Args: - req: Request containing active status filter + Args: + req: Request containing active status filter - Returns: - List of budgets matching the filter criteria + Returns: + List of budgets matching the filter criteria - """ - budget_array = await self._client.get_budgets() + """ + budget_array = await self._client.get_budgets() - # Apply active filter if provided - budgets_data = budget_array.data - if req.active is not None: - budgets_data = [x for x in budgets_data if x.attributes.active == req.active] + # Apply active filter if provided + budgets_data = budget_array.data + if req.active is not None: + budgets_data = [x for x in budgets_data if x.attributes.active == req.active] - return [Budget.from_budget_read(budget_read) for budget_read in budgets_data] + return [Budget.from_budget_read(budget_read) for budget_read in budgets_data] - async def get_budget(self, req: GetBudgetRequest) -> Budget: - """Get detailed information for a single budget. + async def get_budget(self, req: GetBudgetRequest) -> Budget: + """Get detailed information for a single budget. - Args: - req: Request containing the budget ID - - Returns: - Budget details + Args: + req: Request containing the budget ID + + Returns: + Budget details - """ - budget_single = await self._client.get_budget(req.id) - return Budget.from_budget_read(budget_single.data) + """ + budget_single = await self._client.get_budget(req.id) + return Budget.from_budget_read(budget_single.data) - async def get_budget_spending(self, req: GetBudgetSpendingRequest) -> BudgetSpending: - """Get spending analysis for a specific budget and time period. + async def get_budget_spending(self, req: GetBudgetSpendingRequest) -> BudgetSpending: + """Get spending analysis for a specific budget and time period. - This method orchestrates multiple API calls to calculate budget spending, - aggregating data from budget limits and performing calculations for - spent amounts, remaining budget, and percentage used. + This method orchestrates multiple API calls to calculate budget spending, + aggregating data from budget limits and performing calculations for + spent amounts, remaining budget, and percentage used. - Args: - req: Request containing budget ID and time period + Args: + req: Request containing budget ID and time period - Returns: - Budget spending analysis with calculations - - """ - # Get budget info first - budget_single = await self._client.get_budget(req.budget_id) - budget_name = budget_single.data.attributes.name - - # Get spending data from budget limits endpoint - limits_array = await self._client.get_budget_limits( - req.budget_id, req.start_date, req.end_date - ) - - # Calculate spending from limits data - spent = 0.0 - budgeted = None - - for limit in limits_array.data: - if limit.attributes.spent: - for spent_entry in limit.attributes.spent: - if spent_entry.sum: - spent += abs(float(spent_entry.sum)) - - # amount is still a string field - if limit.attributes.amount: - if budgeted is None: - budgeted = 0.0 - budgeted += float(limit.attributes.amount) - - remaining = (budgeted - spent) if budgeted is not None else None - percentage_spent = (spent / budgeted * 100) if budgeted and budgeted > 0 else None - - return BudgetSpending( - budget_id=req.budget_id, - budget_name=budget_name, - spent=spent, - budgeted=budgeted, - remaining=remaining, - percentage_spent=percentage_spent, - ) - - async def get_budget_summary(self, req: GetBudgetSummaryRequest) -> BudgetSummary: - """Get comprehensive summary of all budgets with spending information. - - This method orchestrates multiple API calls to aggregate spending data - across all budgets, calculating totals and providing a comprehensive - budget overview. - - Args: - req: Request containing time period for analysis - - Returns: - Comprehensive budget summary with totals - - """ - # Get all budgets - budgets_array = await self._client.get_budgets() - - budget_spendings: list[BudgetSpending] = [] - total_spent = 0.0 - total_budgeted = 0.0 - - for budget in budgets_array.data: - spending_req = GetBudgetSpendingRequest( - budget_id=budget.id, - start_date=req.start_date, - end_date=req.end_date, - ) - budget_spending = await self.get_budget_spending(spending_req) - budget_spendings.append(budget_spending) - - total_spent += budget_spending.spent - if budget_spending.budgeted: - total_budgeted += budget_spending.budgeted - - total_remaining = total_budgeted - total_spent if total_budgeted > 0 else None - - return BudgetSummary( - budgets=budget_spendings, - total_budgeted=total_budgeted if total_budgeted > 0 else None, - total_spent=total_spent, - total_remaining=total_remaining, - available_budget=None, # Would need additional API call to get available budget - ) - - async def get_available_budget(self, req: GetAvailableBudgetRequest) -> AvailableBudget: - """Get available budget amount for a specified period. - - Args: - req: Request containing time period for available budget - - Returns: - Available budget information - - """ - - available_array = await self._client.get_available_budgets(req.start_date, req.end_date) - - # Parse the available budget data - if available_array.data: - first_budget = available_array.data[0] - return AvailableBudget( - amount=float(first_budget.attributes.amount), - currency_code=first_budget.attributes.currency_code or 'USD', - start_date=req.start_date or first_budget.attributes.start.date(), - end_date=req.end_date or first_budget.attributes.end.date(), - ) - else: - # Return default if no data available - today = date.today() - return AvailableBudget( - amount=0.0, - currency_code='USD', - start_date=req.start_date or today.replace(day=1), - end_date=req.end_date or today, - ) - - async def create_budget(self, budget_store: BudgetStore) -> Budget: - """Create a new budget. - - Args: - budget_store: Budget data for creation - - Returns: - Created budget details - - """ - budget_single = await self._client.create_budget(budget_store) - return Budget.from_budget_read(budget_single.data) + Returns: + Budget spending analysis with calculations + + """ + # Get budget info first + budget_single = await self._client.get_budget(req.budget_id) + budget_name = budget_single.data.attributes.name + + # Get spending data from budget limits endpoint + limits_array = await self._client.get_budget_limits( + req.budget_id, req.start_date, req.end_date + ) + + # Calculate spending from limits data + spent = 0.0 + budgeted = None + + for limit in limits_array.data: + if limit.attributes.spent: + for spent_entry in limit.attributes.spent: + if spent_entry.sum: + spent += abs(float(spent_entry.sum)) + + # amount is still a string field + if limit.attributes.amount: + if budgeted is None: + budgeted = 0.0 + budgeted += float(limit.attributes.amount) + + remaining = (budgeted - spent) if budgeted is not None else None + percentage_spent = (spent / budgeted * 100) if budgeted and budgeted > 0 else None + + return BudgetSpending( + budget_id=req.budget_id, + budget_name=budget_name, + spent=spent, + budgeted=budgeted, + remaining=remaining, + percentage_spent=percentage_spent, + ) + + async def get_budget_summary(self, req: GetBudgetSummaryRequest) -> BudgetSummary: + """Get comprehensive summary of all budgets with spending information. + + This method orchestrates multiple API calls to aggregate spending data + across all budgets, calculating totals and providing a comprehensive + budget overview. + + Args: + req: Request containing time period for analysis + + Returns: + Comprehensive budget summary with totals + + """ + # Get all budgets + budgets_array = await self._client.get_budgets() + + budget_spendings: list[BudgetSpending] = [] + total_spent = 0.0 + total_budgeted = 0.0 + + for budget in budgets_array.data: + spending_req = GetBudgetSpendingRequest( + budget_id=budget.id, + start_date=req.start_date, + end_date=req.end_date, + ) + budget_spending = await self.get_budget_spending(spending_req) + budget_spendings.append(budget_spending) + + total_spent += budget_spending.spent + if budget_spending.budgeted: + total_budgeted += budget_spending.budgeted + + total_remaining = total_budgeted - total_spent if total_budgeted > 0 else None + + return BudgetSummary( + budgets=budget_spendings, + total_budgeted=total_budgeted if total_budgeted > 0 else None, + total_spent=total_spent, + total_remaining=total_remaining, + available_budget=None, # Would need additional API call to get available budget + ) + + async def get_available_budget(self, req: GetAvailableBudgetRequest) -> AvailableBudget: + """Get available budget amount for a specified period. + + Args: + req: Request containing time period for available budget + + Returns: + Available budget information + + """ + available_array = await self._client.get_available_budgets(req.start_date, req.end_date) + + # Parse the available budget data + if available_array.data: + first_budget = available_array.data[0] + return AvailableBudget( + amount=float(first_budget.attributes.amount), + currency_code=first_budget.attributes.currency_code or 'USD', + start_date=req.start_date or first_budget.attributes.start.date(), + end_date=req.end_date or first_budget.attributes.end.date(), + ) + else: + # Return default if no data available + today = date.today() + return AvailableBudget( + amount=0.0, + currency_code='USD', + start_date=req.start_date or today.replace(day=1), + end_date=req.end_date or today, + ) + + async def create_budget(self, budget_store: BudgetStore) -> Budget: + """Create a new budget. + + Args: + budget_store: Budget data for creation + + Returns: + Created budget details + + """ + budget_single = await self._client.create_budget(budget_store) + return Budget.from_budget_read(budget_single.data) diff --git a/src/lampyrid/services/transactions.py b/src/lampyrid/services/transactions.py index 9638848..4dc3f93 100644 --- a/src/lampyrid/services/transactions.py +++ b/src/lampyrid/services/transactions.py @@ -8,347 +8,343 @@ from ..clients.firefly import FireflyClient from ..models.firefly_models import ( - TransactionSplitStore, - TransactionSplitUpdate, - TransactionStore, - TransactionTypeProperty, - TransactionUpdate, + TransactionSplitStore, + TransactionSplitUpdate, + TransactionStore, + TransactionTypeProperty, + TransactionUpdate, ) from ..models.lampyrid_models import ( - BulkUpdateTransactionsRequest, - CreateBulkTransactionsRequest, - CreateDepositRequest, - CreateTransferRequest, - CreateWithdrawalRequest, - DeleteTransactionRequest, - GetTransactionRequest, - GetTransactionsRequest, - SearchTransactionsRequest, - Transaction, - TransactionListResponse, - UpdateTransactionRequest, + BulkUpdateTransactionsRequest, + CreateBulkTransactionsRequest, + CreateDepositRequest, + CreateTransferRequest, + CreateWithdrawalRequest, + DeleteTransactionRequest, + GetTransactionRequest, + GetTransactionsRequest, + SearchTransactionsRequest, + Transaction, + TransactionListResponse, + UpdateTransactionRequest, ) class TransactionService: - """Service for managing Firefly III transactions. - - This service provides a high-level interface for transaction operations, - handling bulk operations, model conversion, and business logic while - delegating HTTP operations to the FireflyClient. - """ - - def __init__(self, client: FireflyClient) -> None: - """Initialize the transaction service with a FireflyClient instance.""" - self._client = client - - async def create_withdrawal(self, req: CreateWithdrawalRequest) -> Transaction: - """Create a withdrawal transaction. - - Args: - req: Withdrawal transaction details - - Returns: - Created transaction details - - """ - - trx = TransactionSplitStore( - amount=str(req.amount), - description=req.description, - type=TransactionTypeProperty.withdrawal, - date=req.date, - source_id=req.source_id, - destination_name=req.destination_name, - budget_id=req.budget_id, - budget_name=req.budget_name, - ) - trx_store = TransactionStore( - transactions=[trx], - apply_rules=False, - fire_webhooks=True, - group_title=None, - error_if_duplicate_hash=False, - ) - transaction_single = await self._client.create_transaction(trx_store) - return Transaction.from_transaction_single(transaction_single) - - async def create_deposit(self, req: CreateDepositRequest) -> Transaction: - """Create a deposit transaction. - - Args: - req: Deposit transaction details - - Returns: - Created transaction details - - """ - - trx = TransactionSplitStore( - amount=str(req.amount), - description=req.description, - type=TransactionTypeProperty.deposit, - date=req.date, - source_name=req.source_name, - destination_id=req.destination_id, - ) - trx_store = TransactionStore( - transactions=[trx], - apply_rules=False, - fire_webhooks=True, - group_title=None, - error_if_duplicate_hash=False, - ) - transaction_single = await self._client.create_transaction(trx_store) - return Transaction.from_transaction_single(transaction_single) - - async def create_transfer(self, req: CreateTransferRequest) -> Transaction: - """Create a transfer transaction. - - Args: - req: Transfer transaction details - - Returns: - Created transaction details - - """ - - trx = TransactionSplitStore( - amount=str(req.amount), - description=req.description, - type=TransactionTypeProperty.transfer, - date=req.date, - source_id=req.source_id, - destination_id=req.destination_id, - ) - trx_store = TransactionStore( - transactions=[trx], - apply_rules=False, - fire_webhooks=True, - group_title=None, - error_if_duplicate_hash=False, - ) - transaction_single = await self._client.create_transaction(trx_store) - return Transaction.from_transaction_single(transaction_single) - - async def create_bulk_transactions( - self, req: CreateBulkTransactionsRequest - ) -> List[Transaction]: - """Create multiple transactions in a single operation. - - This method orchestrates the creation of multiple transactions, - handling the business logic for bulk operations while delegating - the individual HTTP requests to the FireflyClient. - - Args: - req: Request containing multiple transaction details - - Returns: - List of created transactions - - """ - created_transactions: List[Transaction] = [] - - for transaction in req.transactions: - trx_split = transaction.to_transaction_split_store() - trx_store = TransactionStore( - transactions=[trx_split], - apply_rules=False, - fire_webhooks=True, - group_title=None, - error_if_duplicate_hash=False, - ) - transaction_single = await self._client.create_transaction(trx_store) - created_transactions.append(Transaction.from_transaction_single(transaction_single)) - - return created_transactions - - async def get_transaction(self, req: GetTransactionRequest) -> Transaction: - """Get detailed information for a single transaction. - - Args: - req: Request containing the transaction ID - - Returns: - Transaction details - - """ - transaction_single = await self._client.get_transaction(req.id) - return Transaction.from_transaction_single(transaction_single) - - async def get_transactions(self, req: GetTransactionsRequest) -> TransactionListResponse: - """Get transactions with optional filtering and pagination. - - Args: - req: Request containing filter criteria and pagination parameters - - Returns: - Paginated list of transactions - - """ - if req.account_id is not None: - transaction_array = await self._client.get_account_transactions( - account_id=req.account_id, - page=req.page or 1, - limit=req.limit or 50, - start_date=req.start_date, - end_date=req.end_date, - transaction_type=req.transaction_type.value if req.transaction_type else None, - ) - else: - transaction_array = await self._client.get_transactions( - page=req.page or 1, - limit=req.limit or 50, - start_date=req.start_date, - end_date=req.end_date, - transaction_type=req.transaction_type.value if req.transaction_type else None, - ) - - return TransactionListResponse.from_transaction_array( - transaction_array, current_page=req.page or 1, per_page=req.limit or 50 - ) - - async def search_transactions(self, req: SearchTransactionsRequest) -> TransactionListResponse: - """Search transactions with advanced filtering. - - Args: - req: Request containing search criteria and filters - - Returns: - Paginated list of matching transactions - - """ - # Build query string from structured fields - query_parts = [] - - # Add raw query if provided - if req.query: - query_parts.append(req.query) - - # Transaction type and amount filters - if req.type: - query_parts.append(f'type:{req.type}') - if req.amount_equals is not None: - query_parts.append(f'amount:{req.amount_equals}') - if req.amount_more is not None: - query_parts.append(f'more:{req.amount_more}') - if req.amount_less is not None: - query_parts.append(f'less:{req.amount_less}') - - # Date filters - if req.date_on: - query_parts.append(f'date_on:{req.date_on}') - if req.date_after: - query_parts.append(f'date_after:{req.date_after}') - if req.date_before: - query_parts.append(f'date_before:{req.date_before}') - - # Content filters - if req.description_contains: - query_parts.append(f'description_contains:"{req.description_contains}"') - - # Metadata filters - if req.category: - query_parts.append(f'category_is:"{req.category}"') - if req.budget: - query_parts.append(f'budget_is:"{req.budget}"') - - # Account filters - if req.account_contains: - query_parts.append(f'account_contains:"{req.account_contains}"') - if req.account_id is not None: - query_parts.append(f'account_id:{req.account_id}') - - # Combine all query parts with spaces (AND logic) - final_query = ' '.join(query_parts) - - transaction_array = await self._client.search_transactions( - query=final_query, page=req.page or 1, limit=req.limit or 50 - ) - - return TransactionListResponse.from_transaction_array( - transaction_array, current_page=req.page or 1, per_page=req.limit or 50 - ) - - async def update_transaction(self, req: UpdateTransactionRequest) -> Transaction: - """Update an existing transaction. - - Args: - req: Request containing updated transaction details - - Returns: - Updated transaction details - - """ - - # Build the update payload with only provided fields using explicit parameters - update_kwargs = {} - - if req.amount is not None: - update_kwargs['amount'] = str(req.amount) - if req.description is not None: - update_kwargs['description'] = req.description - if req.date is not None: - update_kwargs['date'] = req.date - if req.source_id is not None: - update_kwargs['source_id'] = req.source_id - if req.destination_id is not None: - update_kwargs['destination_id'] = req.destination_id - if req.budget_id is not None: - update_kwargs['budget_id'] = req.budget_id - if req.category_name is not None: - update_kwargs['category_name'] = req.category_name - - trx_split_update = TransactionSplitUpdate(**update_kwargs) - - transaction_update = TransactionUpdate( - apply_rules=False, fire_webhooks=True, group_title=None, transactions=[trx_split_update] - ) - - transaction_single = await self._client.update_transaction( - req.transaction_id, transaction_update - ) - return Transaction.from_transaction_single(transaction_single) - - async def bulk_update_transactions( - self, req: BulkUpdateTransactionsRequest - ) -> List[Transaction]: - """Update multiple transactions in a single operation. - - This method orchestrates the update of multiple transactions, - handling the business logic for bulk operations while delegating - the individual HTTP requests to the FireflyClient. - - Args: - req: Request containing multiple transaction updates - - Returns: - List of updated transactions - - """ - updated_transactions: List[Transaction] = [] - - for update_req in req.updates: - try: - updated_transaction = await self.update_transaction(update_req) - updated_transactions.append(updated_transaction) - except Exception as e: - # Re-raise with transaction ID context - raise Exception( - f'Failed to update transaction {update_req.transaction_id}: {e}' - ) from e - - return updated_transactions - - async def delete_transaction(self, req: DeleteTransactionRequest) -> bool: - """Delete a transaction. - - Args: - req: Request containing the transaction ID to delete - - Returns: - True if deletion was successful - - """ - result = await self._client.delete_transaction(req.id) - return result + """Service for managing Firefly III transactions. + + This service provides a high-level interface for transaction operations, + handling bulk operations, model conversion, and business logic while + delegating HTTP operations to the FireflyClient. + """ + + def __init__(self, client: FireflyClient) -> None: + """Initialize the transaction service with a FireflyClient instance.""" + self._client = client + + async def create_withdrawal(self, req: CreateWithdrawalRequest) -> Transaction: + """Create a withdrawal transaction. + + Args: + req: Withdrawal transaction details + + Returns: + Created transaction details + + """ + trx = TransactionSplitStore( + amount=str(req.amount), + description=req.description, + type=TransactionTypeProperty.withdrawal, + date=req.date, + source_id=req.source_id, + destination_name=req.destination_name, + budget_id=req.budget_id, + budget_name=req.budget_name, + ) + trx_store = TransactionStore( + transactions=[trx], + apply_rules=False, + fire_webhooks=True, + group_title=None, + error_if_duplicate_hash=False, + ) + transaction_single = await self._client.create_transaction(trx_store) + return Transaction.from_transaction_single(transaction_single) + + async def create_deposit(self, req: CreateDepositRequest) -> Transaction: + """Create a deposit transaction. + + Args: + req: Deposit transaction details + + Returns: + Created transaction details + + """ + trx = TransactionSplitStore( + amount=str(req.amount), + description=req.description, + type=TransactionTypeProperty.deposit, + date=req.date, + source_name=req.source_name, + destination_id=req.destination_id, + ) + trx_store = TransactionStore( + transactions=[trx], + apply_rules=False, + fire_webhooks=True, + group_title=None, + error_if_duplicate_hash=False, + ) + transaction_single = await self._client.create_transaction(trx_store) + return Transaction.from_transaction_single(transaction_single) + + async def create_transfer(self, req: CreateTransferRequest) -> Transaction: + """Create a transfer transaction. + + Args: + req: Transfer transaction details + + Returns: + Created transaction details + + """ + trx = TransactionSplitStore( + amount=str(req.amount), + description=req.description, + type=TransactionTypeProperty.transfer, + date=req.date, + source_id=req.source_id, + destination_id=req.destination_id, + ) + trx_store = TransactionStore( + transactions=[trx], + apply_rules=False, + fire_webhooks=True, + group_title=None, + error_if_duplicate_hash=False, + ) + transaction_single = await self._client.create_transaction(trx_store) + return Transaction.from_transaction_single(transaction_single) + + async def create_bulk_transactions( + self, req: CreateBulkTransactionsRequest + ) -> List[Transaction]: + """Create multiple transactions in a single operation. + + This method orchestrates the creation of multiple transactions, + handling the business logic for bulk operations while delegating + the individual HTTP requests to the FireflyClient. + + Args: + req: Request containing multiple transaction details + + Returns: + List of created transactions + + """ + created_transactions: List[Transaction] = [] + + for transaction in req.transactions: + trx_split = transaction.to_transaction_split_store() + trx_store = TransactionStore( + transactions=[trx_split], + apply_rules=False, + fire_webhooks=True, + group_title=None, + error_if_duplicate_hash=False, + ) + transaction_single = await self._client.create_transaction(trx_store) + created_transactions.append(Transaction.from_transaction_single(transaction_single)) + + return created_transactions + + async def get_transaction(self, req: GetTransactionRequest) -> Transaction: + """Get detailed information for a single transaction. + + Args: + req: Request containing the transaction ID + + Returns: + Transaction details + + """ + transaction_single = await self._client.get_transaction(req.id) + return Transaction.from_transaction_single(transaction_single) + + async def get_transactions(self, req: GetTransactionsRequest) -> TransactionListResponse: + """Get transactions with optional filtering and pagination. + + Args: + req: Request containing filter criteria and pagination parameters + + Returns: + Paginated list of transactions + + """ + if req.account_id is not None: + transaction_array = await self._client.get_account_transactions( + account_id=req.account_id, + page=req.page or 1, + limit=req.limit or 50, + start_date=req.start_date, + end_date=req.end_date, + transaction_type=req.transaction_type.value if req.transaction_type else None, + ) + else: + transaction_array = await self._client.get_transactions( + page=req.page or 1, + limit=req.limit or 50, + start_date=req.start_date, + end_date=req.end_date, + transaction_type=req.transaction_type.value if req.transaction_type else None, + ) + + return TransactionListResponse.from_transaction_array( + transaction_array, current_page=req.page or 1, per_page=req.limit or 50 + ) + + async def search_transactions(self, req: SearchTransactionsRequest) -> TransactionListResponse: + """Search transactions with advanced filtering. + + Args: + req: Request containing search criteria and filters + + Returns: + Paginated list of matching transactions + + """ + # Build query string from structured fields + query_parts = [] + + # Add raw query if provided + if req.query: + query_parts.append(req.query) + + # Transaction type and amount filters + if req.type: + query_parts.append(f'type:{req.type}') + if req.amount_equals is not None: + query_parts.append(f'amount:{req.amount_equals}') + if req.amount_more is not None: + query_parts.append(f'more:{req.amount_more}') + if req.amount_less is not None: + query_parts.append(f'less:{req.amount_less}') + + # Date filters + if req.date_on: + query_parts.append(f'date_on:{req.date_on}') + if req.date_after: + query_parts.append(f'date_after:{req.date_after}') + if req.date_before: + query_parts.append(f'date_before:{req.date_before}') + + # Content filters + if req.description_contains: + query_parts.append(f'description_contains:"{req.description_contains}"') + + # Metadata filters + if req.category: + query_parts.append(f'category_is:"{req.category}"') + if req.budget: + query_parts.append(f'budget_is:"{req.budget}"') + + # Account filters + if req.account_contains: + query_parts.append(f'account_contains:"{req.account_contains}"') + if req.account_id is not None: + query_parts.append(f'account_id:{req.account_id}') + + # Combine all query parts with spaces (AND logic) + final_query = ' '.join(query_parts) + + transaction_array = await self._client.search_transactions( + query=final_query, page=req.page or 1, limit=req.limit or 50 + ) + + return TransactionListResponse.from_transaction_array( + transaction_array, current_page=req.page or 1, per_page=req.limit or 50 + ) + + async def update_transaction(self, req: UpdateTransactionRequest) -> Transaction: + """Update an existing transaction. + + Args: + req: Request containing updated transaction details + + Returns: + Updated transaction details + + """ + # Build the update payload with only provided fields using explicit parameters + update_kwargs = {} + + if req.amount is not None: + update_kwargs['amount'] = str(req.amount) + if req.description is not None: + update_kwargs['description'] = req.description + if req.date is not None: + update_kwargs['date'] = req.date + if req.source_id is not None: + update_kwargs['source_id'] = req.source_id + if req.destination_id is not None: + update_kwargs['destination_id'] = req.destination_id + if req.budget_id is not None: + update_kwargs['budget_id'] = req.budget_id + if req.category_name is not None: + update_kwargs['category_name'] = req.category_name + + trx_split_update = TransactionSplitUpdate(**update_kwargs) + + transaction_update = TransactionUpdate( + apply_rules=False, fire_webhooks=True, group_title=None, transactions=[trx_split_update] + ) + + transaction_single = await self._client.update_transaction( + req.transaction_id, transaction_update + ) + return Transaction.from_transaction_single(transaction_single) + + async def bulk_update_transactions( + self, req: BulkUpdateTransactionsRequest + ) -> List[Transaction]: + """Update multiple transactions in a single operation. + + This method orchestrates the update of multiple transactions, + handling the business logic for bulk operations while delegating + the individual HTTP requests to the FireflyClient. + + Args: + req: Request containing multiple transaction updates + + Returns: + List of updated transactions + + """ + updated_transactions: List[Transaction] = [] + + for update_req in req.updates: + try: + updated_transaction = await self.update_transaction(update_req) + updated_transactions.append(updated_transaction) + except Exception as e: + # Re-raise with transaction ID context + raise Exception( + f'Failed to update transaction {update_req.transaction_id}: {e}' + ) from e + + return updated_transactions + + async def delete_transaction(self, req: DeleteTransactionRequest) -> bool: + """Delete a transaction. + + Args: + req: Request containing the transaction ID to delete + + Returns: + True if deletion was successful + + """ + result = await self._client.delete_transaction(req.id) + return result diff --git a/src/lampyrid/tools/__init__.py b/src/lampyrid/tools/__init__.py index 8afb6a4..cf79c7f 100644 --- a/src/lampyrid/tools/__init__.py +++ b/src/lampyrid/tools/__init__.py @@ -1,5 +1,4 @@ -""" -MCP Tools for LamPyrid. +"""MCP Tools for LamPyrid. This module coordinates the composition of all MCP tool servers organized by domain. Uses FastMCP's native import_server() for static server composition. @@ -14,19 +13,19 @@ async def compose_all_servers(mcp: FastMCP, client: FireflyClient) -> None: - """ - Compose all domain-specific MCP servers into the main server using static composition. - - Args: - mcp: The main FastMCP server instance - client: The FireflyClient instance for API interactions - """ - # Create standalone servers for each domain - accounts_server = create_accounts_server(client) - transactions_server = create_transactions_server(client) - budgets_server = create_budgets_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) + """Compose all domain-specific MCP servers into the main server using static composition. + + Args: + mcp: The main FastMCP server instance + client: The FireflyClient instance for API interactions + + """ + # Create standalone servers for each domain + accounts_server = create_accounts_server(client) + transactions_server = create_transactions_server(client) + budgets_server = create_budgets_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) diff --git a/src/lampyrid/tools/accounts.py b/src/lampyrid/tools/accounts.py index b3ddba9..f0c6155 100644 --- a/src/lampyrid/tools/accounts.py +++ b/src/lampyrid/tools/accounts.py @@ -10,43 +10,51 @@ from ..clients.firefly import FireflyClient from ..models.lampyrid_models import ( - Account, - GetAccountRequest, - ListAccountRequest, - SearchAccountRequest, + Account, + GetAccountRequest, + ListAccountRequest, + SearchAccountRequest, ) from ..services.accounts import AccountService def create_accounts_server(client: FireflyClient) -> FastMCP: - account_service = AccountService(client) - """ - Create a standalone FastMCP server for account management tools. - - Args: - client: The FireflyClient instance for API interactions - - Returns: - FastMCP server instance with account management tools registered - """ - accounts_mcp = FastMCP('accounts') - - @accounts_mcp.tool(tags={'accounts'}) - async def list_accounts(req: ListAccountRequest) -> List[Account]: - """Retrieve accounts from Firefly III. Use 'asset' for checking/savings accounts, 'expense' - for spending accounts, 'revenue' for income sources. Essential for finding account IDs - before creating transactions. - """ - return await account_service.list_accounts(req) - - @accounts_mcp.tool(tags={'accounts'}) - async def get_account(req: GetAccountRequest) -> Account: - """Retrieve detailed account information including current balance and currency. Use this to verify account details before transactions.""" - return await account_service.get_account(req) - - @accounts_mcp.tool(tags={'accounts'}) - async def search_accounts(req: SearchAccountRequest) -> List[Account]: - """Find accounts by partial name matching. Useful when you know the account name but not the ID. Supports filtering by account type.""" - return await account_service.search_accounts(req) - - return accounts_mcp + """Create a standalone FastMCP server for account management tools. + + Args: + client: The FireflyClient instance for API interactions + + Returns: + FastMCP server instance with account management tools registered + + """ + account_service = AccountService(client) + + accounts_mcp = FastMCP('accounts') + + @accounts_mcp.tool(tags={'accounts'}) + async def list_accounts(req: ListAccountRequest) -> List[Account]: + """Retrieve accounts from Firefly III. + + Use 'asset' for checking/savings accounts, 'expense' for spending accounts, 'revenue' for + income sources. Essential for finding account IDs before creating transactions. + """ + return await account_service.list_accounts(req) + + @accounts_mcp.tool(tags={'accounts'}) + async def get_account(req: GetAccountRequest) -> Account: + """Retrieve detailed account information including current balance and currency. + + Use this to verify account details before transactions. + """ + return await account_service.get_account(req) + + @accounts_mcp.tool(tags={'accounts'}) + async def search_accounts(req: SearchAccountRequest) -> List[Account]: + """Find accounts by partial name matching. + + Useful when you know the account name but not the ID. Supports filtering by account type. + """ + return await account_service.search_accounts(req) + + return accounts_mcp diff --git a/src/lampyrid/tools/budgets.py b/src/lampyrid/tools/budgets.py index c431d05..8ed71da 100644 --- a/src/lampyrid/tools/budgets.py +++ b/src/lampyrid/tools/budgets.py @@ -10,55 +10,72 @@ from ..clients.firefly import FireflyClient from ..models.lampyrid_models import ( - AvailableBudget, - Budget, - BudgetSpending, - BudgetSummary, - GetAvailableBudgetRequest, - GetBudgetRequest, - GetBudgetSpendingRequest, - GetBudgetSummaryRequest, - ListBudgetsRequest, + AvailableBudget, + Budget, + BudgetSpending, + BudgetSummary, + GetAvailableBudgetRequest, + GetBudgetRequest, + GetBudgetSpendingRequest, + GetBudgetSummaryRequest, + ListBudgetsRequest, ) from ..services.budgets import BudgetService def create_budgets_server(client: FireflyClient) -> FastMCP: - budget_service = BudgetService(client) - """ - Create a standalone FastMCP server for budget management tools. - - Args: - client: The FireflyClient instance for API interactions - - Returns: - FastMCP server instance with budget management tools registered - """ - budgets_mcp = FastMCP('budgets') - - @budgets_mcp.tool(tags={'budgets'}) - async def list_budgets(req: ListBudgetsRequest) -> List[Budget]: - """Retrieve your budgets for expense tracking and financial planning. Filter by active status to see current or all budgets.""" - return await budget_service.list_budgets(req) - - @budgets_mcp.tool(tags={'budgets'}) - async def get_budget(req: GetBudgetRequest) -> Budget: - """Retrieve detailed budget information including name, status, and notes. Use this to verify budget details before assigning transactions.""" - return await budget_service.get_budget(req) - - @budgets_mcp.tool(tags={'budgets', 'analysis'}) - async def get_budget_spending(req: GetBudgetSpendingRequest) -> BudgetSpending: - """Analyze spending against a budget including amount spent, remaining budget, and percentage used. Essential for budget monitoring and overspending alerts.""" - return await budget_service.get_budget_spending(req) - - @budgets_mcp.tool(tags={'budgets', 'analysis'}) - async def get_budget_summary(req: GetBudgetSummaryRequest) -> BudgetSummary: - """Comprehensive overview of all budget performance with totals and spending analysis. Perfect for monthly reviews and financial dashboards.""" - return await budget_service.get_budget_summary(req) - - @budgets_mcp.tool(tags={'budgets', 'analysis'}) - async def get_available_budget(req: GetAvailableBudgetRequest) -> AvailableBudget: - """Check unallocated budget available for new budgets or unexpected expenses. Shows money set aside but not assigned to specific budgets.""" - return await budget_service.get_available_budget(req) - - return budgets_mcp + """Create a standalone FastMCP server for budget management tools. + + Args: + client: The FireflyClient instance for API interactions + + Returns: + FastMCP server instance with budget management tools registered + + """ + budget_service = BudgetService(client) + + budgets_mcp = FastMCP('budgets') + + @budgets_mcp.tool(tags={'budgets'}) + async def list_budgets(req: ListBudgetsRequest) -> List[Budget]: + """Retrieve your budgets for expense tracking and financial planning. + + Filter by active status to see current or all budgets. + """ + return await budget_service.list_budgets(req) + + @budgets_mcp.tool(tags={'budgets'}) + async def get_budget(req: GetBudgetRequest) -> Budget: + """Retrieve detailed budget information including name, status, and notes. + + Use this to verify budget details before assigning transactions. + """ + return await budget_service.get_budget(req) + + @budgets_mcp.tool(tags={'budgets', 'analysis'}) + async def get_budget_spending(req: GetBudgetSpendingRequest) -> BudgetSpending: + """Analyze spending against a budget. + + Includes amount spent, remaining budget, and percentage used. Essential for budget + monitoring and overspending alerts. + """ + return await budget_service.get_budget_spending(req) + + @budgets_mcp.tool(tags={'budgets', 'analysis'}) + async def get_budget_summary(req: GetBudgetSummaryRequest) -> BudgetSummary: + """Comprehensive overview of all budget performance with totals and spending analysis. + + Perfect for monthly reviews and financial dashboards. + """ + return await budget_service.get_budget_summary(req) + + @budgets_mcp.tool(tags={'budgets', 'analysis'}) + async def get_available_budget(req: GetAvailableBudgetRequest) -> AvailableBudget: + """Check unallocated budget available for new budgets or unexpected expenses. + + Shows money set aside but not assigned to specific budgets. + """ + return await budget_service.get_available_budget(req) + + return budgets_mcp diff --git a/src/lampyrid/tools/transactions.py b/src/lampyrid/tools/transactions.py index c8197ab..6978f86 100644 --- a/src/lampyrid/tools/transactions.py +++ b/src/lampyrid/tools/transactions.py @@ -10,89 +10,124 @@ from ..clients.firefly import FireflyClient from ..models.lampyrid_models import ( - BulkUpdateTransactionsRequest, - CreateBulkTransactionsRequest, - CreateDepositRequest, - CreateTransferRequest, - CreateWithdrawalRequest, - DeleteTransactionRequest, - GetTransactionRequest, - GetTransactionsRequest, - SearchTransactionsRequest, - Transaction, - TransactionListResponse, - UpdateTransactionRequest, + BulkUpdateTransactionsRequest, + CreateBulkTransactionsRequest, + CreateDepositRequest, + CreateTransferRequest, + CreateWithdrawalRequest, + DeleteTransactionRequest, + GetTransactionRequest, + GetTransactionsRequest, + SearchTransactionsRequest, + Transaction, + TransactionListResponse, + UpdateTransactionRequest, ) from ..services.transactions import TransactionService def create_transactions_server(client: FireflyClient) -> FastMCP: - transaction_service = TransactionService(client) - """ - Create a standalone FastMCP server for transaction management tools. - - Args: - client: The FireflyClient instance for API interactions - - Returns: - FastMCP server instance with transaction management tools registered - """ - transactions_mcp = FastMCP('transactions') - - @transactions_mcp.tool(tags={'transactions', 'create'}) - async def create_withdrawal(req: CreateWithdrawalRequest) -> Transaction: - """Record expenses and spending. Money leaves your asset accounts to pay for goods, - services,or cash withdrawals. Can be assigned to budgets for expense tracking. - """ - transaction = await transaction_service.create_withdrawal(req) - return transaction - - @transactions_mcp.tool(tags={'transactions', 'create'}) - async def create_deposit(req: CreateDepositRequest) -> Transaction: - """Record income and money received. Represents salary, refunds, gifts, or any money coming into your asset accounts from external sources.""" - transaction = await transaction_service.create_deposit(req) - return transaction - - @transactions_mcp.tool(tags={'transactions', 'create'}) - async def create_transfer(req: CreateTransferRequest) -> Transaction: - """Move money between your own accounts. Use for transferring to savings, paying credit cards from checking, or consolidating funds.""" - transaction = await transaction_service.create_transfer(req) - return transaction - - @transactions_mcp.tool(tags={'transactions', 'create', 'bulk'}) - async def create_bulk_transactions(req: CreateBulkTransactionsRequest) -> List[Transaction]: - """Efficiently create multiple transactions in one operation. Ideal for importing transaction batches, recording monthly bills, or processing CSV data.""" - transactions = await transaction_service.create_bulk_transactions(req) - return transactions - - @transactions_mcp.tool(tags={'transactions', 'query'}) - async def get_transaction(req: GetTransactionRequest) -> Transaction: - """Retrieve complete transaction details. Use this to verify transaction information before updates or to examine specific transactions.""" - return await transaction_service.get_transaction(req) - - @transactions_mcp.tool(tags={'transactions', 'query'}) - async def get_transactions(req: GetTransactionsRequest) -> TransactionListResponse: - """Retrieve transaction history with flexible filtering and pagination. Essential for financial analysis, spending pattern review, and account activity monitoring.""" - return await transaction_service.get_transactions(req) - - @transactions_mcp.tool(tags={'transactions', 'query'}) - async def search_transactions(req: SearchTransactionsRequest) -> TransactionListResponse: - """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.""" - return await transaction_service.search_transactions(req) - - @transactions_mcp.tool(tags={'transactions', 'manage'}) - async def delete_transaction(req: DeleteTransactionRequest) -> bool: - """Permanently remove a transaction. Use to correct mistakes, remove duplicates, or delete test data. This action cannot be undone.""" - return await transaction_service.delete_transaction(req) - - @transactions_mcp.tool(tags={'transactions', 'manage'}) - async def update_transaction(req: UpdateTransactionRequest) -> Transaction: - """Modify transaction details such as amounts, descriptions, dates, accounts, or budget assignments. Useful for correcting imported data or updating incomplete information.""" - return await transaction_service.update_transaction(req) - - @transactions_mcp.tool(tags={'transactions', 'manage', 'bulk'}) - async def bulk_update_transactions(req: BulkUpdateTransactionsRequest) -> List[Transaction]: - """Efficiently update multiple transactions in one operation. Ideal for batch account changes, budget reassignments, or correcting imported data.""" - return await transaction_service.bulk_update_transactions(req) - - return transactions_mcp + """Create a standalone FastMCP server for transaction management tools. + + Args: + client: The FireflyClient instance for API interactions + + Returns: + FastMCP server instance with transaction management tools registered + + """ + transaction_service = TransactionService(client) + + transactions_mcp = FastMCP('transactions') + + @transactions_mcp.tool(tags={'transactions', 'create'}) + async def create_withdrawal(req: CreateWithdrawalRequest) -> Transaction: + """Record expenses and spending. + + Money leaves your asset accounts to pay for goods, services, or cash + withdrawals. Can be assigned to budgets for expense tracking. + """ + transaction = await transaction_service.create_withdrawal(req) + return transaction + + @transactions_mcp.tool(tags={'transactions', 'create'}) + async def create_deposit(req: CreateDepositRequest) -> Transaction: + """Record income and money received. + + Represents salary, refunds, gifts, or any money coming into your asset accounts from + external sources. + """ + transaction = await transaction_service.create_deposit(req) + return transaction + + @transactions_mcp.tool(tags={'transactions', 'create'}) + async def create_transfer(req: CreateTransferRequest) -> Transaction: + """Move money between your own accounts. + + Use for transferring to savings, paying credit cards from checking, or consolidating funds. + """ + transaction = await transaction_service.create_transfer(req) + return transaction + + @transactions_mcp.tool(tags={'transactions', 'create', 'bulk'}) + async def create_bulk_transactions(req: CreateBulkTransactionsRequest) -> List[Transaction]: + """Efficiently create multiple transactions in one operation. + + Ideal for importing transaction batches, recording monthly bills, or processing CSV data. + """ + transactions = await transaction_service.create_bulk_transactions(req) + return transactions + + @transactions_mcp.tool(tags={'transactions', 'query'}) + async def get_transaction(req: GetTransactionRequest) -> Transaction: + """Retrieve complete transaction details. + + Use this to verify transaction information before updates or to examine specific + transactions. + """ + return await transaction_service.get_transaction(req) + + @transactions_mcp.tool(tags={'transactions', 'query'}) + async def get_transactions(req: GetTransactionsRequest) -> TransactionListResponse: + """Retrieve transaction history with flexible filtering and pagination. + + Essential for financial analysis, spending pattern review, and account activity monitoring. + """ + return await transaction_service.get_transactions(req) + + @transactions_mcp.tool(tags={'transactions', 'query'}) + async def search_transactions(req: SearchTransactionsRequest) -> TransactionListResponse: + """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. + """ + return await transaction_service.search_transactions(req) + + @transactions_mcp.tool(tags={'transactions', 'manage'}) + async def delete_transaction(req: DeleteTransactionRequest) -> bool: + """Permanently remove a transaction. + + Use to correct mistakes, remove duplicates, or delete test data. This action cannot be + undone. + """ + return await transaction_service.delete_transaction(req) + + @transactions_mcp.tool(tags={'transactions', 'manage'}) + async def update_transaction(req: UpdateTransactionRequest) -> Transaction: + """Modify transaction details such as amounts, descriptions, dates, accounts, etc. + + Useful for correcting imported data or updating incomplete information. + """ + return await transaction_service.update_transaction(req) + + @transactions_mcp.tool(tags={'transactions', 'manage', 'bulk'}) + async def bulk_update_transactions(req: BulkUpdateTransactionsRequest) -> List[Transaction]: + """Efficiently update multiple transactions in one operation. + + Ideal for batch account changes, budget reassignments, or correcting imported data. + """ + return await transaction_service.bulk_update_transactions(req) + + return transactions_mcp diff --git a/src/lampyrid/utils.py b/src/lampyrid/utils.py index 5c17739..93fad95 100644 --- a/src/lampyrid/utils.py +++ b/src/lampyrid/utils.py @@ -1,5 +1,4 @@ -""" -Custom HTTP routes and utilities for LamPyrid MCP server. +"""Custom HTTP routes and utilities for LamPyrid MCP server. This module provides custom HTTP route handlers that are served at the root level, alongside the MCP protocol endpoints. @@ -14,28 +13,28 @@ def get_assets_path(filename: str) -> Path: - """Get path to an asset file bundled with the package.""" - asset_resource = files('lampyrid').joinpath('assets', filename) - return Path(str(asset_resource)) + """Get path to an asset file bundled with the package.""" + asset_resource = files('lampyrid').joinpath('assets', filename) + return Path(str(asset_resource)) async def serve_favicon(request: Request): - """Serve favicon.ico file at the root level.""" - favicon_path = get_assets_path('favicon.ico') - if favicon_path.exists(): - return FileResponse(favicon_path, media_type='image/x-icon') - return JSONResponse({'error': 'Not found'}, status_code=404) + """Serve favicon.ico file at the root level.""" + favicon_path = get_assets_path('favicon.ico') + if favicon_path.exists(): + return FileResponse(favicon_path, media_type='image/x-icon') + return JSONResponse({'error': 'Not found'}, status_code=404) def register_custom_routes(mcp: FastMCP) -> None: - """ - Register custom HTTP routes with the FastMCP server. + """Register custom HTTP routes with the FastMCP server. + + These routes are served at the root level (e.g., /favicon.ico), + not nested under the MCP protocol path (e.g., /mcp). - These routes are served at the root level (e.g., /favicon.ico), - not nested under the MCP protocol path (e.g., /mcp). + Args: + mcp: The FastMCP server instance - Args: - mcp: The FastMCP server instance - """ - # Register favicon route - mcp.custom_route('/favicon.ico', methods=['GET'])(serve_favicon) + """ + # Register favicon route + mcp.custom_route('/favicon.ico', methods=['GET'])(serve_favicon) diff --git a/tests/conftest.py b/tests/conftest.py index 9f319b2..690991a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,30 +16,30 @@ from fastmcp import Client, FastMCP from lampyrid.models.firefly_models import ( - AccountRoleProperty, - AccountRolePropertyEnum, - AccountStore, - AccountTypeFilter, - BudgetStore, - ShortAccountTypeProperty, + AccountRoleProperty, + AccountRolePropertyEnum, + AccountStore, + AccountTypeFilter, + BudgetStore, + ShortAccountTypeProperty, ) from lampyrid.models.lampyrid_models import ( - Account, - Budget, - ListAccountRequest, - ListBudgetsRequest, + Account, + Budget, + ListAccountRequest, + ListBudgetsRequest, ) # Load test environment variables FIRST, before importing settings test_env_path = Path(__file__).parent / '.env.test' if test_env_path.exists(): - load_dotenv(test_env_path) + load_dotenv(test_env_path) else: - # Try to load from .env.test.example for CI - print( - 'Warning: .env.test not found. Make sure FIREFLY_BASE_URL and FIREFLY_TOKEN ' - 'are set in environment.' - ) + # Try to load from .env.test.example for CI + print( + 'Warning: .env.test not found. Make sure FIREFLY_BASE_URL and FIREFLY_TOKEN ' + 'are set in environment.' + ) from lampyrid.clients.firefly import FireflyClient # noqa: E402 @@ -56,253 +56,259 @@ @pytest.fixture(scope='session', autouse=True) async def _setup_test_data(): - """Autouse fixture to create test accounts and budget at session start. - This ensures test data exists before any tests run. - """ - global _cached_test_accounts, _cached_test_budgets - - test_env_path = Path(__file__).parent / '.env.test' - if test_env_path.exists(): - load_dotenv(test_env_path) - - if not settings.firefly_base_url or not settings.firefly_token: - return # Skip setup if no config - - client = FireflyClient() - account_service = AccountService(client) - budget_service = BudgetService(client) - - try: - # Create test accounts - if _cached_test_accounts is None or len(_cached_test_accounts) < 2: - _cached_test_accounts = [] - - existing_accounts = await account_service.list_accounts( - ListAccountRequest(type=AccountTypeFilter.asset) - ) - - checking = None - savings = None - for account in existing_accounts: - if 'test checking' in account.name.lower(): - checking = account - elif 'test savings' in account.name.lower(): - savings = account - - if checking is None: - checking_store = AccountStore( - name='Test Checking', - type=ShortAccountTypeProperty.asset, - account_role=AccountRoleProperty(AccountRolePropertyEnum.defaultAsset), - currency_code='USD', - opening_balance='1000.00', - opening_balance_date=datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc), - ) - checking = await account_service.create_account(checking_store) - _created_account_ids.append(checking.id) - - _cached_test_accounts.append(checking) - - if savings is None: - savings_store = AccountStore( - name='Test Savings', - type=ShortAccountTypeProperty.asset, - account_role=AccountRoleProperty(AccountRolePropertyEnum.savingAsset), - currency_code='USD', - opening_balance='500.00', - opening_balance_date=datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc), - ) - savings = await account_service.create_account(savings_store) - _created_account_ids.append(savings.id) - - _cached_test_accounts.append(savings) - - # Create expense account for withdrawal tests - existing_expense = await account_service.list_accounts( - ListAccountRequest(type=AccountTypeFilter.expense) - ) - - expense = None - for account in existing_expense: - if 'test expense' in account.name.lower(): - expense = account - break - - if expense is None: - expense_store = AccountStore( - name='Test Expense', - type=ShortAccountTypeProperty.expense, - currency_code='EUR', - ) - expense = await account_service.create_account(expense_store) - _created_account_ids.append(expense.id) - - _cached_test_accounts.append(expense) - - # Create revenue account for deposit tests - existing_revenue = await account_service.list_accounts( - ListAccountRequest(type=AccountTypeFilter.revenue) - ) - - revenue = None - for account in existing_revenue: - if 'test revenue' in account.name.lower(): - revenue = account - break - - if revenue is None: - revenue_store = AccountStore( - name='Test Revenue', - type=ShortAccountTypeProperty.revenue, - currency_code='EUR', - ) - revenue = await account_service.create_account(revenue_store) - _created_account_ids.append(revenue.id) - - _cached_test_accounts.append(revenue) - - # Create test budget - if _cached_test_budgets is None: - _cached_test_budgets = [] - - budget_array = await budget_service.list_budgets(ListBudgetsRequest(active=True)) - - test_budget = None - for budget in budget_array: - if 'test budget' in budget.name.lower(): - test_budget = budget - break - - if test_budget is None: - budget_store = BudgetStore(name='Test Budget', active=True) - test_budget = await budget_service.create_budget(budget_store) - _created_budget_ids.append(test_budget.id) - - _cached_test_budgets.append(test_budget) - - finally: - await client._client.aclose() + """Autouse fixture to create test accounts and budget at session start. + + This ensures test data exists before any tests run. + """ + global _cached_test_accounts, _cached_test_budgets + + test_env_path = Path(__file__).parent / '.env.test' + if test_env_path.exists(): + load_dotenv(test_env_path) + + if not settings.firefly_base_url or not settings.firefly_token: + return # Skip setup if no config + + client = FireflyClient() + account_service = AccountService(client) + budget_service = BudgetService(client) + + try: + # Create test accounts + if _cached_test_accounts is None or len(_cached_test_accounts) < 2: + _cached_test_accounts = [] + + existing_accounts = await account_service.list_accounts( + ListAccountRequest(type=AccountTypeFilter.asset) + ) + + checking = None + savings = None + for account in existing_accounts: + if 'test checking' in account.name.lower(): + checking = account + elif 'test savings' in account.name.lower(): + savings = account + + if checking is None: + checking_store = AccountStore( + name='Test Checking', + type=ShortAccountTypeProperty.asset, + account_role=AccountRoleProperty(AccountRolePropertyEnum.defaultAsset), + currency_code='USD', + opening_balance='1000.00', + opening_balance_date=datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + ) + checking = await account_service.create_account(checking_store) + _created_account_ids.append(checking.id) + + _cached_test_accounts.append(checking) + + if savings is None: + savings_store = AccountStore( + name='Test Savings', + type=ShortAccountTypeProperty.asset, + account_role=AccountRoleProperty(AccountRolePropertyEnum.savingAsset), + currency_code='USD', + opening_balance='500.00', + opening_balance_date=datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + ) + savings = await account_service.create_account(savings_store) + _created_account_ids.append(savings.id) + + _cached_test_accounts.append(savings) + + # Create expense account for withdrawal tests + existing_expense = await account_service.list_accounts( + ListAccountRequest(type=AccountTypeFilter.expense) + ) + + expense = None + for account in existing_expense: + if 'test expense' in account.name.lower(): + expense = account + break + + if expense is None: + expense_store = AccountStore( + name='Test Expense', + type=ShortAccountTypeProperty.expense, + currency_code='EUR', + ) + expense = await account_service.create_account(expense_store) + _created_account_ids.append(expense.id) + + _cached_test_accounts.append(expense) + + # Create revenue account for deposit tests + existing_revenue = await account_service.list_accounts( + ListAccountRequest(type=AccountTypeFilter.revenue) + ) + + revenue = None + for account in existing_revenue: + if 'test revenue' in account.name.lower(): + revenue = account + break + + if revenue is None: + revenue_store = AccountStore( + name='Test Revenue', + type=ShortAccountTypeProperty.revenue, + currency_code='EUR', + ) + revenue = await account_service.create_account(revenue_store) + _created_account_ids.append(revenue.id) + + _cached_test_accounts.append(revenue) + + # Create test budget + if _cached_test_budgets is None: + _cached_test_budgets = [] + + budget_array = await budget_service.list_budgets(ListBudgetsRequest(active=True)) + + test_budget = None + for budget in budget_array: + if 'test budget' in budget.name.lower(): + test_budget = budget + break + + if test_budget is None: + budget_store = BudgetStore(name='Test Budget', active=True) + test_budget = await budget_service.create_budget(budget_store) + _created_budget_ids.append(test_budget.id) + + _cached_test_budgets.append(test_budget) + + finally: + await client._client.aclose() @pytest.fixture(scope='function') async def firefly_client(): - """Create a FireflyClient instance for testing. + """Create a FireflyClient instance for testing. - This fixture is function-scoped to avoid event loop conflicts. - The client reads configuration from the global settings object which - loads from environment variables (.env.test file). - """ - # Validate that required settings are present - if not settings.firefly_base_url or not settings.firefly_token: - raise RuntimeError( - 'FIREFLY_BASE_URL and FIREFLY_TOKEN must be set in environment or .env.test file' - ) + This fixture is function-scoped to avoid event loop conflicts. + The client reads configuration from the global settings object which + loads from environment variables (.env.test file). + """ + # Validate that required settings are present + if not settings.firefly_base_url or not settings.firefly_token: + raise RuntimeError( + 'FIREFLY_BASE_URL and FIREFLY_TOKEN must be set in environment or .env.test file' + ) - client = FireflyClient() + client = FireflyClient() - yield client + yield client - # Explicitly close the client to clean up connections - try: - await client._client.aclose() - except Exception: - pass # Ignore errors during cleanup + # Explicitly close the client to clean up connections + try: + await client._client.aclose() + except Exception: + pass # Ignore errors during cleanup @pytest.fixture(scope='function') async def mcp_client(firefly_client: FireflyClient): - """Create a FastMCP Client for testing tools. + """Create a FastMCP Client for testing tools. - This fixture uses in-memory transport to test the full MCP stack: - MCP Protocol -> Tool Functions -> FireflyClient -> Firefly III API + This fixture uses in-memory transport to test the full MCP stack: + MCP Protocol -> Tool Functions -> FireflyClient -> Firefly III API - The server is created fresh for each test function to ensure isolation. - All domain-specific tools (accounts, transactions, budgets) are composed - into the server. - """ - # Create a new FastMCP server for testing - mcp = FastMCP('lampyrid-test') + The server is created fresh for each test function to ensure isolation. + All domain-specific tools (accounts, transactions, budgets) are composed + into the server. + """ + # Create a new FastMCP server for testing + mcp = FastMCP('lampyrid-test') - # Compose all domain servers (accounts, transactions, budgets) - await compose_all_servers(mcp, firefly_client) + # Compose all domain servers (accounts, transactions, budgets) + await compose_all_servers(mcp, firefly_client) - # Create a FastMCP Client using in-memory transport - async with Client(transport=mcp) as client: - yield client + # Create a FastMCP Client using in-memory transport + async with Client(transport=mcp) as client: + yield client @pytest.fixture(scope='session') def test_asset_account() -> Account: - """Get the first test asset account (Test Checking). - The account is created by the autouse _setup_test_data fixture. - """ - if _cached_test_accounts is None or len(_cached_test_accounts) == 0: - raise RuntimeError('Test accounts not initialized. Check if _setup_test_data ran.') - return _cached_test_accounts[0] + """Get the first test asset account (Test Checking). + + The account is created by the autouse _setup_test_data fixture. + """ + if _cached_test_accounts is None or len(_cached_test_accounts) == 0: + raise RuntimeError('Test accounts not initialized. Check if _setup_test_data ran.') + return _cached_test_accounts[0] @pytest.fixture(scope='session') def test_second_asset_account() -> Account: - """Get the second test asset account (Test Savings) for transfer testing. - The account is created by the autouse _setup_test_data fixture. - """ - if _cached_test_accounts is None or len(_cached_test_accounts) < 2: - raise RuntimeError('Test accounts not initialized. Check if _setup_test_data ran.') - return _cached_test_accounts[1] + """Get the second test asset account (Test Savings) for transfer testing. + + The account is created by the autouse _setup_test_data fixture. + """ + if _cached_test_accounts is None or len(_cached_test_accounts) < 2: + raise RuntimeError('Test accounts not initialized. Check if _setup_test_data ran.') + return _cached_test_accounts[1] @pytest.fixture(scope='session') def test_expense_account() -> str: - """Get expense account name for withdrawal testing. - For withdrawals, we only need the destination name (expense account), - not the full account object. - """ - # Return a default name - Firefly III will create it automatically - return 'Test Expense' + """Get expense account name for withdrawal testing. + + For withdrawals, we only need the destination name (expense account), + not the full account object. + """ + # Return a default name - Firefly III will create it automatically + return 'Test Expense' @pytest.fixture(scope='session') def test_revenue_account() -> str: - """Get revenue account name for deposit testing. - For deposits, we only need the source name (revenue account), - not the full account object. - """ - # Return a default name - Firefly III will create it automatically - return 'Test Revenue' + """Get revenue account name for deposit testing. + + For deposits, we only need the source name (revenue account), + not the full account object. + """ + # Return a default name - Firefly III will create it automatically + return 'Test Revenue' @pytest.fixture(scope='session') def test_budget() -> Budget: - """Get the test budget. - The budget is created by the autouse _setup_test_data fixture. - """ - if _cached_test_budgets is None or len(_cached_test_budgets) == 0: - raise RuntimeError('Test budget not initialized. Check if _setup_test_data ran.') - return _cached_test_budgets[0] + """Get the test budget. + + The budget is created by the autouse _setup_test_data fixture. + """ + if _cached_test_budgets is None or len(_cached_test_budgets) == 0: + raise RuntimeError('Test budget not initialized. Check if _setup_test_data ran.') + return _cached_test_budgets[0] @pytest.fixture async def transaction_cleanup(firefly_client: FireflyClient): - """Fixture to track and cleanup transactions created during tests. - - Usage: - @pytest.mark.asyncio - async def test_create_transaction(firefly_client, transaction_cleanup): - transaction = await create_transaction(...) - transaction_cleanup.append(transaction.id) - # Test code here - # Transaction will be deleted after test completes - """ - created_transaction_ids: List[str] = [] - - # Provide the list to the test - yield created_transaction_ids - - # Cleanup after test - - for transaction_id in created_transaction_ids: - try: - await firefly_client.delete_transaction(transaction_id) - print(f'Cleaned up transaction: {transaction_id}') - except Exception as e: - print(f'Failed to cleanup transaction {transaction_id}: {e}') + """Fixture to track and cleanup transactions created during tests. + + Usage: + @pytest.mark.asyncio + async def test_create_transaction(firefly_client, transaction_cleanup): + transaction = await create_transaction(...) + transaction_cleanup.append(transaction.id) + # Test code here + # Transaction will be deleted after test completes + """ + created_transaction_ids: List[str] = [] + + # Provide the list to the test + yield created_transaction_ids + + # Cleanup after test + + for transaction_id in created_transaction_ids: + try: + await firefly_client.delete_transaction(transaction_id) + print(f'Cleaned up transaction: {transaction_id}') + except Exception as e: + print(f'Failed to cleanup transaction {transaction_id}: {e}') diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py index 0d7f215..e69de29 100644 --- a/tests/fixtures/__init__.py +++ b/tests/fixtures/__init__.py @@ -1 +0,0 @@ -# Test data fixtures diff --git a/tests/fixtures/accounts.py b/tests/fixtures/accounts.py index 6a5df51..a1ff6a6 100644 --- a/tests/fixtures/accounts.py +++ b/tests/fixtures/accounts.py @@ -2,26 +2,26 @@ from lampyrid.models.firefly_models import AccountTypeFilter from lampyrid.models.lampyrid_models import ( - GetAccountRequest, - ListAccountRequest, - SearchAccountRequest, + GetAccountRequest, + ListAccountRequest, + SearchAccountRequest, ) def make_list_account_request(type: AccountTypeFilter) -> ListAccountRequest: - """Create a ListAccountRequest for testing.""" - return ListAccountRequest(type=type) + """Create a ListAccountRequest for testing.""" + return ListAccountRequest(type=type) def make_search_account_request( - query: str, type: AccountTypeFilter | None = None + query: str, type: AccountTypeFilter | None = None ) -> SearchAccountRequest: - """Create a SearchAccountRequest for testing.""" - if type is None: - return SearchAccountRequest(query=query) - return SearchAccountRequest(query=query, type=type) + """Create a SearchAccountRequest for testing.""" + if type is None: + return SearchAccountRequest(query=query) + return SearchAccountRequest(query=query, type=type) def make_get_account_request(account_id: str) -> GetAccountRequest: - """Create a GetAccountRequest for testing.""" - return GetAccountRequest(id=account_id) + """Create a GetAccountRequest for testing.""" + return GetAccountRequest(id=account_id) diff --git a/tests/fixtures/budgets.py b/tests/fixtures/budgets.py index 128174c..f3b3ade 100644 --- a/tests/fixtures/budgets.py +++ b/tests/fixtures/budgets.py @@ -3,64 +3,64 @@ from datetime import date, timedelta from lampyrid.models.lampyrid_models import ( - GetAvailableBudgetRequest, - GetBudgetRequest, - GetBudgetSpendingRequest, - GetBudgetSummaryRequest, - ListBudgetsRequest, + GetAvailableBudgetRequest, + GetBudgetRequest, + GetBudgetSpendingRequest, + GetBudgetSummaryRequest, + ListBudgetsRequest, ) def make_list_budgets_request(active: bool | None = None) -> ListBudgetsRequest: - """Create a ListBudgetsRequest for testing.""" - return ListBudgetsRequest(active=active) + """Create a ListBudgetsRequest for testing.""" + return ListBudgetsRequest(active=active) def make_get_budget_request(budget_id: str) -> GetBudgetRequest: - """Create a GetBudgetRequest for testing.""" - return GetBudgetRequest(id=budget_id) + """Create a GetBudgetRequest for testing.""" + return GetBudgetRequest(id=budget_id) def make_get_budget_spending_request( - budget_id: str, start: date | None = None, end: date | None = None + budget_id: str, start: date | None = None, end: date | None = None ) -> GetBudgetSpendingRequest: - """Create a GetBudgetSpendingRequest 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) + """Create a GetBudgetSpendingRequest 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 GetBudgetSpendingRequest(budget_id=budget_id, start_date=start, end_date=end) + return GetBudgetSpendingRequest(budget_id=budget_id, start_date=start, end_date=end) def make_get_budget_summary_request( - start: date | None = None, end: date | None = None + start: date | None = None, end: date | None = None ) -> GetBudgetSummaryRequest: - """Create a GetBudgetSummaryRequest 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) + """Create a GetBudgetSummaryRequest 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 GetBudgetSummaryRequest(start_date=start, end_date=end) + return GetBudgetSummaryRequest(start_date=start, end_date=end) def make_get_available_budget_request( - start: date | None = None, end: date | None = None + start: date | None = None, end: date | None = None ) -> GetAvailableBudgetRequest: - """Create a GetAvailableBudgetRequest 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) + """Create a GetAvailableBudgetRequest 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 GetAvailableBudgetRequest(start_date=start, end_date=end) + return GetAvailableBudgetRequest(start_date=start, end_date=end) diff --git a/tests/fixtures/transactions.py b/tests/fixtures/transactions.py index 2b556f9..b1955d6 100644 --- a/tests/fixtures/transactions.py +++ b/tests/fixtures/transactions.py @@ -5,178 +5,178 @@ from lampyrid.models.firefly_models import TransactionTypeFilter from lampyrid.models.lampyrid_models import ( - BulkUpdateTransactionsRequest, - CreateBulkTransactionsRequest, - CreateDepositRequest, - CreateTransferRequest, - CreateWithdrawalRequest, - DeleteTransactionRequest, - GetTransactionRequest, - GetTransactionsRequest, - SearchTransactionsRequest, - Transaction, - UpdateTransactionRequest, + BulkUpdateTransactionsRequest, + CreateBulkTransactionsRequest, + CreateDepositRequest, + CreateTransferRequest, + CreateWithdrawalRequest, + DeleteTransactionRequest, + GetTransactionRequest, + GetTransactionsRequest, + SearchTransactionsRequest, + Transaction, + UpdateTransactionRequest, ) def make_create_withdrawal_request( - amount: float, - description: str, - source_id: str, - destination_name: str, - budget_id: str | None = None, - date: datetime | None = None, + amount: float, + description: str, + source_id: str, + destination_name: str, + budget_id: str | None = None, + date: datetime | None = None, ) -> CreateWithdrawalRequest: - """Create a CreateWithdrawalRequest for testing.""" - if date is None: - date = datetime.now(timezone.utc) + """Create a CreateWithdrawalRequest for testing.""" + if date is None: + date = datetime.now(timezone.utc) - return CreateWithdrawalRequest( - amount=amount, - description=description, - date=date, - source_id=source_id, - destination_name=destination_name, - budget_id=budget_id, - ) + return CreateWithdrawalRequest( + amount=amount, + description=description, + date=date, + source_id=source_id, + destination_name=destination_name, + budget_id=budget_id, + ) def make_create_deposit_request( - amount: float, - description: str, - destination_id: str, - source_name: str, - date: datetime | None = None, + amount: float, + description: str, + destination_id: str, + source_name: str, + date: datetime | None = None, ) -> CreateDepositRequest: - """Create a CreateDepositRequest for testing.""" - if date is None: - date = datetime.now(timezone.utc) + """Create a CreateDepositRequest for testing.""" + if date is None: + date = datetime.now(timezone.utc) - return CreateDepositRequest( - amount=amount, - description=description, - date=date, - source_name=source_name, - destination_id=destination_id, - ) + return CreateDepositRequest( + amount=amount, + description=description, + date=date, + source_name=source_name, + destination_id=destination_id, + ) def make_create_transfer_request( - amount: float, - description: str, - source_id: str, - destination_id: str, - date: datetime | None = None, + amount: float, + description: str, + source_id: str, + destination_id: str, + date: datetime | None = None, ) -> CreateTransferRequest: - """Create a CreateTransferRequest for testing.""" - if date is None: - date = datetime.now(timezone.utc) + """Create a CreateTransferRequest for testing.""" + if date is None: + date = datetime.now(timezone.utc) - return CreateTransferRequest( - amount=amount, - description=description, - date=date, - source_id=source_id, - destination_id=destination_id, - ) + return CreateTransferRequest( + amount=amount, + description=description, + date=date, + source_id=source_id, + destination_id=destination_id, + ) def make_create_bulk_transactions_request( - transactions: List[Transaction], + transactions: List[Transaction], ) -> CreateBulkTransactionsRequest: - """Create a CreateBulkTransactionsRequest for testing.""" - return CreateBulkTransactionsRequest(transactions=transactions) + """Create a CreateBulkTransactionsRequest for testing.""" + return CreateBulkTransactionsRequest(transactions=transactions) def make_get_transaction_request(transaction_id: str) -> GetTransactionRequest: - """Create a GetTransactionRequest for testing.""" - return GetTransactionRequest(id=transaction_id) + """Create a GetTransactionRequest for testing.""" + return GetTransactionRequest(id=transaction_id) def make_get_transactions_request( - account_id: str | None = None, - start_date: date | None = None, - end_date: date | None = None, - transaction_type: TransactionTypeFilter | None = None, - page: int = 1, - limit: int = 50, + account_id: str | None = None, + start_date: date | None = None, + end_date: date | None = None, + transaction_type: TransactionTypeFilter | None = None, + page: int = 1, + limit: int = 50, ) -> GetTransactionsRequest: - """Create a GetTransactionsRequest for testing.""" - return GetTransactionsRequest( - account_id=account_id, - start_date=start_date, - end_date=end_date, - transaction_type=transaction_type, - page=page, - limit=limit, - ) + """Create a GetTransactionsRequest for testing.""" + return GetTransactionsRequest( + account_id=account_id, + start_date=start_date, + end_date=end_date, + transaction_type=transaction_type, + page=page, + limit=limit, + ) def make_search_transactions_request( - query: str | None = None, - description_contains: str | None = None, - amount_equals: float | None = None, - amount_more: float | None = None, - amount_less: float | None = None, - date_on: datetime | None = None, - date_after: datetime | None = None, - date_before: datetime | None = None, - transaction_type: str | None = None, - category: str | None = None, - budget: str | None = None, - account_contains: str | None = None, - page: int = 1, - limit: int = 50, + query: str | None = None, + description_contains: str | None = None, + amount_equals: float | None = None, + amount_more: float | None = None, + amount_less: float | None = None, + date_on: datetime | None = None, + date_after: datetime | None = None, + date_before: datetime | None = None, + transaction_type: str | None = None, + category: str | None = None, + budget: str | None = None, + account_contains: str | None = None, + page: int = 1, + limit: int = 50, ) -> SearchTransactionsRequest: - """Create a SearchTransactionsRequest for testing.""" - return SearchTransactionsRequest( - query=query, - description_contains=description_contains, - amount_equals=amount_equals, - amount_more=amount_more, - amount_less=amount_less, - date_on=date_on, - date_after=date_after, - date_before=date_before, - type=transaction_type, # ty:ignore[invalid-argument-type] - category=category, - budget=budget, - account_contains=account_contains, - page=page, - limit=limit, - ) + """Create a SearchTransactionsRequest for testing.""" + return SearchTransactionsRequest( + query=query, + description_contains=description_contains, + amount_equals=amount_equals, + amount_more=amount_more, + amount_less=amount_less, + date_on=date_on, + date_after=date_after, + date_before=date_before, + type=transaction_type, # ty:ignore[invalid-argument-type] + category=category, + budget=budget, + account_contains=account_contains, + page=page, + limit=limit, + ) def make_update_transaction_request( - transaction_id: str, - amount: float | None = None, - description: str | None = None, - date: datetime | None = None, - source_id: str | None = None, - destination_id: str | None = None, - budget_id: str | None = None, - category_name: str | None = None, + transaction_id: str, + amount: float | None = None, + description: str | None = None, + date: datetime | None = None, + source_id: str | None = None, + destination_id: str | None = None, + budget_id: str | None = None, + category_name: str | None = None, ) -> UpdateTransactionRequest: - """Create an UpdateTransactionRequest for testing.""" - return UpdateTransactionRequest( - transaction_id=transaction_id, - amount=amount, - description=description, - date=date, - source_id=source_id, - destination_id=destination_id, - budget_id=budget_id, - category_name=category_name, - ) + """Create an UpdateTransactionRequest for testing.""" + return UpdateTransactionRequest( + transaction_id=transaction_id, + amount=amount, + description=description, + date=date, + source_id=source_id, + destination_id=destination_id, + budget_id=budget_id, + category_name=category_name, + ) def make_bulk_update_transactions_request( - updates: List[UpdateTransactionRequest], + updates: List[UpdateTransactionRequest], ) -> BulkUpdateTransactionsRequest: - """Create a BulkUpdateTransactionsRequest for testing.""" - return BulkUpdateTransactionsRequest(updates=updates) + """Create a BulkUpdateTransactionsRequest for testing.""" + return BulkUpdateTransactionsRequest(updates=updates) def make_delete_transaction_request(transaction_id: str) -> DeleteTransactionRequest: - """Create a DeleteTransactionRequest for testing.""" - return DeleteTransactionRequest(id=transaction_id) + """Create a DeleteTransactionRequest for testing.""" + return DeleteTransactionRequest(id=transaction_id) diff --git a/tests/integration/test_accounts.py b/tests/integration/test_accounts.py index 9723bba..fa56c76 100644 --- a/tests/integration/test_accounts.py +++ b/tests/integration/test_accounts.py @@ -13,256 +13,256 @@ @pytest.mark.accounts @pytest.mark.integration async def test_list_accounts_all(mcp_client): - """Test listing all account types.""" - result = await mcp_client.call_tool('list_accounts', {'req': {'type': 'all'}}) - accounts = result.data - - # Should have at least one account - assert len(accounts) > 0 - - # All accounts should have required fields - for account in accounts: - assert account['id'] is not None - assert account['name'] is not None - assert account['type'] is not None - - # Validate account structure with snapshot - assert accounts == snapshot( - [ - { - 'id': '2', - 'name': 'Initial balance for "Test Checking"', - 'type': 'initial-balance', - 'currency_code': 'USD', - 'current_balance': IsFloat(), - }, - { - 'id': '4', - 'name': 'Initial balance for "Test Savings"', - 'type': 'initial-balance', - 'currency_code': 'USD', - 'current_balance': IsFloat(), - }, - { - 'id': '5', - 'name': 'Test Expense', - 'type': 'expense', - 'currency_code': 'EUR', - 'current_balance': IsFloat(), - }, - { - 'id': '6', - 'name': 'Test Revenue', - 'type': 'revenue', - 'currency_code': 'EUR', - 'current_balance': IsFloat(), - }, - { - 'id': '1', - 'name': 'Test Checking', - 'type': 'asset', - 'currency_code': 'USD', - 'current_balance': IsFloat(), - }, - { - 'id': '3', - 'name': 'Test Savings', - 'type': 'asset', - 'currency_code': 'USD', - 'current_balance': IsFloat(), - }, - ] - ) + """Test listing all account types.""" + result = await mcp_client.call_tool('list_accounts', {'req': {'type': 'all'}}) + accounts = result.data + + # Should have at least one account + assert len(accounts) > 0 + + # All accounts should have required fields + for account in accounts: + assert account['id'] is not None + assert account['name'] is not None + assert account['type'] is not None + + # Validate account structure with snapshot + assert accounts == snapshot( + [ + { + 'id': '2', + 'name': 'Initial balance for "Test Checking"', + 'type': 'initial-balance', + 'currency_code': 'USD', + 'current_balance': IsFloat(), + }, + { + 'id': '4', + 'name': 'Initial balance for "Test Savings"', + 'type': 'initial-balance', + 'currency_code': 'USD', + 'current_balance': IsFloat(), + }, + { + 'id': '5', + 'name': 'Test Expense', + 'type': 'expense', + 'currency_code': 'EUR', + 'current_balance': IsFloat(), + }, + { + 'id': '6', + 'name': 'Test Revenue', + 'type': 'revenue', + 'currency_code': 'EUR', + 'current_balance': IsFloat(), + }, + { + 'id': '1', + 'name': 'Test Checking', + 'type': 'asset', + 'currency_code': 'USD', + 'current_balance': IsFloat(), + }, + { + 'id': '3', + 'name': 'Test Savings', + 'type': 'asset', + 'currency_code': 'USD', + 'current_balance': IsFloat(), + }, + ] + ) @pytest.mark.asyncio @pytest.mark.accounts @pytest.mark.integration async def test_list_accounts_by_type_asset(mcp_client): - """Test filtering accounts by asset type.""" - result = await mcp_client.call_tool('list_accounts', {'req': {'type': 'asset'}}) - accounts = result.data - - # Should have at least one asset account for tests to work - assert len(accounts) > 0 - - # All accounts should be asset type - for account in accounts: - assert account['type'] == 'asset' - - assert accounts == snapshot( - [ - { - 'id': '1', - 'name': 'Test Checking', - 'type': 'asset', - 'currency_code': 'USD', - 'current_balance': IsFloat(), - }, - { - 'id': '3', - 'name': 'Test Savings', - 'type': 'asset', - 'currency_code': 'USD', - 'current_balance': IsFloat(), - }, - ] - ) + """Test filtering accounts by asset type.""" + result = await mcp_client.call_tool('list_accounts', {'req': {'type': 'asset'}}) + accounts = result.data + + # Should have at least one asset account for tests to work + assert len(accounts) > 0 + + # All accounts should be asset type + for account in accounts: + assert account['type'] == 'asset' + + assert accounts == snapshot( + [ + { + 'id': '1', + 'name': 'Test Checking', + 'type': 'asset', + 'currency_code': 'USD', + 'current_balance': IsFloat(), + }, + { + 'id': '3', + 'name': 'Test Savings', + 'type': 'asset', + 'currency_code': 'USD', + 'current_balance': IsFloat(), + }, + ] + ) @pytest.mark.asyncio @pytest.mark.accounts @pytest.mark.integration async def test_list_accounts_by_type_expense(mcp_client): - """Test filtering accounts by expense type.""" - result = await mcp_client.call_tool('list_accounts', {'req': {'type': 'expense'}}) - accounts = result.data - - # May or may not have expense accounts - # If we have any, they should all be expense type - for account in accounts: - assert account['type'] == 'expense' - - assert accounts == snapshot( - [ - { - 'id': '5', - 'name': 'Test Expense', - 'type': 'expense', - 'currency_code': 'EUR', - 'current_balance': IsFloat(), - } - ] - ) + """Test filtering accounts by expense type.""" + result = await mcp_client.call_tool('list_accounts', {'req': {'type': 'expense'}}) + accounts = result.data + + # May or may not have expense accounts + # If we have any, they should all be expense type + for account in accounts: + assert account['type'] == 'expense' + + assert accounts == snapshot( + [ + { + 'id': '5', + 'name': 'Test Expense', + 'type': 'expense', + 'currency_code': 'EUR', + 'current_balance': IsFloat(), + } + ] + ) @pytest.mark.asyncio @pytest.mark.accounts @pytest.mark.integration async def test_list_accounts_by_type_revenue(mcp_client: Client): - """Test filtering accounts by revenue type.""" - result = await mcp_client.call_tool('list_accounts', {'req': {'type': 'revenue'}}) - accounts = result.data - - assert len(accounts) > 0 - assert accounts == snapshot( - [ - { - 'id': '6', - 'name': 'Test Revenue', - 'type': 'revenue', - 'currency_code': 'EUR', - 'current_balance': IsFloat(), - } - ] - ) + """Test filtering accounts by revenue type.""" + result = await mcp_client.call_tool('list_accounts', {'req': {'type': 'revenue'}}) + accounts = result.data + + assert len(accounts) > 0 + assert accounts == snapshot( + [ + { + 'id': '6', + 'name': 'Test Revenue', + 'type': 'revenue', + 'currency_code': 'EUR', + 'current_balance': IsFloat(), + } + ] + ) @pytest.mark.asyncio @pytest.mark.accounts @pytest.mark.integration async def test_get_account_valid(mcp_client: Client, test_asset_account: Account): - """Test retrieving a single account by valid ID.""" - result = await mcp_client.call_tool('get_account', {'req': {'id': test_asset_account.id}}) - account = Account.model_validate(result.structured_content) - - # Should return the same account - assert account.id == test_asset_account.id - assert account.name == test_asset_account.name - assert account.type.value == test_asset_account.type.value - - # Validate account structure with snapshot - assert result.structured_content == snapshot( - { - 'id': '1', - 'name': 'Test Checking', - 'type': 'asset', - 'currency_code': 'USD', - 'current_balance': IsFloat(), - } - ) + """Test retrieving a single account by valid ID.""" + result = await mcp_client.call_tool('get_account', {'req': {'id': test_asset_account.id}}) + account = Account.model_validate(result.structured_content) + + # Should return the same account + assert account.id == test_asset_account.id + assert account.name == test_asset_account.name + assert account.type.value == test_asset_account.type.value + + # Validate account structure with snapshot + assert result.structured_content == snapshot( + { + 'id': '1', + 'name': 'Test Checking', + 'type': 'asset', + 'currency_code': 'USD', + 'current_balance': IsFloat(), + } + ) @pytest.mark.asyncio @pytest.mark.accounts @pytest.mark.integration async def test_get_account_invalid(mcp_client): - """Test handling of invalid account ID (404).""" - # FastMCP Client wraps HTTPStatusError in ToolError - with pytest.raises(ToolError) as exc_info: - await mcp_client.call_tool('get_account', {'req': {'id': '999999'}}) + """Test handling of invalid account ID (404).""" + # FastMCP Client wraps HTTPStatusError in ToolError + with pytest.raises(ToolError) as exc_info: + await mcp_client.call_tool('get_account', {'req': {'id': '999999'}}) - assert '404' in str(exc_info.value) + assert '404' in str(exc_info.value) @pytest.mark.asyncio @pytest.mark.accounts @pytest.mark.integration async def test_search_accounts_exact(mcp_client, test_asset_account: Account): - """Test searching accounts with exact name match.""" - result = await mcp_client.call_tool( - 'search_accounts', {'req': {'query': test_asset_account.name}} - ) - accounts = result.data + """Test searching accounts with exact name match.""" + result = await mcp_client.call_tool( + 'search_accounts', {'req': {'query': test_asset_account.name}} + ) + accounts = result.data - # Should find at least the test account - assert len(accounts) > 0 + # Should find at least the test account + assert len(accounts) > 0 - # Should contain our test account - account_ids = [account['id'] for account in accounts] - assert test_asset_account.id in account_ids + # Should contain our test account + account_ids = [account['id'] for account in accounts] + assert test_asset_account.id in account_ids @pytest.mark.asyncio @pytest.mark.accounts @pytest.mark.integration async def test_search_accounts_partial(mcp_client, test_asset_account: Account): - """Test searching accounts with partial name matching.""" - # Use first 3 characters of account name - partial_name = test_asset_account.name[:3] - result = await mcp_client.call_tool('search_accounts', {'req': {'query': partial_name}}) - accounts = result.data + """Test searching accounts with partial name matching.""" + # Use first 3 characters of account name + partial_name = test_asset_account.name[:3] + result = await mcp_client.call_tool('search_accounts', {'req': {'query': partial_name}}) + accounts = result.data - # Should find at least one account - assert len(accounts) > 0 + # Should find at least one account + assert len(accounts) > 0 - # Should contain our test account (or at least accounts starting with the partial name) - account_ids = [account['id'] for account in accounts] - assert test_asset_account.id in account_ids + # Should contain our test account (or at least accounts starting with the partial name) + account_ids = [account['id'] for account in accounts] + assert test_asset_account.id in account_ids @pytest.mark.asyncio @pytest.mark.accounts @pytest.mark.integration async def test_search_accounts_with_type(mcp_client, test_asset_account: Account): - """Test searching accounts with type filtering.""" - result = await mcp_client.call_tool( - 'search_accounts', {'req': {'query': test_asset_account.name, 'type': 'asset'}} - ) - accounts = result.data + """Test searching accounts with type filtering.""" + result = await mcp_client.call_tool( + 'search_accounts', {'req': {'query': test_asset_account.name, 'type': 'asset'}} + ) + accounts = result.data - # Should find at least the test account - assert len(accounts) > 0 + # Should find at least the test account + assert len(accounts) > 0 - # All results should be asset type - for account in accounts: - assert account['type'] == 'asset' + # All results should be asset type + for account in accounts: + assert account['type'] == 'asset' - # Should contain our test account - account_ids = [account['id'] for account in accounts] - assert test_asset_account.id in account_ids + # Should contain our test account + account_ids = [account['id'] for account in accounts] + assert test_asset_account.id in account_ids @pytest.mark.asyncio @pytest.mark.accounts @pytest.mark.integration async def test_search_accounts_no_results(mcp_client): - """Test searching accounts with no matching results.""" - # Use a query that should not match any account - result = await mcp_client.call_tool( - 'search_accounts', {'req': {'query': 'xyzabc123nonexistent'}} - ) - accounts = result.data - - # Should return empty list - assert len(accounts) == 0 + """Test searching accounts with no matching results.""" + # Use a query that should not match any account + result = await mcp_client.call_tool( + 'search_accounts', {'req': {'query': 'xyzabc123nonexistent'}} + ) + accounts = result.data + + # Should return empty list + assert len(accounts) == 0 diff --git a/tests/integration/test_budgets.py b/tests/integration/test_budgets.py index 0b234a1..47c7439 100644 --- a/tests/integration/test_budgets.py +++ b/tests/integration/test_budgets.py @@ -13,156 +13,156 @@ @pytest.mark.budgets @pytest.mark.integration async def test_list_budgets_all(mcp_client: Client): - """Test listing all budgets regardless of active status.""" - result = await mcp_client.call_tool('list_budgets', {'req': {}}) - budgets = result.data + """Test listing all budgets regardless of active status.""" + result = await mcp_client.call_tool('list_budgets', {'req': {}}) + budgets = result.data - # Should have at least one budget for testing - assert len(budgets) > 0 + # Should have at least one budget for testing + assert len(budgets) > 0 - assert budgets == snapshot( - [{'id': '1', 'name': 'Test Budget', 'active': True, 'notes': None, 'order': 1}] - ) + assert budgets == snapshot( + [{'id': '1', 'name': 'Test Budget', 'active': True, 'notes': None, 'order': 1}] + ) @pytest.mark.asyncio @pytest.mark.budgets @pytest.mark.integration async def test_list_budgets_active_only(mcp_client: Client): - """Test filtering budgets by active status.""" - result = await mcp_client.call_tool('list_budgets', {'req': {'active': True}}) - budgets = result.data + """Test filtering budgets by active status.""" + result = await mcp_client.call_tool('list_budgets', {'req': {'active': True}}) + budgets = result.data - # Should have at least one active budget - assert len(budgets) > 0 + # Should have at least one active budget + assert len(budgets) > 0 - assert budgets == snapshot( - [{'id': '1', 'name': 'Test Budget', 'active': True, 'notes': None, 'order': 1}] - ) + assert budgets == snapshot( + [{'id': '1', 'name': 'Test Budget', 'active': True, 'notes': None, 'order': 1}] + ) @pytest.mark.asyncio @pytest.mark.budgets @pytest.mark.integration async def test_get_budget(mcp_client: Client, test_budget: Budget): - """Test retrieving a single budget by ID.""" - result = await mcp_client.call_tool('get_budget', {'req': {'id': test_budget.id}}) - budget = result.structured_content + """Test retrieving a single budget by ID.""" + result = await mcp_client.call_tool('get_budget', {'req': {'id': test_budget.id}}) + budget = result.structured_content - assert budget == snapshot( - {'id': '1', 'name': 'Test Budget', 'active': True, 'notes': None, 'order': 1} - ) + assert budget == snapshot( + {'id': '1', 'name': 'Test Budget', 'active': True, 'notes': None, 'order': 1} + ) @pytest.mark.asyncio @pytest.mark.budgets @pytest.mark.integration async def test_get_budget_spending(mcp_client: Client, test_budget: Budget): - """Test getting budget spending analysis for a period.""" - # Use 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) - - result = await mcp_client.call_tool( - 'get_budget_spending', - {'req': {'budget_id': test_budget.id, 'start': start.isoformat(), 'end': end.isoformat()}}, - ) - spending = BudgetSpending.model_validate(result.structured_content) - - # Should return spending information - assert spending.budget_id == test_budget.id - assert spending.budget_name == test_budget.name - assert spending.spent >= 0 # Spent should be non-negative - # budgeted, remaining, and percentage_spent may be None if no limits are set - - # Validate spending structure with snapshot - assert result.structured_content == snapshot( - { - 'budget_id': '1', - 'budget_name': 'Test Budget', - 'spent': 0.0, - 'budgeted': None, - 'remaining': None, - 'percentage_spent': None, - } - ) + """Test getting budget spending analysis for a period.""" + # Use 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) + + result = await mcp_client.call_tool( + 'get_budget_spending', + {'req': {'budget_id': test_budget.id, 'start': start.isoformat(), 'end': end.isoformat()}}, + ) + spending = BudgetSpending.model_validate(result.structured_content) + + # Should return spending information + assert spending.budget_id == test_budget.id + assert spending.budget_name == test_budget.name + assert spending.spent >= 0 # Spent should be non-negative + # budgeted, remaining, and percentage_spent may be None if no limits are set + + # Validate spending structure with snapshot + assert result.structured_content == snapshot( + { + 'budget_id': '1', + 'budget_name': 'Test Budget', + 'spent': 0.0, + 'budgeted': None, + 'remaining': None, + 'percentage_spent': None, + } + ) @pytest.mark.asyncio @pytest.mark.budgets @pytest.mark.integration async def test_get_budget_summary(mcp_client: Client): - """Test getting comprehensive budget summary.""" - # Use 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) - - result = await mcp_client.call_tool( - 'get_budget_summary', {'req': {'start': start.isoformat(), 'end': end.isoformat()}} - ) - summary = BudgetSummary.model_validate(result.structured_content) - - # Should return summary with at least one budget - assert len(summary.budgets) > 0 - - # Total spent should be sum of all budget spending - assert summary.total_spent >= 0 - - # Check individual budgets - for budget_spending in summary.budgets: - assert budget_spending.budget_id is not None - assert budget_spending.budget_name is not None - assert budget_spending.spent >= 0 - - assert result.structured_content == snapshot( - { - 'budgets': [ - { - 'budget_id': '1', - 'budget_name': 'Test Budget', - 'spent': 0.0, - 'budgeted': None, - 'remaining': None, - 'percentage_spent': None, - } - ], - 'total_budgeted': None, - 'total_spent': 0.0, - 'total_remaining': None, - 'available_budget': None, - } - ) + """Test getting comprehensive budget summary.""" + # Use 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) + + result = await mcp_client.call_tool( + 'get_budget_summary', {'req': {'start': start.isoformat(), 'end': end.isoformat()}} + ) + summary = BudgetSummary.model_validate(result.structured_content) + + # Should return summary with at least one budget + assert len(summary.budgets) > 0 + + # Total spent should be sum of all budget spending + assert summary.total_spent >= 0 + + # Check individual budgets + for budget_spending in summary.budgets: + assert budget_spending.budget_id is not None + assert budget_spending.budget_name is not None + assert budget_spending.spent >= 0 + + assert result.structured_content == snapshot( + { + 'budgets': [ + { + 'budget_id': '1', + 'budget_name': 'Test Budget', + 'spent': 0.0, + 'budgeted': None, + 'remaining': None, + 'percentage_spent': None, + } + ], + 'total_budgeted': None, + 'total_spent': 0.0, + 'total_remaining': None, + 'available_budget': None, + } + ) @pytest.mark.asyncio @pytest.mark.budgets @pytest.mark.integration @pytest.mark.xfail( - reason='Firefly III API bug - currency_id returned as int instead of string (issue #43)' + reason='Firefly III API bug - currency_id returned as int instead of string (issue #43)' ) async def test_get_available_budget(mcp_client: Client): - """Test getting available budget for a period.""" - # Use 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) - - result = await mcp_client.call_tool( - 'get_available_budget', {'req': {'start': start.isoformat(), 'end': end.isoformat()}} - ) - available = result.data - - # Should return available budget information - # amount may be 0 if no available budget is set - assert available['amount'] >= 0 - assert available['currency_code'] is not None - assert available['start_date'] == start.isoformat() - assert available['end_date'] == end.isoformat() + """Test getting available budget for a period.""" + # Use 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) + + result = await mcp_client.call_tool( + 'get_available_budget', {'req': {'start': start.isoformat(), 'end': end.isoformat()}} + ) + available = result.data + + # Should return available budget information + # amount may be 0 if no available budget is set + assert available['amount'] >= 0 + assert available['currency_code'] is not None + assert available['start_date'] == start.isoformat() + assert available['end_date'] == end.isoformat() diff --git a/tests/integration/test_transactions.py b/tests/integration/test_transactions.py index af0a3ca..70706dd 100644 --- a/tests/integration/test_transactions.py +++ b/tests/integration/test_transactions.py @@ -18,288 +18,288 @@ @pytest.mark.transactions @pytest.mark.integration async def test_create_withdrawal_basic( - mcp_client: Client, - test_asset_account: Account, - test_expense_account: str, - transaction_cleanup: List[str], + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, + transaction_cleanup: List[str], ): - """Test creating a basic withdrawal transaction.""" - result = await mcp_client.call_tool( - 'create_withdrawal', - { - 'req': { - 'amount': 10.50, - 'description': 'Test withdrawal - coffee', - 'source_id': test_asset_account.id, - 'destination_name': test_expense_account, - 'date': datetime.now(timezone.utc).isoformat(), - } - }, - ) - transaction = Transaction.model_validate(result.structured_content) - assert transaction is not None - assert transaction.id is not None - transaction_cleanup.append(transaction.id) - - # Validate transaction structure with snapshot - assert result.structured_content == snapshot( - { - 'id': IsStr(min_length=1), - 'amount': 10.5, - 'description': 'Test withdrawal - coffee', - 'type': 'withdrawal', - 'date': IsDatetime(iso_string=True), - 'source_id': '1', - 'destination_id': '5', - 'source_name': 'Test Checking', - 'destination_name': 'Test Expense', - 'currency_code': 'USD', - 'budget_id': None, - 'budget_name': None, - } - ) + """Test creating a basic withdrawal transaction.""" + result = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 10.50, + 'description': 'Test withdrawal - coffee', + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + transaction = Transaction.model_validate(result.structured_content) + assert transaction is not None + assert transaction.id is not None + transaction_cleanup.append(transaction.id) + + # Validate transaction structure with snapshot + assert result.structured_content == snapshot( + { + 'id': IsStr(min_length=1), + 'amount': 10.5, + 'description': 'Test withdrawal - coffee', + 'type': 'withdrawal', + 'date': IsDatetime(iso_string=True), + 'source_id': '1', + 'destination_id': '5', + 'source_name': 'Test Checking', + 'destination_name': 'Test Expense', + 'currency_code': 'USD', + 'budget_id': None, + 'budget_name': None, + } + ) @pytest.mark.asyncio @pytest.mark.transactions @pytest.mark.integration async def test_create_withdrawal_with_budget( - mcp_client: Client, - test_asset_account: Account, - test_expense_account: str, - test_budget: Budget, - transaction_cleanup: List[str], + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, + test_budget: Budget, + transaction_cleanup: List[str], ): - """Test creating a withdrawal with budget allocation.""" - result = await mcp_client.call_tool( - 'create_withdrawal', - { - 'req': { - 'amount': 25.00, - 'description': 'Test withdrawal with budget - groceries', - 'source_id': test_asset_account.id, - 'destination_name': test_expense_account, - 'budget_id': test_budget.id, - 'date': datetime.now(timezone.utc).isoformat(), - } - }, - ) - transaction = Transaction.model_validate(result.structured_content) - assert transaction is not None - assert transaction.id is not None - transaction_cleanup.append(transaction.id) - - # Verify transaction was created with budget - assert result.structured_content == snapshot( - { - 'id': IsStr(min_length=1), - 'amount': 25.0, - 'description': 'Test withdrawal with budget - groceries', - 'type': 'withdrawal', - 'date': IsDatetime(iso_string=True), - 'source_id': '1', - 'destination_id': '5', - 'source_name': 'Test Checking', - 'destination_name': 'Test Expense', - 'currency_code': 'USD', - 'budget_id': '1', - 'budget_name': 'Test Budget', - } - ) + """Test creating a withdrawal with budget allocation.""" + result = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 25.00, + 'description': 'Test withdrawal with budget - groceries', + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + 'budget_id': test_budget.id, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + transaction = Transaction.model_validate(result.structured_content) + assert transaction is not None + assert transaction.id is not None + transaction_cleanup.append(transaction.id) + + # Verify transaction was created with budget + assert result.structured_content == snapshot( + { + 'id': IsStr(min_length=1), + 'amount': 25.0, + 'description': 'Test withdrawal with budget - groceries', + 'type': 'withdrawal', + 'date': IsDatetime(iso_string=True), + 'source_id': '1', + 'destination_id': '5', + 'source_name': 'Test Checking', + 'destination_name': 'Test Expense', + 'currency_code': 'USD', + 'budget_id': '1', + 'budget_name': 'Test Budget', + } + ) @pytest.mark.asyncio @pytest.mark.transactions @pytest.mark.integration async def test_create_deposit_basic( - mcp_client: Client, - test_asset_account: Account, - test_revenue_account: str, - transaction_cleanup: List[str], + mcp_client: Client, + test_asset_account: Account, + test_revenue_account: str, + transaction_cleanup: List[str], ): - """Test creating a basic deposit transaction.""" - result = await mcp_client.call_tool( - 'create_deposit', - { - 'req': { - 'amount': 500.00, - 'description': 'Test deposit - salary', - 'destination_id': test_asset_account.id, - 'source_name': test_revenue_account, - 'date': datetime.now(timezone.utc).isoformat(), - } - }, - ) - transaction = Transaction.model_validate(result.structured_content) - assert transaction is not None - assert transaction.id is not None - transaction_cleanup.append(transaction.id) - - # Verify transaction was created - assert result.structured_content == snapshot( - { - 'id': IsStr(min_length=1), - 'amount': 500.0, - 'description': 'Test deposit - salary', - 'type': 'deposit', - 'date': IsDatetime(iso_string=True), - 'source_id': '6', - 'destination_id': '1', - 'source_name': 'Test Revenue', - 'destination_name': 'Test Checking', - 'currency_code': 'USD', - 'budget_id': None, - 'budget_name': None, - } - ) + """Test creating a basic deposit transaction.""" + result = await mcp_client.call_tool( + 'create_deposit', + { + 'req': { + 'amount': 500.00, + 'description': 'Test deposit - salary', + 'destination_id': test_asset_account.id, + 'source_name': test_revenue_account, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + transaction = Transaction.model_validate(result.structured_content) + assert transaction is not None + assert transaction.id is not None + transaction_cleanup.append(transaction.id) + + # Verify transaction was created + assert result.structured_content == snapshot( + { + 'id': IsStr(min_length=1), + 'amount': 500.0, + 'description': 'Test deposit - salary', + 'type': 'deposit', + 'date': IsDatetime(iso_string=True), + 'source_id': '6', + 'destination_id': '1', + 'source_name': 'Test Revenue', + 'destination_name': 'Test Checking', + 'currency_code': 'USD', + 'budget_id': None, + 'budget_name': None, + } + ) @pytest.mark.asyncio @pytest.mark.transactions @pytest.mark.integration async def test_create_transfer_basic( - mcp_client: Client, - test_asset_account: Account, - test_second_asset_account: Account, - transaction_cleanup: List[str], + mcp_client: Client, + test_asset_account: Account, + test_second_asset_account: Account, + transaction_cleanup: List[str], ): - """Test creating a transfer between asset accounts.""" - result = await mcp_client.call_tool( - 'create_transfer', - { - 'req': { - 'amount': 100.00, - 'description': 'Test transfer between accounts', - 'source_id': test_asset_account.id, - 'destination_id': test_second_asset_account.id, - 'date': datetime.now(timezone.utc).isoformat(), - } - }, - ) - transaction = Transaction.model_validate(result.structured_content) - assert transaction is not None - assert transaction.id is not None - transaction_cleanup.append(transaction.id) - - # Verify transaction was created - assert result.structured_content == snapshot( - { - 'id': IsStr(min_length=1), - 'amount': 100.0, - 'description': 'Test transfer between accounts', - 'type': 'transfer', - 'date': IsDatetime(iso_string=True), - 'source_id': '1', - 'destination_id': '3', - 'source_name': 'Test Checking', - 'destination_name': 'Test Savings', - 'currency_code': 'USD', - 'budget_id': None, - 'budget_name': None, - } - ) + """Test creating a transfer between asset accounts.""" + result = await mcp_client.call_tool( + 'create_transfer', + { + 'req': { + 'amount': 100.00, + 'description': 'Test transfer between accounts', + 'source_id': test_asset_account.id, + 'destination_id': test_second_asset_account.id, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + transaction = Transaction.model_validate(result.structured_content) + assert transaction is not None + assert transaction.id is not None + transaction_cleanup.append(transaction.id) + + # Verify transaction was created + assert result.structured_content == snapshot( + { + 'id': IsStr(min_length=1), + 'amount': 100.0, + 'description': 'Test transfer between accounts', + 'type': 'transfer', + 'date': IsDatetime(iso_string=True), + 'source_id': '1', + 'destination_id': '3', + 'source_name': 'Test Checking', + 'destination_name': 'Test Savings', + 'currency_code': 'USD', + 'budget_id': None, + 'budget_name': None, + } + ) @pytest.mark.asyncio @pytest.mark.transactions @pytest.mark.integration async def test_create_bulk_transactions( - mcp_client: Client, - test_asset_account: Account, - test_expense_account: str, - test_revenue_account: str, - transaction_cleanup: List[str], + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, + test_revenue_account: str, + transaction_cleanup: List[str], ): - """Test creating multiple transactions in bulk.""" - # Create 3 transactions: 1 withdrawal, 1 deposit, 1 withdrawal - transactions = [ - { - 'amount': 5.00, - 'description': 'Bulk test - transaction 1', - 'type': 'withdrawal', - 'date': datetime.now(timezone.utc).isoformat(), - 'source_id': test_asset_account.id, - 'destination_name': test_expense_account, - }, - { - 'amount': 100.00, - 'description': 'Bulk test - transaction 2', - 'type': 'deposit', - 'date': datetime.now(timezone.utc).isoformat(), - 'source_name': test_revenue_account, - 'destination_id': test_asset_account.id, - }, - { - 'amount': 15.00, - 'description': 'Bulk test - transaction 3', - 'type': 'withdrawal', - 'date': datetime.now(timezone.utc).isoformat(), - 'source_id': test_asset_account.id, - 'destination_name': test_expense_account, - }, - ] - - result = await mcp_client.call_tool( - 'create_bulk_transactions', {'req': {'transactions': transactions}} - ) - created = result.data - - # Add all to cleanup - for txn in created: - assert txn is not None - assert txn['id'] is not None - transaction_cleanup.append(txn['id']) - - # Verify all transactions were created - assert len(created) == 3 - assert created[0] == snapshot( - { - 'id': IsStr(min_length=1), - 'amount': 5.0, - 'description': 'Bulk test - transaction 1', - 'type': 'withdrawal', - 'date': IsDatetime(iso_string=True), - 'source_id': '1', - 'destination_id': '5', - 'source_name': 'Test Checking', - 'destination_name': 'Test Expense', - 'currency_code': 'USD', - 'budget_id': None, - 'budget_name': None, - } - ) - assert created[1] == snapshot( - { - 'id': IsStr(min_length=1), - 'amount': 100.0, - 'description': 'Bulk test - transaction 2', - 'type': 'deposit', - 'date': IsDatetime(iso_string=True), - 'source_id': '6', - 'destination_id': '1', - 'source_name': 'Test Revenue', - 'destination_name': 'Test Checking', - 'currency_code': 'USD', - 'budget_id': None, - 'budget_name': None, - } - ) - assert created[2] == snapshot( - { - 'id': IsStr(min_length=1), - 'amount': 15.0, - 'description': 'Bulk test - transaction 3', - 'type': 'withdrawal', - 'date': IsDatetime(iso_string=True), - 'source_id': '1', - 'destination_id': '5', - 'source_name': 'Test Checking', - 'destination_name': 'Test Expense', - 'currency_code': 'USD', - 'budget_id': None, - 'budget_name': None, - } - ) + """Test creating multiple transactions in bulk.""" + # Create 3 transactions: 1 withdrawal, 1 deposit, 1 withdrawal + transactions = [ + { + 'amount': 5.00, + 'description': 'Bulk test - transaction 1', + 'type': 'withdrawal', + 'date': datetime.now(timezone.utc).isoformat(), + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + }, + { + 'amount': 100.00, + 'description': 'Bulk test - transaction 2', + 'type': 'deposit', + 'date': datetime.now(timezone.utc).isoformat(), + 'source_name': test_revenue_account, + 'destination_id': test_asset_account.id, + }, + { + 'amount': 15.00, + 'description': 'Bulk test - transaction 3', + 'type': 'withdrawal', + 'date': datetime.now(timezone.utc).isoformat(), + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + }, + ] + + result = await mcp_client.call_tool( + 'create_bulk_transactions', {'req': {'transactions': transactions}} + ) + created = result.data + + # Add all to cleanup + for txn in created: + assert txn is not None + assert txn['id'] is not None + transaction_cleanup.append(txn['id']) + + # Verify all transactions were created + assert len(created) == 3 + assert created[0] == snapshot( + { + 'id': IsStr(min_length=1), + 'amount': 5.0, + 'description': 'Bulk test - transaction 1', + 'type': 'withdrawal', + 'date': IsDatetime(iso_string=True), + 'source_id': '1', + 'destination_id': '5', + 'source_name': 'Test Checking', + 'destination_name': 'Test Expense', + 'currency_code': 'USD', + 'budget_id': None, + 'budget_name': None, + } + ) + assert created[1] == snapshot( + { + 'id': IsStr(min_length=1), + 'amount': 100.0, + 'description': 'Bulk test - transaction 2', + 'type': 'deposit', + 'date': IsDatetime(iso_string=True), + 'source_id': '6', + 'destination_id': '1', + 'source_name': 'Test Revenue', + 'destination_name': 'Test Checking', + 'currency_code': 'USD', + 'budget_id': None, + 'budget_name': None, + } + ) + assert created[2] == snapshot( + { + 'id': IsStr(min_length=1), + 'amount': 15.0, + 'description': 'Bulk test - transaction 3', + 'type': 'withdrawal', + 'date': IsDatetime(iso_string=True), + 'source_id': '1', + 'destination_id': '5', + 'source_name': 'Test Checking', + 'destination_name': 'Test Expense', + 'currency_code': 'USD', + 'budget_id': None, + 'budget_name': None, + } + ) # ==================== Read Operations ==================== @@ -309,197 +309,197 @@ async def test_create_bulk_transactions( @pytest.mark.transactions @pytest.mark.integration async def test_get_transaction( - mcp_client: Client, - test_asset_account: Account, - test_expense_account: str, - transaction_cleanup: List[str], + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, + transaction_cleanup: List[str], ): - """Test retrieving a single transaction by ID.""" - # Create a transaction first - create_result = await mcp_client.call_tool( - 'create_withdrawal', - { - 'req': { - 'amount': 7.50, - 'description': 'Test get transaction', - 'source_id': test_asset_account.id, - 'destination_name': test_expense_account, - 'date': datetime.now(timezone.utc).isoformat(), - } - }, - ) - created = Transaction.model_validate(create_result.structured_content) - assert created is not None - assert created.id is not None - transaction_cleanup.append(created.id) - - # Now retrieve it - get_result = await mcp_client.call_tool('get_transaction', {'req': {'id': created.id}}) - transaction = Transaction.model_validate(get_result.structured_content) - - # Verify it's the same transaction - assert transaction.id == created.id - assert transaction.description == 'Test get transaction' - assert transaction.amount == 7.50 - - # Validate transaction structure with snapshot - assert get_result.structured_content == snapshot( - { - 'id': IsStr(min_length=1), - 'amount': 7.5, - 'description': 'Test get transaction', - 'type': 'withdrawal', - 'date': IsDatetime(iso_string=True), - 'source_id': '1', - 'destination_id': '5', - 'source_name': 'Test Checking', - 'destination_name': 'Test Expense', - 'currency_code': 'USD', - 'budget_id': None, - 'budget_name': None, - } - ) + """Test retrieving a single transaction by ID.""" + # Create a transaction first + create_result = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 7.50, + 'description': 'Test get transaction', + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + created = Transaction.model_validate(create_result.structured_content) + assert created is not None + assert created.id is not None + transaction_cleanup.append(created.id) + + # Now retrieve it + get_result = await mcp_client.call_tool('get_transaction', {'req': {'id': created.id}}) + transaction = Transaction.model_validate(get_result.structured_content) + + # Verify it's the same transaction + assert transaction.id == created.id + assert transaction.description == 'Test get transaction' + assert transaction.amount == 7.50 + + # Validate transaction structure with snapshot + assert get_result.structured_content == snapshot( + { + 'id': IsStr(min_length=1), + 'amount': 7.5, + 'description': 'Test get transaction', + 'type': 'withdrawal', + 'date': IsDatetime(iso_string=True), + 'source_id': '1', + 'destination_id': '5', + 'source_name': 'Test Checking', + 'destination_name': 'Test Expense', + 'currency_code': 'USD', + 'budget_id': None, + 'budget_name': None, + } + ) @pytest.mark.asyncio @pytest.mark.transactions @pytest.mark.integration async def test_get_transactions_all( - mcp_client: Client, - test_asset_account: Account, - test_expense_account: str, - transaction_cleanup: List[str], + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, + transaction_cleanup: List[str], ): - """Test listing all transactions without filters.""" - # Create a transaction first - create_result = await mcp_client.call_tool( - 'create_withdrawal', - { - 'req': { - 'amount': 5.00, - 'description': 'Test get all transactions', - 'source_id': test_asset_account.id, - 'destination_name': test_expense_account, - 'date': datetime.now(timezone.utc).isoformat(), - } - }, - ) - created = Transaction.model_validate(create_result.structured_content) - assert created is not None - assert created.id is not None - transaction_cleanup.append(created.id) - - result = await mcp_client.call_tool('get_transactions', {'req': {}}) - transactions_response = TransactionListResponse.model_validate(result.structured_content) - - # Should have at least one transaction (the one we just created) - assert len(transactions_response.transactions) > 0 - assert transactions_response.total_count is not None and transactions_response.total_count > 0 + """Test listing all transactions without filters.""" + # Create a transaction first + create_result = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 5.00, + 'description': 'Test get all transactions', + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + created = Transaction.model_validate(create_result.structured_content) + assert created is not None + assert created.id is not None + transaction_cleanup.append(created.id) + + result = await mcp_client.call_tool('get_transactions', {'req': {}}) + transactions_response = TransactionListResponse.model_validate(result.structured_content) + + # Should have at least one transaction (the one we just created) + assert len(transactions_response.transactions) > 0 + assert transactions_response.total_count is not None and transactions_response.total_count > 0 @pytest.mark.asyncio @pytest.mark.transactions @pytest.mark.integration async def test_get_transactions_filtered( - mcp_client: Client, - test_asset_account: Account, - test_expense_account: str, - transaction_cleanup: List[str], + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, + transaction_cleanup: List[str], ): - """Test filtering transactions by date range and type.""" - # Create a transaction first - create_result = await mcp_client.call_tool( - 'create_withdrawal', - { - 'req': { - 'amount': 12.00, - 'description': 'Test filtered transaction', - 'source_id': test_asset_account.id, - 'destination_name': test_expense_account, - 'date': datetime.now(timezone.utc).isoformat(), - } - }, - ) - created = Transaction.model_validate(create_result.structured_content) - assert created is not None - assert created.id is not None - transaction_cleanup.append(created.id) - - # Get transactions from last 7 days, withdrawals only - end_date = datetime.now(timezone.utc).date() - start_date = (datetime.now(timezone.utc) - timedelta(days=7)).date() - - result = await mcp_client.call_tool( - 'get_transactions', - { - 'req': { - 'start_date': start_date.isoformat(), - 'end_date': end_date.isoformat(), - 'transaction_type': 'withdrawal', - } - }, - ) - transactions_response = TransactionListResponse.model_validate(result.structured_content) - - # Should include our test transaction - transaction_ids = [t.id for t in transactions_response.transactions] - assert created.id in transaction_ids + """Test filtering transactions by date range and type.""" + # Create a transaction first + create_result = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 12.00, + 'description': 'Test filtered transaction', + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + created = Transaction.model_validate(create_result.structured_content) + assert created is not None + assert created.id is not None + transaction_cleanup.append(created.id) + + # Get transactions from last 7 days, withdrawals only + end_date = datetime.now(timezone.utc).date() + start_date = (datetime.now(timezone.utc) - timedelta(days=7)).date() + + result = await mcp_client.call_tool( + 'get_transactions', + { + 'req': { + 'start_date': start_date.isoformat(), + 'end_date': end_date.isoformat(), + 'transaction_type': 'withdrawal', + } + }, + ) + transactions_response = TransactionListResponse.model_validate(result.structured_content) + + # Should include our test transaction + transaction_ids = [t.id for t in transactions_response.transactions] + assert created.id in transaction_ids @pytest.mark.asyncio @pytest.mark.transactions @pytest.mark.integration async def test_get_transactions_paginated(mcp_client: Client): - """Test pagination of transaction results.""" - # Get first page with limit of 5 - result = await mcp_client.call_tool('get_transactions', {'req': {'page': 1, 'limit': 5}}) - transactions_response = TransactionListResponse.model_validate(result.structured_content) + """Test pagination of transaction results.""" + # Get first page with limit of 5 + result = await mcp_client.call_tool('get_transactions', {'req': {'page': 1, 'limit': 5}}) + transactions_response = TransactionListResponse.model_validate(result.structured_content) - # Should respect limit - assert len(transactions_response.transactions) <= 5 - assert transactions_response.current_page == 1 - assert transactions_response.per_page == 5 + # Should respect limit + assert len(transactions_response.transactions) <= 5 + assert transactions_response.current_page == 1 + assert transactions_response.per_page == 5 @pytest.mark.asyncio @pytest.mark.transactions @pytest.mark.integration async def test_search_transactions_description( - mcp_client: Client, - test_asset_account: Account, - test_expense_account: str, - transaction_cleanup: List[str], + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, + transaction_cleanup: List[str], ): - """Test searching transactions by description.""" - # Create a transaction with unique description - unique_desc = f'Unique search test {datetime.now(timezone.utc).timestamp()}' - create_result = await mcp_client.call_tool( - 'create_withdrawal', - { - 'req': { - 'amount': 3.00, - 'description': unique_desc, - 'source_id': test_asset_account.id, - 'destination_name': test_expense_account, - 'date': datetime.now(timezone.utc).isoformat(), - } - }, - ) - created = Transaction.model_validate(create_result.structured_content) - assert created is not None - assert created.id is not None - transaction_cleanup.append(created.id) - - # Search by description - search_result = await mcp_client.call_tool( - 'search_transactions', {'req': {'description_contains': unique_desc}} - ) - search_response = TransactionListResponse.model_validate(search_result.structured_content) - - # Should find our transaction - assert len(search_response.transactions) > 0 - transaction_ids = [t.id for t in search_response.transactions] - assert created.id in transaction_ids + """Test searching transactions by description.""" + # Create a transaction with unique description + unique_desc = f'Unique search test {datetime.now(timezone.utc).timestamp()}' + create_result = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 3.00, + 'description': unique_desc, + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + created = Transaction.model_validate(create_result.structured_content) + assert created is not None + assert created.id is not None + transaction_cleanup.append(created.id) + + # Search by description + search_result = await mcp_client.call_tool( + 'search_transactions', {'req': {'description_contains': unique_desc}} + ) + search_response = TransactionListResponse.model_validate(search_result.structured_content) + + # Should find our transaction + assert len(search_response.transactions) > 0 + transaction_ids = [t.id for t in search_response.transactions] + assert created.id in transaction_ids # ==================== Update Operations ==================== @@ -509,120 +509,120 @@ async def test_search_transactions_description( @pytest.mark.transactions @pytest.mark.integration async def test_update_transaction_amount( - mcp_client: Client, - test_asset_account: Account, - test_expense_account: str, - transaction_cleanup: List[str], + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, + transaction_cleanup: List[str], ): - """Test updating transaction amount.""" - # Create a transaction - create_result = await mcp_client.call_tool( - 'create_withdrawal', - { - 'req': { - 'amount': 10.00, - 'description': 'Test update amount', - 'source_id': test_asset_account.id, - 'destination_name': test_expense_account, - 'date': datetime.now(timezone.utc).isoformat(), - } - }, - ) - created = Transaction.model_validate(create_result.structured_content) - assert created is not None - assert created.id is not None - transaction_cleanup.append(created.id) - - # Update amount - update_result = await mcp_client.call_tool( - 'update_transaction', {'req': {'transaction_id': created.id, 'amount': 15.00}} - ) - updated = Transaction.model_validate(update_result.structured_content) - - # Verify amount was updated - assert updated.id == created.id - assert updated.amount == 15.00 + """Test updating transaction amount.""" + # Create a transaction + create_result = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 10.00, + 'description': 'Test update amount', + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + created = Transaction.model_validate(create_result.structured_content) + assert created is not None + assert created.id is not None + transaction_cleanup.append(created.id) + + # Update amount + update_result = await mcp_client.call_tool( + 'update_transaction', {'req': {'transaction_id': created.id, 'amount': 15.00}} + ) + updated = Transaction.model_validate(update_result.structured_content) + + # Verify amount was updated + assert updated.id == created.id + assert updated.amount == 15.00 @pytest.mark.asyncio @pytest.mark.transactions @pytest.mark.integration async def test_update_transaction_description( - mcp_client: Client, - test_asset_account: Account, - test_expense_account: str, - transaction_cleanup: List[str], + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, + transaction_cleanup: List[str], ): - """Test updating transaction description.""" - # Create a transaction - create_result = await mcp_client.call_tool( - 'create_withdrawal', - { - 'req': { - 'amount': 8.00, - 'description': 'Old description', - 'source_id': test_asset_account.id, - 'destination_name': test_expense_account, - 'date': datetime.now(timezone.utc).isoformat(), - } - }, - ) - created = Transaction.model_validate(create_result.structured_content) - assert created is not None - assert created.id is not None - transaction_cleanup.append(created.id) - - # Update description - update_result = await mcp_client.call_tool( - 'update_transaction', - {'req': {'transaction_id': created.id, 'description': 'New description'}}, - ) - updated = Transaction.model_validate(update_result.structured_content) - - # Verify description was updated - assert updated.id == created.id - assert updated.description == 'New description' + """Test updating transaction description.""" + # Create a transaction + create_result = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 8.00, + 'description': 'Old description', + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + created = Transaction.model_validate(create_result.structured_content) + assert created is not None + assert created.id is not None + transaction_cleanup.append(created.id) + + # Update description + update_result = await mcp_client.call_tool( + 'update_transaction', + {'req': {'transaction_id': created.id, 'description': 'New description'}}, + ) + updated = Transaction.model_validate(update_result.structured_content) + + # Verify description was updated + assert updated.id == created.id + assert updated.description == 'New description' @pytest.mark.asyncio @pytest.mark.transactions @pytest.mark.integration async def test_update_transaction_budget( - mcp_client: Client, - test_asset_account: Account, - test_expense_account: str, - test_budget: Budget, - transaction_cleanup: List[str], + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, + test_budget: Budget, + transaction_cleanup: List[str], ): - """Test updating transaction budget allocation.""" - # Create a transaction without budget - create_result = await mcp_client.call_tool( - 'create_withdrawal', - { - 'req': { - 'amount': 20.00, - 'description': 'Test update budget', - 'source_id': test_asset_account.id, - 'destination_name': test_expense_account, - 'date': datetime.now(timezone.utc).isoformat(), - } - }, - ) - created = Transaction.model_validate(create_result.structured_content) - assert created is not None - assert created.id is not None - transaction_cleanup.append(created.id) - - # Update to add budget - update_result = await mcp_client.call_tool( - 'update_transaction', - {'req': {'transaction_id': created.id, 'budget_id': test_budget.id}}, - ) - updated = Transaction.model_validate(update_result.structured_content) - - # Verify budget was added - assert updated.id == created.id - assert updated.budget_id == test_budget.id + """Test updating transaction budget allocation.""" + # Create a transaction without budget + create_result = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 20.00, + 'description': 'Test update budget', + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + created = Transaction.model_validate(create_result.structured_content) + assert created is not None + assert created.id is not None + transaction_cleanup.append(created.id) + + # Update to add budget + update_result = await mcp_client.call_tool( + 'update_transaction', + {'req': {'transaction_id': created.id, 'budget_id': test_budget.id}}, + ) + updated = Transaction.model_validate(update_result.structured_content) + + # Verify budget was added + assert updated.id == created.id + assert updated.budget_id == test_budget.id # ==================== Delete Operations ==================== @@ -632,49 +632,49 @@ async def test_update_transaction_budget( @pytest.mark.transactions @pytest.mark.integration async def test_delete_transaction( - mcp_client: Client, - test_asset_account: Account, - test_expense_account: str, + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, ): - """Test deleting a transaction.""" - # Create a transaction - create_result = await mcp_client.call_tool( - 'create_withdrawal', - { - 'req': { - 'amount': 1.00, - 'description': 'Test delete', - 'source_id': test_asset_account.id, - 'destination_name': test_expense_account, - 'date': datetime.now(timezone.utc).isoformat(), - } - }, - ) - created = Transaction.model_validate(create_result.structured_content) - assert created is not None - assert created.id is not None - - # Delete it - delete_result = await mcp_client.call_tool('delete_transaction', {'req': {'id': created.id}}) - result: bool = delete_result.data - - # Should return True - assert result is True - - # Verify it was deleted by trying to get it - with pytest.raises(ToolError) as exc_info: - await mcp_client.call_tool('get_transaction', {'req': {'id': created.id}}) - - assert '404' in str(exc_info.value) + """Test deleting a transaction.""" + # Create a transaction + create_result = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 1.00, + 'description': 'Test delete', + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + created = Transaction.model_validate(create_result.structured_content) + assert created is not None + assert created.id is not None + + # Delete it + delete_result = await mcp_client.call_tool('delete_transaction', {'req': {'id': created.id}}) + result: bool = delete_result.data + + # Should return True + assert result is True + + # Verify it was deleted by trying to get it + with pytest.raises(ToolError) as exc_info: + await mcp_client.call_tool('get_transaction', {'req': {'id': created.id}}) + + assert '404' in str(exc_info.value) @pytest.mark.asyncio @pytest.mark.transactions @pytest.mark.integration async def test_delete_nonexistent_transaction(mcp_client: Client): - """Test handling deletion of non-existent transaction (404).""" - # Should raise HTTPStatusError with 404 - with pytest.raises(ToolError) as exc_info: - await mcp_client.call_tool('delete_transaction', {'req': {'id': '999999'}}) + """Test handling deletion of non-existent transaction (404).""" + # Should raise HTTPStatusError with 404 + with pytest.raises(ToolError) as exc_info: + await mcp_client.call_tool('delete_transaction', {'req': {'id': '999999'}}) - assert '404' in str(exc_info.value) + assert '404' in str(exc_info.value) diff --git a/tests/verify_setup.py b/tests/verify_setup.py index b04ed85..e98505b 100755 --- a/tests/verify_setup.py +++ b/tests/verify_setup.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Verification script for Firefly III test setup. +"""Verification script for Firefly III test setup. This script checks that: 1. Firefly III is running and accessible @@ -31,29 +30,29 @@ # Load test environment env_path = Path(__file__).parent / '.env.test' if env_path.exists(): - load_dotenv(env_path) + load_dotenv(env_path) else: - print('✗ tests/.env.test file not found') - print('\nPlease create it with:') - print(' FIREFLY_BASE_URL=http://localhost:8080') - print(' FIREFLY_TOKEN=your_token_here') - sys.exit(1) + print('✗ tests/.env.test file not found') + print('\nPlease create it with:') + print(' FIREFLY_BASE_URL=http://localhost:8080') + print(' FIREFLY_TOKEN=your_token_here') + sys.exit(1) FIREFLY_URL = os.getenv('FIREFLY_BASE_URL', 'http://localhost:8080') TOKEN = os.getenv('FIREFLY_TOKEN', '') if not TOKEN: - print('✗ FIREFLY_TOKEN not set in tests/.env.test') - sys.exit(1) + print('✗ FIREFLY_TOKEN not set in tests/.env.test') + sys.exit(1) print('=== Firefly III Test Setup Verification ===\n') print(f'Firefly URL: {FIREFLY_URL}') print(f'Token: {TOKEN[:20]}...\n') headers = { - 'Authorization': f'Bearer {TOKEN}', - 'Accept': 'application/json', + 'Authorization': f'Bearer {TOKEN}', + 'Accept': 'application/json', } all_checks_passed = True @@ -62,63 +61,63 @@ # Check 1: Firefly III is running print('1. Checking Firefly III accessibility...') try: - response = httpx.get(f'{FIREFLY_URL}/api/v1/about', headers=headers, timeout=10.0) - if response.status_code == 200: - data = response.json() - version = data.get('data', {}).get('version', 'unknown') - print(f' ✓ Firefly III is running (version: {version})') - else: - print(f' ✗ Unexpected response: {response.status_code}') - all_checks_passed = False + response = httpx.get(f'{FIREFLY_URL}/api/v1/about', headers=headers, timeout=10.0) + if response.status_code == 200: + data = response.json() + version = data.get('data', {}).get('version', 'unknown') + print(f' ✓ Firefly III is running (version: {version})') + else: + print(f' ✗ Unexpected response: {response.status_code}') + all_checks_passed = False except Exception as e: - print(f' ✗ Cannot connect to Firefly III: {e}') - print(' Make sure Firefly III is running: docker-compose -f docker-compose.test.yml up -d') - all_checks_passed = False + print(f' ✗ Cannot connect to Firefly III: {e}') + print(' Make sure Firefly III is running: docker-compose -f docker-compose.test.yml up -d') + all_checks_passed = False # Check 2: Token is valid print('\n2. Checking API token validity...') try: - response = httpx.get(f'{FIREFLY_URL}/api/v1/about', headers=headers, timeout=10.0) - if response.status_code == 200: - print(' ✓ Token is valid') - elif response.status_code == 401: - print(' ✗ Token is invalid or expired') - print(' Please regenerate token in Firefly III web UI') - all_checks_passed = False - else: - print(f' ✗ Unexpected response: {response.status_code}') - all_checks_passed = False + response = httpx.get(f'{FIREFLY_URL}/api/v1/about', headers=headers, timeout=10.0) + if response.status_code == 200: + print(' ✓ Token is valid') + elif response.status_code == 401: + print(' ✗ Token is invalid or expired') + print(' Please regenerate token in Firefly III web UI') + all_checks_passed = False + else: + print(f' ✗ Unexpected response: {response.status_code}') + all_checks_passed = False except Exception as e: - print(f' ✗ Cannot verify token: {e}') - all_checks_passed = False + print(f' ✗ Cannot verify token: {e}') + all_checks_passed = False # Check 3: Verify test data can be created print('\n3. Checking test data creation capability...') try: - response = httpx.get(f'{FIREFLY_URL}/api/v1/accounts', headers=headers, timeout=10.0) - if response.status_code == 200: - print(' ✓ Can access accounts endpoint') - # Check if we can create accounts (POST request would be needed for full verification) - print(' ℹ Tests will create accounts and budgets programmatically') - else: - print(f' ✗ Cannot access accounts endpoint: {response.status_code}') - all_checks_passed = False + response = httpx.get(f'{FIREFLY_URL}/api/v1/accounts', headers=headers, timeout=10.0) + if response.status_code == 200: + print(' ✓ Can access accounts endpoint') + # Check if we can create accounts (POST request would be needed for full verification) + print(' ℹ Tests will create accounts and budgets programmatically') + else: + print(f' ✗ Cannot access accounts endpoint: {response.status_code}') + all_checks_passed = False except Exception as e: - print(f' ✗ Cannot verify account access: {e}') - all_checks_passed = False + print(f' ✗ Cannot verify account access: {e}') + all_checks_passed = False # Summary print('\n=== Verification Summary ===') if all_checks_passed: - print('✓ All checks passed! Your test environment is ready.') - print('\nYou can now run tests with:') - print(' uv run pytest tests/') - print(' # Or run specific test suites:') - print(' uv run pytest -m accounts') - print(' uv run pytest -m transactions') - print(' uv run pytest -m budgets') - sys.exit(0) + print('✓ All checks passed! Your test environment is ready.') + print('\nYou can now run tests with:') + print(' uv run pytest tests/') + print(' # Or run specific test suites:') + print(' uv run pytest -m accounts') + print(' uv run pytest -m transactions') + print(' uv run pytest -m budgets') + sys.exit(0) else: - print('✗ Some checks failed. Please fix the issues above.') - print('\nFor setup instructions, see CLAUDE.md#Testing') - sys.exit(1) + print('✗ Some checks failed. Please fix the issues above.') + print('\nFor setup instructions, see CLAUDE.md#Testing') + sys.exit(1) From 5b4ae85fd85b0385a8275eac2234bf8e20dfa959 Mon Sep 17 00:00:00 2001 From: Radith Samarakoon Date: Sat, 17 Jan 2026 19:26:01 +0530 Subject: [PATCH 04/12] test: add transaction filtering, search, and update tests - Add test_second_expense_account fixture for multi-account scenarios - Add integration tests for get_transactions with account_id filter - Add search_transactions tests with type, amount, date, and metadata filters - Add comprehensive update_transaction test covering all fields - Add bulk_update_transactions test with failure handling temp --- tests/conftest.py | 27 +- tests/integration/test_accounts.py | 18 +- tests/integration/test_transactions.py | 408 ++++++++++++++++++++++++- 3 files changed, 447 insertions(+), 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 690991a..22546e3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -124,10 +124,12 @@ async def _setup_test_data(): ) expense = None + expense2 = None for account in existing_expense: - if 'test expense' in account.name.lower(): + if account.name == 'Test Expense': expense = account - break + elif account.name == 'Test Expense 2': + expense2 = account if expense is None: expense_store = AccountStore( @@ -140,6 +142,17 @@ async def _setup_test_data(): _cached_test_accounts.append(expense) + if expense2 is None: + expense2_store = AccountStore( + name='Test Expense 2', + type=ShortAccountTypeProperty.expense, + currency_code='EUR', + ) + expense2 = await account_service.create_account(expense2_store) + _created_account_ids.append(expense2.id) + + _cached_test_accounts.append(expense2) + # Create revenue account for deposit tests existing_revenue = await account_service.list_accounts( ListAccountRequest(type=AccountTypeFilter.revenue) @@ -265,6 +278,16 @@ def test_expense_account() -> str: return 'Test Expense' +@pytest.fixture(scope='session') +def test_second_expense_account() -> str: + """Get second expense account name for testing. + + This can be used in tests that require multiple expense accounts. + """ + # Return a default name - Firefly III will create it automatically + return 'Test Expense 2' + + @pytest.fixture(scope='session') def test_revenue_account() -> str: """Get revenue account name for deposit testing. diff --git a/tests/integration/test_accounts.py b/tests/integration/test_accounts.py index fa56c76..8f03bc5 100644 --- a/tests/integration/test_accounts.py +++ b/tests/integration/test_accounts.py @@ -52,6 +52,13 @@ async def test_list_accounts_all(mcp_client): }, { 'id': '6', + 'name': 'Test Expense 2', + 'type': 'expense', + 'currency_code': 'EUR', + 'current_balance': 0.0, + }, + { + 'id': '7', 'name': 'Test Revenue', 'type': 'revenue', 'currency_code': 'EUR', @@ -131,7 +138,14 @@ async def test_list_accounts_by_type_expense(mcp_client): 'type': 'expense', 'currency_code': 'EUR', 'current_balance': IsFloat(), - } + }, + { + 'id': '6', + 'name': 'Test Expense 2', + 'type': 'expense', + 'currency_code': 'EUR', + 'current_balance': 0.0, + }, ] ) @@ -148,7 +162,7 @@ async def test_list_accounts_by_type_revenue(mcp_client: Client): assert accounts == snapshot( [ { - 'id': '6', + 'id': '7', 'name': 'Test Revenue', 'type': 'revenue', 'currency_code': 'EUR', diff --git a/tests/integration/test_transactions.py b/tests/integration/test_transactions.py index 70706dd..f71d2b2 100644 --- a/tests/integration/test_transactions.py +++ b/tests/integration/test_transactions.py @@ -143,7 +143,7 @@ async def test_create_deposit_basic( 'description': 'Test deposit - salary', 'type': 'deposit', 'date': IsDatetime(iso_string=True), - 'source_id': '6', + 'source_id': '7', 'destination_id': '1', 'source_name': 'Test Revenue', 'destination_name': 'Test Checking', @@ -275,7 +275,7 @@ async def test_create_bulk_transactions( 'description': 'Bulk test - transaction 2', 'type': 'deposit', 'date': IsDatetime(iso_string=True), - 'source_id': '6', + 'source_id': '7', 'destination_id': '1', 'source_name': 'Test Revenue', 'destination_name': 'Test Checking', @@ -678,3 +678,407 @@ async def test_delete_nonexistent_transaction(mcp_client: Client): await mcp_client.call_tool('delete_transaction', {'req': {'id': '999999'}}) assert '404' in str(exc_info.value) + + +@pytest.mark.asyncio +@pytest.mark.transactions +@pytest.mark.integration +async def test_get_transactions_with_account_id( + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, + transaction_cleanup: List[str], +): + """Test getting transactions filtered by account ID.""" + # Create a transaction first + create_result = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 18.00, + 'description': 'Test account filter', + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + created = Transaction.model_validate(create_result.structured_content) + assert created is not None + assert created.id is not None + transaction_cleanup.append(created.id) + + # Get transactions filtered by account ID + result = await mcp_client.call_tool( + 'get_transactions', {'req': {'account_id': test_asset_account.id}} + ) + transactions_response = TransactionListResponse.model_validate(result.structured_content) + + # Should include our test transaction + transaction_ids = [t.id for t in transactions_response.transactions] + assert created.id in transaction_ids + + +@pytest.mark.asyncio +@pytest.mark.transactions +@pytest.mark.integration +async def test_search_transactions_with_type_filter( + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, + transaction_cleanup: List[str], +): + """Test searching transactions with type filter.""" + # Create a transaction first + create_result = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 7.00, + 'description': 'Test type filter', + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + created = Transaction.model_validate(create_result.structured_content) + assert created is not None + assert created.id is not None + transaction_cleanup.append(created.id) + + # Search by type + search_result = await mcp_client.call_tool( + 'search_transactions', {'req': {'type': 'withdrawal'}} + ) + search_response = TransactionListResponse.model_validate(search_result.structured_content) + + # Should find our transaction + assert len(search_response.transactions) > 0 + transaction_ids = [t.id for t in search_response.transactions] + assert created.id in transaction_ids + + +@pytest.mark.asyncio +@pytest.mark.transactions +@pytest.mark.integration +async def test_search_transactions_with_amount_filters( + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, + transaction_cleanup: List[str], +): + """Test searching transactions with amount filters.""" + # Create a transaction first + create_result = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 25.00, + 'description': 'Test amount filters', + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + created = Transaction.model_validate(create_result.structured_content) + assert created is not None + assert created.id is not None + transaction_cleanup.append(created.id) + + # Search by exact amount + search_result = await mcp_client.call_tool( + 'search_transactions', {'req': {'amount_equals': 25.00}} + ) + search_response = TransactionListResponse.model_validate(search_result.structured_content) + + # Should find our transaction + assert len(search_response.transactions) > 0 + transaction_ids = [t.id for t in search_response.transactions] + assert created.id in transaction_ids + + # Search by amount range + search_result = await mcp_client.call_tool( + 'search_transactions', {'req': {'amount_more': 20.00, 'amount_less': 30.00}} + ) + search_response = TransactionListResponse.model_validate(search_result.structured_content) + + # Should find our transaction + assert len(search_response.transactions) > 0 + transaction_ids = [t.id for t in search_response.transactions] + assert created.id in transaction_ids + + +@pytest.mark.asyncio +@pytest.mark.transactions +@pytest.mark.integration +async def test_search_transactions_with_date_filters( + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, + transaction_cleanup: List[str], +): + """Test searching transactions with date filters.""" + test_date = datetime.now(timezone.utc).date() - timedelta(days=1) + + # Create a transaction first + create_result = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 9.00, + 'description': 'Test date filters', + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + 'date': datetime.combine( + test_date, datetime.min.time(), tzinfo=timezone.utc + ).isoformat(), + } + }, + ) + created = Transaction.model_validate(create_result.structured_content) + assert created is not None + assert created.id is not None + transaction_cleanup.append(created.id) + + # Search by date_on + search_result = await mcp_client.call_tool( + 'search_transactions', {'req': {'date_on': test_date}} + ) + search_response = TransactionListResponse.model_validate(search_result.structured_content) + + # Should find our transaction + assert len(search_response.transactions) > 0 + transaction_ids = [t.id for t in search_response.transactions] + assert created.id in transaction_ids + + # Search by date range + search_result = await mcp_client.call_tool( + 'search_transactions', + { + 'req': { + 'date_after': ( + datetime.combine(test_date, datetime.min.time(), tzinfo=timezone.utc) + - timedelta(days=1) + ).isoformat(), + 'date_before': ( + datetime.combine(test_date, datetime.min.time(), tzinfo=timezone.utc) + + timedelta(days=1) + ).isoformat(), + } + }, + ) + search_response = TransactionListResponse.model_validate(search_result.structured_content) + + # Should find our transaction + assert len(search_response.transactions) > 0 + transaction_ids = [t.id for t in search_response.transactions] + assert created.id in transaction_ids + + +@pytest.mark.asyncio +@pytest.mark.transactions +@pytest.mark.integration +async def test_search_transactions_with_metadata_filters( + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, + test_budget: Budget, + transaction_cleanup: List[str], +): + """Test searching transactions with metadata filters (category, budget).""" + # Create a transaction with budget + create_result = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 30.00, + 'description': 'Test metadata filters', + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + 'budget_id': test_budget.id, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + created = Transaction.model_validate(create_result.structured_content) + assert created is not None + assert created.id is not None + transaction_cleanup.append(created.id) + + # Search by budget + search_result = await mcp_client.call_tool( + 'search_transactions', {'req': {'budget': test_budget.name}} + ) + search_response = TransactionListResponse.model_validate(search_result.structured_content) + + # Should find our transaction + assert len(search_response.transactions) > 0 + transaction_ids = [t.id for t in search_response.transactions] + assert created.id in transaction_ids + + # Search by account contains + search_result = await mcp_client.call_tool( + 'search_transactions', {'req': {'account_contains': test_asset_account.name[:5]}} + ) + search_response = TransactionListResponse.model_validate(search_result.structured_content) + + # Should find our transaction + assert len(search_response.transactions) > 0 + transaction_ids = [t.id for t in search_response.transactions] + assert created.id in transaction_ids + + # Search by account_id + search_result = await mcp_client.call_tool( + 'search_transactions', {'req': {'account_id': test_asset_account.id}} + ) + search_response = TransactionListResponse.model_validate(search_result.structured_content) + + # Should find our transaction + assert len(search_response.transactions) > 0 + transaction_ids = [t.id for t in search_response.transactions] + assert created.id in transaction_ids + + +@pytest.mark.asyncio +@pytest.mark.transactions +@pytest.mark.integration +async def test_update_transaction_all_fields( + mcp_client: Client, + test_asset_account: Account, + test_second_asset_account: Account, + test_expense_account: str, + test_second_expense_account: str, + test_budget: Budget, + transaction_cleanup: List[str], +): + """Test updating transaction with all possible fields.""" + # Create a transaction first + create_1_result = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 12.00, + 'description': 'Test update all fields', + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + + create_2_result = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 12.00, + 'description': 'Test update all fields', + 'source_id': test_asset_account.id, + 'destination_name': test_second_expense_account, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + + created = [ + Transaction.model_validate(create_1_result.structured_content), + Transaction.model_validate(create_2_result.structured_content), + ] + assert created is not None + assert created[0].id is not None + assert created[1].id is not None + transaction_cleanup.append(created[0].id) + transaction_cleanup.append(created[1].id) + + # Update all fields + update_result = await mcp_client.call_tool( + 'update_transaction', + { + 'req': { + 'transaction_id': created[0].id, + 'amount': 15.00, + 'description': 'Updated all fields', + 'date': (datetime.now(timezone.utc) + timedelta(days=1)).isoformat(), + 'source_id': test_second_asset_account.id, + 'destination_id': created[1].destination_id, + 'budget_id': test_budget.id, + 'category_name': 'Test Category', + } + }, + ) + updated = Transaction.model_validate(update_result.structured_content) + + # Verify all fields were updated + assert updated.id == created[0].id + assert updated.amount == 15.00 + assert updated.description == 'Updated all fields' + assert updated.budget_id == test_budget.id + + +@pytest.mark.asyncio +@pytest.mark.transactions +@pytest.mark.integration +async def test_bulk_update_transactions_with_failure( + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, + transaction_cleanup: List[str], +): + """Test bulk update transactions with one failure to test exception handling.""" + # Create two transactions first + create_result1 = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 5.00, + 'description': 'Test bulk update 1', + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + created1 = Transaction.model_validate(create_result1.structured_content) + assert created1 is not None + assert created1.id is not None + transaction_cleanup.append(created1.id) + + create_result2 = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 6.00, + 'description': 'Test bulk update 2', + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + created2 = Transaction.model_validate(create_result2.structured_content) + assert created2 is not None + assert created2.id is not None + transaction_cleanup.append(created2.id) + + # Try to bulk update where one update will fail (using non-existent transaction) + with pytest.raises(ToolError) as exc_info: + await mcp_client.call_tool( + 'bulk_update_transactions', + { + 'req': { + 'updates': [ + { + 'transaction_id': created1.id, + 'description': 'Updated successfully', + }, + { + 'transaction_id': '999999', # Non-existent transaction + 'description': 'This should fail', + }, + ] + } + }, + ) + + assert 'Failed to update transaction 999999' in str(exc_info.value) From 58a4adacbf557c490ccda756efe17d5e87759d9b Mon Sep 17 00:00:00 2001 From: Radith Samarakoon Date: Sat, 17 Jan 2026 20:37:55 +0530 Subject: [PATCH 05/12] test: add unit tests to increase coverage - Budget service business logic and calculations - Configuration management and validation - HTTP client API communication and error handling - Entry point transport configuration - Pydantic model validation rules - Server initialization and authentication setup - Utility functions for assets and HTTP routes --- tests/unit/test_budgets_service.py | 328 +++++++++++++++++++++++ tests/unit/test_config.py | 278 ++++++++++++++++++++ tests/unit/test_firefly_client.py | 404 +++++++++++++++++++++++++++++ tests/unit/test_main.py | 115 ++++++++ tests/unit/test_models.py | 45 ++++ tests/unit/test_server.py | 194 ++++++++++++++ tests/unit/test_utils.py | 123 +++++++++ 7 files changed, 1487 insertions(+) create mode 100644 tests/unit/test_budgets_service.py create mode 100644 tests/unit/test_config.py create mode 100644 tests/unit/test_firefly_client.py create mode 100644 tests/unit/test_main.py create mode 100644 tests/unit/test_models.py create mode 100644 tests/unit/test_server.py create mode 100644 tests/unit/test_utils.py diff --git a/tests/unit/test_budgets_service.py b/tests/unit/test_budgets_service.py new file mode 100644 index 0000000..4e39d4a --- /dev/null +++ b/tests/unit/test_budgets_service.py @@ -0,0 +1,328 @@ +"""Unit tests for BudgetService.""" + +from datetime import date +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from lampyrid.models.lampyrid_models import ( + GetAvailableBudgetRequest, + GetBudgetSpendingRequest, + GetBudgetSummaryRequest, +) +from lampyrid.services.budgets import BudgetService + + +class TestBudgetService: + """Test cases for BudgetService class.""" + + @pytest.fixture + def mock_service(self): + """Create a BudgetService with mocked FireflyClient.""" + with patch('lampyrid.services.budgets.FireflyClient') as mock_firefly: + mock_client_instance = AsyncMock() + mock_firefly.return_value = mock_client_instance + + service = BudgetService(mock_client_instance) + return service, mock_client_instance + + @pytest.mark.asyncio + async def test_get_budget_spending_with_spent_entries(self, mock_service): + """Test get_budget_spending when spent entries exist.""" + service, mock_client = mock_service + + # Mock response with spent entries + mock_budget_single = MagicMock() + mock_budget_single.data.id = '1' + mock_budget_single.data.attributes.name = 'Test Budget' + + mock_limits_response = MagicMock() + mock_limits_response.data = [ + MagicMock( + attributes=MagicMock( + spent=[MagicMock(sum='50.0'), MagicMock(sum='25.0')], amount='200.0' + ) + ) + ] + + mock_client.get_budget.return_value = mock_budget_single + mock_client.get_budget_limits.return_value = mock_limits_response + + req = GetBudgetSpendingRequest( + budget_id='1', start_date=date(2023, 1, 1), end_date=date(2023, 12, 31) + ) + + result = await service.get_budget_spending(req) + + # Verify calculations + assert result.budget_id == '1' + assert result.budget_name == 'Test Budget' + assert result.spent == 75.0 # 50.0 + 25.0 + assert result.budgeted == 200.0 + assert result.remaining == 125.0 + assert result.percentage_spent == 37.5 # (75.0 / 200.0) * 100 + + @pytest.mark.asyncio + async def test_get_budget_spending_without_spent_entries(self, mock_service): + """Test get_budget_spending when no spent entries exist.""" + service, mock_client = mock_service + + # Mock response without spent entries but with amount + mock_budget_single = MagicMock() + mock_budget_single.data.id = '1' + mock_budget_single.data.attributes.name = 'Test Budget' + + mock_limits_response = MagicMock() + mock_limits_response.data = [MagicMock(attributes=MagicMock(spent=None, amount='150.0'))] + + mock_client.get_budget.return_value = mock_budget_single + mock_client.get_budget_limits.return_value = mock_limits_response + + req = GetBudgetSpendingRequest( + budget_id='1', start_date=date(2023, 1, 1), end_date=date(2023, 12, 31) + ) + + result = await service.get_budget_spending(req) + + # Verify calculations + assert result.budget_id == '1' + assert result.budget_name == 'Test Budget' + assert result.spent == 0.0 + assert result.budgeted == 150.0 + assert result.remaining == 150.0 + assert result.percentage_spent == 0.0 + + @pytest.mark.asyncio + async def test_get_budget_spending_without_amount(self, mock_service): + """Test get_budget_spending when no amount is set.""" + service, mock_client = mock_service + + # Mock response with spent entries but no amount + mock_budget_single = MagicMock() + mock_budget_single.data.id = '1' + mock_budget_single.data.attributes.name = 'Test Budget' + + mock_limits_response = MagicMock() + mock_limits_response.data = [ + MagicMock(attributes=MagicMock(spent=[MagicMock(sum='30.0')], amount=None)) + ] + + mock_client.get_budget.return_value = mock_budget_single + mock_client.get_budget_limits.return_value = mock_limits_response + + req = GetBudgetSpendingRequest( + budget_id='1', start_date=date(2023, 1, 1), end_date=date(2023, 12, 31) + ) + + result = await service.get_budget_spending(req) + + # Verify calculations when no amount is set + assert result.budget_id == '1' + assert result.budget_name == 'Test Budget' + assert result.spent == 30.0 + assert result.budgeted is None + assert result.remaining is None + assert result.percentage_spent is None + + @pytest.mark.asyncio + async def test_get_budget_summary_with_budgeted_amounts(self, mock_service): + """Test get_budget_summary when budgets have budgeted amounts.""" + service, mock_client = mock_service + + # Mock budgets response + mock_budgets_response = MagicMock() + mock_budgets_response.data = [MagicMock(id='1'), MagicMock(id='2')] + + # Mock individual budget spending calls + async def mock_get_budget_limits(budget_id, start_date, end_date): + if budget_id == '1': + mock_response = MagicMock() + mock_response.data = [ + MagicMock( + attributes=MagicMock( + spent=[MagicMock(sum='50.0'), MagicMock(sum='0.0')], amount='100.0' + ) + ) + ] + return mock_response + else: + mock_response = MagicMock() + mock_response.data = [ + MagicMock( + attributes=MagicMock( + spent=[MagicMock(sum='25.0'), MagicMock(sum='0.0')], amount='50.0' + ) + ) + ] + return mock_response + + # Mock individual budget calls for budget names + def mock_get_budget(budget_id): + mock_budget = MagicMock() + if budget_id == '1': + mock_budget.data.attributes.name = 'Budget 1' + else: + mock_budget.data.attributes.name = 'Budget 2' + return mock_budget + + mock_client.get_budgets.return_value = mock_budgets_response + mock_client.get_budget.side_effect = mock_get_budget + mock_client.get_budget_limits.side_effect = mock_get_budget_limits + + req = GetBudgetSummaryRequest(start_date=date(2023, 1, 1), end_date=date(2023, 12, 31)) + + result = await service.get_budget_summary(req) + + # Verify summary calculations + assert len(result.budgets) == 2 + assert result.total_spent == 75.0 # 50.0 + 25.0 + assert result.total_budgeted == 150.0 # 100.0 + 50.0 + assert result.total_remaining == 75.0 + assert result.available_budget is None + + @pytest.mark.asyncio + async def test_get_budget_summary_without_budgeted_amounts(self, mock_service): + """Test get_budget_summary when budgets have no budgeted amounts.""" + service, mock_client = mock_service + + # Mock budgets response + mock_budgets_response = MagicMock() + mock_budgets_response.data = [MagicMock(id='1'), MagicMock(id='2')] + + # Mock individual budget spending with no budgeted amounts + async def mock_get_budget_limits(budget_id, start_date, end_date): + if budget_id == '1': + mock_response = MagicMock() + mock_response.data = [ + MagicMock(attributes=MagicMock(spent=[MagicMock(sum='30.0')], amount=None)) + ] + return mock_response + else: + mock_response = MagicMock() + mock_response.data = [ + MagicMock(attributes=MagicMock(spent=[MagicMock(sum='20.0')], amount=None)) + ] + return mock_response + + # Mock individual budget calls for budget names + def mock_get_budget(budget_id): + mock_budget = MagicMock() + if budget_id == '1': + mock_budget.data.attributes.name = 'Budget 1' + else: + mock_budget.data.attributes.name = 'Budget 2' + return mock_budget + + mock_client.get_budgets.return_value = mock_budgets_response + mock_client.get_budget.side_effect = mock_get_budget + mock_client.get_budget_limits.side_effect = mock_get_budget_limits + + req = GetBudgetSummaryRequest(start_date=date(2023, 1, 1), end_date=date(2023, 12, 31)) + + result = await service.get_budget_summary(req) + + # Verify summary calculations + assert len(result.budgets) == 2 + assert result.total_spent == 50.0 # 30.0 + 20.0 + assert result.total_budgeted is None + assert result.total_remaining is None + assert result.available_budget is None + + @pytest.mark.asyncio + async def test_get_available_budget_with_data(self, mock_service): + """Test get_available_budget when data is available.""" + service, mock_client = mock_service + + # Mock available budgets response + mock_available_response = MagicMock() + mock_budget = MagicMock() + mock_budget.attributes.amount = '500.0' + mock_budget.attributes.currency_code = 'USD' + mock_budget.attributes.start = MagicMock() + mock_budget.attributes.start.date.return_value = date(2023, 1, 1) + mock_budget.attributes.end = MagicMock() + mock_budget.attributes.end.date.return_value = date(2023, 12, 31) + + mock_available_response.data = [mock_budget] + + mock_client.get_available_budgets.return_value = mock_available_response + + req = GetAvailableBudgetRequest(start_date=date(2023, 1, 1), end_date=date(2023, 12, 31)) + + result = await service.get_available_budget(req) + + # Verify result with available data + assert result.amount == 500.0 + assert result.currency_code == 'USD' + assert result.start_date == date(2023, 1, 1) + assert result.end_date == date(2023, 12, 31) + + @pytest.mark.asyncio + async def test_get_available_budget_without_data(self, mock_service): + """Test get_available_budget when no data is available.""" + service, mock_client = mock_service + + # Mock empty available budgets response + mock_available_response = MagicMock() + mock_available_response.data = [] + + mock_client.get_available_budgets.return_value = mock_available_response + + req = GetAvailableBudgetRequest(start_date=date(2023, 1, 1), end_date=date(2023, 12, 31)) + + result = await service.get_available_budget(req) + + # Verify default result when no data + assert result.amount == 0.0 + assert result.currency_code == 'USD' + assert result.start_date == date(2023, 1, 1) + assert result.end_date == date(2023, 12, 31) + + @pytest.mark.asyncio + async def test_get_available_budget_uses_default_dates(self, mock_service): + """Test get_available_budget uses default dates when not provided.""" + service, mock_client = mock_service + + # Mock empty available budgets response + mock_available_response = MagicMock() + mock_available_response.data = [] + + mock_client.get_available_budgets.return_value = mock_available_response + + # Request with no dates + req = GetAvailableBudgetRequest() + + result = await service.get_available_budget(req) + + # Verify default dates are used + today = date.today() + assert result.start_date == today.replace(day=1) + assert result.end_date == today + + @pytest.mark.asyncio + async def test_create_budget(self, mock_service): + """Test creating a new budget.""" + service, mock_client = mock_service + + # Mock response + mock_budget_single = MagicMock() + mock_budget_single.data.id = '123' + mock_budget_single.data.attributes.name = 'New Budget' + mock_budget_single.data.attributes.active = True + mock_budget_single.data.attributes.notes = None + mock_budget_single.data.attributes.order = 0 + + mock_client.create_budget.return_value = mock_budget_single + + # Mock BudgetStore to avoid complex required fields + budget_store = MagicMock() + + result = await service.create_budget(budget_store) + + # Verify the client was called + mock_client.create_budget.assert_called_once_with(budget_store) + + # Verify the result is converted correctly + assert result.id == '123' + assert result.name == 'New Budget' + assert result.active is True diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..606ca65 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,278 @@ +"""Unit tests for configuration settings.""" + +import os +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest +from pydantic import HttpUrl +from pydantic_core import ValidationError + +from lampyrid.config import Settings, _init_settings + + +class TestSettings: + """Test cases for Settings class.""" + + def test_settings_with_required_fields_only(self): + """Test creating settings with only required fields.""" + with patch.dict(os.environ, {}, clear=True): # Clear env to avoid interference + settings = Settings( + firefly_base_url=HttpUrl('https://firefly.example.com'), firefly_token='test_token' + ) + + assert str(settings.firefly_base_url) == 'https://firefly.example.com/' + assert settings.firefly_token == 'test_token' + assert settings.logging_level == 'INFO' # Default value + assert settings.mcp_transport == 'stdio' # Default value + assert settings.mcp_host == '0.0.0.0' # Default value + assert settings.mcp_port == 3000 # Default value + + def test_settings_with_all_fields(self): + """Test creating settings with all fields configured.""" + settings = Settings( + firefly_base_url=HttpUrl('https://firefly.example.com'), + firefly_token='test_token', + logging_level='DEBUG', + mcp_transport='http', + mcp_host='localhost', + mcp_port=8080, + google_client_id='client_id', + google_client_secret='client_secret', + server_base_url=HttpUrl('https://server.example.com'), + jwt_signing_key='jwt_key', + oauth_storage_encryption_key='encryption_key', + oauth_storage_path=Path('/custom/path'), + ) + + assert settings.logging_level == 'DEBUG' + assert settings.mcp_transport == 'http' + assert settings.mcp_host == 'localhost' + assert settings.mcp_port == 8080 + assert settings.google_client_id == 'client_id' + assert settings.google_client_secret == 'client_secret' + assert str(settings.server_base_url) == 'https://server.example.com/' + assert settings.jwt_signing_key == 'jwt_key' + assert settings.oauth_storage_encryption_key == 'encryption_key' + assert settings.oauth_storage_path == Path('/custom/path') + + def test_google_oauth_validation_all_provided(self): + """Test Google OAuth validation passes when all fields are provided.""" + settings = Settings( + firefly_base_url=HttpUrl('https://firefly.example.com'), + firefly_token='test_token', + google_client_id='client_id', + google_client_secret='client_secret', + server_base_url=HttpUrl('https://server.example.com'), + ) + + # Should not raise an error + assert settings.google_client_id == 'client_id' + assert settings.google_client_secret == 'client_secret' + assert str(settings.server_base_url) == 'https://server.example.com/' + + def test_google_oauth_validation_none_provided(self): + """Test Google OAuth validation passes when no fields are provided.""" + settings = Settings( + firefly_base_url=HttpUrl('https://firefly.example.com'), firefly_token='test_token' + ) + + # Should not raise an error + assert settings.google_client_id is None + assert settings.google_client_secret is None + assert settings.server_base_url is None + + def test_google_oauth_validation_partial_provided(self): + """Test Google OAuth validation fails when only some fields are provided.""" + with pytest.raises(ValidationError) as exc_info: + Settings( + firefly_base_url=HttpUrl('https://firefly.example.com'), + firefly_token='test_token', + google_client_id='client_id', + # Missing google_client_secret and server_base_url + ) + + assert 'Google OAuth configuration is incomplete' in str(exc_info.value) + assert 'Currently provided: 1/3' in str(exc_info.value) + + def test_google_oauth_validation_two_provided(self): + """Test Google OAuth validation fails when only 2 of 3 fields are provided.""" + with pytest.raises(ValidationError) as exc_info: + Settings( + firefly_base_url=HttpUrl('https://firefly.example.com'), + firefly_token='test_token', + google_client_id='client_id', + google_client_secret='client_secret', + # Missing server_base_url + ) + + assert 'Google OAuth configuration is incomplete' in str(exc_info.value) + assert 'Currently provided: 2/3' in str(exc_info.value) + + def test_is_auth_enabled_true(self): + """Test is_auth_enabled returns True when all OAuth fields are set.""" + settings = Settings( + firefly_base_url=HttpUrl('https://firefly.example.com'), + firefly_token='test_token', + google_client_id='client_id', + google_client_secret='client_secret', + server_base_url=HttpUrl('https://server.example.com'), + ) + + assert settings.is_auth_enabled is True + + def test_is_auth_enabled_false(self): + """Test is_auth_enabled returns False when OAuth fields are not all set.""" + settings = Settings( + firefly_base_url=HttpUrl('https://firefly.example.com'), firefly_token='test_token' + ) + + assert settings.is_auth_enabled is False + + def test_is_auth_enabled_partial(self): + """Test is_auth_enabled returns False when only some OAuth fields are set.""" + with pytest.raises(ValidationError): + Settings( + firefly_base_url=HttpUrl('https://firefly.example.com'), + firefly_token='test_token', + google_client_id='client_id', + ) + + def test_is_token_persistence_enabled_true(self): + """Test is_token_persistence_enabled returns True when both keys are set.""" + settings = Settings( + firefly_base_url=HttpUrl('https://firefly.example.com'), + firefly_token='test_token', + jwt_signing_key='jwt_key', + oauth_storage_encryption_key='encryption_key', + ) + + assert settings.is_token_persistence_enabled is True + + def test_is_token_persistence_enabled_false(self): + """Test is_token_persistence_enabled returns False when keys are not both set.""" + settings = Settings( + firefly_base_url=HttpUrl('https://firefly.example.com'), firefly_token='test_token' + ) + + assert settings.is_token_persistence_enabled is False + + def test_is_token_persistence_enabled_partial(self): + """Test is_token_persistence_enabled returns False when only one key is set.""" + settings = Settings( + firefly_base_url=HttpUrl('https://firefly.example.com'), + firefly_token='test_token', + jwt_signing_key='jwt_key', + # Missing oauth_storage_encryption_key + ) + + assert settings.is_token_persistence_enabled is False + + def test_default_oauth_storage_path(self): + """Test default OAuth storage path is set correctly.""" + settings = Settings( + firefly_base_url=HttpUrl('https://firefly.example.com'), firefly_token='test_token' + ) + + expected_path = Path.home() / '.local' / 'share' / 'lampyrid' / 'oauth' + assert settings.oauth_storage_path == expected_path + + +class TestInitSettings: + """Test cases for _init_settings function.""" + + def test_init_settings_success(self): + """Test successful initialization with environment variables.""" + with patch.dict(os.environ, {}, clear=True): # Clear existing env first + with patch.dict( + os.environ, + {'FIREFLY_BASE_URL': 'https://firefly.example.com', 'FIREFLY_TOKEN': 'test_token'}, + ): + settings = _init_settings() + + assert str(settings.firefly_base_url) == 'https://firefly.example.com/' + assert settings.firefly_token == 'test_token' + + def test_init_settings_validation_error_missing_required(self): + """Test initialization fails with missing required fields.""" + # Clear environment variables to force missing fields + with patch.dict(os.environ, {}, clear=True): + with patch('sys.exit') as mock_exit: + # Mock sys.exit to prevent actual exit during test + mock_exit.return_value = None + + try: + _init_settings() + except SystemExit: + pass # Expected behavior + + # Verify sys.exit was called with code 1 + mock_exit.assert_called_once_with(1) + + def test_init_settings_validation_error_invalid_url(self): + """Test initialization fails with invalid URL.""" + with patch.dict( + os.environ, + { + 'FIREFLY_BASE_URL': 'invalid-url', # Invalid URL + 'FIREFLY_TOKEN': 'test_token', + }, + ): + with patch('sys.exit') as mock_exit: + # Mock sys.exit to prevent actual exit during test + mock_exit.return_value = None + + try: + _init_settings() + except SystemExit: + pass # Expected behavior + + # Verify sys.exit was called with code 1 + mock_exit.assert_called_once_with(1) + + def test_init_settings_unexpected_error(self): + """Test initialization re-raises unexpected errors.""" + # This would test the else branch in _init_settings + with patch.object(Settings, 'model_validate', side_effect=RuntimeError('Unexpected error')): + with pytest.raises(RuntimeError, match='Unexpected error'): + _init_settings() + + def test_init_settings_with_env_file(self): + """Test initialization using custom env file path.""" + # Test with a custom env file path + env_content = """FIREFLY_BASE_URL=https://firefly.example.com +FIREFLY_TOKEN=test_token +LOGGING_LEVEL=DEBUG +""" + + with tempfile.NamedTemporaryFile(mode='w', suffix='.env', delete=False) as f: + f.write(env_content) + env_file_path = f.name + + try: + # Clear existing env and test with custom env file + with patch.dict(os.environ, {}, clear=True): + # Patch Settings to use our custom env file + with patch('lampyrid.config.Settings') as mock_settings_class: + # Configure mock to create proper settings when called + mock_settings = mock_settings_class.return_value + mock_settings.firefly_base_url = HttpUrl('https://firefly.example.com') + mock_settings.firefly_token = 'test_token' + mock_settings.logging_level = 'DEBUG' + + # Mock the model_validate method to return our settings + mock_settings_class.model_validate.return_value = mock_settings + + settings = _init_settings() + + # Verify Settings.model_validate was called + mock_settings_class.model_validate.assert_called_once() + + # This mainly tests that _init_settings doesn't crash with custom files + assert settings is not None + + finally: + # Cleanup + if Path(env_file_path).exists(): + Path(env_file_path).unlink() diff --git a/tests/unit/test_firefly_client.py b/tests/unit/test_firefly_client.py new file mode 100644 index 0000000..ed68ebb --- /dev/null +++ b/tests/unit/test_firefly_client.py @@ -0,0 +1,404 @@ +"""Unit tests for FireflyClient.""" + +from datetime import date +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from httpx import Response + +from lampyrid.clients.firefly import FireflyClient + + +class TestFireflyClient: + """Test cases for FireflyClient class.""" + + @pytest.fixture + def mock_client(self): + """Create a FireflyClient with mocked HTTP client.""" + with patch('lampyrid.clients.firefly.httpx.AsyncClient') as mock_http_client: + with patch('lampyrid.clients.firefly.settings') as mock_settings: + mock_settings.firefly_base_url = 'https://firefly.example.com' + mock_settings.firefly_token = 'test_token' + + mock_response = AsyncMock(spec=Response) + mock_response.status_code = 200 + mock_response.json.return_value = {} + + mock_client_instance = AsyncMock() + mock_client_instance.get.return_value = mock_response + mock_client_instance.post.return_value = mock_response + mock_client_instance.put.return_value = mock_response + mock_client_instance.delete.return_value = mock_response + + mock_http_client.return_value = mock_client_instance + + client = FireflyClient() + client._client = mock_client_instance + + return client, mock_client_instance, mock_response + + def test_init(self): + """Test FireflyClient initialization.""" + with patch('lampyrid.clients.firefly.settings') as mock_settings: + mock_settings.firefly_base_url = 'https://firefly.example.com' + mock_settings.firefly_token = 'test_token' + + client = FireflyClient() + + # Verify the client was initialized with correct base URL + assert client._client.base_url == 'https://firefly.example.com' + + def test_init_with_trailing_slash(self): + """Test FireflyClient initialization with trailing slash.""" + with patch('lampyrid.clients.firefly.settings') as mock_settings: + mock_settings.firefly_base_url = 'https://firefly.example.com/' + mock_settings.firefly_token = 'test_token' + + client = FireflyClient() + + # Should handle trailing slash correctly + assert 'firefly.example.com' in str(client._client.base_url) + + @pytest.mark.asyncio + async def test_create_account(self, mock_client): + """Test creating an account.""" + client, mock_http_client, mock_response = mock_client + + # Mock response data + mock_response.json.return_value = { + 'data': { + 'id': '123', + 'type': 'accounts', + 'attributes': {'name': 'Test Account', 'type': 'asset'}, + } + } + + # Mock AccountStore to avoid complex required fields + account_store = MagicMock() + account_store.model_dump.return_value = {'name': 'Test Account'} + + result = await client.create_account(account_store) + + # Verify POST request was made + mock_http_client.post.assert_called_once() + call_args = mock_http_client.post.call_args + + # Check that it was called with correct relative URL + assert '/api/v1/accounts' in str(call_args[0]) + assert 'json' in call_args[1] + + # Verify result is validated + assert result is not None + + @pytest.mark.asyncio + async def test_create_account_with_error_handling(self, mock_client): + """Test create_account error handling.""" + client, mock_http_client, mock_response = mock_client + + # Mock error response + mock_response.status_code = 422 + mock_response.text = 'Validation error' + mock_response.raise_for_status.side_effect = Exception('HTTP Error') + + # Mock AccountStore to avoid complex required fields + account_store = MagicMock() + account_store.model_dump.return_value = {'name': 'Test Account'} + + # Should not raise exception immediately (error handling happens internally) + try: + await client.create_account(account_store) + except Exception: + pass # Expected after raise_for_status + + # Verify error handling was called + # The method should complete but error details should be logged + + def test_sanitize_value_without_special_chars(self): + """Test _sanitize_value with normal string.""" + result = FireflyClient._sanitize_value('simple_string') + assert result == 'simple_string' + + def test_sanitize_value_with_spaces(self): + """Test _sanitize_value with spaces.""" + result = FireflyClient._sanitize_value('hello world') + assert result == '"hello world"' + + def test_sanitize_value_with_quotes(self): + """Test _sanitize_value with quote characters.""" + result = FireflyClient._sanitize_value('say "hello"') + assert result == '"say \\"hello\\""' + + def test_sanitize_value_with_single_quotes(self): + """Test _sanitize_value with single quotes.""" + result = FireflyClient._sanitize_value("don't") + assert result == '"don\'t"' + + def test_sanitize_value_with_backslashes(self): + """Test _sanitize_value with backslashes.""" + result = FireflyClient._sanitize_value('path\\to\\file') + assert result == 'path\\\\to\\\\file' + + def test_sanitize_value_with_mixed_special_chars(self): + """Test _sanitize_value with mixed special characters.""" + result = FireflyClient._sanitize_value('hello "world" and\\backslash') + assert result == '"hello \\"world\\" and\\\\backslash"' + + @pytest.mark.asyncio + async def test_get_account_transactions_with_dates(self, mock_client): + """Test get_account_transactions with date filters.""" + client, mock_http_client, mock_response = mock_client + + # Mock response with proper structure for TransactionArray + mock_response.json.return_value = {'data': [], 'meta': {'pagination': {}}, 'links': {}} + + start_date = date(2023, 1, 1) + end_date = date(2023, 12, 31) + + await client.get_account_transactions( + account_id='123', + start_date=start_date, + end_date=end_date, + transaction_type='withdrawal', + ) + + # Verify parameters were included + call_args = mock_http_client.get.call_args + params = call_args[1]['params'] + + assert params['start'] == '2023-01-01' + assert params['end'] == '2023-12-31' + assert params['type'] == 'withdrawal' + + @pytest.mark.asyncio + async def test_get_account_transactions_without_dates(self, mock_client): + """Test get_account_transactions without date filters.""" + client, mock_http_client, mock_response = mock_client + + # Mock response with proper structure + mock_response.json.return_value = {'data': [], 'meta': {'pagination': {}}, 'links': {}} + + await client.get_account_transactions(account_id='123') + + # Verify date parameters were not included + call_args = mock_http_client.get.call_args + params = call_args[1]['params'] + + assert 'start' not in params + assert 'end' not in params + assert 'type' not in params + + @pytest.mark.asyncio + async def test_get_budget_limits_with_dates(self, mock_client): + """Test get_budget_limits with date filters.""" + client, mock_http_client, mock_response = mock_client + + # Mock response with proper structure for BudgetLimitArray + mock_response.json.return_value = {'data': [], 'meta': {'pagination': {}}} + + start_date = date(2023, 1, 1) + end_date = date(2023, 12, 31) + + await client.get_budget_limits(budget_id='123', start_date=start_date, end_date=end_date) + + # Verify parameters were included + call_args = mock_http_client.get.call_args + params = call_args[1]['params'] + + assert params['start'] == '2023-01-01' + assert params['end'] == '2023-12-31' + + @pytest.mark.asyncio + async def test_get_budget_limits_without_dates(self, mock_client): + """Test get_budget_limits without date filters.""" + client, mock_http_client, mock_response = mock_client + + # Mock response with proper structure + mock_response.json.return_value = {'data': [], 'meta': {'pagination': {}}} + + await client.get_budget_limits(budget_id='123') + + # Verify date parameters were not included + call_args = mock_http_client.get.call_args + params = call_args[1]['params'] + + assert 'start' not in params + assert 'end' not in params + + @pytest.mark.asyncio + async def test_create_budget(self, mock_client): + """Test creating a budget.""" + client, mock_http_client, mock_response = mock_client + + # Mock response data + mock_response.json.return_value = { + 'data': { + 'id': '456', + 'type': 'budgets', + 'attributes': {'name': 'Test Budget', 'active': True}, + } + } + + # Mock BudgetStore to avoid complex required fields + budget_store = MagicMock() + budget_store.model_dump.return_value = {'name': 'Test Budget', 'active': True} + + result = await client.create_budget(budget_store) + + # Verify POST request was made + mock_http_client.post.assert_called_once() + call_args = mock_http_client.post.call_args + + # Check that it was called with correct relative URL + assert '/api/v1/budgets' in str(call_args[0]) + assert 'json' in call_args[1] + + # Verify result is validated + assert result is not None + + @pytest.mark.asyncio + async def test_create_budget_with_error_handling(self, mock_client): + """Test create_budget error handling.""" + client, mock_http_client, mock_response = mock_client + + # Mock error response + mock_response.status_code = 422 + mock_response.text = 'Validation error' + mock_response.raise_for_status.side_effect = Exception('HTTP Error') + + # Mock BudgetStore to avoid complex required fields + budget_store = MagicMock() + budget_store.model_dump.return_value = {'name': 'Test Budget', 'active': True} + + # Should not raise exception immediately (error handling happens internally) + try: + await client.create_budget(budget_store) + except Exception: + pass # Expected after raise_for_status + + # Verify error handling was called with payload + # The method should complete but error details should be logged + + @pytest.mark.asyncio + async def test_get_available_budgets_with_dates(self, mock_client): + """Test get_available_budgets with date filters.""" + client, mock_http_client, mock_response = mock_client + + # Mock response with proper structure for AvailableBudgetArray + mock_response.json.return_value = {'data': [], 'meta': {'pagination': {}}} + + start_date = date(2023, 1, 1) + end_date = date(2023, 12, 31) + + await client.get_available_budgets(start_date=start_date, end_date=end_date) + + # Verify parameters were included + call_args = mock_http_client.get.call_args + params = call_args[1]['params'] + + assert params['start'] == '2023-01-01' + assert params['end'] == '2023-12-31' + + @pytest.mark.asyncio + async def test_get_available_budgets_without_dates(self, mock_client): + """Test get_available_budgets without date filters.""" + client, mock_http_client, mock_response = mock_client + + # Mock response with proper structure + mock_response.json.return_value = {'data': [], 'meta': {'pagination': {}}} + + await client.get_available_budgets() + + # Verify date parameters were not included + call_args = mock_http_client.get.call_args + params = call_args[1]['params'] + + assert 'start' not in params + assert 'end' not in params + + def test_serialize_model(self): + """Test _serialize_model helper method.""" + with patch('lampyrid.clients.firefly.settings') as mock_settings: + mock_settings.firefly_base_url = 'https://firefly.example.com' + mock_settings.firefly_token = 'test_token' + + client = FireflyClient() + + # Create a mock Pydantic model + mock_model = MagicMock() + mock_model.model_dump.return_value = {'key': 'value', 'none_field': None} + + result = client._serialize_model(mock_model) + + # Check that model_dump was called with correct parameters (excluding None) + mock_model.model_dump.assert_called_with( + mode='json', exclude_none=True, exclude_unset=False + ) + # The actual result might include None if mock is not perfect, but the call is correct + assert 'key' in result + + def test_serialize_model_with_exclude_unset(self): + """Test _serialize_model with exclude_unset option.""" + with patch('lampyrid.clients.firefly.settings') as mock_settings: + mock_settings.firefly_base_url = 'https://firefly.example.com' + mock_settings.firefly_token = 'test_token' + + client = FireflyClient() + + # Create a mock Pydantic model + mock_model = MagicMock() + mock_model.model_dump.return_value = {'key': 'value', 'unset_field': None} + + client._serialize_model(mock_model, exclude_unset=True) + + # Should call with correct parameters + mock_model.model_dump.assert_called_with( + mode='json', exclude_unset=True, exclude_none=True + ) + + def test_handle_api_error_with_error_response(self): + """Test _handle_api_error with error response.""" + with patch('lampyrid.clients.firefly.settings') as mock_settings: + mock_settings.firefly_base_url = 'https://firefly.example.com' + mock_settings.firefly_token = 'test_token' + + client = FireflyClient() + + mock_response = MagicMock() + mock_response.status_code = 422 + mock_response.text = 'Validation error details' + mock_response.request.url = 'https://firefly.example.com/api/v1/accounts' + + # Should not raise exception, just log error + client._handle_api_error(mock_response) + + def test_handle_api_error_with_payload(self): + """Test _handle_api_error with request payload.""" + with patch('lampyrid.clients.firefly.settings') as mock_settings: + mock_settings.firefly_base_url = 'https://firefly.example.com' + mock_settings.firefly_token = 'test_token' + + client = FireflyClient() + + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.text = 'Bad request' + mock_response.request.url = 'https://firefly.example.com/api/v1/transactions' + + payload = {'amount': 'invalid'} + + # Should not raise exception, just log error and payload + client._handle_api_error(mock_response, payload) + + def test_handle_api_error_with_success_response(self): + """Test _handle_api_error with success response.""" + with patch('lampyrid.clients.firefly.settings') as mock_settings: + mock_settings.firefly_base_url = 'https://firefly.example.com' + mock_settings.firefly_token = 'test_token' + + client = FireflyClient() + + mock_response = MagicMock() + mock_response.status_code = 200 + + # Should not do anything for successful response + client._handle_api_error(mock_response) diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py new file mode 100644 index 0000000..1b79bbe --- /dev/null +++ b/tests/unit/test_main.py @@ -0,0 +1,115 @@ +"""Unit tests for __main__ module.""" + +from unittest.mock import patch + +from lampyrid.__main__ import main + + +class TestMainModule: + """Test cases for __main__ module.""" + + def test_main_stdio_transport(self): + """Test main() with stdio transport (default).""" + with ( + patch('lampyrid.__main__.settings') as mock_settings, + patch('lampyrid.__main__.mcp') as mock_mcp, + ): + # Mock settings for stdio + mock_settings.mcp_transport = 'stdio' + + # Call main + main() + + # Verify mcp.run was called with stdio + mock_mcp.run.assert_called_once_with(transport='stdio') + + def test_main_http_transport(self): + """Test main() with HTTP transport.""" + with ( + patch('lampyrid.__main__.settings') as mock_settings, + patch('lampyrid.__main__.mcp') as mock_mcp, + ): + # Mock settings for HTTP + mock_settings.mcp_transport = 'http' + mock_settings.mcp_host = '0.0.0.0' + mock_settings.mcp_port = 3000 + + # Call main + main() + + # Verify mcp.run was called with HTTP parameters + mock_mcp.run.assert_called_once_with( + transport='streamable-http', host='0.0.0.0', port=3000 + ) + + def test_main_sse_transport(self): + """Test main() with SSE transport.""" + with ( + patch('lampyrid.__main__.settings') as mock_settings, + patch('lampyrid.__main__.mcp') as mock_mcp, + ): + # Mock settings for SSE + mock_settings.mcp_transport = 'sse' + mock_settings.mcp_host = 'localhost' + mock_settings.mcp_port = 8080 + + # Call main + main() + + # Verify mcp.run was called with SSE parameters + mock_mcp.run.assert_called_once_with(transport='sse', host='localhost', port=8080) + + def test_main_unknown_transport(self): + """Test main() with unknown transport defaults to stdio.""" + with ( + patch('lampyrid.__main__.settings') as mock_settings, + patch('lampyrid.__main__.mcp') as mock_mcp, + ): + # Mock settings for unknown transport + mock_settings.mcp_transport = 'unknown' + + # Call main + main() + + # Verify mcp.run was called with default stdio + mock_mcp.run.assert_called_once_with(transport='stdio') + + def test_main_called_when_name_is_main(self): + """Test that main() is called when __name__ == '__main__'.""" + with ( + patch('lampyrid.__main__.settings') as mock_settings, + patch('lampyrid.__main__.mcp') as mock_mcp, + ): + # Mock settings + mock_settings.mcp_transport = 'stdio' + + # Call main when __name__ is __main__ + with patch('lampyrid.__main__.__name__', '__main__'): + main() + + # Verify mcp.run was called + mock_mcp.run.assert_called_once_with(transport='stdio') + + def test_main_not_called_when_name_is_not_main(self): + """Test that main() is NOT called when __name__ != '__main__'.""" + with ( + patch('lampyrid.__main__.settings') as mock_settings, + patch('lampyrid.__main__.mcp') as mock_mcp, + patch('lampyrid.__main__.main') as mock_main, + ): + # Mock settings + mock_settings.mcp_transport = 'stdio' + + # Set __name__ to something else + with patch('lampyrid.__main__.__name__', 'not_main'): + from lampyrid import __main__ as main_module + + # Import and run - this should trigger the if condition + main_module.main() + + # Verify main() was called (when run as module) + mock_main.assert_called_once() + + # But main() should NOT have been called during import + # (since __name__ was not '__main__') + mock_mcp.run.assert_not_called() diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py new file mode 100644 index 0000000..8158628 --- /dev/null +++ b/tests/unit/test_models.py @@ -0,0 +1,45 @@ +"""Unit tests for lampyrid models.""" + +import pytest + +from lampyrid.models.lampyrid_models import ( + SearchTransactionsRequest, + utc_now, +) + + +class TestLampyridModels: + """Test cases for lampyrid models.""" + + def test_utc_now(self): + """Test utc_now function returns a datetime with UTC timezone.""" + result = utc_now() + + # Should return a datetime object + assert hasattr(result, 'year') + assert hasattr(result, 'month') + assert hasattr(result, 'day') + assert hasattr(result, 'hour') + assert hasattr(result, 'minute') + assert hasattr(result, 'second') + + def test_search_transactions_request_with_no_criteria(self): + """Test SearchTransactionsRequest validation with no search criteria.""" + with pytest.raises(ValueError, match='At least one search criterion must be provided'): + SearchTransactionsRequest( + # No search fields provided + ) + + def test_search_transactions_request_with_empty_criteria(self): + """Test SearchTransactionsRequest validation with empty string criteria.""" + with pytest.raises(ValueError, match='At least one search criterion must be provided'): + SearchTransactionsRequest( + query='', # Empty string + ) + + def test_search_transactions_request_with_valid_criteria(self): + """Test SearchTransactionsRequest validation passes with valid criteria.""" + # Should not raise any exception + request = SearchTransactionsRequest(query='valid query') + + assert request.query == 'valid query' diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py new file mode 100644 index 0000000..ea59532 --- /dev/null +++ b/tests/unit/test_server.py @@ -0,0 +1,194 @@ +"""Unit tests for server initialization and configuration.""" + +from unittest.mock import MagicMock, patch + +import pytest +from cryptography.fernet import Fernet + +from lampyrid.server import _create_auth_provider, _initialize_server + + +class TestServer: + """Test cases for server initialization functions.""" + + def test_create_auth_provider_no_auth(self): + """Test _create_auth_provider returns None when auth is disabled.""" + with patch('lampyrid.server.settings') as mock_settings: + mock_settings.is_auth_enabled = False + + result = _create_auth_provider() + + assert result is None + + def test_create_auth_provider_without_persistence(self): + """Test _create_auth_provider creates GoogleProvider without persistence.""" + with patch('lampyrid.server.settings') as mock_settings: + mock_settings.is_auth_enabled = True + mock_settings.is_token_persistence_enabled = False + mock_settings.google_client_id = 'test_client_id' + mock_settings.google_client_secret = 'test_client_secret' + mock_settings.server_base_url = 'https://example.com' + mock_settings.jwt_signing_key = 'test_jwt_key' + + with patch('lampyrid.server.GoogleProvider') as mock_google_provider: + mock_google_provider.return_value = MagicMock() + + result = _create_auth_provider() + + assert result is not None + mock_google_provider.assert_called_once_with( + client_id='test_client_id', + client_secret='test_client_secret', + base_url='https://example.com', + required_scopes=[ + 'openid', + 'https://www.googleapis.com/auth/userinfo.email', + ], + jwt_signing_key='test_jwt_key', + client_storage=None, + ) + + def test_create_auth_provider_with_persistence(self): + """Test _create_auth_provider creates GoogleProvider with persistence.""" + with patch('lampyrid.server.settings') as mock_settings: + mock_settings.is_auth_enabled = True + mock_settings.is_token_persistence_enabled = True + mock_settings.google_client_id = 'test_client_id' + mock_settings.google_client_secret = 'test_client_secret' + mock_settings.server_base_url = 'https://example.com' + mock_settings.jwt_signing_key = 'test_jwt_key' + mock_settings.oauth_storage_encryption_key = Fernet.generate_key().decode() + mock_settings.oauth_storage_path = MagicMock() + mock_settings.oauth_storage_path.mkdir = MagicMock() + + with ( + patch('lampyrid.server.GoogleProvider') as mock_google_provider, + patch('lampyrid.server.DiskStore') as mock_disk_store, + patch('lampyrid.server.FernetEncryptionWrapper') as mock_encryption_wrapper, + ): + mock_google_provider.return_value = MagicMock() + mock_disk_store.return_value = MagicMock() + mock_encryption_wrapper.return_value = MagicMock() + + result = _create_auth_provider() + + assert result is not None + # Verify storage directory was created + mock_settings.oauth_storage_path.mkdir.assert_called_once_with( + parents=True, exist_ok=True + ) + # Verify disk store and encryption wrapper were initialized + mock_disk_store.assert_called_once_with(directory=mock_settings.oauth_storage_path) + mock_encryption_wrapper.assert_called_once() + # Verify GoogleProvider was called with client_storage + args, kwargs = mock_google_provider.call_args + assert 'client_storage' in kwargs + assert kwargs['client_storage'] is not None + + def test_initialize_server(self): + """Test _initialize_server creates and configures FastMCP server.""" + with ( + patch('lampyrid.server.settings') as mock_settings, + patch('lampyrid.server.FireflyClient') as mock_firefly_client, + patch('lampyrid.server.compose_all_servers') as mock_compose_servers, + patch('lampyrid.server.register_custom_routes') as mock_register_routes, + patch('lampyrid.server.FastMCP') as mock_fastmcp, + patch('lampyrid.server.configure_logging') as mock_configure_logging, + patch('lampyrid.server.get_assets_path') as mock_get_assets_path, + patch('lampyrid.server.asyncio.run') as mock_asyncio_run, + patch('lampyrid.server._create_auth_provider') as mock_create_auth, + ): + # Mock settings + mock_settings.logging_level = 'INFO' + + # Mock dependencies + mock_settings.is_auth_enabled = False + mock_create_auth.return_value = None + mock_firefly_client.return_value = MagicMock() + + # Mock the Image object to avoid file operations + with patch('lampyrid.server.Image') as mock_image: + mock_icon = MagicMock() + mock_icon.to_data_uri.return_value = 'data:image/png;base64,test' + mock_image.return_value = mock_icon + + mock_get_assets_path.return_value = '/path/to/favicon.png' + mock_fastmcp.return_value = MagicMock() + + # Call the function + result = _initialize_server() + + # Verify auth provider was created + mock_create_auth.assert_called_once() + + # Verify FastMCP was created with correct parameters + mock_fastmcp.assert_called_once() + fastmcp_args = mock_fastmcp.call_args + assert fastmcp_args[0][0] == 'lampyrid' # name + assert fastmcp_args[1]['auth'] is None + assert 'icons' in fastmcp_args[1] + + # Verify FireflyClient was created + mock_firefly_client.assert_called_once() + + # Verify logging was configured + mock_configure_logging.assert_called_once_with(level='INFO') + + # Verify servers were composed + mock_asyncio_run.assert_called_once() + mock_compose_servers.assert_called_once() + + # Verify custom routes were registered + mock_register_routes.assert_called_once() + + # Verify the server instance was returned + assert result is not None + + def test_initialize_server_with_auth(self): + """Test _initialize_server with authentication enabled.""" + with ( + patch('lampyrid.server.settings') as mock_settings, + patch('lampyrid.server.FireflyClient') as mock_firefly_client, + patch('lampyrid.server.compose_all_servers'), + patch('lampyrid.server.register_custom_routes'), + patch('lampyrid.server.FastMCP') as mock_fastmcp, + patch('lampyrid.server.configure_logging') as mock_configure_logging, + patch('lampyrid.server.get_assets_path') as mock_get_assets_path, + patch('lampyrid.server.asyncio.run'), + patch('lampyrid.server._create_auth_provider') as mock_create_auth, + ): + # Mock settings with auth enabled + mock_settings.logging_level = 'DEBUG' + mock_settings.is_auth_enabled = True + + auth_provider = MagicMock() + mock_create_auth.return_value = auth_provider + mock_firefly_client.return_value = MagicMock() + + # Mock the Image object to avoid file operations + with patch('lampyrid.server.Image') as mock_image: + mock_icon = MagicMock() + mock_icon.to_data_uri.return_value = 'data:image/png;base64,test' + mock_image.return_value = mock_icon + + mock_get_assets_path.return_value = '/path/to/favicon.png' + mock_fastmcp.return_value = MagicMock() + + # Call the function + _initialize_server() + + # Verify FastMCP was created with auth provider + mock_fastmcp.assert_called_once() + args, kwargs = mock_fastmcp.call_args + assert kwargs['auth'] is auth_provider + + # Verify logging was configured with correct level + mock_configure_logging.assert_called_once_with(level='DEBUG') + + @pytest.mark.skip(reason='Module-level execution is complex to test reliably') + def test_server_module_level_execution(self): + """Test that server module executes _initialize_server at module level.""" + # This test verifies the module-level code execution + # Skipping because module-level code is already executed during test imports + # and complex to mock reliably + pass diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py new file mode 100644 index 0000000..ded0083 --- /dev/null +++ b/tests/unit/test_utils.py @@ -0,0 +1,123 @@ +"""Unit tests for utility functions.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from fastmcp import FastMCP +from starlette.requests import Request + +from lampyrid.utils import get_assets_path, register_custom_routes, serve_favicon + + +class TestUtils: + """Test cases for utility functions.""" + + def test_get_assets_path(self): + """Test getting path to asset file.""" + with patch('lampyrid.utils.files') as mock_files: + # Mock the files function to return a mock resource + mock_resource = MagicMock() + mock_resource.joinpath.return_value = Path('/mock/assets/test.png') + mock_files.return_value = mock_resource + + result = get_assets_path('test.png') + + # Verify files('lampyrid') was called + mock_files.assert_called_once_with('lampyrid') + + # Verify joinpath was called with correct filename + mock_resource.joinpath.assert_called_once_with('assets', 'test.png') + + # Verify the result is a Path + assert isinstance(result, Path) + assert str(result) == '/mock/assets/test.png' + + @pytest.mark.asyncio + async def test_serve_favicon_file_exists(self): + """Test serving favicon when file exists.""" + with ( + patch('lampyrid.utils.get_assets_path') as mock_get_assets_path, + patch('lampyrid.utils.FileResponse') as mock_file_response, + ): + # Mock favicon path exists + mock_favicon_path = MagicMock() + mock_favicon_path.exists.return_value = True + mock_get_assets_path.return_value = mock_favicon_path + + mock_file_response.return_value = MagicMock() + + # Create a mock request + mock_request = MagicMock(spec=Request) + + # Call the function + result = await serve_favicon(mock_request) + + # Verify get_assets_path was called + mock_get_assets_path.assert_called_once_with('favicon.ico') + + # Verify FileResponse was called with the favicon path + mock_file_response.assert_called_once_with(mock_favicon_path, media_type='image/x-icon') + + # Verify the FileResponse is returned + assert result is mock_file_response.return_value + + @pytest.mark.asyncio + async def test_serve_favicon_file_not_exists(self): + """Test serving favicon when file doesn't exist.""" + with ( + patch('lampyrid.utils.get_assets_path') as mock_get_assets_path, + patch('lampyrid.utils.FileResponse') as mock_file_response, + patch('lampyrid.utils.JSONResponse') as mock_json_response, + ): + # Mock favicon path doesn't exist + mock_favicon_path = MagicMock() + mock_favicon_path.exists.return_value = False + mock_get_assets_path.return_value = mock_favicon_path + + mock_json_response.return_value = MagicMock() + + # Create a mock request + mock_request = MagicMock(spec=Request) + + # Call the function + result = await serve_favicon(mock_request) + + # Verify get_assets_path was called + mock_get_assets_path.assert_called_once_with('favicon.ico') + + # Verify FileResponse was not called + mock_file_response.assert_not_called() + + # Verify JSONResponse was called with error + mock_json_response.assert_called_once_with({'error': 'Not found'}, status_code=404) + + # Verify the JSONResponse is returned + assert result is mock_json_response.return_value + + @pytest.mark.asyncio + async def test_register_custom_routes(self): + """Test registering custom routes with FastMCP server.""" + with patch('lampyrid.utils.serve_favicon') as mock_serve_favicon: + # Create a mock FastMCP server + mock_mcp = MagicMock(spec=FastMCP) + + # Call the function + register_custom_routes(mock_mcp) + + # Verify custom_route was called with favicon endpoint + mock_mcp.custom_route.assert_called_once_with('/favicon.ico', methods=['GET']) + + # Verify that serve_favicon is referenced (even if not called directly) + # The fact that the function was imported and available is enough + assert mock_serve_favicon is not None + + # Check that serve_favicon was called + if mock_serve_favicon.called: + # Verify it was called with a Request object + call_args = mock_serve_favicon.call_args + assert len(call_args) > 0 + # The first argument should be a Request + request_arg = call_args[0][0] + # Should be a Request instance (or mock that acts like one) + assert hasattr(request_arg, 'method') or hasattr(request_arg, 'url') From 0a943cfa5753784ccafe36174d9a714ebdf8806d Mon Sep 17 00:00:00 2001 From: Radith Samarakoon Date: Sat, 17 Jan 2026 23:55:43 +0530 Subject: [PATCH 06/12] refactor(txs): enhance bulk operations with atomic mode - Add atomic/non-atomic modes to bulk creation with rollback support - Introduce BulkCreateResult and BulkUpdateResult models for detailed operation outcomes - Improve error handling with partial success reporting and failure details - Update integration tests to cover new atomic behaviors and edge cases - Modify docker-compose to build from local Dockerfile for development --- src/lampyrid/models/lampyrid_models.py | 45 +++ src/lampyrid/services/transactions.py | 154 ++++++-- src/lampyrid/tools/transactions.py | 17 +- tests/fixtures/transactions.py | 3 +- tests/integration/test_transactions.py | 467 +++++++++++++++++++++++-- 5 files changed, 621 insertions(+), 65 deletions(-) diff --git a/src/lampyrid/models/lampyrid_models.py b/src/lampyrid/models/lampyrid_models.py index 640c0d9..826812e 100644 --- a/src/lampyrid/models/lampyrid_models.py +++ b/src/lampyrid/models/lampyrid_models.py @@ -464,6 +464,44 @@ class GetTransactionRequest(BaseModel): id: str = Field(..., description='Unique identifier of the transaction to get details for') +class BulkOperationError(BaseModel): + """Error details for a failed operation in a bulk request.""" + + index: int = Field(..., description='Zero-based index of the failed item in the request') + transaction_id: Optional[str] = Field( + None, description='Transaction ID if available (for updates)' + ) + error: str = Field(..., description='Error message describing what went wrong') + + +class BulkCreateResult(BaseModel): + """Result of a bulk transaction creation operation.""" + + successful: List[Transaction] = Field( + default_factory=list, description='Transactions that were successfully created' + ) + failed: List[BulkOperationError] = Field( + default_factory=list, description='Errors for transactions that failed to create' + ) + total_requested: int = Field(..., description='Total number of transactions requested') + total_succeeded: int = Field(..., description='Number of transactions successfully created') + total_failed: int = Field(..., description='Number of transactions that failed') + + +class BulkUpdateResult(BaseModel): + """Result of a bulk transaction update operation.""" + + successful: List[Transaction] = Field( + default_factory=list, description='Transactions that were successfully updated' + ) + failed: List[BulkOperationError] = Field( + default_factory=list, description='Errors for transactions that failed to update' + ) + total_requested: int = Field(..., description='Total number of updates requested') + total_succeeded: int = Field(..., description='Number of transactions successfully updated') + total_failed: int = Field(..., description='Number of updates that failed') + + class TransactionListResponse(BaseModel): """Response model for transaction listings.""" @@ -609,6 +647,13 @@ class CreateBulkTransactionsRequest(BaseModel): min_length=1, max_length=100, ) + atomic: bool = Field( + default=True, + description=( + 'If True (default), all transactions are rolled back if any creation fails. ' + 'If False, continues on error and returns partial results.' + ), + ) @model_validator(mode='after') def validate_transactions(self): diff --git a/src/lampyrid/services/transactions.py b/src/lampyrid/services/transactions.py index 4dc3f93..9071e3f 100644 --- a/src/lampyrid/services/transactions.py +++ b/src/lampyrid/services/transactions.py @@ -15,6 +15,9 @@ TransactionUpdate, ) from ..models.lampyrid_models import ( + BulkCreateResult, + BulkOperationError, + BulkUpdateResult, BulkUpdateTransactionsRequest, CreateBulkTransactionsRequest, CreateDepositRequest, @@ -130,35 +133,104 @@ async def create_transfer(self, req: CreateTransferRequest) -> Transaction: async def create_bulk_transactions( self, req: CreateBulkTransactionsRequest - ) -> List[Transaction]: + ) -> BulkCreateResult: """Create multiple transactions in a single operation. - This method orchestrates the creation of multiple transactions, - handling the business logic for bulk operations while delegating - the individual HTTP requests to the FireflyClient. + Supports two modes: + - atomic=True (default): Rolls back all created transactions if any fails + - atomic=False: Continues on error and returns partial results Args: - req: Request containing multiple transaction details + req: Request containing transaction details and atomic flag Returns: - List of created transactions + BulkCreateResult with successful/failed transactions + + Raises: + Exception: In atomic mode if any creation fails (after rollback) + Exception: In non-atomic mode if ALL creations fail """ - created_transactions: List[Transaction] = [] - - for transaction in req.transactions: - trx_split = transaction.to_transaction_split_store() - trx_store = TransactionStore( - transactions=[trx_split], - apply_rules=False, - fire_webhooks=True, - group_title=None, - error_if_duplicate_hash=False, + if req.atomic: + return await self._create_bulk_atomic(req.transactions) + else: + return await self._create_bulk_non_atomic(req.transactions) + + async def _create_bulk_atomic(self, transactions: List[Transaction]) -> BulkCreateResult: + """Create transactions atomically - rollback all on any failure.""" + created: List[Transaction] = [] + created_ids: List[str] = [] + + try: + for idx, transaction in enumerate(transactions): + trx_split = transaction.to_transaction_split_store() + trx_store = TransactionStore( + transactions=[trx_split], + apply_rules=False, + fire_webhooks=True, + group_title=None, + error_if_duplicate_hash=False, + ) + transaction_single = await self._client.create_transaction(trx_store) + result = Transaction.from_transaction_single(transaction_single) + created.append(result) + if result.id: + created_ids.append(result.id) + except Exception as e: + # Rollback: delete all created transactions + rollback_failures: List[str] = [] + for txn_id in created_ids: + try: + await self._client.delete_transaction(txn_id) + except Exception as rollback_error: + rollback_failures.append(f'{txn_id}: {rollback_error}') + + error_msg = ( + f'Bulk creation failed at index {len(created_ids)}, ' + f'rolled back {len(created_ids)} transactions: {e}' ) - transaction_single = await self._client.create_transaction(trx_store) - created_transactions.append(Transaction.from_transaction_single(transaction_single)) + if rollback_failures: + error_msg += f' Rollback failures: {rollback_failures}' + raise Exception(error_msg) from e + + return BulkCreateResult( + successful=created, + failed=[], + total_requested=len(transactions), + total_succeeded=len(created), + total_failed=0, + ) + + async def _create_bulk_non_atomic(self, transactions: List[Transaction]) -> BulkCreateResult: + """Create transactions non-atomically - continue on error.""" + successful: List[Transaction] = [] + failed: List[BulkOperationError] = [] + + for idx, transaction in enumerate(transactions): + try: + trx_split = transaction.to_transaction_split_store() + trx_store = TransactionStore( + transactions=[trx_split], + apply_rules=False, + fire_webhooks=True, + group_title=None, + error_if_duplicate_hash=False, + ) + transaction_single = await self._client.create_transaction(trx_store) + successful.append(Transaction.from_transaction_single(transaction_single)) + except Exception as e: + failed.append(BulkOperationError(index=idx, error=str(e))) + + if len(failed) == len(transactions): + raise Exception(f'All {len(transactions)} transactions failed to create') - return created_transactions + return BulkCreateResult( + successful=successful, + failed=failed, + total_requested=len(transactions), + total_succeeded=len(successful), + total_failed=len(failed), + ) async def get_transaction(self, req: GetTransactionRequest) -> Transaction: """Get detailed information for a single transaction. @@ -308,33 +380,47 @@ async def update_transaction(self, req: UpdateTransactionRequest) -> Transaction async def bulk_update_transactions( self, req: BulkUpdateTransactionsRequest - ) -> List[Transaction]: + ) -> BulkUpdateResult: """Update multiple transactions in a single operation. - This method orchestrates the update of multiple transactions, - handling the business logic for bulk operations while delegating - the individual HTTP requests to the FireflyClient. + Continues processing on errors and returns partial results. Args: - req: Request containing multiple transaction updates + req: Request containing multiple transaction updates Returns: - List of updated transactions + BulkUpdateResult with successful/failed updates + + Raises: + Exception: If ALL updates fail """ - updated_transactions: List[Transaction] = [] + successful: List[Transaction] = [] + failed: List[BulkOperationError] = [] - for update_req in req.updates: + for idx, update_req in enumerate(req.updates): try: updated_transaction = await self.update_transaction(update_req) - updated_transactions.append(updated_transaction) + successful.append(updated_transaction) except Exception as e: - # Re-raise with transaction ID context - raise Exception( - f'Failed to update transaction {update_req.transaction_id}: {e}' - ) from e - - return updated_transactions + failed.append( + BulkOperationError( + index=idx, + transaction_id=update_req.transaction_id, + error=str(e), + ) + ) + + if len(failed) == len(req.updates): + raise Exception(f'All {len(req.updates)} transaction updates failed') + + return BulkUpdateResult( + successful=successful, + failed=failed, + total_requested=len(req.updates), + total_succeeded=len(successful), + total_failed=len(failed), + ) async def delete_transaction(self, req: DeleteTransactionRequest) -> bool: """Delete a transaction. diff --git a/src/lampyrid/tools/transactions.py b/src/lampyrid/tools/transactions.py index 6978f86..380a163 100644 --- a/src/lampyrid/tools/transactions.py +++ b/src/lampyrid/tools/transactions.py @@ -4,12 +4,12 @@ creating, retrieving, searching, updating, and deleting transactions. """ -from typing import List - from fastmcp import FastMCP from ..clients.firefly import FireflyClient from ..models.lampyrid_models import ( + BulkCreateResult, + BulkUpdateResult, BulkUpdateTransactionsRequest, CreateBulkTransactionsRequest, CreateDepositRequest, @@ -70,13 +70,13 @@ async def create_transfer(req: CreateTransferRequest) -> Transaction: return transaction @transactions_mcp.tool(tags={'transactions', 'create', 'bulk'}) - async def create_bulk_transactions(req: CreateBulkTransactionsRequest) -> List[Transaction]: + async def create_bulk_transactions(req: CreateBulkTransactionsRequest) -> BulkCreateResult: """Efficiently create multiple transactions in one operation. - Ideal for importing transaction batches, recording monthly bills, or processing CSV data. + By default (atomic=True), rolls back all transactions if any fail. + Set atomic=False to continue on error and return partial results. """ - transactions = await transaction_service.create_bulk_transactions(req) - return transactions + return await transaction_service.create_bulk_transactions(req) @transactions_mcp.tool(tags={'transactions', 'query'}) async def get_transaction(req: GetTransactionRequest) -> Transaction: @@ -123,10 +123,11 @@ async def update_transaction(req: UpdateTransactionRequest) -> Transaction: return await transaction_service.update_transaction(req) @transactions_mcp.tool(tags={'transactions', 'manage', 'bulk'}) - async def bulk_update_transactions(req: BulkUpdateTransactionsRequest) -> List[Transaction]: + async def bulk_update_transactions(req: BulkUpdateTransactionsRequest) -> BulkUpdateResult: """Efficiently update multiple transactions in one operation. - Ideal for batch account changes, budget reassignments, or correcting imported data. + Continues on error and returns partial results with error details. + Raises exception only if ALL updates fail. """ return await transaction_service.bulk_update_transactions(req) diff --git a/tests/fixtures/transactions.py b/tests/fixtures/transactions.py index b1955d6..8f9fe68 100644 --- a/tests/fixtures/transactions.py +++ b/tests/fixtures/transactions.py @@ -83,9 +83,10 @@ def make_create_transfer_request( def make_create_bulk_transactions_request( transactions: List[Transaction], + atomic: bool = True, ) -> CreateBulkTransactionsRequest: """Create a CreateBulkTransactionsRequest for testing.""" - return CreateBulkTransactionsRequest(transactions=transactions) + return CreateBulkTransactionsRequest(transactions=transactions, atomic=atomic) def make_get_transaction_request(transaction_id: str) -> GetTransactionRequest: diff --git a/tests/integration/test_transactions.py b/tests/integration/test_transactions.py index f71d2b2..83abcba 100644 --- a/tests/integration/test_transactions.py +++ b/tests/integration/test_transactions.py @@ -9,7 +9,15 @@ from fastmcp.exceptions import ToolError from inline_snapshot import snapshot -from lampyrid.models.lampyrid_models import Account, Budget, Transaction, TransactionListResponse +from lampyrid.models.firefly_models import TransactionTypeProperty +from lampyrid.models.lampyrid_models import ( + Account, + Budget, + BulkCreateResult, + BulkUpdateResult, + Transaction, + TransactionListResponse, +) # ==================== Create Operations ==================== @@ -242,22 +250,27 @@ async def test_create_bulk_transactions( result = await mcp_client.call_tool( 'create_bulk_transactions', {'req': {'transactions': transactions}} ) - created = result.data - - # Add all to cleanup - for txn in created: - assert txn is not None - assert txn['id'] is not None - transaction_cleanup.append(txn['id']) - - # Verify all transactions were created - assert len(created) == 3 - assert created[0] == snapshot( + bulk_result = BulkCreateResult.model_validate(result.structured_content) + + # Add successful transactions to cleanup + for txn in bulk_result.successful: + assert txn.id is not None + transaction_cleanup.append(txn.id) + + # Verify all transactions were created successfully + assert bulk_result.total_requested == 3 + assert bulk_result.total_succeeded == 3 + assert bulk_result.total_failed == 0 + assert len(bulk_result.successful) == 3 + assert len(bulk_result.failed) == 0 + + # Verify transaction details using snapshots + assert bulk_result.successful[0].model_dump() == snapshot( { 'id': IsStr(min_length=1), 'amount': 5.0, 'description': 'Bulk test - transaction 1', - 'type': 'withdrawal', + 'type': TransactionTypeProperty.withdrawal, 'date': IsDatetime(iso_string=True), 'source_id': '1', 'destination_id': '5', @@ -268,12 +281,12 @@ async def test_create_bulk_transactions( 'budget_name': None, } ) - assert created[1] == snapshot( + assert bulk_result.successful[1].model_dump() == snapshot( { 'id': IsStr(min_length=1), 'amount': 100.0, 'description': 'Bulk test - transaction 2', - 'type': 'deposit', + 'type': TransactionTypeProperty.deposit, 'date': IsDatetime(iso_string=True), 'source_id': '7', 'destination_id': '1', @@ -284,12 +297,12 @@ async def test_create_bulk_transactions( 'budget_name': None, } ) - assert created[2] == snapshot( + assert bulk_result.successful[2].model_dump() == snapshot( { 'id': IsStr(min_length=1), 'amount': 15.0, 'description': 'Bulk test - transaction 3', - 'type': 'withdrawal', + 'type': TransactionTypeProperty.withdrawal, 'date': IsDatetime(iso_string=True), 'source_id': '1', 'destination_id': '5', @@ -1062,6 +1075,414 @@ async def test_bulk_update_transactions_with_failure( transaction_cleanup.append(created2.id) # Try to bulk update where one update will fail (using non-existent transaction) + result = await mcp_client.call_tool( + 'bulk_update_transactions', + { + 'req': { + 'updates': [ + { + 'transaction_id': created1.id, + 'description': 'Updated successfully', + }, + { + 'transaction_id': '999999', # Non-existent transaction + 'description': 'This should fail', + }, + ] + } + }, + ) + + bulk_result = BulkUpdateResult.model_validate(result.structured_content) + + # Verify partial success + assert bulk_result.total_requested == 2 + assert bulk_result.total_succeeded == 1 + assert bulk_result.total_failed == 1 + assert len(bulk_result.successful) == 1 + assert len(bulk_result.failed) == 1 + + # Verify successful update + updated_txn = bulk_result.successful[0] + assert updated_txn.id == created1.id + assert updated_txn.description == 'Updated successfully' + + # Verify failure details + failure = bulk_result.failed[0] + assert failure.index == 1 # Second update failed + assert failure.transaction_id == '999999' + assert '404' in failure.error and 'not found' in failure.error.lower() + + assert bulk_result.model_dump() == snapshot( + { + 'successful': [ + { + 'id': IsStr(min_length=1), + 'amount': 5.0, + 'description': 'Updated successfully', + 'type': TransactionTypeProperty.withdrawal, + 'date': IsDatetime(iso_string=True), + 'source_id': '1', + 'destination_id': '5', + 'source_name': 'Test Checking', + 'destination_name': 'Test Expense', + 'currency_code': 'USD', + 'budget_id': None, + 'budget_name': None, + } + ], + 'failed': [ + { + 'index': 1, + 'transaction_id': '999999', + 'error': """\ +Client error '404 Not Found' for url 'http://localhost:8080/api/v1/transactions/999999' +For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404\ +""", + } + ], + 'total_requested': 2, + 'total_succeeded': 1, + 'total_failed': 1, + } + ) + + +@pytest.mark.asyncio +@pytest.mark.transactions +@pytest.mark.integration +async def test_create_bulk_transactions_atomic_rollback( + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, +): + """Test atomic bulk create with rollback when middle transaction fails.""" + transactions = [ + { + 'amount': 5.00, + 'description': 'Valid transaction 1', + 'type': 'withdrawal', + 'date': datetime.now(timezone.utc).isoformat(), + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + }, + { + 'amount': -10.00, # Invalid negative amount + 'description': 'Invalid transaction with negative amount', + 'type': 'withdrawal', + 'date': datetime.now(timezone.utc).isoformat(), + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + }, + { + 'amount': 15.00, + 'description': 'Valid transaction 2', + 'type': 'withdrawal', + 'date': datetime.now(timezone.utc).isoformat(), + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + }, + ] + + # Should fail with rollback + with pytest.raises(ToolError) as exc_info: + await mcp_client.call_tool( + 'create_bulk_transactions', {'req': {'transactions': transactions, 'atomic': True}} + ) + + assert 'rolled back 1' in str(exc_info.value) and 'failed at index 1' in str(exc_info.value) + + +@pytest.mark.asyncio +@pytest.mark.transactions +@pytest.mark.integration +async def test_create_bulk_transactions_non_atomic_partial_success( + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, + transaction_cleanup: List[str], +): + """Test non-atomic bulk create with partial success.""" + transactions = [ + { + 'amount': 5.00, + 'description': 'Valid transaction 1', + 'type': 'withdrawal', + 'date': datetime.now(timezone.utc).isoformat(), + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + }, + { + 'amount': -10.00, # Invalid negative amount + 'description': 'Invalid transaction with negative amount', + 'type': 'withdrawal', + 'date': datetime.now(timezone.utc).isoformat(), + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + }, + { + 'amount': 15.00, + 'description': 'Valid transaction 2', + 'type': 'withdrawal', + 'date': datetime.now(timezone.utc).isoformat(), + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + }, + ] + + result = await mcp_client.call_tool( + 'create_bulk_transactions', {'req': {'transactions': transactions, 'atomic': False}} + ) + bulk_result = BulkCreateResult.model_validate(result.structured_content) + + # Add successful transactions to cleanup + for txn in bulk_result.successful: + if txn.id: + transaction_cleanup.append(txn.id) + + # Verify partial success + assert bulk_result.total_requested == 3 + assert bulk_result.total_succeeded == 2 + assert bulk_result.total_failed == 1 + assert len(bulk_result.successful) == 2 + assert len(bulk_result.failed) == 1 + + # Verify failure details + failure = bulk_result.failed[0] + assert failure.index == 1 # Second transaction failed + assert 'unprocessable' in failure.error.lower() + + assert bulk_result.model_dump() == snapshot( + { + 'successful': [ + { + 'id': IsStr(min_length=1), + 'amount': 5.0, + 'description': 'Valid transaction 1', + 'type': TransactionTypeProperty.withdrawal, + 'date': IsDatetime(iso_string=True), + 'source_id': '1', + 'destination_id': '5', + 'source_name': 'Test Checking', + 'destination_name': 'Test Expense', + 'currency_code': 'USD', + 'budget_id': None, + 'budget_name': None, + }, + { + 'id': IsStr(min_length=1), + 'amount': 15.0, + 'description': 'Valid transaction 2', + 'type': TransactionTypeProperty.withdrawal, + 'date': IsDatetime(iso_string=True), + 'source_id': '1', + 'destination_id': '5', + 'source_name': 'Test Checking', + 'destination_name': 'Test Expense', + 'currency_code': 'USD', + 'budget_id': None, + 'budget_name': None, + }, + ], + 'failed': [ + { + 'index': 1, + 'transaction_id': None, + 'error': """\ +Client error '422 Unprocessable Content' for url 'http://localhost:8080/api/v1/transactions' +For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422\ +""", + } + ], + 'total_requested': 3, + 'total_succeeded': 2, + 'total_failed': 1, + } + ) + + +@pytest.mark.asyncio +@pytest.mark.transactions +@pytest.mark.integration +async def test_create_bulk_transactions_non_atomic_all_fail( + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, +): + """Test non-atomic bulk create where all transactions fail.""" + transactions = [ + { + 'amount': -10.00, # Invalid negative amount + 'description': 'Invalid transaction 1', + 'type': 'withdrawal', + 'date': datetime.now(timezone.utc).isoformat(), + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + }, + { + 'amount': -20.00, # Invalid negative amount + 'description': 'Invalid transaction 2', + 'type': 'withdrawal', + 'date': datetime.now(timezone.utc).isoformat(), + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + }, + ] + + # Should fail since all transactions failed + with pytest.raises(ToolError) as exc_info: + await mcp_client.call_tool( + 'create_bulk_transactions', {'req': {'transactions': transactions, 'atomic': False}} + ) + + assert str(exc_info.value) == snapshot( + "Error calling tool 'create_bulk_transactions': All 2 transactions failed to create" + ) + + +@pytest.mark.asyncio +@pytest.mark.transactions +@pytest.mark.integration +async def test_bulk_update_transactions_partial_success( + mcp_client: Client, + test_asset_account: Account, + test_expense_account: str, + transaction_cleanup: List[str], +): + """Test bulk update transactions with partial success.""" + # Create two transactions first + create_result1 = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 5.00, + 'description': 'Test bulk update partial 1', + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + created1 = Transaction.model_validate(create_result1.structured_content) + if created1.id: + transaction_cleanup.append(created1.id) + + create_result2 = await mcp_client.call_tool( + 'create_withdrawal', + { + 'req': { + 'amount': 6.00, + 'description': 'Test bulk update partial 2', + 'source_id': test_asset_account.id, + 'destination_name': test_expense_account, + 'date': datetime.now(timezone.utc).isoformat(), + } + }, + ) + created2 = Transaction.model_validate(create_result2.structured_content) + if created2.id: + transaction_cleanup.append(created2.id) + + # Update 3 transactions where 1 will fail + result = await mcp_client.call_tool( + 'bulk_update_transactions', + { + 'req': { + 'updates': [ + { + 'transaction_id': created1.id, + 'description': 'Updated successfully 1', + }, + { + 'transaction_id': '999999', # Non-existent transaction + 'description': 'This should fail', + }, + { + 'transaction_id': created2.id, + 'description': 'Updated successfully 2', + }, + ] + } + }, + ) + + bulk_result = BulkUpdateResult.model_validate(result.structured_content) + + # Verify partial success + assert bulk_result.total_requested == 3 + assert bulk_result.total_succeeded == 2 + assert bulk_result.total_failed == 1 + assert len(bulk_result.successful) == 2 + assert len(bulk_result.failed) == 1 + + # Verify successful updates + descriptions = [txn.description for txn in bulk_result.successful] + assert 'Updated successfully 1' in descriptions + assert 'Updated successfully 2' in descriptions + + # Verify failure details + failure = bulk_result.failed[0] + assert failure.index == 1 # Second update failed + assert failure.transaction_id == '999999' + assert '404' in failure.error or 'not found' in failure.error.lower() + + assert bulk_result.model_dump() == snapshot( + { + 'successful': [ + { + 'id': IsStr(min_length=1), + 'amount': 5.0, + 'description': 'Updated successfully 1', + 'type': TransactionTypeProperty.withdrawal, + 'date': IsDatetime(iso_string=True), + 'source_id': '1', + 'destination_id': '5', + 'source_name': 'Test Checking', + 'destination_name': 'Test Expense', + 'currency_code': 'USD', + 'budget_id': None, + 'budget_name': None, + }, + { + 'id': IsStr(min_length=1), + 'amount': 6.0, + 'description': 'Updated successfully 2', + 'type': TransactionTypeProperty.withdrawal, + 'date': IsDatetime(iso_string=True), + 'source_id': '1', + 'destination_id': '5', + 'source_name': 'Test Checking', + 'destination_name': 'Test Expense', + 'currency_code': 'USD', + 'budget_id': None, + 'budget_name': None, + }, + ], + 'failed': [ + { + 'index': 1, + 'transaction_id': '999999', + 'error': """\ +Client error '404 Not Found' for url 'http://localhost:8080/api/v1/transactions/999999' +For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404\ +""", + } + ], + 'total_requested': 3, + 'total_succeeded': 2, + 'total_failed': 1, + } + ) + + +@pytest.mark.asyncio +@pytest.mark.transactions +@pytest.mark.integration +async def test_bulk_update_transactions_all_fail( + mcp_client: Client, +): + """Test bulk update where all updates fail.""" + # Should fail since all updates are for non-existent transactions with pytest.raises(ToolError) as exc_info: await mcp_client.call_tool( 'bulk_update_transactions', @@ -1069,16 +1490,18 @@ async def test_bulk_update_transactions_with_failure( 'req': { 'updates': [ { - 'transaction_id': created1.id, - 'description': 'Updated successfully', + 'transaction_id': '999999', # Non-existent transaction + 'description': 'This should fail 1', }, { - 'transaction_id': '999999', # Non-existent transaction - 'description': 'This should fail', + 'transaction_id': '888888', # Non-existent transaction + 'description': 'This should fail 2', }, ] } }, ) - assert 'Failed to update transaction 999999' in str(exc_info.value) + assert str(exc_info.value) == snapshot( + "Error calling tool 'bulk_update_transactions': All 2 transaction updates failed" + ) From 144147aa058860925ed36ad75b895fe8491ddb5f Mon Sep 17 00:00:00 2001 From: Radith Samarakoon Date: Sun, 18 Jan 2026 00:59:00 +0530 Subject: [PATCH 07/12] refactor(tests): remove unused test --- tests/unit/test_server.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index ea59532..ed36d74 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -184,11 +184,3 @@ def test_initialize_server_with_auth(self): # Verify logging was configured with correct level mock_configure_logging.assert_called_once_with(level='DEBUG') - - @pytest.mark.skip(reason='Module-level execution is complex to test reliably') - def test_server_module_level_execution(self): - """Test that server module executes _initialize_server at module level.""" - # This test verifies the module-level code execution - # Skipping because module-level code is already executed during test imports - # and complex to mock reliably - pass From dd7c06b9fbba31f1296e526b0fc369324b81c35e Mon Sep 17 00:00:00 2001 From: Radith Samarakoon Date: Sun, 18 Jan 2026 01:03:31 +0530 Subject: [PATCH 08/12] refactor(client): add async context manager support to FireflyClient - Add aclose(), __aenter__(), and __aexit__() methods for proper resource management - Update test fixtures to use the new aclose() method - Add unit tests for async context manager behavior and exception handling --- src/lampyrid/clients/firefly.py | 16 +++++++++++ tests/conftest.py | 4 +-- tests/unit/test_firefly_client.py | 47 +++++++++++++++++++++++++++++++ tests/unit/test_server.py | 1 - 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/lampyrid/clients/firefly.py b/src/lampyrid/clients/firefly.py index 68391cf..4d6e7e9 100644 --- a/src/lampyrid/clients/firefly.py +++ b/src/lampyrid/clients/firefly.py @@ -42,6 +42,22 @@ def __init__(self) -> None: timeout=30.0, ) + async def aclose(self) -> None: + """Close the underlying HTTP client. + + Should be called when the client is no longer needed to release resources. + Alternatively, use the client as an async context manager. + """ + await self._client.aclose() + + async def __aenter__(self) -> 'FireflyClient': + """Async context manager entry.""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + """Async context manager exit - close the client.""" + await self.aclose() + def _serialize_model(self, model: Any, exclude_unset: bool = False) -> Dict[str, Any]: """Serialize a Pydantic model to dict, excluding None values by default. diff --git a/tests/conftest.py b/tests/conftest.py index 22546e3..5be4a66 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -195,7 +195,7 @@ async def _setup_test_data(): _cached_test_budgets.append(test_budget) finally: - await client._client.aclose() + await client.aclose() @pytest.fixture(scope='function') @@ -218,7 +218,7 @@ async def firefly_client(): # Explicitly close the client to clean up connections try: - await client._client.aclose() + await client.aclose() except Exception: pass # Ignore errors during cleanup diff --git a/tests/unit/test_firefly_client.py b/tests/unit/test_firefly_client.py index ed68ebb..9cea229 100644 --- a/tests/unit/test_firefly_client.py +++ b/tests/unit/test_firefly_client.py @@ -402,3 +402,50 @@ def test_handle_api_error_with_success_response(self): # Should not do anything for successful response client._handle_api_error(mock_response) + + @pytest.mark.asyncio + async def test_aclose(self, mock_client): + """Test aclose method closes the underlying HTTP client.""" + client, mock_http_client, _ = mock_client + + await client.aclose() + + # Verify the underlying client's aclose was called + mock_http_client.aclose.assert_called_once() + + @pytest.mark.asyncio + async def test_async_context_manager(self): + """Test FireflyClient can be used as an async context manager.""" + with patch('lampyrid.clients.firefly.httpx.AsyncClient') as mock_http_client: + with patch('lampyrid.clients.firefly.settings') as mock_settings: + mock_settings.firefly_base_url = 'https://firefly.example.com' + mock_settings.firefly_token = 'test_token' + + mock_client_instance = AsyncMock() + mock_http_client.return_value = mock_client_instance + + async with FireflyClient() as client: + # Verify we get the client instance + assert isinstance(client, FireflyClient) + + # Verify aclose was called when exiting the context + mock_client_instance.aclose.assert_called_once() + + @pytest.mark.asyncio + async def test_async_context_manager_closes_on_exception(self): + """Test that async context manager closes client even when exception occurs.""" + with patch('lampyrid.clients.firefly.httpx.AsyncClient') as mock_http_client: + with patch('lampyrid.clients.firefly.settings') as mock_settings: + mock_settings.firefly_base_url = 'https://firefly.example.com' + mock_settings.firefly_token = 'test_token' + + mock_client_instance = AsyncMock() + mock_http_client.return_value = mock_client_instance + + with pytest.raises(ValueError): + async with FireflyClient() as client: + assert client is not None + raise ValueError('Test exception') + + # Verify aclose was still called despite the exception + mock_client_instance.aclose.assert_called_once() diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index ed36d74..4530b98 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -2,7 +2,6 @@ from unittest.mock import MagicMock, patch -import pytest from cryptography.fernet import Fernet from lampyrid.server import _create_auth_provider, _initialize_server From d8842f00d97280728bcaeb3ae88c1f1d4b216924 Mon Sep 17 00:00:00 2001 From: Radith Samarakoon Date: Sun, 18 Jan 2026 01:13:43 +0530 Subject: [PATCH 09/12] refactor(services): sanitize user inputs in transaction search queries Add input sanitization to search_transactions method in TransactionService to escape special characters in user-provided values for description_contains, category, budget, and account_contains. Include comprehensive unit tests for sanitization logic and query construction. --- src/lampyrid/services/transactions.py | 18 +- tests/unit/test_transactions_service.py | 262 ++++++++++++++++++++++++ 2 files changed, 273 insertions(+), 7 deletions(-) create mode 100644 tests/unit/test_transactions_service.py diff --git a/src/lampyrid/services/transactions.py b/src/lampyrid/services/transactions.py index 9071e3f..8259041 100644 --- a/src/lampyrid/services/transactions.py +++ b/src/lampyrid/services/transactions.py @@ -312,19 +312,23 @@ async def search_transactions(self, req: SearchTransactionsRequest) -> Transacti if req.date_before: query_parts.append(f'date_before:{req.date_before}') - # Content filters + # Content filters - sanitize user-provided values to escape special characters if req.description_contains: - query_parts.append(f'description_contains:"{req.description_contains}"') + sanitized = FireflyClient._sanitize_value(req.description_contains) + query_parts.append(f'description_contains:{sanitized}') - # Metadata filters + # Metadata filters - sanitize user-provided values to escape special characters if req.category: - query_parts.append(f'category_is:"{req.category}"') + sanitized = FireflyClient._sanitize_value(req.category) + query_parts.append(f'category_is:{sanitized}') if req.budget: - query_parts.append(f'budget_is:"{req.budget}"') + sanitized = FireflyClient._sanitize_value(req.budget) + query_parts.append(f'budget_is:{sanitized}') - # Account filters + # Account filters - sanitize user-provided values to escape special characters if req.account_contains: - query_parts.append(f'account_contains:"{req.account_contains}"') + sanitized = FireflyClient._sanitize_value(req.account_contains) + query_parts.append(f'account_contains:{sanitized}') if req.account_id is not None: query_parts.append(f'account_id:{req.account_id}') diff --git a/tests/unit/test_transactions_service.py b/tests/unit/test_transactions_service.py new file mode 100644 index 0000000..2c9525b --- /dev/null +++ b/tests/unit/test_transactions_service.py @@ -0,0 +1,262 @@ +"""Unit tests for TransactionService.""" + +from datetime import date +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from lampyrid.models.lampyrid_models import SearchTransactionsRequest +from lampyrid.services.transactions import TransactionService + + +class TestTransactionServiceSearchSanitization: + """Test cases for search_transactions input sanitization.""" + + @pytest.fixture + def mock_client(self): + """Create a mock FireflyClient.""" + client = MagicMock() + client.search_transactions = AsyncMock() + # Return a mock TransactionArray with required structure + mock_response = MagicMock() + mock_response.data = [] + mock_response.meta = MagicMock() + mock_response.meta.pagination = MagicMock() + mock_response.meta.pagination.total = 0 + mock_response.meta.pagination.count = 0 + mock_response.meta.pagination.per_page = 50 + mock_response.meta.pagination.current_page = 1 + mock_response.meta.pagination.total_pages = 0 + client.search_transactions.return_value = mock_response + return client + + @pytest.fixture + def service(self, mock_client): + """Create a TransactionService with mock client.""" + return TransactionService(mock_client) + + def _get_query_from_call(self, mock_client) -> str: + """Extract the query string passed to search_transactions.""" + call_args = mock_client.search_transactions.call_args + return call_args.kwargs.get('query', call_args.args[0] if call_args.args else '') + + @pytest.mark.asyncio + async def test_search_transactions_sanitizes_description_contains(self, service, mock_client): + """Test that description_contains values are properly sanitized.""" + req = SearchTransactionsRequest(description_contains='test "quoted" value') + + await service.search_transactions(req) + + query = self._get_query_from_call(mock_client) + # Should escape quotes and wrap in quotes since it contains special chars + assert 'description_contains:"test \\"quoted\\" value"' in query + + @pytest.mark.asyncio + async def test_search_transactions_sanitizes_category(self, service, mock_client): + """Test that category values are properly sanitized.""" + req = SearchTransactionsRequest(category='Food & "Dining"') + + await service.search_transactions(req) + + query = self._get_query_from_call(mock_client) + # Should escape quotes and wrap in quotes since it contains special chars + assert 'category_is:"Food & \\"Dining\\""' in query + + @pytest.mark.asyncio + async def test_search_transactions_sanitizes_budget(self, service, mock_client): + """Test that budget values are properly sanitized.""" + req = SearchTransactionsRequest(budget='Monthly "Expenses"') + + await service.search_transactions(req) + + query = self._get_query_from_call(mock_client) + # Should escape quotes and wrap in quotes since it contains special chars + assert 'budget_is:"Monthly \\"Expenses\\""' in query + + @pytest.mark.asyncio + async def test_search_transactions_sanitizes_account_contains(self, service, mock_client): + """Test that account_contains values are properly sanitized.""" + req = SearchTransactionsRequest(account_contains='Bank "Account"') + + await service.search_transactions(req) + + query = self._get_query_from_call(mock_client) + # Should escape quotes and wrap in quotes since it contains special chars + assert 'account_contains:"Bank \\"Account\\""' in query + + @pytest.mark.asyncio + async def test_search_transactions_handles_backslashes_in_values(self, service, mock_client): + """Test that backslashes in values are properly escaped.""" + req = SearchTransactionsRequest(description_contains='path\\to\\file') + + await service.search_transactions(req) + + query = self._get_query_from_call(mock_client) + # Backslashes should be escaped (doubled) + assert 'description_contains:path\\\\to\\\\file' in query + + @pytest.mark.asyncio + async def test_search_transactions_handles_spaces_in_values(self, service, mock_client): + """Test that values with spaces are properly quoted.""" + req = SearchTransactionsRequest(category='Food and Drinks') + + await service.search_transactions(req) + + query = self._get_query_from_call(mock_client) + # Should be wrapped in quotes due to spaces + assert 'category_is:"Food and Drinks"' in query + + @pytest.mark.asyncio + async def test_search_transactions_simple_values_not_quoted(self, service, mock_client): + """Test that simple values without special chars are not unnecessarily quoted.""" + req = SearchTransactionsRequest(category='Groceries') + + await service.search_transactions(req) + + query = self._get_query_from_call(mock_client) + # Should NOT be wrapped in quotes (no special chars) + assert 'category_is:Groceries' in query + assert 'category_is:"Groceries"' not in query + + @pytest.mark.asyncio + async def test_search_transactions_handles_mixed_special_chars(self, service, mock_client): + """Test values with both quotes and backslashes.""" + req = SearchTransactionsRequest(description_contains='test "quoted" and\\backslash') + + await service.search_transactions(req) + + query = self._get_query_from_call(mock_client) + # Both quotes and backslashes should be escaped, wrapped in quotes + assert 'description_contains:"test \\"quoted\\" and\\\\backslash"' in query + + @pytest.mark.asyncio + async def test_search_transactions_handles_single_quotes(self, service, mock_client): + """Test that single quotes trigger quoting (per _sanitize_value logic).""" + req = SearchTransactionsRequest(budget="don't spend") + + await service.search_transactions(req) + + query = self._get_query_from_call(mock_client) + # Should be wrapped in quotes due to single quote + assert 'budget_is:"don\'t spend"' in query + + @pytest.mark.asyncio + async def test_search_transactions_multiple_sanitized_fields(self, service, mock_client): + """Test that multiple fields are all properly sanitized.""" + req = SearchTransactionsRequest( + description_contains='desc "test"', + category='cat "test"', + budget='budget "test"', + account_contains='account "test"', + ) + + await service.search_transactions(req) + + query = self._get_query_from_call(mock_client) + # All fields should be properly sanitized + assert 'description_contains:"desc \\"test\\""' in query + assert 'category_is:"cat \\"test\\""' in query + assert 'budget_is:"budget \\"test\\""' in query + assert 'account_contains:"account \\"test\\""' in query + + @pytest.mark.asyncio + async def test_search_transactions_non_string_fields_unchanged(self, service, mock_client): + """Test that non-string fields like account_id are not affected.""" + req = SearchTransactionsRequest(account_id='123') + + await service.search_transactions(req) + + query = self._get_query_from_call(mock_client) + # account_id should be formatted as-is (string ID) + assert 'account_id:123' in query + + +class TestTransactionServiceSearchQueryConstruction: + """Test cases for search_transactions query construction.""" + + @pytest.fixture + def mock_client(self): + """Create a mock FireflyClient.""" + client = MagicMock() + client.search_transactions = AsyncMock() + mock_response = MagicMock() + mock_response.data = [] + mock_response.meta = MagicMock() + mock_response.meta.pagination = MagicMock() + mock_response.meta.pagination.total = 0 + mock_response.meta.pagination.count = 0 + mock_response.meta.pagination.per_page = 50 + mock_response.meta.pagination.current_page = 1 + mock_response.meta.pagination.total_pages = 0 + client.search_transactions.return_value = mock_response + return client + + @pytest.fixture + def service(self, mock_client): + """Create a TransactionService with mock client.""" + return TransactionService(mock_client) + + def _get_query_from_call(self, mock_client) -> str: + """Extract the query string passed to search_transactions.""" + call_args = mock_client.search_transactions.call_args + return call_args.kwargs.get('query', call_args.args[0] if call_args.args else '') + + @pytest.mark.asyncio + async def test_search_transactions_with_raw_query(self, service, mock_client): + """Test that raw query is included in the final query.""" + req = SearchTransactionsRequest(query='some raw query') + + await service.search_transactions(req) + + query = self._get_query_from_call(mock_client) + assert 'some raw query' in query + + @pytest.mark.asyncio + async def test_search_transactions_with_type_filter(self, service, mock_client): + """Test that type filter is properly formatted.""" + req = SearchTransactionsRequest(type='withdrawal') + + await service.search_transactions(req) + + query = self._get_query_from_call(mock_client) + assert 'type:withdrawal' in query + + @pytest.mark.asyncio + async def test_search_transactions_with_amount_filters(self, service, mock_client): + """Test that amount filters are properly formatted.""" + req = SearchTransactionsRequest(amount_equals=100.50, amount_more=50.0, amount_less=200.0) + + await service.search_transactions(req) + + query = self._get_query_from_call(mock_client) + assert 'amount:100.5' in query + assert 'more:50.0' in query + assert 'less:200.0' in query + + @pytest.mark.asyncio + async def test_search_transactions_with_date_filters(self, service, mock_client): + """Test that date filters are properly formatted.""" + req = SearchTransactionsRequest( + date_on=date(2024, 1, 15), + date_after=date(2024, 1, 1), + date_before=date(2024, 1, 31), + ) + + await service.search_transactions(req) + + query = self._get_query_from_call(mock_client) + assert 'date_on:2024-01-15' in query + assert 'date_after:2024-01-01' in query + assert 'date_before:2024-01-31' in query + + @pytest.mark.asyncio + async def test_search_transactions_passes_pagination_params(self, service, mock_client): + """Test that pagination parameters are passed to client.""" + req = SearchTransactionsRequest(query='test', page=2, limit=25) + + await service.search_transactions(req) + + mock_client.search_transactions.assert_called_once() + call_kwargs = mock_client.search_transactions.call_args.kwargs + assert call_kwargs['page'] == 2 + assert call_kwargs['limit'] == 25 From ae1c57af93ebccba6a0e00b5018255e988a19746 Mon Sep 17 00:00:00 2001 From: Radith Samarakoon Date: Sun, 18 Jan 2026 01:20:29 +0530 Subject: [PATCH 10/12] fix(tests): update parameter names in get_budget_spending test --- tests/integration/test_budgets.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_budgets.py b/tests/integration/test_budgets.py index 47c7439..29e13b7 100644 --- a/tests/integration/test_budgets.py +++ b/tests/integration/test_budgets.py @@ -68,7 +68,13 @@ async def test_get_budget_spending(mcp_client: Client, test_budget: Budget): result = await mcp_client.call_tool( 'get_budget_spending', - {'req': {'budget_id': test_budget.id, 'start': start.isoformat(), 'end': end.isoformat()}}, + { + 'req': { + 'budget_id': test_budget.id, + 'start_date': start.isoformat(), + 'end_date': end.isoformat(), + } + }, ) spending = BudgetSpending.model_validate(result.structured_content) From 913be3e8006be56a474b7e903d0256739b4fa6a2 Mon Sep 17 00:00:00 2001 From: Radith Samarakoon Date: Sun, 18 Jan 2026 01:22:42 +0530 Subject: [PATCH 11/12] fix(tests): add correct type to search_transactions_request fixture --- tests/fixtures/transactions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/fixtures/transactions.py b/tests/fixtures/transactions.py index 8f9fe68..66e7a00 100644 --- a/tests/fixtures/transactions.py +++ b/tests/fixtures/transactions.py @@ -119,9 +119,9 @@ def make_search_transactions_request( amount_equals: float | None = None, amount_more: float | None = None, amount_less: float | None = None, - date_on: datetime | None = None, - date_after: datetime | None = None, - date_before: datetime | None = None, + date_on: date | None = None, + date_after: date | None = None, + date_before: date | None = None, transaction_type: str | None = None, category: str | None = None, budget: str | None = None, From 0b707b16ced61d1f6a1c3b6c8543ea336e1122ef Mon Sep 17 00:00:00 2001 From: Radith Samarakoon Date: Sun, 18 Jan 2026 01:25:29 +0530 Subject: [PATCH 12/12] refactor(models): update Transaction date field to use utc_now --- src/lampyrid/models/lampyrid_models.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lampyrid/models/lampyrid_models.py b/src/lampyrid/models/lampyrid_models.py index 826812e..149bf2b 100644 --- a/src/lampyrid/models/lampyrid_models.py +++ b/src/lampyrid/models/lampyrid_models.py @@ -93,9 +93,7 @@ class Transaction(BaseModel): amount: float = Field(..., description='Amount of the transaction') description: str = Field(..., description='Description of the transaction') type: TransactionTypeProperty = Field(..., description='Type of the transaction') - date: datetime = Field( - default_factory=datetime.now, description='Date and time of the transaction' - ) + date: datetime = Field(default_factory=utc_now, description='Date and time of the transaction') source_id: Optional[str] = Field( None, description=(