Repository: bvanelli/actualpy
Pull request #12 (2024-05-06 20:08:05)

Add support for running and inserting rules
- Introduce Rule and RuleSet for creating new rules and running existing rules on the local database.
- Refactor some parts of the code to make the API naming more uniform
- Update database model to include the custom reports

Here is one example rule adding with frontend result:

```python
import datetime

from actual import Actual
from actual.queries import create_rule
from actual.rules import (
    Action,
    Condition,
    ConditionType,
    Rule,
)


with Actual("http://localhost:5006", password="mypass", file="My Finances") as actual:
    action = Action(field="cleared", value=1)
    condition = Condition(field="date", op=ConditionType.IS_APPROX, value=datetime.date(2024, 1, 2))
    rule = Rule(conditions=[condition], actions=[action], operation="all", stage="pre")
    created_rule = create_rule(actual.session, rule)
    actual.commit()
```

Result:

<img width="1665" alt="image" src="https://github.com/bvanelli/actualpy/assets/8211602/a6738185-2d91-4f2e-b2cc-b455988ecf93">

Closes https://github.com/bvanelli/actualpy/issues/11


Patch:
diff --git a/actual/__init__.py b/actual/__init__.py
index 58d1eae..c058171 100644
--- a/actual/__init__.py
+++ b/actual/__init__.py
@@ -10,7 +10,8 @@ import sqlite3
 import tempfile
 import uuid
 import zipfile
-from typing import Union
+from os import PathLike
+from typing import IO, Union
 
 import sqlalchemy
 import sqlalchemy.orm
@@ -47,7 +48,7 @@ class Actual(ActualServer):
         books using Python.
 
         Parts of the implementation are available at the following file:
-        https://github.com/actualbudget/actual/blob/master/packages/loot-core/src/server/cloud-storage.ts
+        https://github.com/actualbudget/actual/blob/2178da0414958064337b2c53efc95ff1d3abf98a/packages/loot-core/src/server/cloud-storage.ts
 
         :param base_url: url of the running Actual server
         :param token: the token for authentication, if this is available (optional)
@@ -129,7 +130,7 @@ class Actual(ActualServer):
         conn.commit()
         conn.close()
 
-    def create_budget(self, budget_name: str, encryption_password: str = None):
+    def create_budget(self, budget_name: str):
         """Creates a budget using the remote server default database and migrations. If password is provided, the
         budget will be encrypted."""
         migration_files = self.data_file_index()
@@ -162,12 +163,16 @@ class Actual(ActualServer):
         # create a clock
         self.load_clock()
 
-    def _gen_zip(self) -> bytes:
-        binary_data = io.BytesIO()
-        with zipfile.ZipFile(binary_data, "a", zipfile.ZIP_DEFLATED, False) as z:
+    def export_data(self, output_file: str | PathLike[str] | IO[bytes] = None) -> bytes:
+        """Export your data as a zip file containing db.sqlite and metadata.json files. It can be imported into another
+        Actual instance by closing an open file (if any), then clicking the “Import file” button, then choosing
+        “Actual.” Even though encryption is enabled, the exported zip file will not have any encryption."""
+        if not output_file:
+            output_file = io.BytesIO()
+        with zipfile.ZipFile(output_file, "a", zipfile.ZIP_DEFLATED, False) as z:
             z.write(self._data_dir / "db.sqlite", "db.sqlite")
             z.write(self._data_dir / "metadata.json", "metadata.json")
-        return binary_data.getvalue()
+        return output_file.getvalue()
 
     def encrypt(self, encryption_password: str):
         """Encrypts the local database using a new key, and re-uploads to the server.
@@ -189,7 +194,7 @@ class Actual(ActualServer):
             raise ActualError("Budget is encrypted but password was not provided")
         self._master_key = create_key_buffer(encryption_password, salt)
         # encrypt binary data with
-        encrypted = encrypt(self._file.encrypt_key_id, self._master_key, self._gen_zip())
+        encrypted = encrypt(self._file.encrypt_key_id, self._master_key, self.export_data())
         binary_data = io.BytesIO(base64.b64decode(encrypted["value"]))
         encryption_meta = encrypted["meta"]
         self.reset_user_file(self._file.file_id)
@@ -227,6 +232,10 @@ class Actual(ActualServer):
                     self.update_metadata({message.row: message.get_value()})
                     continue
                 table = get_class_by_table_name(message.dataset)
+                if table is None:
+                    raise ActualError(
+                        f"Actual is at a version not supported by the library: '{message.dataset}' not found"
+                    )
                 column = get_attribute_by_table_name(message.dataset, message.column)
                 entry = s.query(table).get(message.row)
                 if not entry:
@@ -255,18 +264,20 @@ class Actual(ActualServer):
 
         if self._file.encrypt_key_id and encryption_password is None:
             raise ActualError("File is encrypted but no encryption password provided.")
-        if encryption_password is not None:
+        if encryption_password is not None and self._file.encrypt_key_id:
             file_info = self.get_user_file_info(self._file.file_id)
             key_info = self.user_get_key(self._file.file_id)
             self._master_key = create_key_buffer(encryption_password, key_info.data.salt)
             # decrypt file bytes
             file_bytes = decrypt_from_meta(self._master_key, file_bytes, file_info.data.encrypt_meta)
-        self._load_zip(file_bytes)
+        self.import_zip(io.BytesIO(file_bytes))
+        # actual js always calls validation
+        self.validate()
+        self.sync()
 
-    def _load_zip(self, file_bytes: bytes):
-        f = io.BytesIO(file_bytes)
+    def import_zip(self, file_bytes: str | PathLike[str] | IO[bytes]):
         try:
-            zip_file = zipfile.ZipFile(f)
+            zip_file = zipfile.ZipFile(file_bytes)
         except zipfile.BadZipfile as e:
             raise InvalidZipFile(f"Invalid zip file: {e}") from None
         if not self._data_dir:
@@ -275,10 +286,10 @@ class Actual(ActualServer):
         zip_file.extractall(self._data_dir)
         engine = sqlalchemy.create_engine(f"sqlite:///{self._data_dir}/db.sqlite")
         self.session_maker = sqlalchemy.orm.sessionmaker(engine, autoflush=False)
-        # actual js always calls validation
-        self.validate()
         # load the client id
         self.load_clock()
+
+    def sync(self):
         # after downloading the budget, some pending transactions still need to be retrieved using sync
         request = SyncRequest(
             {
@@ -289,7 +300,7 @@ class Actual(ActualServer):
             }
         )
         request.set_null_timestamp(client_id=self._client.client_id)  # using 0 timestamp to retrieve all changes
-        changes = self.sync(request)
+        changes = self.sync_sync(request)
         self.apply_changes(changes.get_messages(self._master_key))
         if changes.messages:
             self._client = HULC_Client.from_timestamp(changes.messages[-1].timestamp)
@@ -338,4 +349,4 @@ class Actual(ActualServer):
         # make sure changes are valid before syncing
         self._session.commit()
         # sync all changes to the server
-        self.sync(req)
+        self.sync_sync(req)
diff --git a/actual/api.py b/actual/api.py
index 1cc5bfc..3b16261 100644
--- a/actual/api.py
+++ b/actual/api.py
@@ -307,7 +307,7 @@ class ActualServer:
         )
         return StatusDTO.parse_obj(response.json())
 
-    def sync(self, request: SyncRequest) -> SyncResponse:
+    def sync_sync(self, request: SyncRequest) -> SyncResponse:
         """Calls the sync endpoint with a request and returns the response. Both the request and response are
         protobuf models. The request and response are not standard REST, but rather protobuf binary serialized data.
         The server stores this serialized data to allow the user to replay all changes to the database and construct
