init
This commit is contained in:
396
internal/service/budget_service.go
Normal file
396
internal/service/budget_service.go
Normal file
@@ -0,0 +1,396 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"accounting-app/internal/models"
|
||||
"accounting-app/internal/repository"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Service layer errors for budgets
|
||||
var (
|
||||
ErrBudgetNotFound = errors.New("budget not found")
|
||||
ErrBudgetInUse = errors.New("budget is in use and cannot be deleted")
|
||||
ErrInvalidBudgetAmount = errors.New("budget amount must be positive")
|
||||
ErrInvalidDateRange = errors.New("end date must be after start date")
|
||||
ErrInvalidPeriodType = errors.New("invalid period type")
|
||||
ErrCategoryOrAccountRequired = errors.New("either category or account must be specified")
|
||||
)
|
||||
|
||||
// BudgetInput represents the input data for creating or updating a budget
|
||||
type BudgetInput struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Amount float64 `json:"amount" binding:"required,gt=0"`
|
||||
PeriodType models.PeriodType `json:"period_type" binding:"required"`
|
||||
CategoryID *uint `json:"category_id,omitempty"`
|
||||
AccountID *uint `json:"account_id,omitempty"`
|
||||
IsRolling bool `json:"is_rolling"`
|
||||
StartDate time.Time `json:"start_date" binding:"required"`
|
||||
EndDate *time.Time `json:"end_date,omitempty"`
|
||||
}
|
||||
|
||||
// BudgetProgress represents the progress of a budget
|
||||
type BudgetProgress struct {
|
||||
BudgetID uint `json:"budget_id"`
|
||||
Name string `json:"name"`
|
||||
Amount float64 `json:"amount"`
|
||||
Spent float64 `json:"spent"`
|
||||
Remaining float64 `json:"remaining"`
|
||||
Progress float64 `json:"progress"` // Percentage (0-100)
|
||||
PeriodType models.PeriodType `json:"period_type"`
|
||||
CurrentPeriod string `json:"current_period"`
|
||||
IsRolling bool `json:"is_rolling"`
|
||||
IsOverBudget bool `json:"is_over_budget"`
|
||||
IsNearLimit bool `json:"is_near_limit"` // 80% threshold
|
||||
CategoryID *uint `json:"category_id,omitempty"`
|
||||
AccountID *uint `json:"account_id,omitempty"`
|
||||
}
|
||||
|
||||
// BudgetService handles business logic for budgets
|
||||
type BudgetService struct {
|
||||
repo *repository.BudgetRepository
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewBudgetService creates a new BudgetService instance
|
||||
func NewBudgetService(repo *repository.BudgetRepository, db *gorm.DB) *BudgetService {
|
||||
return &BudgetService{
|
||||
repo: repo,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateBudget creates a new budget with business logic validation
|
||||
func (s *BudgetService) CreateBudget(input BudgetInput) (*models.Budget, error) {
|
||||
// Validate amount
|
||||
if input.Amount <= 0 {
|
||||
return nil, ErrInvalidBudgetAmount
|
||||
}
|
||||
|
||||
// Validate that at least category or account is specified
|
||||
if input.CategoryID == nil && input.AccountID == nil {
|
||||
return nil, ErrCategoryOrAccountRequired
|
||||
}
|
||||
|
||||
// Validate date range
|
||||
if input.EndDate != nil && input.EndDate.Before(input.StartDate) {
|
||||
return nil, ErrInvalidDateRange
|
||||
}
|
||||
|
||||
// Validate period type
|
||||
if !isValidPeriodType(input.PeriodType) {
|
||||
return nil, ErrInvalidPeriodType
|
||||
}
|
||||
|
||||
// Create the budget model
|
||||
budget := &models.Budget{
|
||||
UserID: input.UserID,
|
||||
Name: input.Name,
|
||||
Amount: input.Amount,
|
||||
PeriodType: input.PeriodType,
|
||||
CategoryID: input.CategoryID,
|
||||
AccountID: input.AccountID,
|
||||
IsRolling: input.IsRolling,
|
||||
StartDate: input.StartDate,
|
||||
EndDate: input.EndDate,
|
||||
}
|
||||
|
||||
// Save to database
|
||||
if err := s.repo.Create(budget); err != nil {
|
||||
return nil, fmt.Errorf("failed to create budget: %w", err)
|
||||
}
|
||||
|
||||
return budget, nil
|
||||
}
|
||||
|
||||
// GetBudget retrieves a budget by ID and verifies ownership
|
||||
func (s *BudgetService) GetBudget(userID, id uint) (*models.Budget, error) {
|
||||
budget, err := s.repo.GetByID(userID, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrBudgetNotFound) {
|
||||
return nil, ErrBudgetNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get budget: %w", err)
|
||||
}
|
||||
// userID check handled by repo
|
||||
return budget, nil
|
||||
}
|
||||
|
||||
// GetAllBudgets retrieves all budgets for a user
|
||||
func (s *BudgetService) GetAllBudgets(userID uint) ([]models.Budget, error) {
|
||||
budgets, err := s.repo.GetAll(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get budgets: %w", err)
|
||||
}
|
||||
return budgets, nil
|
||||
}
|
||||
|
||||
// UpdateBudget updates an existing budget after verifying ownership
|
||||
func (s *BudgetService) UpdateBudget(userID, id uint, input BudgetInput) (*models.Budget, error) {
|
||||
// Get existing budget
|
||||
budget, err := s.repo.GetByID(userID, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrBudgetNotFound) {
|
||||
return nil, ErrBudgetNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get budget: %w", err)
|
||||
}
|
||||
// userID check handled by repo
|
||||
|
||||
// Validate amount
|
||||
if input.Amount <= 0 {
|
||||
return nil, ErrInvalidBudgetAmount
|
||||
}
|
||||
|
||||
// Validate that at least category or account is specified
|
||||
if input.CategoryID == nil && input.AccountID == nil {
|
||||
return nil, ErrCategoryOrAccountRequired
|
||||
}
|
||||
|
||||
// Validate date range
|
||||
if input.EndDate != nil && input.EndDate.Before(input.StartDate) {
|
||||
return nil, ErrInvalidDateRange
|
||||
}
|
||||
|
||||
// Validate period type
|
||||
if !isValidPeriodType(input.PeriodType) {
|
||||
return nil, ErrInvalidPeriodType
|
||||
}
|
||||
|
||||
// Update fields
|
||||
budget.Name = input.Name
|
||||
budget.Amount = input.Amount
|
||||
budget.PeriodType = input.PeriodType
|
||||
budget.CategoryID = input.CategoryID
|
||||
budget.AccountID = input.AccountID
|
||||
budget.IsRolling = input.IsRolling
|
||||
budget.StartDate = input.StartDate
|
||||
budget.EndDate = input.EndDate
|
||||
|
||||
// Save to database
|
||||
if err := s.repo.Update(budget); err != nil {
|
||||
return nil, fmt.Errorf("failed to update budget: %w", err)
|
||||
}
|
||||
|
||||
return budget, nil
|
||||
}
|
||||
|
||||
// DeleteBudget deletes a budget by ID after verifying ownership
|
||||
func (s *BudgetService) DeleteBudget(userID, id uint) error {
|
||||
_, err := s.repo.GetByID(userID, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrBudgetNotFound) {
|
||||
return ErrBudgetNotFound
|
||||
}
|
||||
return fmt.Errorf("failed to check budget existence: %w", err)
|
||||
}
|
||||
// userID check handled by repo
|
||||
|
||||
err = s.repo.Delete(userID, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrBudgetNotFound) {
|
||||
return ErrBudgetNotFound
|
||||
}
|
||||
if errors.Is(err, repository.ErrBudgetInUse) {
|
||||
return ErrBudgetInUse
|
||||
}
|
||||
return fmt.Errorf("failed to delete budget: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBudgetProgress calculates and returns the progress of a budget for a user
|
||||
// This implements the core budget progress calculation logic for weekly, monthly, and rolling budgets
|
||||
func (s *BudgetService) GetBudgetProgress(userID, id uint) (*BudgetProgress, error) {
|
||||
// Get the budget
|
||||
budget, err := s.repo.GetByID(userID, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrBudgetNotFound) {
|
||||
return nil, ErrBudgetNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get budget: %w", err)
|
||||
}
|
||||
// userID check handled by repo
|
||||
|
||||
// Calculate the current period based on budget period type
|
||||
now := time.Now()
|
||||
startDate, endDate := s.calculateCurrentPeriod(budget, now)
|
||||
|
||||
// Get spent amount for the current period
|
||||
spent, err := s.repo.GetSpentAmount(budget, startDate, endDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to calculate spent amount: %w", err)
|
||||
}
|
||||
|
||||
// Calculate effective budget amount (considering rolling budget)
|
||||
effectiveAmount := budget.Amount
|
||||
if budget.IsRolling {
|
||||
// For rolling budgets, add the previous period's remaining balance
|
||||
prevStartDate, prevEndDate := s.calculatePreviousPeriod(budget, now)
|
||||
prevSpent, err := s.repo.GetSpentAmount(budget, prevStartDate, prevEndDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to calculate previous period spent: %w", err)
|
||||
}
|
||||
prevRemaining := budget.Amount - prevSpent
|
||||
if prevRemaining > 0 {
|
||||
effectiveAmount += prevRemaining
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate progress metrics
|
||||
remaining := effectiveAmount - spent
|
||||
progress := 0.0
|
||||
if effectiveAmount > 0 {
|
||||
progress = (spent / effectiveAmount) * 100
|
||||
}
|
||||
|
||||
isOverBudget := spent > effectiveAmount
|
||||
isNearLimit := progress >= 80.0 && !isOverBudget
|
||||
|
||||
return &BudgetProgress{
|
||||
BudgetID: budget.ID,
|
||||
Name: budget.Name,
|
||||
Amount: effectiveAmount,
|
||||
Spent: spent,
|
||||
Remaining: remaining,
|
||||
Progress: progress,
|
||||
PeriodType: budget.PeriodType,
|
||||
CurrentPeriod: formatPeriod(startDate, endDate),
|
||||
IsRolling: budget.IsRolling,
|
||||
IsOverBudget: isOverBudget,
|
||||
IsNearLimit: isNearLimit,
|
||||
CategoryID: budget.CategoryID,
|
||||
AccountID: budget.AccountID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAllBudgetProgress returns progress for all active budgets for a user
|
||||
func (s *BudgetService) GetAllBudgetProgress(userID uint) ([]BudgetProgress, error) {
|
||||
budgets, err := s.repo.GetActiveBudgets(userID, time.Now())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get active budgets: %w", err)
|
||||
}
|
||||
|
||||
var progressList []BudgetProgress
|
||||
for _, budget := range budgets {
|
||||
progress, err := s.GetBudgetProgress(userID, budget.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to calculate progress for budget %d: %w", budget.ID, err)
|
||||
}
|
||||
progressList = append(progressList, *progress)
|
||||
}
|
||||
|
||||
return progressList, nil
|
||||
}
|
||||
|
||||
// calculateCurrentPeriod calculates the start and end date of the current budget period
|
||||
func (s *BudgetService) calculateCurrentPeriod(budget *models.Budget, referenceDate time.Time) (time.Time, time.Time) {
|
||||
switch budget.PeriodType {
|
||||
case models.PeriodTypeDaily:
|
||||
// Daily budget: current day
|
||||
start := time.Date(referenceDate.Year(), referenceDate.Month(), referenceDate.Day(), 0, 0, 0, 0, referenceDate.Location())
|
||||
end := start.AddDate(0, 0, 1).Add(-time.Second)
|
||||
return start, end
|
||||
|
||||
case models.PeriodTypeWeekly:
|
||||
// Weekly budget: current week (Monday to Sunday)
|
||||
weekday := int(referenceDate.Weekday())
|
||||
if weekday == 0 { // Sunday
|
||||
weekday = 7
|
||||
}
|
||||
daysFromMonday := weekday - 1
|
||||
start := time.Date(referenceDate.Year(), referenceDate.Month(), referenceDate.Day()-daysFromMonday, 0, 0, 0, 0, referenceDate.Location())
|
||||
end := start.AddDate(0, 0, 7).Add(-time.Second)
|
||||
return start, end
|
||||
|
||||
case models.PeriodTypeMonthly:
|
||||
// Monthly budget: current month
|
||||
start := time.Date(referenceDate.Year(), referenceDate.Month(), 1, 0, 0, 0, 0, referenceDate.Location())
|
||||
end := start.AddDate(0, 1, 0).Add(-time.Second)
|
||||
return start, end
|
||||
|
||||
case models.PeriodTypeYearly:
|
||||
// Yearly budget: current year
|
||||
start := time.Date(referenceDate.Year(), 1, 1, 0, 0, 0, 0, referenceDate.Location())
|
||||
end := start.AddDate(1, 0, 0).Add(-time.Second)
|
||||
return start, end
|
||||
|
||||
default:
|
||||
// Default to monthly
|
||||
start := time.Date(referenceDate.Year(), referenceDate.Month(), 1, 0, 0, 0, 0, referenceDate.Location())
|
||||
end := start.AddDate(0, 1, 0).Add(-time.Second)
|
||||
return start, end
|
||||
}
|
||||
}
|
||||
|
||||
// calculatePreviousPeriod calculates the start and end date of the previous budget period
|
||||
func (s *BudgetService) calculatePreviousPeriod(budget *models.Budget, referenceDate time.Time) (time.Time, time.Time) {
|
||||
switch budget.PeriodType {
|
||||
case models.PeriodTypeDaily:
|
||||
prevDay := referenceDate.AddDate(0, 0, -1)
|
||||
return s.calculateCurrentPeriod(budget, prevDay)
|
||||
|
||||
case models.PeriodTypeWeekly:
|
||||
prevWeek := referenceDate.AddDate(0, 0, -7)
|
||||
return s.calculateCurrentPeriod(budget, prevWeek)
|
||||
|
||||
case models.PeriodTypeMonthly:
|
||||
prevMonth := referenceDate.AddDate(0, -1, 0)
|
||||
return s.calculateCurrentPeriod(budget, prevMonth)
|
||||
|
||||
case models.PeriodTypeYearly:
|
||||
prevYear := referenceDate.AddDate(-1, 0, 0)
|
||||
return s.calculateCurrentPeriod(budget, prevYear)
|
||||
|
||||
default:
|
||||
prevMonth := referenceDate.AddDate(0, -1, 0)
|
||||
return s.calculateCurrentPeriod(budget, prevMonth)
|
||||
}
|
||||
}
|
||||
|
||||
// isValidPeriodType checks if a period type is valid
|
||||
func isValidPeriodType(periodType models.PeriodType) bool {
|
||||
switch periodType {
|
||||
case models.PeriodTypeDaily, models.PeriodTypeWeekly, models.PeriodTypeMonthly, models.PeriodTypeYearly:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// formatPeriod formats a period as a string
|
||||
func formatPeriod(start, end time.Time) string {
|
||||
return fmt.Sprintf("%s to %s", start.Format("2006-01-02"), end.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
// GetBudgetsByCategoryID retrieves all budgets for a specific category and user
|
||||
func (s *BudgetService) GetBudgetsByCategoryID(userID, categoryID uint) ([]models.Budget, error) {
|
||||
budgets, err := s.repo.GetByCategoryID(userID, categoryID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get budgets by category: %w", err)
|
||||
}
|
||||
return budgets, nil
|
||||
}
|
||||
|
||||
// GetBudgetsByAccountID retrieves all budgets for a specific account and user
|
||||
func (s *BudgetService) GetBudgetsByAccountID(userID, accountID uint) ([]models.Budget, error) {
|
||||
budgets, err := s.repo.GetByAccountID(userID, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get budgets by account: %w", err)
|
||||
}
|
||||
return budgets, nil
|
||||
}
|
||||
|
||||
// GetActiveBudgets retrieves all currently active budgets for a user
|
||||
func (s *BudgetService) GetActiveBudgets(userID uint) ([]models.Budget, error) {
|
||||
budgets, err := s.repo.GetActiveBudgets(userID, time.Now())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get active budgets: %w", err)
|
||||
}
|
||||
return budgets, nil
|
||||
}
|
||||
Reference in New Issue
Block a user