Repository: bvanelli/actualpy (python)

Issue:
Operation ActionType.SET_SPLIT_AMOUNT not supported
### Description

I've a rule that given a certain payee, it creates a split transaction of 50% each.
it seems that the code here is not supporting it.
is there a way to support this or a hint on how i can create a pr?



Pull request:
feat: Implement custom set split amount rules.
Closes #53 

Patch:
diff --git a/actual/__init__.py b/actual/__init__.py
index 3ae112c..564c3a7 100644
--- a/actual/__init__.py
+++ b/actual/__init__.py
@@ -402,7 +402,7 @@ class Actual(ActualServer):
 
     def run_rules(self):
         ruleset = get_ruleset(self.session)
-        transactions = get_transactions(self.session)
+        transactions = get_transactions(self.session, is_parent=True)
         ruleset.run(transactions)
 
     def _run_bank_sync_account(self, acct: Accounts, start_date: datetime.date) -> list[Transactions]:
diff --git a/actual/database.py b/actual/database.py
index d01b737..1957e39 100644
--- a/actual/database.py
+++ b/actual/database.py
@@ -537,7 +537,8 @@ class Transactions(BaseModel, table=True):
     splits: List["Transactions"] = Relationship(
         back_populates="parent",
         sa_relationship_kwargs={
-            "primaryjoin": "and_(Transactions.id == remote(Transactions.parent_id), remote(Transactions.tombstone)==0)"
+            "primaryjoin": "and_(Transactions.id == remote(Transactions.parent_id), remote(Transactions.tombstone)==0)",
+            "order_by": "remote(Transactions.sort_order.desc())",
         },
     )
 
diff --git a/actual/exceptions.py b/actual/exceptions.py
index 6dc99a7..55d5aa8 100644
--- a/actual/exceptions.py
+++ b/actual/exceptions.py
@@ -46,3 +46,7 @@ class InvalidFile(ActualError):
 
 class ActualDecryptionError(ActualError):
     pass
