Gonçalo Rodrigues 292b2f46f0 fix(finance): seed admin account in finance_users on startup (#37)
SeedAdmin now creates the finance_users account from ADMIN_EMAIL /
ADMIN_PASSWORD env vars if it doesn't exist, so a fresh cluster
bootstraps a working login without manual registration.

Wires ADMIN_EMAIL and ADMIN_PASSWORD into the deployment from the
finance-api-secrets k8s secret (optional — pod still starts without it).

Also gitignores /main build artifact and *.test binaries.

Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 17:43:59 +01:00

572 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"context"
"fmt"
"log/slog"
"os"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
// SeedAdmin ensures an admin account exists in finance_users, then 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"
}
if _, err := store.findAuthUserByEmail(ctx, email); err != nil {
password := os.Getenv("ADMIN_PASSWORD")
if password == "" {
slog.Warn("seed: no finance_users account and ADMIN_PASSWORD not set, skipping", "email", email)
} else {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
slog.Error("seed: bcrypt failed", "err", err)
return
}
name := os.Getenv("ADMIN_NAME")
if name == "" {
name = "Admin"
}
u := &AuthUser{
Email: email,
Name: name,
PasswordHash: string(hash),
CreatedAt: time.Now(),
}
if err := store.createAuthUser(ctx, u); err != nil {
slog.Error("seed: create admin user failed", "err", err)
return
}
slog.Info("seed: created admin user", "email", email)
}
}
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 resolves a user ID from email.
// Checks finance_users first (standalone/cloud deployment), then the shared
// "users" collection (Traefik forward-auth deployment).
func lookupUserByEmailMongo(ctx context.Context, store *Store, email string) (string, error) {
// standalone auth
var finUser struct {
ID bson.ObjectID `bson:"_id"`
}
if err := store.db.Collection("finance_users").FindOne(ctx, bson.M{"email": email}).Decode(&finUser); err == nil {
return finUser.ID.Hex(), nil
}
// legacy shared-auth fallback
var legacy struct {
ID string `bson:"_id"`
Email string `bson:"email"`
}
if err := store.db.Collection("users").FindOne(ctx, bson.M{"email": email}).Decode(&legacy); err != nil {
return "", fmt.Errorf("user %q not found in mongo: %w", email, err)
}
return legacy.ID, nil
}
// SeedExtras seeds goals, property, and loan data if not already present.
// Called independently so it runs even when transactions already exist.
func SeedExtras(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 extras: could not resolve user, skipping", "email", email, "err", err)
return
}
slog.Info("seed extras: checking for goals/property", "user_id", userID)
// goals
goals, _ := store.getGoals(ctx, userID)
if len(goals) == 0 {
slog.Info("seed: seeding goals", "user_id", userID)
if err := seedGoals(ctx, store, userID); err != nil {
slog.Error("seed: goals failed", "err", err)
}
goals, _ = store.getGoals(ctx, userID)
}
// goal-tagged transactions (tx-backed progress — idempotent)
if len(goals) > 0 {
if err := seedGoalTransactions(ctx, store, userID, goals); err != nil {
slog.Error("seed: goal transactions failed", "err", err)
}
}
// properties & loans
props, _ := store.getProperties(ctx, userID)
if len(props) == 0 {
slog.Info("seed: seeding property + loan", "user_id", userID)
if err := seedProperty(ctx, store, userID); err != nil {
slog.Error("seed: property failed", "err", err)
}
}
}
func seedGoals(ctx context.Context, store *Store, userID string) error {
now := time.Now()
goals := []*Goal{
{
ID: bson.NewObjectID().Hex(),
UserID: userID,
Name: "Emergency fund (3 months)",
Type: GoalTypeEmergency,
TargetCents: 390000, // €3,900
SavedCents: 210000, // €2,100 already set aside
Deadline: now.AddDate(1, 0, 0),
Committed: true,
CreatedAt: now.AddDate(0, -4, 0),
},
{
ID: bson.NewObjectID().Hex(),
UserID: userID,
Name: "Japan trip",
Type: GoalTypeOnce,
TargetCents: 350000, // €3,500
SavedCents: 80000, // €800 saved
Deadline: now.AddDate(1, 6, 0),
Committed: false,
CreatedAt: now.AddDate(0, -2, 0),
},
{
ID: bson.NewObjectID().Hex(),
UserID: userID,
Name: "MacBook Pro",
Type: GoalTypeOnce,
TargetCents: 250000, // €2,500
SavedCents: 40000, // €400
Deadline: now.AddDate(0, 10, 0),
Committed: false,
CreatedAt: now.AddDate(0, -1, 0),
},
{
ID: bson.NewObjectID().Hex(),
UserID: userID,
Name: "House down payment",
Type: GoalTypeDeposit,
TargetCents: 4000000, // €40,000
SavedCents: 500000, // €5,000 saved
Deadline: now.AddDate(5, 0, 0),
Committed: true,
CreatedAt: now.AddDate(0, -6, 0),
},
}
for _, g := range goals {
if err := store.createGoal(ctx, g); err != nil {
return fmt.Errorf("create goal %q: %w", g.Name, err)
}
}
return nil
}
// seedGoalTransactions back-fills goal-tagged transactions so the tx-backed
// SavedCents and waterfall reflect realistic progress. Idempotent — skips if
// any goal-tagged transactions already exist.
func seedGoalTransactions(ctx context.Context, store *Store, userID string, goals []Goal) error {
existing, _ := store.getTransactions(ctx, userID, bson.M{
"goal_id": bson.M{"$exists": true, "$ne": ""},
})
if len(existing) > 0 {
slog.Info("seed: goal transactions already present, skipping")
return nil
}
accounts, _ := store.getAccounts(ctx, userID)
savingsID := ""
checkingID := ""
for _, a := range accounts {
switch a.Type {
case "savings":
savingsID = a.ID
case "checking":
if checkingID == "" {
checkingID = a.ID
}
}
}
if savingsID == "" {
savingsID = checkingID
}
if savingsID == "" && len(accounts) > 0 {
savingsID = accounts[0].ID
}
goalsByName := make(map[string]Goal, len(goals))
for _, g := range goals {
goalsByName[g.Name] = g
}
now := time.Now()
monthStart := func(monthsAgo int, day int) time.Time {
t := time.Date(now.Year(), now.Month(), day, 0, 0, 0, 0, now.Location())
return t.AddDate(0, -monthsAgo, 0)
}
var txns []Transaction
mkTxn := func(date time.Time, desc string, cents int64, goalID string) {
txns = append(txns, Transaction{
ID: bson.NewObjectID().Hex(),
UserID: userID,
AccountID: savingsID,
Date: date,
Description: desc,
AmountCents: -cents, // outflow
Category: "Investments",
GoalID: goalID,
CreatedAt: time.Now(),
})
}
// Emergency fund (3 months) — 7×€300 past months + €300 this month = €2,400
if g, ok := goalsByName["Emergency fund (3 months)"]; ok {
for i := 7; i >= 1; i-- {
mkTxn(monthStart(i, 5), "Emergency fund transfer", 30000, g.ID)
}
mkTxn(monthStart(0, 5), "Emergency fund transfer", 30000, g.ID)
}
// House down payment — 10×€500 past months + €500 this month = €5,500
if g, ok := goalsByName["House down payment"]; ok {
for i := 10; i >= 1; i-- {
mkTxn(monthStart(i, 10), "House down payment savings", 50000, g.ID)
}
mkTxn(monthStart(0, 10), "House down payment savings", 50000, g.ID)
}
// Japan trip — 4×€200 past months + €200 this month = €1,000
if g, ok := goalsByName["Japan trip"]; ok {
for i := 4; i >= 1; i-- {
mkTxn(monthStart(i, 15), "Japan trip savings", 20000, g.ID)
}
mkTxn(monthStart(0, 15), "Japan trip savings", 20000, g.ID)
}
// MacBook Pro — 2×€200 past months + €200 this month = €600
if g, ok := goalsByName["MacBook Pro"]; ok {
for i := 2; i >= 1; i-- {
mkTxn(monthStart(i, 20), "MacBook Pro savings", 20000, g.ID)
}
mkTxn(monthStart(0, 20), "MacBook Pro savings", 20000, g.ID)
}
if len(txns) == 0 {
return nil
}
slog.Info("seed: inserting goal-tagged transactions", "count", len(txns))
return store.createTransactions(ctx, txns)
}
func seedProperty(ctx context.Context, store *Store, userID string) error {
now := time.Now()
propID := bson.NewObjectID().Hex()
loanID := bson.NewObjectID().Hex()
prop := &Property{
ID: propID,
UserID: userID,
Name: "Apartamento T2 — Porto",
Address: "Rua de Santa Catarina, Porto",
PurchasePriceCents: 18000000, // €180,000
CurrentValueCents: 22000000, // €220,000
AppreciationPct: 3.0,
PurchaseDate: now.AddDate(-4, 0, 0),
Status: PropertyOwned,
CreatedAt: now.AddDate(-4, 0, 0),
}
if err := store.createProperty(ctx, prop); err != nil {
return fmt.Errorf("create property: %w", err)
}
// 25-year mortgage, started 4 years ago, ~€900/month at 3.5%
loan := &Loan{
ID: loanID,
UserID: userID,
PropertyID: propID,
Name: "Hipoteca CGD — Porto",
Type: LoanMortgage,
PrincipalCents: 18000000,
BalanceCents: 16068700, // after 48 payments
InterestRatePct: 3.5,
TermMonths: 300, // 25 years
StartDate: now.AddDate(-4, 0, 0),
MonthlyPaymentCents: 90070, // €900.70 EMI
Status: LoanActive,
CreatedAt: now.AddDate(-4, 0, 0),
}
return store.createLoan(ctx, loan)
}
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
}