Repository: bvanelli/actualpy
Pull request #75 (2024-09-15 18:17:49)

feat: Support rules and actions on the latest version of Actual.
Cloes #69 

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/bvanelli/actualpy/pull/75?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 `90.47619%` with `2 lines` in your changes missing coverage. Please review.
> Project coverage is 97.11%. Comparing base [(`a05baca`)](https://app.codecov.io/gh/bvanelli/actualpy/commit/a05bacafa14514a5b87d2433483b252db1f353de?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) to head [(`4049b1d`)](https://app.codecov.io/gh/bvanelli/actualpy/commit/4049b1d2b854350149064d8585dc7e93a896e569?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/75?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/rules.py](https://app.codecov.io/gh/bvanelli/actualpy/pull/75?src=pr&el=tree&filepath=actual%2Frules.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli#diff-YWN0dWFsL3J1bGVzLnB5) | 90.47% | [2 Missing :warning: ](https://app.codecov.io/gh/bvanelli/actualpy/pull/75?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      #75      +/-   ##
==========================================
- Coverage   97.14%   97.11%   -0.03%     
==========================================
  Files          14       14              
  Lines        1924     1944      +20     
==========================================
+ Hits         1869     1888      +19     
- Misses         55       56       +1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/bvanelli/actualpy/pull/75?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).


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 @@ class ValueType(enum.Enum):
         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 @@ class Action(pydantic.BaseModel):
             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 @@ class Action(pydantic.BaseModel):
                 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 @@ class Action(pydantic.BaseModel):
                 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(