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>
572 lines
21 KiB
Go
572 lines
21 KiB
Go
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
|
||
}
|