package service import ( "encoding/json" "errors" "fmt" "time" "accounting-app/internal/models" "accounting-app/internal/repository" "gorm.io/gorm" ) // Service layer errors for piggy banks var ( ErrPiggyBankNotFound = errors.New("piggy bank not found") ErrPiggyBankInUse = errors.New("piggy bank is in use and cannot be deleted") ErrInvalidTargetAmount = errors.New("target amount must be positive") ErrInvalidDepositAmount = errors.New("deposit amount must be positive") ErrInvalidWithdrawAmount = errors.New("withdraw amount must be positive") ErrInvalidPiggyBankType = errors.New("invalid piggy bank type") ErrInvalidAutoRule = errors.New("invalid auto rule format") ErrInsufficientAccountFunds = errors.New("insufficient funds in linked account") ) // PiggyBankInput represents the input data for creating or updating a piggy bank type PiggyBankInput struct { UserID uint `json:"user_id"` Name string `json:"name" binding:"required"` TargetAmount float64 `json:"target_amount" binding:"required,gt=0"` Type models.PiggyBankType `json:"type" binding:"required"` TargetDate *time.Time `json:"target_date,omitempty"` LinkedAccountID *uint `json:"linked_account_id,omitempty"` AutoRule *AutoDepositRule `json:"auto_rule,omitempty"` } // AutoDepositRule represents the automatic deposit rule for auto piggy banks type AutoDepositRule struct { Frequency string `json:"frequency"` // daily, weekly, monthly Amount float64 `json:"amount"` DayOfWeek *int `json:"day_of_week,omitempty"` // 0-6 for weekly DayOfMonth *int `json:"day_of_month,omitempty"` // 1-31 for monthly } // DepositInput represents the input for depositing to a piggy bank type DepositInput struct { Amount float64 `json:"amount" binding:"required,gt=0"` FromAccountID *uint `json:"from_account_id,omitempty"` Note string `json:"note,omitempty"` } // WithdrawInput represents the input for withdrawing from a piggy bank type WithdrawInput struct { Amount float64 `json:"amount" binding:"required,gt=0"` ToAccountID *uint `json:"to_account_id,omitempty"` Note string `json:"note,omitempty"` } // PiggyBankProgress represents the progress of a piggy bank type PiggyBankProgress struct { PiggyBankID uint `json:"piggy_bank_id"` Name string `json:"name"` TargetAmount float64 `json:"target_amount"` CurrentAmount float64 `json:"current_amount"` Remaining float64 `json:"remaining"` Progress float64 `json:"progress"` // Percentage (0-100) Type models.PiggyBankType `json:"type"` TargetDate *time.Time `json:"target_date,omitempty"` IsCompleted bool `json:"is_completed"` DaysRemaining *int `json:"days_remaining,omitempty"` LinkedAccountID *uint `json:"linked_account_id,omitempty"` } // PiggyBankService handles business logic for piggy banks type PiggyBankService struct { repo *repository.PiggyBankRepository accountRepo *repository.AccountRepository db *gorm.DB } // NewPiggyBankService creates a new PiggyBankService instance func NewPiggyBankService(repo *repository.PiggyBankRepository, accountRepo *repository.AccountRepository, db *gorm.DB) *PiggyBankService { return &PiggyBankService{ repo: repo, accountRepo: accountRepo, db: db, } } // CreatePiggyBank creates a new piggy bank with business logic validation func (s *PiggyBankService) CreatePiggyBank(input PiggyBankInput) (*models.PiggyBank, error) { // Validate target amount if input.TargetAmount <= 0 { return nil, ErrInvalidTargetAmount } // Validate piggy bank type if !isValidPiggyBankType(input.Type) { return nil, ErrInvalidPiggyBankType } // Validate linked account if specified if input.LinkedAccountID != nil { exists, err := s.accountRepo.ExistsByID(input.UserID, *input.LinkedAccountID) if err != nil { return nil, fmt.Errorf("failed to check account existence: %w", err) } if !exists { return nil, ErrAccountNotFound } } // For auto piggy banks, validate auto rule var autoRuleJSON string if input.Type == models.PiggyBankTypeAuto || input.Type == models.PiggyBankTypeFixedDeposit || input.Type == models.PiggyBankTypeWeek52 { if input.AutoRule != nil { ruleBytes, err := json.Marshal(input.AutoRule) if err != nil { return nil, ErrInvalidAutoRule } autoRuleJSON = string(ruleBytes) } else if input.Type == models.PiggyBankTypeAuto || input.Type == models.PiggyBankTypeFixedDeposit { // Auto and fixed deposit types require auto rule return nil, ErrInvalidAutoRule } } // Create the piggy bank model piggyBank := &models.PiggyBank{ UserID: input.UserID, Name: input.Name, TargetAmount: input.TargetAmount, CurrentAmount: 0, Type: input.Type, TargetDate: input.TargetDate, LinkedAccountID: input.LinkedAccountID, AutoRule: autoRuleJSON, } // Save to database if err := s.repo.Create(piggyBank); err != nil { return nil, fmt.Errorf("failed to create piggy bank: %w", err) } return piggyBank, nil } // GetPiggyBank retrieves a piggy bank by ID and verifies ownership func (s *PiggyBankService) GetPiggyBank(userID, id uint) (*models.PiggyBank, error) { piggyBank, err := s.repo.GetByID(userID, id) if err != nil { if errors.Is(err, repository.ErrPiggyBankNotFound) { return nil, ErrPiggyBankNotFound } return nil, fmt.Errorf("failed to get piggy bank: %w", err) } // userID check handled by repo return piggyBank, nil } // GetAllPiggyBanks retrieves all piggy banks for a user func (s *PiggyBankService) GetAllPiggyBanks(userID uint) ([]models.PiggyBank, error) { piggyBanks, err := s.repo.GetAll(userID) if err != nil { return nil, fmt.Errorf("failed to get piggy banks: %w", err) } return piggyBanks, nil } // UpdatePiggyBank updates an existing piggy bank after verifying ownership func (s *PiggyBankService) UpdatePiggyBank(userID, id uint, input PiggyBankInput) (*models.PiggyBank, error) { // Get existing piggy bank piggyBank, err := s.repo.GetByID(userID, id) if err != nil { if errors.Is(err, repository.ErrPiggyBankNotFound) { return nil, ErrPiggyBankNotFound } return nil, fmt.Errorf("failed to get piggy bank: %w", err) } // userID check handled by repo // Validate target amount if input.TargetAmount <= 0 { return nil, ErrInvalidTargetAmount } // Validate piggy bank type if !isValidPiggyBankType(input.Type) { return nil, ErrInvalidPiggyBankType } // Validate linked account if specified if input.LinkedAccountID != nil { exists, err := s.accountRepo.ExistsByID(userID, *input.LinkedAccountID) if err != nil { return nil, fmt.Errorf("failed to check account existence: %w", err) } if !exists { return nil, ErrAccountNotFound } } // For auto piggy banks, validate auto rule var autoRuleJSON string if input.Type == models.PiggyBankTypeAuto || input.Type == models.PiggyBankTypeFixedDeposit || input.Type == models.PiggyBankTypeWeek52 { if input.AutoRule != nil { ruleBytes, err := json.Marshal(input.AutoRule) if err != nil { return nil, ErrInvalidAutoRule } autoRuleJSON = string(ruleBytes) } else if input.Type == models.PiggyBankTypeAuto || input.Type == models.PiggyBankTypeFixedDeposit { // Auto and fixed deposit types require auto rule return nil, ErrInvalidAutoRule } } // Update fields piggyBank.Name = input.Name piggyBank.TargetAmount = input.TargetAmount piggyBank.Type = input.Type piggyBank.TargetDate = input.TargetDate piggyBank.LinkedAccountID = input.LinkedAccountID piggyBank.AutoRule = autoRuleJSON // Save to database if err := s.repo.Update(piggyBank); err != nil { return nil, fmt.Errorf("failed to update piggy bank: %w", err) } return piggyBank, nil } // DeletePiggyBank deletes a piggy bank by ID after verifying ownership func (s *PiggyBankService) DeletePiggyBank(userID, id uint) error { _, err := s.repo.GetByID(userID, id) if err != nil { if errors.Is(err, repository.ErrPiggyBankNotFound) { return ErrPiggyBankNotFound } return fmt.Errorf("failed to check piggy bank existence: %w", err) } // userID check handled by repo err = s.repo.Delete(userID, id) if err != nil { if errors.Is(err, repository.ErrPiggyBankNotFound) { return ErrPiggyBankNotFound } if errors.Is(err, repository.ErrPiggyBankInUse) { return ErrPiggyBankInUse } return fmt.Errorf("failed to delete piggy bank: %w", err) } return nil } // Deposit adds money to a piggy bank // If fromAccountID is provided, it will deduct from that account func (s *PiggyBankService) Deposit(userID, id uint, input DepositInput) (*models.PiggyBank, error) { // Validate deposit amount if input.Amount <= 0 { return nil, ErrInvalidDepositAmount } // Start a transaction tx := s.db.Begin() defer func() { if r := recover(); r != nil { tx.Rollback() } }() // Get the piggy bank using the transaction var piggyBank models.PiggyBank if err := tx.Preload("LinkedAccount").First(&piggyBank, id).Error; err != nil { tx.Rollback() if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrPiggyBankNotFound } return nil, fmt.Errorf("failed to get piggy bank: %w", err) } // If fromAccountID is provided, deduct from that account if input.FromAccountID != nil { var account models.Account if err := tx.Where("user_id = ?", userID).First(&account, *input.FromAccountID).Error; err != nil { tx.Rollback() if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrAccountNotFound } return nil, fmt.Errorf("failed to get account: %w", err) } // Check if account has sufficient funds (only for non-credit accounts) if !account.IsCredit && account.Balance < input.Amount { tx.Rollback() return nil, ErrInsufficientAccountFunds } // Deduct from account account.Balance -= input.Amount if err := tx.Save(&account).Error; err != nil { tx.Rollback() return nil, fmt.Errorf("failed to update account balance: %w", err) } } // Add to piggy bank piggyBank.CurrentAmount += input.Amount // Update piggy bank if err := tx.Save(&piggyBank).Error; err != nil { tx.Rollback() return nil, fmt.Errorf("failed to update piggy bank: %w", err) } // Commit transaction if err := tx.Commit().Error; err != nil { return nil, fmt.Errorf("failed to commit transaction: %w", err) } return &piggyBank, nil } // Withdraw removes money from a piggy bank (breaking the piggy bank) // If toAccountID is provided, it will add to that account func (s *PiggyBankService) Withdraw(userID, id uint, input WithdrawInput) (*models.PiggyBank, error) { // Validate withdraw amount if input.Amount <= 0 { return nil, ErrInvalidWithdrawAmount } // Start a transaction tx := s.db.Begin() defer func() { if r := recover(); r != nil { tx.Rollback() } }() // Get the piggy bank using the transaction var piggyBank models.PiggyBank if err := tx.Preload("LinkedAccount").Where("user_id = ?", userID).First(&piggyBank, id).Error; err != nil { tx.Rollback() if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrPiggyBankNotFound } return nil, fmt.Errorf("failed to get piggy bank: %w", err) } // Check if piggy bank has sufficient balance if piggyBank.CurrentAmount < input.Amount { tx.Rollback() return nil, ErrInsufficientBalance } // Deduct from piggy bank piggyBank.CurrentAmount -= input.Amount // Update piggy bank if err := tx.Save(&piggyBank).Error; err != nil { tx.Rollback() return nil, fmt.Errorf("failed to update piggy bank: %w", err) } // If toAccountID is provided, add to that account if input.ToAccountID != nil { var account models.Account if err := tx.Where("user_id = ?", userID).First(&account, *input.ToAccountID).Error; err != nil { tx.Rollback() if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrAccountNotFound } return nil, fmt.Errorf("failed to get account: %w", err) } // Add to account account.Balance += input.Amount if err := tx.Save(&account).Error; err != nil { tx.Rollback() return nil, fmt.Errorf("failed to update account balance: %w", err) } } // Commit transaction if err := tx.Commit().Error; err != nil { return nil, fmt.Errorf("failed to commit transaction: %w", err) } return &piggyBank, nil } // GetPiggyBankProgress calculates and returns the progress of a piggy bank for a user func (s *PiggyBankService) GetPiggyBankProgress(userID, id uint) (*PiggyBankProgress, error) { // Get the piggy bank piggyBank, err := s.repo.GetByID(userID, id) if err != nil { if errors.Is(err, repository.ErrPiggyBankNotFound) { return nil, ErrPiggyBankNotFound } return nil, fmt.Errorf("failed to get piggy bank: %w", err) } // userID check handled by repo // Calculate progress metrics remaining := piggyBank.TargetAmount - piggyBank.CurrentAmount if remaining < 0 { remaining = 0 } progress := 0.0 if piggyBank.TargetAmount > 0 { progress = (piggyBank.CurrentAmount / piggyBank.TargetAmount) * 100 if progress > 100 { progress = 100 } } isCompleted := piggyBank.CurrentAmount >= piggyBank.TargetAmount // Calculate days remaining if target date is set var daysRemaining *int if piggyBank.TargetDate != nil { days := int(time.Until(*piggyBank.TargetDate).Hours() / 24) daysRemaining = &days } return &PiggyBankProgress{ PiggyBankID: piggyBank.ID, Name: piggyBank.Name, TargetAmount: piggyBank.TargetAmount, CurrentAmount: piggyBank.CurrentAmount, Remaining: remaining, Progress: progress, Type: piggyBank.Type, TargetDate: piggyBank.TargetDate, IsCompleted: isCompleted, DaysRemaining: daysRemaining, LinkedAccountID: piggyBank.LinkedAccountID, }, nil } // GetAllPiggyBankProgress returns progress for all piggy banks for a user func (s *PiggyBankService) GetAllPiggyBankProgress(userID uint) ([]PiggyBankProgress, error) { piggyBanks, err := s.repo.GetAll(userID) if err != nil { return nil, fmt.Errorf("failed to get piggy banks: %w", err) } var progressList []PiggyBankProgress for _, piggyBank := range piggyBanks { progress, err := s.GetPiggyBankProgress(userID, piggyBank.ID) if err != nil { return nil, fmt.Errorf("failed to calculate progress for piggy bank %d: %w", piggyBank.ID, err) } progressList = append(progressList, *progress) } return progressList, nil } // GetActivePiggyBanks retrieves all piggy banks that haven't reached their target yet for a user func (s *PiggyBankService) GetActivePiggyBanks(userID uint) ([]models.PiggyBank, error) { piggyBanks, err := s.repo.GetActiveGoals(userID) if err != nil { return nil, fmt.Errorf("failed to get active piggy banks: %w", err) } return piggyBanks, nil } // GetCompletedPiggyBanks retrieves all piggy banks that have reached their target for a user func (s *PiggyBankService) GetCompletedPiggyBanks(userID uint) ([]models.PiggyBank, error) { piggyBanks, err := s.repo.GetCompletedGoals(userID) if err != nil { return nil, fmt.Errorf("failed to get completed piggy banks: %w", err) } return piggyBanks, nil } // GetPiggyBanksByType retrieves all piggy banks of a specific type for a user func (s *PiggyBankService) GetPiggyBanksByType(userID uint, piggyBankType models.PiggyBankType) ([]models.PiggyBank, error) { piggyBanks, err := s.repo.GetByType(userID, piggyBankType) if err != nil { return nil, fmt.Errorf("failed to get piggy banks by type: %w", err) } return piggyBanks, nil } // ProcessAutoDeposits processes automatic deposits for all auto piggy banks of a user // This should be called by a scheduled job func (s *PiggyBankService) ProcessAutoDeposits(userID uint) error { // Get all auto piggy banks autoPiggyBanks, err := s.repo.GetByType(userID, models.PiggyBankTypeAuto) if err != nil { return fmt.Errorf("failed to get auto piggy banks: %w", err) } fixedDepositPiggyBanks, err := s.repo.GetByType(userID, models.PiggyBankTypeFixedDeposit) if err != nil { return fmt.Errorf("failed to get fixed deposit piggy banks: %w", err) } week52PiggyBanks, err := s.repo.GetByType(userID, models.PiggyBankTypeWeek52) if err != nil { return fmt.Errorf("failed to get week 52 piggy banks: %w", err) } allAutoPiggyBanks := append(autoPiggyBanks, fixedDepositPiggyBanks...) allAutoPiggyBanks = append(allAutoPiggyBanks, week52PiggyBanks...) now := time.Now() for _, piggyBank := range allAutoPiggyBanks { // Skip if already completed if piggyBank.CurrentAmount >= piggyBank.TargetAmount { continue } // Parse auto rule if piggyBank.AutoRule == "" { continue } var rule AutoDepositRule if err := json.Unmarshal([]byte(piggyBank.AutoRule), &rule); err != nil { continue } // Check if deposit should be made based on frequency shouldDeposit := false depositAmount := rule.Amount switch rule.Frequency { case "daily": shouldDeposit = true case "weekly": if rule.DayOfWeek != nil && int(now.Weekday()) == *rule.DayOfWeek { shouldDeposit = true } case "monthly": if rule.DayOfMonth != nil && now.Day() == *rule.DayOfMonth { shouldDeposit = true } } // For Week 52 type, calculate the week number and deposit amount if piggyBank.Type == models.PiggyBankTypeWeek52 { // Calculate week number since creation weeksSinceCreation := int(time.Since(piggyBank.CreatedAt).Hours() / 24 / 7) if weeksSinceCreation < 52 { depositAmount = float64(weeksSinceCreation + 1) // Week 1: $1, Week 2: $2, etc. shouldDeposit = int(now.Weekday()) == 1 // Monday } } if shouldDeposit && piggyBank.LinkedAccountID != nil { // Make the deposit _, err := s.Deposit(userID, piggyBank.ID, DepositInput{ Amount: depositAmount, FromAccountID: piggyBank.LinkedAccountID, Note: "Automatic deposit", }) if err != nil { // Log error but continue with other piggy banks fmt.Printf("Failed to process auto deposit for piggy bank %d: %v\n", piggyBank.ID, err) } } } return nil } // isValidPiggyBankType checks if a piggy bank type is valid func isValidPiggyBankType(piggyBankType models.PiggyBankType) bool { switch piggyBankType { case models.PiggyBankTypeManual, models.PiggyBankTypeAuto, models.PiggyBankTypeFixedDeposit, models.PiggyBankTypeWeek52: return true default: return false } }