diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 5f8b306..54ad068 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -18,7 +18,7 @@ client = FinWise() ```python accounts = client.accounts.list() -for account in accounts.data: +for account in accounts: print(f"{account.name}: {account.currency} {account.balance}") ``` diff --git a/docs/index.md b/docs/index.md index b57e6e0..d52ebf5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,7 +13,7 @@ For the official API documentation, see [finwiseapp.io/docs/api](https://finwise - **Type-safe**: Full type hints and Pydantic models - **Automatic retries**: Exponential backoff for transient errors -- **Pagination support**: Easy iteration through paginated results +- **Simple API**: Returns plain Python lists for easy iteration - **Context manager**: Automatic resource cleanup ## Quick Example @@ -25,7 +25,7 @@ client = FinWise(api_key="your-api-key") # List all accounts accounts = client.accounts.list() -for account in accounts.data: +for account in accounts: print(f"{account.name}: {account.currency} {account.balance}") ``` diff --git a/docs/reference/pagination.md b/docs/reference/pagination.md index 2c4ebb9..eb1f817 100644 --- a/docs/reference/pagination.md +++ b/docs/reference/pagination.md @@ -1,6 +1,7 @@ # Pagination -All list methods return a `PaginatedResponse` object for easy iteration through results. +!!! note "API Behavior" + The FinWise API does not currently support pagination for list endpoints. All `list()` methods return all available items as a simple Python list. ## Basic Usage @@ -9,67 +10,44 @@ from finwise import FinWise client = FinWise(api_key="your-api-key") -# Get first page -accounts = client.accounts.list(page_number=1, page_size=50) +# Get all accounts +accounts = client.accounts.list() +print(f"Found {len(accounts)} accounts") -print(f"Page {accounts.page_number} of {accounts.total_pages}") -print(f"Showing {len(accounts)} of {accounts.total_count} accounts") -``` - -## PaginatedResponse Properties - -| Property | Type | Description | -|----------|------|-------------| -| `data` | `list` | Items on the current page | -| `page_number` | `int` | Current page number (1-indexed) | -| `page_size` | `int` | Items per page | -| `total_count` | `int` | Total items across all pages | -| `total_pages` | `int` | Total number of pages | -| `has_next` | `bool` | Whether there's a next page | -| `has_previous` | `bool` | Whether there's a previous page | - -## Iterating Through Items - -```python -# Iterate through items on this page -for account in accounts.data: - print(account.name) - -# Or iterate directly on the response +# Iterate through items for account in accounts: print(account.name) # Access by index -first_account = accounts[0] # or accounts.data[0] +first_account = accounts[0] ``` -## Fetching Multiple Pages - -```python -# Check for more pages -if accounts.has_next: - next_page = client.accounts.list( - page_number=accounts.page_number + 1, - page_size=50, - ) -``` +## List Methods -## Iterating Through All Pages +All list methods return a `list` of model objects: -```python -page_number = 1 -all_accounts = [] +| Method | Return Type | +|--------|-------------| +| `accounts.list()` | `list[Account]` | +| `transactions.list()` | `list[Transaction]` | +| `transaction_categories.list()` | `list[TransactionCategory]` | +| `account_balances.list()` | `list[AccountBalance]` | -while True: - accounts = client.accounts.list( - page_number=page_number, - page_size=100 - ) - all_accounts.extend(accounts.data) +## Filtering - if not accounts.has_next: - break - page_number += 1 +While pagination is not supported, you can still filter results using available parameters: -print(f"Fetched {len(all_accounts)} accounts") +```python +# Filter transactions by date range +transactions = client.transactions.list( + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 31), + type="expense", +) + +# Filter by account +transactions = client.transactions.list(account_id="acc_123") + +# Filter account balances +balances = client.account_balances.list(account_id="acc_123") ``` diff --git a/docs/usage/account-balances.md b/docs/usage/account-balances.md index 737f17a..38d8882 100644 --- a/docs/usage/account-balances.md +++ b/docs/usage/account-balances.md @@ -30,16 +30,16 @@ balance = client.account_balances.create( ```python balances = client.account_balances.list(account_id="acc_123") -for balance in balances.data: +for balance in balances: print(f"{balance.balance_date}: {balance.balance}") ``` ## Get Aggregated Balance -Get the total balance across all accounts: +Get the total balance across all accounts for a specific currency: ```python -summary = client.account_balances.aggregated() +summary = client.account_balances.aggregated(currency="USD") print(f"Total: {summary.currency} {summary.total_balance}") print(f"Across {summary.account_count} accounts") ``` @@ -48,10 +48,14 @@ You can also get the balance as of a specific date: ```python summary = client.account_balances.aggregated( + currency="USD", as_of_date=date(2024, 1, 31) ) ``` +!!! note "Currency Parameter" + The `currency` parameter specifies which currency to aggregate balances for (e.g., "USD", "EUR", "ZAR"). + ## Archive a Balance Record ```python diff --git a/docs/usage/accounts.md b/docs/usage/accounts.md index a44e4ed..abc50b6 100644 --- a/docs/usage/accounts.md +++ b/docs/usage/accounts.md @@ -50,17 +50,9 @@ account = client.accounts.update( ## List Accounts ```python -# Basic listing accounts = client.accounts.list() -for account in accounts.data: +for account in accounts: print(f" - {account.name}") - -# With pagination -accounts = client.accounts.list(page_number=1, page_size=50) -print(f"Total accounts: {accounts.total_count}") - -if accounts.has_next: - next_page = client.accounts.list(page_number=2, page_size=50) ``` ## Archive an Account diff --git a/docs/usage/transaction-categories.md b/docs/usage/transaction-categories.md index e60f6a3..28180ac 100644 --- a/docs/usage/transaction-categories.md +++ b/docs/usage/transaction-categories.md @@ -31,7 +31,7 @@ subcategory = client.transaction_categories.create( ```python categories = client.transaction_categories.list() -for cat in categories.data: +for cat in categories: prefix = " " if cat.is_subcategory else "" print(f"{prefix}{cat.name}") ``` diff --git a/docs/usage/transactions.md b/docs/usage/transactions.md index 8f44dfc..ed010b5 100644 --- a/docs/usage/transactions.md +++ b/docs/usage/transactions.md @@ -46,24 +46,10 @@ transactions = client.transactions.list( type="expense", ) -for txn in transactions.data: +for txn in transactions: print(f"{txn.transaction_date}: {txn.description} ({txn.amount})") ``` -## Get Aggregated Summary - -Get totals for a date range: - -```python -summary = client.transactions.aggregated( - start_date=date(2024, 1, 1), - end_date=date(2024, 1, 31), -) -print(f"Income: {summary.total_income}") -print(f"Expenses: {summary.total_expenses}") -print(f"Net: {summary.net_amount}") -``` - ## Archive a Transaction ```python diff --git a/src/finwise/__init__.py b/src/finwise/__init__.py index da443e9..fa92758 100644 --- a/src/finwise/__init__.py +++ b/src/finwise/__init__.py @@ -12,7 +12,7 @@ >>> >>> # List accounts >>> accounts = client.accounts.list() - >>> for account in accounts.data: + >>> for account in accounts: ... print(account.name) >>> >>> # Create a transaction @@ -74,16 +74,12 @@ AccountCreateRequest, AccountUpdateRequest, AggregatedBalance, - AggregatedTransactions, Transaction, TransactionCategory, TransactionCategoryCreateRequest, TransactionCreateRequest, ) -# Types -from finwise.types import PaginatedResponse, PaginationParams - __all__ = [ # Version "__version__", @@ -112,11 +108,7 @@ # Models - Transaction "Transaction", "TransactionCreateRequest", - "AggregatedTransactions", # Models - Transaction Category "TransactionCategory", "TransactionCategoryCreateRequest", - # Types - "PaginatedResponse", - "PaginationParams", ] diff --git a/src/finwise/exceptions.py b/src/finwise/exceptions.py index eba878a..6bd4952 100644 --- a/src/finwise/exceptions.py +++ b/src/finwise/exceptions.py @@ -174,6 +174,42 @@ class FinWiseTimeoutError(FinWiseError): pass +def _extract_error_message(response_body: dict[str, Any]) -> str: + """ + Extract error message from API response formats. + + The FinWise API returns errors in the format: + {"error": {"errors": [...], "name": "...", "message": "..."}} + + This function handles multiple formats for robustness. + + Args: + response_body: Parsed JSON response body. + + Returns: + Extracted error message or a fallback message. + """ + # Try top-level message first + if "message" in response_body and isinstance(response_body["message"], str): + return response_body["message"] + + # Try nested error.message (actual API format) + if "error" in response_body and isinstance(response_body["error"], dict): + error_obj = response_body["error"] + if "message" in error_obj and isinstance(error_obj["message"], str): + return error_obj["message"] + + # Try detail (FastAPI style) + if "detail" in response_body and isinstance(response_body["detail"], str): + return response_body["detail"] + + # Fallback: include response body for debugging + if response_body: + return f"API error: {response_body}" + + return "Unknown error (empty response body)" + + def raise_for_status( status_code: int, response_body: dict[str, Any], @@ -190,7 +226,7 @@ def raise_for_status( Raises: FinWiseAPIError: Appropriate subclass based on status code. """ - message = response_body.get("message", "Unknown error") + message = _extract_error_message(response_body) error_code = response_body.get("code") kwargs: dict[str, Any] = { diff --git a/src/finwise/models/__init__.py b/src/finwise/models/__init__.py index f605b2d..8671657 100644 --- a/src/finwise/models/__init__.py +++ b/src/finwise/models/__init__.py @@ -15,7 +15,6 @@ BalanceType, ) from finwise.models.transaction import ( - AggregatedTransactions, Transaction, TransactionCreateRequest, ) @@ -40,7 +39,6 @@ # Transaction "Transaction", "TransactionCreateRequest", - "AggregatedTransactions", # Transaction Category "TransactionCategory", "TransactionCategoryCreateRequest", diff --git a/src/finwise/resources/account_balances.py b/src/finwise/resources/account_balances.py index 2e4c2f7..72eb4f3 100644 --- a/src/finwise/resources/account_balances.py +++ b/src/finwise/resources/account_balances.py @@ -155,7 +155,7 @@ def aggregated( if as_of_date: params["asOfDate"] = as_of_date.isoformat() if currency: - params["currency"] = currency + params["currencyCode"] = currency response = self._transport.get( f"{self._path}/aggregated", params=params or None diff --git a/src/finwise/resources/transaction_categories.py b/src/finwise/resources/transaction_categories.py index b6e2178..7289ca5 100644 --- a/src/finwise/resources/transaction_categories.py +++ b/src/finwise/resources/transaction_categories.py @@ -9,7 +9,6 @@ TransactionCategoryCreateRequest, ) from finwise.resources._base import BaseResource -from finwise.types.pagination import PaginatedResponse class TransactionCategoriesResource(BaseResource): @@ -96,20 +95,19 @@ def list( self, *, parent_id: Optional[str] = None, - page_number: int = 1, - page_size: int = 100, - ) -> PaginatedResponse[TransactionCategory]: + ) -> list[TransactionCategory]: """ - List transaction categories with pagination. + List transaction categories. + + Note: The API does not support pagination for this endpoint. + All categories are returned. Args: parent_id: Optional filter by parent category ID. Use None to get top-level categories only. - page_number: Page number to retrieve (default: 1). - page_size: Number of items per page (default: 100, max: 500). Returns: - Paginated response containing TransactionCategory objects. + List of TransactionCategory objects. Example: >>> # List all categories @@ -122,28 +120,23 @@ def list( ... parent_id="cat_food", ... ) """ - params = self._build_pagination_params(page_number, page_size) + params: dict[str, str] = {} if parent_id is not None: params["parentId"] = parent_id - response = self._transport.get(self._path, params=params) + response = self._transport.get(self._path, params=params or None) + + # API returns raw list + if isinstance(response, list): + return [TransactionCategory.model_validate(item) for item in response] - categories = [ + # Fallback for wrapped response + return [ TransactionCategory.model_validate(item) for item in response.get("data", []) ] - return PaginatedResponse[TransactionCategory]( - data=categories, - page_number=response.get("pageNumber", page_number), - page_size=response.get("pageSize", page_size), - total_count=response.get("totalCount", len(categories)), - total_pages=response.get("totalPages", 1), - has_next=response.get("hasNext", False), - has_previous=response.get("hasPrevious", False), - ) - def delete(self, category_id: str) -> None: """ Delete a transaction category. diff --git a/src/finwise/resources/transactions.py b/src/finwise/resources/transactions.py index 55bad00..5da91c8 100644 --- a/src/finwise/resources/transactions.py +++ b/src/finwise/resources/transactions.py @@ -7,12 +7,10 @@ from typing import Literal, Optional from finwise.models.transaction import ( - AggregatedTransactions, Transaction, TransactionCreateRequest, ) from finwise.resources._base import BaseResource -from finwise.types.pagination import PaginatedResponse class TransactionsResource(BaseResource): @@ -35,12 +33,8 @@ class TransactionsResource(BaseResource): >>> >>> # List transactions >>> transactions = client.transactions.list() - >>> - >>> # Get aggregated summary - >>> summary = client.transactions.aggregated( - ... start_date=date(2024, 1, 1), - ... end_date=date(2024, 1, 31), - ... ) + >>> for txn in transactions: + ... print(f"{txn.description}: {txn.amount}") """ _path = "/transactions" @@ -121,11 +115,12 @@ def list( start_date: Optional[date] = None, end_date: Optional[date] = None, type: Optional[Literal["income", "expense", "transfer"]] = None, - page_number: int = 1, - page_size: int = 100, - ) -> PaginatedResponse[Transaction]: + ) -> list[Transaction]: """ - List transactions with pagination and filtering. + List transactions with optional filtering. + + Note: The API does not support pagination for this endpoint. + All matching transactions are returned. Args: account_id: Optional filter by account ID. @@ -133,15 +128,15 @@ def list( start_date: Optional filter for transactions on or after this date. end_date: Optional filter for transactions on or before this date. type: Optional filter by transaction type. - page_number: Page number to retrieve (default: 1). - page_size: Number of items per page (default: 100, max: 500). Returns: - Paginated response containing Transaction objects. + List of Transaction objects. Example: >>> # List all transactions >>> transactions = client.transactions.list() + >>> for txn in transactions: + ... print(f"{txn.description}: {txn.amount}") >>> >>> # Filter by date range >>> transactions = client.transactions.list( @@ -155,7 +150,7 @@ def list( ... type="expense", ... ) """ - params = self._build_pagination_params(page_number, page_size) + params: dict[str, str] = {} if account_id: params["accountId"] = account_id @@ -168,74 +163,14 @@ def list( if type: params["type"] = type - response = self._transport.get(self._path, params=params) - - transactions = [ - Transaction.model_validate(item) for item in response.get("data", []) - ] + response = self._transport.get(self._path, params=params or None) - return PaginatedResponse[Transaction]( - data=transactions, - page_number=response.get("pageNumber", page_number), - page_size=response.get("pageSize", page_size), - total_count=response.get("totalCount", len(transactions)), - total_pages=response.get("totalPages", 1), - has_next=response.get("hasNext", False), - has_previous=response.get("hasPrevious", False), - ) - - def aggregated( - self, - *, - start_date: Optional[date] = None, - end_date: Optional[date] = None, - account_id: Optional[str] = None, - category_id: Optional[str] = None, - ) -> AggregatedTransactions: - """ - Get aggregated transaction summary. - - Provides a summary of income, expenses, and net amount over - a specified period. - - Args: - start_date: Start date for aggregation (default: first of month). - end_date: End date for aggregation (default: today). - account_id: Optional filter by account ID. - category_id: Optional filter by category ID. - - Returns: - AggregatedTransactions with income/expense summary. - - Example: - >>> from datetime import date - >>> - >>> # Get monthly summary - >>> summary = client.transactions.aggregated( - ... start_date=date(2024, 1, 1), - ... end_date=date(2024, 1, 31), - ... ) - >>> print(f"Income: {summary.total_income}") - >>> print(f"Expenses: {summary.total_expenses}") - >>> print(f"Net: {summary.net_amount}") - >>> print(f"Transactions: {summary.transaction_count}") - """ - params: dict[str, str] = {} - - if start_date: - params["startDate"] = start_date.isoformat() - if end_date: - params["endDate"] = end_date.isoformat() - if account_id: - params["accountId"] = account_id - if category_id: - params["categoryId"] = category_id - - response = self._transport.get( - f"{self._path}/aggregated", params=params or None - ) + # API returns raw list + if isinstance(response, list): + return [Transaction.model_validate(item) for item in response] - return AggregatedTransactions.model_validate(response) + # Fallback for wrapped response + return [Transaction.model_validate(item) for item in response.get("data", [])] def archive(self, transaction_id: str) -> Transaction: """ diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..baa7bba --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,137 @@ +"""Tests for exception handling and error message extraction.""" + +from __future__ import annotations + +from typing import Any + +import pytest +import respx +from httpx import Response + +from finwise import FinWise, ValidationError +from finwise.exceptions import _extract_error_message + + +class TestExtractErrorMessage: + """Tests for _extract_error_message helper function.""" + + def test_top_level_message(self) -> None: + """Test extraction of top-level message.""" + response = {"message": "Validation failed", "code": "VALIDATION_ERROR"} + assert _extract_error_message(response) == "Validation failed" + + def test_nested_error_message(self) -> None: + """Test extraction from nested error.message (actual API format).""" + response = { + "error": { + "errors": [{"code": "unrecognized_keys"}], + "name": "BadRequestError", + "message": "Invalid request query params", + } + } + assert _extract_error_message(response) == "Invalid request query params" + + def test_fastapi_detail_format(self) -> None: + """Test extraction of FastAPI-style detail field.""" + response = {"detail": "Not authenticated"} + assert _extract_error_message(response) == "Not authenticated" + + def test_top_level_takes_precedence(self) -> None: + """Test that top-level message takes precedence over nested.""" + response = { + "message": "Top level message", + "error": {"message": "Nested message"}, + } + assert _extract_error_message(response) == "Top level message" + + def test_fallback_includes_response_body(self) -> None: + """Test fallback includes response body for debugging.""" + response = {"unknownField": "some value", "code": 12345} + result = _extract_error_message(response) + assert "API error:" in result + assert "unknownField" in result + + def test_empty_response_body(self) -> None: + """Test handling of empty response body.""" + response: dict[str, Any] = {} + assert _extract_error_message(response) == "Unknown error (empty response body)" + + def test_non_string_message_ignored(self) -> None: + """Test that non-string messages are ignored.""" + response = {"message": 12345, "error": {"message": "Nested message"}} + assert _extract_error_message(response) == "Nested message" + + def test_non_dict_error_ignored(self) -> None: + """Test that non-dict error fields are ignored.""" + response = {"error": "This is a string, not a dict"} + result = _extract_error_message(response) + assert "API error:" in result + + +class TestApiErrorFormat: + """Tests for actual API error format handling.""" + + def test_pagination_error_format( + self, + client: FinWise, + mock_api: respx.Router, + ) -> None: + """Test that the actual API error format is handled correctly.""" + # This is the actual format returned by the FinWise API + api_error_response = { + "error": { + "errors": [ + { + "code": "unrecognized_keys", + "keys": ["pageNumber", "pageSize"], + "path": [], + "message": "Unrecognized key(s) in object: 'pageNumber', 'pageSize'", + } + ], + "name": "BadRequestError", + "message": "Invalid request query params", + } + } + + mock_api.get("/transactions").mock( + return_value=Response(400, json=api_error_response) + ) + + with pytest.raises(ValidationError) as exc_info: + client.transactions.list() + + # Should extract the correct message, not "Unknown error" + assert exc_info.value.message == "Invalid request query params" + assert exc_info.value.status_code == 400 + + def test_currency_code_error_format( + self, + client: FinWise, + mock_api: respx.Router, + ) -> None: + """Test that currency code validation error is handled correctly.""" + api_error_response = { + "error": { + "errors": [ + { + "code": "invalid_type", + "expected": "string", + "received": "undefined", + "path": ["currencyCode"], + "message": "Required", + } + ], + "name": "BadRequestError", + "message": "Invalid request query params", + } + } + + mock_api.get("/account-balances/aggregated").mock( + return_value=Response(400, json=api_error_response) + ) + + with pytest.raises(ValidationError) as exc_info: + client.account_balances.aggregated() + + assert exc_info.value.message == "Invalid request query params" + assert exc_info.value.status_code == 400