Skip to content

Commit d378688

Browse files
committed
Add scoped net-worth views (Business / House / Personal / Savings)
Replace the single global net-worth figure with a per-role breakdown. Account gains a role (house, personal, business) and a data migration seeds it from existing data — any account that already has a Mortgage transaction is tagged house, the rest fall back to the role implied by scope. The endpoint now returns business, personal, house and savings components plus an all-up total that finally includes the business cash the old summary-only formula was missing. House equity is just the household account balance minus the mortgage; savings (A Prazo) is its own component and lives alongside the other views rather than being folded into the household figure. Net worth = business + house + personal + savings + investments. The Net worth page gains a pill segmented control with five views (All / Business / House / Personal / Savings) that re-renders the hero number, the relevant KPI tiles and the chart for the chosen view. Overview swaps Investments and YTD income for Business / House / Personal / Savings tiles and moves YTD income into the month-to-date row.
1 parent 41803e6 commit d378688

10 files changed

Lines changed: 372 additions & 108 deletions

File tree

finance/admin.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ class AccountAdmin(admin.ModelAdmin):
1616
- Allows filtering by scope and kind
1717
"""
1818

19-
list_display = ("name", "bank", "scope", "kind", "iban", "currency")
20-
list_filter = ("scope", "kind", "bank")
19+
list_display = ("name", "bank", "scope", "role", "kind", "iban", "currency")
20+
list_filter = ("scope", "role", "kind", "bank")
2121
search_fields = ("name", "iban", "bank")
2222

2323

finance/api.py

Lines changed: 73 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -200,17 +200,82 @@ def get(self, request):
200200
return Response(series)
201201

202202

203+
def _account_balance_at(account, on_date):
204+
"""
205+
Return the running balance of an account on or before a given date.
206+
207+
Args:
208+
account (Account): The account to inspect
209+
on_date (datetime.date): The cut-off date
210+
211+
Returns:
212+
float: The latest known balance, or 0 when the account has no movements
213+
"""
214+
215+
last = account.transactions.filter(date__lte=on_date).order_by("-date", "-id").first()
216+
if last and last.balance is not None:
217+
return float(last.balance)
218+
return 0.0
219+
220+
221+
def _compute_net_worth_series():
222+
"""
223+
Build the net-worth time series broken down by account role.
224+
225+
Returns:
226+
list[dict]: One entry per balance snapshot, oldest first, with the
227+
business / personal / house components and the all-up net worth.
228+
"""
229+
230+
# Cache the accounts per role so we don't re-query inside the loop
231+
house_accounts = list(Account.objects.filter(role=Account.Role.HOUSE))
232+
personal_accounts = list(Account.objects.filter(role=Account.Role.PERSONAL))
233+
business_accounts = list(Account.objects.filter(role=Account.Role.BUSINESS))
234+
235+
series = []
236+
for snap in BalanceSnapshot.objects.order_by("as_of"):
237+
as_of = snap.as_of
238+
savings = float(snap.savings_total or 0)
239+
investments = float(snap.investments_total or 0)
240+
mortgage = float(snap.mortgage_balance or 0)
241+
242+
house_current = sum(_account_balance_at(a, as_of) for a in house_accounts)
243+
personal_current = sum(_account_balance_at(a, as_of) for a in personal_accounts)
244+
business_current = sum(_account_balance_at(a, as_of) for a in business_accounts)
245+
246+
# House equity is the household current account net of the mortgage;
247+
# savings and investments are tracked as their own components below
248+
house_total = house_current - mortgage
249+
net_worth = business_current + personal_current + house_total + savings + investments
250+
251+
series.append(
252+
{
253+
"as_of": as_of.isoformat(),
254+
"business": business_current,
255+
"personal": personal_current,
256+
"house": house_total,
257+
"house_current": house_current,
258+
"savings": savings,
259+
"investments": investments,
260+
"mortgage": mortgage,
261+
"net_worth": net_worth,
262+
}
263+
)
264+
265+
return series
266+
267+
203268
class NetWorthView(APIView):
204269
"""
205-
Savings and net worth over time from the balance snapshots.
270+
Net worth over time, broken down by account role.
206271
207-
- Reports the headline balances captured each month
208-
- Computes net worth as assets minus the mortgage balance
272+
- Combines the BalanceSnapshot figures with the per-account running balances
273+
- Returns the business / personal / household components plus the all-up total
209274
"""
210275

211276
def get(self, request):
212277
"""
213-
Return the net-worth series.
278+
Return the scoped net-worth series.
214279
215280
Args:
216281
request (Request): The incoming request
@@ -219,24 +284,7 @@ def get(self, request):
219284
Response: One entry per snapshot, oldest first
220285
"""
221286

222-
series = []
223-
for snap in BalanceSnapshot.objects.order_by("as_of"):
224-
current = float(snap.current_total or 0)
225-
savings = float(snap.savings_total or 0)
226-
investments = float(snap.investments_total or 0)
227-
mortgage = float(snap.mortgage_balance or 0)
228-
series.append(
229-
{
230-
"as_of": snap.as_of.isoformat(),
231-
"current": current,
232-
"savings": savings,
233-
"investments": investments,
234-
"mortgage": mortgage,
235-
"net_worth": current + savings + investments - mortgage,
236-
}
237-
)
238-
239-
return Response(series)
287+
return Response(_compute_net_worth_series())
240288

241289

242290
class AccountsView(APIView):
@@ -298,22 +346,9 @@ def get(self, request):
298346
Response: Headline figures for the homepage
299347
"""
300348

