Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement custom set split amount rules. #56

Merged
merged 5 commits into from
Aug 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion actual/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ def commit(self):

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]:
Expand Down
3 changes: 2 additions & 1 deletion actual/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())",
},
)

Expand Down
4 changes: 4 additions & 0 deletions actual/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,7 @@ class InvalidFile(ActualError):

class ActualDecryptionError(ActualError):
pass


class ActualSplitTransactionError(ActualError):
pass
20 changes: 19 additions & 1 deletion actual/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
100 changes: 91 additions & 9 deletions actual/rules.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import datetime
import decimal
import enum
import re
import typing
Expand All @@ -11,14 +12,17 @@
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())


Expand Down Expand Up @@ -124,8 +128,6 @@ def from_field(cls, field: str | None) -> ValueType:
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.")

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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."""
Expand All @@ -347,7 +363,12 @@ def convert_value(self):
@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)
Expand All @@ -358,8 +379,13 @@ def check_operation_type(self):

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:
Expand Down Expand Up @@ -407,6 +433,57 @@ def __str__(self):
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
Expand All @@ -416,7 +493,12 @@ 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):
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

Expand Down
125 changes: 125 additions & 0 deletions tests/test_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pytest

from actual import ActualError
from actual.exceptions import ActualSplitTransactionError
from actual.queries import (
create_account,
create_category,
Expand All @@ -13,6 +14,7 @@
)
from actual.rules import (
Action,
ActionType,
Condition,
ConditionType,
Rule,
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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)
Loading