diff --git a/apps/finance/services/api/Makefile b/apps/finance/services/api/Makefile index 8638b78..32edea6 100644 --- a/apps/finance/services/api/Makefile +++ b/apps/finance/services/api/Makefile @@ -1,2 +1,3 @@ -PROJECT_ROOT := ../../../../ +PROJECT_ROOT := ../../../../ +SERVICE_NAME := finance-api include ../../../../infrastructure/Makefile/service.mk diff --git a/apps/finance/services/api/k8s/deployment.yaml b/apps/finance/services/api/k8s/deployment.yaml index f55d342..df39e38 100644 --- a/apps/finance/services/api/k8s/deployment.yaml +++ b/apps/finance/services/api/k8s/deployment.yaml @@ -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 diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index f0e73c6..f1ed6dd 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -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, diff --git a/apps/finance/services/api/main/handler_property.go b/apps/finance/services/api/main/handler_property.go index e4f0afa..7e9963e 100644 --- a/apps/finance/services/api/main/handler_property.go +++ b/apps/finance/services/api/main/handler_property.go @@ -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) { diff --git a/apps/finance/services/api/main/models.go b/apps/finance/services/api/main/models.go index d00e6e5..f21d2b5 100644 --- a/apps/finance/services/api/main/models.go +++ b/apps/finance/services/api/main/models.go @@ -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 diff --git a/apps/finance/services/api/main/templates/networth.html b/apps/finance/services/api/main/templates/networth.html index f8de88f..514e070 100644 --- a/apps/finance/services/api/main/templates/networth.html +++ b/apps/finance/services/api/main/templates/networth.html @@ -17,6 +17,16 @@
+ €{{cents $d.PropertyValueCents}} value − €{{cents $d.LoanBalanceCents}} loans +
+