Skip to content

Commit 9ccbd52

Browse files
committed
implement-label-editing-for-a-transaction
1 parent a676a90 commit 9ccbd52

2 files changed

Lines changed: 159 additions & 50 deletions

File tree

run.py

100644100755
Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
#!/usr/bin/env python3
12
import os
23
from dotenv import load_dotenv
34
from spendee import SpendeeApi
@@ -39,7 +40,14 @@
3940
#print(spendee.list_labels(as_json=True))
4041
#print(spendee.list_categories(as_json=True))
4142
#print(spendee._get_raw_transaction('b368c5c2-68fe-4f98-9d4f-08e0cdca57a7', 'a15d8379-6884-4e7d-a007-a1748b62e9d3', as_json=True))
42-
print(spendee._get_raw_transaction('b368c5c2-68fe-4f98-9d4f-08e0cdca57a7', 'd2b4caa7-12eb-4c04-a744-d2bf7e02bdd2', as_json=True))
43+
#print(spendee._get_raw_transaction('b368c5c2-68fe-4f98-9d4f-08e0cdca57a7', 'd2b4caa7-12eb-4c04-a744-d2bf7e02bdd2', as_json=True))
44+
# Example raw document dumps using the new helper. Paste these outputs back here to expand the schema.
45+
46+
#print(spendee._get_raw_document(f"users/{spendee.user_id}", as_json=True))
47+
48+
#print(spendee._list_raw_wallets(as_json=True))
49+
50+
4351
#print("listwallets: ", spendee.list_wallets())
4452
#print("rafi balance: ", spendee.get_wallet_balance('Rafi'))
4553
# for wallet in wallets:
@@ -60,22 +68,41 @@
6068

6169
# Example: Update transaction note and category
6270

63-
updated_transaction = spendee.edit_transaction(
64-
wallet_id='b368c5c2-68fe-4f98-9d4f-08e0cdca57a7',
65-
transaction_id='d2b4caa7-12eb-4c04-a744-d2bf7e02bdd2',
66-
updates={
67-
#'note': 'ADOMÁNY, Bogár Péter István2',
68-
'category': "Other" # Use category name, not ID
69-
#'category': "Kafetéria"
70-
#'category': "Szórakozás"
71-
}
72-
)
73-
print(spendee._get_raw_transaction('b368c5c2-68fe-4f98-9d4f-08e0cdca57a7', 'd2b4caa7-12eb-4c04-a744-d2bf7e02bdd2', as_json=True))
71+
# updated_transaction = spendee.edit_transaction(
72+
# wallet_id='b368c5c2-68fe-4f98-9d4f-08e0cdca57a7',
73+
# transaction_id='d2b4caa7-12eb-4c04-a744-d2bf7e02bdd2',
74+
# updates={
75+
# #'note': 'ADOMÁNY, Bogár Péter István2',
76+
# #'category': "Other" # Use category name, not ID
77+
# #'category': "Kafetéria"
78+
# 'category': "Szórakozás"
79+
# }
80+
# )
81+
# print(spendee._get_raw_transaction('b368c5c2-68fe-4f98-9d4f-08e0cdca57a7', 'd2b4caa7-12eb-4c04-a744-d2bf7e02bdd2', as_json=True))
7482
#print(spendee.get_transaction('b368c5c2-68fe-4f98-9d4f-08e0cdca57a7', '875ebb1c-e03d-433f-a6c9-be0316f6c838', as_json=True))
7583
#print(spendee._get_transation_labels('b368c5c2-68fe-4f98-9d4f-08e0cdca57a7', '875ebb1c-e03d-433f-a6c9-be0316f6c838', as_json=True))
7684

7785
# print(spendee.list_transactions('b368c5c2-68fe-4f98-9d4f-08e0cdca57a7', start='2025-08-01T00:00:00Z', filters=[{"field": "category", "op": "=", "value": None}], fields=["id", "note", "category"], as_json=True))
7886

79-
# Example usage of logger for debug/info
80-
# logger.debug('Debug message')
81-
# logger.info('Info message')
87+
88+
# Simple label tests (one add, one remove, then multiple ops)
89+
# Replace wallet_id and transaction_id with real IDs before running
90+
wallet_id = 'b368c5c2-68fe-4f98-9d4f-08e0cdca57a7'
91+
transaction_id = 'd2b4caa7-12eb-4c04-a744-d2bf7e02bdd2'
92+
93+
print(spendee.get_transaction(wallet_id, transaction_id))
94+
# print("Adding label 'McDonalds'")
95+
# print(spendee.edit_transaction(wallet_id=wallet_id, transaction_id=transaction_id, updates={'labels': '+McDonalds'}))
96+
# print(spendee.get_transaction(wallet_id, transaction_id))
97+
98+
print("\n --- TEST CASE: Adding label 'McDonalds'")
99+
print(spendee.edit_transaction(wallet_id=wallet_id, transaction_id=transaction_id, updates={'labels': '+McDonalds'}))
100+
print(spendee.get_transaction(wallet_id, transaction_id))
101+
102+
print("\n --- TEST CASE: Removing label 'Rossmann'")
103+
print(spendee.edit_transaction(wallet_id=wallet_id, transaction_id=transaction_id, updates={'labels': '-Rossmann'}))
104+
print(spendee.get_transaction(wallet_id, transaction_id))
105+
106+
print("\n --- TEST CASE: Adding label 'Rossmann' and removing 'McDonalds' in one go")
107+
print(spendee.edit_transaction(wallet_id=wallet_id, transaction_id=transaction_id, updates={'labels': '+Rossmann,-McDonalds'}))
108+
print(spendee.get_transaction(wallet_id, transaction_id))

spendee/spendee_firestore.py

Lines changed: 117 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import logging
1212
import re
1313
import os
14+
import uuid
1415

1516
# Improvement ideas:
1617
# - Implement token expiration check in CustomFirebaseCredentials
@@ -56,26 +57,19 @@ def expired(self):
5657
class 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

Comments
 (0)