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}}