feat(savings): Add savings module with categories, transactions, recurring plans

- Categories: regular, deposits, credits, recurring, multi-user, accounts
- Transactions: deposits and withdrawals with user tracking
- Recurring plans: monthly payment obligations per user
- Stats: overdues calculation with allocation algorithm
- Excludes is_account categories from total sums
- Documentation: docs/SAVINGS.md
This commit is contained in:
Cosmo
2026-02-16 06:48:09 +00:00
parent 9e90aa6d95
commit 2a50e50771
18 changed files with 2910 additions and 162 deletions

489
internal/handler/savings.go Normal file
View File

@@ -0,0 +1,489 @@
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)
}