- Create 003_quick_add_library.sql with quick_add_items table and user index - Add QuickAddItem struct to models.go following Category pattern - Add ListQuickAddItems, CreateQuickAddItem, UpdateQuickAddItem, DeleteQuickAddItem to queries.go
730 lines
26 KiB
Go
730 lines
26 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"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
|
|
}
|
|
|
|
func NewQueries(pool *pgxpool.Pool) *Queries {
|
|
return &Queries{pool: pool}
|
|
}
|
|
|
|
// Users
|
|
|
|
func (q *Queries) CreateUser(ctx context.Context, email, passwordHash, displayName, locale string) (*models.User, error) {
|
|
u := &models.User{}
|
|
err := q.pool.QueryRow(ctx,
|
|
`INSERT INTO users (email, password_hash, display_name, preferred_locale)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, email, password_hash, oidc_subject, display_name, preferred_locale, created_at, updated_at`,
|
|
email, passwordHash, displayName, locale,
|
|
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.DisplayName, &u.PreferredLocale, &u.CreatedAt, &u.UpdatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating user: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
|
|
u := &models.User{}
|
|
err := q.pool.QueryRow(ctx,
|
|
`SELECT id, email, password_hash, oidc_subject, display_name, preferred_locale, created_at, updated_at
|
|
FROM users WHERE email = $1`, email,
|
|
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.DisplayName, &u.PreferredLocale, &u.CreatedAt, &u.UpdatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting user by email: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (*models.User, error) {
|
|
u := &models.User{}
|
|
err := q.pool.QueryRow(ctx,
|
|
`SELECT id, email, password_hash, oidc_subject, display_name, preferred_locale, created_at, updated_at
|
|
FROM users WHERE id = $1`, id,
|
|
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.DisplayName, &u.PreferredLocale, &u.CreatedAt, &u.UpdatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting user by id: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func (q *Queries) GetUserByOIDCSubject(ctx context.Context, subject string) (*models.User, error) {
|
|
u := &models.User{}
|
|
err := q.pool.QueryRow(ctx,
|
|
`SELECT id, email, password_hash, oidc_subject, display_name, preferred_locale, created_at, updated_at
|
|
FROM users WHERE oidc_subject = $1`, subject,
|
|
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.DisplayName, &u.PreferredLocale, &u.CreatedAt, &u.UpdatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting user by oidc subject: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func (q *Queries) UpdateUser(ctx context.Context, id uuid.UUID, displayName, locale string) (*models.User, error) {
|
|
u := &models.User{}
|
|
err := q.pool.QueryRow(ctx,
|
|
`UPDATE users SET display_name = $2, preferred_locale = $3, updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING id, email, password_hash, oidc_subject, display_name, preferred_locale, created_at, updated_at`,
|
|
id, displayName, locale,
|
|
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.DisplayName, &u.PreferredLocale, &u.CreatedAt, &u.UpdatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("updating user: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func (q *Queries) UpsertOIDCUser(ctx context.Context, email, subject, displayName string) (*models.User, error) {
|
|
u := &models.User{}
|
|
err := q.pool.QueryRow(ctx,
|
|
`INSERT INTO users (email, oidc_subject, display_name)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (oidc_subject) DO UPDATE SET email = $1, display_name = $3, updated_at = now()
|
|
RETURNING id, email, password_hash, oidc_subject, display_name, preferred_locale, created_at, updated_at`,
|
|
email, subject, displayName,
|
|
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.DisplayName, &u.PreferredLocale, &u.CreatedAt, &u.UpdatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("upserting oidc user: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// Categories
|
|
|
|
func (q *Queries) ListCategories(ctx context.Context, userID uuid.UUID) ([]models.Category, error) {
|
|
rows, err := q.pool.Query(ctx,
|
|
`SELECT id, user_id, name, type, icon, sort_order, created_at, updated_at
|
|
FROM categories WHERE user_id = $1 ORDER BY type, sort_order, name`, userID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing categories: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var cats []models.Category
|
|
for rows.Next() {
|
|
var c models.Category
|
|
if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.Type, &c.Icon, &c.SortOrder, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("scanning category: %w", err)
|
|
}
|
|
cats = append(cats, c)
|
|
}
|
|
return cats, nil
|
|
}
|
|
|
|
func (q *Queries) CreateCategory(ctx context.Context, userID uuid.UUID, name string, catType models.CategoryType, icon string, sortOrder int) (*models.Category, error) {
|
|
c := &models.Category{}
|
|
err := q.pool.QueryRow(ctx,
|
|
`INSERT INTO categories (user_id, name, type, icon, sort_order)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id, user_id, name, type, icon, sort_order, created_at, updated_at`,
|
|
userID, name, catType, icon, sortOrder,
|
|
).Scan(&c.ID, &c.UserID, &c.Name, &c.Type, &c.Icon, &c.SortOrder, &c.CreatedAt, &c.UpdatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating category: %w", err)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func (q *Queries) UpdateCategory(ctx context.Context, id, userID uuid.UUID, name string, catType models.CategoryType, icon string, sortOrder int) (*models.Category, error) {
|
|
c := &models.Category{}
|
|
err := q.pool.QueryRow(ctx,
|
|
`UPDATE categories SET name = $3, type = $4, icon = $5, sort_order = $6, updated_at = now()
|
|
WHERE id = $1 AND user_id = $2
|
|
RETURNING id, user_id, name, type, icon, sort_order, created_at, updated_at`,
|
|
id, userID, name, catType, icon, sortOrder,
|
|
).Scan(&c.ID, &c.UserID, &c.Name, &c.Type, &c.Icon, &c.SortOrder, &c.CreatedAt, &c.UpdatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("updating category: %w", err)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func (q *Queries) DeleteCategory(ctx context.Context, id, userID uuid.UUID) error {
|
|
_, err := q.pool.Exec(ctx,
|
|
`DELETE FROM categories WHERE id = $1 AND user_id = $2`, id, userID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// Budgets
|
|
|
|
func (q *Queries) ListBudgets(ctx context.Context, userID uuid.UUID) ([]models.Budget, error) {
|
|
rows, err := q.pool.Query(ctx,
|
|
`SELECT id, user_id, name, start_date, end_date, currency, carryover_amount, created_at, updated_at
|
|
FROM budgets WHERE user_id = $1 ORDER BY start_date DESC`, userID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing budgets: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var budgets []models.Budget
|
|
for rows.Next() {
|
|
var b models.Budget
|
|
if err := rows.Scan(&b.ID, &b.UserID, &b.Name, &b.StartDate, &b.EndDate, &b.Currency, &b.CarryoverAmount, &b.CreatedAt, &b.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("scanning budget: %w", err)
|
|
}
|
|
budgets = append(budgets, b)
|
|
}
|
|
return budgets, nil
|
|
}
|
|
|
|
func (q *Queries) CreateBudget(ctx context.Context, userID uuid.UUID, name string, startDate, endDate time.Time, currency string, carryover decimal.Decimal) (*models.Budget, error) {
|
|
b := &models.Budget{}
|
|
err := q.pool.QueryRow(ctx,
|
|
`INSERT INTO budgets (user_id, name, start_date, end_date, currency, carryover_amount)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id, user_id, name, start_date, end_date, currency, carryover_amount, created_at, updated_at`,
|
|
userID, name, startDate, endDate, currency, carryover,
|
|
).Scan(&b.ID, &b.UserID, &b.Name, &b.StartDate, &b.EndDate, &b.Currency, &b.CarryoverAmount, &b.CreatedAt, &b.UpdatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating budget: %w", err)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func (q *Queries) GetBudget(ctx context.Context, id, userID uuid.UUID) (*models.Budget, error) {
|
|
b := &models.Budget{}
|
|
err := q.pool.QueryRow(ctx,
|
|
`SELECT id, user_id, name, start_date, end_date, currency, carryover_amount, created_at, updated_at
|
|
FROM budgets WHERE id = $1 AND user_id = $2`, id, userID,
|
|
).Scan(&b.ID, &b.UserID, &b.Name, &b.StartDate, &b.EndDate, &b.Currency, &b.CarryoverAmount, &b.CreatedAt, &b.UpdatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting budget: %w", err)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func (q *Queries) UpdateBudget(ctx context.Context, id, userID uuid.UUID, name string, startDate, endDate time.Time, currency string, carryover decimal.Decimal) (*models.Budget, error) {
|
|
b := &models.Budget{}
|
|
err := q.pool.QueryRow(ctx,
|
|
`UPDATE budgets SET name = $3, start_date = $4, end_date = $5, currency = $6, carryover_amount = $7, updated_at = now()
|
|
WHERE id = $1 AND user_id = $2
|
|
RETURNING id, user_id, name, start_date, end_date, currency, carryover_amount, created_at, updated_at`,
|
|
id, userID, name, startDate, endDate, currency, carryover,
|
|
).Scan(&b.ID, &b.UserID, &b.Name, &b.StartDate, &b.EndDate, &b.Currency, &b.CarryoverAmount, &b.CreatedAt, &b.UpdatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("updating budget: %w", err)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func (q *Queries) DeleteBudget(ctx context.Context, id, userID uuid.UUID) error {
|
|
_, err := q.pool.Exec(ctx,
|
|
`DELETE FROM budgets WHERE id = $1 AND user_id = $2`, id, userID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (q *Queries) GetBudgetWithItems(ctx context.Context, id, userID uuid.UUID) (*models.BudgetDetail, error) {
|
|
budget, err := q.GetBudget(ctx, id, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rows, err := q.pool.Query(ctx,
|
|
`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
|
|
WHERE bi.budget_id = $1
|
|
ORDER BY c.type, c.sort_order, c.name`, id,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing budget items: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
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, &i.ItemTier,
|
|
&i.BudgetedAmount, &i.ActualAmount, &i.Notes, &i.CreatedAt, &i.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("scanning budget item: %w", err)
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
|
|
totals := computeTotals(budget.CarryoverAmount, items)
|
|
|
|
return &models.BudgetDetail{
|
|
Budget: *budget,
|
|
Items: items,
|
|
Totals: totals,
|
|
}, nil
|
|
}
|
|
|
|
func computeTotals(carryover decimal.Decimal, items []models.BudgetItem) models.BudgetTotals {
|
|
var t models.BudgetTotals
|
|
for _, item := range items {
|
|
switch item.CategoryType {
|
|
case models.CategoryIncome:
|
|
t.IncomeBudget = t.IncomeBudget.Add(item.BudgetedAmount)
|
|
t.IncomeActual = t.IncomeActual.Add(item.ActualAmount)
|
|
case models.CategoryBill:
|
|
t.BillsBudget = t.BillsBudget.Add(item.BudgetedAmount)
|
|
t.BillsActual = t.BillsActual.Add(item.ActualAmount)
|
|
case models.CategoryVariableExpense:
|
|
t.ExpensesBudget = t.ExpensesBudget.Add(item.BudgetedAmount)
|
|
t.ExpensesActual = t.ExpensesActual.Add(item.ActualAmount)
|
|
case models.CategoryDebt:
|
|
t.DebtsBudget = t.DebtsBudget.Add(item.BudgetedAmount)
|
|
t.DebtsActual = t.DebtsActual.Add(item.ActualAmount)
|
|
case models.CategorySaving:
|
|
t.SavingsBudget = t.SavingsBudget.Add(item.BudgetedAmount)
|
|
t.SavingsActual = t.SavingsActual.Add(item.ActualAmount)
|
|
case models.CategoryInvestment:
|
|
t.InvestmentsBudget = t.InvestmentsBudget.Add(item.BudgetedAmount)
|
|
t.InvestmentsActual = t.InvestmentsActual.Add(item.ActualAmount)
|
|
}
|
|
}
|
|
|
|
t.Available = carryover.Add(t.IncomeActual).
|
|
Sub(t.BillsActual).
|
|
Sub(t.ExpensesActual).
|
|
Sub(t.DebtsActual).
|
|
Sub(t.SavingsActual).
|
|
Sub(t.InvestmentsActual)
|
|
|
|
return t
|
|
}
|
|
|
|
func (q *Queries) CopyBudgetItems(ctx context.Context, targetBudgetID, sourceBudgetID, userID uuid.UUID) error {
|
|
// Verify both budgets belong to user
|
|
if _, err := q.GetBudget(ctx, targetBudgetID, userID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := q.GetBudget(ctx, sourceBudgetID, userID); err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err := q.pool.Exec(ctx,
|
|
`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,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// Budget Items
|
|
|
|
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, 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, 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, item_tier = $6, updated_at = now()
|
|
WHERE id = $1 AND budget_id = $2
|
|
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)
|
|
}
|
|
return i, nil
|
|
}
|
|
|
|
func (q *Queries) DeleteBudgetItem(ctx context.Context, id, budgetID uuid.UUID) error {
|
|
_, err := q.pool.Exec(ctx,
|
|
`DELETE FROM budget_items WHERE id = $1 AND budget_id = $2`, id, budgetID,
|
|
)
|
|
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)
|
|
}
|
|
|
|
// Quick Add Items
|
|
|
|
func (q *Queries) ListQuickAddItems(ctx context.Context, userID uuid.UUID) ([]models.QuickAddItem, error) {
|
|
rows, err := q.pool.Query(ctx,
|
|
`SELECT id, user_id, name, icon, sort_order, created_at, updated_at
|
|
FROM quick_add_items WHERE user_id = $1 ORDER BY sort_order, name`, userID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing quick add items: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []models.QuickAddItem{}
|
|
for rows.Next() {
|
|
var i models.QuickAddItem
|
|
if err := rows.Scan(&i.ID, &i.UserID, &i.Name, &i.Icon, &i.SortOrder, &i.CreatedAt, &i.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("scanning quick add item: %w", err)
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (q *Queries) CreateQuickAddItem(ctx context.Context, userID uuid.UUID, name, icon string) (*models.QuickAddItem, error) {
|
|
i := &models.QuickAddItem{}
|
|
err := q.pool.QueryRow(ctx,
|
|
`INSERT INTO quick_add_items (user_id, name, icon, sort_order)
|
|
VALUES ($1, $2, $3, (SELECT COALESCE(MAX(sort_order), 0) + 1 FROM quick_add_items WHERE user_id = $1))
|
|
RETURNING id, user_id, name, icon, sort_order, created_at, updated_at`,
|
|
userID, name, icon,
|
|
).Scan(&i.ID, &i.UserID, &i.Name, &i.Icon, &i.SortOrder, &i.CreatedAt, &i.UpdatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating quick add item: %w", err)
|
|
}
|
|
return i, nil
|
|
}
|
|
|
|
func (q *Queries) UpdateQuickAddItem(ctx context.Context, id, userID uuid.UUID, name, icon string, sortOrder int) (*models.QuickAddItem, error) {
|
|
i := &models.QuickAddItem{}
|
|
err := q.pool.QueryRow(ctx,
|
|
`UPDATE quick_add_items SET name = $3, icon = $4, sort_order = $5, updated_at = now()
|
|
WHERE id = $1 AND user_id = $2
|
|
RETURNING id, user_id, name, icon, sort_order, created_at, updated_at`,
|
|
id, userID, name, icon, sortOrder,
|
|
).Scan(&i.ID, &i.UserID, &i.Name, &i.Icon, &i.SortOrder, &i.CreatedAt, &i.UpdatedAt)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, fmt.Errorf("quick add item not found")
|
|
}
|
|
return nil, fmt.Errorf("updating quick add item: %w", err)
|
|
}
|
|
return i, nil
|
|
}
|
|
|
|
func (q *Queries) DeleteQuickAddItem(ctx context.Context, id, userID uuid.UUID) error {
|
|
_, err := q.pool.Exec(ctx,
|
|
`DELETE FROM quick_add_items WHERE id = $1 AND user_id = $2`, id, userID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
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)
|
|
}
|