Skip to content

Commit 02e03a4

Browse files
committed
Implement label baseed filtering, and aggregation. Refined docstrings.
1 parent 9ccbd52 commit 02e03a4

4 files changed

Lines changed: 103 additions & 74 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,6 @@ __pycache__
55
.env
66
.venv
77
*.egg-info
8-
*.log
8+
*.log
9+
# homelab related files
10+
re-deploy.sh

firestore-schema.md

Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -143,42 +143,3 @@ Notes and edge cases:
143143
- `spendee._get_raw_document(f"users/{spendee.user_id}/wallets/{walletId}", as_json=True)`
144144

145145
Provide outputs of these helper calls and I will update this schema with exact fields and types where necessary.
146-
147-
## Next steps / tips
148-
- If you paste the JSON output of these helper calls here, I'll extend this file with precise types and optional fields, and add a complete JSON Schema if you want one.
149-
150-
---
151-
"documentChange": {
152-
"document": {
153-
"name": "projects/spendee-app/databases/(default)/documents/users/af150597-5b96-4f8b-9eb3-0b2e0aa72a81/deletedDocuments/75b406c0-a897-44f3-bce9-5191321d0507",
154-
"fields": {
155-
"modelVersion": {
156-
"integerValue": "1"
157-
},
158-
"path": {
159-
"mapValue": {
160-
"fields": {
161-
"deletedDocument": {
162-
"stringValue": "75b406c0-a897-44f3-bce9-5191321d0507"
163-
},
164-
"user": {
165-
"stringValue": "af150597-5b96-4f8b-9eb3-0b2e0aa72a81"
166-
}
167-
}
168-
}
169-
},
170-
"createdAt": {
171-
"timestampValue": "2025-08-22T13:07:07.586Z"
172-
},
173-
"deletedDocumentPath": {
174-
"stringValue": "users/af150597-5b96-4f8b-9eb3-0b2e0aa72a81/wallets/b368c5c2-68fe-4f98-9d4f-08e0cdca57a7/transactions/fbe90b02-f382-4607-808c-d37bcd17ae76/transactionLabels/0e6ec129-dc67-4fba-92db-953c4677cf76"
175-
}
176-
},
177-
"createTime": "2025-08-22T13:07:07.615660Z",
178-
"updateTime": "2025-08-22T13:07:07.615660Z"
179-
},
180-
"targetIds": [
181-
44
182-
]
183-
}
184-
}

run.py

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,20 @@
3737
spendee = SpendeeFirestore(EMAIL, PASSWORD)
3838

3939

40+
print("Raiffeisen Étkezés sum in 2025.08: " + str(spendee.aggregate_transactions(
41+
wallet_id='b368c5c2-68fe-4f98-9d4f-08e0cdca57a7',
42+
start='2025-08-01T00:00:00Z',
43+
end='2025-08-31T23:59:59Z',
44+
filters=[{"field": "category", "op": "=", "value": "Étkezés"}],
45+
)))
46+
47+
print("Raiffeisen Szigetspicc label sum in 2025.08: " + str(spendee.aggregate_transactions(
48+
wallet_id='b368c5c2-68fe-4f98-9d4f-08e0cdca57a7',
49+
start='2025-08-01T00:00:00Z',
50+
end='2025-08-31T23:59:59Z',
51+
filters=[{"field": "labels", "op": "array-contains", "value": "szigetspicc"}],
52+
)))
53+
4054
#print(spendee.list_labels(as_json=True))
4155
#print(spendee.list_categories(as_json=True))
4256
#print(spendee._get_raw_transaction('b368c5c2-68fe-4f98-9d4f-08e0cdca57a7', 'a15d8379-6884-4e7d-a007-a1748b62e9d3', as_json=True))
@@ -83,26 +97,27 @@
8397
#print(spendee._get_transation_labels('b368c5c2-68fe-4f98-9d4f-08e0cdca57a7', '875ebb1c-e03d-433f-a6c9-be0316f6c838', as_json=True))
8498

8599
# 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))
100+
# print(spendee.list_transactions('b368c5c2-68fe-4f98-9d4f-08e0cdca57a7', start='2025-08-08', end="2025-08-13", filters=[{"field": "type", "op": "=", "value": "expense"}], fields=["id", "note", "category"], as_json=True))
86101

87102

88103
# Simple label tests (one add, one remove, then multiple ops)
89104
# 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'
105+
# wallet_id = 'b368c5c2-68fe-4f98-9d4f-08e0cdca57a7'
106+
# transaction_id = 'd2b4caa7-12eb-4c04-a744-d2bf7e02bdd2'
92107

