package handler import ( "encoding/json" "errors" "net/http" "strconv" "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" ) type SavingsHandler struct { repo *repository.SavingsRepository } func NewSavingsHandler(repo *repository.SavingsRepository) *SavingsHandler { return &SavingsHandler{repo: repo} } // ==================== CATEGORIES ==================== func (h *SavingsHandler) ListCategories(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) categories, err := h.repo.ListCategories(userID) if err != nil { writeError(w, "failed to fetch categories", http.StatusInternalServerError) return } // Process and calculate balances for i := range categories { categories[i].ProcessForJSON() balance, _ := h.repo.GetCategoryBalance(categories[i].ID) categories[i].CurrentAmount = balance if categories[i].IsRecurring { total, _ := h.repo.GetRecurringTotalAmount(categories[i].ID) categories[i].RecurringTotalAmount = total } } writeJSON(w, categories, http.StatusOK) } func (h *SavingsHandler) GetCategory(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) categoryID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, "invalid category id", http.StatusBadRequest) return } category, err := h.repo.GetCategory(categoryID, userID) if err != nil { if errors.Is(err, repository.ErrCategoryNotFound) { writeError(w, "category not found", http.StatusNotFound) return } writeError(w, "failed to fetch category", http.StatusInternalServerError) return } category.ProcessForJSON() balance, _ := h.repo.GetCategoryBalance(category.ID) category.CurrentAmount = balance if category.IsRecurring { total, _ := h.repo.GetRecurringTotalAmount(category.ID) category.RecurringTotalAmount = total } writeJSON(w, category, http.StatusOK) } func (h *SavingsHandler) CreateCategory(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) var req model.CreateSavingsCategoryRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, "invalid request body", http.StatusBadRequest) return } if req.Name == "" { writeError(w, "name is required", http.StatusBadRequest) return } category, err := h.repo.CreateCategory(userID, &req) if err != nil { writeError(w, "failed to create category", http.StatusInternalServerError) return } category.ProcessForJSON() writeJSON(w, category, http.StatusCreated) } func (h *SavingsHandler) UpdateCategory(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) categoryID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, "invalid category id", http.StatusBadRequest) return } var req model.UpdateSavingsCategoryRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, "invalid request body", http.StatusBadRequest) return } category, err := h.repo.UpdateCategory(categoryID, userID, &req) if err != nil { if errors.Is(err, repository.ErrCategoryNotFound) { writeError(w, "category not found", http.StatusNotFound) return } if errors.Is(err, repository.ErrNotAuthorized) { writeError(w, "not authorized", http.StatusForbidden) return } writeError(w, "failed to update category", http.StatusInternalServerError) return } category.ProcessForJSON() balance, _ := h.repo.GetCategoryBalance(category.ID) category.CurrentAmount = balance writeJSON(w, category, http.StatusOK) } func (h *SavingsHandler) DeleteCategory(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) categoryID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, "invalid category id", http.StatusBadRequest) return } if err := h.repo.DeleteCategory(categoryID, userID); err != nil { if errors.Is(err, repository.ErrCategoryNotFound) { writeError(w, "category not found", http.StatusNotFound) return } writeError(w, "failed to delete category", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // ==================== TRANSACTIONS ==================== func (h *SavingsHandler) ListTransactions(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) var categoryID *int64 if catIDStr := r.URL.Query().Get("category_id"); catIDStr != "" { id, err := strconv.ParseInt(catIDStr, 10, 64) if err == nil { categoryID = &id } } limit := 100 if l := r.URL.Query().Get("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed } } offset := 0 if o := r.URL.Query().Get("offset"); o != "" { if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { offset = parsed } } transactions, err := h.repo.ListTransactions(userID, categoryID, limit, offset) if err != nil { writeError(w, "failed to fetch transactions", http.StatusInternalServerError) return } writeJSON(w, transactions, http.StatusOK) } func (h *SavingsHandler) GetTransaction(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) txID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, "invalid transaction id", http.StatusBadRequest) return } transaction, err := h.repo.GetTransaction(txID, userID) if err != nil { if errors.Is(err, repository.ErrTransactionNotFound) { writeError(w, "transaction not found", http.StatusNotFound) return } writeError(w, "failed to fetch transaction", http.StatusInternalServerError) return } writeJSON(w, transaction, http.StatusOK) } func (h *SavingsHandler) CreateTransaction(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) var req model.CreateSavingsTransactionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, "invalid request body", http.StatusBadRequest) return } if req.CategoryID == 0 { writeError(w, "category_id is required", http.StatusBadRequest) return } if req.Amount <= 0 { writeError(w, "amount must be positive", http.StatusBadRequest) return } if req.Type != "deposit" && req.Type != "withdrawal" { writeError(w, "type must be 'deposit' or 'withdrawal'", http.StatusBadRequest) return } if req.Date == "" { writeError(w, "date is required", http.StatusBadRequest) return } transaction, err := h.repo.CreateTransaction(userID, &req) if err != nil { if errors.Is(err, repository.ErrCategoryNotFound) { writeError(w, "category not found", http.StatusNotFound) return } writeError(w, "failed to create transaction: "+err.Error(), http.StatusInternalServerError) return } writeJSON(w, transaction, http.StatusCreated) } func (h *SavingsHandler) UpdateTransaction(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) txID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, "invalid transaction id", http.StatusBadRequest) return } var req model.UpdateSavingsTransactionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, "invalid request body", http.StatusBadRequest) return } transaction, err := h.repo.UpdateTransaction(txID, userID, &req) if err != nil { if errors.Is(err, repository.ErrTransactionNotFound) { writeError(w, "transaction not found", http.StatusNotFound) return } if errors.Is(err, repository.ErrNotAuthorized) { writeError(w, "not authorized", http.StatusForbidden) return } writeError(w, "failed to update transaction", http.StatusInternalServerError) return } writeJSON(w, transaction, http.StatusOK) } func (h *SavingsHandler) DeleteTransaction(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) txID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, "invalid transaction id", http.StatusBadRequest) return } if err := h.repo.DeleteTransaction(txID, userID); err != nil { if errors.Is(err, repository.ErrTransactionNotFound) { writeError(w, "transaction not found", http.StatusNotFound) return } writeError(w, "failed to delete transaction", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // ==================== STATS ==================== func (h *SavingsHandler) Stats(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) stats, err := h.repo.GetStats(userID) if err != nil { writeError(w, "failed to fetch stats", http.StatusInternalServerError) return } writeJSON(w, stats, http.StatusOK) } // ==================== MEMBERS ==================== func (h *SavingsHandler) ListMembers(w http.ResponseWriter, r *http.Request) { categoryID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, "invalid category id", http.StatusBadRequest) return } members, err := h.repo.GetCategoryMembers(categoryID) if err != nil { writeError(w, "failed to fetch members", http.StatusInternalServerError) return } writeJSON(w, members, http.StatusOK) } func (h *SavingsHandler) AddMember(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) categoryID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, "invalid category id", http.StatusBadRequest) return } // Check ownership category, err := h.repo.GetCategory(categoryID, userID) if err != nil { writeError(w, "category not found", http.StatusNotFound) return } if category.UserID != userID { writeError(w, "not authorized", http.StatusForbidden) return } var req struct { UserID int64 `json:"user_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, "invalid request body", http.StatusBadRequest) return } if err := h.repo.AddCategoryMember(categoryID, req.UserID); err != nil { writeError(w, "failed to add member", http.StatusInternalServerError) return } members, _ := h.repo.GetCategoryMembers(categoryID) writeJSON(w, members, http.StatusOK) } func (h *SavingsHandler) RemoveMember(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) categoryID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, "invalid category id", http.StatusBadRequest) return } memberUserID, err := strconv.ParseInt(chi.URLParam(r, "userId"), 10, 64) if err != nil { writeError(w, "invalid user id", http.StatusBadRequest) return } // Check ownership category, err := h.repo.GetCategory(categoryID, userID) if err != nil { writeError(w, "category not found", http.StatusNotFound) return } if category.UserID != userID { writeError(w, "not authorized", http.StatusForbidden) return } if err := h.repo.RemoveCategoryMember(categoryID, memberUserID); err != nil { writeError(w, "failed to remove member", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // ==================== RECURRING PLANS ==================== func (h *SavingsHandler) ListRecurringPlans(w http.ResponseWriter, r *http.Request) { categoryID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, "invalid category id", http.StatusBadRequest) return } plans, err := h.repo.ListRecurringPlans(categoryID) if err != nil { writeError(w, "failed to fetch recurring plans", http.StatusInternalServerError) return } writeJSON(w, plans, http.StatusOK) } func (h *SavingsHandler) CreateRecurringPlan(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) categoryID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, "invalid category id", http.StatusBadRequest) return } // Check access _, err = h.repo.GetCategory(categoryID, userID) if err != nil { writeError(w, "category not found", http.StatusNotFound) return } var req model.CreateRecurringPlanRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, "invalid request body", http.StatusBadRequest) return } plan, err := h.repo.CreateRecurringPlan(categoryID, &req) if err != nil { writeError(w, "failed to create recurring plan: "+err.Error(), http.StatusInternalServerError) return } writeJSON(w, plan, http.StatusCreated) } func (h *SavingsHandler) DeleteRecurringPlan(w http.ResponseWriter, r *http.Request) { planID, err := strconv.ParseInt(chi.URLParam(r, "planId"), 10, 64) if err != nil { writeError(w, "invalid plan id", http.StatusBadRequest) return } if err := h.repo.DeleteRecurringPlan(planID); err != nil { writeError(w, "failed to delete recurring plan", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } func (h *SavingsHandler) UpdateRecurringPlan(w http.ResponseWriter, r *http.Request) { planID, err := strconv.ParseInt(chi.URLParam(r, "planId"), 10, 64) if err != nil { writeError(w, "invalid plan id", http.StatusBadRequest) return } var req model.UpdateRecurringPlanRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, "invalid request body", http.StatusBadRequest) return } plan, err := h.repo.UpdateRecurringPlan(planID, &req) if err != nil { writeError(w, "failed to update recurring plan: "+err.Error(), http.StatusInternalServerError) return } writeJSON(w, plan, http.StatusOK) }