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/main.go b/apps/finance/services/api/main/main.go index e525a71..bb3fce4 100644 --- a/apps/finance/services/api/main/main.go +++ b/apps/finance/services/api/main/main.go @@ -32,6 +32,9 @@ func main() { defer db.Close(ctx) store := NewStore(db) + + go SeedAdmin(ctx, store) + handler := NewHandler(store) mux := http.NewServeMux() 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/seed.go b/apps/finance/services/api/main/seed.go new file mode 100644 index 0000000..9a24253 --- /dev/null +++ b/apps/finance/services/api/main/seed.go @@ -0,0 +1,302 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// SeedAdmin looks up the admin user by email directly in the shared MongoDB +// (both services use the same DB) and seeds demo data if the account has no +// existing transactions. +func SeedAdmin(ctx context.Context, store *Store) { + email := os.Getenv("SEED_USER_EMAIL") + if email == "" { + email = "admin@homelab.local" + } + + userID, err := lookupUserByEmailMongo(ctx, store, email) + if err != nil { + slog.Warn("seed: could not resolve admin user, skipping", "email", email, "err", err) + return + } + + // idempotent — skip if any transactions already exist + existing, err := store.getTransactions(ctx, userID, bson.M{}) + if err == nil && len(existing) > 0 { + slog.Info("seed: data already present, skipping", "user_id", userID) + return + } + + slog.Info("seed: seeding demo data", "user_id", userID, "email", email) + + if err := seedAll(ctx, store, userID); err != nil { + slog.Error("seed: failed", "err", err) + } else { + slog.Info("seed: done") + } +} + +// lookupUserByEmailMongo queries the shared "users" collection directly, +// avoiding any cross-service HTTP dependency. +func lookupUserByEmailMongo(ctx context.Context, store *Store, email string) (string, error) { + coll := store.db.Collection("users") + var result struct { + ID string `bson:"_id"` + Email string `bson:"email"` + } + err := coll.FindOne(ctx, bson.M{"email": email}).Decode(&result) + if err != nil { + return "", fmt.Errorf("user %q not found in mongo: %w", email, err) + } + return result.ID, nil +} + +func seedAll(ctx context.Context, store *Store, userID string) error { + // ── Accounts ───────────────────────────────────────────────────────── + checkingID := bson.NewObjectID().Hex() + savingsID := bson.NewObjectID().Hex() + creditID := bson.NewObjectID().Hex() + investID := bson.NewObjectID().Hex() + + accounts := []*Account{ + {ID: checkingID, UserID: userID, Name: "CGD Checking", Type: "checking"}, + {ID: savingsID, UserID: userID, Name: "CGD Savings", Type: "savings"}, + {ID: creditID, UserID: userID, Name: "Visa Credit", Type: "credit"}, + {ID: investID, UserID: userID, Name: "Trade Republic", Type: "securities"}, + } + for _, a := range accounts { + if err := store.createAccount(ctx, a); err != nil { + return fmt.Errorf("create account: %w", err) + } + } + + // ── Categories with budgets ─────────────────────────────────────────── + type catDef struct { + Name string + Color string + BudgetCents int64 + } + catDefs := []catDef{ + {"Groceries", "#4caf50", 30000}, + {"Food", "#ff9800", 20000}, + {"Transport", "#2196f3", 8000}, + {"Housing", "#9c27b0", 80000}, + {"Utilities", "#607d8b", 10000}, + {"Health", "#f44336", 5000}, + {"Clothing", "#e91e63", 10000}, + {"Games", "#673ab7", 3000}, + {"Entertainment", "#ff5722", 5000}, + {"Subscriptions", "#795548", 4000}, + {"Shopping", "#ff6f00", 15000}, + {"Income", "#2e7d32", 0}, + {"Investments", "#1565c0", 20000}, + {"Others", "#9e9e9e", 5000}, + } + catIDByName := make(map[string]string) + for _, cd := range catDefs { + id := bson.NewObjectID().Hex() + catIDByName[cd.Name] = id + cat := &Category{ + ID: id, + UserID: userID, + Name: cd.Name, + Color: cd.Color, + BudgetCents: cd.BudgetCents, + } + if err := store.createCategory(ctx, cat); err != nil { + return fmt.Errorf("create category %s: %w", cd.Name, err) + } + } + + // ── Transactions — 6 months of realistic Portuguese household spend ─── + now := time.Now() + var txns []Transaction + + type txDef struct { + daysAgo int + desc string + amountCents int64 + cat string + accountID string + } + + rawTxns := []txDef{ + // ── Month 0 (current month) ────────────────────────────────────── + {0, "Salary — Homelab Corp", 230000, "Income", checkingID}, + {1, "Continente Supermercado", -4230, "Groceries", checkingID}, + {2, "MEO Internet", -3999, "Utilities", checkingID}, + {3, "Glovo — Sushi House", -2150, "Food", creditID}, + {4, "Uber Lisboa", -850, "Transport", creditID}, + {5, "Steam — Elden Ring DLC", -2999, "Games", creditID}, + {6, "Pingo Doce", -3120, "Groceries", checkingID}, + {7, "Farmácia Saúde", -1540, "Health", checkingID}, + {8, "Spotify Premium", -999, "Subscriptions", creditID}, + {9, "Netflix", -1599, "Subscriptions", creditID}, + {10, "Lidl Supermercado", -5640, "Groceries", checkingID}, + {11, "Restaurante O Barrigas", -3280, "Food", creditID}, + {12, "Trade Republic — VWCE Buy", -50000, "Investments", investID}, + {13, "CP — Lisboa Cascais", -310, "Transport", creditID}, + {14, "Decathlon", -4990, "Shopping", creditID}, + {15, "Rendimento subarrendamento", 30000, "Income", checkingID}, + // ── Month 1 ────────────────────────────────────────────────────── + {30, "Salary — Homelab Corp", 230000, "Income", checkingID}, + {31, "Auchan Supermercado", -6780, "Groceries", checkingID}, + {32, "EDP Energia", -6200, "Utilities", checkingID}, + {33, "Renda apartamento", -80000, "Housing", checkingID}, + {34, "McDonald's Marquês", -1190, "Food", creditID}, + {35, "Zara — Coleção Verão", -8990, "Clothing", creditID}, + {36, "Bolt ride", -620, "Transport", creditID}, + {37, "NOS Telemóvel", -1799, "Utilities", checkingID}, + {38, "Intermarché", -4420, "Groceries", checkingID}, + {39, "Ginásio Holmes Place", -4900, "Health", checkingID}, + {40, "Amazon Prime", -799, "Subscriptions", creditID}, + {41, "Glovo — Burger King", -1890, "Food", creditID}, + {42, "Trade Republic — SXR8 Buy", -30000, "Investments", investID}, + {43, "Via Verde portagens", -920, "Transport", checkingID}, + {44, "Fnac — Livros", -2350, "Shopping", creditID}, + {45, "H&M online", -5490, "Clothing", creditID}, + // ── Month 2 ────────────────────────────────────────────────────── + {60, "Salary — Homelab Corp", 230000, "Income", checkingID}, + {61, "Continente Supermercado", -5120, "Groceries", checkingID}, + {62, "MEO Internet", -3999, "Utilities", checkingID}, + {63, "Renda apartamento", -80000, "Housing", checkingID}, + {64, "Pastelaria Batalha", -480, "Food", creditID}, + {65, "Uber Lisboa", -1240, "Transport", creditID}, + {66, "Epic Games — Fortnite", -1999, "Games", creditID}, + {67, "Lidl Supermercado", -4890, "Groceries", checkingID}, + {68, "Farmácia da Baixa", -2310, "Health", checkingID}, + {69, "Spotify Premium", -999, "Subscriptions", creditID}, + {70, "Netflix", -1599, "Subscriptions", creditID}, + {71, "Restaurante Eleven", -9400, "Food", creditID}, + {72, "Trade Republic — VWCE Buy", -50000, "Investments", investID}, + {73, "Teatro Nacional", -2500, "Entertainment", creditID}, + {74, "IKEA Lisboa", -14900, "Shopping", creditID}, + {75, "Pingo Doce", -3670, "Groceries", checkingID}, + // ── Month 3 ────────────────────────────────────────────────────── + {90, "Salary — Homelab Corp", 230000, "Income", checkingID}, + {91, "Auchan Supermercado", -7230, "Groceries", checkingID}, + {92, "EDP Energia", -5800, "Utilities", checkingID}, + {93, "Renda apartamento", -80000, "Housing", checkingID}, + {94, "KFC Colombo", -1440, "Food", creditID}, + {95, "Bolt ride", -740, "Transport", creditID}, + {96, "NOS Telemóvel", -1799, "Utilities", checkingID}, + {97, "Intermarché", -5560, "Groceries", checkingID}, + {98, "Consulta médica particular", -8000, "Health", checkingID}, + {99, "Disney+", -799, "Subscriptions", creditID}, + {100, "Amazon Prime", -799, "Subscriptions", creditID}, + {101, "Restaurante A Cevicheria", -6200, "Food", creditID}, + {102, "Trade Republic — SXR8 Buy", -30000, "Investments", investID}, + {103, "CP — InterCity Porto", -2450, "Transport", checkingID}, + {104, "Livraria Bertrand", -1890, "Shopping", creditID}, + // ── Month 4 ────────────────────────────────────────────────────── + {120, "Salary — Homelab Corp", 230000, "Income", checkingID}, + {121, "Continente Supermercado", -6010, "Groceries", checkingID}, + {122, "MEO Internet", -3999, "Utilities", checkingID}, + {123, "Renda apartamento", -80000, "Housing", checkingID}, + {124, "Glovo — Pizza Hut", -2340, "Food", creditID}, + {125, "Uber Lisboa", -990, "Transport", creditID}, + {126, "PlayStation Store", -2999, "Games", creditID}, + {127, "Lidl Supermercado", -5210, "Groceries", checkingID}, + {128, "Farmácia Saúde", -890, "Health", checkingID}, + {129, "Spotify Premium", -999, "Subscriptions", creditID}, + {130, "Netflix", -1599, "Subscriptions", creditID}, + {131, "Tasca do Chico — jantar", -5400, "Food", creditID}, + {132, "Trade Republic — VWCE Buy", -50000, "Investments", investID}, + {133, "Fnac — AirPods", -17900, "Shopping", creditID}, + {134, "Pingo Doce", -4120, "Groceries", checkingID}, + // ── Month 5 ────────────────────────────────────────────────────── + {150, "Salary — Homelab Corp", 230000, "Income", checkingID}, + {151, "Auchan Supermercado", -5890, "Groceries", checkingID}, + {152, "EDP Energia", -6400, "Utilities", checkingID}, + {153, "Renda apartamento", -80000, "Housing", checkingID}, + {154, "Nando's Lisboa", -1980, "Food", creditID}, + {155, "Bolt ride", -510, "Transport", creditID}, + {156, "NOS Telemóvel", -1799, "Utilities", checkingID}, + {157, "Intermarché", -4780, "Groceries", checkingID}, + {158, "Óculos — Ótica Avenida", -12000, "Health", checkingID}, + {159, "Disney+", -799, "Subscriptions", creditID}, + {160, "Amazon Prime", -799, "Subscriptions", creditID}, + {161, "Cinemateca Portuguesa", -600, "Entertainment", creditID}, + {162, "Trade Republic — VWCE Buy", -50000, "Investments", investID}, + {163, "Zara — Outono", -12490, "Clothing", creditID}, + {164, "Worten — Monitor", -34900, "Shopping", creditID}, + } + + for _, td := range rawTxns { + date := now.AddDate(0, 0, -td.daysAgo).Truncate(24 * time.Hour) + txns = append(txns, Transaction{ + ID: bson.NewObjectID().Hex(), + UserID: userID, + AccountID: td.accountID, + Date: date, + Description: td.desc, + AmountCents: td.amountCents, + Category: td.cat, + CreatedAt: time.Now(), + }) + } + + if err := store.createTransactions(ctx, txns); err != nil { + return fmt.Errorf("create transactions: %w", err) + } + + // ── Portfolio trades ───────────────────────────────────────────────── + trades := []Trade{ + // VWCE — Vanguard FTSE All-World + { + ID: bson.NewObjectID().Hex(), UserID: userID, + ISIN: "IE00B3RBWM25", Name: "VWCE - Vanguard All-World", + Type: "buy", Quantity: 12, PriceCents: 11820, TotalCents: 141840, + Date: now.AddDate(0, -5, 5), CreatedAt: time.Now(), + }, + { + ID: bson.NewObjectID().Hex(), UserID: userID, + ISIN: "IE00B3RBWM25", Name: "VWCE - Vanguard All-World", + Type: "buy", Quantity: 8, PriceCents: 11960, TotalCents: 95680, + Date: now.AddDate(0, -3, 12), CreatedAt: time.Now(), + }, + { + ID: bson.NewObjectID().Hex(), UserID: userID, + ISIN: "IE00B3RBWM25", Name: "VWCE - Vanguard All-World", + Type: "buy", Quantity: 6, PriceCents: 12100, TotalCents: 72600, + Date: now.AddDate(0, -1, 12), CreatedAt: time.Now(), + }, + // SXR8 — iShares Core S&P 500 + { + ID: bson.NewObjectID().Hex(), UserID: userID, + ISIN: "IE00B5BMR087", Name: "SXR8 - iShares S&P 500", + Type: "buy", Quantity: 15, PriceCents: 53200, TotalCents: 798000, + Date: now.AddDate(0, -4, 8), CreatedAt: time.Now(), + }, + { + ID: bson.NewObjectID().Hex(), UserID: userID, + ISIN: "IE00B5BMR087", Name: "SXR8 - iShares S&P 500", + Type: "buy", Quantity: 10, PriceCents: 55100, TotalCents: 551000, + Date: now.AddDate(0, -2, 3), CreatedAt: time.Now(), + }, + // EUNL — iShares Core MSCI World + { + ID: bson.NewObjectID().Hex(), UserID: userID, + ISIN: "IE00B4L5Y983", Name: "EUNL - iShares MSCI World", + Type: "buy", Quantity: 20, PriceCents: 8950, TotalCents: 179000, + Date: now.AddDate(0, -5, 20), CreatedAt: time.Now(), + }, + { + ID: bson.NewObjectID().Hex(), UserID: userID, + ISIN: "IE00B4L5Y983", Name: "EUNL - iShares MSCI World", + Type: "buy", Quantity: 10, PriceCents: 9210, TotalCents: 92100, + Date: now.AddDate(0, -2, 18), CreatedAt: time.Now(), + }, + } + + if err := store.createTrades(ctx, trades); err != nil { + return fmt.Errorf("create trades: %w", err) + } + + return nil +} diff --git a/apps/finance/services/api/main/templates/accounts.html b/apps/finance/services/api/main/templates/accounts.html index fb7a42d..8aac44c 100644 --- a/apps/finance/services/api/main/templates/accounts.html +++ b/apps/finance/services/api/main/templates/accounts.html @@ -1,14 +1,15 @@ {{define "content"}} {{$d := .}} -