From 6712c3608116cc218c05a7f54ef8ebf7aa6e1f31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Rodrigues?= Date: Sat, 13 Jun 2026 19:05:36 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20user-editable=20ISIN=E2=86=92ticker=20m?= =?UTF-8?q?appings=20for=20unrecognised=20holdings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a holding has no price (ISIN not in the built-in map and Yahoo rejects the raw ISIN), the portfolio page now shows an amber banner listing each missing ISIN with an inline text input and a "Look up" link to Yahoo Finance symbol search. Submitting the form POSTs to /portfolio/ticker which upserts the mapping into a finance_ticker_mappings collection keyed by (user_id, ISIN). On the next page load custom mappings are resolved first, before the hardcoded isinToTicker table, so user overrides always win. Co-Authored-By: Claude Sonnet 4.6 --- apps/finance/services/api/main/handler.go | 42 ++++++++++++++++-- .../finance/services/api/main/handler_test.go | 5 +++ apps/finance/services/api/main/models.go | 2 + apps/finance/services/api/main/portfolio.go | 16 ++++--- apps/finance/services/api/main/store.go | 30 +++++++++++++ .../api/main/templates/portfolio.html | 17 +++++++ .../services/api/main/testdata/.DS_Store | Bin 0 -> 6148 bytes .../testdata/traderepublic_securities.csv | 6 --- 8 files changed, 103 insertions(+), 15 deletions(-) create mode 100644 apps/finance/services/api/main/testdata/.DS_Store delete mode 100644 apps/finance/services/api/main/testdata/traderepublic_securities.csv 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 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 GIT binary patch literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0