From 6505c131fa1895b306678ba483cca1b816477430 Mon Sep 17 00:00:00 2001 From: JaxOfDiamonds Date: Mon, 2 Mar 2026 20:16:02 -0600 Subject: [PATCH 1/4] Add logic for offBudget account rule --- actual/rules.py | 14 ++++++++++++-- tests/test_rules.py | 12 ++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/actual/rules.py b/actual/rules.py index bbf68a4..5b2f138 100644 --- a/actual/rules.py +++ b/actual/rules.py @@ -8,6 +8,7 @@ import unicodedata import pydantic +import sqlalchemy.orm.session from pydantic import model_serializer from actual import ActualError @@ -42,6 +43,7 @@ class ConditionType(enum.Enum): IS_BETWEEN = "isbetween" MATCHES = "matches" HAS_TAGS = "hasTags" + OFF_BUDGET = "offBudget" class ActionType(enum.Enum): @@ -99,7 +101,7 @@ def is_valid(self, operation: ConditionType) -> bool: "hasTags", ) elif self == ValueType.ID: - return operation.value in ("is", "isNot", "oneOf", "notOneOf") + return operation.value in ("is", "isNot", "oneOf", "notOneOf", "offBudget") elif self == ValueType.NUMBER: return operation.value in ("is", "isapprox", "isbetween", "gt", "gte", "lt", "lte") else: @@ -173,6 +175,7 @@ def condition_evaluation( true_value: int | list[str] | str | datetime.date | None, self_value: int | list[str] | str | datetime.date | BetweenValue | None, options: dict | None = None, + session: sqlalchemy.orm.session.Session | None = None, ) -> bool: """Helper function to evaluate the condition based on the true_value, value found on the transaction, and the self_value, value defined on rule condition.""" @@ -230,6 +233,11 @@ def condition_evaluation( # taken from https://stackoverflow.com/a/26740753/12681470 tags = re.findall(r"\#[\U00002600-\U000027BF\U0001f300-\U0001f64F\U0001f680-\U0001f6FF\w-]+", self_value) return any(tag in true_value for tag in tags) + elif op == ConditionType.OFF_BUDGET: + from actual.queries import get_account # lazy import to prevent circular issues + + account = get_account(session, true_value) + return account is not None and account.offbudget == 1 else: raise ActualError(f"Operation {op} not supported") @@ -322,7 +330,9 @@ def run(self, transaction: Transactions) -> bool: attr = get_attribute_by_table_name(Transactions.__tablename__, self.field) true_value = get_value(getattr(transaction, attr), self.type) self_value = self.get_value() - return condition_evaluation(self.op, true_value, self_value, self.options) + # get inner session from object + session = transaction._sa_instance_state.session + return condition_evaluation(self.op, true_value, self_value, self.options, session=session) class Action(pydantic.BaseModel): diff --git a/tests/test_rules.py b/tests/test_rules.py index 5148889..ac6a68b 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -203,6 +203,7 @@ def test_value_type_condition_validation(): assert ValueType.BOOLEAN.is_valid(ConditionType.IS) is True assert ValueType.ID.is_valid(ConditionType.NOT_ONE_OF) is True assert ValueType.ID.is_valid(ConditionType.CONTAINS) is False + assert ValueType.ID.is_valid(ConditionType.OFF_BUDGET) is True assert ValueType.STRING.is_valid(ConditionType.CONTAINS) is True assert ValueType.STRING.is_valid(ConditionType.GT) is False assert ValueType.IMPORTED_PAYEE.is_valid(ConditionType.CONTAINS) is True @@ -413,3 +414,14 @@ def test_delete_transaction_action(session): assert str(action) == "delete transaction" assert "delete transaction" in str(rule) + + +def test_off_budget_condition(session): + # create basic items + acct = create_account(session, "Bank", off_budget=True) + t = create_transaction(session, datetime.date(2024, 1, 1), acct, imported_payee="") + condition = {"type": "id", "field": "acct", "op": "offBudget", "value": None} + cond = Condition.model_validate(condition) + assert cond.run(t) + acct.offbudget = 0 + assert not cond.run(t) From 2f8557e84c28467ba8bf9640f58f60cdf8e89c04 Mon Sep 17 00:00:00 2001 From: JaxOfDiamonds Date: Fri, 6 Mar 2026 09:27:20 -0600 Subject: [PATCH 2/4] Added onBudget logic as well. --- actual/rules.py | 8 +++++++- tests/test_rules.py | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/actual/rules.py b/actual/rules.py index 5b2f138..cdf7bae 100644 --- a/actual/rules.py +++ b/actual/rules.py @@ -43,6 +43,7 @@ class ConditionType(enum.Enum): IS_BETWEEN = "isbetween" MATCHES = "matches" HAS_TAGS = "hasTags" + ON_BUDGET = "onBudget" OFF_BUDGET = "offBudget" @@ -101,7 +102,7 @@ def is_valid(self, operation: ConditionType) -> bool: "hasTags", ) elif self == ValueType.ID: - return operation.value in ("is", "isNot", "oneOf", "notOneOf", "offBudget") + return operation.value in ("is", "isNot", "oneOf", "notOneOf", "onBudget", "offBudget") elif self == ValueType.NUMBER: return operation.value in ("is", "isapprox", "isbetween", "gt", "gte", "lt", "lte") else: @@ -233,6 +234,11 @@ def condition_evaluation( # taken from https://stackoverflow.com/a/26740753/12681470 tags = re.findall(r"\#[\U00002600-\U000027BF\U0001f300-\U0001f64F\U0001f680-\U0001f6FF\w-]+", self_value) return any(tag in true_value for tag in tags) + elif op == ConditionType.ON_BUDGET: + from actual.queries import get_account # lazy import to prevent circular issues + + account = get_account(session, true_value) + return account is not None and account.offbudget == 0 elif op == ConditionType.OFF_BUDGET: from actual.queries import get_account # lazy import to prevent circular issues diff --git a/tests/test_rules.py b/tests/test_rules.py index ac6a68b..8b9564b 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -416,6 +416,17 @@ def test_delete_transaction_action(session): assert "delete transaction" in str(rule) +def test_on_budget_condition(session): + # create basic items + acct = create_account(session, "Bank", off_budget=False) + t = create_transaction(session, datetime.date(2024, 1, 1), acct, imported_payee="") + condition = {"type": "id", "field": "acct", "op": "onBudget", "value": None} + cond = Condition.model_validate(condition) + assert cond.run(t) + acct.offbudget = 1 + assert not cond.run(t) + + def test_off_budget_condition(session): # create basic items acct = create_account(session, "Bank", off_budget=True) From 527e2d33defa5a14debb577e006b11a8202898a1 Mon Sep 17 00:00:00 2001 From: JaxOfDiamonds Date: Fri, 6 Mar 2026 09:32:18 -0600 Subject: [PATCH 3/4] Adding condition validation for ON_BUDGET --- tests/test_rules.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_rules.py b/tests/test_rules.py index 8b9564b..38b5e39 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -203,6 +203,7 @@ def test_value_type_condition_validation(): assert ValueType.BOOLEAN.is_valid(ConditionType.IS) is True assert ValueType.ID.is_valid(ConditionType.NOT_ONE_OF) is True assert ValueType.ID.is_valid(ConditionType.CONTAINS) is False + assert ValueType.ID.is_valid(ConditionType.ON_BUDGET) is True assert ValueType.ID.is_valid(ConditionType.OFF_BUDGET) is True assert ValueType.STRING.is_valid(ConditionType.CONTAINS) is True assert ValueType.STRING.is_valid(ConditionType.GT) is False From 3261921960a56e372ca152e5cdbd62bde17441e6 Mon Sep 17 00:00:00 2001 From: Brunno Vanelli Date: Sat, 7 Mar 2026 18:38:36 -0300 Subject: [PATCH 4/4] refactor: Make small things explicit. --- actual/rules.py | 8 +++----- tests/test_rules.py | 8 ++++---- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/actual/rules.py b/actual/rules.py index cdf7bae..4c79198 100644 --- a/actual/rules.py +++ b/actual/rules.py @@ -180,6 +180,8 @@ def condition_evaluation( ) -> bool: """Helper function to evaluate the condition based on the true_value, value found on the transaction, and the self_value, value defined on rule condition.""" + from actual.queries import get_account # lazy import to prevent circular issues + if true_value is None: # short circuit as comparisons with NoneType are useless return False @@ -235,13 +237,9 @@ def condition_evaluation( tags = re.findall(r"\#[\U00002600-\U000027BF\U0001f300-\U0001f64F\U0001f680-\U0001f6FF\w-]+", self_value) return any(tag in true_value for tag in tags) elif op == ConditionType.ON_BUDGET: - from actual.queries import get_account # lazy import to prevent circular issues - account = get_account(session, true_value) return account is not None and account.offbudget == 0 elif op == ConditionType.OFF_BUDGET: - from actual.queries import get_account # lazy import to prevent circular issues - account = get_account(session, true_value) return account is not None and account.offbudget == 1 else: @@ -337,7 +335,7 @@ def run(self, transaction: Transactions) -> bool: true_value = get_value(getattr(transaction, attr), self.type) self_value = self.get_value() # get inner session from object - session = transaction._sa_instance_state.session + session = transaction._sa_instance_state.session # noqa return condition_evaluation(self.op, true_value, self_value, self.options, session=session) diff --git a/tests/test_rules.py b/tests/test_rules.py index 38b5e39..09febd4 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -423,9 +423,9 @@ def test_on_budget_condition(session): t = create_transaction(session, datetime.date(2024, 1, 1), acct, imported_payee="") condition = {"type": "id", "field": "acct", "op": "onBudget", "value": None} cond = Condition.model_validate(condition) - assert cond.run(t) + assert cond.run(t) is True acct.offbudget = 1 - assert not cond.run(t) + assert cond.run(t) is False def test_off_budget_condition(session): @@ -434,6 +434,6 @@ def test_off_budget_condition(session): t = create_transaction(session, datetime.date(2024, 1, 1), acct, imported_payee="") condition = {"type": "id", "field": "acct", "op": "offBudget", "value": None} cond = Condition.model_validate(condition) - assert cond.run(t) + assert cond.run(t) is True acct.offbudget = 0 - assert not cond.run(t) + assert cond.run(t) is False