9
9
10
10
import sqlalchemy
11
11
from pydantic import TypeAdapter
12
+ from sqlalchemy import func
12
13
from sqlalchemy .orm import joinedload
13
14
from sqlalchemy .sql .expression import Select
14
15
from sqlmodel import Session , select
32
33
from actual .exceptions import ActualError
33
34
from actual .protobuf_models import HULC_Client
34
35
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
35
37
from actual .utils .title import title
36
38
37
39
T = typing .TypeVar ("T" )
@@ -42,6 +44,7 @@ def _transactions_base_query(
42
44
start_date : datetime .date = None ,
43
45
end_date : datetime .date = None ,
44
46
account : Accounts | str | None = None ,
47
+ category : Categories | str | None = None ,
45
48
include_deleted : bool = False ,
46
49
) -> Select :
47
50
query = (
@@ -63,15 +66,43 @@ def _transactions_base_query(
63
66
)
64
67
)
65
68
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 ))
67
70
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 ))
69
72
if not include_deleted :
70
73
query = query .filter (sqlalchemy .func .coalesce (Transactions .tombstone , 0 ) == 0 )
71
74
if account :
72
75
account = get_account (s , account )
73
76
if account :
74
77
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 )
75
106
return query
76
107
77
108
@@ -81,6 +112,7 @@ def get_transactions(
81
112
end_date : datetime .date = None ,
82
113
notes : str = None ,
83
114
account : Accounts | str | None = None ,
115
+ category : Categories | str | None = None ,
84
116
is_parent : bool = False ,
85
117
include_deleted : bool = False ,
86
118
budget : ZeroBudgets | None = None ,
@@ -94,6 +126,7 @@ def get_transactions(
94
126
:param notes: optional notes filter for the transactions. This looks for a case-insensitive pattern rather than for
95
127
the exact match, i.e. 'foo' would match 'Foo Bar'.
96
128
: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.
97
130
:param is_parent: optional boolean flag to indicate if a transaction is a parent. Parent transactions are either
98
131
single transactions or the main transaction with `Transactions.splits` property. Default is to return all individual
99
132
splits, and the parent can be retrieved by `Transactions.parent`.
@@ -103,7 +136,7 @@ def get_transactions(
103
136
might hide results.
104
137
:return: list of transactions with `account`, `category` and `payee` preloaded.
105
138
"""
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 )
107
140
query = query .filter (Transactions .is_parent == int (is_parent ))
108
141
if notes :
109
142
query = query .filter (Transactions .notes .ilike (f"%{ sqlalchemy .text (notes ).compile ()} %" ))
@@ -114,7 +147,7 @@ def get_transactions(
114
147
f"Provided date filters [{ start_date } , { end_date } ) to get_transactions are outside the bounds of the "
115
148
f"budget range [{ budget_start } , { budget_end } ). Results might be empty!"
116
149
)
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 )
118
151
query = query .filter (
119
152
Transactions .date >= budget_start ,
120
153
Transactions .date < budget_end ,
@@ -194,17 +227,17 @@ def create_transaction_from_ids(
194
227
process_payee : bool = True ,
195
228
) -> Transactions :
196
229
"""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 )
198
231
t = Transactions (
199
232
id = str (uuid .uuid4 ()),
200
233
acct = account_id ,
201
234
date = date_int ,
202
- amount = int ( round ( amount * 100 ) ),
235
+ amount = decimal_to_cents ( amount ),
203
236
category_id = category_id ,
204
237
notes = notes ,
205
238
reconciled = 0 ,
206
239
cleared = int (cleared ),
207
- sort_order = int ( datetime . datetime . now ( datetime . timezone . utc ). timestamp () * 1000 ),
240
+ sort_order = current_timestamp ( ),
208
241
financial_id = imported_id ,
209
242
imported_description = imported_payee ,
210
243
)
@@ -654,9 +687,9 @@ def get_budgets(
654
687
When the frontend shows a budget as 0.00, it might not be returned by this method.
655
688
"""
656
689
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 ())
658
691
if month :
659
- month_filter = int ( datetime . date . strftime ( month , "%Y%m" ) )
692
+ month_filter = date_to_int ( month , month_only = True )
660
693
query = query .filter (table .month == month_filter )
661
694
if category :
662
695
category = get_category (s , category )
@@ -711,6 +744,81 @@ def create_budget(
711
744
return budget
712
745
713
746
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
+
714
822
def create_transfer (
715
823
s : Session ,
716
824
date : datetime .date ,
0 commit comments