diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..c7e9b83 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "finance-api", + "runtimeExecutable": "go", + "runtimeArgs": ["run", "./apps/finance/services/api/main/"], + "port": 8080 + } + ] +} diff --git a/Makefile b/Makefile index 62f757e..33e190b 100644 --- a/Makefile +++ b/Makefile @@ -4,8 +4,6 @@ SHELL := /bin/zsh K3D_SCRIPT := infrastructure/k3d/k3d.sh TERRAFORM := terraform -SHA := $(shell git rev-parse --short HEAD) - .PHONY: up up: ## Create the k3d dev cluster $(K3D_SCRIPT) homelab @@ -22,15 +20,15 @@ SERVICES := $(shell find apps -name Makefile -path "*/services/*" -exec dirname .PHONY: deploy-finance deploy-finance: ## Build and deploy the finance API - $(MAKE) -C apps/finance/services/api build-deploy IMAGE_TAG=$(SHA) + $(MAKE) -C apps/finance/services/api build-deploy .PHONY: deploy-auth-users deploy-auth-users: ## Build and deploy the auth users service - $(MAKE) -C apps/auth/services/users build-deploy IMAGE_TAG=$(SHA) + $(MAKE) -C apps/auth/services/users build-deploy .PHONY: deploy-auth-gateway deploy-auth-gateway: ## Build and deploy the auth gateway service - $(MAKE) -C apps/auth/services/gateway build-deploy IMAGE_TAG=$(SHA) + $(MAKE) -C apps/auth/services/gateway build-deploy .PHONY: test test: ## Run all tests @@ -40,7 +38,7 @@ test: ## Run all tests deploy-all: ## Build, load, deploy, and restart every service @for dir in $(SERVICES); do \ echo "\033[36m>>> $$dir\033[0m"; \ - $(MAKE) -C "$$dir" build-deploy IMAGE_TAG=$(SHA) || true; \ + $(MAKE) -C "$$dir" build-deploy || true; \ done .PHONY: restart-all diff --git a/apps/auth/services/gateway/k8s/deployment.yaml b/apps/auth/services/gateway/k8s/deployment.yaml index 25b87ba..ade73ba 100644 --- a/apps/auth/services/gateway/k8s/deployment.yaml +++ b/apps/auth/services/gateway/k8s/deployment.yaml @@ -17,7 +17,7 @@ spec: spec: containers: - name: gateway - image: homelab/auth-gateway:latest + image: homelab/gateway:latest imagePullPolicy: IfNotPresent ports: - name: http diff --git a/apps/auth/services/users/k8s/deployment.yaml b/apps/auth/services/users/k8s/deployment.yaml index 0c27fbc..84e3d1d 100644 --- a/apps/auth/services/users/k8s/deployment.yaml +++ b/apps/auth/services/users/k8s/deployment.yaml @@ -17,7 +17,7 @@ spec: spec: containers: - name: users - image: homelab/auth-users:latest + image: homelab/users:latest imagePullPolicy: IfNotPresent ports: - name: http diff --git a/apps/finance/services/api/k8s/deployment.yaml b/apps/finance/services/api/k8s/deployment.yaml index 4c19aa6..ccf2151 100644 --- a/apps/finance/services/api/k8s/deployment.yaml +++ b/apps/finance/services/api/k8s/deployment.yaml @@ -17,7 +17,7 @@ spec: spec: containers: - name: api - image: homelab/finance-api:latest + image: homelab/api:latest imagePullPolicy: IfNotPresent ports: - name: http diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index 01e1704..b1fa4b2 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -204,7 +204,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { now := time.Now() thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) lastStart := thisStart.AddDate(0, -1, 0) - lastEnd := thisStart.Add(-time.Nanosecond) + threeMonthsAgo := thisStart.AddDate(0, -3, 0) txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{}) if err != nil { @@ -217,21 +217,23 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { if err != nil { slog.Error("get categories", "err", err) } + catColors := make(map[string]string) + catBudgets := make(map[string]int64) catNames := make(map[string]string) for _, c := range cats { catNames[c.Name] = c.Name + catColors[c.Name] = c.Color + // exclude fixed categories from budget health — they're committed costs, not variable spend + if c.BudgetCents > 0 && !FixedCategories[c.Name] { + catBudgets[c.Name] = c.BudgetCents + } } - thisMonth := &PeriodSummary{ - TotalCents: 0, - ByCategory: make(map[string]int64), - CategoryNames: catNames, - } - lastMonth := &PeriodSummary{ - TotalCents: 0, - ByCategory: make(map[string]int64), - CategoryNames: catNames, - } + thisMonth := &PeriodSummary{ByCategory: make(map[string]int64), CategoryNames: catNames} + lastMonth := &PeriodSummary{ByCategory: make(map[string]int64), CategoryNames: catNames} + + // fixed spending by category over the last 3 months (for recurring detection) + fixedByMonth := make(map[string]map[int]int64) // category -> month-offset -> total var recent []Transaction var balPoints []BalancePoint @@ -239,25 +241,36 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { var balDates []string for _, t := range txns { - if t.Date.After(thisStart) || t.Date.Equal(thisStart) { + isThisMonth := !t.Date.Before(thisStart) + isLastMonth := !t.Date.Before(lastStart) && t.Date.Before(thisStart) + isRecent3 := !t.Date.Before(threeMonthsAgo) && t.Date.Before(thisStart) + + if isThisMonth { thisMonth.TotalCents += t.AmountCents thisMonth.ByCategory[t.Category] += t.AmountCents - } else if t.Date.After(lastStart) && t.Date.Before(lastEnd.Add(24*time.Hour)) { + } else if isLastMonth { lastMonth.TotalCents += t.AmountCents lastMonth.ByCategory[t.Category] += t.AmountCents } - if len(recent) < 10 { + // accumulate fixed category spending over last 3 months + if isRecent3 && FixedCategories[t.Category] && t.AmountCents < 0 { + mo := int(t.Date.Month()) + if fixedByMonth[t.Category] == nil { + fixedByMonth[t.Category] = make(map[int]int64) + } + fixedByMonth[t.Category][mo] += -t.AmountCents + } + + if len(recent) < 5 { recent = append(recent, t) } day := t.Date.Format("2006-01-02") balByDate[day] += t.AmountCents + balDates = appendIfMissing(balDates, day) } - for d := range balByDate { - balDates = append(balDates, d) - } sortStrings(balDates) running := int64(0) for _, d := range balDates { @@ -269,41 +282,158 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { balPoints = balPoints[len(balPoints)-90:] } - // compute income vs expense split for this month + // income / expense split thisMonthIncome := int64(0) thisMonthExpense := int64(0) for _, amt := range thisMonth.ByCategory { if amt > 0 { thisMonthIncome += amt } else { - thisMonthExpense += amt + thisMonthExpense += -amt + } + } + lastMonthIncome := int64(0) + lastMonthSavings := int64(0) + for _, amt := range lastMonth.ByCategory { + if amt > 0 { + lastMonthIncome += amt + } + } + lastMonthSavings = lastMonth.TotalCents + if lastMonthSavings < 0 { + lastMonthSavings = 0 + } + + // detect recurring fixed expenses (average over last 3 months) + var recurringExpenses []RecurringExpense + totalFixedCents := int64(0) + for cat, byMonth := range fixedByMonth { + total := int64(0) + for _, v := range byMonth { + total += v + } + avg := total / int64(len(byMonth)) + recurringExpenses = append(recurringExpenses, RecurringExpense{Category: cat, MonthlyCents: avg}) + totalFixedCents += avg + } + sort.Slice(recurringExpenses, func(i, j int) bool { + return recurringExpenses[i].MonthlyCents > recurringExpenses[j].MonthlyCents + }) + + // disposable income = income - fixed recurring + disposableIncome := thisMonthIncome - totalFixedCents + + // variable spend so far this month (non-fixed categories, expenses only) + variableSpent := int64(0) + for cat, amt := range thisMonth.ByCategory { + if !FixedCategories[cat] && amt < 0 { + variableSpent += -amt } } - // budget data: map category name -> budget cents - catBudgets := make(map[string]int64) - catColors := make(map[string]string) - for _, c := range cats { - if c.BudgetCents > 0 { - catBudgets[c.Name] = c.BudgetCents - } - catColors[c.Name] = c.Color + availableToSpend := disposableIncome - variableSpent + if availableToSpend < 0 { + availableToSpend = 0 } - render(w, dashboardTmpl, map[string]interface{}{ - "UserID": a.UserID, - "Email": a.Email, - "Title": "Dashboard", - "Route": "dashboard", - "IsOwner": true, - "ThisMonth": thisMonth, - "LastMonth": lastMonth, - "RecentTxns": recent, - "BalanceTrend": balPoints, - "ThisMonthIncome": thisMonthIncome, - "ThisMonthExpense": thisMonthExpense, - "CategoryBudgets": catBudgets, - "CategoryColors": catColors, + // month progress + daysInMonth := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Day() + monthProgressPct := int(float64(now.Day()) / float64(daysInMonth) * 100) + + // % of disposable already spent + monthSpentPct := 0 + if disposableIncome > 0 { + monthSpentPct = int(float64(variableSpent) / float64(disposableIncome) * 100) + if monthSpentPct > 100 { + monthSpentPct = 100 + } + } + + // safety buffer = 2 weeks of average daily variable spend over last month + lastMonthVariableSpent := int64(0) + for cat, amt := range lastMonth.ByCategory { + if !FixedCategories[cat] && amt < 0 { + lastMonthVariableSpent += -amt + } + } + safetyBuffer := lastMonthVariableSpent / 2 + + // bank should be = upcoming fixed costs (not yet paid this month) + safety buffer + fixedPaidThisMonth := int64(0) + for cat, amt := range thisMonth.ByCategory { + if FixedCategories[cat] && amt < 0 { + fixedPaidThisMonth += -amt + } + } + bankShouldBe := (totalFixedCents - fixedPaidThisMonth) + safetyBuffer + + // savings rate + savingsRatePct := 0 + if thisMonthIncome > 0 { + saved := thisMonthIncome - thisMonthExpense + if saved > 0 { + savingsRatePct = int(float64(saved) / float64(thisMonthIncome) * 100) + } + } + lastMonthSavingsRatePct := 0 + if lastMonthIncome > 0 && lastMonthSavings > 0 { + lastMonthSavingsRatePct = int(float64(lastMonthSavings) / float64(lastMonthIncome) * 100) + } + + // portfolio snapshot — degrade to cost basis if live prices are unavailable + var portfolioValueCents, portfolioPCLCents int64 + var portfolioHoldings []Holding + var portfolioPricesAvailable bool + if trades, err := h.store.getTrades(ctx, a.UserID); err == nil && len(trades) > 0 { + prices, _ := fetchPricesByISIN(uniqueISINs(trades)) + holdings := computeHoldings(trades, prices) + pr := aggregatePortfolio(holdings) + portfolioHoldings = pr.Holdings + + // check whether any prices came back + for _, p := range prices { + if p > 0 { + portfolioPricesAvailable = true + break + } + } + + if portfolioPricesAvailable { + portfolioValueCents = pr.TotalVal + portfolioPCLCents = pr.TotalPCL + } else { + // fall back to cost basis so the card is still useful + portfolioValueCents = pr.TotalCost + } + } + + render(w, dashboardTmpl, &DashboardData{ + UserID: a.UserID, + Email: a.Email, + Title: "Dashboard", + Route: "dashboard", + IsOwner: true, + ThisMonth: thisMonth, + LastMonth: lastMonth, + RecentTxns: recent, + BalanceTrend: balPoints, + ThisMonthIncome: thisMonthIncome, + ThisMonthExpense: thisMonthExpense, + CategoryBudgets: catBudgets, + CategoryColors: catColors, + AvailableToSpend: availableToSpend, + DisposableIncome: disposableIncome, + MonthProgressPct: monthProgressPct, + MonthSpentPct: monthSpentPct, + RecurringExpenses: recurringExpenses, + BankShouldBe: bankShouldBe, + SafetyBufferCents: safetyBuffer, + SavingsRatePct: savingsRatePct, + LastMonthSavingsRatePct: lastMonthSavingsRatePct, + PortfolioValueCents: portfolioValueCents, + PortfolioPCLCents: portfolioPCLCents, + PortfolioHoldings: portfolioHoldings, + PortfolioPricesAvailable: portfolioPricesAvailable, }) } @@ -1227,3 +1357,12 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { func sortStrings(s []string) { sort.Strings(s) } + +func appendIfMissing(s []string, v string) []string { + for _, x := range s { + if x == v { + return s + } + } + return append(s, v) +} diff --git a/apps/finance/services/api/main/models.go b/apps/finance/services/api/main/models.go index 08a7a47..d7bd27d 100644 --- a/apps/finance/services/api/main/models.go +++ b/apps/finance/services/api/main/models.go @@ -104,6 +104,19 @@ type CSVImportPreview struct { Total int `json:"total"` } +// FixedCategories are treated as recurring committed costs, not variable spend. +var FixedCategories = map[string]bool{ + "Housing": true, + "Utilities": true, + "Subscriptions": true, + "Investments": true, +} + +type RecurringExpense struct { + Category string + MonthlyCents int64 +} + type DashboardData struct { UserID string Email string @@ -114,6 +127,29 @@ type DashboardData struct { LastMonth *PeriodSummary RecentTxns []Transaction BalanceTrend []BalancePoint + + // Phase 1 fields + ThisMonthIncome int64 + ThisMonthExpense int64 + CategoryBudgets map[string]int64 + CategoryColors map[string]string + + AvailableToSpend int64 // income − fixed − variable budgets spent so far + DisposableIncome int64 // income − fixed recurring costs + MonthProgressPct int // % of month elapsed + MonthSpentPct int // % of disposable already spent + + RecurringExpenses []RecurringExpense + BankShouldBe int64 // sum of upcoming fixed costs + safety buffer + SafetyBufferCents int64 + + SavingsRatePct int // savings / income * 100 this month + LastMonthSavingsRatePct int + + PortfolioValueCents int64 + PortfolioPCLCents int64 + PortfolioHoldings []Holding + PortfolioPricesAvailable bool } type PeriodSummary struct { diff --git a/apps/finance/services/api/main/templates/dashboard.html b/apps/finance/services/api/main/templates/dashboard.html index a9ec5ce..02b62a7 100644 --- a/apps/finance/services/api/main/templates/dashboard.html +++ b/apps/finance/services/api/main/templates/dashboard.html @@ -1,217 +1,227 @@ {{define "content"}} {{$d := .}} -{{$change := sub $d.ThisMonth.TotalCents $d.LastMonth.TotalCents}}

