diff --git a/app/apps/rules/forms.py b/app/apps/rules/forms.py index b5b39357..d001efbf 100644 --- a/app/apps/rules/forms.py +++ b/app/apps/rules/forms.py @@ -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), diff --git a/app/apps/rules/migrations/0019_sub_transactions.py b/app/apps/rules/migrations/0019_sub_transactions.py new file mode 100644 index 00000000..4106d3f7 --- /dev/null +++ b/app/apps/rules/migrations/0019_sub_transactions.py @@ -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'), + ), + ] diff --git a/app/apps/rules/models.py b/app/apps/rules/models.py index 653c87c7..85259278 100644 --- a/app/apps/rules/models.py +++ b/app/apps/rules/models.py @@ -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, diff --git a/app/apps/rules/tasks.py b/app/apps/rules/tasks.py index 427be97d..ba4573c1 100644 --- a/app/apps/rules/tasks.py +++ b/app/apps/rules/tasks.py @@ -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, diff --git a/app/apps/rules/tests/test_tasks.py b/app/apps/rules/tests/test_tasks.py index 9520318a..b03f26cc 100644 --- a/app/apps/rules/tests/test_tasks.py +++ b/app/apps/rules/tests/test_tasks.py @@ -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"))], + ) diff --git a/app/apps/rules/utils/transactions.py b/app/apps/rules/utils/transactions.py index 9dd7cba2..85361ebe 100644 --- a/app/apps/rules/utils/transactions.py +++ b/app/apps/rules/utils/transactions.py @@ -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,