From 23939ccc92f9d192ad2dd30baf7ed6c031389ba1 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 04:22:10 +0000 Subject: [PATCH] feat: add finance module (categories, transactions, summary, analytics) - model/finance.go: FinanceCategory, FinanceTransaction, Summary, Analytics - repository/finance.go: CRUD + summary/analytics queries - service/finance.go: business logic with auto-seed default categories - handler/finance.go: REST endpoints with owner-only check (user_id=1) - db.go: finance_categories + finance_transactions migrations - main.go: register /finance/* routes Endpoints: GET/POST/PUT/DELETE /finance/categories, /finance/transactions GET /finance/summary, /finance/analytics --- cmd/api/main.go | 22 ++ docker-compose.dev.yml | 2 +- internal/handler/finance.go | 266 ++++++++++++++++++++++++ internal/model/finance.go | 114 +++++++++++ internal/repository/db.go | 37 ++++ internal/repository/finance.go | 363 +++++++++++++++++++++++++++++++++ internal/service/finance.go | 167 +++++++++++++++ 7 files changed, 970 insertions(+), 1 deletion(-) create mode 100644 internal/handler/finance.go create mode 100644 internal/model/finance.go create mode 100644 internal/repository/finance.go create mode 100644 internal/service/finance.go diff --git a/cmd/api/main.go b/cmd/api/main.go index b8d73ea..e8ee11f 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -33,6 +33,11 @@ func main() { log.Fatalf("Failed to run migrations: %v", err) } + // Run finance migrations + if err := repository.RunFinanceMigrations(db); err != nil { + log.Fatalf("Failed to run finance migrations: %v", err) + } + // Initialize repositories userRepo := repository.NewUserRepository(db) habitRepo := repository.NewHabitRepository(db) @@ -40,12 +45,14 @@ func main() { emailTokenRepo := repository.NewEmailTokenRepository(db) habitFreezeRepo := repository.NewHabitFreezeRepository(db) savingsRepo := repository.NewSavingsRepository(db) + financeRepo := repository.NewFinanceRepository(db) // Initialize services emailService := service.NewEmailService(cfg.ResendAPIKey, cfg.FromEmail, cfg.FromName, cfg.AppURL) authService := service.NewAuthService(userRepo, emailTokenRepo, emailService, cfg.JWTSecret) habitService := service.NewHabitService(habitRepo, habitFreezeRepo) taskService := service.NewTaskService(taskRepo) + financeService := service.NewFinanceService(financeRepo) // Initialize Telegram bot telegramBot, err := bot.New(cfg.TelegramBotToken, userRepo, taskRepo, habitRepo) @@ -72,6 +79,7 @@ func main() { habitFreezeHandler := handler.NewHabitFreezeHandler(habitFreezeRepo, habitRepo) savingsHandler := handler.NewSavingsHandler(savingsRepo) interestHandler := handler.NewInterestHandler(db) + financeHandler := handler.NewFinanceHandler(financeService) // Initialize middleware authMiddleware := customMiddleware.NewAuthMiddleware(cfg.JWTSecret) @@ -178,6 +186,20 @@ func main() { // Savings stats r.Get("/savings/stats", savingsHandler.Stats) + + // Finance routes (owner-only, checked in handler) + r.Get("/finance/categories", financeHandler.ListCategories) + r.Post("/finance/categories", financeHandler.CreateCategory) + r.Put("/finance/categories/{id}", financeHandler.UpdateCategory) + r.Delete("/finance/categories/{id}", financeHandler.DeleteCategory) + + r.Get("/finance/transactions", financeHandler.ListTransactions) + r.Post("/finance/transactions", financeHandler.CreateTransaction) + r.Put("/finance/transactions/{id}", financeHandler.UpdateTransaction) + r.Delete("/finance/transactions/{id}", financeHandler.DeleteTransaction) + + r.Get("/finance/summary", financeHandler.Summary) + r.Get("/finance/analytics", financeHandler.Analytics) }) port := os.Getenv("PORT") diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 00f36cc..0d94744 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -13,7 +13,7 @@ services: ports: - "8081:8080" environment: - - DATABASE_URL=postgres://homelab:${DB_PASSWORD}@db:5432/homelab?sslmode=disable + - DATABASE_URL=postgres://homelab:${DB_PASSWORD}@db:5432/homelab_dev?sslmode=disable - JWT_SECRET=${JWT_SECRET} - PORT=8080 - RESEND_API_KEY=${RESEND_API_KEY} diff --git a/internal/handler/finance.go b/internal/handler/finance.go new file mode 100644 index 0000000..32ca323 --- /dev/null +++ b/internal/handler/finance.go @@ -0,0 +1,266 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/daniil/homelab-api/internal/middleware" + "github.com/daniil/homelab-api/internal/model" + "github.com/daniil/homelab-api/internal/repository" + "github.com/daniil/homelab-api/internal/service" +) + +const ownerUserID int64 = 1 + +type FinanceHandler struct { + svc *service.FinanceService +} + +func NewFinanceHandler(svc *service.FinanceService) *FinanceHandler { + return &FinanceHandler{svc: svc} +} + +func (h *FinanceHandler) checkOwner(w http.ResponseWriter, r *http.Request) (int64, bool) { + userID := middleware.GetUserID(r.Context()) + if userID != ownerUserID { + writeError(w, "forbidden", http.StatusForbidden) + return 0, false + } + return userID, true +} + +// Categories + +func (h *FinanceHandler) ListCategories(w http.ResponseWriter, r *http.Request) { + userID, ok := h.checkOwner(w, r) + if !ok { + return + } + cats, err := h.svc.ListCategories(userID) + if err != nil { + writeError(w, "failed to fetch categories", http.StatusInternalServerError) + return + } + writeJSON(w, cats, http.StatusOK) +} + +func (h *FinanceHandler) CreateCategory(w http.ResponseWriter, r *http.Request) { + userID, ok := h.checkOwner(w, r) + if !ok { + return + } + var req model.CreateFinanceCategoryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + if req.Name == "" || req.Type == "" { + writeError(w, "name and type are required", http.StatusBadRequest) + return + } + if req.Type != "expense" && req.Type != "income" { + writeError(w, "type must be expense or income", http.StatusBadRequest) + return + } + cat, err := h.svc.CreateCategory(userID, &req) + if err != nil { + writeError(w, "failed to create category", http.StatusInternalServerError) + return + } + writeJSON(w, cat, http.StatusCreated) +} + +func (h *FinanceHandler) UpdateCategory(w http.ResponseWriter, r *http.Request) { + userID, ok := h.checkOwner(w, r) + if !ok { + return + } + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + var req model.UpdateFinanceCategoryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + cat, err := h.svc.UpdateCategory(id, userID, &req) + if err != nil { + if errors.Is(err, repository.ErrFinanceCategoryNotFound) { + writeError(w, "category not found", http.StatusNotFound) + return + } + writeError(w, "failed to update category", http.StatusInternalServerError) + return + } + writeJSON(w, cat, http.StatusOK) +} + +func (h *FinanceHandler) DeleteCategory(w http.ResponseWriter, r *http.Request) { + userID, ok := h.checkOwner(w, r) + if !ok { + return + } + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + if err := h.svc.DeleteCategory(id, userID); err != nil { + if errors.Is(err, repository.ErrFinanceCategoryNotFound) { + writeError(w, "category not found", http.StatusNotFound) + return + } + writeError(w, "failed to delete category", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// Transactions + +func (h *FinanceHandler) ListTransactions(w http.ResponseWriter, r *http.Request) { + userID, ok := h.checkOwner(w, r) + if !ok { + return + } + q := r.URL.Query() + month, _ := strconv.Atoi(q.Get("month")) + year, _ := strconv.Atoi(q.Get("year")) + var catID *int64 + if c := q.Get("category_id"); c != "" { + v, _ := strconv.ParseInt(c, 10, 64) + catID = &v + } + txType := q.Get("type") + search := q.Get("search") + limit, _ := strconv.Atoi(q.Get("limit")) + offset, _ := strconv.Atoi(q.Get("offset")) + + txs, err := h.svc.ListTransactions(userID, month, year, catID, txType, search, limit, offset) + if err != nil { + writeError(w, "failed to fetch transactions", http.StatusInternalServerError) + return + } + writeJSON(w, txs, http.StatusOK) +} + +func (h *FinanceHandler) CreateTransaction(w http.ResponseWriter, r *http.Request) { + userID, ok := h.checkOwner(w, r) + if !ok { + return + } + var req model.CreateFinanceTransactionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + if req.Amount <= 0 { + writeError(w, "amount must be positive", http.StatusBadRequest) + return + } + if req.Type != "expense" && req.Type != "income" { + writeError(w, "type must be expense or income", http.StatusBadRequest) + return + } + tx, err := h.svc.CreateTransaction(userID, &req) + if err != nil { + writeError(w, "failed to create transaction", http.StatusInternalServerError) + return + } + writeJSON(w, tx, http.StatusCreated) +} + +func (h *FinanceHandler) UpdateTransaction(w http.ResponseWriter, r *http.Request) { + userID, ok := h.checkOwner(w, r) + if !ok { + return + } + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + var req model.UpdateFinanceTransactionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + tx, err := h.svc.UpdateTransaction(id, userID, &req) + if err != nil { + if errors.Is(err, repository.ErrFinanceTransactionNotFound) { + writeError(w, "transaction not found", http.StatusNotFound) + return + } + writeError(w, "failed to update transaction", http.StatusInternalServerError) + return + } + writeJSON(w, tx, http.StatusOK) +} + +func (h *FinanceHandler) DeleteTransaction(w http.ResponseWriter, r *http.Request) { + userID, ok := h.checkOwner(w, r) + if !ok { + return + } + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + if err := h.svc.DeleteTransaction(id, userID); err != nil { + if errors.Is(err, repository.ErrFinanceTransactionNotFound) { + writeError(w, "transaction not found", http.StatusNotFound) + return + } + writeError(w, "failed to delete transaction", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// Summary & Analytics + +func (h *FinanceHandler) Summary(w http.ResponseWriter, r *http.Request) { + userID, ok := h.checkOwner(w, r) + if !ok { + return + } + q := r.URL.Query() + month, _ := strconv.Atoi(q.Get("month")) + year, _ := strconv.Atoi(q.Get("year")) + if month == 0 || year == 0 { + now := time.Now() + month = int(now.Month()) + year = now.Year() + } + summary, err := h.svc.GetSummary(userID, month, year) + if err != nil { + writeError(w, "failed to get summary", http.StatusInternalServerError) + return + } + writeJSON(w, summary, http.StatusOK) +} + +func (h *FinanceHandler) Analytics(w http.ResponseWriter, r *http.Request) { + userID, ok := h.checkOwner(w, r) + if !ok { + return + } + months, _ := strconv.Atoi(r.URL.Query().Get("months")) + if months <= 0 { + months = 6 + } + analytics, err := h.svc.GetAnalytics(userID, months) + if err != nil { + writeError(w, "failed to get analytics", http.StatusInternalServerError) + return + } + writeJSON(w, analytics, http.StatusOK) +} diff --git a/internal/model/finance.go b/internal/model/finance.go new file mode 100644 index 0000000..4a6210e --- /dev/null +++ b/internal/model/finance.go @@ -0,0 +1,114 @@ +package model + +import ( + "database/sql" + "time" +) + +type FinanceCategory struct { + ID int64 `db:"id" json:"id"` + UserID int64 `db:"user_id" json:"user_id"` + Name string `db:"name" json:"name"` + Emoji string `db:"emoji" json:"emoji"` + Type string `db:"type" json:"type"` + Budget sql.NullFloat64 `db:"budget" json:"-"` + BudgetVal *float64 `db:"-" json:"budget"` + Color string `db:"color" json:"color"` + SortOrder int `db:"sort_order" json:"sort_order"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +func (c *FinanceCategory) ProcessForJSON() { + if c.Budget.Valid { + c.BudgetVal = &c.Budget.Float64 + } +} + +type FinanceTransaction struct { + ID int64 `db:"id" json:"id"` + UserID int64 `db:"user_id" json:"user_id"` + CategoryID int64 `db:"category_id" json:"category_id"` + Type string `db:"type" json:"type"` + Amount float64 `db:"amount" json:"amount"` + Description string `db:"description" json:"description"` + Date time.Time `db:"date" json:"date"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + // Joined fields + CategoryName string `db:"category_name" json:"category_name,omitempty"` + CategoryEmoji string `db:"category_emoji" json:"category_emoji,omitempty"` +} + +type CreateFinanceCategoryRequest struct { + Name string `json:"name"` + Emoji string `json:"emoji,omitempty"` + Type string `json:"type"` + Budget *float64 `json:"budget,omitempty"` + Color string `json:"color,omitempty"` + SortOrder int `json:"sort_order,omitempty"` +} + +type UpdateFinanceCategoryRequest struct { + Name *string `json:"name,omitempty"` + Emoji *string `json:"emoji,omitempty"` + Type *string `json:"type,omitempty"` + Budget *float64 `json:"budget,omitempty"` + Color *string `json:"color,omitempty"` + SortOrder *int `json:"sort_order,omitempty"` +} + +type CreateFinanceTransactionRequest struct { + CategoryID int64 `json:"category_id"` + Type string `json:"type"` + Amount float64 `json:"amount"` + Description string `json:"description,omitempty"` + Date string `json:"date"` +} + +type UpdateFinanceTransactionRequest struct { + CategoryID *int64 `json:"category_id,omitempty"` + Type *string `json:"type,omitempty"` + Amount *float64 `json:"amount,omitempty"` + Description *string `json:"description,omitempty"` + Date *string `json:"date,omitempty"` +} + +type FinanceSummary struct { + Balance float64 `json:"balance"` + TotalIncome float64 `json:"total_income"` + TotalExpense float64 `json:"total_expense"` + ByCategory []CategorySummary `json:"by_category"` + Daily []DailySummary `json:"daily"` +} + +type CategorySummary struct { + CategoryID int64 `json:"category_id" db:"category_id"` + CategoryName string `json:"category_name" db:"category_name"` + CategoryEmoji string `json:"category_emoji" db:"category_emoji"` + Type string `json:"type" db:"type"` + Amount float64 `json:"amount" db:"amount"` + Percentage float64 `json:"percentage"` + Budget *float64 `json:"budget,omitempty"` +} + +type DailySummary struct { + Date string `json:"date" db:"date"` + Amount float64 `json:"amount" db:"amount"` +} + +type FinanceAnalytics struct { + MonthlyTrend []MonthlyTrend `json:"monthly_trend"` + AvgDailyExpense float64 `json:"avg_daily_expense"` + ComparisonPrevMonth Comparison `json:"comparison_prev_month"` +} + +type MonthlyTrend struct { + Month string `json:"month" db:"month"` + Income float64 `json:"income" db:"income"` + Expense float64 `json:"expense" db:"expense"` +} + +type Comparison struct { + Current float64 `json:"current"` + Previous float64 `json:"previous"` + DiffPercent float64 `json:"diff_percent"` +} diff --git a/internal/repository/db.go b/internal/repository/db.go index 311baa0..92a87c8 100644 --- a/internal/repository/db.go +++ b/internal/repository/db.go @@ -127,3 +127,40 @@ func RunMigrations(db *sqlx.DB) error { return nil } + +func RunFinanceMigrations(db *sqlx.DB) error { + migrations := []string{ + `CREATE TABLE IF NOT EXISTS finance_categories ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + emoji VARCHAR(10) DEFAULT '', + type VARCHAR(10) NOT NULL, + budget DECIMAL(12,2), + color VARCHAR(7) DEFAULT '#6366f1', + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS finance_transactions ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + category_id INTEGER REFERENCES finance_categories(id) ON DELETE SET NULL, + type VARCHAR(10) NOT NULL, + amount DECIMAL(12,2) NOT NULL, + description TEXT DEFAULT '', + date DATE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE INDEX IF NOT EXISTS idx_finance_categories_user ON finance_categories(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_finance_transactions_user ON finance_transactions(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_finance_transactions_date ON finance_transactions(date)`, + `CREATE INDEX IF NOT EXISTS idx_finance_transactions_category ON finance_transactions(category_id)`, + `CREATE INDEX IF NOT EXISTS idx_finance_transactions_user_date ON finance_transactions(user_id, date)`, + } + for _, m := range migrations { + if _, err := db.Exec(m); err != nil { + return err + } + } + return nil +} diff --git a/internal/repository/finance.go b/internal/repository/finance.go new file mode 100644 index 0000000..17bcc7a --- /dev/null +++ b/internal/repository/finance.go @@ -0,0 +1,363 @@ +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{} + + // 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 + } + summary.Balance = summary.TotalIncome - summary.TotalExpense + + // 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 +} diff --git a/internal/service/finance.go b/internal/service/finance.go new file mode 100644 index 0000000..a046d1f --- /dev/null +++ b/internal/service/finance.go @@ -0,0 +1,167 @@ +package service + +import ( + "database/sql" + "time" + + "github.com/daniil/homelab-api/internal/model" + "github.com/daniil/homelab-api/internal/repository" +) + +type FinanceService struct { + repo *repository.FinanceRepository +} + +func NewFinanceService(repo *repository.FinanceRepository) *FinanceService { + return &FinanceService{repo: repo} +} + +// --- Categories --- + +func (s *FinanceService) ListCategories(userID int64) ([]model.FinanceCategory, error) { + // Auto-seed if user has no categories + has, err := s.repo.HasCategories(userID) + if err != nil { + return nil, err + } + if !has { + if err := s.repo.SeedDefaultCategories(userID); err != nil { + return nil, err + } + } + + cats, err := s.repo.ListCategories(userID) + if err != nil { + return nil, err + } + if cats == nil { + cats = []model.FinanceCategory{} + } + return cats, nil +} + +func (s *FinanceService) CreateCategory(userID int64, req *model.CreateFinanceCategoryRequest) (*model.FinanceCategory, error) { + cat := &model.FinanceCategory{ + UserID: userID, + Name: req.Name, + Emoji: req.Emoji, + Type: req.Type, + Color: defaultString(req.Color, "#6366f1"), + SortOrder: req.SortOrder, + } + if req.Budget != nil { + cat.Budget = sql.NullFloat64{Float64: *req.Budget, Valid: true} + } + if err := s.repo.CreateCategory(cat); err != nil { + return nil, err + } + cat.ProcessForJSON() + return cat, nil +} + +func (s *FinanceService) UpdateCategory(id, userID int64, req *model.UpdateFinanceCategoryRequest) (*model.FinanceCategory, error) { + cat, err := s.repo.GetCategory(id, userID) + if err != nil { + return nil, err + } + if req.Name != nil { + cat.Name = *req.Name + } + if req.Emoji != nil { + cat.Emoji = *req.Emoji + } + if req.Type != nil { + cat.Type = *req.Type + } + if req.Budget != nil { + cat.Budget = sql.NullFloat64{Float64: *req.Budget, Valid: true} + } + if req.Color != nil { + cat.Color = *req.Color + } + if req.SortOrder != nil { + cat.SortOrder = *req.SortOrder + } + if err := s.repo.UpdateCategory(cat); err != nil { + return nil, err + } + cat.ProcessForJSON() + return cat, nil +} + +func (s *FinanceService) DeleteCategory(id, userID int64) error { + return s.repo.DeleteCategory(id, userID) +} + +// --- Transactions --- + +func (s *FinanceService) ListTransactions(userID int64, month, year int, categoryID *int64, txType, search string, limit, offset int) ([]model.FinanceTransaction, error) { + txs, err := s.repo.ListTransactions(userID, month, year, categoryID, txType, search, limit, offset) + if err != nil { + return nil, err + } + if txs == nil { + txs = []model.FinanceTransaction{} + } + return txs, nil +} + +func (s *FinanceService) CreateTransaction(userID int64, req *model.CreateFinanceTransactionRequest) (*model.FinanceTransaction, error) { + date, err := time.Parse("2006-01-02", req.Date) + if err != nil { + date = time.Now() + } + tx := &model.FinanceTransaction{ + UserID: userID, + CategoryID: req.CategoryID, + Type: req.Type, + Amount: req.Amount, + Description: req.Description, + Date: date, + } + if err := s.repo.CreateTransaction(tx); err != nil { + return nil, err + } + return tx, nil +} + +func (s *FinanceService) UpdateTransaction(id, userID int64, req *model.UpdateFinanceTransactionRequest) (*model.FinanceTransaction, error) { + tx, err := s.repo.GetTransaction(id, userID) + if err != nil { + return nil, err + } + if req.CategoryID != nil { + tx.CategoryID = *req.CategoryID + } + if req.Type != nil { + tx.Type = *req.Type + } + if req.Amount != nil { + tx.Amount = *req.Amount + } + if req.Description != nil { + tx.Description = *req.Description + } + if req.Date != nil { + parsed, err := time.Parse("2006-01-02", *req.Date) + if err == nil { + tx.Date = parsed + } + } + if err := s.repo.UpdateTransaction(tx); err != nil { + return nil, err + } + return tx, nil +} + +func (s *FinanceService) DeleteTransaction(id, userID int64) error { + return s.repo.DeleteTransaction(id, userID) +} + +func (s *FinanceService) GetSummary(userID int64, month, year int) (*model.FinanceSummary, error) { + return s.repo.GetSummary(userID, month, year) +} + +func (s *FinanceService) GetAnalytics(userID int64, months int) (*model.FinanceAnalytics, error) { + return s.repo.GetAnalytics(userID, months) +}