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)
|
||||
}
|
||||
Reference in New Issue
Block a user