diff --git a/actual/crypto.py b/actual/crypto.py
index 82cb20a..65d1d01 100644
--- a/actual/crypto.py
+++ b/actual/crypto.py
@@ -1,5 +1,8 @@
+from __future__ import annotations
+
 import base64
 import os
+import uuid
 
 import cryptography.exceptions
 from cryptography.hazmat.primitives import hashes
@@ -20,8 +23,6 @@ def make_salt(length: int = 32) -> str:
 
 
 def create_key_buffer(password: str, key_salt: str) -> bytes:
-    if key_salt is None:
-        key_salt = make_salt()
     kdf = PBKDF2HMAC(algorithm=hashes.SHA512(), length=32, salt=key_salt.encode(), iterations=10_000)
     return kdf.derive(password.encode())
 
@@ -66,3 +67,32 @@ def make_test_message(key_id: str, key: bytes) -> dict:
     binary_message = Message.serialize(m)
     # return encrypted binary message
     return encrypt(key_id, key, binary_message)
+
+
+def is_uuid(text: str, version: int = 4):
+    """
+    Check if uuid_to_test is a valid UUID.
+
+    Taken from https://stackoverflow.com/a/54254115/12681470
+
+     Parameters
+    ----------
+    uuid_to_test : str
+    version : {1, 2, 3, 4}
+
+     Returns
+    -------
+    `True` if uuid_to_test is a valid UUID, otherwise `False`.
+
+     Examples
+    --------
+    >>> is_uuid('c9bf9e57-1685-4c89-bafb-ff5af830be8a')
+    True
+    >>> is_uuid('c9bf9e58')
+    False
+    """
+    try:
+        uuid.UUID(str(text), version=version)
+        return True
+    except ValueError:
+        return False
diff --git a/actual/database.py b/actual/database.py
index 358164b..81f2278 100644
--- a/actual/database.py
+++ b/actual/database.py
@@ -1,6 +1,14 @@
+"""
+This file was partially generated using sqlacodegen using the downloaded version of the db.sqlite file export
+in order to update this file, you can generate the code with:
+
+> sqlacodegen --generator sqlmodels sqlite:///db.sqlite
+
+and patch the necessary models by merging the results.
+"""
+
 import datetime
 import decimal
