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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user