feat: add finance module (categories, transactions, summary, analytics)
All checks were successful
CI / ci (push) Successful in 12s
All checks were successful
CI / ci (push) Successful in 12s
- 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
This commit is contained in:
266
internal/handler/finance.go
Normal file
266
internal/handler/finance.go
Normal file
@@ -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)
|
||||
}
|
||||
114
internal/model/finance.go
Normal file
114
internal/model/finance.go
Normal file
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
363
internal/repository/finance.go
Normal file
363
internal/repository/finance.go
Normal file
@@ -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
|
||||
}
|
||||
167
internal/service/finance.go
Normal file
167
internal/service/finance.go
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user