-import uuid
 from typing import List, Optional, Union
 
 from sqlalchemy import (
@@ -95,8 +103,8 @@ class BaseModel(SQLModel):
         return changes
 
     def delete(self):
-        if not getattr(self, "tombstone", None):
-            raise AttributeError(f"Model {self.__name__} has no tombstone field and cannot be deleted.")
+        if not hasattr(self, "tombstone"):
+            raise AttributeError(f"Model {self.__class__.__name__} has no tombstone field and cannot be deleted.")
         setattr(self, "tombstone", 1)
 
 
@@ -184,6 +192,42 @@ class CreatedBudgets(SQLModel, table=True):
     month: Optional[str] = Field(default=None, sa_column=Column("month", Text, primary_key=True))
 
 
+class CustomReports(BaseModel, table=True):
+    __tablename__ = "custom_reports"
+
+    id: Optional[str] = Field(default=None, sa_column=Column("id", Text, primary_key=True))
+    name: Optional[str] = Field(default=None, sa_column=Column("name", Text))
+    start_date: Optional[str] = Field(default=None, sa_column=Column("start_date", Text))
+    end_date: Optional[str] = Field(default=None, sa_column=Column("end_date", Text))
+    date_static: Optional[int] = Field(default=None, sa_column=Column("date_static", Integer, server_default=text("0")))
+    date_range: Optional[str] = Field(default=None, sa_column=Column("date_range", Text))
+    mode: Optional[str] = Field(default=None, sa_column=Column("mode", Text, server_default=text("'total'")))
+    group_by: Optional[str] = Field(default=None, sa_column=Column("group_by", Text, server_default=text("'Category'")))
+    balance_type: Optional[str] = Field(
+        default=None, sa_column=Column("balance_type", Text, server_default=text("'Expense'"))
+    )
+    show_empty: Optional[int] = Field(default=None, sa_column=Column("show_empty", Integer, server_default=text("0")))
+    show_offbudget: Optional[int] = Field(
+        default=None, sa_column=Column("show_offbudget", Integer, server_default=text("0"))
+    )
+    show_hidden: Optional[int] = Field(default=None, sa_column=Column("show_hidden", Integer, server_default=text("0")))
+    show_uncategorized: Optional[int] = Field(
+        default=None, sa_column=Column("show_uncategorized", Integer, server_default=text("0"))
+    )
+    selected_categories: Optional[str] = Field(default=None, sa_column=Column("selected_categories", Text))
+    graph_type: Optional[str] = Field(
+        default=None, sa_column=Column("graph_type", Text, server_default=text("'BarGraph'"))
+    )
+    conditions: Optional[str] = Field(default=None, sa_column=Column("conditions", Text))
+    conditions_op: Optional[str] = Field(
+        default=None, sa_column=Column("conditions_op", Text, server_default=text("'and'"))
+    )
+    metadata_: Optional[str] = Field(default=None, sa_column=Column("metadata", Text))
+    interval: Optional[str] = Field(default=None, sa_column=Column("interval", Text, server_default=text("'Monthly'")))
+    color_scheme: Optional[str] = Field(default=None, sa_column=Column("color_scheme", Text))
+    tombstone: Optional[int] = Field(default=None, sa_column=Column("tombstone", Integer, server_default=text("0")))
+
+
 class Kvcache(SQLModel, table=True):
     key: Optional[str] = Field(default=None, sa_column=Column("key", Text, primary_key=True))
     value: Optional[str] = Field(default=None, sa_column=Column("value", Text))
@@ -351,33 +395,18 @@ class Transactions(BaseModel, table=True):
     category: Optional["Categories"] = Relationship(back_populates="transactions")
     payee: Optional["Payees"] = Relationship(back_populates="transactions")
 
-    @classmethod
-    def new(
-        cls,
-        account_id: str,
-        amount: decimal.Decimal,
-        date: datetime.date,
-        notes: Optional[str] = None,
-        category: Optional[Categories] = None,
-        payee: Optional[Payees] = None,
-    ):
-        date_int = int(datetime.date.strftime(date, "%Y%m%d"))
-        return cls(
-            id=str(uuid.uuid4()),
-            acct=account_id,
-            date=date_int,
-            amount=int(amount * 100),
-            category=category,
-            payee=payee,
-            notes=notes,
-            reconciled=0,
-            cleared=0,
-            sort_order=datetime.datetime.utcnow().timestamp(),
-        )
-
     def get_date(self) -> datetime.date:
         return datetime.datetime.strptime(str(self.date), "%Y%m%d").date()
 
+    def set_date(self, date: datetime.date):
+        self.date = int(datetime.date.strftime(date, "%Y%m%d"))
+
+    def set_amount(self, amount: Union[decimal.Decimal, int, float]):
+        self.amount = int(amount * 100)
+
+    def get_amount(self) -> decimal.Decimal:
+        return decimal.Decimal(self.amount) / decimal.Decimal(100)
+
 
 class ZeroBudgetMonths(SQLModel, table=True):
     __tablename__ = "zero_budget_months"
diff --git a/actual/protobuf_models.py b/actual/protobuf_models.py
index 091ce82..04325f5 100644
--- a/actual/protobuf_models.py
+++ b/actual/protobuf_models.py
@@ -7,6 +7,7 @@ import uuid
 import proto
 
 from actual.crypto import decrypt, encrypt
+from actual.exceptions import ActualDecryptionError
 
 """
 Protobuf message definitions taken from:
@@ -150,7 +151,7 @@ class SyncResponse(proto.Message):
         for message in self.messages:  # noqa
             if message.isEncrypted:
                 if not master_key:
-                    raise ValueError("Master key not provided and data is encrypted.")
+                    raise ActualDecryptionError("Master key not provided and data is encrypted.")
                 encrypted = EncryptedData.deserialize(message.content)
                 content = decrypt(master_key, encrypted.iv, encrypted.data, encrypted.authTag)
             else:
diff --git a/actual/queries.py b/actual/queries.py
index a5d2ce6..0d27335 100644
--- a/actual/queries.py
+++ b/actual/queries.py
@@ -2,12 +2,15 @@ from __future__ import annotations
 
 import datetime
 import decimal
+import json
 import typing
 import uuid
 
+import pydantic
 import sqlalchemy
 from sqlalchemy.orm import Session, joinedload
 
+from actual.crypto import is_uuid
 from actual.database import (
     Accounts,
     Categories,
@@ -15,42 +18,15 @@ from actual.database import (
     CategoryMapping,
     PayeeMapping,
     Payees,
+    Rules,
     Transactions,
 )
 from actual.exceptions import ActualError
+from actual.rules import Action, Condition, Rule, RuleSet
 
 T = typing.TypeVar("T")
 
 
-def is_uuid(text: str, version: int = 4):
-    """
-    Check if uuid_to_test is a valid UUID.
-
-    Taken from https://stackoverflow.com/a/54254115/12681470
-
-     Parameters
-    ----------
-    uuid_to_test : str
-    version : {1, 2, 3, 4}
-
-     Returns
-    -------
-    `True` if uuid_to_test is a valid UUID, otherwise `False`.
-
-     Examples
-    --------
-    >>> is_uuid('c9bf9e57-1685-4c89-bafb-ff5af830be8a')
-    True
-    >>> is_uuid('c9bf9e58')
-    False
-    """
-    try:
-        uuid.UUID(str(text), version=version)
-        return True
-    except ValueError:
-        return False
-
-
 def get_transactions(
     s: Session,
     start_date: datetime.date = None,
@@ -157,7 +133,7 @@ def create_transaction(
     return create_transaction_from_ids(s, date, acct.id, payee.id, notes, category_id, amount)
 
 
-def base_query(s: Session, instance: typing.Type[T], name: str, include_deleted: bool = False) -> typing.List[T]:
+def base_query(s: Session, instance: typing.Type[T], name: str = None, include_deleted: bool = False) -> typing.List[T]:
     """Internal method to reduce querying complexity on sub-functions."""
     query = s.query(instance)
     if not include_deleted:
@@ -382,3 +358,59 @@ def create_transfer(
     s.add(source_transaction)
     s.add(dest_transaction)
     return source_transaction, dest_transaction
+
+
+def get_rules(s: Session, include_deleted: bool = False) -> list[Rules]:
+    """
+    Returns a list of all available rules.
+
+    :param s: session from Actual local database.
+    :param include_deleted: includes all payees which were deleted via frontend. They would not show normally.
+    :return: list of rules.
+    """
+    return base_query(s, Rules, None, include_deleted).all()  # noqa
+
+
+def get_ruleset(s: Session) -> RuleSet:
+    """
+    Returns a list of all available rules, but as a rule set that can be used to be applied to existing transactions.
+
+    :param s: session from Actual local database.
+    :return: RuleSet object that contains all rules and can be either.
+    """
+    rule_set = list()
+    for rule in get_rules(s):
+        conditions = pydantic.parse_obj_as(list[Condition], json.loads(rule.conditions))
+        actions = pydantic.parse_obj_as(list[Action], json.loads(rule.actions))
+        rs = Rule(conditions=conditions, operation=rule.conditions_op, actions=actions, stage=rule.stage)
+        rule_set.append(rs)
+    return RuleSet(rules=rule_set)
+
+
+def create_rule(
+    s: Session,
+    rule: Rule,
+    run_immediately: bool = False,
+) -> Rules:
+    """
+    Creates a rule based on the conditions and actions defined on the input rule. The rule can be ordered to run
+    immediately, running the action for all entries that match the conditions on insertion.
+
+    :param s: session from Actual local database.
+    :param rule: a constructed rule object from `actual.rules`. The rule format and data types are validated on the
+        constructor, **but the data itself is not. Make sure that, if you reference uuids, that they exist.
+    :param run_immediately: if the run should run for all transactions on insert, defaults to `False`.
+    :return: Rule database object created.
+    """
+    rule.dict()
+    conditions = json.dumps([c.as_dict() for c in rule.conditions])
+    actions = json.dumps([a.as_dict() for a in rule.actions])
+    database_rule = Rules(
+        id=str(uuid.uuid4()), stage=rule.stage, conditions_op=rule.operation, conditions=conditions, actions=actions
+    )
+    s.add(database_rule)
+    if run_immediately:
+        for t in get_transactions(s):
+            if rule.run(t):
+                s.add(t)
+    return database_rule
diff --git a/actual/rules.py b/actual/rules.py
new file mode 100644
index 0000000..af77526
--- /dev/null
+++ b/actual/rules.py
@@ -0,0 +1,374 @@
+from __future__ import annotations
+
+import datetime
+import enum
+import typing
+
+import pydantic
+
+from actual import ActualError
+from actual.crypto import is_uuid
+from actual.database import Transactions, get_attribute_by_table_name
+
+
+class ConditionType(enum.Enum):
+    IS = "is"
+    IS_APPROX = "isapprox"
+    GT = "gt"
+    GTE = "gte"
+    LT = "lt"
+    LTE = "lte"
+    CONTAINS = "contains"
+    ONE_OF = "oneOf"
+    IS_NOT = "isNot"
+    DOES_NOT_CONTAIN = "doesNotContain"
+    NOT_ONE_OF = "notOneOf"
+    IS_BETWEEN = "isbetween"
+
+
+class ActionType(enum.Enum):
+    SET = "set"
+    SET_SPLIT_AMOUNT = "set-split-amount"
+    LINK_SCHEDULE = "link-schedule"
+
+
+class ValueType(enum.Enum):
+    DATE = "date"
+    ID = "id"
+    STRING = "string"
+    NUMBER = "number"
+    BOOLEAN = "boolean"
+
+    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,
+        then `RuleValueType.STRING.is_valid(ConditionType.GT)` will return false, because there is no logical
+        greater than defined for strings."""
+        if self == ValueType.DATE:
+            return operation.value in ("is", "isapprox", "gt", "gte", "lt", "lte")
+        elif self == ValueType.STRING:
+            return operation.value in ("is", "contains", "oneOf", "isNot", "doesNotContain", "notOneOf")
+        elif self == ValueType.ID:
+            return operation.value in ("is", "isNot", "oneOf", "notOneOf")
+        elif self == ValueType.NUMBER:
+            return operation.value in ("is", "isapprox", "isbetween", "gt", "gte", "lt", "lte")
+        else:
+            # 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)
+        if value is None:
+            return True
+        if self == ValueType.ID:
+            # make sure it's an uuid
+            return isinstance(value, str) and is_uuid(value)
+        elif self == ValueType.STRING:
+            return isinstance(value, str)
+        elif self == ValueType.DATE:
+            try:
+                res = bool(get_value(value, self))
+            except ValueError:
+                res = False
+            return res
+        elif self == ValueType.NUMBER:
+            return (isinstance(value, int) or isinstance(value, float)) and value >= 0
+        else:
+            # must be BOOLEAN
+            return isinstance(value, bool)
+
+    @classmethod
+    def from_field(cls, field: str) -> ValueType:
+        if field in ("acct", "category", "description"):
+            return ValueType.ID
+        elif field in ("notes", "imported_description"):
+            return ValueType.STRING
+        elif field in ("date",):
+            return ValueType.DATE
+        elif field in ("cleared",):
+            return ValueType.BOOLEAN
+        elif field in ("amount",):
+            return ValueType.NUMBER
+        else:
+            raise ValueError(f"Field '{field}' does not have a matching ValueType.")
+
+
+def get_value(
+    value: typing.Union[int, list[str], str, None], value_type: ValueType
+) -> typing.Union[int, datetime.date, list[str], str, None]:
+    """Converts the value to an actual value according to the type."""
+    if value_type is ValueType.DATE:
+        if isinstance(value, str):
+            return datetime.datetime.strptime(value, "%Y-%m-%d").date()
+        elif isinstance(value, int):
+            return datetime.datetime.strptime(str(value), "%Y%m%d").date()
+    elif value_type is ValueType.BOOLEAN:
+        return int(value)  # database accepts 0 or 1
+    return 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],
+) -> 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."""
+    if true_value is None:
+        # short circuit as comparisons with NoneType are useless
+        return False
+    if op == ConditionType.IS:
+        return self_value == true_value
+    elif op == ConditionType.IS_NOT:
+        return self_value != true_value
+    elif op == ConditionType.IS_APPROX:
+        # Actual uses two days as reference
+        # https://github.com/actualbudget/actual/blob/98a7aac73667241da350169e55edd2fc16a6687f/packages/loot-core/src/server/accounts/rules.ts#L302-L304
+        interval = datetime.timedelta(days=2)
+        return self_value - interval <= true_value <= self_value + interval
+    elif op in (ConditionType.ONE_OF, ConditionType.CONTAINS):
+        return true_value in self_value
+    elif op in (ConditionType.NOT_ONE_OF, ConditionType.DOES_NOT_CONTAIN):
+        return true_value not in self_value
+    elif op == ConditionType.GT:
+        return true_value > self_value
+    elif op == ConditionType.GTE:
+        return true_value >= self_value
+    elif op == ConditionType.LT:
+        return self_value > true_value
+    elif op == ConditionType.LTE:
+        return self_value >= true_value
+    else:
+        raise ActualError(f"Operation {op} not supported")
+
+
+class Condition(pydantic.BaseModel):
+    """
+    A condition does a single comparison check for a transaction. The 'op' indicates the action type, usually being
+    set to IS or CONTAINS, and the operation applied to a 'field' with certain 'value'. If the transaction value matches
+    the condition, the `run` method returns `True`, otherwise it returns `False`.
+
+    The 'field' can be one of the following ('type' will be set automatically):
+
+        - imported_description: 'type' must be 'string' and 'value' any string
+        - acct: 'type' must be 'id' and 'value' a valid uuid
+        - category: 'type' must be 'id' and 'value' a valid uuid
+        - date: 'type' must be 'date' and 'value' a string in the date format '2024-04-11'
+        - description: 'type' must be 'id' and 'value' a valid uuid (means payee_id)
+        - notes: 'type' must be 'string' and 'value' any string
+        - amount: 'type' must be 'number' and format in cents (additional "options":{"inflow":true} or
+            "options":{"outflow":true} for inflow/outflow distinction, set automatically based on the amount sign)
+    """
+
+    field: typing.Literal["imported_description", "acct", "category", "date", "description", "notes", "amount"]
+    op: ConditionType
+    value: typing.Union[int, list[str], list[pydantic.BaseModel], str, pydantic.BaseModel, datetime.date, None]
+    type: ValueType = None
+    options: dict = None
+
+    def __str__(self) -> str:
+        value = f"'{self.value}'" if isinstance(self.value, str) else str(self.value)
+        return f"'{self.field}' {self.op.value} {value}"
+
+    def as_dict(self):
+        """Returns valid dict for database insertion."""
+        ret = self.model_dump(mode="json")
+        if not self.options:
+            ret.pop("options", None)
+        return ret
+
+    def get_value(self) -> typing.Union[int, datetime.date, list[str], str, None]:
+        return get_value(self.value, self.type)
+
+    @pydantic.model_validator(mode="after")
+    def convert_value(self):
+        if isinstance(self.value, int) and self.options is None:
+            self.options = {"inflow": True} if self.value > 0 else {"outflow": True}
+            self.value = int(abs(self.value) * 100)
+        return self
+
+    @pydantic.model_validator(mode="after")
+    def check_operation_type(self):
+        if not self.type:
+            self.type = ValueType.from_field(self.field)
+        # check if types are fine
+        if not self.type.is_valid(self.op):
+            raise ValueError(f"Operation {self.op} not supported for type {self.type}")
+        # 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)
+        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):
+            raise ValueError(f"Value {self.value} is not valid for type {self.type.name} and operation {self.op.name}")
+        return self
+
+    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)
+
+
+class Action(pydantic.BaseModel):
+    """
+    An Action does a single column change for a transaction. The 'op' indicates the action type, usually being to SET
+    a 'field' with certain 'value'.
+
+    The 'field' can be one of the following ('type' will be set automatically):
+
+        - category: 'type' must be 'id' and 'value' a valid uuid
+        - description: 'type' must be 'id' 'value' a valid uuid (additional "options":{"splitIndex":0})
+        - notes: 'type' must be 'string' and 'value' any string
+        - cleared: 'type' must be 'boolean' and value is a literal True/False (additional "options":{"splitIndex":0})
+        - acct: 'type' must be 'id' and 'value' an uuid
+        - date: 'type' must be 'date' and 'value' a string in the date format '2024-04-11'
+    """
+
+    field: typing.Literal["category", "description", "notes", "cleared", "acct", "date"]
+    op: ActionType = pydantic.Field(ActionType.SET, description="Action type to apply (default changes a column).")
+    value: typing.Union[str, bool, pydantic.BaseModel, None]
+    type: ValueType = None
+    options: dict = None
+
+    def __str__(self) -> str:
+        return f"{self.op.value} '{self.field}' to '{self.value}'"
+
+    def as_dict(self):
+        """Returns valid dict for database insertion."""
+        ret = self.model_dump(mode="json")
+        if not self.options:
+            ret.pop("options", None)
+        return ret
+
+    @pydantic.model_validator(mode="after")
+    def check_operation_type(self):
+        if not self.type:
+            self.type = ValueType.from_field(self.field)
+        # 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)
+        # make sure the data matches the value type
+        if not self.type.validate(self.value):
+            raise ValueError(f"Value {self.value} is not valid for type {self.type.name}")
+        return self
+
+    def run(self, transaction: Transactions) -> None:
+        if self.op == ActionType.SET:
+            attr = get_attribute_by_table_name(Transactions.__tablename__, self.field)
+            value = get_value(self.value, self.type)
+            if self.type == ValueType.DATE:
+                transaction.set_date(value)
+            else:
+                setattr(transaction, attr, value)
+        else:
+            raise ActualError(f"Operation {self.op} not supported")
+
+
+class Rule(pydantic.BaseModel):
+    """Contains an individual rule, with multiple conditions and multiple actions. Can only be used
+    on a single transaction at a time. You can evaluate if the rule would activate without doing any changes on the
+    transaction by calling the `evaluate` method.
+
+    Note that the frontend refers to the operation as either 'all' or 'any', but stores them as 'and' and 'or'
+    respectively on the database. If you provide the frontend values, they will be converted on the background
+    automatically.
+    """
+
+    conditions: list[Condition] = pydantic.Field(
+        ..., description="List of conditions that need to be met (one or all) in order for the actions to be applied."
+    )
+    operation: typing.Literal["and", "or"] = pydantic.Field(
+        "and", description="Operation to apply for the rule evaluation. If 'all' or 'any' need to be evaluated."
+    )
+    actions: list[Action] = pydantic.Field(..., description="List of actions to apply to the transaction.")
+    stage: typing.Literal["pre", "post", None] = pydantic.Field(
+        None, description="Stage in which the rule" "will be evaluated (default None)"
+    )
+
+    @pydantic.model_validator(mode="before")
+    def correct_operation(cls, value):
+        """If the user provides the same 'all' or 'any' that the frontend provides, we fix it silently."""
+        if value.get("operation") == "all":
+            value["operation"] = "and"
+        elif value.get("operation") == "any":
+            value["operation"] = "or"
+        return value
+
+    def __str__(self):
+        """Returns a readable string representation of the rule."""
+        operation = "all" if self.operation == "and" else "any"
+        conditions = f" {self.operation} ".join([str(c) for c in self.conditions])
+        actions = ", ".join([str(a) for a in self.actions])
+        return f"If {operation} of these conditions match {conditions} then {actions}"
+
+    def evaluate(self, transaction: Transactions) -> bool:
+        """Evaluates the rule on the transaction, without applying any action."""
+        op = any if self.operation == "or" else all
+        return op(c.run(transaction) for c in self.conditions)
+
+    def run(self, transaction: Transactions) -> bool:
+        """Runs the rule on the transaction, calling evaluate, and if the return is `True` then running each of
+        the actions."""
+        if condition_met := self.evaluate(transaction):
+            for action in self.actions:
+                action.run(transaction)
+        return condition_met
+
+
+class RuleSet(pydantic.BaseModel):
+    """
+    A RuleSet is a collection of Conditions and Actions that will evaluate for one or more transactions.
+
+    The conditions are list of rules that will compare fields from the transaction. If all conditions from a RuleEntry
+    are met (or any, if the RuleEntry has an 'or' operation), then the actions will be applied.
+
+    The actions are a list of changes that will be applied to one or more transaction.
+
+    Full example ruleset: "If all of these conditions match 'notes' contains 'foo' then set 'notes' to 'bar'"
+
+    To create that rule set, you can do:
+
+    >>> RuleSet(rules=[
+    >>>     Rule(
+    >>>         operation="and",
+    >>>         conditions=[Condition(field="notes", op=ConditionType.CONTAINS, value="foo")],
+    >>>         actions=[Action(field="notes", value="bar")],
+    >>>     )
+    >>> ])
+    """
+
+    rules: list[Rule]
+
+    def __str__(self):
+        return "\n".join([str(r) for r in self.rules])
+
+    def _run(
+        self, transaction: typing.Union[Transactions, list[Transactions]], stage: typing.Literal["pre", "post", None]
+    ):
+        for rule in [r for r in self.rules if r.stage == stage]:
+            if isinstance(transaction, list):
+                for t in transaction:
+                    rule.run(t)
+            else:
+                rule.run(transaction)
+
+    def run(
+        self,
+        transaction: typing.Union[Transactions, list[Transactions]],
+        stage: typing.Literal["all", "pre", "post", None] = "all",
+    ):
+        """Runs the rules for each and every transaction on the list. If stage is 'all' (default), all rules are run in
+        the order 'pre' -> None -> 'post'. You can provide a value to run only a certain stage of rules."""
+        if stage == "all":
+            self._run(transaction, "pre")
+            self._run(transaction, None)
+            self._run(transaction, "post")
+        else:
+            self._run(transaction, stage)  # noqa
+
+    def add(self, rule: Rule):
+        """Adds a new rule to the ruleset."""
+        self.rules.append(rule)
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 8076a54..bd2279a 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -3,7 +3,7 @@ version: '3'
 services:
   actual:
     container_name: actual
-    image: docker.io/actualbudget/actual-server:24.3.0
+    image: docker.io/actualbudget/actual-server:24.5.0
     ports:
       - '5006:5006'
     volumes:
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..db34d46
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,2 @@
+pytest-mock
+pytest


Test patch:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 24711d6..4e346ef 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -24,6 +24,7 @@ jobs:
           python -m pip install --upgrade pip
           pip install ruff pytest
           if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
+          if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi
       - name: Lint with ruff
         run: |
           # stop the build if there are Python syntax errors or undefined names
diff --git a/tests/test_crypto.py b/tests/test_crypto.py
index 656874c..bd0310b 100644
--- a/tests/test_crypto.py
+++ b/tests/test_crypto.py
@@ -3,7 +3,15 @@ import base64
 import pytest
 
 from actual.api import EncryptMetaDTO
-from actual.crypto import create_key_buffer, decrypt_from_meta, encrypt, random_bytes
+from actual.crypto import (
+    create_key_buffer,
+    decrypt,
+    decrypt_from_meta,
+    encrypt,
+    make_salt,
+    make_test_message,
+    random_bytes,
+)
 from actual.exceptions import ActualDecryptionError
 from actual.protobuf_models import HULC_Client, Message, SyncRequest, SyncResponse
 
@@ -35,6 +43,18 @@ def test_encrypt_decrypt_message():
     req.set_messages([m], HULC_Client(), master_key=key)
     resp = SyncResponse()
     resp.messages = req.messages
+    with pytest.raises(ActualDecryptionError):
+        resp.get_messages()  # should fail to get messages without a key
     decrypted_messages = resp.get_messages(master_key=key)
     assert len(decrypted_messages) == 1
     assert decrypted_messages[0] == m
+
+
+def test_create_test_message():
+    key = create_key_buffer(make_salt(), make_salt())
+    tm = make_test_message("", key)
+    dfm = decrypt(
+        key, base64.b64decode(tm["meta"]["iv"]), base64.b64decode(tm["value"]), base64.b64decode(tm["meta"]["authTag"])
+    )
+    m = Message.deserialize(dfm)
+    assert isinstance(m, Message)
diff --git a/tests/test_models.py b/tests/test_models.py
index af650f2..0d2ceae 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -1,7 +1,10 @@
 import datetime
-import decimal
+import uuid
+
+import pytest
 
 from actual.database import (
+    CategoryMapping,
     Transactions,
     get_attribute_by_table_name,
     get_class_by_table_name,
@@ -25,8 +28,20 @@ def test_get_attribute_by_table_name():
 
 
 def test_conversion():
-    t = Transactions.new("foo", decimal.Decimal(10), datetime.date(2024, 3, 17))
-    # modified one field afterwards
+    t = Transactions(
+        id=str(uuid.uuid4()),
+        acct="foo",
+        amount=1000,
+        reconciled=0,
+        cleared=0,
+        sort_order=datetime.datetime.utcnow().timestamp(),
+    )
+    t.set_amount(10)
+    t.set_date(datetime.date(2024, 3, 17))
+    # ensure fields are correctly retrieved
+    assert t.get_amount() == 10
+    assert t.get_date() == datetime.date(2024, 3, 17)
+    # modified one field after-wards
     t.is_parent = 1
     conversion = t.convert()
     # conversion should all contain the same row id and same dataset
@@ -37,3 +52,13 @@ def test_conversion():
     assert [c for c in conversion if c.column == "amount"][0].get_value() == 1000
     assert [c for c in conversion if c.column == "date"][0].get_value() == 20240317
     assert [c for c in conversion if c.column == "isParent"][0].get_value() == 1
+    # make sure delete only changes the tomstone
+    assert t.tombstone is None  # server default is 0, but local copy is None
+    t.delete()
+    assert t.tombstone == 1
+
+
+def test_delete_exception():
+    cm = CategoryMapping(id="foo")
+    with pytest.raises(AttributeError):
+        cm.delete()
diff --git a/tests/test_protobuf.py b/tests/test_protobuf.py
index 3ba653f..6e41090 100644
--- a/tests/test_protobuf.py
+++ b/tests/test_protobuf.py
@@ -15,6 +15,7 @@ def test_timestamp():
     now = datetime.datetime(2020, 10, 11, 12, 13, 14, 15 * 1000)
     ts = HULC_Client("foo").timestamp(now)
     assert ts == "2020-10-11T12:13:14.015Z-0000-foo"
+    assert "foo" == HULC_Client.from_timestamp(ts).client_id
 
 
 def test_message_envelope():
diff --git a/tests/test_rules.py b/tests/test_rules.py
new file mode 100644
index 0000000..64e7a2e
--- /dev/null
+++ b/tests/test_rules.py
@@ -0,0 +1,195 @@
+import datetime
+import json
+import uuid
+from unittest.mock import MagicMock
+
+import pytest
+
+from actual import ActualError
+from actual.queries import (
+    create_account,
+    create_category,
+    create_payee,
+    create_rule,
+    create_transaction,
+)
+from actual.rules import (
+    Action,
+    Condition,
+    ConditionType,
+    Rule,
+    RuleSet,
+    ValueType,
+    condition_evaluation,
+)
+
+
+def test_category_rule():
+    mock = MagicMock()
+    # create basic items
+    acct = create_account(mock, "Bank")
+    cat = create_category(mock, "Food", "Expenses")
+    payee = create_payee(mock, "My payee")
+    # create rule
+    condition = Condition(field="category", op="is", value=cat)
+    action = Action(field="description", value=payee)
+    rule = Rule(conditions=[condition], actions=[action], operation="all")
+    rs = RuleSet(rules=[])
+    rs.add(rule)
+    # run for one transaction
+    t = create_transaction(mock, datetime.date(2024, 1, 1), acct, "", category=cat)
+    rs.run(t)
+    # evaluate if things match
+    assert t.payee_id == payee.id
+    assert (
+        str(rs) == f"If all of these conditions match 'category' is '{cat.id}' "
+        f"then set 'description' to '{payee.id}'"
+    )
+    # check if it ignores the input when making the category None
+    t.category_id = None
+    assert condition.run(t) is False
+
+
+def test_datetime_rule():
+    mock = MagicMock()
+    acct = create_account(mock, "Bank")
+    t = create_transaction(mock, datetime.date(2024, 1, 1), acct, "")
+    condition = Condition(field="date", op="isapprox", value=datetime.date(2024, 1, 2))
+    action = Action(field="date", value="2024-01-30")
+    rs = RuleSet(rules=[Rule(conditions=[condition], actions=[action], operation="any")])
+    # run only first stage
+    rs.run([t], stage=None)
+    target_date = datetime.date(2024, 1, 30)
+    assert t.get_date() == target_date
+    # try the is not
+    assert Condition(field="date", op="is", value=target_date).run(t) is True
+    # try the comparison operators
+    assert Condition(field="date", op="gte", value=target_date).run(t) is True
+    assert Condition(field="date", op="lte", value=target_date).run(t) is True
+    assert Condition(field="date", op="gte", value=target_date - datetime.timedelta(days=1)).run(t) is True
+    assert Condition(field="date", op="lte", value=target_date + datetime.timedelta(days=1)).run(t) is True
+    assert Condition(field="date", op="lt", value=target_date).run(t) is False
+    assert Condition(field="date", op="gt", value=target_date).run(t) is False
+    assert Condition(field="date", op="gt", value=target_date - datetime.timedelta(days=1)).run(t) is True
+    assert Condition(field="date", op="lt", value=target_date + datetime.timedelta(days=1)).run(t) is True
+
+
+def test_numeric_condition():
+    t = create_transaction(MagicMock(), datetime.date(2024, 1, 1), "Bank", "", amount=5)
+    c1 = Condition(field="amount", op="gt", value=10)
+    assert "inflow" in c1.options
+    assert c1.run(t) is False
+    c2 = Condition(field="amount", op="lt", value=-10)
+    assert "outflow" in c2.options
+    assert c2.run(t) is True  # outflow, so the comparison should be with the positive value
+
+
+def test_complex_rule():
+    mock = MagicMock()
+    # create basic items
+    acct = create_account(mock, "Bank")
+    cat = create_category(mock, "Food", "Expenses")
+    cat_extra = create_category(mock, "Restaurants", "Expenses")
+    payee = create_payee(mock, "My payee")
+    # create rule set
+    rs = RuleSet(
+        rules=[
+            Rule(
+                conditions=[
+                    Condition(
+                        field="category",
+                        op=ConditionType.ONE_OF,
+                        value=[cat],
+                    ),
+                    Condition(
+                        field="category",
+                        op=ConditionType.NOT_ONE_OF,
+                        value=[cat_extra],
+                    ),
+                    Condition(field="description", op="isNot", value=str(uuid.uuid4())),  # should not match
+                ],
+                actions=[Action(field="cleared", value=True)],
+                operation="all",
+            )
+        ]
+    )
+    t_true = create_transaction(mock, datetime.date(2024, 1, 1), acct, payee, category=cat)
+    t_false = create_transaction(mock, datetime.date(2024, 1, 1), acct, payee)
+    rs.run([t_true, t_false])
+    assert t_true.cleared == 1
+    assert t_false.cleared == 0
+
+
+def test_invalid_inputs():
+    with pytest.raises(ValueError):
+        Condition(field="amount", op="gt", value="foo")
+    with pytest.raises(ValueError):
+        Condition(field="amount", op="contains", value=10)
+    with pytest.raises(ValueError):
+        Action(field="date", value="foo")
+    with pytest.raises(ValueError):
+        Condition(field="description", op="is", value="foo")  # not an uuid
+    with pytest.raises(ActualError):
+        Action(field="notes", op="link-schedule", value="foo").run(None)  # noqa: use None instead of transaction
+    with pytest.raises(ActualError):
+        condition_evaluation(None, "foo", "foo")  # noqa: use None instead of transaction
+
+
+def test_value_type_condition_validation():
+    assert ValueType.DATE.is_valid(ConditionType.IS_APPROX) is True
+    assert ValueType.DATE.is_valid(ConditionType.CONTAINS) is False
+    assert ValueType.NUMBER.is_valid(ConditionType.IS_BETWEEN) is True
+    assert ValueType.NUMBER.is_valid(ConditionType.DOES_NOT_CONTAIN) is False
+    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.STRING.is_valid(ConditionType.CONTAINS) is True
+    assert ValueType.STRING.is_valid(ConditionType.GT) is False
+
+
+def test_value_type_value_validation():
+    assert ValueType.DATE.validate(20241004) is True
+    assert ValueType.DATE.validate(123) is False
+    assert ValueType.DATE.validate("2024-10-04") is True
+    assert ValueType.STRING.validate("") is True
+    assert ValueType.STRING.validate(123) is False
+    assert ValueType.NUMBER.validate(123) is True
+    assert ValueType.NUMBER.validate(1.23) is True  # noqa: test just in case
+    assert ValueType.NUMBER.validate("123") is False
+    assert ValueType.ID.validate("1c1a1707-15ea-4051-b98a-e400ee2900c7") is True
+    assert ValueType.ID.validate("foo") is False
+    assert ValueType.BOOLEAN.validate(True) is True
+    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
+
+
+def test_value_type_from_field():
+    assert ValueType.from_field("description") == ValueType.ID
+    assert ValueType.from_field("amount") == ValueType.NUMBER
+    assert ValueType.from_field("notes") == ValueType.STRING
+    assert ValueType.from_field("date") == ValueType.DATE
+    assert ValueType.from_field("cleared") == ValueType.BOOLEAN
+    with pytest.raises(ValueError):
+        ValueType.from_field("foo")
+
+
+def test_rule_insertion_method(mocker):
+    action = Action(field="cleared", value=1)
+    assert action.as_dict() == {"field": "cleared", "op": "set", "type": "boolean", "value": True}
+    condition = Condition(field="date", op=ConditionType.IS_APPROX, value=datetime.date(2024, 1, 2))
+    assert condition.as_dict() == {"field": "date", "op": "isapprox", "type": "date", "value": "2024-01-02"}
+    # test full rule
+    s = MagicMock()
+    rule = Rule(conditions=[condition], actions=[action], operation="all", stage="pre")
+    created_rule = create_rule(s, rule)
+    assert [condition.as_dict()] == json.loads(created_rule.conditions)
+    assert [action.as_dict()] == json.loads(created_rule.actions)
+    assert created_rule.conditions_op == "and"
+    assert created_rule.stage == "pre"
+    t = create_transaction(s, datetime.date(2024, 1, 4), create_account(s, "Bank"), "")
+    mocker.patch("actual.queries.get_transactions", return_value=[t])
+    create_rule(s, rule, True)
+    assert hasattr(s.add.call_args_list[-1].args[0], "cleared")
+    assert s.add.call_args_list[-1].args[0].cleared == 1