+
+
+class ActualSplitTransactionError(ActualError):
+    pass
diff --git a/actual/queries.py b/actual/queries.py
index 4714377..d19efe3 100644
--- a/actual/queries.py
+++ b/actual/queries.py
@@ -194,7 +194,7 @@ def create_transaction(
     date: datetime.date,
     account: str | Accounts,
     payee: str | Payees = "",
-    notes: str = "",
+    notes: str | None = "",
     category: str | Categories | None = None,
     amount: decimal.Decimal | float | int = 0,
     imported_id: str | None = None,
@@ -337,6 +337,24 @@ def create_splits(
     return split_transaction
 
 
+def create_split(s: Session, transaction: Transactions, amount: float | decimal.Decimal) -> Transactions:
+    """
+    Creates a transaction split based on the parent transaction. This is the opposite of create_splits, that joins
+    all transactions as one big transaction. When using this method, you need to make sure all splits that you add to
+    a transaction are then valid.
+
+    :param s: session from Actual local database.
+    :param transaction: parent transaction to the split you want to create.
+    :param amount: amount of the split.
+    :return: the generated transaction object for the split transaction.
+    """
+    split = create_transaction(
+        s, transaction.get_date(), transaction.account, transaction.payee, None, transaction.category, amount=amount
+    )
+    split.parent_id, split.is_parent, split.is_child = transaction.id, 0, 1
+    return split
+
+
 def base_query(instance: typing.Type[T], name: str = None, include_deleted: bool = False) -> Select:
     """Internal method to reduce querying complexity on sub-functions."""
     query = select(instance)
diff --git a/actual/rules.py b/actual/rules.py
index de1fd65..231b84a 100644
--- a/actual/rules.py
+++ b/actual/rules.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import datetime
+import decimal
 import enum
 import re
 import typing
@@ -11,14 +12,17 @@ import pydantic
 from actual import ActualError
 from actual.crypto import is_uuid
 from actual.database import BaseModel, Transactions, get_attribute_by_table_name
+from actual.exceptions import ActualSplitTransactionError
 from actual.schedules import Schedule
 
 
-def get_normalized_string(value: str) -> str:
+def get_normalized_string(value: str) -> typing.Optional[str]:
     """Normalization of string for comparison. Uses lowercase and Canonical Decomposition.
 
     See https://github.com/actualbudget/actual/blob/a22160579d6e1f7a17213561cec79c321a14525b/packages/loot-core/src/shared/normalisation.ts
     """
+    if value is None:
+        return None
     return unicodedata.normalize("NFD", value.lower())
 
 
@@ -124,8 +128,6 @@ class ValueType(enum.Enum):
             return ValueType.BOOLEAN
         elif field in ("amount",):
             return ValueType.NUMBER
-        elif field is None:
-            return ValueType.ID  # link-schedule
         else:
             raise ValueError(f"Field '{field}' does not have a matching ValueType.")
 
@@ -189,7 +191,7 @@ def condition_evaluation(
     elif op == ConditionType.ONE_OF:
         return true_value in self_value
     elif op == ConditionType.CONTAINS:
-        return get_normalized_string(self_value) in get_normalized_string(true_value)
+        return self_value in true_value
     elif op == ConditionType.MATCHES:
         return bool(re.match(self_value, true_value, re.IGNORECASE))
     elif op == ConditionType.NOT_ONE_OF:
@@ -305,6 +307,12 @@ 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'.
 
+    For the 'op' LINKED_SCHEDULE, the operation will link the transaction to a certain schedule id that generated it.
+
+    For the 'op' SET_SPLIT_AMOUNT, the transaction will be split into multiple different splits depending on the rules
+    defined by the user, being it on 'fixed-amount', 'fixed-percent' or 'remainder'. The options will then be on the
+    format {"method": "remainder", "splitIndex": 1}
+
     The 'field' can be one of the following ('type' will be set automatically):
 
         - category: 'type' must be 'id' and 'value' a valid uuid
@@ -322,11 +330,19 @@ class Action(pydantic.BaseModel):
     op: ActionType = pydantic.Field(ActionType.SET, description="Action type to apply (default changes a column).")
     value: typing.Union[str, bool, int, float, pydantic.BaseModel, None]
     type: typing.Optional[ValueType] = None
-    options: dict = None
+    options: dict[str, typing.Union[str, int]] = None
 
     def __str__(self) -> str:
-        field_str = f" '{self.field}'" if self.field else ""
-        return f"{self.op.value}{field_str} to '{self.value}'"
+        if self.op in (ActionType.SET, ActionType.LINK_SCHEDULE):
+            split_info = ""
+            if self.options and self.options.get("splitIndex") > 0:
+                split_info = f" at Split {self.options.get('splitIndex')}"
+            field_str = f" '{self.field}'" if self.field else ""
+            return f"{self.op.value}{field_str}{split_info} to '{self.value}'"
+        elif self.op == ActionType.SET_SPLIT_AMOUNT:
+            method = self.options.get("method") or ""
+            split_index = self.options.get("splitIndex") or ""
+            return f"allocate a {method} at Split {split_index}: {self.value}"
 
     def as_dict(self):
         """Returns valid dict for database insertion."""
@@ -347,7 +363,12 @@ class Action(pydantic.BaseModel):
     @pydantic.model_validator(mode="after")
     def check_operation_type(self):
         if not self.type:
-            self.type = ValueType.from_field(self.field)
+            if self.field is not None:
+                self.type = ValueType.from_field(self.field)
+            elif self.op == ActionType.LINK_SCHEDULE:
+                self.type = ValueType.ID
+            elif self.op == ActionType.SET_SPLIT_AMOUNT:
+                self.type = ValueType.NUMBER
         # 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)
@@ -358,8 +379,13 @@ class Action(pydantic.BaseModel):
 
     def run(self, transaction: Transactions) -> None:
         if self.op == ActionType.SET:
-            attr = get_attribute_by_table_name(Transactions.__tablename__, self.field)
+            attr = get_attribute_by_table_name(Transactions.__tablename__, str(self.field))
             value = get_value(self.value, self.type)
+            # if the split index is existing, modify instead the split transaction
+            split_index = self.options.get("splitIndex", None) if self.options else None
+            if split_index and len(transaction.splits) >= split_index:
+                transaction = transaction.splits[split_index - 1]
+            # set the value
             if self.type == ValueType.DATE:
                 transaction.set_date(value)
             else:
@@ -407,6 +433,57 @@ class Rule(pydantic.BaseModel):
         actions = ", ".join([str(a) for a in self.actions])
         return f"If {operation} of these conditions match {conditions} then {actions}"
 
+    def set_split_amount(self, transaction: Transactions) -> typing.List[Transactions]:
+        """Run the rules from setting split amounts."""
+        from actual.queries import (
+            create_split,  # lazy import to prevert circular issues
+        )
+
+        # get actions that split the transaction
+        split_amount_actions = [action for action in self.actions if action.op == ActionType.SET_SPLIT_AMOUNT]
+        if not split_amount_actions or len(transaction.splits) or transaction.is_child:
+            return []  # nothing to create
+        # get inner session from object
+        session = transaction._sa_instance_state.session  # noqa
+        # first, do all entries that have fixed values
+        split_by_index: typing.List[Transactions] = [None for _ in range(len(split_amount_actions))]  # noqa
+        fixed_split_amount_actions = [a for a in split_amount_actions if a.options["method"] == "fixed-amount"]
+        remainder = transaction.amount
+        for action in fixed_split_amount_actions:
+            remainder -= action.value
+            split = create_split(session, transaction, decimal.Decimal(action.value) / 100)
+            split_by_index[action.options.get("splitIndex") - 1] = split
+        # now do the ones with a percentage amount
+        percent_split_amount_actions = [a for a in split_amount_actions if a.options["method"] == "fixed-percent"]
+        amount_to_distribute = remainder
+        for action in percent_split_amount_actions:
+            value = round(amount_to_distribute * action.value / 100, 0)
+            remainder -= value
+            split = create_split(session, transaction, decimal.Decimal(value) / 100)
+            split_by_index[action.options.get("splitIndex") - 1] = split
+        # now, divide the remainder equally between the entries
+        remainder_split_amount_actions = [a for a in split_amount_actions if a.options["method"] == "remainder"]
+        if not len(remainder_split_amount_actions) and remainder:
+            # create a virtual split that contains the leftover remainders
+            split = create_split(session, transaction, decimal.Decimal(remainder) / 100)
+            split_by_index.append(split)
+        elif len(remainder_split_amount_actions):
+            amount_per_remainder_split = round(remainder / len(remainder_split_amount_actions), 0)
+            for action in remainder_split_amount_actions:
+                split = create_split(session, transaction, decimal.Decimal(amount_per_remainder_split) / 100)
+                remainder -= amount_per_remainder_split
+                split_by_index[action.options.get("splitIndex") - 1] = split
+            # The last non-fixed split will be adjusted for the remainder
+            split_by_index[remainder_split_amount_actions[-1].options.get("splitIndex") - 1].amount += remainder
+        # make sure the splits are still valid and the sum equals the parent
+        if sum(s.amount for s in split_by_index) != transaction.amount:
+            raise ActualSplitTransactionError("Splits do not match amount of parent transaction.")
+        transaction.is_parent, transaction.is_child = 1, 0
+        # make sure the splits are ordered correctly
+        for idx, split in enumerate(split_by_index):
+            split.sort_order = -idx - 2
+        return split_by_index
+
     def evaluate(self, transaction: Transactions) -> bool:
         """Evaluates the rule on the transaction, without applying any action."""
         op = any if self.operation == "or" else all
@@ -416,7 +493,12 @@ class Rule(pydantic.BaseModel):
         """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):
+            splits = self.set_split_amount(transaction)
+            if splits:
+                transaction.splits = splits
             for action in self.actions:
+                if action.op == ActionType.SET_SPLIT_AMOUNT:
+                    continue  # handle in the create_splits
                 action.run(transaction)
         return condition_met
 


Test patch:
diff --git a/tests/test_rules.py b/tests/test_rules.py
index bc28ec9..71d22c4 100644
--- a/tests/test_rules.py
+++ b/tests/test_rules.py
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock
 import pytest
 
 from actual import ActualError
+from actual.exceptions import ActualSplitTransactionError
 from actual.queries import (
     create_account,
     create_category,
@@ -13,6 +14,7 @@ from actual.queries import (
 )
 from actual.rules import (
     Action,
+    ActionType,
     Condition,
     ConditionType,
     Rule,
@@ -166,6 +168,7 @@ def test_invalid_inputs():
         Action(field="notes", op="set-split-amount", 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
+    assert Condition(field="notes", op="is", value=None).get_value() is None  # noqa: handle when value is None
 
 
 def test_value_type_condition_validation():
@@ -206,3 +209,125 @@ def test_value_type_from_field():
     assert ValueType.from_field("cleared") == ValueType.BOOLEAN
     with pytest.raises(ValueError):
         ValueType.from_field("foo")
+
+
+@pytest.mark.parametrize(
+    "method,value,expected_splits",
+    [
+        ("remainder", None, [0.50, 4.50]),
+        ("fixed-amount", 100, [0.40, 1.00, 3.60]),
+        ("fixed-percent", 20, [0.50, 1.00, 3.50]),
+    ],
+)
+def test_set_split_amount(session, method, value, expected_splits):
+    acct = create_account(session, "Bank")
+    cat = create_category(session, "Food", "Expenses")
+    payee = create_payee(session, "My payee")
+    alternative_payee = create_payee(session, "My other payee")
+
+    rs = RuleSet(
+        rules=[
+            Rule(
+                conditions=[Condition(field="category", op=ConditionType.ONE_OF, value=[cat])],
+                actions=[
+                    Action(
+                        field=None,
+                        op=ActionType.SET_SPLIT_AMOUNT,
+                        value=10,
+                        options={"splitIndex": 1, "method": "fixed-percent"},
+                    ),
+                    Action(
+                        field=None,
+                        op=ActionType.SET_SPLIT_AMOUNT,
+                        value=value,
+                        options={"splitIndex": 2, "method": method},
+                    ),
+                    # add one action that changes the second split payee
+                    Action(
+                        field="description", op=ActionType.SET, value=alternative_payee.id, options={"splitIndex": 2}
+                    ),
+                ],
+            )
+        ]
+    )
+    t = create_transaction(session, datetime.date(2024, 1, 1), acct, payee, category=cat, amount=5.0)
+    session.flush()
+    rs.run(t)
+    session.refresh(t)
+    assert [float(s.get_amount()) for s in t.splits] == expected_splits
+    # check the first split has the original payee, and the second split has the payee from the action
+    assert t.splits[0].payee_id == payee.id
+    assert t.splits[1].payee_id == alternative_payee.id
+    # check string comparison
+    assert (
+        str(rs.rules[0]) == f"If all of these conditions match 'category' oneOf ['{cat.id}'] then "
+        f"allocate a fixed-percent at Split 1: 10, "
+        f"allocate a {method} at Split 2: {value}, "
+        f"set 'description' at Split 2 to '{alternative_payee.id}'"
+    )
+
+
+@pytest.mark.parametrize(
+    "method,n,expected_splits",
+    [
+        # test equal remainders
+        ("remainder", 1, [5.00]),
+        ("remainder", 2, [2.50, 2.50]),
+        ("remainder", 3, [1.67, 1.67, 1.66]),
+        ("remainder", 4, [1.25, 1.25, 1.25, 1.25]),
+        ("remainder", 5, [1.00, 1.00, 1.00, 1.00, 1.00]),
+        ("remainder", 6, [0.83, 0.83, 0.83, 0.83, 0.83, 0.85]),
+        # and fixed amount
+        ("fixed-amount", 1, [1.0, 4.0]),
+        ("fixed-amount", 2, [1.0, 1.0, 3.0]),
+        ("fixed-amount", 3, [1.0, 1.0, 1.0, 2.0]),
+        ("fixed-amount", 4, [1.0, 1.0, 1.0, 1.0, 1.0]),
+        ("fixed-amount", 5, [1.0, 1.0, 1.0, 1.0, 1.0]),
+        ("fixed-amount", 6, [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0]),
+    ],
+)
+def test_split_amount_equal_parts(session, method, n, expected_splits):
+    acct = create_account(session, "Bank")
+    actions = [
+        Action(
+            field=None,
+            op=ActionType.SET_SPLIT_AMOUNT,
+            value=100,  # value is only used for fixed-amount
+            options={"splitIndex": i + 1, "method": method},
+        )
+        for i in range(n)
+    ]
+    rs = Rule(conditions=[], actions=actions)
+    t = create_transaction(session, datetime.date(2024, 1, 1), acct, "", amount=5.0)
+    session.flush()
+    # test split amounts
+    splits = rs.set_split_amount(t)
+    assert [float(s.get_amount()) for s in splits] == expected_splits
+
+
+def test_set_split_amount_exception(session, mocker):
+    mocker.patch("actual.rules.sum", lambda x: 0)
+
+    acct = create_account(session, "Bank")
+    cat = create_category(session, "Food", "Expenses")
+    payee = create_payee(session, "My payee")
+
+    rs = RuleSet(
+        rules=[
+            Rule(
+                conditions=[Condition(field="category", op=ConditionType.ONE_OF, value=[cat])],
+                actions=[
+                    Action(
+                        field=None,
+                        op=ActionType.SET_SPLIT_AMOUNT,
+                        value=10,
+                        options={"splitIndex": 1, "method": "fixed-percent"},
+                    )
+                ],
+            )
+        ]
+    )
+    t = create_transaction(session, datetime.date(2024, 1, 1), acct, payee, category=cat, amount=5.0)
+    session.flush()
+    with pytest.raises(ActualSplitTransactionError):
+        rs.run(t)