From c255d7f52377c0ae4419cefb7b10522a7a8b2e2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Rodrigues?= Date: Sat, 13 Jun 2026 12:47:03 +0100 Subject: [PATCH] fix(finance): portfolio prices and projections template Portfolio: - fetchPrices was passing ISINs directly to Yahoo Finance, which only accepts ticker symbols (e.g. VWCE.DE, not IE00B3RBWM25). Added a hardcoded isinToTicker map covering common European ETFs (Vanguard, iShares, Xtrackers, Amundi). fetchPricesByISIN now resolves each ISIN to its ticker before calling Yahoo and keys the result by ISIN so computeHoldings can look up prices correctly. Renamed holdingsByISIN to uniqueISINs to reflect what it actually returns. Projections: - Template had a missing and a broken pace-bar width expression that mixed float64/int64 in the div template function, causing a template execution error that rendered the page blank. Moved all math server-side: the handler now pre-computes []catProjection with MonthlyAvg, AnnualTotal, and PacePct already calculated. Template is now simple range + printf. Chart switched to horizontal bar for better readability of long category names. Co-Authored-By: Claude Sonnet 4.6 --- apps/finance/services/api/main/handler.go | 45 +++++++++-- apps/finance/services/api/main/portfolio.go | 48 ++++++++++-- .../services/api/main/portfolio_test.go | 2 +- .../api/main/templates/projections.html | 75 ++++++++++++------- 4 files changed, 130 insertions(+), 40 deletions(-) diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index ffbdae0..01e1704 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -933,6 +933,41 @@ func (h *Handler) Projections(w http.ResponseWriter, r *http.Request) { } annualTotal := int64(math.Round(float64(-totalSpend) / float64(monthCount) * 12)) + monthlyTotal := float64(annualTotal) / 12 + + // pre-compute pace percentage per category for the template (avoids float/int type issues) + type catProjection struct { + Name string + MonthlyAvg float64 + AnnualTotal float64 + PacePct int + } + cats2, _ := h.store.getCategories(ctx, a.UserID) + catColors2 := make(map[string]string) + for _, c := range cats2 { + catColors2[c.Name] = c.Color + } + + var projections []catProjection + for cat, avg := range monthlyAvg { + pct := 0 + if monthlyTotal > 0 { + pct = int(math.Round(avg / monthlyTotal * 100)) + if pct > 100 { + pct = 100 + } + } + projections = append(projections, catProjection{ + Name: cat, + MonthlyAvg: avg, + AnnualTotal: avg * 12, + PacePct: pct, + }) + } + // sort by monthly avg descending + sort.Slice(projections, func(i, j int) bool { + return projections[i].MonthlyAvg > projections[j].MonthlyAvg + }) render(w, projectionsTmpl, map[string]interface{}{ "UserID": a.UserID, @@ -940,9 +975,9 @@ func (h *Handler) Projections(w http.ResponseWriter, r *http.Request) { "Title": "Projections", "Route": "projections", "IsOwner": true, - "MonthlyAvg": monthlyAvg, + "Projections": projections, "AnnualTotal": annualTotal, - "CategoryNames": catNames, + "CategoryColors": catColors2, }) } @@ -957,8 +992,8 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) { return } - tickers := holdingsByISIN(trades) - if len(tickers) == 0 { + isins := uniqueISINs(trades) + if len(isins) == 0 { render(w, portfolioTmpl, &PortfolioData{ UserID: a.UserID, Email: a.Email, @@ -968,7 +1003,7 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) { return } - prices, err := fetchPrices(tickers) + prices, err := fetchPricesByISIN(isins) if err != nil { slog.Error("fetch prices", "err", err) } diff --git a/apps/finance/services/api/main/portfolio.go b/apps/finance/services/api/main/portfolio.go index 99f6f6c..e869e18 100644 --- a/apps/finance/services/api/main/portfolio.go +++ b/apps/finance/services/api/main/portfolio.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "math" "net/http" "sort" @@ -15,6 +16,30 @@ type TickerMapping struct { Ticker string `bson:"ticker" json:"ticker"` } +// isinToTicker maps well-known ISIN codes to their Yahoo Finance ticker symbols. +// Add entries here for any ETF/stock you trade that isn't covered yet. +var isinToTicker = map[string]string{ + // Vanguard + "IE00B3RBWM25": "VWCE.DE", // VWCE — All-World + "IE00B3XXRP09": "VWRL.AS", // VWRL — All-World + "IE00BK5BQT80": "VWRA.L", // VWRA — All-World (acc, LSE) + // iShares + "IE00B4L5Y983": "EUNL.DE", // EUNL — MSCI World + "IE00B5BMR087": "SXR8.DE", // SXR8 — S&P 500 + "IE00B4K48X80": "SXRV.DE", // SXRV — S&P 500 EUR hedged + "IE00B52MJY50": "IUSA.AS", // IUSA — S&P 500 + "IE00B0M63177": "IWRD.AS", // IWRD — MSCI World + "IE00B4ND3602": "EMIM.AS", // EMIM — Emerging Markets + "IE00BKM4GZ66": "IS3N.DE", // IS3N — Core MSCI EM + "IE00B4L5YC18": "CSSPX.MI", // CSSPX — Core S&P 500 + // Xtrackers + "LU0274208692": "DBXW.DE", // DBXW — MSCI World + "LU0490618542": "XMWO.DE", // XMWO — MSCI World Swap + // Amundi + "LU1681043599": "CW8.PA", // CW8 — MSCI World + "FR0010315770": "CU2.PA", // CU2 — S&P 500 +} + type yahooChartResponse struct { Chart struct { Result []struct { @@ -124,21 +149,27 @@ type TickerStore interface { Load() error } -func fetchPrices(tickers []string) (map[string]int64, error) { - if len(tickers) == 0 { +// fetchPricesByISIN resolves each ISIN to a Yahoo Finance ticker (via isinToTicker), +// fetches the current market price, and returns a map keyed by ISIN. +// ISINs with no known ticker mapping are tried directly as a ticker (fallback). +func fetchPricesByISIN(isins []string) (map[string]int64, error) { + if len(isins) == 0 { return map[string]int64{}, nil } result := make(map[string]int64) client := &http.Client{Timeout: 10 * time.Second} - for _, ticker := range tickers { - if ticker == "" { - continue + for _, isin := range isins { + ticker, ok := isinToTicker[isin] + if !ok { + ticker = isin // last-resort fallback } + url := fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s", ticker) resp, err := client.Get(url) if err != nil { + slog.Warn("price fetch failed", "isin", isin, "ticker", ticker, "err", err) continue } body, err := io.ReadAll(resp.Body) @@ -154,14 +185,17 @@ func fetchPrices(tickers []string) (map[string]int64, error) { if len(chart.Chart.Result) > 0 { price := chart.Chart.Result[0].Meta.RegularMarketPrice - result[ticker] = int64(price * 100) + if price > 0 { + result[isin] = int64(price * 100) + slog.Info("price fetched", "isin", isin, "ticker", ticker, "price_cents", result[isin]) + } } } return result, nil } -func holdingsByISIN(trades []Trade) []string { +func uniqueISINs(trades []Trade) []string { seen := make(map[string]bool) var result []string for _, t := range trades { diff --git a/apps/finance/services/api/main/portfolio_test.go b/apps/finance/services/api/main/portfolio_test.go index 473a312..15eb733 100644 --- a/apps/finance/services/api/main/portfolio_test.go +++ b/apps/finance/services/api/main/portfolio_test.go @@ -133,7 +133,7 @@ func TestHoldingsByISIN(t *testing.T) { {ISIN: "US0378331005"}, {ISIN: "IE00B0M62X35"}, } - got := holdingsByISIN(trades) + got := uniqueISINs(trades) want := []string{"IE00B0M62X35", "US0378331005"} if len(got) != len(want) { t.Fatalf("got %d isins, want %d: %v", len(got), len(want), got) diff --git a/apps/finance/services/api/main/templates/projections.html b/apps/finance/services/api/main/templates/projections.html index b48983a..e3eacfa 100644 --- a/apps/finance/services/api/main/templates/projections.html +++ b/apps/finance/services/api/main/templates/projections.html @@ -2,24 +2,26 @@ {{$d := .}}