Dashboard

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

Net This Month

-
€0.00
+ +
+
+ Available to spend this month + + income − fixed costs − spent so far +
-
-

Income

-
€0.00
+ +
+
€0.00
+
+ of €0.00 disposable +  ·  {{$d.MonthSpentPct}}% used +
-
-

Expenses

-
€0.00
+ +
+
-
-

vs Last Month

-
€0.00
+
+ Month progress: {{$d.MonthProgressPct}}% + Spent: {{$d.MonthSpentPct}}%
- + +
+ +
+

Bank balance should be

+
€0.00
+

upcoming fixed + safety buffer

+
+ +
+

Savings rate

+
{{$d.SavingsRatePct}}%
+ {{if $d.LastMonthSavingsRatePct}} +

+ {{if gt $d.SavingsRatePct $d.LastMonthSavingsRatePct}}↑{{else}}↓{{end}} vs last month ({{$d.LastMonthSavingsRatePct}}%) +

+ {{end}} +
+ +
+

Portfolio today

+ {{if $d.PortfolioHoldings}} +
€0.00
+ {{if $d.PortfolioPricesAvailable}} +

+ {{if ge $d.PortfolioPCLCents 0}}+{{else}}−{{end}}€{{cents (centsAbs $d.PortfolioPCLCents)}} total P&L +

