feat(rules): add, change and remove sub-transactions in rules

Adds a Sub-transactions field to edit actions and update-or-create
actions. Expressions get a sub_transactions variable and return a list of
{name, amount}; an empty list removes the parts. Rules skip transfers, and
setting the amount of a split transaction is skipped.
This commit is contained in:
Herculino Trotta committed 2026-11-14 02:13:21 -03:00
1 parent 5459865366
commit 188876fdf0
6 files changed
+236 -8

No files matched your search

+2
View File
@@ -190,6 +190,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
"set_date": _("Date"),
"set_reference_date": _("Reference Date"),
"set_amount": _("Amount"),
"set_sub_transactions": _("Sub-transactions"),
"set_description": _("Description"),
"set_tags": _("Tags"),
"set_entities": _("Entities"),
@@ -367,6 +368,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
Field("set_reference_date", rows=1),
Field("set_description", rows=1),
Field("set_amount", rows=1),
Field("set_sub_transactions", rows=1),
Field("set_category", rows=1),
Field("set_tags", rows=1),
Field("set_notes", rows=1),
@@ -0,0 +1,23 @@
# Generated by Django 5.2.17 on 2026-11-14 03:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0018_transactionrule_order'),
]
operations = [
migrations.AddField(
model_name='updateorcreatetransactionruleaction',
name='set_sub_transactions',
field=models.TextField(blank=True, verbose_name='Sub-transactions'),
),
migrations.AlterField(
model_name='transactionruleaction',
name='field',
field=models.CharField(choices=[('account', 'Account'), ('type', 'Type'), ('is_paid', 'Paid'), ('date', 'Date'), ('reference_date', 'Reference Date'), ('mute', 'Mute'), ('amount', 'Amount'), ('sub_transactions', 'Sub-transactions'), ('description', 'Description'), ('notes', 'Notes'), ('category', 'Category'), ('tags', 'Tags'), ('entities', 'Entities'), ('internal_nome', 'Internal Note'), ('internal_id', 'Internal ID')], max_length=50, verbose_name='Field'),
),
]
+5
View File
@@ -39,6 +39,7 @@ class TransactionRuleAction(models.Model):
reference_date = "reference_date", _("Reference Date")
mute = "mute", _("Mute")
amount = "amount", _("Amount")
sub_transactions = "sub_transactions", _("Sub-transactions")
description = "description", _("Description")
notes = "notes", _("Notes")
category = "category", _("Category")
@@ -315,6 +316,10 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
verbose_name=_("Tags"),
blank=True,
)
set_sub_transactions = models.TextField(
verbose_name=_("Sub-transactions"),
blank=True,
)
set_mute = models.TextField(
verbose_name=_("Mute"),
blank=True,
+94 -6
View File
@@ -17,12 +17,14 @@ from procrastinate.contrib.django import app
from simpleeval import EvalWithCompoundTypes
from apps.accounts.models import Account
from apps.common.functions.decimals import truncate_decimal
from apps.common.middleware.thread_local import write_current_user, delete_current_user
from apps.rules.models import (
TransactionRule,
TransactionRuleAction,
)
from apps.transactions.models import (
SubTransaction,
Transaction,
TransactionCategory,
TransactionTag,
@@ -32,6 +34,39 @@ from apps.rules.utils import transactions
logger = logging.getLogger(__name__)
SUB_TRANSACTIONS_SHAPE = (
"Sub-transactions must be a list like [{'name': 'Fee', 'amount': 1.5}, ...]"
)
def build_sub_transactions(transaction: Transaction, value) -> list[SubTransaction]:
"""Validated sub-transactions for `transaction` from a rule expression's result."""
if not isinstance(value, (list, tuple)):
raise ValueError(SUB_TRANSACTIONS_SHAPE)
places = transaction.account.currency.decimal_places
subs = []
for item in value:
if not isinstance(item, dict) or item.get("amount") is None:
raise ValueError(SUB_TRANSACTIONS_SHAPE)
sub = SubTransaction(
transaction=transaction,
name=str(item.get("name") or "").strip(),
amount=truncate_decimal(
value=Decimal(str(item["amount"])), decimal_places=places
),
)
# the transaction may not be saved yet, so its link isn't checked here
sub.full_clean(exclude=["transaction"], validate_constraints=False)
subs.append(sub)
return subs
def _snapshot(transaction: Transaction) -> Transaction:
"""A copy to show in dry-run results, as the transaction is right now."""
copy = transaction.deepcopy()
copy.copy_sub_transactions_from(transaction)
return copy
class DryRunResults:
def __init__(self, dry_run: bool):
@@ -49,7 +84,7 @@ class DryRunResults:
if not self.dry_run:
return
if isinstance(instance, Transaction):
instance = instance.deepcopy()
instance = _snapshot(instance)
elif isinstance(instance, dict):
instance = deepcopy(instance)
@@ -65,7 +100,7 @@ class DryRunResults:
if not self.dry_run:
return
if isinstance(instance, Transaction):
instance = instance.deepcopy()
instance = _snapshot(instance)
elif isinstance(instance, dict):
instance = deepcopy(instance)
@@ -95,7 +130,7 @@ class DryRunResults:
return
if isinstance(end_instance, Transaction):
end_instance = end_instance.deepcopy()
end_instance = _snapshot(end_instance)
elif isinstance(end_instance, dict):
end_instance = deepcopy(end_instance)
@@ -201,6 +236,14 @@ def check_for_transaction_rules(
f"{prefix}entities_ids": (
[x.id for x in transaction.entities.all()] if transaction.id else []
),
f"{prefix}sub_transactions": (
[
{"name": s.name, "amount": s.amount}
for s in transaction.sub_transactions.all()
]
if transaction.id
else []
),
f"{prefix}is_expense": transaction.type == Transaction.Type.EXPENSE,
f"{prefix}is_income": transaction.type == Transaction.Type.INCOME,
f"{prefix}is_paid": transaction.is_paid,
@@ -250,6 +293,10 @@ def check_for_transaction_rules(
f"{prefix}entities_ids": [
x[0] for x in transaction.get("entities", [])
],
f"{prefix}sub_transactions": [
{"name": s["name"], "amount": Decimal(s["amount"])}
for s in transaction.get("sub_transactions", [])
],
f"{prefix}is_expense": transaction.get("type")
== Transaction.Type.EXPENSE,
f"{prefix}is_income": transaction.get("type")
@@ -274,6 +321,22 @@ def check_for_transaction_rules(
f"{prefix}installment_total": transaction.get("installment_total"),
}
def _evaluate_sub_transactions(transaction, value):
"""The parts an expression's `value` gives `transaction`, with its amount set
to their sum, or None for a transfer, which can't have any."""
if transaction.linked_transaction_id:
_log("Skipped: transfers can't have sub-transactions")
return None
subs = build_sub_transactions(transaction, value)
if subs:
transaction.amount = sum(s.amount for s in subs)
_log(f"Sub-transactions will be set as: {[(s.name, s.amount) for s in subs]}")
return subs
def _save_sub_transactions(transaction, subs):
transaction.sub_transactions.all().delete()
SubTransaction.objects.bulk_create(subs)
def _process_update_or_create_transaction_action(processed_action):
"""Helper to process a single linked transaction action"""
@@ -295,7 +358,7 @@ def check_for_transaction_rules(
existing = True
if dry_run:
starting_instance = transaction.deepcopy()
starting_instance = _snapshot(transaction)
_log("Found at least one matching transaction, using latest:")
_log("{}".format(pformat(model_to_dict(transaction))))
@@ -348,7 +411,15 @@ def check_for_transaction_rules(
processed_action.set_reference_date
)
if processed_action.set_amount:
subs = None
if processed_action.set_sub_transactions:
subs = _evaluate_sub_transactions(
transaction, simple.eval(processed_action.set_sub_transactions)
)
# A transaction with parts gets its amount from them
has_parts = bool(subs) if subs is not None else transaction.has_sub_transactions
if processed_action.set_amount and not has_parts:
transaction.amount = simple.eval(processed_action.set_amount)
if processed_action.set_description:
@@ -383,6 +454,8 @@ def check_for_transaction_rules(
)
)
transaction.save()
if subs is not None:
_save_sub_transactions(transaction, subs)
# Handle M2M fields after save
tags = []
@@ -441,7 +514,10 @@ def check_for_transaction_rules(
dry_run_results.header("edit_transaction", action=processed_action)
field = processed_action.field
original_value = getattr(transaction, field)
if field == TransactionRuleAction.Field.sub_transactions:
original_value = list(transaction.sub_transactions.values("name", "amount"))
else:
original_value = getattr(transaction, field)
new_value = simple.eval(processed_action.value)
tags = []
@@ -518,6 +594,18 @@ def check_for_transaction_rules(
transaction.entities.add(entity)
entities.append(entity)
elif field == TransactionRuleAction.Field.sub_transactions:
subs = _evaluate_sub_transactions(transaction, new_value)
if subs is not None:
_save_sub_transactions(transaction, subs)
new_value = ", ".join(f"{s.name}: {s.amount}" for s in subs) or "[]"
elif (
field == TransactionRuleAction.Field.amount
and transaction.has_sub_transactions
):
_log("Skipped: the amount is the sum of the transaction's sub-transactions")
else:
setattr(
transaction,
+108 -2
View File
@@ -6,10 +6,15 @@ from django.contrib.auth import get_user_model
from django.test import TransactionTestCase
from apps.accounts.models import Account
from apps.common.middleware.thread_local import write_current_user
from apps.currencies.models import Currency
from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction
from apps.rules.models import (
TransactionRule,
TransactionRuleAction,
UpdateOrCreateTransactionRuleAction,
)
from apps.rules.tasks import check_for_transaction_rules
from apps.transactions.models import Transaction
from apps.transactions.models import SubTransaction, Transaction
def run_check_for_transaction_rules_without_worker_wrapper(**kwargs):
@@ -122,3 +127,104 @@ class CheckForTransactionRulesTests(TransactionTestCase):
generated_transaction = Transaction.objects.get(description="Muted copy")
self.assertTrue(generated_transaction.mute)
self.assertFalse(generated_transaction.is_paid)
@patch("apps.rules.signals.check_for_transaction_rules.defer")
def test_edit_amount_skips_transactions_with_sub_transactions(self, mock_defer):
rule = TransactionRule.objects.create(
active=True, on_update=True, name="Set amount", trigger="True", owner=self.user
)
TransactionRuleAction.objects.create(rule=rule, field="amount", value="99")
TransactionRuleAction.objects.create(rule=rule, field="description", value="'Ruled'")
amounts = {}
for parts in ([], [Decimal("4"), Decimal("6")]):
transaction = Transaction.objects.create(
account=self.account, type=Transaction.Type.EXPENSE, amount=sum(parts) or 1,
date=date(2026, 5, 4), owner=self.user,
)
for amount in parts:
SubTransaction.objects.create(transaction=transaction, name="Part", amount=amount)
run_check_for_transaction_rules_without_worker_wrapper(
instance_id=transaction.id, user_id=self.user.id, signal="transaction_updated"
)
transaction.refresh_from_db()
self.assertEqual(transaction.description, "Ruled")
amounts[len(parts)] = transaction.amount
self.assertEqual(amounts, {0: Decimal("99"), 2: Decimal("10")})
class SubTransactionRuleTests(TransactionTestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(email="subs@example.com", password="x")
currency = Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
self.account = Account.objects.create(name="Main", currency=currency, owner=self.user)
self.rule = TransactionRule.objects.create(
active=True, on_update=True, name="Parts", trigger="True", owner=self.user
)
def _transaction(self, *parts, amount=None):
transaction = Transaction.objects.create(
account=self.account, type=Transaction.Type.EXPENSE, date=date(2026, 5, 4),
amount=amount if amount is not None else sum(a for _, a in parts), owner=self.user,
)
for name, part in parts:
SubTransaction.objects.create(transaction=transaction, name=name, amount=part)
return transaction
def _run(self, transaction):
with patch("apps.rules.signals.check_for_transaction_rules.defer"):
run_check_for_transaction_rules_without_worker_wrapper(
instance_id=transaction.id, user_id=self.user.id, signal="transaction_updated"
)
transaction.refresh_from_db()
return transaction.amount, list(transaction.sub_transactions.values_list("name", "amount"))
def _edit(self, expression):
TransactionRuleAction.objects.create(rule=self.rule, field="sub_transactions", value=expression)
def test_edit_action_adds_changes_and_deletes_parts(self):
self._edit(
'[{"name": s["name"], "amount": s["amount"] * 2} for s in sub_transactions'
' if s["name"] != "Tip"] + [{"name": "Fee", "amount": decimal("0.50")}]'
)
result = self._run(self._transaction(("Meal", Decimal("10")), ("Tip", Decimal("2"))))
self.assertEqual(result, (Decimal("20.50"), [("Meal", Decimal("20")), ("Fee", Decimal("0.5"))]))
def test_edit_action_splits_a_plain_transaction_and_clearing_keeps_amount(self):
self._edit('[{"name": "Principal", "amount": amount - 5}, {"name": "Fee", "amount": 5}]')
self.assertEqual(
self._run(self._transaction(amount=Decimal("100"))),
(Decimal("100"), [("Principal", Decimal("95")), ("Fee", Decimal("5"))]),
)
self.rule.transaction_actions.update(value="[]")
self.assertEqual(self._run(self._transaction(("A", Decimal("3")))), (Decimal("3"), []))
def test_invalid_parts_leave_the_transaction_alone(self):
transaction = self._transaction(("A", Decimal("3")))
for expression in ('"Fee"', '[{"name": "Fee"}]', '[{"name": "", "amount": 1}]',
'[{"name": "Refund", "amount": -1}]'):
with self.subTest(expression=expression):
self.rule.transaction_actions.all().delete()
self._edit(expression)
self.assertEqual(self._run(transaction), (Decimal("3"), [("A", Decimal("3"))]))
def test_transfers_are_skipped(self):
out, inc = self._transaction(amount=Decimal("5")), self._transaction(amount=Decimal("5"))
write_current_user(self.user)
with patch("apps.rules.signals.check_for_transaction_rules.defer"):
Transaction.objects.filter(pk=out.pk).update(linked_transaction=inc)
self._edit('[{"name": "Fee", "amount": 1}]')
self.assertEqual(self._run(out), (Decimal("5"), []))
def test_update_or_create_creates_a_transaction_from_its_parts(self):
UpdateOrCreateTransactionRuleAction.objects.create(
rule=self.rule, set_account="account_id", set_type="'EX'", set_date="date",
set_description="'Copy'", set_amount="999",
set_sub_transactions="sub_transactions + [{'name': 'Fee', 'amount': 1}]",
)
self._run(self._transaction(("A", Decimal("3"))))
copy = Transaction.objects.get(description="Copy")
self.assertEqual(copy.amount, Decimal("4"))
self.assertEqual(
list(copy.sub_transactions.values_list("name", "amount")),
[("A", Decimal("3")), ("Fee", Decimal("1"))],
)
+4
View File
@@ -86,6 +86,10 @@ def serialize_transaction(sender: Transaction, deleted: bool):
"notes": sender.notes,
"tags": list(sender.tags.values_list("id", "name")),
"entities": list(sender.entities.values_list("id", "name")),
"sub_transactions": [
{"name": name, "amount": str(amount)}
for name, amount in sender.sub_transactions.values_list("name", "amount")
],
"deleted": deleted,
"internal_note": sender.internal_note,
"internal_id": sender.internal_id,