feat(finance): Layer 2 — property equity integrated into Net Worth (#30)

* feat(finance): Layer 2 — property equity flows into Net Worth

- NetWorthData gains PropertyValueCents, LoanBalanceCents, PropertyEquityCents
- NetWorth handler fetches properties + loans; adds equity to current snapshot
  and uses amortisation formula to compute historical loan balances per month,
  so the chart reflects how equity grew as loans were paid down
- Dashboard NetWorthCents now includes property equity
- loanBalanceAt() helper: B_n = P*(1+r)^n - (M/r)*((1+r)^n - 1)
- networth.html: inline breakdown row in hero card (cash / portfolio / equity),
  new "Property equity" breakdown card (value − loans), chart gains a dashed
  red "Loans outstanding" line when properties are present

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(property): resolve template, image pull, and build issues

- Fix parseTmpl missing base.html causing "base.html is undefined" error
- Change imagePullPolicy to IfNotPresent for local k3d dev workflow
- Add SERVICE_NAME to Makefile so make build-deploy uses correct image name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gonçalo Rodrigues 2026-06-15 23:01:55 +01:00 committed by GitHub
parent 4305a77612
commit ac073acad9
6 changed files with 167 additions and 38 deletions

View File

@ -1,2 +1,3 @@
PROJECT_ROOT := ../../../../
PROJECT_ROOT := ../../../../
SERVICE_NAME := finance-api
include ../../../../infrastructure/Makefile/service.mk

View File

@ -20,7 +20,7 @@ spec:
containers:
- name: api
image: git.homelab.local/admin/finance-api:latest
imagePullPolicy: Always
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080

View File

@ -689,6 +689,22 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
}
}
// ── Property equity (for net worth card on dashboard) ───────────────
var dashPropertyEquity int64
if dProps, err2 := h.store.getProperties(ctx, a.UserID); err2 == nil {
dLoans, _ := h.store.getLoans(ctx, a.UserID)
for _, p := range dProps {
if p.Status != PropertySold {
dashPropertyEquity += p.CurrentValueCents
}
}
for _, l := range dLoans {
if l.Status == LoanActive {
dashPropertyEquity -= l.BalanceCents
}
}
}
// ── Alerts ──────────────────────────────────────────────────────────
var alerts []Alert
@ -792,7 +808,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
PortfolioPCLCents: portfolioPCLCents,
PortfolioHoldings: portfolioHoldings,
PortfolioPricesAvailable: portfolioPricesAvailable,
NetWorthCents: portfolioValueCents + running,
NetWorthCents: portfolioValueCents + running + dashPropertyEquity,
Alerts: alerts,
})
}
@ -2140,17 +2156,56 @@ func (h *Handler) NetWorth(w http.ResponseWriter, r *http.Request) {
}
}
netWorthCents := cashCents + portfolioCents
// ── Property equity ──────────────────────────────────────────────────────────
var propertyValueCents, loanBalanceCents int64
props, _ := h.store.getProperties(ctx, a.UserID)
loans, _ := h.store.getLoans(ctx, a.UserID)
// build history points — each month: cash snapshot + portfolio (we only have current portfolio)
for _, p := range props {
if p.Status != PropertySold {
propertyValueCents += p.CurrentValueCents
}
}
for _, l := range loans {
if l.Status == LoanActive {
loanBalanceCents += l.BalanceCents
}
}
propertyEquityCents := propertyValueCents - loanBalanceCents
netWorthCents := cashCents + portfolioCents + propertyEquityCents
// build history points — cash snapshot + portfolio + property equity (amortised)
var history []NetWorthPoint
for _, m := range months {
cash := monthCash[m]
// For each month in history, compute what the loan balance was at that point
// using standard amortisation: B_n = P*(1+r)^n - (M/r)*((1+r)^n - 1)
histLoanBalance := int64(0)
for _, l := range loans {
if l.Status != LoanActive {
continue
}
t, _ := time.Parse("2006-01", m)
monthsElapsed := monthsBetween(l.StartDate, t)
if monthsElapsed < 0 {
// loan didn't exist yet — exclude its balance from this month
continue
}
monthly := l.MonthlyPaymentCents
if monthly == 0 {
monthly = loanMonthlyPayment(l.PrincipalCents, l.InterestRatePct, l.TermMonths)
}
histLoanBalance += loanBalanceAt(l.PrincipalCents, l.InterestRatePct, monthly, monthsElapsed)
}
// property value is static (current estimate — we don't have historical valuations)
histEquity := propertyValueCents - histLoanBalance
history = append(history, NetWorthPoint{
Month: m,
AssetCents: cash + portfolioCents,
LiabCents: 0,
NetCents: cash + portfolioCents,
AssetCents: cash + portfolioCents + propertyValueCents,
LiabCents: histLoanBalance,
NetCents: cash + portfolioCents + histEquity,
})
}
@ -2162,6 +2217,9 @@ func (h *Handler) NetWorth(w http.ResponseWriter, r *http.Request) {
CashCents: cashCents,
PortfolioCents: portfolioCents,
CreditCents: 0,
PropertyValueCents: propertyValueCents,
LoanBalanceCents: loanBalanceCents,
PropertyEquityCents: propertyEquityCents,
NetWorthCents: netWorthCents,
PortfolioPricesAvailable: pricesAvailable,
History: history,

View File

@ -12,7 +12,7 @@ import (
"go.mongodb.org/mongo-driver/v2/bson"
)
var propertyTmpl = parseTmpl("templates/property.html")
var propertyTmpl = parseTmpl("templates/base.html", "templates/property.html")
// ── Amortization helpers ──────────────────────────────────────────────────────
@ -114,6 +114,28 @@ func toPropertyView(p Property, allLoans []Loan) PropertyView {
}
}
// loanBalanceAt returns the outstanding balance after n monthly payments.
// Formula: B_n = P*(1+r)^n - (M/r)*((1+r)^n - 1)
func loanBalanceAt(principalCents int64, annualRatePct float64, monthlyPaymentCents int64, monthsElapsed int) int64 {
if monthsElapsed <= 0 {
return principalCents
}
if annualRatePct == 0 {
b := principalCents - monthlyPaymentCents*int64(monthsElapsed)
if b < 0 {
return 0
}
return b
}
r := annualRatePct / 12 / 100
factor := math.Pow(1+r, float64(monthsElapsed))
balance := float64(principalCents)*factor - (float64(monthlyPaymentCents)/r)*(factor-1)
if balance < 0 {
return 0
}
return int64(math.Round(balance))
}
// ── Handler ───────────────────────────────────────────────────────────────────
func (h *Handler) Properties(w http.ResponseWriter, r *http.Request) {

View File

@ -408,16 +408,19 @@ type NetWorthPoint struct {
}
type NetWorthData struct {
UserID string
Email string
Title string
Route string
UserID string
Email string
Title string
Route string
// current snapshot
CashCents int64 // running balance of all non-credit accounts
PortfolioCents int64 // market value (or cost basis)
CreditCents int64 // total outstanding on credit accounts (positive = owed)
NetWorthCents int64 // cash + portfolio credit
CashCents int64 // running balance of all non-credit accounts
PortfolioCents int64 // market value (or cost basis)
CreditCents int64 // total outstanding on credit accounts (positive = owed)
PropertyValueCents int64 // sum of current value of non-sold properties
LoanBalanceCents int64 // sum of active loan balances
PropertyEquityCents int64 // PropertyValueCents - LoanBalanceCents
NetWorthCents int64 // cash + portfolio + propertyEquity credit
PortfolioPricesAvailable bool

View File

@ -17,6 +17,16 @@
<div class="animate-counter {{if lt $d.NetWorthCents 0}}negative{{else}}positive{{end}}"
style="font-size:42px; font-weight:500; letter-spacing:-1.5px; line-height:1;"
data-target="{{$d.NetWorthCents}}" data-prefix="€">€0.00</div>
<div style="margin-top:14px; display:flex; flex-wrap:wrap; gap:18px; font-size:13px; color:var(--text3);">
<span>💵 Cash <strong style="color:var(--text2);">€{{cents $d.CashCents}}</strong></span>
<span>📈 Portfolio <strong style="color:var(--text2);">€{{cents $d.PortfolioCents}}</strong></span>
{{if $d.PropertyValueCents}}
<span>🏠 Property equity <strong style="color:var(--text2);">€{{cents $d.PropertyEquityCents}}</strong></span>
{{end}}
{{if $d.CreditCents}}
<span>💳 Credit <strong style="color:var(--red);">-€{{cents $d.CreditCents}}</strong></span>
{{end}}
</div>
</div>
<!-- Breakdown cards -->
@ -40,6 +50,17 @@
{{end}}
</div>
{{if $d.PropertyValueCents}}
<div class="card value-card animate-on-scroll">
<h2>Property equity</h2>
<div class="value animate-counter {{if lt $d.PropertyEquityCents 0}}negative{{else}}positive{{end}}"
data-target="{{$d.PropertyEquityCents}}" data-prefix="€">€0.00</div>
<p style="font-size:12px; color:var(--text3); margin-top:6px;">
€{{cents $d.PropertyValueCents}} value €{{cents $d.LoanBalanceCents}} loans
</p>
</div>
{{end}}
{{if $d.CreditCents}}
<div class="card value-card animate-on-scroll">
<h2>Credit / liabilities</h2>
@ -62,37 +83,61 @@
<script>
(function() {
const labels = [{{range $d.History}}"{{.Month}}",{{end}}];
const data = [{{range $d.History}}{{.NetCents}},{{end}}];
const ctx = document.getElementById('nw-chart').getContext('2d');
const labels = [{{range $d.History}}"{{.Month}}",{{end}}];
const netData = [{{range $d.History}}{{.NetCents}},{{end}}];
const cashData = [{{range $d.History}}{{.AssetCents}},{{end}}]; // assets (cash+portfolio+property)
const liabData = [{{range $d.History}}{{.LiabCents}},{{end}}]; // loan balances
const hasProperty = {{if $d.PropertyValueCents}}true{{else}}false{{end}};
const ctx = document.getElementById('nw-chart').getContext('2d');
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#6979f8';
const green = getComputedStyle(document.documentElement).getPropertyValue('--green').trim() || '#4ade80';
const style = getComputedStyle(document.documentElement);
const accent = style.getPropertyValue('--accent').trim() || '#6979f8';
const green = style.getPropertyValue('--green').trim() || '#4ade80';
const red = style.getPropertyValue('--red').trim() || '#f87171';
const teal = '#14b8a6';
const pts = netData.length > 24 ? 0 : 3;
const datasets = [{
label: 'Net Worth',
data: netData.map(v => v / 100),
borderColor: accent,
backgroundColor: isDark ? 'rgba(105,121,248,0.07)' : 'rgba(67,85,232,0.05)',
fill: true,
tension: 0.35,
pointRadius: pts,
pointHoverRadius: 5,
borderWidth: 2.5,
order: 0,
}];
if (hasProperty) {
datasets.push({
label: 'Loans outstanding',
data: liabData.map(v => -(v / 100)),
borderColor: red,
backgroundColor: isDark ? 'rgba(248,113,113,0.06)' : 'rgba(239,68,68,0.04)',
fill: true,
tension: 0.35,
pointRadius: 0,
pointHoverRadius: 4,
borderWidth: 1.5,
borderDash: [4, 3],
order: 1,
});
}
new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
label: 'Net Worth (€)',
data: data.map(v => v / 100),
borderColor: accent,
backgroundColor: isDark ? 'rgba(105,121,248,0.08)' : 'rgba(67,85,232,0.06)',
fill: true,
tension: 0.35,
pointRadius: data.length > 24 ? 0 : 3,
pointHoverRadius: 5,
borderWidth: 2,
}]
},
data: { labels, datasets },
options: {
responsive: true,
interaction: { intersect: false, mode: 'index' },
plugins: {
legend: { display: false },
legend: { display: hasProperty, labels: { color: isDark ? '#8892b0' : '#64748b', boxWidth: 12, font: { size: 12 } } },
tooltip: {
callbacks: {
label: ctx => '€' + ctx.parsed.y.toLocaleString('pt-PT', {minimumFractionDigits:2})
label: c => c.dataset.label + ': €' + Math.abs(c.parsed.y).toLocaleString('pt-PT', {minimumFractionDigits:2})
}
}
},