Repository: bvanelli/actualpy

Issue:
Error when trying to run rules or evaluate rules
### Checks

- [X] I have checked that this issue has not already been reported.
- [X] I have confirmed this bug exists on the latest version of actualpy.

### Reproducible example

```python
from actual import Actual

with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual:
    actual.run_rules()
```

```python
from actual import Actual
from actual.queries import get_ruleset, get_transactions

with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual:
    rs = get_ruleset(actual.session)
    transactions = get_transactions(actual.session)
    for rule in rs:
        for t in transactions:
            if rule.evaluate(t):
                print(f"Rule {rule} matches for {t}")
```


### Log output

```shell
Traceback (most recent call last):
  File "/mnt/cache/appdata/scripts/actual-test.py", line 8, in <module>
    actual.run_rules()
  File "/mnt/cache/appdata/scripts/.venv/lib64/python3.9/site-packages/actual/__init__.py", line 401, in run_rules
    ruleset = get_ruleset(self.session)
  File "/mnt/cache/appdata/scripts/.venv/lib64/python3.9/site-packages/actual/queries.py", line 586, in get_ruleset
    conditions = TypeAdapter(list[Condition]).validate_json(rule.conditions)
  File "/mnt/cache/appdata/scripts/.venv/lib64/python3.9/site-packages/pydantic/type_adapter.py", line 142, in wrapped
    return func(self, *args, **kwargs)
  File "/mnt/cache/appdata/scripts/.venv/lib64/python3.9/site-packages/pydantic/type_adapter.py", line 391, in validate_json
    return self.validator.validate_json(data, strict=strict, context=context)
pydantic_core._pydantic_core.ValidationError: 8 validation errors for list[function-after[check_operation_type(), function-after[convert_value(), Condition]]]
3.value.int
  Input should be a valid integer [type=int_type, input_value={'num1': -6000, 'num2': -100000}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.8/v/int_type
3.value.float
  Input should be a valid number [type=float_type, input_value={'num1': -6000, 'num2': -100000}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.8/v/float_type
3.value.str
  Input should be a valid string [type=string_type, input_value={'num1': -6000, 'num2': -100000}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.8/v/string_type
3.value.list[str]
  Input should be a valid array [type=list_type, input_value={'num1': -6000, 'num2': -100000}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.8/v/list_type
3.value.function-after[validate_end_date(), Schedule].start
  Field required [type=missing, input_value={'num1': -6000, 'num2': -100000}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.8/v/missing
3.value.list[BaseModel]
  Input should be a valid array [type=list_type, input_value={'num1': -6000, 'num2': -100000}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.8/v/list_type
3.value.BaseModel.id
  Field required [type=missing, input_value={'num1': -6000, 'num2': -100000}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.8/v/missing
3.value.date
  Input should be a valid date [type=date_type, input_value={'num1': -6000, 'num2': -100000}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.8/v/date_type
```


### Issue description

The issue primarily happens around rule evaluation

### Expected behavior

Rules are evaluated and run

### Installed versions

- actualpy version: - Actual Server version:
https://github.com/bvanelli/actualpy/tree/43-header-login - v24.7.0

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/bvanelli/actualpy/pull/47?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 `95.83333%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 96.75%. Comparing base [(`c59a360`)](https://app.codecov.io/gh/bvanelli/actualpy/commit/c59a360127c47b906e1a5d7a98974b3b3bcdda08?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) to head [(`4a1d706`)](https://app.codecov.io/gh/bvanelli/actualpy/commit/4a1d706bc31a6f274421cd5f109ee223d7c6ee16?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli).

| [Files](https://app.codecov.io/gh/bvanelli/actualpy/pull/47?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/47?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) | 95.65% | [1 Missing :warning: ](https://app.codecov.io/gh/bvanelli/actualpy/pull/47?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      #47      +/-   ##
==========================================
- Coverage   96.77%   96.75%   -0.03%     
==========================================
  Files          13       13              
  Lines        1738     1754      +16     
==========================================
+ Hits         1682     1697      +15     
- Misses         56       57       +1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/bvanelli/actualpy/pull/47?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/README.md b/README.md
index 3096b70..5c93e5b 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,22 @@ with Actual(
         print(t.date, account_name, t.notes, t.amount, category)
 ```
 
