feat(05-01): template query functions and updated budget item queries with item_tier

- Add BudgetExistsError struct and ErrBudgetExists sentinel
- Update GetBudgetWithItems, CopyBudgetItems to include item_tier
- Update CreateBudgetItem/UpdateBudgetItem signatures to accept itemTier (default one_off)
- Add GetTemplate, UpdateTemplateName, CreateTemplateItem, UpdateTemplateItem, DeleteTemplateItem
- Add ReorderTemplateItems with transaction
- Add GenerateBudgetFromTemplate with duplicate-month detection and locale-aware naming
- Update handlers to pass ItemTier from request body (Rule 3 fix - blocking compile)
This commit is contained in:
2026-03-12 12:06:43 +01:00
parent b3082ca14f
commit f9dd40984c
2 changed files with 330 additions and 17 deletions

View File

@@ -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)
}