Repository: bvanelli/actualpy
Pull request #125 (2025-03-25 18:04:00)

feat: Add query to retrieve the budget balance.
Creates functions:

- `get_budgeted_balance`: Returns the budgeted balance as shown by the Actual UI under the category for the individual month. Does not take into account previous months.
- `get_accumulated_budgeted_balance`: Returns the budgeted balance as shown by the Actual UI under the category. This is calculated by summing all considered budget values and subtracting all transactions for them.

Tested performance on my own budget and it looks like budgets are returned in less than 0.02 seconds, so it should be performant enough even with multiple consecutive queries.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/bvanelli/actualpy/pull/125?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) Report
Attention: Patch coverage is `13.63636%` with `19 lines` in your changes missing coverage. Please review.
> Project coverage is 97.27%. Comparing base [(`661b743`)](https://app.codecov.io/gh/bvanelli/actualpy/commit/661b743706cddfd300ba0592efa5f0bfe91fb70d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) to head [(`c27ea81`)](https://app.codecov.io/gh/bvanelli/actualpy/commit/c27ea816d1d0695369c4bfdf6ccd0a450256631f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli).

| [Files with missing lines](https://app.codecov.io/gh/bvanelli/actualpy/pull/125?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) | Patch % | Lines |
|---|---|---|
| [actual/queries.py](https://app.codecov.io/gh/bvanelli/actualpy/pull/125?src=pr&el=tree&filepath=actual%2Fqueries.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli#diff-YWN0dWFsL3F1ZXJpZXMucHk=) | 13.63% | [19 Missing :warning: ](https://app.codecov.io/gh/bvanelli/actualpy/pull/125?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #125      +/-   ##
==========================================
- Coverage   98.05%   97.27%   -0.78%     
==========================================
  Files          17       17              
  Lines        2363     2384      +21     
==========================================
+ Hits         2317     2319       +2     
- Misses         46       65      +19     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/bvanelli/actualpy/pull/125?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/actual/api/__init__.py b/actual/api/__init__.py
index 74ffa97..fac1f1b 100644
--- a/actual/api/__init__.py
+++ b/actual/api/__init__.py
@@ -90,13 +90,15 @@ class ActualServer:
                 json={"loginMethod": method},
                 headers={"X-ACTUAL-PASSWORD": password},
             )
-        response_dict = response.json()
         if response.status_code == 400 and "invalid-password" in response.text:
             raise AuthorizationError("Could not validate password on login.")
         elif response.status_code == 200 and "invalid-header" in response.text:
             # try the same login with the header
             return self.login(password, "header")
-        elif response_dict["status"] == "error":
+        elif response.status_code > 400:
+            raise AuthorizationError(f"Server returned an HTTP error '{response.status_code}': '{response.text}'")
+        response_dict = response.json()
+        if response_dict["status"] == "error":
             # for example, when not trusting the proxy
             raise AuthorizationError(f"Something went wrong on login: {response_dict['reason']}")
         login_response = LoginDTO.model_validate(response.json())
diff --git a/actual/database.py b/actual/database.py
index 9f51e28..290bc3e 100644
--- a/actual/database.py
+++ b/actual/database.py
@@ -42,6 +42,7 @@ from sqlmodel import (
 
 from actual.exceptions import ActualInvalidOperationError
 from actual.protobuf_models import HULC_Client, Message
+from actual.utils.conversions import cents_to_decimal, date_to_int, decimal_to_cents, int_to_date, month_range
 
 """
 This variable contains the internal model mappings for all databases. It solves a couple of issues, namely having the
@@ -263,7 +264,7 @@ class Accounts(BaseModel, table=True):
                 Transactions.tombstone == 0,
             )
         )
-        return decimal.Decimal(value) / 100
+        return cents_to_decimal(value)
 
     @property
     def notes(self) -> Optional[str]:
@@ -336,7 +337,7 @@ class Categories(BaseModel, table=True):
                 Transactions.tombstone == 0,
             )
         )
-        return decimal.Decimal(value) / 100
+        return cents_to_decimal(value)
 
 
 class CategoryGroups(BaseModel, table=True):
@@ -525,7 +526,7 @@ class Payees(BaseModel, table=True):
                 Transactions.tombstone == 0,
             )
         )
-        return decimal.Decimal(value) / 100
+        return cents_to_decimal(value)
 
 
 class Preferences(BaseModel, table=True):
@@ -687,19 +688,19 @@ class Transactions(BaseModel, table=True):
 
     def get_date(self) -> datetime.date:
         """Returns the transaction date as a datetime.date object, instead of as a string."""
-        return datetime.datetime.strptime(str(self.date), "%Y%m%d").date()
+        return int_to_date(self.date)
 
     def set_date(self, date: datetime.date):
         """Sets the transaction date as a datetime.date object, instead of as a string."""
-        self.date = int(datetime.date.strftime(date, "%Y%m%d"))
+        self.date = date_to_int(date)
 
     def set_amount(self, amount: Union[decimal.Decimal, int, float]):
         """Sets the amount as a decimal.Decimal object, instead of as an integer representing the number of cents."""
-        self.amount = int(round(amount * 100))
+        self.amount = decimal_to_cents(amount)
 
     def get_amount(self) -> decimal.Decimal:
         """Returns the amount as a decimal.Decimal, instead of as an integer representing the number of cents."""
-        return decimal.Decimal(self.amount) / decimal.Decimal(100)
+        return cents_to_decimal(self.amount)
 
 
 class ZeroBudgetMonths(SQLModel, table=True):
@@ -724,7 +725,7 @@ class BaseBudgets(BaseModel):
 
     def get_date(self) -> datetime.date:
         """Returns the transaction date as a datetime.date object, instead of as a string."""
-        return datetime.datetime.strptime(str(self.month), "%Y%m").date()
+        return int_to_date(self.month, month_only=True)
 
     def set_date(self, date: datetime.date):
         """
@@ -733,15 +734,15 @@ class BaseBudgets(BaseModel):
         If the date value contains a day, it will be truncated and only the month and year will be inserted, as the
         budget applies to a month.
         """
-        self.month = int(datetime.date.strftime(date, "%Y%m"))
+        self.month = date_to_int(date, month_only=True)
 
     def set_amount(self, amount: Union[decimal.Decimal, int, float]):
         """Sets the amount as a decimal.Decimal object, instead of as an integer representing the number of cents."""
-        self.amount = int(round(amount * 100))
+        self.amount = decimal_to_cents(amount)
 
     def get_amount(self) -> decimal.Decimal:
         """Returns the amount as a decimal.Decimal, instead of as an integer representing the number of cents."""
-        return decimal.Decimal(self.amount) / decimal.Decimal(100)
+        return cents_to_decimal(self.amount)
 
     @property
     def range(self) -> Tuple[datetime.date, datetime.date]:
@@ -750,20 +751,20 @@ class BaseBudgets(BaseModel):
 
         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
+        return month_range(self.get_date())
 
     @property
     def balance(self) -> decimal.Decimal:
         """
-        Returns the current balance of the budget.
+        Returns the current **spent** 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.
+
+        If you want to get the balance from the frontend, take a look at the query
+        [get_accumulated_budgeted_balance][actual.queries.get_accumulated_budgeted_balance] instead.
         """
-        budget_start, budget_end = (int(datetime.date.strftime(d, "%Y%m%d")) for d in self.range)
+        budget_start, budget_end = (date_to_int(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,
@@ -773,7 +774,7 @@ class BaseBudgets(BaseModel):
                 Transactions.tombstone == 0,
             )
         )
-        return decimal.Decimal(value) / 100
+        return cents_to_decimal(value)
 
 
 class ReflectBudgets(BaseBudgets, table=True):
diff --git a/actual/protobuf_models.py b/actual/protobuf_models.py
index b5bd9a9..824cb7d 100644
--- a/actual/protobuf_models.py
+++ b/actual/protobuf_models.py
@@ -49,7 +49,7 @@ class HULC_Client:
         for reference.
         """
         if not now:
-            now = datetime.datetime.utcnow()
+            now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
         count = f"{self.initial_count:0>4X}"
         self.initial_count += 1
         return f"{now.isoformat(timespec='milliseconds')}Z-{count}-{self.client_id}"
diff --git a/actual/queries.py b/actual/queries.py
index fcf2726..64da1d5 100644
--- a/actual/queries.py
+++ b/actual/queries.py
@@ -9,6 +9,7 @@ import warnings
 
 import sqlalchemy
 from pydantic import TypeAdapter
+from sqlalchemy import func
 from sqlalchemy.orm import joinedload
 from sqlalchemy.sql.expression import Select
 from sqlmodel import Session, select
@@ -32,6 +33,7 @@ from actual.database import (
 from actual.exceptions import ActualError
 from actual.protobuf_models import HULC_Client
 from actual.rules import Action, Condition, Rule, RuleSet
+from actual.utils.conversions import cents_to_decimal, current_timestamp, date_to_int, decimal_to_cents, month_range
 from actual.utils.title import title
 
 T = typing.TypeVar("T")
@@ -42,6 +44,7 @@ def _transactions_base_query(
     start_date: datetime.date = None,
     end_date: datetime.date = None,
     account: Accounts | str | None = None,
+    category: Categories | str | None = None,
     include_deleted: bool = False,
 ) -> Select:
     query = (
@@ -63,15 +66,43 @@ def _transactions_base_query(
         )
     )
     if start_date:
-        query = query.filter(Transactions.date >= int(datetime.date.strftime(start_date, "%Y%m%d")))
+        query = query.filter(Transactions.date >= date_to_int(start_date))
     if end_date:
-        query = query.filter(Transactions.date < int(datetime.date.strftime(end_date, "%Y%m%d")))
+        query = query.filter(Transactions.date < date_to_int(end_date))
     if not include_deleted:
         query = query.filter(sqlalchemy.func.coalesce(Transactions.tombstone, 0) == 0)
     if account:
         account = get_account(s, account)
         if account:
             query = query.filter(Transactions.acct == account.id)
+    if category:
+        category = get_category(s, category)
+        if category:
+            query = query.filter(Transactions.category_id == category.id)
+    return query
+
+
+def _balance_base_query(
+    s: Session,
+    start_date: datetime.date,
+    end_date: datetime.date,
+    account: Accounts | str | None = None,
+    category: Categories | str | None = None,
+) -> Select:
+    query = select(func.coalesce(func.sum(Transactions.amount), 0)).where(
+        Transactions.date >= date_to_int(start_date),
+        Transactions.date < date_to_int(end_date),
+        Transactions.is_parent == 0,
+        Transactions.tombstone == 0,
+    )
+    if account:
+        account = get_account(s, account)
+        if account:
+            query = query.filter(Transactions.acct == account.id)
+    if category:
+        category = get_category(s, category)
+        if category:
+            query = query.filter(Transactions.category_id == category.id)
     return query
 
 
@@ -81,6 +112,7 @@ def get_transactions(
     end_date: datetime.date = None,
     notes: str = None,
     account: Accounts | str | None = None,
+    category: Categories | str | None = None,
     is_parent: bool = False,
     include_deleted: bool = False,
     budget: ZeroBudgets | None = None,
@@ -94,6 +126,7 @@ def get_transactions(
     :param notes: optional notes filter for the transactions. This looks for a case-insensitive pattern rather than for
     the exact match, i.e. 'foo' would match 'Foo Bar'.
     :param account: optional account (either Account object or Account name) filter for the transactions.
+    :param category: optional category (either Category object or Category name) filter for the transactions.
     :param is_parent: optional boolean flag to indicate if a transaction is a parent. Parent transactions are either
     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`.
@@ -103,7 +136,7 @@ def get_transactions(
                    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 = _transactions_base_query(s, start_date, end_date, account, category, include_deleted)
     query = query.filter(Transactions.is_parent == int(is_parent))
     if notes:
         query = query.filter(Transactions.notes.ilike(f"%{sqlalchemy.text(notes).compile()}%"))
@@ -114,7 +147,7 @@ def get_transactions(
                 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)
+        budget_start, budget_end = (date_to_int(d) for d in budget.range)
         query = query.filter(
             Transactions.date >= budget_start,
             Transactions.date < budget_end,
@@ -194,17 +227,17 @@ def create_transaction_from_ids(
     process_payee: bool = True,
 ) -> Transactions:
     """Internal method to generate a transaction from ids instead of objects."""
-    date_int = int(datetime.date.strftime(date, "%Y%m%d"))
+    date_int = date_to_int(date)
     t = Transactions(
         id=str(uuid.uuid4()),
         acct=account_id,
         date=date_int,
-        amount=int(round(amount * 100)),
+        amount=decimal_to_cents(amount),
         category_id=category_id,
         notes=notes,
         reconciled=0,
         cleared=int(cleared),
-        sort_order=int(datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000),
+        sort_order=current_timestamp(),
         financial_id=imported_id,
         imported_description=imported_payee,
     )
@@ -654,9 +687,9 @@ def get_budgets(
              When the frontend shows a budget as 0.00, it might not be returned by this method.
     """
     table = _get_budget_table(s)
-    query = select(table).options(joinedload(table.category))
+    query = select(table).options(joinedload(table.category)).order_by(table.month.asc())
     if month:
-        month_filter = int(datetime.date.strftime(month, "%Y%m"))
+        month_filter = date_to_int(month, month_only=True)
         query = query.filter(table.month == month_filter)
     if category:
         category = get_category(s, category)
@@ -711,6 +744,81 @@ def create_budget(
     return budget
 
 
+def get_budgeted_balance(s: Session, month: datetime.date, category: str | Categories) -> decimal.Decimal:
+    """
+    Returns the budgeted balance as shown by the Actual UI under the category for the individual month. Does not take
+    into account previous months.
+
+    :param s: session from Actual local database.
+    :param month: month to get budgets for, as a date for that month. Use `datetime.date.today()` if you want the budget
+                  for current month.
+    :param category:  category to filter for the budget.
+    :return: A decimal representing the budget real balance for the category.
+    """
+
+    budget = get_budget(s, month, category)
+    if not budget:
+        # create a temporary budget
+        range_start, range_end = month_range(month)
+        balance = s.scalar(_balance_base_query(s, range_start, range_end, category=category))
+        budget_leftover = cents_to_decimal(balance)
+    else:
+        budget_leftover = budget.get_amount() + budget.balance  # we can sum because balance is negative
+    return budget_leftover
+
+
+def _get_first_positive_transaction(s: Session, category: Categories) -> typing.Optional[Transactions]:
+    """
+    Returns the first positive transaction in a certain category. This is used to find the month to start the
+    budgeting calculation, since it makes the budget positive.
+    """
+    query = select(Transactions).where(Transactions.amount > 0, Transactions.category_id == category.id)
+    return s.exec(query).first()
+
+
+def get_accumulated_budgeted_balance(s: Session, month: datetime.date, category: str | Categories) -> decimal.Decimal:
+    """
+    Returns the budgeted balance as shown by the Actual UI under the category. This is calculated by summing all
+    considered budget values and subtracting all transactions for them.
+
+    When using **envelope budget**, this value will accumulate with each consecutive month that your spending is
+    greater than your budget. If this value goes under 0.00, your budget is reset for the next month.
+
+    When using **tracking budget**, only the current month is considering for savings, so no previous values will carry
+    over.
+
+    :param s: session from Actual local database.
+    :param month: month to get budgets for, as a date for that month. Use `datetime.date.today()` if you want the budget
+                  for current month.
+    :param category:  category to filter for the budget.
+    :return: A decimal representing the budget real balance for the category. This is evaluated by adding all
+             previous leftover budgets that have a value greater than 0.
+    """
+    budgets = get_budgets(s, category=category)
+    is_tracking_budget = _get_budget_table(s) is ReflectBudgets
+    # the first ever budget is the longest we have to look for when searching for the running balance
+    # If the budget is set to tracking, the accumulated value will always be the months balance
+    if not budgets or is_tracking_budget:
+        return get_budgeted_balance(s, month, category)
+    first_budget_month = budgets[0].get_date()
+    # Get first positive transaction
+    first_positive_transaction = _get_first_positive_transaction(s, category)
+    first_transaction_month = (
+        first_positive_transaction.get_date() if first_positive_transaction else first_budget_month
+    )
+    # current month is the least of those two dates
+    current_month = min(first_budget_month, first_transaction_month)
+    accumulated_balance = decimal.Decimal(0)
+    while current_month <= month:
+        if accumulated_balance < 0:
+            accumulated_balance = decimal.Decimal(0)
+        current_month_balance = get_budgeted_balance(s, current_month, category)
+        accumulated_balance += current_month_balance
+        # go to the next month
+        current_month = (current_month.replace(day=1) + datetime.timedelta(days=31)).replace(day=1)
+    return accumulated_balance
+
+
 def create_transfer(
     s: Session,
     date: datetime.date,
diff --git a/actual/utils/conversions.py b/actual/utils/conversions.py
new file mode 100644
index 0000000..ae313a1
--- /dev/null
+++ b/actual/utils/conversions.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+import datetime
+import decimal
+from typing import Tuple
+
+
+def date_to_int(date: datetime.date, month_only: bool = False) -> int:
+    """
+    Converts a date object to an integer representation. For example, the `date(2025, 3, 10)` gets converted to
+    `20250310`.
+
+    If `month_only` is set to `True`, the day will be removed from the date. For example, the same date above gets
+    converted to `202503`.
+    """
+    date_format = "%Y%m" if month_only else "%Y%m%d"
+    return int(datetime.date.strftime(date, date_format))
+
+
+def int_to_date(date: int | str, month_only: bool = False) -> datetime.date:
+    """
+    Converts an `int` or `str` object to the `datetime.date` representation. For example, the int `20250310`
+    gets converted to `date(2025, 3, 10)`.
+    """
+    date_format = "%Y%m" if month_only else "%Y%m%d"
+    return datetime.datetime.strptime(str(date), date_format).date()
+
+
+def month_range(month: datetime.date) -> Tuple[datetime.date, datetime.date]:
+    """
+    Range of the provided `month` as a tuple [start, end).
+
+    The end date is not inclusive, as it represents the start of the next month.
+    """
+    range_start = month.replace(day=1)
+    # conversion taken from https://stackoverflow.com/a/59199379/12681470
+    range_end = (range_start + datetime.timedelta(days=32)).replace(day=1)
+    return range_start, range_end
+
+
+def current_timestamp() -> int:
+    """Returns the current timestamp in milliseconds, using UTC time."""
+    return int(datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None).timestamp() * 1000)
+
+
+def cents_to_decimal(amount: int) -> decimal.Decimal:
+    """Converts the number of cents to a `decimal.Decimal` object. When providing `500`, the result will be
+    `decimal.Decimal(5.0)`.
+    """
+    return decimal.Decimal(amount) / decimal.Decimal(100)
+
+
+def decimal_to_cents(amount: decimal.Decimal | int | float) -> int:
+    """Converts the decimal amount (`decimal.Decimal` or `int` or `float`) to an integer value. When providing
+    `decimal.Decimal(5.0)`, the result will be `500`."""
+    return int(round(amount * 100))


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
index ec60b70..1e6fb66 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -47,6 +47,16 @@ def test_api_login_unknown_error(_post, mocker):
         actual.login("foo")
 
 
+@patch.object(Session, "post", return_value=RequestsMock({}, status_code=403))
+def test_api_login_http_error(_post, mocker):
+    mocker.patch("actual.Actual.validate")
+    actual = Actual(token="foo")
+    actual.api_url = "localhost"
+    actual.cert = False
+    with pytest.raises(AuthorizationError, match="HTTP error '403'"):
+        actual.login("foo")
+
+
 def test_no_certificate(mocker):
     mocker.patch("actual.Actual.validate")
     actual = Actual(token="foo", cert=False)
diff --git a/tests/test_database.py b/tests/test_database.py
index f3072d5..0b21243 100644
--- a/tests/test_database.py
+++ b/tests/test_database.py
@@ -15,6 +15,7 @@ from actual.queries import (
     create_transaction,
     create_transfer,
     get_accounts,
+    get_accumulated_budgeted_balance,
     get_budgets,
     get_or_create_category,
     get_or_create_clock,
@@ -149,6 +150,9 @@ def test_create_splits(session):
     parents = get_transactions(session, is_parent=True)
     assert len(parents) == 1
     assert len(parents[0].splits) == 2
+    # find all with category
+    category = get_transactions(session, category="Dining")
+    assert len(category) == 1
 
 
 def test_create_splits_error(session):
@@ -239,6 +243,43 @@ def test_budgets(session, budget_type, budget_table):
         get_transactions(session, date(2024, 9, 1), date(2024, 9, 15), budget=budget)
 
 
+@pytest.mark.parametrize(
+    "budget_type,with_reset,with_previous_value,expected_value_previous_month,expected_value_current_month",
+    [
+        ("rollover", False, False, decimal.Decimal(5), decimal.Decimal(25)),
+        ("rollover", False, True, decimal.Decimal(15), decimal.Decimal(35)),
+        ("rollover", True, True, decimal.Decimal(-5), decimal.Decimal(20)),
+        ("rollover", True, False, decimal.Decimal(-15), decimal.Decimal(20)),
+        ("report", False, True, decimal.Decimal(-5), decimal.Decimal(20)),
+    ],
+)
+def test_accumulated_budget_amount(
+    session, budget_type, with_reset, with_previous_value, expected_value_current_month, expected_value_previous_month
+):
+    get_or_create_preference(session, "budgetType", budget_type)
+
+    category = get_or_create_category(session, "Expenses")
+    bank = create_account(session, "Bank")
+
+    # create three months of budgets
+    create_budget(session, date(2025, 1, 1), category, 20.0)
+    create_budget(session, date(2025, 2, 1), category, 20.0)
+    create_budget(session, date(2025, 3, 1), category, 20.0)
+    # should be considered since is an income before the beginning of the budget period
+    if with_previous_value:
+        create_transaction(session, date(2024, 10, 1), bank, category=category, amount=10.0)
+    # other transactions
+    create_transaction(session, date(2025, 1, 1), bank, category=category, amount=-10.0)
+    create_transaction(session, date(2025, 2, 1), bank, category=category, amount=-10.0)
+    create_transaction(session, date(2025, 2, 3), bank, category=category, amount=-15.0)
+    # should reset rollover budget
+    if with_reset:
+        create_transaction(session, date(2025, 2, 4), bank, category=category, amount=-20.0)
+
+    assert get_accumulated_budgeted_balance(session, date(2025, 2, 1), category) == expected_value_previous_month
+    assert get_accumulated_budgeted_balance(session, date(2025, 3, 1), category) == expected_value_current_month
+
+
 def test_normalize_payee():
     assert normalize_payee("   mY paYeE ") == "My Payee"
     assert normalize_payee("  ", raw_payee_name=True) == ""
diff --git a/tests/test_models.py b/tests/test_models.py
index 0d2ceae..4deede6 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -9,6 +9,7 @@ from actual.database import (
     get_attribute_by_table_name,
     get_class_by_table_name,
 )
+from actual.utils.conversions import current_timestamp
 
 
 def test_get_class_by_table_name():
@@ -34,7 +35,7 @@ def test_conversion():
         amount=1000,
         reconciled=0,
         cleared=0,
-        sort_order=datetime.datetime.utcnow().timestamp(),
+        sort_order=current_timestamp(),
     )
     t.set_amount(10)
     t.set_date(datetime.date(2024, 3, 17))