mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-15 05:12:38 +01:00
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:
1 parent
5459865366
commit
188876fdf0
6 files changed
+236
-8
No files matched your search
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"))],
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user