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:
parent
4305a77612
commit
ac073acad9
@ -1,2 +1,3 @@
|
||||
PROJECT_ROOT := ../../../../
|
||||
PROJECT_ROOT := ../../../../
|
||||
SERVICE_NAME := finance-api
|
||||
include ../../../../infrastructure/Makefile/service.mk
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user