Repository: bvanelli/actualpy

Issue:
run_rules() error
### 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
with Actual(base_url=URL_ACTUAL, password=PASSWORD_ACTUAL, file=FILE_ACTUAL) as actual:
    actual.run_rules()
    actual.commit()
```


### Log output

```shell
Traceback (most recent call last):
  File "/config/actual-pluggy-py/actual-pluggy.py", line 27, in <module>
    actual.run_rules()
  File "/config/actual-pluggy-py/.conda/lib/python3.11/site-packages/actual/__init__.py", line 414, in run_rules
    ruleset = get_ruleset(self.session)
              ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/config/actual-pluggy-py/.conda/lib/python3.11/site-packages/actual/queries.py", line 604, in get_ruleset
    conditions = TypeAdapter(list[Condition]).validate_json(rule.conditions)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/config/actual-pluggy-py/.conda/lib/python3.11/site-packages/pydantic/type_adapter.py", line 144, in wrapped
    return func(self, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/config/actual-pluggy-py/.conda/lib/python3.11/site-packages/pydantic/type_adapter.py", line 393, in validate_json
    return self.validator.validate_json(data, strict=strict, context=context)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for list[function-after[check_operation_type(), function-after[convert_value(), Condition]]]
0.type
  Input should be 'date', 'id', 'string', 'number' or 'boolean' [type=enum, input_value='imported_payee', input_type=str]
    For further information visit https://errors.pydantic.dev/2.9/v/enum
```


### Issue description

Example of rule set that broke the code: "imported payee **contains** XXXXXX -> set payee to YYYYYY"

### Expected behavior

do not get "exit code"

### Installed versions

- actualpy version: 0.4.0 - Actual Server version: 24.9.0

Discussion:
@bvanelli: Closed as solved in https://github.com/bvanelli/actualpy/pull/70, the type of condition was not implemented, I might have skipped due to thinking it was a deprecated function.

You can try it locally and let me know if it works. I wrote some tests and also did some local testing too, and everything seems to work.

Solution patch:
diff --git a/actual/queries.py b/actual/queries.py
index d19efe3..7cc1a15 100644
--- a/actual/queries.py
+++ b/actual/queries.py
@@ -224,11 +224,16 @@ def create_transaction(
     acct = get_account(s, account)
     if acct is None:
         raise ActualError(f"Account {account} not found")
+    if imported_payee:
+        imported_payee = imported_payee.strip()
+        if not payee:
+            payee = imported_payee
     payee = get_or_create_payee(s, payee)
     if category:
         category_id = get_or_create_category(s, category).id
     else:
         category_id = None
+
     return create_transaction_from_ids(
         s, date, acct.id, payee.id, notes, category_id, amount, imported_id, cleared, imported_payee
     )
diff --git a/actual/rules.py b/actual/rules.py
index 231b84a..bb7c611 100644
--- a/actual/rules.py
+++ b/actual/rules.py
@@ -74,6 +74,7 @@ class ValueType(enum.Enum):
     STRING = "string"
     NUMBER = "number"
     BOOLEAN = "boolean"
+    IMPORTED_PAYEE = "imported_payee"
 
     def is_valid(self, operation: ConditionType) -> bool:
         """Returns if a conditional operation for a certain type is valid. For example, if the value is of type string,
@@ -81,7 +82,7 @@ def is_valid(self, operation: ConditionType) -> bool:
         greater than defined for strings."""
         if self == ValueType.DATE:
             return operation.value in ("is", "isapprox", "gt", "gte", "lt", "lte")
-        elif self == ValueType.STRING:
+        elif self in (ValueType.STRING, ValueType.IMPORTED_PAYEE):
             return operation.value in ("is", "contains", "oneOf", "isNot", "doesNotContain", "notOneOf", "matches")
         elif self == ValueType.ID:
             return operation.value in ("is", "isNot", "oneOf", "notOneOf")
@@ -99,7 +100,7 @@ def validate(self, value: typing.Union[int, list[str], str, None], operation: Co
         if self == ValueType.ID:
             # make sure it's an uuid
             return isinstance(value, str) and is_uuid(value)
-        elif self == ValueType.STRING:
+        elif self in (ValueType.STRING, ValueType.IMPORTED_PAYEE):
             return isinstance(value, str)
         elif self == ValueType.DATE:
             try:
@@ -120,8 +121,10 @@ def validate(self, value: typing.Union[int, list[str], str, None], operation: Co
     def from_field(cls, field: str | None) -> ValueType:
         if field in ("acct", "category", "description"):
             return ValueType.ID
-        elif field in ("notes", "imported_description"):
+        elif field in ("notes",):
             return ValueType.STRING
+        elif field in ("imported_description",):
+            return ValueType.IMPORTED_PAYEE
         elif field in ("date",):
             return ValueType.DATE
         elif field in ("cleared", "reconciled"):
@@ -143,7 +146,7 @@ def get_value(
             return datetime.datetime.strptime(str(value), "%Y%m%d").date()
     elif value_type is ValueType.BOOLEAN:
         return int(value)  # database accepts 0 or 1
-    elif value_type is ValueType.STRING:
+    elif value_type in (ValueType.STRING, ValueType.IMPORTED_PAYEE):
         if isinstance(value, list):
             return [get_value(v, value_type) for v in value]
         else:
@@ -193,7 +196,7 @@ def condition_evaluation(
     elif op == ConditionType.CONTAINS:
         return self_value in true_value
     elif op == ConditionType.MATCHES:
-        return bool(re.match(self_value, true_value, re.IGNORECASE))
+        return bool(re.search(self_value, true_value, re.IGNORECASE))
     elif op == ConditionType.NOT_ONE_OF:
         return true_value not in self_value
     elif op == ConditionType.DOES_NOT_CONTAIN:


Test patch:
diff --git a/tests/test_database.py b/tests/test_database.py
index f09297a..db26051 100644
--- a/tests/test_database.py
+++ b/tests/test_database.py
@@ -191,3 +191,10 @@ def test_model_notes(session):
     session.commit()
     assert account_with_note.notes == "My note"
     assert account_without_note.notes is None
+
+
+def test_default_imported_payee(session):
+    t = create_transaction(session, date(2024, 1, 4), create_account(session, "Bank"), imported_payee=" foo ")
+    session.flush()
+    assert t.payee.name == "foo"
+    assert t.imported_description == "foo"
diff --git a/tests/test_rules.py b/tests/test_rules.py
index 71d22c4..9c1bab6 100644
--- a/tests/test_rules.py
+++ b/tests/test_rules.py
@@ -98,6 +98,23 @@ def test_string_condition():
     assert Condition(field="notes", op="doesNotContain", value="FOOBAR").run(t) is True
 
 
+@pytest.mark.parametrize(
+    "op,condition_value,value,expected_result",
+    [
+        ("contains", "supermarket", "Best Supermarket", True),
+        ("contains", "supermarket", None, False),
+        ("oneOf", ["my supermarket", "other supermarket"], "MY SUPERMARKET", True),
+        ("oneOf", ["supermarket"], None, False),
+        ("matches", "market", "hypermarket", True),
+    ],
+)
+def test_imported_payee_condition(op, condition_value, value, expected_result):
+    t = create_transaction(MagicMock(), datetime.date(2024, 1, 1), "Bank", "", amount=5, imported_payee=value)
+    condition = {"field": "imported_description", "type": "imported_payee", "op": op, "value": condition_value}
+    cond = Condition.model_validate(condition)
+    assert cond.run(t) == expected_result
+
+
 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)
@@ -181,6 +198,8 @@ def test_value_type_condition_validation():
     assert ValueType.ID.is_valid(ConditionType.CONTAINS) is False
     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
+    assert ValueType.IMPORTED_PAYEE.is_valid(ConditionType.GT) is False
 
 
 def test_value_type_value_validation():
@@ -196,6 +215,8 @@ def test_value_type_value_validation():
     assert ValueType.ID.validate("foo") is False
     assert ValueType.BOOLEAN.validate(True) is True
     assert ValueType.BOOLEAN.validate("") is False
+    assert ValueType.IMPORTED_PAYEE.validate("") is True
+    assert ValueType.IMPORTED_PAYEE.validate(1) is False
     # list and NoneType
     assert ValueType.DATE.validate(None)
     assert ValueType.DATE.validate(["2024-10-04"], ConditionType.ONE_OF) is True
@@ -207,6 +228,7 @@ def test_value_type_from_field():
     assert ValueType.from_field("notes") == ValueType.STRING
     assert ValueType.from_field("date") == ValueType.DATE
     assert ValueType.from_field("cleared") == ValueType.BOOLEAN
+    assert ValueType.from_field("imported_description") == ValueType.IMPORTED_PAYEE
     with pytest.raises(ValueError):
         ValueType.from_field("foo")