93-
print(spendee.get_transaction(wallet_id, transaction_id))
108+
# print(spendee.get_transaction(wallet_id, transaction_id))
94109
# print("Adding label 'McDonalds'")
95110
# print(spendee.edit_transaction(wallet_id=wallet_id, transaction_id=transaction_id, updates={'labels': '+McDonalds'}))
96111
# print(spendee.get_transaction(wallet_id, transaction_id))
97112

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))
113+
# print("\n --- TEST CASE: Adding label 'McDonalds'")
114+
# print(spendee.edit_transaction(wallet_id=wallet_id, transaction_id=transaction_id, updates={'labels': '+McDonalds'}))
115+
# print(spendee.get_transaction(wallet_id, transaction_id))
101116

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))
117+
# print("\n --- TEST CASE: Removing label 'Rossmann'")
118+
# print(spendee.edit_transaction(wallet_id=wallet_id, transaction_id=transaction_id, updates={'labels': '-Rossmann'}))
119+
# print(spendee.get_transaction(wallet_id, transaction_id))
105120

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))
121+
# print("\n --- TEST CASE: Adding label 'Rossmann' and removing 'McDonalds' in one go")
122+
# print(spendee.edit_transaction(wallet_id=wallet_id, transaction_id=transaction_id, updates={'labels': '+Rossmann,-McDonalds'}))
123+
# print(spendee.get_transaction(wallet_id, transaction_id))

spendee/spendee_firestore.py

Lines changed: 73 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
from decimal import Decimal
2-
from typing import Callable, Dict
2+
from typing import Callable, Dict, List
33
from google.auth.credentials import Credentials
44
from google.cloud import firestore
55
from google.cloud.firestore_v1.base_query import FieldFilter
6-
from google.oauth2 import service_account
76
from .firebase_client import FirebaseClient
87

98
import json
109
import datetime
1110
import logging
1211
import re
13-
import os
1412
import uuid
1513

1614
# Improvement ideas:
@@ -130,13 +128,18 @@ def as_json(self, obj):
130128
@staticmethod
131129
def _matches_filters(value, filters):
132130
for f in filters or []:
131+
if "field" not in f or "op" not in f or "value" not in f:
132+
raise ValueError(f"Invalid filter format: {f}")
133133
field = f.get("field")
134-
if field == "labels":
135-
continue
136134
op = f.get("op")
137135
filter_value = f.get("value")
138136
v = value.get(field)
139-
if op == ">":
137+
if op == "array-contains":
138+
if field != "labels":
139+
raise ValueError("array-contains operator is only supported for 'labels' field")
140+
if filter_value not in v:
141+
return False
142+
elif op == ">":
140143
if not (v is not None and float(v) > float(filter_value)):
141144
return False
142145
elif op == ">=":
@@ -217,7 +220,7 @@ def _list_raw_labels(self, as_json: bool = False):
217220

218221

