diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index 4be62c7..a8c4fb1 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -186,6 +186,8 @@ type storeIface interface { updateGoal(ctx context.Context, id, userID string, update bson.M) error deleteGoal(ctx context.Context, id, userID string) error seedCategories(ctx context.Context, userID string) error + getTickerMappings(ctx context.Context, userID string) ([]TickerMapping, error) + saveTickerMapping(ctx context.Context, userID, isin, ticker string) error getHousehold(ctx context.Context, userID string) (*Household, error) createHousehold(ctx context.Context, h *Household) error deleteHousehold(ctx context.Context, userID string) error @@ -460,7 +462,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { var portfolioHoldings []Holding var portfolioPricesAvailable bool if trades, err := h.store.getTrades(ctx, a.UserID); err == nil && len(trades) > 0 { - prices, _ := fetchPricesByISIN(uniqueISINs(trades)) + prices, _ := fetchPricesByISIN(uniqueISINs(trades), nil) holdings := computeHoldings(trades, prices) pr := aggregatePortfolio(holdings) portfolioHoldings = pr.Holdings @@ -1330,11 +1332,26 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) { return } - prices, err := fetchPricesByISIN(isins) + // load user-saved ISIN→ticker overrides + customMappings, _ := h.store.getTickerMappings(ctx, a.UserID) + custom := make(map[string]string, len(customMappings)) + for _, m := range customMappings { + custom[m.ISIN] = m.Ticker + } + + prices, err := fetchPricesByISIN(isins, custom) if err != nil { slog.Error("fetch prices", "err", err) } + // collect ISINs for which we got no price + var missingPrices []string + for _, isin := range isins { + if prices[isin] == 0 { + missingPrices = append(missingPrices, isin) + } + } + holdings := computeHoldings(trades, prices) pr := aggregatePortfolio(holdings) @@ -1348,9 +1365,27 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) { TotalCostCents: pr.TotalCost, TotalPCLCents: pr.TotalPCL, TotalPCLPct: pr.PCLPct, + MissingPrices: missingPrices, }) } +func (h *Handler) SaveTickerMapping(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + a := getAuth(r) + isin := strings.TrimSpace(r.FormValue("isin")) + ticker := strings.TrimSpace(r.FormValue("ticker")) + if isin == "" || ticker == "" { + http.Error(w, "isin and ticker required", http.StatusBadRequest) + return + } + if err := h.store.saveTickerMapping(ctx, a.UserID, isin, ticker); err != nil { + slog.Error("save ticker mapping", "err", err) + http.Error(w, "save error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/portfolio", http.StatusSeeOther) +} + func (h *Handler) ImportSecurities(w http.ResponseWriter, r *http.Request) { ctx := r.Context() a := getAuth(r) @@ -1884,7 +1919,7 @@ func (h *Handler) NetWorth(w http.ResponseWriter, r *http.Request) { var portfolioCents int64 var pricesAvailable bool if trades, err2 := h.store.getTrades(ctx, a.UserID); err2 == nil && len(trades) > 0 { - prices, _ := fetchPricesByISIN(uniqueISINs(trades)) + prices, _ := fetchPricesByISIN(uniqueISINs(trades), nil) holdings := computeHoldings(trades, prices) pr := aggregatePortfolio(holdings) for _, p := range prices { @@ -2414,6 +2449,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /import/preview", h.ImportPreview) mux.HandleFunc("POST /import/confirm", h.ImportConfirm) mux.HandleFunc("POST /import/securities", h.ImportSecurities) + mux.HandleFunc("POST /portfolio/ticker", h.SaveTickerMapping) mux.HandleFunc("POST /accounts", h.Accounts) mux.HandleFunc("DELETE /accounts/{id}", h.Accounts) mux.HandleFunc("POST /categories", h.Categories) diff --git a/apps/finance/services/api/main/handler_test.go b/apps/finance/services/api/main/handler_test.go index b93ea51..608a0cd 100644 --- a/apps/finance/services/api/main/handler_test.go +++ b/apps/finance/services/api/main/handler_test.go @@ -154,6 +154,11 @@ func (m *mockStore) deleteGoal(_ context.Context, id, _ string) error { } func (m *mockStore) seedCategories(_ context.Context, _ string) error { return nil } +func (m *mockStore) getTickerMappings(_ context.Context, _ string) ([]TickerMapping, error) { + return nil, nil +} +func (m *mockStore) saveTickerMapping(_ context.Context, _, _, _ string) error { return nil } + func (m *mockStore) getHousehold(_ context.Context, _ string) (*Household, error) { return nil, fmt.Errorf("not found") } diff --git a/apps/finance/services/api/main/models.go b/apps/finance/services/api/main/models.go index fa15ee0..d00e6e5 100644 --- a/apps/finance/services/api/main/models.go +++ b/apps/finance/services/api/main/models.go @@ -207,6 +207,8 @@ type PortfolioData struct { TotalPCLCents int64 TotalPCLPct float64 RealizedPCLCents int64 + // ISINs for which no price could be fetched (so user can supply a ticker) + MissingPrices []string } type SharingData struct { diff --git a/apps/finance/services/api/main/portfolio.go b/apps/finance/services/api/main/portfolio.go index a69c0f7..dc114b3 100644 --- a/apps/finance/services/api/main/portfolio.go +++ b/apps/finance/services/api/main/portfolio.go @@ -13,6 +13,7 @@ import ( type TickerMapping struct { ISIN string `bson:"_id" json:"isin"` + UserID string `bson:"user_id" json:"user_id"` Ticker string `bson:"ticker" json:"ticker"` } @@ -150,10 +151,10 @@ type TickerStore interface { Load() error } -// 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) { +// fetchPricesByISIN resolves each ISIN to a Yahoo Finance ticker, checking +// custom (user-saved) mappings first, then the hardcoded isinToTicker map, +// then falling back to the raw ISIN as a last resort. +func fetchPricesByISIN(isins []string, custom map[string]string) (map[string]int64, error) { if len(isins) == 0 { return map[string]int64{}, nil } @@ -162,8 +163,11 @@ func fetchPricesByISIN(isins []string) (map[string]int64, error) { client := &http.Client{Timeout: 10 * time.Second} for _, isin := range isins { - ticker, ok := isinToTicker[isin] - if !ok { + ticker := custom[isin] + if ticker == "" { + ticker = isinToTicker[isin] + } + if ticker == "" { ticker = isin // last-resort fallback } diff --git a/apps/finance/services/api/main/store.go b/apps/finance/services/api/main/store.go index da99640..df5fa33 100644 --- a/apps/finance/services/api/main/store.go +++ b/apps/finance/services/api/main/store.go @@ -328,6 +328,36 @@ var defaultCategories = []struct { {"Other", "#9E9E9E"}, } +func (s *Store) tickerMappings() *mgmongo.Collection { + return s.db.Collection("finance_ticker_mappings") +} + +func (s *Store) getTickerMappings(ctx context.Context, userID string) ([]TickerMapping, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getTickerMappings") + defer span.End() + cur, err := s.tickerMappings().Find(ctx, bson.M{"user_id": userID}) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var items []TickerMapping + if err := cur.All(ctx, &items); err != nil { + return nil, err + } + return items, nil +} + +func (s *Store) saveTickerMapping(ctx context.Context, userID, isin, ticker string) error { + ctx, span := mongo.StartSpan(ctx, "Store.saveTickerMapping") + defer span.End() + _, err := s.tickerMappings().UpdateOne(ctx, + bson.M{"_id": isin, "user_id": userID}, + bson.M{"$set": bson.M{"ticker": ticker, "user_id": userID}}, + options.UpdateOne().SetUpsert(true), + ) + return err +} + func (s *Store) households() *mgmongo.Collection { return s.db.Collection("finance_households") } diff --git a/apps/finance/services/api/main/templates/portfolio.html b/apps/finance/services/api/main/templates/portfolio.html index 1e36af9..45f33e8 100644 --- a/apps/finance/services/api/main/templates/portfolio.html +++ b/apps/finance/services/api/main/templates/portfolio.html @@ -4,6 +4,23 @@ {{if $d.Holdings}} +{{if $d.MissingPrices}} +
+
⚠ Live price unavailable for {{len $d.MissingPrices}} holding{{if gt (len $d.MissingPrices) 1}}s{{end}} — add a Yahoo Finance ticker to fix this
+ {{range $d.MissingPrices}} +
+ + {{.}} + + + Look up ↗ +
+ {{end}} +
+{{end}} +

Total Value

diff --git a/apps/finance/services/api/main/testdata/.DS_Store b/apps/finance/services/api/main/testdata/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/apps/finance/services/api/main/testdata/.DS_Store differ diff --git a/apps/finance/services/api/main/testdata/traderepublic_securities.csv b/apps/finance/services/api/main/testdata/traderepublic_securities.csv deleted file mode 100644 index a141b62..0000000 --- a/apps/finance/services/api/main/testdata/traderepublic_securities.csv +++ /dev/null @@ -1,6 +0,0 @@ -"datetime","date","account_type","category","type","asset_class","name","symbol","shares","price","amount","fee","tax","currency","original_amount","original_currency","fx_rate","description","transaction_id","counterparty_name","counterparty_iban","payment_reference","mcc_code" -"2025-12-11T08:08:30.375Z","2025-12-11","DEFAULT","TRADING","BUY","FUND","S&P 500 Information Tech USD (Acc)","IE00B3WJKG14","0.8289580000","36.1900000000","-30.00","-1.00","","EUR","","","","Buy trade IE00B3WJKG14 iShares S&P 500 Info Tech UCITS ETF USD (Acc), quantity: 0.828958","905f97ce-8c20-4891-8713-cb084b0cecb1","","","","" -"2025-12-11T08:09:16.732Z","2025-12-11","DEFAULT","TRADING","BUY","FUND","Core S&P 500 USD (Acc)","IE00B5BMR087","0.0287900000","625.1800000000","-18.00","-1.00","","EUR","","","","Buy trade IE00B5BMR087 iShares Core S&P 500 UCITS ETF USD (Acc), quantity: 0.02879","8bf8206a-a90e-4df4-98d5-3473dcf1c7d6","","","","" -"2026-02-26T07:53:08.309Z","2026-02-26","DEFAULT","TRADING","BUY","FUND","Core S&P 500 USD (Acc)","IE00B5BMR087","0.1586540000","630.3000000000","-100.00","-1.00","","EUR","","","","Buy trade IE00B5BMR087 iShares Core S&P 500 UCITS ETF USD (Acc), quantity: 0.158654","c154e2fd-7e13-43dc-b78f-48a0bf4b9fc6","","","","" -"2026-02-26T07:54:29.927Z","2026-02-26","DEFAULT","TRADING","BUY","FUND","Core MSCI World USD (Acc)","IE00B4L5Y983","0.8578050000","114.2500000000","-98.00","-1.00","","EUR","","","","Buy trade IE00B4L5Y983 iShares Core MSCI World UCITS ETF USD (Acc), quantity: 0.857805","7c79b9aa-82ce-48e7-bf93-20786b7977b5","","","","" -"2026-04-15T07:28:37.604Z","2026-04-15","DEFAULT","TRADING","BUY","FUND","Core S&P 500 USD (Acc)","IE00B5BMR087","0.1575100000","634.8800000000","-100.00","-1.00","","EUR","","","","Buy trade IE00B5BMR087 iShares Core S&P 500 UCITS ETF USD (Acc), quantity: 0.15751","4062d871-f968-483a-8f1f-16447c877201","","","",""