diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index a513186..6eeba21 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -385,13 +385,14 @@ func (h *Handlers) CreateBudgetItem(w http.ResponseWriter, r *http.Request) { BudgetedAmount decimal.Decimal `json:"budgeted_amount"` ActualAmount decimal.Decimal `json:"actual_amount"` Notes string `json:"notes"` + ItemTier models.ItemTier `json:"item_tier"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } - item, err := h.queries.CreateBudgetItem(r.Context(), budgetID, req.CategoryID, req.BudgetedAmount, req.ActualAmount, req.Notes) + item, err := h.queries.CreateBudgetItem(r.Context(), budgetID, req.CategoryID, req.BudgetedAmount, req.ActualAmount, req.Notes, req.ItemTier) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create budget item") return @@ -415,13 +416,14 @@ func (h *Handlers) UpdateBudgetItem(w http.ResponseWriter, r *http.Request) { BudgetedAmount decimal.Decimal `json:"budgeted_amount"` ActualAmount decimal.Decimal `json:"actual_amount"` Notes string `json:"notes"` + ItemTier models.ItemTier `json:"item_tier"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } - item, err := h.queries.UpdateBudgetItem(r.Context(), itemID, budgetID, req.BudgetedAmount, req.ActualAmount, req.Notes) + item, err := h.queries.UpdateBudgetItem(r.Context(), itemID, budgetID, req.BudgetedAmount, req.ActualAmount, req.Notes, req.ItemTier) if err != nil { writeError(w, http.StatusNotFound, "budget item not found") return diff --git a/backend/internal/db/queries.go b/backend/internal/db/queries.go index b4f78d4..2ebeaa1 100644 --- a/backend/internal/db/queries.go +++ b/backend/internal/db/queries.go @@ -6,11 +6,24 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/shopspring/decimal" "simplefinancedash/backend/internal/models" ) +// BudgetExistsError is returned by GenerateBudgetFromTemplate when a budget already exists for the given month. +type BudgetExistsError struct { + ExistingBudgetID uuid.UUID +} + +func (e *BudgetExistsError) Error() string { + return fmt.Sprintf("budget already exists: %s", e.ExistingBudgetID) +} + +// ErrBudgetExists is a sentinel for checking if an error is a BudgetExistsError. +var ErrBudgetExists = &BudgetExistsError{} + type Queries struct { pool *pgxpool.Pool } @@ -235,7 +248,7 @@ func (q *Queries) GetBudgetWithItems(ctx context.Context, id, userID uuid.UUID) } rows, err := q.pool.Query(ctx, - `SELECT bi.id, bi.budget_id, bi.category_id, c.name, c.type, + `SELECT bi.id, bi.budget_id, bi.category_id, c.name, c.type, bi.item_tier, bi.budgeted_amount, bi.actual_amount, bi.notes, bi.created_at, bi.updated_at FROM budget_items bi JOIN categories c ON c.id = bi.category_id @@ -250,7 +263,7 @@ func (q *Queries) GetBudgetWithItems(ctx context.Context, id, userID uuid.UUID) var items []models.BudgetItem for rows.Next() { var i models.BudgetItem - if err := rows.Scan(&i.ID, &i.BudgetID, &i.CategoryID, &i.CategoryName, &i.CategoryType, + if err := rows.Scan(&i.ID, &i.BudgetID, &i.CategoryID, &i.CategoryName, &i.CategoryType, &i.ItemTier, &i.BudgetedAmount, &i.ActualAmount, &i.Notes, &i.CreatedAt, &i.UpdatedAt); err != nil { return nil, fmt.Errorf("scanning budget item: %w", err) } @@ -311,8 +324,8 @@ func (q *Queries) CopyBudgetItems(ctx context.Context, targetBudgetID, sourceBud } _, err := q.pool.Exec(ctx, - `INSERT INTO budget_items (budget_id, category_id, budgeted_amount, actual_amount, notes) - SELECT $1, category_id, budgeted_amount, 0, '' + `INSERT INTO budget_items (budget_id, category_id, item_tier, budgeted_amount, actual_amount, notes) + SELECT $1, category_id, item_tier, budgeted_amount, 0, '' FROM budget_items WHERE budget_id = $2`, targetBudgetID, sourceBudgetID, ) @@ -321,28 +334,31 @@ func (q *Queries) CopyBudgetItems(ctx context.Context, targetBudgetID, sourceBud // Budget Items -func (q *Queries) CreateBudgetItem(ctx context.Context, budgetID, categoryID uuid.UUID, budgeted, actual decimal.Decimal, notes string) (*models.BudgetItem, error) { +func (q *Queries) CreateBudgetItem(ctx context.Context, budgetID, categoryID uuid.UUID, budgeted, actual decimal.Decimal, notes string, itemTier models.ItemTier) (*models.BudgetItem, error) { + if itemTier == "" { + itemTier = models.ItemTierOneOff + } i := &models.BudgetItem{} err := q.pool.QueryRow(ctx, - `INSERT INTO budget_items (budget_id, category_id, budgeted_amount, actual_amount, notes) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, budget_id, category_id, budgeted_amount, actual_amount, notes, created_at, updated_at`, - budgetID, categoryID, budgeted, actual, notes, - ).Scan(&i.ID, &i.BudgetID, &i.CategoryID, &i.BudgetedAmount, &i.ActualAmount, &i.Notes, &i.CreatedAt, &i.UpdatedAt) + `INSERT INTO budget_items (budget_id, category_id, item_tier, budgeted_amount, actual_amount, notes) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, budget_id, category_id, item_tier, budgeted_amount, actual_amount, notes, created_at, updated_at`, + budgetID, categoryID, itemTier, budgeted, actual, notes, + ).Scan(&i.ID, &i.BudgetID, &i.CategoryID, &i.ItemTier, &i.BudgetedAmount, &i.ActualAmount, &i.Notes, &i.CreatedAt, &i.UpdatedAt) if err != nil { return nil, fmt.Errorf("creating budget item: %w", err) } return i, nil } -func (q *Queries) UpdateBudgetItem(ctx context.Context, id, budgetID uuid.UUID, budgeted, actual decimal.Decimal, notes string) (*models.BudgetItem, error) { +func (q *Queries) UpdateBudgetItem(ctx context.Context, id, budgetID uuid.UUID, budgeted, actual decimal.Decimal, notes string, itemTier models.ItemTier) (*models.BudgetItem, error) { i := &models.BudgetItem{} err := q.pool.QueryRow(ctx, - `UPDATE budget_items SET budgeted_amount = $3, actual_amount = $4, notes = $5, updated_at = now() + `UPDATE budget_items SET budgeted_amount = $3, actual_amount = $4, notes = $5, item_tier = $6, updated_at = now() WHERE id = $1 AND budget_id = $2 - RETURNING id, budget_id, category_id, budgeted_amount, actual_amount, notes, created_at, updated_at`, - id, budgetID, budgeted, actual, notes, - ).Scan(&i.ID, &i.BudgetID, &i.CategoryID, &i.BudgetedAmount, &i.ActualAmount, &i.Notes, &i.CreatedAt, &i.UpdatedAt) + RETURNING id, budget_id, category_id, item_tier, budgeted_amount, actual_amount, notes, created_at, updated_at`, + id, budgetID, budgeted, actual, notes, itemTier, + ).Scan(&i.ID, &i.BudgetID, &i.CategoryID, &i.ItemTier, &i.BudgetedAmount, &i.ActualAmount, &i.Notes, &i.CreatedAt, &i.UpdatedAt) if err != nil { return nil, fmt.Errorf("updating budget item: %w", err) } @@ -355,3 +371,298 @@ func (q *Queries) DeleteBudgetItem(ctx context.Context, id, budgetID uuid.UUID) ) return err } + +// Templates + +func (q *Queries) GetTemplate(ctx context.Context, userID uuid.UUID) (*models.TemplateDetail, error) { + var t models.Template + err := q.pool.QueryRow(ctx, + `SELECT id, user_id, name, created_at, updated_at FROM templates WHERE user_id = $1`, userID, + ).Scan(&t.ID, &t.UserID, &t.Name, &t.CreatedAt, &t.UpdatedAt) + if err != nil { + if err == pgx.ErrNoRows { + return &models.TemplateDetail{Items: []models.TemplateItem{}}, nil + } + return nil, fmt.Errorf("getting template: %w", err) + } + + rows, err := q.pool.Query(ctx, + `SELECT ti.id, ti.template_id, ti.category_id, c.name, c.type, c.icon, + ti.item_tier, ti.budgeted_amount, ti.sort_order, ti.created_at, ti.updated_at + FROM template_items ti + JOIN categories c ON c.id = ti.category_id + WHERE ti.template_id = $1 + ORDER BY ti.sort_order`, t.ID, + ) + if err != nil { + return nil, fmt.Errorf("listing template items: %w", err) + } + defer rows.Close() + + items := []models.TemplateItem{} + for rows.Next() { + var i models.TemplateItem + if err := rows.Scan(&i.ID, &i.TemplateID, &i.CategoryID, &i.CategoryName, &i.CategoryType, &i.CategoryIcon, + &i.ItemTier, &i.BudgetedAmount, &i.SortOrder, &i.CreatedAt, &i.UpdatedAt); err != nil { + return nil, fmt.Errorf("scanning template item: %w", err) + } + items = append(items, i) + } + + return &models.TemplateDetail{Template: t, Items: items}, nil +} + +func (q *Queries) UpdateTemplateName(ctx context.Context, userID uuid.UUID, name string) (*models.Template, error) { + t := &models.Template{} + err := q.pool.QueryRow(ctx, + `UPDATE templates SET name = $2, updated_at = now() + WHERE user_id = $1 + RETURNING id, user_id, name, created_at, updated_at`, + userID, name, + ).Scan(&t.ID, &t.UserID, &t.Name, &t.CreatedAt, &t.UpdatedAt) + if err != nil { + if err == pgx.ErrNoRows { + return nil, fmt.Errorf("no template exists for user") + } + return nil, fmt.Errorf("updating template name: %w", err) + } + return t, nil +} + +func (q *Queries) CreateTemplateItem(ctx context.Context, userID, categoryID uuid.UUID, itemTier models.ItemTier, budgetedAmount *decimal.Decimal, sortOrder int) (*models.TemplateItem, error) { + // Lazy create template if it doesn't exist + var templateID uuid.UUID + err := q.pool.QueryRow(ctx, + `INSERT INTO templates (user_id) VALUES ($1) + ON CONFLICT (user_id) DO UPDATE SET updated_at = now() + RETURNING id`, + userID, + ).Scan(&templateID) + if err != nil { + return nil, fmt.Errorf("ensuring template exists: %w", err) + } + + i := &models.TemplateItem{} + err = q.pool.QueryRow(ctx, + `INSERT INTO template_items (template_id, category_id, item_tier, budgeted_amount, sort_order) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, template_id, category_id, item_tier, budgeted_amount, sort_order, created_at, updated_at`, + templateID, categoryID, itemTier, budgetedAmount, sortOrder, + ).Scan(&i.ID, &i.TemplateID, &i.CategoryID, &i.ItemTier, &i.BudgetedAmount, &i.SortOrder, &i.CreatedAt, &i.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("creating template item: %w", err) + } + + // Fetch category details + err = q.pool.QueryRow(ctx, + `SELECT name, type, icon FROM categories WHERE id = $1`, categoryID, + ).Scan(&i.CategoryName, &i.CategoryType, &i.CategoryIcon) + if err != nil { + return nil, fmt.Errorf("fetching category for template item: %w", err) + } + + return i, nil +} + +func (q *Queries) UpdateTemplateItem(ctx context.Context, userID, itemID uuid.UUID, itemTier models.ItemTier, budgetedAmount *decimal.Decimal, sortOrder int) (*models.TemplateItem, error) { + i := &models.TemplateItem{} + err := q.pool.QueryRow(ctx, + `UPDATE template_items SET item_tier = $3, budgeted_amount = $4, sort_order = $5, updated_at = now() + WHERE id = $1 AND template_id = (SELECT id FROM templates WHERE user_id = $2) + RETURNING id, template_id, category_id, item_tier, budgeted_amount, sort_order, created_at, updated_at`, + itemID, userID, itemTier, budgetedAmount, sortOrder, + ).Scan(&i.ID, &i.TemplateID, &i.CategoryID, &i.ItemTier, &i.BudgetedAmount, &i.SortOrder, &i.CreatedAt, &i.UpdatedAt) + if err != nil { + if err == pgx.ErrNoRows { + return nil, fmt.Errorf("template item not found") + } + return nil, fmt.Errorf("updating template item: %w", err) + } + + // Fetch category details + err = q.pool.QueryRow(ctx, + `SELECT name, type, icon FROM categories WHERE id = $1`, i.CategoryID, + ).Scan(&i.CategoryName, &i.CategoryType, &i.CategoryIcon) + if err != nil { + return nil, fmt.Errorf("fetching category for template item: %w", err) + } + + return i, nil +} + +func (q *Queries) DeleteTemplateItem(ctx context.Context, userID, itemID uuid.UUID) error { + _, err := q.pool.Exec(ctx, + `DELETE FROM template_items WHERE id = $1 AND template_id = (SELECT id FROM templates WHERE user_id = $2)`, + itemID, userID, + ) + return err +} + +func (q *Queries) ReorderTemplateItems(ctx context.Context, userID uuid.UUID, itemOrders []struct { + ID uuid.UUID + SortOrder int +}) error { + tx, err := q.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("beginning transaction: %w", err) + } + defer tx.Rollback(ctx) + + for _, order := range itemOrders { + result, err := tx.Exec(ctx, + `UPDATE template_items SET sort_order = $3, updated_at = now() + WHERE id = $1 AND template_id = (SELECT id FROM templates WHERE user_id = $2)`, + order.ID, userID, order.SortOrder, + ) + if err != nil { + return fmt.Errorf("reordering template item %s: %w", order.ID, err) + } + if result.RowsAffected() == 0 { + return fmt.Errorf("template item %s not found or not owned by user", order.ID) + } + } + + return tx.Commit(ctx) +} + +var monthNamesEN = map[time.Month]string{ + time.January: "January", + time.February: "February", + time.March: "March", + time.April: "April", + time.May: "May", + time.June: "June", + time.July: "July", + time.August: "August", + time.September: "September", + time.October: "October", + time.November: "November", + time.December: "December", +} + +var monthNamesDE = map[time.Month]string{ + time.January: "Januar", + time.February: "Februar", + time.March: "März", + time.April: "April", + time.May: "Mai", + time.June: "Juni", + time.July: "Juli", + time.August: "August", + time.September: "September", + time.October: "Oktober", + time.November: "November", + time.December: "Dezember", +} + +func (q *Queries) GenerateBudgetFromTemplate(ctx context.Context, userID uuid.UUID, month string, currency string) (*models.BudgetDetail, error) { + // Parse "2026-04" into a time + parsed, err := time.Parse("2006-01", month) + if err != nil { + return nil, fmt.Errorf("invalid month format (expected YYYY-MM): %w", err) + } + + startDate := time.Date(parsed.Year(), parsed.Month(), 1, 0, 0, 0, 0, time.UTC) + // Last day of month: first day of next month minus 1 day + endDate := startDate.AddDate(0, 1, 0).Add(-24 * time.Hour) + + tx, err := q.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("beginning transaction: %w", err) + } + defer tx.Rollback(ctx) + + // Check if budget already exists for this month + var existingBudgetID uuid.UUID + err = tx.QueryRow(ctx, + `SELECT id FROM budgets WHERE user_id = $1 AND start_date <= $3 AND end_date >= $2`, + userID, startDate, endDate, + ).Scan(&existingBudgetID) + if err == nil { + return nil, &BudgetExistsError{ExistingBudgetID: existingBudgetID} + } + if err != pgx.ErrNoRows { + return nil, fmt.Errorf("checking existing budget: %w", err) + } + + // Get user's preferred locale + var preferredLocale string + err = tx.QueryRow(ctx, `SELECT preferred_locale FROM users WHERE id = $1`, userID).Scan(&preferredLocale) + if err != nil { + return nil, fmt.Errorf("getting user locale: %w", err) + } + + // Build budget name + var monthName string + if preferredLocale == "de" { + monthName = monthNamesDE[parsed.Month()] + } else { + monthName = monthNamesEN[parsed.Month()] + } + budgetName := fmt.Sprintf("%s %d", monthName, parsed.Year()) + + // Create budget + var budget models.Budget + err = tx.QueryRow(ctx, + `INSERT INTO budgets (user_id, name, start_date, end_date, currency, carryover_amount) + VALUES ($1, $2, $3, $4, $5, 0) + RETURNING id, user_id, name, start_date, end_date, currency, carryover_amount, created_at, updated_at`, + userID, budgetName, startDate, endDate, currency, + ).Scan(&budget.ID, &budget.UserID, &budget.Name, &budget.StartDate, &budget.EndDate, + &budget.Currency, &budget.CarryoverAmount, &budget.CreatedAt, &budget.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("creating budget: %w", err) + } + + // Get template items + rows, err := tx.Query(ctx, + `SELECT ti.category_id, ti.item_tier, ti.budgeted_amount + FROM template_items ti + JOIN templates t ON t.id = ti.template_id + WHERE t.user_id = $1 + ORDER BY ti.sort_order`, + userID, + ) + if err != nil { + return nil, fmt.Errorf("fetching template items: %w", err) + } + defer rows.Close() + + type templateRow struct { + categoryID uuid.UUID + itemTier models.ItemTier + budgetedAmount *decimal.Decimal + } + var templateRows []templateRow + for rows.Next() { + var tr templateRow + if err := rows.Scan(&tr.categoryID, &tr.itemTier, &tr.budgetedAmount); err != nil { + return nil, fmt.Errorf("scanning template item: %w", err) + } + templateRows = append(templateRows, tr) + } + rows.Close() + + // Insert budget items from template + for _, tr := range templateRows { + var budgetedAmt decimal.Decimal + if tr.itemTier == models.ItemTierFixed && tr.budgetedAmount != nil { + budgetedAmt = *tr.budgetedAmount + } + _, err := tx.Exec(ctx, + `INSERT INTO budget_items (budget_id, category_id, item_tier, budgeted_amount, actual_amount, notes) + VALUES ($1, $2, $3, $4, 0, '')`, + budget.ID, tr.categoryID, tr.itemTier, budgetedAmt, + ) + if err != nil { + return nil, fmt.Errorf("inserting budget item from template: %w", err) + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("committing transaction: %w", err) + } + + // Return full BudgetDetail + return q.GetBudgetWithItems(ctx, budget.ID, userID) +}