Repository: bvanelli/actualpy

Issue:
Support hasTags rule
### Description

See https://github.com/actualbudget/actual/blob/bbff543768eda550569c6e0a0a318446fbafaa0e/packages/loot-core/src/server/accounts/transaction-rules.ts#L476-L479

Actual looks for notes with those tags, that can be identified by a hashtag on the notes:

<img width="1671" alt="image" src="https://github.com/user-attachments/assets/9b76c745-6d64-4d8f-826e-34aaa6748dcc">

Then the tag can be used for rules:

<img width="921" alt="image" src="https://github.com/user-attachments/assets/a17d3cc4-725f-4b19-9e8f-b8b20a5e36e6">

The rule itself seems to be broken on frontend, so I guess I can wait to implement it since it will probably be patched:

<img width="921" alt="image" src="https://github.com/user-attachments/assets/5b63d0e5-b10f-47d9-8adb-c499a70b21cf">

Hints from issue comments:

Solution patch:
diff --git a/actual/rules.py b/actual/rules.py
index bb7c611..0de8980 100644
--- a/actual/rules.py
+++ b/actual/rules.py
@@ -40,12 +40,15 @@ class ConditionType(enum.Enum):
     NOT_ONE_OF = "notOneOf"
     IS_BETWEEN = "isbetween"
     MATCHES = "matches"
+    HAS_TAGS = "hasTags"
 
 
 class ActionType(enum.Enum):
     SET = "set"
     SET_SPLIT_AMOUNT = "set-split-amount"
     LINK_SCHEDULE = "link-schedule"
+    PREPEND_NOTES = "prepend-notes"
+    APPEND_NOTES = "append-notes"
 
 
 class BetweenValue(pydantic.BaseModel):
@@ -83,7 +86,16 @@ def is_valid(self, operation: ConditionType) -> bool:
         if self == ValueType.DATE:
             return operation.value in ("is", "isapprox", "gt", "gte", "lt", "lte")
         elif self in (ValueType.STRING, ValueType.IMPORTED_PAYEE):
-            return operation.value in ("is", "contains", "oneOf", "isNot", "doesNotContain", "notOneOf", "matches")
+            return operation.value in (
+                "is",
+                "contains",
+                "oneOf",
+                "isNot",
+                "doesNotContain",
+                "notOneOf",
+                "matches",
+                "hasTags",
+            )
         elif self == ValueType.ID:
             return operation.value in ("is", "isNot", "oneOf", "notOneOf")
         elif self == ValueType.NUMBER:
