package repository import ( "database/sql" "errors" "fmt" "strings" "time" "github.com/daniil/homelab-api/internal/model" "github.com/jmoiron/sqlx" ) var ErrFinanceCategoryNotFound = errors.New("finance category not found") var ErrFinanceTransactionNotFound = errors.New("finance transaction not found") type FinanceRepository struct { db *sqlx.DB } func NewFinanceRepository(db *sqlx.DB) *FinanceRepository { return &FinanceRepository{db: db} } // --- Categories --- func (r *FinanceRepository) CreateCategory(cat *model.FinanceCategory) error { query := `INSERT INTO finance_categories (user_id, name, emoji, type, budget, color, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at` return r.db.QueryRow(query, cat.UserID, cat.Name, cat.Emoji, cat.Type, cat.Budget, cat.Color, cat.SortOrder). Scan(&cat.ID, &cat.CreatedAt) } func (r *FinanceRepository) ListCategories(userID int64) ([]model.FinanceCategory, error) { query := `SELECT id, user_id, name, emoji, type, budget, color, sort_order, created_at FROM finance_categories WHERE user_id = $1 ORDER BY sort_order, id` rows, err := r.db.Query(query, userID) if err != nil { return nil, err } defer rows.Close() var cats []model.FinanceCategory for rows.Next() { var c model.FinanceCategory if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.Emoji, &c.Type, &c.Budget, &c.Color, &c.SortOrder, &c.CreatedAt); err != nil { return nil, err } c.ProcessForJSON() cats = append(cats, c) } return cats, nil } func (r *FinanceRepository) GetCategory(id, userID int64) (*model.FinanceCategory, error) { var c model.FinanceCategory query := `SELECT id, user_id, name, emoji, type, budget, color, sort_order, created_at FROM finance_categories WHERE id = $1 AND user_id = $2` err := r.db.QueryRow(query, id, userID).Scan(&c.ID, &c.UserID, &c.Name, &c.Emoji, &c.Type, &c.Budget, &c.Color, &c.SortOrder, &c.CreatedAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrFinanceCategoryNotFound } return nil, err } c.ProcessForJSON() return &c, nil } func (r *FinanceRepository) UpdateCategory(cat *model.FinanceCategory) error { query := `UPDATE finance_categories SET name=$2, emoji=$3, type=$4, budget=$5, color=$6, sort_order=$7 WHERE id=$1 AND user_id=$8` result, err := r.db.Exec(query, cat.ID, cat.Name, cat.Emoji, cat.Type, cat.Budget, cat.Color, cat.SortOrder, cat.UserID) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return ErrFinanceCategoryNotFound } return nil } func (r *FinanceRepository) DeleteCategory(id, userID int64) error { result, err := r.db.Exec(`DELETE FROM finance_categories WHERE id=$1 AND user_id=$2`, id, userID) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return ErrFinanceCategoryNotFound } return nil } func (r *FinanceRepository) SeedDefaultCategories(userID int64) error { type cat struct { emoji, name, typ, color string } defaults := []cat{ {"🏠", "Жильё", "expense", "#ef4444"}, {"🍔", "Еда", "expense", "#f97316"}, {"🚗", "Транспорт", "expense", "#6366f1"}, {"👕", "Одежда", "expense", "#8b5cf6"}, {"🏥", "Здоровье", "expense", "#22c55e"}, {"🎮", "Развлечения", "expense", "#ec4899"}, {"📱", "Связь / Подписки", "expense", "#0ea5e9"}, {"✈️", "Путешествия", "expense", "#14b8a6"}, {"🎁", "Подарки", "expense", "#a855f7"}, {"🛒", "Бытовое", "expense", "#64748b"}, {"🛍️", "Маркетплейсы", "expense", "#F7B538"}, {"💎", "Накопления", "expense", "#0D4F4F"}, {"📦", "Другое", "expense", "#78716c"}, {"💰", "Зарплата", "income", "#22c55e"}, {"💼", "Фриланс", "income", "#6366f1"}, {"📈", "Другой доход", "income", "#0ea5e9"}, } for i, c := range defaults { _, err := r.db.Exec(`INSERT INTO finance_categories (user_id, name, emoji, type, color, sort_order) VALUES ($1,$2,$3,$4,$5,$6)`, userID, c.name, c.emoji, c.typ, c.color, i) if err != nil { return err } } return nil } // --- Transactions --- func (r *FinanceRepository) CreateTransaction(tx *model.FinanceTransaction) error { query := `INSERT INTO finance_transactions (user_id, category_id, type, amount, description, date) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id, created_at` return r.db.QueryRow(query, tx.UserID, tx.CategoryID, tx.Type, tx.Amount, tx.Description, tx.Date). Scan(&tx.ID, &tx.CreatedAt) } func (r *FinanceRepository) ListTransactions(userID int64, month, year int, categoryID *int64, txType, search string, limit, offset int) ([]model.FinanceTransaction, error) { args := []interface{}{userID} conditions := []string{"t.user_id = $1"} argIdx := 2 if month > 0 && year > 0 { conditions = append(conditions, fmt.Sprintf("EXTRACT(MONTH FROM t.date) = $%d AND EXTRACT(YEAR FROM t.date) = $%d", argIdx, argIdx+1)) args = append(args, month, year) argIdx += 2 } if categoryID != nil { conditions = append(conditions, fmt.Sprintf("t.category_id = $%d", argIdx)) args = append(args, *categoryID) argIdx++ } if txType != "" { conditions = append(conditions, fmt.Sprintf("t.type = $%d", argIdx)) args = append(args, txType) argIdx++ } if search != "" { conditions = append(conditions, fmt.Sprintf("t.description ILIKE $%d", argIdx)) args = append(args, "%"+search+"%") argIdx++ } if limit <= 0 { limit = 50 } query := fmt.Sprintf(`SELECT t.id, t.user_id, t.category_id, t.type, t.amount, t.description, t.date, t.created_at, COALESCE(c.name,'') as category_name, COALESCE(c.emoji,'') as category_emoji FROM finance_transactions t LEFT JOIN finance_categories c ON c.id = t.category_id WHERE %s ORDER BY t.date DESC, t.id DESC LIMIT %d OFFSET %d`, strings.Join(conditions, " AND "), limit, offset) rows, err := r.db.Query(query, args...) if err != nil { return nil, err } defer rows.Close() var txs []model.FinanceTransaction for rows.Next() { var t model.FinanceTransaction if err := rows.Scan(&t.ID, &t.UserID, &t.CategoryID, &t.Type, &t.Amount, &t.Description, &t.Date, &t.CreatedAt, &t.CategoryName, &t.CategoryEmoji); err != nil { return nil, err } txs = append(txs, t) } return txs, nil } func (r *FinanceRepository) GetTransaction(id, userID int64) (*model.FinanceTransaction, error) { var t model.FinanceTransaction query := `SELECT t.id, t.user_id, t.category_id, t.type, t.amount, t.description, t.date, t.created_at, COALESCE(c.name,'') as category_name, COALESCE(c.emoji,'') as category_emoji FROM finance_transactions t LEFT JOIN finance_categories c ON c.id = t.category_id WHERE t.id=$1 AND t.user_id=$2` err := r.db.QueryRow(query, id, userID).Scan(&t.ID, &t.UserID, &t.CategoryID, &t.Type, &t.Amount, &t.Description, &t.Date, &t.CreatedAt, &t.CategoryName, &t.CategoryEmoji) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrFinanceTransactionNotFound } return nil, err } return &t, nil } func (r *FinanceRepository) UpdateTransaction(tx *model.FinanceTransaction) error { query := `UPDATE finance_transactions SET category_id=$2, type=$3, amount=$4, description=$5, date=$6 WHERE id=$1 AND user_id=$7` result, err := r.db.Exec(query, tx.ID, tx.CategoryID, tx.Type, tx.Amount, tx.Description, tx.Date, tx.UserID) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return ErrFinanceTransactionNotFound } return nil } func (r *FinanceRepository) DeleteTransaction(id, userID int64) error { result, err := r.db.Exec(`DELETE FROM finance_transactions WHERE id=$1 AND user_id=$2`, id, userID) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return ErrFinanceTransactionNotFound } return nil } // --- Summary & Analytics --- func (r *FinanceRepository) GetSummary(userID int64, month, year int) (*model.FinanceSummary, error) { summary := &model.FinanceSummary{} // First day of selected month and last day of selected month firstDay := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) lastDay := firstDay.AddDate(0, 1, -1) // Carried over: balance of all transactions BEFORE the selected month err := r.db.QueryRow(`SELECT COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE -amount END), 0) FROM finance_transactions WHERE user_id=$1 AND date < $2`, userID, firstDay).Scan(&summary.CarriedOver) if err != nil { return nil, err } // Total income & expense for the month err = r.db.QueryRow(`SELECT COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END),0), COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END),0) FROM finance_transactions WHERE user_id=$1 AND EXTRACT(MONTH FROM date)=$2 AND EXTRACT(YEAR FROM date)=$3`, userID, month, year).Scan(&summary.TotalIncome, &summary.TotalExpense) if err != nil { return nil, err } // Cumulative balance: all transactions up to end of selected month err = r.db.QueryRow(`SELECT COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE -amount END), 0) FROM finance_transactions WHERE user_id=$1 AND date <= $2`, userID, lastDay).Scan(&summary.Balance) if err != nil { return nil, err } // By category rows, err := r.db.Query(`SELECT c.id, c.name, c.emoji, t.type, SUM(t.amount) as amount FROM finance_transactions t JOIN finance_categories c ON c.id=t.category_id WHERE t.user_id=$1 AND EXTRACT(MONTH FROM t.date)=$2 AND EXTRACT(YEAR FROM t.date)=$3 GROUP BY c.id, c.name, c.emoji, t.type ORDER BY amount DESC`, userID, month, year) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var cs model.CategorySummary if err := rows.Scan(&cs.CategoryID, &cs.CategoryName, &cs.CategoryEmoji, &cs.Type, &cs.Amount); err != nil { return nil, err } total := summary.TotalExpense if cs.Type == "income" { total = summary.TotalIncome } if total > 0 { cs.Percentage = cs.Amount / total * 100 } summary.ByCategory = append(summary.ByCategory, cs) } if summary.ByCategory == nil { summary.ByCategory = []model.CategorySummary{} } // Daily expenses dailyRows, err := r.db.Query(`SELECT date::text, SUM(amount) as amount FROM finance_transactions WHERE user_id=$1 AND type='expense' AND EXTRACT(MONTH FROM date)=$2 AND EXTRACT(YEAR FROM date)=$3 GROUP BY date ORDER BY date`, userID, month, year) if err != nil { return nil, err } defer dailyRows.Close() for dailyRows.Next() { var d model.DailySummary if err := dailyRows.Scan(&d.Date, &d.Amount); err != nil { return nil, err } summary.Daily = append(summary.Daily, d) } if summary.Daily == nil { summary.Daily = []model.DailySummary{} } return summary, nil } func (r *FinanceRepository) GetAnalytics(userID int64, months int) (*model.FinanceAnalytics, error) { analytics := &model.FinanceAnalytics{} // Monthly trend rows, err := r.db.Query(`SELECT TO_CHAR(date, 'YYYY-MM') as month, COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END),0) as income, COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END),0) as expense FROM finance_transactions WHERE user_id=$1 AND date >= (CURRENT_DATE - ($2 || ' months')::interval) GROUP BY TO_CHAR(date, 'YYYY-MM') ORDER BY month`, userID, months) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var mt model.MonthlyTrend if err := rows.Scan(&mt.Month, &mt.Income, &mt.Expense); err != nil { return nil, err } analytics.MonthlyTrend = append(analytics.MonthlyTrend, mt) } if analytics.MonthlyTrend == nil { analytics.MonthlyTrend = []model.MonthlyTrend{} } // Avg daily expense for current month now := time.Now() var totalExpense float64 var dayCount int r.db.QueryRow(`SELECT COALESCE(SUM(amount),0), COUNT(DISTINCT date) FROM finance_transactions WHERE user_id=$1 AND type='expense' AND EXTRACT(MONTH FROM date)=$2 AND EXTRACT(YEAR FROM date)=$3`, userID, int(now.Month()), now.Year()).Scan(&totalExpense, &dayCount) if dayCount > 0 { analytics.AvgDailyExpense = totalExpense / float64(dayCount) } // Comparison with previous month prevMonth := now.AddDate(0, -1, 0) var currentMonthExp, prevMonthExp float64 r.db.QueryRow(`SELECT COALESCE(SUM(amount),0) FROM finance_transactions WHERE user_id=$1 AND type='expense' AND EXTRACT(MONTH FROM date)=$2 AND EXTRACT(YEAR FROM date)=$3`, userID, int(now.Month()), now.Year()).Scan(¤tMonthExp) r.db.QueryRow(`SELECT COALESCE(SUM(amount),0) FROM finance_transactions WHERE user_id=$1 AND type='expense' AND EXTRACT(MONTH FROM date)=$2 AND EXTRACT(YEAR FROM date)=$3`, userID, int(prevMonth.Month()), prevMonth.Year()).Scan(&prevMonthExp) analytics.ComparisonPrevMonth = model.Comparison{ Current: currentMonthExp, Previous: prevMonthExp, } if prevMonthExp > 0 { analytics.ComparisonPrevMonth.DiffPercent = (currentMonthExp - prevMonthExp) / prevMonthExp * 100 } return analytics, nil } func (r *FinanceRepository) HasCategories(userID int64) (bool, error) { var count int err := r.db.Get(&count, `SELECT COUNT(*) FROM finance_categories WHERE user_id=$1`, userID) return count > 0, err }