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
267 lines
7.0 KiB
Go
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)
|
|
}
|