+ {{else}} +

cost basis · prices unavailable

+ {{end}} + {{else}} +
No trades yet
+

Import trades →

+ {{end}} +
+ +
+ + +
+ +
+
+

What should be in your bank

+ right now +
+ {{if $d.RecurringExpenses}} +
+ {{range $d.RecurringExpenses}} + {{$color := index $d.CategoryColors .Category}} +
+ + {{if $color}}{{end}} + {{.Category}} + + − €{{cents .MonthlyCents}} +
+ {{end}} + {{if $d.SafetyBufferCents}} +
+ Safety buffer (2 weeks) + − €{{cents $d.SafetyBufferCents}} +
+ {{end}} +
+ Minimum recommended + €0.00 +
+
+ {{else}} +
+

No recurring expenses detected yet.
Import a few months of transactions.

+
+ {{end}} +
+ +
+
+

Stocks at a glance

+ → portfolio +
+ {{if $d.PortfolioHoldings}} +
+ {{range $d.PortfolioHoldings}} +
+
+
{{.Name}}
+
{{printf "%.4f" .SharesOwned}} shares
+
+
+ {{if $d.PortfolioPricesAvailable}} +
€{{cents .CurrentValueCents}}
+
+ {{pctSign .UnrealizedPCLPct}}{{printf "%.1f" .UnrealizedPCLPct}}% +
+ {{else}} +
€{{cents .TotalCostCents}}
+
cost basis
+ {{end}} +
+
+ {{end}} +
+ Total{{if not $d.PortfolioPricesAvailable}} invested{{end}} + €0.00 +
+
+ {{else}} +
+