301-
# Latest balance snapshot, if any imports have produced one
302-
snap = BalanceSnapshot.objects.order_by("-as_of").first()
303-
net_worth_block = None
304-
if snap:
305-
current = float(snap.current_total or 0)
306-
savings = float(snap.savings_total or 0)
307-
investments = float(snap.investments_total or 0)
308-
mortgage = float(snap.mortgage_balance or 0)
309-
net_worth_block = {
310-
"as_of": snap.as_of.isoformat(),
311-
"current": current,
312-
"savings": savings,
313-
"investments": investments,
314-
"mortgage": mortgage,
315-
"net_worth": current + savings + investments - mortgage,
316-
}
349+
# Latest snapshot, blended with business and personal account balances
350+
series = _compute_net_worth_series()
351+
net_worth_block = series[-1] if series else None
317352

318353
# Date range we report on: current month and current calendar year
319354
today = date.today()
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Generated by Django 5.2.14 on 2026-05-28 16:30
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("finance", "0002_categoryrule"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="account",
14+
name="role",
15+
field=models.CharField(
16+
choices=[
17+
("house", "Household (holds the mortgage)"),
18+
("personal", "Personal"),
19+
("business", "Business"),
20+
],
21+
default="personal",
22+
max_length=10,
23+
),
24+
),
25+
]
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Author: xhico
2+
# Date: May 27, 2026
3+
"""Populate Account.role from scope and Mortgage transactions."""
4+
5+
from django.db import migrations
6+
7+
8+
def populate_roles(apps, schema_editor):
9+
"""
10+
Seed Account.role from existing data.
11+
12+
A personal account that already has any "Mortgage" category transaction is
13+
tagged as the household; the rest fall back to the role implied by scope.
14+
15+
Args:
16+
apps (StateApps): Historical app registry from the migration framework
17+
schema_editor: The database schema editor (unused)
18+
19+
Returns:
20+
None
21+
"""
22+
23+
Account = apps.get_model("finance", "Account")
24+
Category = apps.get_model("finance", "Category")
25+
26+
# Look up the Mortgage category if the user has seeded it
27+
mortgage = Category.objects.filter(name="Mortgage").first()
28+
mortgage_id = mortgage.id if mortgage else None
29+
30+
for account in Account.objects.all():
31+
if account.scope == "business":
32+
account.role = "business"
33+
elif mortgage_id is not None and account.transactions.filter(category_id=mortgage_id).exists():
34+
account.role = "house"
35+
else:
36+
account.role = "personal"
37+
account.save(update_fields=["role"])
38+
39+
40+
def noop(apps, schema_editor):
41+
"""
42+
Reverse migration is a no-op since the column is dropped on rollback.
43+
44+
Args:
45+
apps (StateApps): Unused
46+
schema_editor: Unused
47+
48+
Returns:
49+
None
50+
"""
51+
52+
53+
class Migration(migrations.Migration):
54+
"""Seed Account.role values for existing accounts."""
55+
56+
dependencies = [
57+
("finance", "0003_account_role"),
58+
]
59+
60+
operations = [
61+
migrations.RunPython(populate_roles, noop),
62+
]

finance/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,18 @@ class Kind(models.TextChoices):
3232
TERM = "term", "Term deposit"
3333
CREDIT = "credit", "Credit"
3434

35+
class Role(models.TextChoices):
36+
HOUSE = "house", "Household (holds the mortgage)"
37+
PERSONAL = "personal", "Personal"
38+
BUSINESS = "business", "Business"
39+
3540
name = models.CharField(max_length=120)
3641
bank = models.CharField(max_length=120)
3742
# Normalised IBAN (no spaces) used to match transactions to this account
3843
iban = models.CharField(max_length=34, unique=True)
3944
scope = models.CharField(max_length=10, choices=Scope.choices)
45+
# Finer breakdown for net-worth views; the migration seeds this from scope
46+
role = models.CharField(max_length=10, choices=Role.choices, default=Role.PERSONAL)
4047
kind = models.CharField(max_length=10, choices=Kind.choices, default=Kind.CURRENT)
4148
currency = models.CharField(max_length=3, default="EUR")
4249
created_at = models.DateTimeField(auto_now_add=True)

0 commit comments

Comments
 (0)