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
|
include ../../../../infrastructure/Makefile/service.mk
|
||||||
|
|||||||
@ -20,7 +20,7 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: api
|
- name: api
|
||||||
image: git.homelab.local/admin/finance-api:latest
|
image: git.homelab.local/admin/finance-api:latest
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: IfNotPresent
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 8080
|
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 ──────────────────────────────────────────────────────────
|
// ── Alerts ──────────────────────────────────────────────────────────
|
||||||
var alerts []Alert
|
var alerts []Alert
|
||||||
|
|
||||||
@ -792,7 +808,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
PortfolioPCLCents: portfolioPCLCents,
|
PortfolioPCLCents: portfolioPCLCents,
|
||||||
PortfolioHoldings: portfolioHoldings,
|
PortfolioHoldings: portfolioHoldings,
|
||||||
PortfolioPricesAvailable: portfolioPricesAvailable,
|
PortfolioPricesAvailable: portfolioPricesAvailable,
|
||||||
NetWorthCents: portfolioValueCents + running,
|
NetWorthCents: portfolioValueCents + running + dashPropertyEquity,
|
||||||
Alerts: alerts,
|
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
|
var history []NetWorthPoint
|
||||||
for _, m := range months {
|
for _, m := range months {
|
||||||
cash := monthCash[m]
|
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{
|
history = append(history, NetWorthPoint{
|
||||||
Month: m,
|
Month: m,
|
||||||
AssetCents: cash + portfolioCents,
|
AssetCents: cash + portfolioCents + propertyValueCents,
|
||||||
LiabCents: 0,
|
LiabCents: histLoanBalance,
|
||||||
NetCents: cash + portfolioCents,
|
NetCents: cash + portfolioCents + histEquity,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2162,6 +2217,9 @@ func (h *Handler) NetWorth(w http.ResponseWriter, r *http.Request) {
|
|||||||
CashCents: cashCents,
|
CashCents: cashCents,
|
||||||
PortfolioCents: portfolioCents,
|
PortfolioCents: portfolioCents,
|
||||||
CreditCents: 0,
|
CreditCents: 0,
|
||||||
|
PropertyValueCents: propertyValueCents,
|
||||||
|
LoanBalanceCents: loanBalanceCents,
|
||||||
|
PropertyEquityCents: propertyEquityCents,
|
||||||
NetWorthCents: netWorthCents,
|
NetWorthCents: netWorthCents,
|
||||||
PortfolioPricesAvailable: pricesAvailable,
|
PortfolioPricesAvailable: pricesAvailable,
|
||||||
History: history,
|
History: history,
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import (
|
|||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
|
|
||||||
var propertyTmpl = parseTmpl("templates/property.html")
|
var propertyTmpl = parseTmpl("templates/base.html", "templates/property.html")
|
||||||
|
|
||||||
// ── Amortization helpers ──────────────────────────────────────────────────────
|
// ── 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 ───────────────────────────────────────────────────────────────────
|
// ── Handler ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *Handler) Properties(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Properties(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@ -408,16 +408,19 @@ type NetWorthPoint struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type NetWorthData struct {
|
type NetWorthData struct {
|
||||||
UserID string
|
UserID string
|
||||||
Email string
|
Email string
|
||||||
Title string
|
Title string
|
||||||
Route string
|
Route string
|
||||||
|
|
||||||
// current snapshot
|
// current snapshot
|
||||||
CashCents int64 // running balance of all non-credit accounts
|
CashCents int64 // running balance of all non-credit accounts
|
||||||
PortfolioCents int64 // market value (or cost basis)
|
PortfolioCents int64 // market value (or cost basis)
|
||||||
CreditCents int64 // total outstanding on credit accounts (positive = owed)
|
CreditCents int64 // total outstanding on credit accounts (positive = owed)
|
||||||
NetWorthCents int64 // cash + portfolio − credit
|
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
|
PortfolioPricesAvailable bool
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,16 @@
|
|||||||
<div class="animate-counter {{if lt $d.NetWorthCents 0}}negative{{else}}positive{{end}}"
|
<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;"
|
style="font-size:42px; font-weight:500; letter-spacing:-1.5px; line-height:1;"
|
||||||
data-target="{{$d.NetWorthCents}}" data-prefix="€">€0.00</div>
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Breakdown cards -->
|
<!-- Breakdown cards -->
|
||||||
@ -40,6 +50,17 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</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}}
|
{{if $d.CreditCents}}
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Credit / liabilities</h2>
|
<h2>Credit / liabilities</h2>
|
||||||
@ -62,37 +83,61 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
const labels = [{{range $d.History}}"{{.Month}}",{{end}}];
|
const labels = [{{range $d.History}}"{{.Month}}",{{end}}];
|
||||||
const data = [{{range $d.History}}{{.NetCents}},{{end}}];
|
const netData = [{{range $d.History}}{{.NetCents}},{{end}}];
|
||||||
const ctx = document.getElementById('nw-chart').getContext('2d');
|
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 isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#6979f8';
|
const style = getComputedStyle(document.documentElement);
|
||||||
const green = getComputedStyle(document.documentElement).getPropertyValue('--green').trim() || '#4ade80';
|
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, {
|
new Chart(ctx, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: { labels, datasets },
|
||||||
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,
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
interaction: { intersect: false, mode: 'index' },
|
interaction: { intersect: false, mode: 'index' },
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { display: false },
|
legend: { display: hasProperty, labels: { color: isDark ? '#8892b0' : '#64748b', boxWidth: 12, font: { size: 12 } } },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
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