No holdings yet.
Import trades →

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

Spending by Category — This Month

- {{if $d.ThisMonth.ByCategory}} -
- +
+

Budget health

+ → categories +
+
+ {{range $cat, $budget := $d.CategoryBudgets}} + {{$spent := index $d.ThisMonth.ByCategory $cat}} + {{$spentAbs := centsAbs $spent}} + {{$color := index $d.CategoryColors $cat}} + {{$over := isOver $spentAbs $budget}} + {{$pct := clampPct $spentAbs $budget}} +
+
+ + {{if $color}}{{end}} + {{$cat}} + + + {{$pct}}%{{if $over}} ⚠{{end}} + +
+
+
+
+
+ {{end}} +
+
+ {{end}} + +
+
+

Recent activity

+ → all transactions +
+ {{if $d.RecentTxns}} +
+ {{range $d.RecentTxns}} + {{$color := index $d.CategoryColors .Category}} +
+
+
+
+
{{.Description}}
+
{{dateShort .Date}}
+
+
+
+ {{if lt .AmountCents 0}}−{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}} +
+
+ {{end}}
{{else}} -
No spending data this month.
- {{end}} -
-
-

Balance Trend — 90 Days

- {{if $d.BalanceTrend}} -
- -
- {{else}} -
No transactions yet. Import some!
- {{end}} -
-
- - -{{if $d.CategoryBudgets}} -
-
-

Budget vs Actual — This Month

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

Recent Transactions

- View all -
-
- - - - - - - - - - - {{range $d.RecentTxns}} - {{$color := index $d.CategoryColors .Category}} - - - - - - - {{else}} - - - - {{end}} - -
DateDescriptionCategoryAmount
{{dateShort .Date}}{{.Description}} - - {{if $color}}{{end}} - {{.Category}} - - - {{if lt .AmountCents 0}}−{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}} -
- No transactions yet. Import some! -
-
-
- - -{{end}}