1111import logging
1212import re
1313import os
14+ import uuid
1415
1516# Improvement ideas:
1617# - Implement token expiration check in CustomFirebaseCredentials
@@ -56,26 +57,19 @@ def expired(self):
5657class SpendeeFirestore (FirebaseClient ):
5758
5859 def __init__ (self , email : str , password : str , base_url : str = 'https://api.spendee.com/' ,
59- google_client_id : str = 'AIzaSyCCJPDxVNVFEARQ-LxH7q2aZtdQJGGFO84' ,
60- service_account_path : str = None ):
60+ google_client_id : str = 'AIzaSyCCJPDxVNVFEARQ-LxH7q2aZtdQJGGFO84' ):
6161 logger .info ("Initializing SpendeeFirestore client." )
6262 self .base_url = base_url
63- self .service_account_path = service_account_path
6463
65- # Try service account authentication first if provided
66- if service_account_path and os .path .exists (service_account_path ):
67- logger .info (f"Using service account authentication from: { service_account_path } " )
68- self ._init_with_service_account ()
69- else :
70- logger .info ("Using Firebase authentication." )
71- super ().__init__ (email , password , google_client_id )
72- self .authenticate ()
73- self .credentials = CustomFirebaseCredentials (self )
74- self .client = firestore .Client (project = "spendee-app" , credentials = self .credentials )
75- access_token_data = self ._jwt_instance .decode (self .access_token , do_verify = False )
76- self .user_id = access_token_data .get ('user_id' , None )
77- self .email = access_token_data .get ('email' , None )
78- self .user_name = access_token_data .get ('name' , None )
64+ logger .info ("Using Firebase authentication." )
65+ super ().__init__ (email , password , google_client_id )
66+ self .authenticate ()
67+ self .credentials = CustomFirebaseCredentials (self )
68+ self .client = firestore .Client (project = "spendee-app" , credentials = self .credentials )
69+ access_token_data = self ._jwt_instance .decode (self .access_token , do_verify = False )
70+ self .user_id = access_token_data .get ('user_id' , None )
71+ self .email = access_token_data .get ('email' , None )
72+ self .user_name = access_token_data .get ('name' , None )
7973
8074 # Initialize mappings
8175 self .wallet_name_map = { x ['name' ]: x ['id' ] for x in self .list_wallets ()}
@@ -297,11 +291,9 @@ def list_wallets(self):
297291 - updatedAt: Last updated timestamp of the wallet
298292 """
299293
294+ # Fetch raw wallet documents (use helper to allow reuse)
300295 logger .info ("Listing wallets." )
301- raw_data = [
302- x .to_dict ()
303- for x in self .client .collection (f'users/{ self .user_id } /wallets' ).get ()
304- ]
296+ raw_data = self ._list_raw_wallets ()
305297 return_data = []
306298 for wallet in raw_data :
307299 if wallet ["status" ] != 'active' :
@@ -319,13 +311,47 @@ def list_wallets(self):
319311 logger .debug (f"Fetched wallets content: { return_data } " )
320312 return return_data
321313
314+ def _list_raw_wallets (self , as_json : bool = False ):
315+ """Return raw wallet documents for the authenticated user.
316+
317+ This helper returns the full document dicts from Firestore without any
318+ post-processing. It is intended for internal use by other methods that
319+ need the unmodified wallet documents.
320+ """
321+ logger .info ("Listing all raw wallets." )
322+ raw_data = [
323+ x .to_dict ()
324+ for x in self .client .collection (f'users/{ self .user_id } /wallets' ).get ()
325+ ]
326+ logger .debug (f"Fetched raw wallets content: { raw_data } " )
327+ return self .as_json (raw_data ) if as_json else raw_data
328+
322329
323330 def _get_raw_transaction (self , wallet_id : str , transaction_id : str , as_json : bool = False ):
324331 logger .info (f"Fetching raw transaction: wallet_id={ wallet_id } , transaction_id={ transaction_id } " )
325332 obj = self .client .document (f"users/{ self .user_id } /wallets/{ wallet_id } /transactions/{ transaction_id } " ).get ().to_dict ()
326333 logger .debug (f"Fetched raw transaction content: { obj } " )
327334 return self .as_json (obj ) if as_json else obj
328335
336+ def _get_raw_document (self , doc_path : str , as_json : bool = False ):
337+ """
338+ Fetch any document by its absolute Firestore path under the project, for example:
339+ - users/{userId}
340+ - users/{userId}/wallets/{walletId}
341+ - users/{userId}/categories/{categoryId}
342+ Returns the dict or JSON string when as_json=True.
343+ """
344+ logger .info (f"Fetching raw document: { doc_path } " )
345+ # Support leading/trailing slashes
346+ doc_path = doc_path .strip ('/' )
347+ try :
348+ obj = self .client .document (doc_path ).get ().to_dict ()
349+ except Exception as e :
350+ logger .exception (f"Failed to fetch document { doc_path } : { e } " )
351+ raise
352+ logger .debug (f"Fetched raw document content for { doc_path } : { obj } " )
353+ return self .as_json (obj ) if as_json else obj
354+
329355
330356 @mcp_tool
331357 def get_transaction (self , wallet_id : str , transaction_id : str , resolve_category : bool = True , resolve_labels : bool = True , as_json : bool = False ):
@@ -509,14 +535,17 @@ def edit_transaction(self, wallet_id: str, transaction_id: str, updates: dict):
509535 updates (dict): Dictionary containing the fields to update. Supported fields:
510536 - note (str): Transaction note/description
511537 - category (str): Category name (will be converted to category ID)
512-
513- Returns:
514- dict: The updated transaction data.
538+ - labels (str): A single string containing comma-separated operations where each
539+ element starts with '+' to add or '-' to remove a label. Example: "+McDonalds,-Rossmann".
540+ Labels must already exist for the user; unknown label names will raise ValueError.
541+ Raises:
542+ ValueError: If the transaction does not exist, or if invalid updates are provided.
515543 """
516544 logger .info (f"Editing transaction: wallet_id={ wallet_id } , transaction_id={ transaction_id } , updates={ updates } " )
517545
518546 # Get the current transaction data
519547 current_data = self ._get_raw_transaction (wallet_id , transaction_id )
548+ current_label_ids = self ._get_transation_labels (wallet_id , transaction_id , resolve_names = False )
520549 if not current_data :
521550 raise ValueError (f"Transaction { transaction_id } not found in wallet { wallet_id } " )
522551
@@ -547,17 +576,70 @@ def edit_transaction(self, wallet_id: str, transaction_id: str, updates: dict):
547576 logger .warning (f"Category type '{ category_type } ' is not valid for transaction: { transaction_id } " )
548577 update_data ['category' ] = category_id
549578
550- if not update_data :
551- logger .warning ("No valid fields to update provided" )
552- return self .get_transaction (wallet_id , transaction_id )
579+ # Handle labels operations: single string with comma separated ops, each starting with + or -
580+ # Example: "+McDonalds,-Rossmann" -> add McDonalds, remove Rossmann
581+ labels_to_add = set ()
582+ labels_to_delete = set ()
583+ if 'labels' in updates :
584+ labels_val = updates .get ('labels' )
585+ if not isinstance (labels_val , str ):
586+ raise ValueError ("'labels' must be a string of comma-separated +Label or -Label operations" )
587+
588+ ops = [s .strip () for s in labels_val .split (',' ) if s .strip ()]
589+ coll_path = f'users/{ self .user_id } /wallets/{ wallet_id } /transactions/{ transaction_id } /transactionLabels'
590+ coll = self .client .collection (coll_path )
591+ for op in ops :
592+ if len (op ) < 2 :
593+ raise ValueError (f"Invalid label operation: '{ op } '" )
594+ action = op [0 ]
595+ label_name = op [1 :].strip ()
596+ # find label id by name; label must exist
597+ label_id = None
598+ for lid , lname in self .label_name_map .items ():
599+ if lname == label_name :
600+ label_id = lid
601+ break
602+ if label_id is None :
603+ raise ValueError (f"Label not found: { label_name } " )
604+
605+ if action == '+' :
606+ if label_id not in current_label_ids :
607+ labels_to_add .add (label_id )
608+ else :
609+ logger .info (f"Label '{ label_name } ' already present on transaction { transaction_id } , skipping add." )
610+ elif action == '-' :
611+ # collect matching label doc refs to delete
612+ for doc in coll .where ('label' , '==' , label_id ).get ():
613+ labels_to_delete .add (doc .reference )
614+ else :
615+ raise ValueError (f"Unsupported label action: '{ action } '" )
616+
617+ transaction_path = f"users/{ self .user_id } /wallets/{ wallet_id } /transactions/{ transaction_id } "
618+
619+
620+ batch = self .client .batch ()
621+ for label_id in labels_to_add :
622+ uuid_str = str (uuid .uuid4 ())
623+ label_doc_ref = self .client .collection (f"{ transaction_path } /transactionLabels" ).document (uuid_str )
624+ batch .set (label_doc_ref , {
625+ 'label' : label_id ,
626+ 'createdAt' : firestore .SERVER_TIMESTAMP ,
627+ 'author' : self .user_id ,
628+ 'modelVersion' : 1 ,
629+ 'path' : {
630+ 'user' : self .user_id ,
631+ 'wallet' : wallet_id ,
632+ 'transaction' : transaction_id ,
633+ 'transactionLabel' : uuid_str ,
634+ }
635+ })
636+ for label_doc_ref in labels_to_delete :
637+ batch .delete (label_doc_ref )
553638
554- # Try multiple approaches to match the web application's behavior
555- doc_ref = self .client .document (f"users/{ self .user_id } /wallets/{ wallet_id } /transactions/{ transaction_id } " )
639+ doc_ref = self .client .document (transaction_path )
556640 update_data ['updatedAt' ] = firestore .SERVER_TIMESTAMP
641+ batch .set (doc_ref , update_data , merge = True )
642+
643+ batch .update
644+ batch .commit ()
557645
558- try :
559- doc_ref .set (update_data , merge = True )
560- logger .info (f"Transaction { transaction_id } updated successfully using set with merge" )
561- except Exception as e1 :
562- logger .warning (f"Set with merge failed: { e1 } " )
563- raise e1
0 commit comments