feat: add finance module (categories, transactions, summary, analytics)
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:
Cosmo
2026-03-01 04:22:10 +00:00
parent 8d9fe818f4
commit 23939ccc92
7 changed files with 970 additions and 1 deletions

167
internal/service/finance.go Normal file
View 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)
}