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 := .}} -

Accounts

+

Accounts

-
-
-
+
+

Add Account

+ +
- +
-
+
- +
-
+
- + + + + + {{range $d.Accounts}} - - - + + + {{else}} - + + + {{end}}
NameType
NameType
{{.Name}}{{.Type}}
{{.Name}} + {{$icon := "🏦"}} + {{if eq .Type "savings"}}{{$icon = "🏧"}}{{end}} + {{if eq .Type "credit"}}{{$icon = "💳"}}{{end}} + {{if eq .Type "securities"}}{{$icon = "📈"}}{{end}} + + {{$icon}} {{.Type}} + +
No accounts yet.
No accounts yet.
@@ -47,7 +62,9 @@ {{end}} diff --git a/apps/finance/services/api/main/templates/base.html b/apps/finance/services/api/main/templates/base.html index 57f7bf7..110e52d 100644 --- a/apps/finance/services/api/main/templates/base.html +++ b/apps/finance/services/api/main/templates/base.html @@ -1,214 +1,474 @@ - + {{if .Title}}{{.Title}} — {{end}}Finance +
{{block "content" .}}{{end}}
- diff --git a/apps/finance/services/api/main/templates/categories.html b/apps/finance/services/api/main/templates/categories.html index 6746d77..48dd82b 100644 --- a/apps/finance/services/api/main/templates/categories.html +++ b/apps/finance/services/api/main/templates/categories.html @@ -4,14 +4,14 @@

Add Category

