Gonçalo Rodrigues be0c2bd89e feat: phase 2 — goals explore mode
Adds a Goals page where users can plan financial goals before committing
to them. Each goal shows:

- Required monthly contribution to hit the deadline
- Months remaining vs months at current savings rate
- Disposable income impact (what's left after the contribution)
- Feasibility banner (green if on track, red with month delta if not)
- Progress bar once savings are tracked

Goal types: one-off purchase, deposit/down-payment, emergency fund,
recurring investment — each with a description hint in the creation modal.

Data: Goal model + store CRUD in finance_goals collection.
Nav: Goals tab added between Portfolio and Sharing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:20:46 +01:00

347 lines
9.9 KiB
Go

package main
import (
"context"
"fmt"
"homelab/pkg/mongo"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
mgmongo "go.mongodb.org/mongo-driver/v2/mongo"
)
type Store struct {
db *mongo.DB
}
func NewStore(db *mongo.DB) *Store {
return &Store{db: db}
}
func (s *Store) accounts() *mgmongo.Collection {
return s.db.Collection("finance_accounts")
}
func (s *Store) categories() *mgmongo.Collection {
return s.db.Collection("finance_categories")
}
func (s *Store) transactions() *mgmongo.Collection {
return s.db.Collection("finance_transactions")
}
func (s *Store) trades() *mgmongo.Collection {
return s.db.Collection("finance_trades")
}
func (s *Store) permissions() *mgmongo.Collection {
return s.db.Collection("finance_permissions")
}
func (s *Store) getAccounts(ctx context.Context, userID string) ([]Account, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getAccounts")
defer span.End()
cur, err := s.accounts().Find(ctx, bson.M{"user_id": userID})
if err != nil {
return nil, fmt.Errorf("find accounts: %w", err)
}
defer cur.Close(ctx)
var accounts []Account
if err := cur.All(ctx, &accounts); err != nil {
return nil, fmt.Errorf("decode accounts: %w", err)
}
return accounts, nil
}
func (s *Store) getAccount(ctx context.Context, id string) (*Account, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getAccount")
defer span.End()
var a Account
if err := s.accounts().FindOne(ctx, bson.M{"_id": id}).Decode(&a); err != nil {
return nil, fmt.Errorf("find account: %w", err)
}
return &a, nil
}
func (s *Store) createAccount(ctx context.Context, a *Account) error {
ctx, span := mongo.StartSpan(ctx, "Store.createAccount")
defer span.End()
_, err := s.accounts().InsertOne(ctx, a)
return err
}
func (s *Store) deleteAccount(ctx context.Context, id, userID string) error {
ctx, span := mongo.StartSpan(ctx, "Store.deleteAccount")
defer span.End()
_, err := s.accounts().DeleteOne(ctx, bson.M{"_id": id, "user_id": userID})
return err
}
func (s *Store) getCategories(ctx context.Context, userID string) ([]Category, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getCategories")
defer span.End()
cur, err := s.categories().Find(ctx, bson.M{"user_id": userID})
if err != nil {
return nil, fmt.Errorf("find categories: %w", err)
}
defer cur.Close(ctx)
var cats []Category
if err := cur.All(ctx, &cats); err != nil {
return nil, fmt.Errorf("decode categories: %w", err)
}
return cats, nil
}
func (s *Store) createCategory(ctx context.Context, c *Category) error {
ctx, span := mongo.StartSpan(ctx, "Store.createCategory")
defer span.End()
_, err := s.categories().InsertOne(ctx, c)
return err
}
func (s *Store) updateCategory(ctx context.Context, c *Category) error {
ctx, span := mongo.StartSpan(ctx, "Store.updateCategory")
defer span.End()
_, err := s.categories().UpdateOne(ctx, bson.M{"_id": c.ID, "user_id": c.UserID}, bson.M{"$set": bson.M{
"name": c.Name,
"color": c.Color,
"budget_cents": c.BudgetCents,
}})
return err
}
func (s *Store) deleteCategory(ctx context.Context, id, userID string) error {
ctx, span := mongo.StartSpan(ctx, "Store.deleteCategory")
defer span.End()
_, err := s.categories().DeleteOne(ctx, bson.M{"_id": id, "user_id": userID})
return err
}
func (s *Store) getTransactions(ctx context.Context, userID string, filter bson.M) ([]Transaction, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getTransactions")
defer span.End()
q := bson.M{"user_id": userID}
for k, v := range filter {
q[k] = v
}
opts := options.Find().SetSort(bson.M{"date": -1})
cur, err := s.transactions().Find(ctx, q, opts)
if err != nil {
return nil, fmt.Errorf("find transactions: %w", err)
}
defer cur.Close(ctx)
var txns []Transaction
if err := cur.All(ctx, &txns); err != nil {
return nil, fmt.Errorf("decode transactions: %w", err)
}
return txns, nil
}
func (s *Store) getTransaction(ctx context.Context, id, userID string) (*Transaction, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getTransaction")
defer span.End()
var t Transaction
if err := s.transactions().FindOne(ctx, bson.M{"_id": id, "user_id": userID}).Decode(&t); err != nil {
return nil, fmt.Errorf("find transaction: %w", err)
}
return &t, nil
}
func (s *Store) createTransactions(ctx context.Context, txns []Transaction) error {
ctx, span := mongo.StartSpan(ctx, "Store.createTransactions")
defer span.End()
docs := make([]interface{}, len(txns))
for i := range txns {
docs[i] = txns[i]
}
_, err := s.transactions().InsertMany(ctx, docs)
return err
}
func (s *Store) updateTransaction(ctx context.Context, id, userID string, update bson.M) error {
ctx, span := mongo.StartSpan(ctx, "Store.updateTransaction")
defer span.End()
_, err := s.transactions().UpdateOne(ctx, bson.M{"_id": id, "user_id": userID}, bson.M{"$set": update})
return err
}
func (s *Store) deleteTransaction(ctx context.Context, id, userID string) error {
ctx, span := mongo.StartSpan(ctx, "Store.deleteTransaction")
defer span.End()
_, err := s.transactions().DeleteOne(ctx, bson.M{"_id": id, "user_id": userID})
return err
}
func (s *Store) aggregateTransactions(ctx context.Context, userID string, pipeline bson.A) ([]bson.M, error) {
ctx, span := mongo.StartSpan(ctx, "Store.aggregateTransactions")
defer span.End()
pipeline = append(bson.A{bson.M{"$match": bson.M{"user_id": userID}}}, pipeline...)
cur, err := s.transactions().Aggregate(ctx, pipeline)
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var results []bson.M
if err := cur.All(ctx, &results); err != nil {
return nil, err
}
return results, nil
}
func (s *Store) getTrades(ctx context.Context, userID string) ([]Trade, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getTrades")
defer span.End()
opts := options.Find().SetSort(bson.M{"date": 1})
cur, err := s.trades().Find(ctx, bson.M{"user_id": userID}, opts)
if err != nil {
return nil, fmt.Errorf("find trades: %w", err)
}
defer cur.Close(ctx)
var trades []Trade
if err := cur.All(ctx, &trades); err != nil {
return nil, fmt.Errorf("decode trades: %w", err)
}
return trades, nil
}
func (s *Store) createTrades(ctx context.Context, trades []Trade) error {
ctx, span := mongo.StartSpan(ctx, "Store.createTrades")
defer span.End()
docs := make([]interface{}, len(trades))
for i := range trades {
docs[i] = trades[i]
}
_, err := s.trades().InsertMany(ctx, docs)
return err
}
func (s *Store) deleteTrade(ctx context.Context, id, userID string) error {
ctx, span := mongo.StartSpan(ctx, "Store.deleteTrade")
defer span.End()
_, err := s.trades().DeleteOne(ctx, bson.M{"_id": id, "user_id": userID})
return err
}
func (s *Store) getPermissions(ctx context.Context, ownerID string) ([]Permission, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getPermissions")
defer span.End()
cur, err := s.permissions().Find(ctx, bson.M{"owner_id": ownerID})
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var perms []Permission
if err := cur.All(ctx, &perms); err != nil {
return nil, err
}
return perms, nil
}
func (s *Store) getGrantedViewers(ctx context.Context, viewerID string) ([]Permission, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getGrantedViewers")
defer span.End()
cur, err := s.permissions().Find(ctx, bson.M{"viewer_id": viewerID})
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var perms []Permission
if err := cur.All(ctx, &perms); err != nil {
return nil, err
}
return perms, nil
}
func (s *Store) createPermission(ctx context.Context, p *Permission) error {
ctx, span := mongo.StartSpan(ctx, "Store.createPermission")
defer span.End()
_, err := s.permissions().InsertOne(ctx, p)
return err
}
func (s *Store) goals() *mgmongo.Collection {
return s.db.Collection("finance_goals")
}
func (s *Store) getGoals(ctx context.Context, userID string) ([]Goal, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getGoals")
defer span.End()
opts := options.Find().SetSort(bson.M{"created_at": 1})
cur, err := s.goals().Find(ctx, bson.M{"user_id": userID}, opts)
if err != nil {
return nil, fmt.Errorf("find goals: %w", err)
}
defer cur.Close(ctx)
var goals []Goal
if err := cur.All(ctx, &goals); err != nil {
return nil, fmt.Errorf("decode goals: %w", err)
}
return goals, nil
}
func (s *Store) createGoal(ctx context.Context, g *Goal) error {
ctx, span := mongo.StartSpan(ctx, "Store.createGoal")
defer span.End()
_, err := s.goals().InsertOne(ctx, g)
return err
}
func (s *Store) deleteGoal(ctx context.Context, id, userID string) error {
ctx, span := mongo.StartSpan(ctx, "Store.deleteGoal")
defer span.End()
_, err := s.goals().DeleteOne(ctx, bson.M{"_id": id, "user_id": userID})
return err
}
func (s *Store) deletePermission(ctx context.Context, ownerID, viewerID string) error {
ctx, span := mongo.StartSpan(ctx, "Store.deletePermission")
defer span.End()
_, err := s.permissions().DeleteOne(ctx, bson.M{"owner_id": ownerID, "viewer_id": viewerID})
return err
}
var defaultCategories = []struct {
Name string
Color string
}{
{"Food", "#FF6384"},
{"Groceries", "#36A2EB"},
{"Games", "#FFCE56"},
{"Clothing", "#4BC0C0"},
{"Housing", "#9966FF"},
{"Transport", "#FF9F40"},
{"Health", "#C9CBCF"},
{"Income", "#00E676"},
{"Savings", "#651FFF"},
{"Investments", "#FF6F00"},
{"Entertainment", "#E91E63"},
{"Utilities", "#607D8B"},
{"Education", "#3F51B5"},
{"Other", "#9E9E9E"},
}
func (s *Store) seedCategories(ctx context.Context, userID string) error {
ctx, span := mongo.StartSpan(ctx, "Store.seedCategories")
defer span.End()
count, err := s.categories().CountDocuments(ctx, bson.M{"user_id": userID})
if err != nil {
return err
}
if count > 0 {
return nil
}
for _, dc := range defaultCategories {
cat := &Category{
ID: bson.NewObjectID().Hex(),
UserID: userID,
Name: dc.Name,
Color: dc.Color,
}
if _, err := s.categories().InsertOne(ctx, cat); err != nil {
return fmt.Errorf("seed category %s: %w", dc.Name, err)
}
}
return nil
}