219222
@mcp_tool
220-
def list_labels(self, as_json: bool = False):
223+
def list_labels(self, as_json: bool = False) -> list[Dict[str, str]]:
221224
"""
222225
Returns the list of labels used by the user.
223226
If as_json is True, returns the data as a JSON string.
@@ -401,7 +404,7 @@ def _list_raw_transactions(self, wallet_id: str, start: str, end: str = None, fi
401404
end (str, optional): End date (ISO 8601).
402405
filters (list, optional): List of filter dicts, e.g. [{"field": "amount", "op": ">=", "value": 100}].
403406
The 'field' and 'op' values must be strings.
404-
Supported operators: "=", "~=", ">", ">=", "<", "<=", where "~=" is regex match.
407+
Supported operators: "=", "~=", ">", ">=", "<", "<=", "array-contains", where "~=" is regex match and "array-contains" is for labels only.
405408
limit (int, optional): Max number of transactions to return (default 20).
406409
as_json (bool, optional): Return as JSON string if True.
407410
Returns:
@@ -417,26 +420,36 @@ def _list_raw_transactions(self, wallet_id: str, start: str, end: str = None, fi
417420

418421
# Only order by 'madeAt' (descending), fetch all for post-filtering and limiting
419422
query = query.order_by("madeAt", direction=firestore.Query.DESCENDING)
423+
#query._all_descendants = True
420424
transactions = query.stream()
421425

422426
results = []
427+
stored_transactions = []
423428
for transaction in transactions:
424429
value = transaction.to_dict()
425430
# Add the transaction ID to the raw data for consistency
426431
value["id"] = value.get("path", {}).get("transaction", "")
427-
if resolve_category:
432+
category_id = value.get("category", None)
433+
if resolve_category and category_id is not None:
428434
# Resolve category ID to category name
429-
category_id = value.get("category", None)
430-
category_name = self.category_name_map.get(category_id, None)
431-
if category_name is None:
432-
logger.warning(f"Category ID {category_id} not found in category_name_map, using None.")
433-
434-
if resolve_labels:
435-
value["labels"] = self._get_transation_labels(wallet_id, value["id"], resolve_names=True)
436-
if not self._matches_filters(value, filters):
435+
value["category"] = self.category_name_map.get(category_id, None)
436+
stored_transactions.append(value)
437+
438+
labels_query = self.client.collection_group('transactionLabels')
439+
labels_query = labels_query.where(filter=FieldFilter("path.user", "==", self.user_id))
440+
transactionLabels = [x.to_dict() for x in labels_query.stream()]
441+
442+
for transaction in stored_transactions:
443+
transaction["labels"] = [
444+
self.label_name_map.get(x.get('label'), None) if resolve_labels else x.get('label')
445+
for x in transactionLabels
446+
if x.get('path', {}).get('transaction') == transaction.get('id')
447+
]
448+
449+
if not self._matches_filters(transaction, filters):
437450
continue
438-
results.append(value)
439-
if len(results) >= limit:
451+
results.append(transaction)
452+
if limit and len(results) >= limit:
440453
break
441454

442455
logger.info(f"Found {len(results)} raw transactions.")
@@ -455,18 +468,28 @@ def list_transactions(self,
455468
List transactions for a wallet, filtered by date range and dynamic filters.
456469
457470
Results are always sorted by 'madeAt' in descending order (most recent transactions first).
458-
Each returned item has the same fields as get_transaction: id, note, madeAt, category (name), type, isPending, amount.
471+
Each returned item has the same fields as get_transaction: id, note, madeAt, category (name), isPending, type, amount, labels.
459472
Optionally, the returned fields can be limited by the 'fields' parameter, default is ["note", "madeAt", "category", "amount"].
460473
Category IDs are automatically resolved to category names.
461474
475+
Fields of a transaction (no other fields are supported!):
476+
- id (str): UUID of the transaction
477+
- note (str): Transaction note/description
478+
- madeAt (str): ISO 8601 timestamp of when the transaction was made
479+
- category (str): Category name
480+
- isPending (bool): Whether the transaction is pending
481+
- type (str): Wether the transactions is one of "regular" or "transfer" (across wallets, or out-of-spendee).
482+
- amount (int): Amount of the transaction in the wallet's currency
483+
- labels (list): List of label names associated with the transaction. Not supported in filters.
484+
462485
Args:
463486
wallet_id (str): UUID of the wallet.
464487
start (str): Start date (ISO 8601, required).
465488
end (str, optional): End date (ISO 8601).
466489
filters (list, optional): List of filter dicts, e.g. [{"field": "amount", "op": ">=", "value": 100}].
467490
The 'field' and 'op' values must be strings.
468-
Supported operators: "=", "~=", ">", ">=", "<", "<=", where "~=" is regex match.
469-
Does not support filtering by labels.
491+
Supported operators: "=", "~=", ">", ">=", "<", "<=", "array-contains", where "~=" is regex match and "array-contains" is for labels only.
492+
If not provided, use an empty list.
470493
limit (int, optional): Max number of transactions to return (default 20).
471494
fields (list, optional): List of field names to include in the result. Only supported fields are allowed.
472495
as_json (bool, optional): Return as JSON string if True.
@@ -510,6 +533,34 @@ def list_transactions(self,
510533
logger.info(f"Found {len(results)} transactions.")
511534
return self.as_json(results) if as_json else results
512535

536+
@mcp_tool
537+
def aggregate_transactions(self, wallet_id: str, start: str, end: str = None, filters: list = []) -> float:
538+
"""
539+
Aggregate transactions for a wallet, filtered by date range and dynamic filters.
540+
Returns the total sum of amounts of the matching transactions.
541+
542+
Args:
543+
wallet_id (str): UUID of the wallet.
544+
start (str): Start date (ISO 8601, required).
545+
end (str, optional): End date (ISO 8601).
546+
filters (list, optional): List of filter dicts, e.g. [{"field": "amount", "op": ">=", "value": 100}].
547+
The 'field' and 'op' values must be strings.
548+
Supported operators: "=", "~=", ">", ">=", "<", "<=", "array-contains", where "~=" is regex match and "array-contains" is for labels only.
549+
If not provided, use an empty list.
550+
Returns:
551+
int: The total sum of amounts of the matching transactions.
552+
"""
553+
logger.info(f"Aggregating transactions for wallet_id={wallet_id}, start={start}, end={end}, filters={filters}")
554+
555+
# Get raw transactions first
556+
raw_transactions = self.list_transactions(wallet_id, start, end, filters, limit=None, fields=["amount"], as_json=False)
557+
558+
# Sum the amounts
559+
total_amount = sum(float(tx.get("amount", 0)) for tx in raw_transactions)
560+
561+
logger.info(f"Total aggregated amount: {total_amount}")
562+
return total_amount
563+
513564
def _get_transation_labels(self, wallet_id: str, transaction_id: str, resolve_names: bool = True, as_json: bool = False):
514565
"""
515566
Get the labels of a specific transaction by its ID from a wallet.

0 commit comments

Comments
 (0)