@@ -211,6 +223,11 @@ def condition_evaluation(
         return self_value >= true_value
     elif op == ConditionType.IS_BETWEEN:
         return self_value.num_1 <= true_value <= self_value.num_2
+    elif op == ConditionType.HAS_TAGS:
+        # this regex is not correct, but is good enough according to testing
+        # 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)
     else:
         raise ActualError(f"Operation {op} not supported")
 
@@ -346,6 +363,12 @@ def __str__(self) -> str:
             method = self.options.get("method") or ""
             split_index = self.options.get("splitIndex") or ""
             return f"allocate a {method} at Split {split_index}: {self.value}"
+        elif self.op in (ActionType.APPEND_NOTES, ActionType.PREPEND_NOTES):
+            return (
+                f"append to notes '{self.value}'"
+                if self.op == ActionType.APPEND_NOTES
+                else f"prepend to notes '{self.value}'"
+            )
 
     def as_dict(self):
         """Returns valid dict for database insertion."""
@@ -372,6 +395,9 @@ def check_operation_type(self):
                 self.type = ValueType.ID
             elif self.op == ActionType.SET_SPLIT_AMOUNT:
                 self.type = ValueType.NUMBER
+        # questionable choice from the developers to set it to ID, I hope they fix it at some point, but we change it
+        if self.op in (ActionType.APPEND_NOTES, ActionType.PREPEND_NOTES):
+            self.type = ValueType.STRING
         # if a pydantic object is provided and id is expected, extract the id
         if isinstance(self.value, pydantic.BaseModel) and hasattr(self.value, "id"):
             self.value = str(self.value.id)
@@ -395,6 +421,16 @@ def run(self, transaction: Transactions) -> None:
                 setattr(transaction, attr, value)
         elif self.op == ActionType.LINK_SCHEDULE:
             transaction.schedule_id = self.value
+        # for the notes rule, check if the rule was already applied since actual does not do that.
+        # this should ensure the prefix or suffix is not applied multiple times
+        elif self.op == ActionType.APPEND_NOTES:
+            notes = transaction.notes or ""
+            if not notes.endswith(self.value):
+                transaction.notes = f"{notes}{self.value}"
+        elif self.op == ActionType.PREPEND_NOTES:
+            notes = transaction.notes or ""
+            if not notes.startswith(self.value):
+                transaction.notes = f"{self.value}{notes}"
         else:
             raise ActualError(f"Operation {self.op} not supported")
 
diff --git a/actual/version.py b/actual/version.py
index 163ce2b..f29c654 100644
--- a/actual/version.py
+++ b/actual/version.py
@@ -1,2 +1,2 @@
-__version_info__ = ("0", "4", "1")
+__version_info__ = ("0", "5", "0")
 __version__ = ".".join(__version_info__)


Test patch:
diff --git a/tests/test_rules.py b/tests/test_rules.py
index 9c1bab6..273fcde 100644
--- a/tests/test_rules.py
+++ b/tests/test_rules.py
@@ -87,7 +87,7 @@ def test_string_condition():
     assert Condition(field="notes", op="matches", value="g.*").run(t) is False
     assert Condition(field="notes", op="doesNotContain", value="foo").run(t) is False
     assert Condition(field="notes", op="doesNotContain", value="foobar").run(t) is True
-    # test the cases where the case do not match
+    # case insensitive entries
     assert Condition(field="notes", op="oneOf", value=["FOO", "BAR"]).run(t) is True
     assert Condition(field="notes", op="notOneOf", value=["FOO", "BAR"]).run(t) is False
     assert Condition(field="notes", op="contains", value="FO").run(t) is True
@@ -98,6 +98,18 @@ def test_string_condition():
     assert Condition(field="notes", op="doesNotContain", value="FOOBAR").run(t) is True
 
 
+def test_has_tags():
+    mock = MagicMock()
+    acct = create_account(mock, "Bank")
+    t = create_transaction(mock, datetime.date(2024, 1, 1), acct, "", "foo #bar #✨ #🙂‍↔️")
+    assert Condition(field="notes", op="hasTags", value="#bar").run(t) is True
+    assert Condition(field="notes", op="hasTags", value="#foo").run(t) is False
+    # test other unicode entries
+    assert Condition(field="notes", op="hasTags", value="#emoji #✨").run(t) is True
+    assert Condition(field="notes", op="hasTags", value="#🙂‍↔️").run(t) is True  # new emojis should be supported
+    assert Condition(field="notes", op="hasTags", value="bar").run(t) is False  # individual string will not match
+
+
 @pytest.mark.parametrize(
     "op,condition_value,value,expected_result",
     [
@@ -353,3 +365,23 @@ def test_set_split_amount_exception(session, mocker):
     session.flush()
     with pytest.raises(ActualSplitTransactionError):
         rs.run(t)
+
+
+@pytest.mark.parametrize(
+    "operation,value,note,expected",
+    [
+        ("append-notes", "bar", "foo", "foobar"),
+        ("prepend-notes", "bar", "foo", "barfoo"),
+        ("append-notes", "bar", None, "bar"),
+        ("prepend-notes", "bar", None, "bar"),
+    ],
+)
+def test_preppend_append_notes(operation, value, note, expected):
+    mock = MagicMock()
+    t = create_transaction(mock, datetime.date(2024, 1, 1), "Bank", "", notes=note)
+    action = Action(field="description", op=operation, value=value)
+    action.run(t)
+    assert t.notes == expected
+    action.run(t)  # second iteration should not update the result
+    assert t.notes == expected
+    assert f"{operation.split('-')[0]} to notes '{value}'" in str(action)
diff --git a/tests/test_schedules.py b/tests/test_schedules.py
index fb51f47..d7dee96 100644
--- a/tests/test_schedules.py
+++ b/tests/test_schedules.py
@@ -70,6 +70,25 @@ def test_complex_schedules():
     assert str(s) == "Every month on the last Sunday, 2nd Saturday, 10th, 31st, 5th (before weekend)"
 
 
+def test_skip_weekend_schedule():
+    s = Schedule.model_validate(
+        {
+            "start": "2024-09-14",
+            "interval": 1,
+            "frequency": "monthly",
+            "patterns": [],
+            "skipWeekend": True,
+            "weekendSolveMode": "after",
+            "endMode": "on_date",
+            "endOccurrences": 1,
+            "endDate": "2024-09-14",
+        }
+    )
+    after = s.xafter(date(2024, 8, 14), count=2)
+    # we should ensure that dates that fall outside the endDate are not covered, even though actual will accept it
+    assert after == []
+
+
 def test_is_approx():
     # create schedule for every 1st and last day of the month (30th or 31st)
     s = Schedule.model_validate(