1+ import logging
12from typing import Any , Dict , List
23
34import httpx
4546 UpdateTransactionRequest ,
4647)
4748
49+ logger = logging .getLogger (__name__ )
50+
4851
4952class FireflyClient :
5053 def __init__ (self ) -> None :
@@ -59,16 +62,43 @@ def __init__(self) -> None:
5962 timeout = 30.0 ,
6063 )
6164
65+ def _serialize_model (self , model : Any , exclude_unset : bool = False ) -> Dict [str , Any ]:
66+ """Serialize a Pydantic model to dict, excluding None values by default.
67+
68+ Firefly III API rejects None values for many fields, so we exclude them.
69+ Use exclude_unset=True for update operations to only send changed fields.
70+ """
71+ return model .model_dump (mode = 'json' , exclude_none = True , exclude_unset = exclude_unset )
72+
73+ def _handle_api_error (
74+ self , response : httpx .Response , payload : Dict [str , Any ] | None = None
75+ ) -> None :
76+ """Log detailed error information for API errors.
77+
78+ Args:
79+ response: The HTTP response object
80+ payload: The request payload that was sent (optional, for POST/PUT requests)
81+ """
82+ if response .status_code >= 400 :
83+ logger .error (
84+ f'Firefly III API error ({ response .status_code } ): { response .text } ' ,
85+ )
86+ if payload :
87+ logger .error (f'Request payload: { payload } ' )
88+ logger .error (f'Request URL: { response .request .url } ' )
89+
6290 async def list_accounts (
6391 self , page : int = 1 , type : AccountTypeFilter = AccountTypeFilter .all
6492 ) -> AccountArray :
6593 r = await self ._client .get ('/api/v1/accounts' , params = {'page' : page , 'type' : type .value })
94+ self ._handle_api_error (r )
6695 r .raise_for_status ()
6796 return AccountArray .model_validate (r .json ())
6897
6998 async def get_account (self , req : GetAccountRequest ) -> Account :
7099 """Get a single account by ID."""
71100 r = await self ._client .get (f'/api/v1/accounts/{ req .id } ' )
101+ self ._handle_api_error (r )
72102 r .raise_for_status ()
73103 account_single = AccountSingle .model_validate (r .json ())
74104 return Account .from_account_read (account_single .data )
@@ -84,7 +114,7 @@ async def search_accounts(self, req: SearchAccountRequest) -> AccountArray:
84114 'page' : 1 ,
85115 },
86116 )
87-
117+ self . _handle_api_error ( r )
88118 r .raise_for_status ()
89119 return AccountArray .model_validate (r .json ())
90120
@@ -164,6 +194,7 @@ async def search_transactions(self, req: SearchTransactionsRequest) -> Transacti
164194 }
165195
166196 r = await self ._client .get ('/api/v1/search/transactions' , params = params )
197+ self ._handle_api_error (r )
167198 r .raise_for_status ()
168199 return TransactionArray .model_validate (r .json ())
169200
@@ -179,7 +210,9 @@ async def create_withdrawal(self, withdrawal: CreateWithdrawalRequest) -> Transa
179210 budget_name = withdrawal .budget_name ,
180211 )
181212 trx_store = TransactionStore (transactions = [trx ])
182- r = await self ._client .post ('/api/v1/transactions' , json = trx_store .model_dump (mode = 'json' ))
213+ payload = self ._serialize_model (trx_store )
214+ r = await self ._client .post ('/api/v1/transactions' , json = payload )
215+ self ._handle_api_error (r , payload )
183216 r .raise_for_status ()
184217 res = TransactionSingle .model_validate (r .json ())
185218 return Transaction .from_transaction_single (res )
@@ -194,7 +227,9 @@ async def create_deposit(self, deposit: CreateDepositRequest) -> Transaction:
194227 destination_id = deposit .destination_id ,
195228 )
196229 trx_store = TransactionStore (transactions = [trx ])
197- r = await self ._client .post ('/api/v1/transactions' , json = trx_store .model_dump (mode = 'json' ))
230+ payload = self ._serialize_model (trx_store )
231+ r = await self ._client .post ('/api/v1/transactions' , json = payload )
232+ self ._handle_api_error (r , payload )
198233 r .raise_for_status ()
199234 res = TransactionSingle .model_validate (r .json ())
200235 return Transaction .from_transaction_single (res )
@@ -209,7 +244,9 @@ async def create_transfer(self, transfer: CreateTransferRequest) -> Transaction:
209244 destination_id = transfer .destination_id ,
210245 )
211246 trx_store = TransactionStore (transactions = [trx ])
212- r = await self ._client .post ('/api/v1/transactions' , json = trx_store .model_dump (mode = 'json' ))
247+ payload = self ._serialize_model (trx_store )
248+ r = await self ._client .post ('/api/v1/transactions' , json = payload )
249+ self ._handle_api_error (r , payload )
213250 r .raise_for_status ()
214251 res = TransactionSingle .model_validate (r .json ())
215252 return Transaction .from_transaction_single (res )
@@ -228,9 +265,9 @@ async def create_bulk_transactions(
228265 fire_webhooks = True ,
229266 error_if_duplicate_hash = False ,
230267 )
231- r = await self ._client . post (
232- '/api/v1/transactions' , json = trx_store . model_dump ( mode = 'json' )
233- )
268+ payload = self ._serialize_model ( trx_store )
269+ r = await self . _client . post ( '/api/v1/transactions' , json = payload )
270+ self . _handle_api_error ( r , payload )
234271 r .raise_for_status ()
235272 res = TransactionSingle .model_validate (r .json ())
236273 created_transactions .append (Transaction .from_transaction_single (res ))
@@ -263,10 +300,9 @@ async def update_transaction(self, req: UpdateTransactionRequest) -> Transaction
263300 apply_rules = False , fire_webhooks = True , group_title = None , transactions = [trx_split_update ]
264301 )
265302
266- r = await self ._client .put (
267- f'/api/v1/transactions/{ req .transaction_id } ' ,
268- json = trx_update .model_dump (mode = 'json' , exclude_unset = True ),
269- )
303+ payload = self ._serialize_model (trx_update , exclude_unset = True )
304+ r = await self ._client .put (f'/api/v1/transactions/{ req .transaction_id } ' , json = payload )
305+ self ._handle_api_error (r , payload )
270306 r .raise_for_status ()
271307 transaction_single = TransactionSingle .model_validate (r .json ())
272308 return Transaction .from_transaction_single (transaction_single )
@@ -306,6 +342,7 @@ async def get_transactions(self, req: GetTransactionsRequest) -> TransactionArra
306342 params ['type' ] = req .transaction_type .value
307343
308344 r = await self ._client .get ('/api/v1/transactions' , params = params )
345+ self ._handle_api_error (r )
309346 r .raise_for_status ()
310347 return TransactionArray .model_validate (r .json ())
311348
@@ -329,25 +366,29 @@ async def get_account_transactions(self, req: GetTransactionsRequest) -> Transac
329366 raise ValueError ('account_id must be provided for account transactions retrieval.' )
330367
331368 r = await self ._client .get (f'/api/v1/accounts/{ req .account_id } /transactions' , params = params )
369+ self ._handle_api_error (r )
332370 r .raise_for_status ()
333371 return TransactionArray .model_validate (r .json ())
334372
335373 async def get_transaction (self , req : GetTransactionRequest ) -> Transaction :
336374 """Get a single transaction by ID."""
337375 r = await self ._client .get (f'/api/v1/transactions/{ req .id } ' )
376+ self ._handle_api_error (r )
338377 r .raise_for_status ()
339378 transaction_single = TransactionSingle .model_validate (r .json ())
340379 return Transaction .from_transaction_single (transaction_single )
341380
342381 async def delete_transaction (self , req : DeleteTransactionRequest ) -> bool :
343382 """Delete a transaction by ID."""
344383 r = await self ._client .delete (f'/api/v1/transactions/{ req .id } ' )
384+ self ._handle_api_error (r )
345385 r .raise_for_status ()
346386 return r .status_code == 204
347387
348388 async def list_budgets (self , req : ListBudgetsRequest ) -> BudgetArray :
349389 """List all budgets."""
350390 r = await self ._client .get ('/api/v1/budgets' )
391+ self ._handle_api_error (r )
351392 r .raise_for_status ()
352393 budget_array = BudgetArray .model_validate (r .json ())
353394 if req .active is not None :
@@ -357,6 +398,7 @@ async def list_budgets(self, req: ListBudgetsRequest) -> BudgetArray:
357398 async def get_budget (self , req : GetBudgetRequest ) -> Budget :
358399 """Get a single budget by ID."""
359400 r = await self ._client .get (f'/api/v1/budgets/{ req .id } ' )
401+ self ._handle_api_error (r )
360402 r .raise_for_status ()
361403 budget_single = BudgetSingle .model_validate (r .json ())
362404 return Budget .from_budget_read (budget_single .data )
@@ -372,6 +414,7 @@ async def get_budget_spending(self, req: GetBudgetSpendingRequest) -> BudgetSpen
372414
373415 # Get budget info first
374416 budget_r = await self ._client .get (f'/api/v1/budgets/{ req .budget_id } ' )
417+ self ._handle_api_error (budget_r )
375418 budget_r .raise_for_status ()
376419 budget_single = BudgetSingle .model_validate (budget_r .json ())
377420 budget_name = budget_single .data .attributes .name
@@ -380,6 +423,7 @@ async def get_budget_spending(self, req: GetBudgetSpendingRequest) -> BudgetSpen
380423 spending_r = await self ._client .get (
381424 f'/api/v1/budgets/{ req .budget_id } /limits' , params = params
382425 )
426+ self ._handle_api_error (spending_r )
383427 spending_r .raise_for_status ()
384428 limits_array = BudgetLimitArray .model_validate (spending_r .json ())
385429
@@ -415,6 +459,7 @@ async def get_budget_summary(self, req: GetBudgetSummaryRequest) -> BudgetSummar
415459 """Get summary of all budgets with spending information."""
416460 # Get all budgets
417461 budgets_r = await self ._client .get ('/api/v1/budgets' )
462+ self ._handle_api_error (budgets_r )
418463 budgets_r .raise_for_status ()
419464 budgets_array = BudgetArray .model_validate (budgets_r .json ())
420465
@@ -455,6 +500,7 @@ async def get_available_budget(self, req: GetAvailableBudgetRequest) -> Availabl
455500 params ['end' ] = req .end_date .strftime ('%Y-%m-%d' )
456501
457502 r = await self ._client .get ('/api/v1/available-budgets' , params = params )
503+ self ._handle_api_error (r )
458504 r .raise_for_status ()
459505 available_array = AvailableBudgetArray .model_validate (r .json ())
460506
0 commit comments