Repository: bvanelli/actualpy

Issue:
Category from budgets should be scoped to the filtered month
### Description

I'd think something like

```python

budget = get_budget(actual.session, datetime.date.today(), 'Online Shopping')
LOGGER.debug("%s", budget.category.balance)
```

Would only include transactions from the filtered month instead of every transaction

Discussion:
@bvanelli: Makes, since, it's actually a bug from my side that I do not currently do that, I'll fix it soon.@bvanelli: It was actually missing, checkout #91, feedback is welcome.

Solution patch:
diff --git a/actual/database.py b/actual/database.py
index a131f91..2847a55 100644
--- a/actual/database.py
+++ b/actual/database.py
@@ -13,7 +13,7 @@
 
 import datetime
 import decimal
-from typing import Dict, List, Optional, Union
+from typing import Dict, List, Optional, Tuple, Union
 
 from sqlalchemy import MetaData, Table, engine, event, inspect
 from sqlalchemy.dialects.sqlite import insert
@@ -669,6 +669,31 @@ def set_amount(self, amount: Union[decimal.Decimal, int, float]):
     def get_amount(self) -> decimal.Decimal:
         return decimal.Decimal(self.amount) / decimal.Decimal(100)
 
+    @property
+    def range(self) -> Tuple[datetime.date, datetime.date]:
+        """Range of the budget as a tuple [start, end). The end date is not inclusive, as it represents the start of the
+        next month."""
+        budget_start = self.get_date().replace(day=1)
+        # conversion taken from https://stackoverflow.com/a/59199379/12681470
+        budget_end = (budget_start + datetime.timedelta(days=32)).replace(day=1)
+        return budget_start, budget_end
+
+    @property
+    def balance(self) -> decimal.Decimal:
+        """Returns the current balance of the budget. The evaluation will take into account the budget month and
+        only selected transactions for the combination month and category. Deleted transactions are ignored."""
+        budget_start, budget_end = (int(datetime.date.strftime(d, "%Y%m%d")) for d in self.range)
+        value = object_session(self).scalar(
+            select(func.coalesce(func.sum(Transactions.amount), 0)).where(
+                Transactions.category_id == self.category_id,
+                Transactions.date >= budget_start,
+                Transactions.date < budget_end,
+                Transactions.is_parent == 0,
+                Transactions.tombstone == 0,
+            )
+        )
+        return decimal.Decimal(value) / 100
+
 
 class PendingTransactions(SQLModel, table=True):
     __tablename__ = "pending_transactions"
diff --git a/actual/queries.py b/actual/queries.py
index 9960497..6e62d78 100644
--- a/actual/queries.py
+++ b/actual/queries.py
@@ -5,6 +5,7 @@
 import json
 import typing
 import uuid
+import warnings
 
 import sqlalchemy
 from pydantic import TypeAdapter
@@ -78,6 +79,7 @@ def get_transactions(
     account: Accounts | str | None = None,
     is_parent: bool = False,
     include_deleted: bool = False,
+    budget: ZeroBudgets | None = None,
 ) -> typing.Sequence[Transactions]:
     """
     Returns a list of all available transactions, sorted by date in descending order.
@@ -92,12 +94,28 @@ def get_transactions(
     single transactions or the main transaction with `Transactions.splits` property. Default is to return all individual
     splits, and the parent can be retrieved by `Transactions.parent`.
     :param include_deleted: includes deleted transactions from the search.
+    :param budget: optional budget filter for the transactions. The budget range and category will be used to filter the
+                   final results. **Usually not used together with the `start_date` and `end_date` filters, as they
+                   might hide results.
     :return: list of transactions with `account`, `category` and `payee` preloaded.
     """
     query = _transactions_base_query(s, start_date, end_date, account, include_deleted)
     query = query.filter(Transactions.is_parent == int(is_parent))
     if notes:
         query = query.filter(Transactions.notes.ilike(f"%{sqlalchemy.text(notes).compile()}%"))
+    if budget:
+        budget_start, budget_end = budget.range
+        if (start_date and start_date >= budget_end) or (end_date and end_date < budget_start):
+            warnings.warn(
+                f"Provided date filters [{start_date}, {end_date}) to get_transactions are outside the bounds of the "
+                f"budget range [{budget_start}, {budget_end}). Results might be empty!"
+            )
+        budget_start, budget_end = (int(datetime.date.strftime(d, "%Y%m%d")) for d in budget.range)
+        query = query.filter(
+            Transactions.date >= budget_start,
+            Transactions.date < budget_end,
+            Transactions.category_id == budget.category_id,
+        )
     return s.exec(query).all()
 
 


Test patch:
diff --git a/tests/test_database.py b/tests/test_database.py
index 3608bd5..685a294 100644
--- a/tests/test_database.py
+++ b/tests/test_database.py
@@ -192,6 +192,7 @@ def test_rule_insertion_method(session):
 def test_budgets(session):
     # insert a budget
     category = get_or_create_category(session, "Expenses")
+    unrelated_category = get_or_create_category(session, "Unrelated")
     session.commit()
     create_budget(session, date(2024, 10, 7), category, 10.0)
     assert len(get_budgets(session)) == 1
@@ -204,9 +205,26 @@ def test_budgets(session):
     # get a budget that already exists, but re-set it
     create_budget(session, date(2024, 10, 7), category, 20.0)
     assert budget.get_amount() == 20.0
+    assert budget.range == (date(2024, 10, 1), date(2024, 11, 1))
+    # insert a transaction in the range and see if they are counted on the balance
+    bank = create_account(session, "Bank")
+    t1 = create_transaction(session, date(2024, 10, 1), bank, category=category, amount=-10.0)
+    t2 = create_transaction(session, date(2024, 10, 15), bank, category=category, amount=-10.0)
+    t3 = create_transaction(session, date(2024, 10, 31), bank, category=category, amount=-15.0)
+    # should not be counted
+    create_transaction(session, date(2024, 10, 1), bank, category=category, amount=-15.0).delete()
+    create_transaction(session, date(2024, 11, 1), bank, category=category, amount=-20.0)
+    create_transaction(session, date(2024, 10, 15), bank, category=unrelated_category, amount=-20.0)
+    assert budget.balance == -35.0
+    budget_transactions = get_transactions(session, budget=budget)
+    assert len(budget_transactions) == 3
+    assert all(t in budget_transactions for t in (t1, t2, t3))
     # test if it fails if category does not exist
     with pytest.raises(ActualError, match="Category is provided but does not exist"):
         get_budgets(session, category="foo")
+    # filtering by budget will raise a warning if get_transactions with budget also provides a start-end outside range
+    with pytest.warns(match="Provided date filters"):
+        get_transactions(session, date(2024, 9, 1), date(2024, 9, 15), budget=budget)
 
 
 def test_normalize_payee():