package service import ( "errors" "fmt" "accounting-app/internal/models" "accounting-app/internal/repository" ) // Category service errors var ( ErrCategoryNotFound = errors.New("category not found") ErrCategoryInUse = errors.New("category is in use and cannot be deleted") ErrCategoryHasChildren = errors.New("category has children and cannot be deleted") ErrInvalidParentCategory = errors.New("invalid parent category") ErrParentTypeMismatch = errors.New("parent category type must match child category type") ErrCircularReference = errors.New("circular reference detected in category hierarchy") ErrParentIsChild = errors.New("cannot set a child category as parent") ) // CategoryInput represents the input data for creating or updating a category type CategoryInput struct { UserID uint `json:"user_id"` Name string `json:"name" binding:"required"` Icon string `json:"icon"` Type models.CategoryType `json:"type" binding:"required"` ParentID *uint `json:"parent_id,omitempty"` SortOrder int `json:"sort_order"` } // CategoryService handles business logic for categories type CategoryService struct { repo *repository.CategoryRepository } // NewCategoryService creates a new CategoryService instance func NewCategoryService(repo *repository.CategoryRepository) *CategoryService { return &CategoryService{ repo: repo, } } // CreateCategory creates a new category with business logic validation func (s *CategoryService) CreateCategory(input CategoryInput) (*models.Category, error) { // Validate parent category if provided if input.ParentID != nil { parent, err := s.repo.GetByID(input.UserID, *input.ParentID) if err != nil { if errors.Is(err, repository.ErrCategoryNotFound) { return nil, ErrInvalidParentCategory } return nil, fmt.Errorf("failed to validate parent category: %w", err) } // userID check handled by repo // Ensure parent category type matches the new category type if parent.Type != input.Type { return nil, ErrParentTypeMismatch } // Ensure parent is not already a child (only allow 2 levels) if parent.ParentID != nil { return nil, ErrParentIsChild } } // Create the category model category := &models.Category{ UserID: input.UserID, Name: input.Name, Icon: input.Icon, Type: input.Type, ParentID: input.ParentID, SortOrder: input.SortOrder, } // Save to database if err := s.repo.Create(category); err != nil { return nil, fmt.Errorf("failed to create category: %w", err) } return category, nil } // GetCategory retrieves a category by ID and verifies ownership func (s *CategoryService) GetCategory(userID, id uint) (*models.Category, error) { category, err := s.repo.GetByID(userID, id) if err != nil { if errors.Is(err, repository.ErrCategoryNotFound) { return nil, ErrCategoryNotFound } return nil, fmt.Errorf("failed to get category: %w", err) } // userID check handled by repo return category, nil } // GetCategoryWithChildren retrieves a category with its children and verifies ownership func (s *CategoryService) GetCategoryWithChildren(userID, id uint) (*models.Category, error) { category, err := s.repo.GetWithChildren(userID, id) if err != nil { if errors.Is(err, repository.ErrCategoryNotFound) { return nil, ErrCategoryNotFound } return nil, fmt.Errorf("failed to get category with children: %w", err) } // userID check handled by repo return category, nil } // GetAllCategories retrieves all categories for a user func (s *CategoryService) GetAllCategories(userID uint) ([]models.Category, error) { categories, err := s.repo.GetAll(userID) if err != nil { return nil, fmt.Errorf("failed to get categories: %w", err) } return categories, nil } // GetCategoriesByType retrieves all categories of a specific type for a user func (s *CategoryService) GetCategoriesByType(userID uint, categoryType models.CategoryType) ([]models.Category, error) { categories, err := s.repo.GetByType(userID, categoryType) if err != nil { return nil, fmt.Errorf("failed to get categories by type: %w", err) } return categories, nil } // GetCategoryTree retrieves all categories in a hierarchical tree structure for a user // Returns only root categories with their children preloaded func (s *CategoryService) GetCategoryTree(userID uint) ([]models.Category, error) { categories, err := s.repo.GetAllWithChildren(userID) if err != nil { return nil, fmt.Errorf("failed to get category tree: %w", err) } return categories, nil } // GetCategoryTreeByType retrieves categories of a specific type in a hierarchical tree structure for a user func (s *CategoryService) GetCategoryTreeByType(userID uint, categoryType models.CategoryType) ([]models.Category, error) { // Get root categories of the specified type rootCategories, err := s.repo.GetRootCategoriesByType(userID, categoryType) if err != nil { return nil, fmt.Errorf("failed to get root categories by type: %w", err) } // Load children for each root category for i := range rootCategories { children, err := s.repo.GetChildren(userID, rootCategories[i].ID) if err != nil { return nil, fmt.Errorf("failed to get children for category %d: %w", rootCategories[i].ID, err) } rootCategories[i].Children = children } return rootCategories, nil } // GetRootCategories retrieves all root categories (categories without parent) for a user func (s *CategoryService) GetRootCategories(userID uint) ([]models.Category, error) { categories, err := s.repo.GetRootCategories(userID) if err != nil { return nil, fmt.Errorf("failed to get root categories: %w", err) } return categories, nil } // GetChildCategories retrieves all child categories of a given parent func (s *CategoryService) GetChildCategories(userID, parentID uint) ([]models.Category, error) { // Verify parent exists _, err := s.repo.GetByID(userID, parentID) if err != nil { if errors.Is(err, repository.ErrCategoryNotFound) { return nil, ErrCategoryNotFound } return nil, fmt.Errorf("failed to verify parent category: %w", err) } // userID check handled by repo children, err := s.repo.GetChildren(userID, parentID) if err != nil { return nil, fmt.Errorf("failed to get child categories: %w", err) } return children, nil } // UpdateCategory updates an existing category after verifying ownership func (s *CategoryService) UpdateCategory(userID, id uint, input CategoryInput) (*models.Category, error) { // Get existing category category, err := s.repo.GetByID(userID, id) if err != nil { if errors.Is(err, repository.ErrCategoryNotFound) { return nil, ErrCategoryNotFound } return nil, fmt.Errorf("failed to get category: %w", err) } // userID check handled by repo // Validate parent category if provided if input.ParentID != nil { // Cannot set self as parent if *input.ParentID == id { return nil, ErrCircularReference } parent, err := s.repo.GetByID(userID, *input.ParentID) if err != nil { if errors.Is(err, repository.ErrCategoryNotFound) { return nil, ErrInvalidParentCategory } return nil, fmt.Errorf("failed to validate parent category: %w", err) } // userID check handled by repo // Ensure parent category type matches if parent.Type != input.Type { return nil, ErrParentTypeMismatch } // Ensure parent is not already a child (only allow 2 levels) if parent.ParentID != nil { return nil, ErrParentIsChild } // Check if the new parent is a child of the current category (circular reference) if parent.ParentID != nil && *parent.ParentID == id { return nil, ErrCircularReference } } // If this category has children and we're trying to set a parent, reject // (would create more than 2 levels) if input.ParentID != nil { children, err := s.repo.GetChildren(userID, id) if err != nil { return nil, fmt.Errorf("failed to check children: %w", err) } if len(children) > 0 { return nil, ErrParentIsChild } } // Update fields category.Name = input.Name category.Icon = input.Icon category.Type = input.Type category.ParentID = input.ParentID category.SortOrder = input.SortOrder // Save to database if err := s.repo.Update(category); err != nil { return nil, fmt.Errorf("failed to update category: %w", err) } return category, nil } // DeleteCategory deletes a category by ID after verifying ownership func (s *CategoryService) DeleteCategory(userID, id uint) error { _, err := s.repo.GetByID(userID, id) if err != nil { if errors.Is(err, repository.ErrCategoryNotFound) { return ErrCategoryNotFound } return fmt.Errorf("failed to check category existence: %w", err) } // userID check handled by repo err = s.repo.Delete(userID, id) if err != nil { if errors.Is(err, repository.ErrCategoryNotFound) { return ErrCategoryNotFound } if errors.Is(err, repository.ErrCategoryInUse) { return ErrCategoryInUse } if errors.Is(err, repository.ErrCategoryHasChildren) { return ErrCategoryHasChildren } return fmt.Errorf("failed to delete category: %w", err) } return nil } // CategoryExists checks if a category exists by ID func (s *CategoryService) CategoryExists(userID uint, id uint) (bool, error) { exists, err := s.repo.ExistsByID(userID, id) if err != nil { return false, fmt.Errorf("failed to check category existence: %w", err) } return exists, nil } // GetCategoryPath returns the full path of a category (parent -> child) func (s *CategoryService) GetCategoryPath(userID, id uint) ([]models.Category, error) { category, err := s.repo.GetWithParent(userID, id) if err != nil { if errors.Is(err, repository.ErrCategoryNotFound) { return nil, ErrCategoryNotFound } return nil, fmt.Errorf("failed to get category: %w", err) } // userID check handled by repo path := []models.Category{} if category.Parent != nil { path = append(path, *category.Parent) } path = append(path, *category) return path, nil }