383 lines
13 KiB
Go
383 lines
13 KiB
Go
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, month, year 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 selected month
|
|
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, month, year).Scan(&totalExpense, &dayCount)
|
|
if dayCount > 0 {
|
|
analytics.AvgDailyExpense = totalExpense / float64(dayCount)
|
|
}
|
|
|
|
// Comparison with previous month
|
|
selectedMonth := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
|
|
prevMonthTime := selectedMonth.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, month, 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(prevMonthTime.Month()), prevMonthTime.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
|
|
}
|