Projections

+{{if $d.Projections}} +

Projected Annual Spend

€0.00
-

Based on 6-month average

+

Based on 6-month average

Projected Monthly Spend

{{$monthly := div $d.AnnualTotal 12}}
€0.00
-

Average across categories

+

Average across all categories

-

Monthly Average by Category — Last 6 Months

+

Monthly Average by Category — Last 6 Months

- +
@@ -31,21 +33,31 @@ Category Monthly Avg Projected Annual - Pace + Share of Spend - {{range $cat, $avg := $d.MonthlyAvg}} - {{$cat}} - €{{printf "%.2f" $avg}} - €{{printf "%.2f" (mul $avg 12)}} - -
-
-
- + {{range $d.Projections}} + {{$color := index $d.CategoryColors .Name}} + + + + {{if $color}}{{end}} + {{.Name}} + + + €{{printf "%.2f" .MonthlyAvg}} + €{{printf "%.2f" .AnnualTotal}} + +
+
+
+
+ {{.PacePct}}% +
+ {{end}} @@ -58,35 +70,44 @@ const isDark = () => document.documentElement.getAttribute('data-theme') === 'da const gridC = () => isDark() ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)'; const textC = () => isDark() ? '#5c6585' : '#8a92b0'; -const catColors = [ - '#6979f8','#f87171','#fbbf24','#34d399','#a78bfa', - '#f472b6','#38bdf8','#fb923c','#4ade80','#e879f9', - '#94a3b8','#22d3ee','#f97316','#a3e635', -]; +const catColors = { {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}} }; +const labels = [{{range $d.Projections}}"{{.Name}}",{{end}}]; +const data = [{{range $d.Projections}}{{printf "%.2f" .MonthlyAvg}},{{end}}]; +const colors = labels.map(k => catColors[k] || '#6979f8'); new Chart(document.getElementById('projChart'), { type: 'bar', data: { - labels: [{{range $cat, $_ := $d.MonthlyAvg}}"{{$cat}}",{{end}}], + labels, datasets: [{ label: 'Monthly Avg (€)', - data: [{{range $_, $avg := $d.MonthlyAvg}}{{$avg}},{{end}}], - backgroundColor: catColors.map(c => c + '99'), - borderColor: catColors, + data, + backgroundColor: colors.map(c => c + '99'), + borderColor: colors, borderWidth: 1.5, borderRadius: 6, borderSkipped: false, }] }, options: { + indexAxis: 'y', responsive: true, animation: { duration: 900, easing: 'easeOutQuart' }, plugins: { legend: { display: false } }, scales: { - y: { beginAtZero: true, grid: { color: gridC() }, ticks: { color: textC(), callback: v => '€' + v } }, - x: { grid: { display: false }, ticks: { color: textC() } } + x: { beginAtZero: true, grid: { color: gridC() }, ticks: { color: textC(), callback: v => '€' + v } }, + y: { grid: { display: false }, ticks: { color: textC() } } } } }); + +{{else}} +
+
📊
+

No spending data yet

+

Import at least a month of transactions to see projections.

+ Import Transactions +
+{{end}} {{end}}