Skip to content

Commit 3daffd4

Browse files
committed
Make category rules date-aware via effective_from
The same description can mean different things over time — bank-side DEGIRO rows before 2026 are internal transfers (because Degiro annual reports cover those years authoritatively), but from 2026 onwards the user funds Degiro directly from CGD and wants those rows tagged as Investment. Add an effective_from date to CategoryRule and check it in matches() so a rule only classifies movements on or after the cut-off. The seed loader carries the field through and includes it in the natural key, so the same (match_text, sign, scope) pair can have one rule with no cut-off and another with one, and both round-trip through seed_finance cleanly.
1 parent 2ba2b37 commit 3daffd4

5 files changed

Lines changed: 75 additions & 1 deletion

File tree

finance/admin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ class CategoryRuleAdmin(admin.ModelAdmin):
4444
- Ordered so the highest-priority rules show first
4545
"""
4646

47-
list_display = ("match_text", "sign", "scope", "category", "priority")
47+
list_display = ("match_text", "sign", "scope", "effective_from", "category", "priority")
4848
list_filter = ("sign", "scope", "category")
4949
search_fields = ("match_text",)
5050

finance/management/commands/seed_finance.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ def handle(self, *args, **options):
101101
match_text=entry["match_text"],
102102
sign=entry.get("sign", CategoryRule.Sign.ANY),
103103
scope=entry.get("scope", ""),
104+
effective_from=entry.get("effective_from") or None,
104105
defaults={
105106
"category": category,
106107
"priority": entry.get("priority", 100),
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Generated by Django 5.2.14 on 2026-05-29 08:10
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("finance", "0004_populate_account_role"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="categoryrule",
14+
name="effective_from",
15+
field=models.DateField(blank=True, null=True),
16+
),
17+
]

finance/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ class Sign(models.TextChoices):
129129
sign = models.CharField(max_length=6, choices=Sign.choices, default=Sign.ANY)
130130
# Blank scope matches any account scope
131131
scope = models.CharField(max_length=10, choices=Account.Scope.choices, blank=True)
132+
# Only match transactions on or after this date when set; lets the same
133+
# description map to different categories before and after a cut-off
134+
effective_from = models.DateField(null=True, blank=True)
132135
category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name="rules")
133136
priority = models.IntegerField(default=100)
134137

@@ -167,6 +170,8 @@ def matches(self, txn):
167170
return False
168171
if self.scope and txn.account.scope != self.scope:
169172
return False
173+
if self.effective_from and txn.date < self.effective_from:
174+
return False
170175
return True
171176

172177

finance/tests/test_dashboard.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,57 @@ def test_dashboard_requires_authentication():
184184
assert response.status_code in (401, 403)
185185

186186

187+
@pytest.mark.django_db
188+
def test_category_rule_effective_from_only_matches_later_dates():
189+
"""
190+
A rule with effective_from set only classifies transactions on or after it.
191+
192+
Args:
193+
None
194+
195+
Returns:
196+
None
197+
"""
198+
199+
investment = Category.objects.create(name="Investment", kind=Category.Kind.INVESTMENT)
200+
transfer = Category.objects.create(name="Internal transfer", kind=Category.Kind.TRANSFER)
201+
# Cut-off rule for 2026 onwards, plus a catch-all for everything before
202+
CategoryRule.objects.create(
203+
match_text="BROKER",
204+
sign=CategoryRule.Sign.ANY,
205+
category=investment,
206+
effective_from=date(2026, 1, 1),
207+
priority=15,
208+
)
209+
CategoryRule.objects.create(
210+
match_text="BROKER",
211+
sign=CategoryRule.Sign.ANY,
212+
category=transfer,
213+
priority=20,
214+
)
215+
216+
account = Account.objects.create(name="House", bank="Bank", iban="PT50000000000000000000020", scope="personal")
217+
pre = Transaction.objects.create(
218+
account=account,
219+
date=date(2025, 6, 1),
220+
description="BROKER deposit",
221+
amount=Decimal("-500.00"),
222+
)
223+
post = Transaction.objects.create(
224+
account=account,
225+
date=date(2026, 2, 1),
226+
description="BROKER deposit",
227+
amount=Decimal("-500.00"),
228+
)
229+
230+
classify_transactions()
231+
pre.refresh_from_db()
232+
post.refresh_from_db()
233+
234+
assert pre.category.name == "Internal transfer"
235+
assert post.category.name == "Investment"
236+
237+
187238
@pytest.mark.django_db
188239
def test_seed_finance_updates_existing_rules_in_place(tmp_path):
189240
"""

0 commit comments

Comments
 (0)