-
+
- +
@@ -22,7 +22,7 @@ - + @@ -32,28 +32,35 @@ {{range $d.Categories}} {{else}} - + + + {{end}}
Color Name Monthly Budget
- + {{.Name}} - - {{if gt .BudgetCents 0}}€{{cents .BudgetCents}}{{else}}No budget set{{end}} + + {{if gt .BudgetCents 0}}€{{cents .BudgetCents}}{{else}}No budget{{end}} -
No categories yet. Add one above.
+ No categories yet. Add one above. +
@@ -64,20 +71,17 @@ function editBudget(id) { document.getElementById('budget-display-' + id).style.display = 'none'; document.getElementById('budget-btn-' + id).style.display = 'none'; - document.getElementById('budget-edit-' + id).style.display = 'inline-flex'; - document.getElementById('budget-edit-' + id).style.gap = '6px'; - document.getElementById('budget-edit-' + id).style.alignItems = 'center'; + const edit = document.getElementById('budget-edit-' + id); + edit.style.display = 'inline-flex'; document.getElementById('budget-input-' + id).focus(); } - function cancelBudget(id) { document.getElementById('budget-display-' + id).style.display = 'inline'; - document.getElementById('budget-btn-' + id).style.display = 'inline-block'; + document.getElementById('budget-btn-' + id).style.display = 'inline-flex'; document.getElementById('budget-edit-' + id).style.display = 'none'; } - function saveBudget(id) { - const val = parseFloat(document.getElementById('budget-input-' + id).value.replace(',', '.')); + const val = parseFloat(document.getElementById('budget-input-' + id).value.replace(',', '.')); if (isNaN(val) || val < 0) return; const cents = Math.round(val * 100); fetch('/categories/' + id, { @@ -86,14 +90,15 @@ function saveBudget(id) { body: JSON.stringify({budget_cents: cents}) }).then(r => { if (!r.ok) return; - const display = document.getElementById('budget-display-' + id); - display.innerHTML = cents > 0 ? '€' + (cents / 100).toFixed(2) : 'No budget set'; + const d = document.getElementById('budget-display-' + id); + d.innerHTML = cents > 0 + ? '€' + (cents / 100).toLocaleString('pt-PT', {minimumFractionDigits:2}) + : 'No budget'; cancelBudget(id); }); } - function delCat(id) { - if (!confirm('Delete this category? Existing transactions will keep their category label.')) return; + if (!confirm('Delete this category? Transactions keep their label.')) return; fetch('/categories/' + id, {method: 'DELETE'}).then(r => { if (r.ok) document.getElementById('cat-row-' + id).remove(); }); diff --git a/apps/finance/services/api/main/templates/dashboard.html b/apps/finance/services/api/main/templates/dashboard.html index 6bd111a..a9ec5ce 100644 --- a/apps/finance/services/api/main/templates/dashboard.html +++ b/apps/finance/services/api/main/templates/dashboard.html @@ -1,48 +1,56 @@ {{define "content"}} {{$d := .}} {{$change := sub $d.ThisMonth.TotalCents $d.LastMonth.TotalCents}} -

Dashboard

- -
+
+

Dashboard

+ {{if $d.Email}}{{$d.Email}}{{end}} +
+ + +
-

This Month (Net)

+

Net This Month

€0
+ data-target="{{$d.ThisMonth.TotalCents}}" data-prefix="€">€0.00

Income

€0
+ data-target="{{$d.ThisMonthIncome}}" data-prefix="€">€0.00

Expenses

€0
+ data-target="{{$d.ThisMonthExpense}}" data-prefix="€">€0.00

vs Last Month

€0
+ data-target="{{$change}}" data-prefix="€">€0.00
- -
+ +
-

Spending by Category (This Month)

+

Spending by Category — This Month

{{if $d.ThisMonth.ByCategory}} - +
+ +
{{else}}
No spending data this month.
{{end}}
-

Balance Trend (90 days)

+

Balance Trend — 90 Days

{{if $d.BalanceTrend}} - +
+ +
{{else}} -
No transactions yet. Import some!
+
No transactions yet. Import some!
{{end}}
@@ -50,36 +58,42 @@ {{if $d.CategoryBudgets}}
-

Budget vs Actual (This Month)

-
+
+

Budget vs Actual — This Month

+ Manage budgets +
+
{{range $cat, $budget := $d.CategoryBudgets}} {{$spent := index $d.ThisMonth.ByCategory $cat}} - {{$color := index $d.CategoryColors $cat}} {{$spentAbs := centsAbs $spent}} + {{$color := index $d.CategoryColors $cat}} + {{$over := isOver $spentAbs $budget}} + {{$pct := clampPct $spentAbs $budget}}
-
- - {{if $color}}{{end}} +
+ + {{if $color}}{{end}} {{$cat}} - - €{{cents $spentAbs}} / €{{cents $budget}} + + €{{cents $spentAbs}} / €{{cents $budget}} + {{if $over}} ⚠ over budget{{end}}
-
-
+
+
{{end}}
-

Set budgets in Categories.

{{end}}
-
+

Recent Transactions

View all
@@ -97,23 +111,27 @@ {{range $d.RecentTxns}} {{$color := index $d.CategoryColors .Category}} - {{dateShort .Date}} - {{.Description}} + {{dateShort .Date}} + {{.Description}} - - {{if $color}}{{end}} + + {{if $color}}{{end}} {{.Category}} - + {{if lt .AmountCents 0}}−{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}} {{else}} - No transactions yet. Import some! + + + No transactions yet. Import some! + + {{end}} @@ -121,69 +139,75 @@
+ +{{else}} + +
+
+

+ Bank Transactions +

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

+ Securities Trades +

+

+ Upload your Trade Republic securities CSV to import buy/sell trades into your portfolio. +

+
+
+ + +
+ +
+ +
+

+ After importing, visit Portfolio to see live prices and P&L. +

+
+
+
+{{end}} {{end}} diff --git a/apps/finance/services/api/main/templates/portfolio.html b/apps/finance/services/api/main/templates/portfolio.html index 296cab7..7deba1b 100644 --- a/apps/finance/services/api/main/templates/portfolio.html +++ b/apps/finance/services/api/main/templates/portfolio.html @@ -1,69 +1,85 @@ {{define "content"}} {{$d := .}} -

Portfolio

+

Portfolio

{{if $d.Holdings}} +
-
+

Total Value

-
- €{{cents $d.TotalValueCents}} -
+
€0.00
-
+

Total Cost

-
€{{cents $d.TotalCostCents}}
+
€0.00
-
-

Unrealized P&L

-
- {{pctSign $d.TotalPCLPct}}€{{cents $d.TotalPCLCents}} - ({{pctSign $d.TotalPCLPct}}{{printf "%.2f" $d.TotalPCLPct}}%) +
+

Unrealized P&L

+
€0.00
+

+ {{pctSign $d.TotalPCLPct}}{{printf "%.2f" $d.TotalPCLPct}}% +

+
+
+ +
+ +
+

Allocation

+
+
+ + +
+

Holdings

+
+ + + + + + + + + + + + + {{range $i, $h := $d.Holdings}} + + + + + + + + + + {{end}} + +
AssetSharesAvg CostPriceValueP&L
+
{{.Name}}
+
{{.ISIN}}
+
{{printf "%.4f" .SharesOwned}}€{{cents .AvgEntryCents}}€{{cents .CurrentPriceCents}}€{{cents .CurrentValueCents}} +
+ {{pctSign .UnrealizedPCLPct}}{{printf "%.2f" .UnrealizedPCLPct}}% +
+
+ {{if ge .UnrealizedPCLCents 0}}+{{else}}−{{end}}€{{cents (centsAbs .UnrealizedPCLCents)}} +
+
+
+ +
+ Add trades via + Import CSV
-
-

Allocation

-
- -
-
-
- -
- - - - - - - - - - - - - - {{range $i, $h := $d.Holdings}} - - - - - - - - - - {{end}} - -
StockSharesAvg EntryCurrent PriceValueP&LReturn
{{.Name}}
{{.ISIN}}
{{printf "%.4f" .SharesOwned}}€{{cents .AvgEntryCents}}€{{cents .CurrentPriceCents}}€{{cents .CurrentValueCents}} - €{{cents .UnrealizedPCLCents}} - - {{pctSign .UnrealizedPCLPct}}{{printf "%.2f" .UnrealizedPCLPct}}% -
-
- + {{else}} -
-

No trades imported yet

-

Go to Import and upload your Trade Republic securities CSV.

+
+
📈
+

No trades yet

+

Import your Trade Republic securities CSV to see your portfolio.

+ Import Trades
{{end}} {{end}} diff --git a/apps/finance/services/api/main/templates/projections.html b/apps/finance/services/api/main/templates/projections.html index a3163a0..e3eacfa 100644 --- a/apps/finance/services/api/main/templates/projections.html +++ b/apps/finance/services/api/main/templates/projections.html @@ -1,64 +1,113 @@ {{define "content"}} {{$d := .}} -

Projections

+

Projections

+ +{{if $d.Projections}}
-
+

Projected Annual Spend

-
€{{cents $d.AnnualTotal}}
+
€0.00
+

Based on 6-month average

+
+
+

Projected Monthly Spend

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

Average across all categories

-

Monthly Average Spend (Last 6 Months)

- +

Monthly Average by Category — Last 6 Months

+
+ +
-
- - - - - - - - - - {{range $cat, $avg := $d.MonthlyAvg}} - - - - - - {{end}} - -
CategoryMonthly AvgProjected Annual
{{$cat}}€{{printf "%.2f" $avg}}€{{printf "%.2f" (mul $avg 12)}}
+
+
+ + + + + + + + + + + {{range $d.Projections}} + {{$color := index $d.CategoryColors .Name}} + + + + + + + {{end}} + +
CategoryMonthly AvgProjected AnnualShare of Spend
+ + {{if $color}}{{end}} + {{.Name}} + + €{{printf "%.2f" .MonthlyAvg}}€{{printf "%.2f" .AnnualTotal}} +
+
+
+
+ {{.PacePct}}% +
+
+
+ +{{else}} +
+
📊
+

No spending data yet

+

Import at least a month of transactions to see projections.

+ Import Transactions +
+{{end}} {{end}} diff --git a/apps/finance/services/api/main/templates/reports.html b/apps/finance/services/api/main/templates/reports.html index f3d7d90..8ea8321 100644 --- a/apps/finance/services/api/main/templates/reports.html +++ b/apps/finance/services/api/main/templates/reports.html @@ -4,43 +4,54 @@

12-Month Spend by Category

- +
+ +
-
- - - - - {{range $cat, $_ := $d.CategoryNames}} - + + {{range $cat, $_ := $d.CategoryNames}} + {{$v := index $row.Totals $cat}} + + {{end}} + + + {{end}} + +
Month +
+

Breakdown by Month

+
+ + + + + {{range $cat, $_ := $d.CategoryNames}} {{$color := index $d.CategoryColors $cat}} - {{if $color}}{{end}}{{$cat}} - - {{end}} - - - - - {{range $row := $d.MonthlyData}} - - - {{range $cat, $_ := $d.CategoryNames}} - - {{end}} + + {{end}} + + + + + {{range $row := $d.MonthlyData}} {{$total := sub 0 0}} {{range $_, $v := $row.Totals}}{{$total = add $total $v}}{{end}} - - - {{end}} - -
MonthTotal
{{$row.Month}} - {{$v := index $row.Totals $cat}} - {{if $v}}€{{cents $v}}{{else}}{{end}} - + {{if $color}}{{end}}{{$cat}} + Total
€{{cents $total}}
+
{{$row.Month}} + {{if $v}}€{{cents $v}}{{else}}{{end}} + + €{{cents $total}} +
+
- {{end}} diff --git a/apps/finance/services/api/main/templates/transactions.html b/apps/finance/services/api/main/templates/transactions.html index e8df6c0..3893f9a 100644 --- a/apps/finance/services/api/main/templates/transactions.html +++ b/apps/finance/services/api/main/templates/transactions.html @@ -6,9 +6,9 @@
-
+
- +
-
- +
+
- - + +
{{if or $.Cat $.Search $.Days}} @@ -56,26 +56,36 @@ {{range $d.Txns}} {{$color := index $d.CategoryColors .Category}} - {{dateShort .Date}} - {{.Description}} - + {{dateShort .Date}} + {{.Description}} + {{$name := index $d.AccountNames .AccountID}} - {{if $name}}{{$name}}{{else}}{{end}} + {{if $name}}{{$name}}{{else}}{{end}} - - {{if $color}}{{end}}{{.Category}} + + + {{if $color}}{{end}} + {{.Category}} - {{range $d.Categories}} - + {{end}} - + - + {{if lt .AmountCents 0}}−{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}} @@ -83,7 +93,13 @@ {{else}} - No transactions found. Import some or . + + + No transactions found. + Import some or + . + + {{end}} @@ -91,9 +107,17 @@
-