Skip to content

Commit 684564c

Browse files
authoredMar 27, 2025··
feat: Add query to retrieve the budget balance. (#125)
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.
1 parent 661b743 commit 684564c

8 files changed

+250
-31
lines changed
 

‎actual/api/__init__.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,15 @@ def login(self, password: str, method: Literal["password", "header"] = "password
9090
json={"loginMethod": method},
9191
headers={"X-ACTUAL-PASSWORD": password},
9292
)
93-
response_dict = response.json()
9493
if response.status_code == 400 and "invalid-password" in response.text:
9594
raise AuthorizationError("Could not validate password on login.")
9695
elif response.status_code == 200 and "invalid-header" in response.text:
9796
# try the same login with the header
9897
return self.login(password, "header")
99-
elif response_dict["status"] == "error":
98+
elif response.status_code > 400:
99+
raise AuthorizationError(f"Server returned an HTTP error '{response.status_code}': '{response.text}'")
100+
response_dict = response.json()
101+
if response_dict["status"] == "error":
100102
# for example, when not trusting the proxy
101103
raise AuthorizationError(f"Something went wrong on login: {response_dict['reason']}")
102104
login_response = LoginDTO.model_validate(response.json())

‎actual/database.py

+19-18
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242

4343
from actual.exceptions import ActualInvalidOperationError
4444
from actual.protobuf_models import HULC_Client, Message
45+
from actual.utils.conversions import cents_to_decimal, date_to_int, decimal_to_cents, int_to_date, month_range
4546

4647
"""
4748
This variable contains the internal model mappings for all databases. It solves a couple of issues, namely having the
@@ -263,7 +264,7 @@ def balance(self) -> decimal.Decimal:
263264
Transactions.tombstone == 0,
264265
)
265266
)
266-
return decimal.Decimal(value) / 100
267+
return cents_to_decimal(value)
267268

268269
@property
269270
def notes(self) -> Optional[str]:
@@ -336,7 +337,7 @@ def balance(self) -> decimal.Decimal:
336337
Transactions.tombstone == 0,
337338
)
338339
)
339-
return decimal.Decimal(value) / 100
340+
return cents_to_decimal(value)
340341

341342

342343
class CategoryGroups(BaseModel, table=True):
@@ -525,7 +526,7 @@ def balance(self) -> decimal.Decimal:
525526
Transactions.tombstone == 0,
526527
)
527528
)
528-
return decimal.Decimal(value) / 100
529+
return cents_to_decimal(value)
529530

530531

531532
class Preferences(BaseModel, table=True):
@@ -687,19 +688,19 @@ class Transactions(BaseModel, table=True):
687688

688689
def get_date(self) -> datetime.date:
689690
"""Returns the transaction date as a datetime.date object, instead of as a string."""
690-
return datetime.datetime.strptime(str(self.date), "%Y%m%d").date()
691+
return int_to_date(self.date)
691692

692693
def set_date(self, date: datetime.date):
693694
"""Sets the transaction date as a datetime.date object, instead of as a string."""
694-
self.date = int(datetime.date.strftime(date, "%Y%m%d"))
695+
self.date = date_to_int(date)
695696

696697
def set_amount(self, amount: Union[decimal.Decimal, int, float]):
697698
"""Sets the amount as a decimal.Decimal object, instead of as an integer representing the number of cents."""
698-
self.amount = int(round(amount * 100))
699+
self.amount = decimal_to_cents(amount)
699700

700701
def get_amount(self) -> decimal.Decimal:
701702
"""Returns the amount as a decimal.Decimal, instead of as an integer representing the number of cents."""
702-
return decimal.Decimal(self.amount) / decimal.Decimal(100)
703+
return cents_to_decimal(self.amount)
703704

704705

705706
class ZeroBudgetMonths(SQLModel, table=True):
@@ -724,7 +725,7 @@ class BaseBudgets(BaseModel):
724725

725726
def get_date(self) -> datetime.date:
726727
"""Returns the transaction date as a datetime.date object, instead of as a string."""
727-
return datetime.datetime.strptime(str(self.month), "%Y%m").date()
728+
return int_to_date(self.month, month_only=True)
728729

729730
def set_date(self, date: datetime.date):
730731
"""
@@ -733,15 +734,15 @@ def set_date(self, date: datetime.date):
733734
If the date value contains a day, it will be truncated and only the month and year will be inserted, as the
734735
budget applies to a month.
735736
"""
736-
self.month = int(datetime.date.strftime(date, "%Y%m"))
737+
self.month = date_to_int(date, month_only=True)
737738

738739
def set_amount(self, amount: Union[decimal.Decimal, int, float]):
739740
"""Sets the amount as a decimal.Decimal object, instead of as an integer representing the number of cents."""
740-
self.amount = int(round(amount * 100))
741+
self.amount = decimal_to_cents(amount)
741742

742743
def get_amount(self) -> decimal.Decimal:
743744
"""Returns the amount as a decimal.Decimal, instead of as an integer representing the number of cents."""
744-
return decimal.Decimal(self.amount) / decimal.Decimal(100)
745+
return cents_to_decimal(self.amount)
745746

746747
@property
747748
def range(self) -> Tuple[datetime.date, datetime.date]:
@@ -750,20 +751,20 @@ def range(self) -> Tuple[datetime.date, datetime.date]:
750751
751752
The end date is not inclusive, as it represents the start of the next month.
752753
"""
753-
budget_start = self.get_date().replace(day=1)
754-
# conversion taken from https://stackoverflow.com/a/59199379/12681470
755-
budget_end = (budget_start + datetime.timedelta(days=32)).replace(day=1)
756-
return budget_start, budget_end
754+
return month_range(self.get_date())
757755

758756
@property
759757
def balance(self) -> decimal.Decimal:
760758
"""
761-
Returns the current balance of the budget.
759+
Returns the current **spent** balance of the budget.
762760
763761
The evaluation will take into account the budget month and only selected transactions for the combination month
764762
and category. Deleted transactions are ignored.
763+
764+
If you want to get the balance from the frontend, take a look at the query
765+
[get_accumulated_budgeted_balance][actual.queries.get_accumulated_budgeted_balance] instead.
765766
"""
766-
budget_start, budget_end = (int(datetime.date.strftime(d, "%Y%m%d")) for d in self.range)
767+
budget_start, budget_end = (date_to_int(d) for d in self.range)
767768
value = object_session(self).scalar(
768769
select(func.coalesce(func.sum(Transactions.amount), 0)).where(
769770
Transactions.category_id == self.category_id,
@@ -773,7 +774,7 @@ def balance(self) -> decimal.Decimal:
773774
Transactions.tombstone == 0,
774775
)
775776
)
776-
return decimal.Decimal(value) / 100
777+
return cents_to_decimal(value)
777778

778779

779780
class ReflectBudgets(BaseBudgets, table=True):

‎actual/protobuf_models.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def timestamp(self, now: datetime.datetime = None) -> str:
4949
for reference.
5050
"""
5151
if not now:
52-
now = datetime.datetime.utcnow()
52+
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
5353
count = f"{self.initial_count:0>4X}"
5454
self.initial_count += 1
5555
return f"{now.isoformat(timespec='milliseconds')}Z-{count}-{self.client_id}"

‎actual/queries.py

+117-9
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import sqlalchemy
1111
from pydantic import TypeAdapter
12+
from sqlalchemy import func
1213
from sqlalchemy.orm import joinedload
1314
from sqlalchemy.sql.expression import Select
1415
from sqlmodel import Session, select
@@ -32,6 +33,7 @@
3233
from actual.exceptions import ActualError
3334
from actual.protobuf_models import HULC_Client
3435
from actual.rules import Action, Condition, Rule, RuleSet
36+
from actual.utils.conversions import cents_to_decimal, current_timestamp, date_to_int, decimal_to_cents, month_range
3537
from actual.utils.title import title
3638

3739
T = typing.TypeVar("T")
@@ -42,6 +44,7 @@ def _transactions_base_query(
4244
start_date: datetime.date = None,
4345
end_date: datetime.date = None,
4446
account: Accounts | str | None = None,
47+
category: Categories | str | None = None,
4548
include_deleted: bool = False,
4649
) -> Select:
4750
query = (
@@ -63,15 +66,43 @@ def _transactions_base_query(
6366
)
6467
)
6568
if start_date:
66-
query = query.filter(Transactions.date >= int(datetime.date.strftime(start_date, "%Y%m%d")))
69+
query = query.filter(Transactions.date >= date_to_int(start_date))
6770
if end_date:
68-
query = query.filter(Transactions.date < int(datetime.date.strftime(end_date, "%Y%m%d")))
71+
query = query.filter(Transactions.date < date_to_int(end_date))
6972
if not include_deleted:
7073
query = query.filter(sqlalchemy.func.coalesce(Transactions.tombstone, 0) == 0)
7174
if account:
7275
account = get_account(s, account)
7376
if account:
7477
query = query.filter(Transactions.acct == account.id)
78+
if category:
79+
category = get_category(s, category)
80+
if category:
81+
query = query.filter(Transactions.category_id == category.id)
82+
return query
83+
84+
85+
def _balance_base_query(
86+
s: Session,
87+
start_date: datetime.date,
88+
end_date: datetime.date,
89+
account: Accounts | str | None = None,
90+
category: Categories | str | None = None,
91+
) -> Select:
92+
query = select(func.coalesce(func.sum(Transactions.amount), 0)).where(
93+
Transactions.date >= date_to_int(start_date),
94+
Transactions.date < date_to_int(end_date),
95+
Transactions.is_parent == 0,
96+
Transactions.tombstone == 0,
97+
)
98+
if account:
99+
account = get_account(s, account)
100+
if account:
101+
query = query.filter(Transactions.acct == account.id)
102+
if category:
103+
category = get_category(s, category)
104+
if category:
105+
query = query.filter(Transactions.category_id == category.id)
75106
return query
76107

77108

@@ -81,6 +112,7 @@ def get_transactions(
81112
end_date: datetime.date = None,
82113
notes: str = None,
83114
account: Accounts | str | None = None,
115+
category: Categories | str | None = None,
84116
is_parent: bool = False,
85117
include_deleted: bool = False,
86118
budget: ZeroBudgets | None = None,
@@ -94,6 +126,7 @@ def get_transactions(
94126
:param notes: optional notes filter for the transactions. This looks for a case-insensitive pattern rather than for
95127
the exact match, i.e. 'foo' would match 'Foo Bar'.
96128
:param account: optional account (either Account object or Account name) filter for the transactions.
129+
:param category: optional category (either Category object or Category name) filter for the transactions.
97130
:param is_parent: optional boolean flag to indicate if a transaction is a parent. Parent transactions are either
98131
single transactions or the main transaction with `Transactions.splits` property. Default is to return all individual
99132
splits, and the parent can be retrieved by `Transactions.parent`.
@@ -103,7 +136,7 @@ def get_transactions(
103136
might hide results.
104137
:return: list of transactions with `account`, `category` and `payee` preloaded.
105138
"""
106-
query = _transactions_base_query(s, start_date, end_date, account, include_deleted)
139+
query = _transactions_base_query(s, start_date, end_date, account, category, include_deleted)
107140
query = query.filter(Transactions.is_parent == int(is_parent))
108141
if notes:
109142
query = query.filter(Transactions.notes.ilike(f"%{sqlalchemy.text(notes).compile()}%"))
@@ -114,7 +147,7 @@ def get_transactions(
114147
f"Provided date filters [{start_date}, {end_date}) to get_transactions are outside the bounds of the "
115148
f"budget range [{budget_start}, {budget_end}). Results might be empty!"
116149
)
117-
budget_start, budget_end = (int(datetime.date.strftime(d, "%Y%m%d")) for d in budget.range)
150+
budget_start, budget_end = (date_to_int(d) for d in budget.range)
118151
query = query.filter(
119152
Transactions.date >= budget_start,
120153
Transactions.date < budget_end,
@@ -194,17 +227,17 @@ def create_transaction_from_ids(
194227
process_payee: bool = True,
195228
) -> Transactions:
196229
"""Internal method to generate a transaction from ids instead of objects."""
197-
date_int = int(datetime.date.strftime(date, "%Y%m%d"))
230+
date_int = date_to_int(date)
198231
t = Transactions(
199232
id=str(uuid.uuid4()),
200233
acct=account_id,
201234
date=date_int,
202-
amount=int(round(amount * 100)),
235+
amount=decimal_to_cents(amount),
203236
category_id=category_id,
204237
notes=notes,
205238
reconciled=0,
206239
cleared=int(cleared),
207-
sort_order=int(datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000),
240+
sort_order=current_timestamp(),
208241
financial_id=imported_id,
209242
imported_description=imported_payee,
210243
)
@@ -654,9 +687,9 @@ def get_budgets(
654687
When the frontend shows a budget as 0.00, it might not be returned by this method.
655688
"""
656689
table = _get_budget_table(s)
657-
query = select(table).options(joinedload(table.category))
690+
query = select(table).options(joinedload(table.category)).order_by(table.month.asc())
658691
if month:
659-
month_filter = int(datetime.date.strftime(month, "%Y%m"))
692+
month_filter = date_to_int(month, month_only=True)
660693
query = query.filter(table.month == month_filter)
661694
if category:
662695
category = get_category(s, category)
@@ -711,6 +744,81 @@ def create_budget(
711744
return budget
712745

713746

747+
def get_budgeted_balance(s: Session, month: datetime.date, category: str | Categories) -> decimal.Decimal:
748+
"""
749+
Returns the budgeted balance as shown by the Actual UI under the category for the individual month. Does not take
750+
into account previous months.
751+
752+
:param s: session from Actual local database.
753+
:param month: month to get budgets for, as a date for that month. Use `datetime.date.today()` if you want the budget
754+
for current month.
755+
:param category: category to filter for the budget.
756+
:return: A decimal representing the budget real balance for the category.
757+
"""
758+
759+
budget = get_budget(s, month, category)
760+
if not budget:
761+
# create a temporary budget
762+
range_start, range_end = month_range(month)
763+
balance = s.scalar(_balance_base_query(s, range_start, range_end, category=category))
764+
budget_leftover = cents_to_decimal(balance)
765+
else:
766+
budget_leftover = budget.get_amount() + budget.balance # we can sum because balance is negative
767+
return budget_leftover
768+
769+
770+
def _get_first_positive_transaction(s: Session, category: Categories) -> typing.Optional[Transactions]:
771+
"""
772+
Returns the first positive transaction in a certain category. This is used to find the month to start the
773+
budgeting calculation, since it makes the budget positive.
774+
"""
775+
query = select(Transactions).where(Transactions.amount > 0, Transactions.category_id == category.id)
776+
return s.exec(query).first()
777+
778+
779+
def get_accumulated_budgeted_balance(s: Session, month: datetime.date, category: str | Categories) -> decimal.Decimal:
780+
"""
781+
Returns the budgeted balance as shown by the Actual UI under the category. This is calculated by summing all
782+
considered budget values and subtracting all transactions for them.
783+
784+
When using **envelope budget**, this value will accumulate with each consecutive month that your spending is
785+
greater than your budget. If this value goes under 0.00, your budget is reset for the next month.
786+
787+
When using **tracking budget**, only the current month is considering for savings, so no previous values will carry
788+
over.
789+
790+
:param s: session from Actual local database.
791+
:param month: month to get budgets for, as a date for that month. Use `datetime.date.today()` if you want the budget
792+
for current month.
793+
:param category: category to filter for the budget.
794+
:return: A decimal representing the budget real balance for the category. This is evaluated by adding all
795+
previous leftover budgets that have a value greater than 0.
796+
"""
797+
budgets = get_budgets(s, category=category)
798+
is_tracking_budget = _get_budget_table(s) is ReflectBudgets
799+
# the first ever budget is the longest we have to look for when searching for the running balance
800+
# If the budget is set to tracking, the accumulated value will always be the months balance
801+
if not budgets or is_tracking_budget:
802+
return get_budgeted_balance(s, month, category)
803+
first_budget_month = budgets[0].get_date()
804+
# Get first positive transaction
805+
first_positive_transaction = _get_first_positive_transaction(s, category)
806+
first_transaction_month = (
807+
first_positive_transaction.get_date() if first_positive_transaction else first_budget_month
808+
)
809+
# current month is the least of those two dates
810+
current_month = min(first_budget_month, first_transaction_month)
811+
accumulated_balance = decimal.Decimal(0)
812+
while current_month <= month:
813+
if accumulated_balance < 0:
814+
accumulated_balance = decimal.Decimal(0)
815+
current_month_balance = get_budgeted_balance(s, current_month, category)
816+
accumulated_balance += current_month_balance
817+
# go to the next month
818+
current_month = (current_month.replace(day=1) + datetime.timedelta(days=31)).replace(day=1)
819+
return accumulated_balance
820+
821+
714822
def create_transfer(
715823
s: Session,
716824
date: datetime.date,

0 commit comments

Comments
 (0)
Please sign in to comment.