Files
pulse-api/internal/handler/finance.go
Cosmo 23939ccc92
All checks were successful
CI / ci (push) Successful in 12s
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
2026-03-01 04:22:10 +00:00

267 lines
7.0 KiB
Go

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)
}