Files
SimpleFinanceDash/backend/internal/db/queries.go
2026-03-06 19:42:15 +00:00

358 lines
13 KiB
Go

package db
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/shopspring/decimal"
"simplefinancedash/backend/internal/models"
)
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.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.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, budgeted_amount, actual_amount, notes)
SELECT $1, category_id, 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) (*models.BudgetItem, error) {
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)
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) {
i := &models.BudgetItem{}
err := q.pool.QueryRow(ctx,
`UPDATE budget_items SET budgeted_amount = $3, actual_amount = $4, notes = $5, 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)
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
}