+The `file` will be matched to either one of the following:
+
+- The name of the budget, found top the top left cornet
+- The ID of the budget, a UUID that is only available if you inspect the result of the method `list_user_files`
+- The Sync ID of the budget, a UUID available on the frontend on the "Advanced options"
+- If none of those options work for you, you can search for the file manually with `list_user_files` and provide the
+object directly:
+
+```python
+from actual import Actual
+
+with Actual("http://localhost:5006", password="mypass") as actual:
+    actual.set_file(actual.list_user_files().data[0])
+    actual.download_budget()
+```
+
 ## Adding new transactions
 
 After you created your first budget (or when updating an existing budget), you can add new transactions by adding them
@@ -80,6 +96,30 @@ Will produce:
 
 ![added-transaction](https://github.com/bvanelli/actualpy/blob/main/docs/static/added-transaction.png?raw=true)
 
+## Updating existing transactions
+
+You may also update transactions using the SQLModel directly, you just need to make sure to commit the results at the
+end:
+
+```python
+from actual import Actual
+from actual.queries import get_transactions
+
+
+with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual:
+    for transaction in get_transactions(actual.session):
+        # change the transactions notes
+        if transaction.notes is not None and "my pattern" in transaction.notes:
+            transaction.notes = transaction.notes + " my suffix!"
+    # commit your changes!
+    actual.commit()
+
+```
+
+> **WARNING:** You can also modify the relationships, for example the `transaction.payee.name`, but you to be aware that
+> this payee might be used for more than one transaction. Whenever the relationship is anything but 1:1, you have to
+> track the changes already done to prevent modifying a field twice.
+
 ## Generating backups
 
 You can use actualpy to generate regular backups of your server files. Here is a script that will backup your server
diff --git a/actual/__init__.py b/actual/__init__.py
index 385059e..3ae112c 100644
--- a/actual/__init__.py
+++ b/actual/__init__.py
@@ -103,8 +103,11 @@ class Actual(ActualServer):
         return self._session
 
     def set_file(self, file_id: Union[str, RemoteFileListDTO]) -> RemoteFileListDTO:
-        """Sets the file id for the class for further requests. The file_id argument can be either a name or remote
-        id from the file. If the name is provided, only the first match is taken."""
+        """
+        Sets the file id for the class for further requests. The file_id argument can be either the name, the remote
+        id or the group id (also known as sync_id) from the file. If there are duplicates for the name, this method
+        will raise `UnknownFileId`.
+        """
         if isinstance(file_id, RemoteFileListDTO):
             self._file = file_id
             return file_id
@@ -112,7 +115,7 @@ class Actual(ActualServer):
             selected_files = []
             user_files = self.list_user_files()
             for file in user_files.data:
-                if (file.file_id == file_id or file.name == file_id) and file.deleted == 0:
+                if (file.file_id == file_id or file.name == file_id or file.group_id == file_id) and file.deleted == 0:
                     selected_files.append(file)
             if len(selected_files) == 0:
                 raise UnknownFileId(f"Could not find a file id or identifier '{file_id}'")
diff --git a/actual/queries.py b/actual/queries.py
index e66b849..4714377 100644
--- a/actual/queries.py
+++ b/actual/queries.py
@@ -585,7 +585,7 @@ def get_ruleset(s: Session) -> RuleSet:
     for rule in get_rules(s):
         conditions = TypeAdapter(list[Condition]).validate_json(rule.conditions)
         actions = TypeAdapter(list[Action]).validate_json(rule.actions)
-        rs = Rule(conditions=conditions, operation=rule.conditions_op, actions=actions, stage=rule.stage)
+        rs = Rule(conditions=conditions, operation=rule.conditions_op, actions=actions, stage=rule.stage)  # noqa
         rule_set.append(rs)
     return RuleSet(rules=rule_set)
 
diff --git a/actual/rules.py b/actual/rules.py
index 83f72a9..6147b85 100644
--- a/actual/rules.py
+++ b/actual/rules.py
@@ -33,6 +33,26 @@ class ActionType(enum.Enum):
     LINK_SCHEDULE = "link-schedule"
 
 
+class BetweenValue(pydantic.BaseModel):
+    """Used for `isbetween` rules."""
+
+    num_1: typing.Union[int, float] = pydantic.Field(alias="num1")
+    num_2: typing.Union[int, float] = pydantic.Field(alias="num2")
+
+    def __str__(self):
+        return f"({self.num_1}, {self.num_2})"
+
+    @pydantic.model_validator(mode="after")
+    def convert_value(self):
+        if isinstance(self.num_1, float):
+            self.num_1 = int(self.num_1 * 100)
+        if isinstance(self.num_2, float):
+            self.num_2 = int(self.num_2 * 100)
+        # sort the values
+        self.num_1, self.num_2 = sorted((self.num_1, self.num_2))
+        return self
+
+
 class ValueType(enum.Enum):
     DATE = "date"
     ID = "id"
@@ -56,9 +76,9 @@ class ValueType(enum.Enum):
             # must be BOOLEAN
             return operation.value in ("is",)
 
-    def validate(self, value: typing.Union[int, list[str], str, None], as_list: bool = False) -> bool:
-        if isinstance(value, list) and as_list:
-            return all(self.validate(v) for v in value)
+    def validate(self, value: typing.Union[int, list[str], str, None], operation: ConditionType = None) -> bool:
+        if isinstance(value, list) and operation in (ConditionType.ONE_OF, ConditionType.NOT_ONE_OF):
+            return all(self.validate(v, None) for v in value)
         if value is None:
             return True
         if self == ValueType.ID:
@@ -73,7 +93,10 @@ class ValueType(enum.Enum):
                 res = False
             return res
         elif self == ValueType.NUMBER:
-            return isinstance(value, int)
+            if operation == ConditionType.IS_BETWEEN:
+                return isinstance(value, BetweenValue)
+            else:
+                return isinstance(value, int)
         else:
             # must be BOOLEAN
             return isinstance(value, bool)
@@ -113,7 +136,7 @@ def get_value(
 def condition_evaluation(
     op: ConditionType,
     true_value: typing.Union[int, list[str], str, datetime.date, None],
-    self_value: typing.Union[int, list[str], str, datetime.date, None],
+    self_value: typing.Union[int, list[str], str, datetime.date, BetweenValue, None],
     options: dict = None,
 ) -> bool:
     """Helper function to evaluate the condition based on the true_value, value found on the transaction, and the
@@ -147,10 +170,14 @@ def condition_evaluation(
             # https://github.com/actualbudget/actual/blob/243703b2f70532ec1acbd3088dda879b5d07a5b3/packages/loot-core/src/shared/rules.ts#L261-L263
             interval = round(abs(self_value) * 0.075, 2)
         return self_value - interval <= true_value <= self_value + interval
-    elif op in (ConditionType.ONE_OF, ConditionType.CONTAINS):
+    elif op == ConditionType.ONE_OF:
         return true_value in self_value
-    elif op in (ConditionType.NOT_ONE_OF, ConditionType.DOES_NOT_CONTAIN):
+    elif op == ConditionType.CONTAINS:
+        return self_value in true_value
+    elif op == ConditionType.NOT_ONE_OF:
         return true_value not in self_value
+    elif op == ConditionType.DOES_NOT_CONTAIN:
+        return self_value not in true_value
     elif op == ConditionType.GT:
         return true_value > self_value
     elif op == ConditionType.GTE:
@@ -159,6 +186,8 @@ def condition_evaluation(
         return self_value > true_value
     elif op == ConditionType.LTE:
         return self_value >= true_value
+    elif op == ConditionType.IS_BETWEEN:
+        return self_value.num_1 <= true_value <= self_value.num_2
     else:
         raise ActualError(f"Operation {op} not supported")
 
@@ -198,7 +227,9 @@ class Condition(pydantic.BaseModel):
         "amount_outflow",
     ]
     op: ConditionType
-    value: typing.Union[int, float, str, list[str], Schedule, list[BaseModel], BaseModel, datetime.date, None]
+    value: typing.Union[
+        int, float, str, list[str], Schedule, list[BaseModel], BetweenValue, BaseModel, datetime.date, None
+    ]
     type: typing.Optional[ValueType] = None
     options: typing.Optional[dict] = None
 
@@ -240,8 +271,7 @@ class Condition(pydantic.BaseModel):
         elif isinstance(self.value, list) and len(self.value) and isinstance(self.value[0], pydantic.BaseModel):
             self.value = [v.id if hasattr(v, "id") else v for v in self.value]
         # make sure the data matches the value type
-        as_list = self.op in (ConditionType.IS_BETWEEN, ConditionType.ONE_OF, ConditionType.NOT_ONE_OF)
-        if not self.type.validate(self.value, as_list=as_list):
+        if not self.type.validate(self.value, self.op):
             raise ValueError(f"Value {self.value} is not valid for type {self.type.name} and operation {self.op.name}")
         return self
 


Test patch:
diff --git a/tests/test_rules.py b/tests/test_rules.py
index ab3d684..fce1352 100644
--- a/tests/test_rules.py
+++ b/tests/test_rules.py
@@ -73,6 +73,18 @@ def test_datetime_rule():
     assert Condition(field="date", op="lt", value=target_date + datetime.timedelta(days=1)).run(t) is True
 
 
+def test_string_condition():
+    mock = MagicMock()
+    acct = create_account(mock, "Bank")
+    t = create_transaction(mock, datetime.date(2024, 1, 1), acct, "", "foo")
+    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
+    assert Condition(field="notes", op="contains", value="foobar").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
+
+
 def test_numeric_condition():
     t = create_transaction(MagicMock(), datetime.date(2024, 1, 1), "Bank", "", amount=5)
     c1 = Condition(field="amount_inflow", op="gt", value=10.0)
@@ -86,6 +98,10 @@ def test_numeric_condition():
     assert c2.run(t) is True
     c3 = Condition(field="amount", op="isapprox", value=5.5)
     assert c3.run(t) is False
+    # isbetween condition
+    c4 = Condition(field="amount", op="isbetween", value={"num1": 5.0, "num2": 10.0})
+    assert c4.run(t) is True
+    assert str(c4) == "'amount' isbetween (500, 1000)"  # value gets converted when input as float
 
 
 def test_complex_rule():
@@ -133,6 +149,8 @@ def test_invalid_inputs():
         Action(field="date", value="foo")
     with pytest.raises(ValueError):
         Condition(field="description", op="is", value="foo")  # not an uuid
+    with pytest.raises(ValueError):
+        Condition(field="amount", op="isbetween", value=5)
     with pytest.raises(ActualError):
         Action(field="notes", op="set-split-amount", value="foo").run(None)  # noqa: use None instead of transaction
     with pytest.raises(ActualError):
@@ -166,7 +184,7 @@ def test_value_type_value_validation():
     assert ValueType.BOOLEAN.validate("") is False
     # list and NoneType
     assert ValueType.DATE.validate(None)
-    assert ValueType.DATE.validate(["2024-10-04"], as_list=True) is True
+    assert ValueType.DATE.validate(["2024-10-04"], ConditionType.ONE_OF) is True
 
 
 def test_value_type_from_field():