package service import ( "errors" "fmt" "time" "accounting-app/internal/models" "accounting-app/internal/repository" "gorm.io/gorm" ) // Sub-account service errors var ( ErrSubAccountNotFound = errors.New("sub-account not found") ErrParentAccountNotFound = errors.New("parent account not found") ErrParentIsSubAccount = errors.New("cannot create sub-account under another sub-account") ErrSubAccountNotBelongTo = errors.New("sub-account does not belong to this parent account") ErrInvalidSubAccountType = errors.New("invalid sub-account type") ErrSavingsPotWithdrawLimit = errors.New("withdrawal amount exceeds savings pot balance") ErrSavingsPotDepositLimit = errors.New("deposit amount exceeds available balance") ErrSavingsPotNotFound = errors.New("savings pot not found") ErrNotASavingsPot = errors.New("account is not a savings pot") ErrInsufficientAvailableBalance = errors.New("insufficient available balance") ErrInsufficientSavingsPotBalance = errors.New("insufficient savings pot balance") ) // CreateSubAccountInput represents the input for creating a sub-account type CreateSubAccountInput struct { Name string `json:"name" binding:"required"` SubAccountType models.SubAccountType `json:"sub_account_type" binding:"required"` Balance float64 `json:"balance"` Currency string `json:"currency"` Icon string `json:"icon"` TargetAmount *float64 `json:"target_amount,omitempty"` TargetDate *time.Time `json:"target_date,omitempty"` AnnualRate *float64 `json:"annual_rate,omitempty"` InterestEnabled bool `json:"interest_enabled"` } // UpdateSubAccountInput represents the input for updating a sub-account type UpdateSubAccountInput struct { Name string `json:"name"` Icon string `json:"icon"` TargetAmount *float64 `json:"target_amount,omitempty"` TargetDate *time.Time `json:"target_date,omitempty"` AnnualRate *float64 `json:"annual_rate,omitempty"` InterestEnabled *bool `json:"interest_enabled,omitempty"` } // SubAccountService handles business logic for sub-accounts // Feature: financial-core-upgrade // Validates: Requirements 1.1, 1.4, 1.6, 1.7 type SubAccountService struct { repo *repository.AccountRepository db *gorm.DB } // NewSubAccountService creates a new SubAccountService instance func NewSubAccountService(repo *repository.AccountRepository, db *gorm.DB) *SubAccountService { return &SubAccountService{ repo: repo, db: db, } } // ValidateParentAccount ensures the parent account exists and is not itself a sub-account func (s *SubAccountService) ValidateParentAccount(userID uint, parentID uint) error { parent, err := s.repo.GetByID(userID, parentID) if err != nil { if errors.Is(err, repository.ErrAccountNotFound) { return ErrParentAccountNotFound } return fmt.Errorf("failed to get parent account: %w", err) } // Check if parent is already a sub-account (max depth = 1) if parent.ParentAccountID != nil { return ErrParentIsSubAccount } return nil } // ListSubAccounts retrieves all sub-accounts for a parent account func (s *SubAccountService) ListSubAccounts(userID uint, parentID uint) ([]models.Account, error) { // Validate parent account exists if err := s.ValidateParentAccount(userID, parentID); err != nil { return nil, err } var subAccounts []models.Account err := s.db.Where("parent_account_id = ?", parentID). Order("sort_order ASC, created_at ASC"). Find(&subAccounts).Error if err != nil { return nil, fmt.Errorf("failed to list sub-accounts: %w", err) } return subAccounts, nil } // CreateSubAccount creates a new sub-account under a parent account func (s *SubAccountService) CreateSubAccount(userID uint, parentID uint, input CreateSubAccountInput) (*models.Account, error) { // Validate parent account if err := s.ValidateParentAccount(userID, parentID); err != nil { return nil, err } // Validate sub-account type if !isValidSubAccountType(input.SubAccountType) { return nil, ErrInvalidSubAccountType } // Get parent account for currency default parent, err := s.repo.GetByID(userID, parentID) if err != nil { return nil, fmt.Errorf("failed to get parent account: %w", err) } // Set default currency from parent if not provided currency := models.Currency(input.Currency) if currency == "" { currency = parent.Currency } // Create sub-account subAccountType := input.SubAccountType subAccount := &models.Account{ UserID: userID, Name: input.Name, Type: parent.Type, // Inherit type from parent Balance: input.Balance, Currency: currency, Icon: input.Icon, ParentAccountID: &parentID, SubAccountType: &subAccountType, TargetAmount: input.TargetAmount, TargetDate: input.TargetDate, AnnualRate: input.AnnualRate, InterestEnabled: input.InterestEnabled, } // For savings pot, initialize frozen balance on parent if input.SubAccountType == models.SubAccountTypeSavingsPot && input.Balance > 0 { // Use transaction to ensure atomicity err = s.db.Transaction(func(tx *gorm.DB) error { // Check if parent has enough available balance if parent.AvailableBalance < input.Balance { return ErrSavingsPotDepositLimit } // Update parent account balances parent.AvailableBalance -= input.Balance parent.FrozenBalance += input.Balance if err := tx.Save(parent).Error; err != nil { return fmt.Errorf("failed to update parent account: %w", err) } // Create sub-account if err := tx.Create(subAccount).Error; err != nil { return fmt.Errorf("failed to create sub-account: %w", err) } return nil }) if err != nil { return nil, err } } else { // For non-savings pot, just create the sub-account if err := s.db.Create(subAccount).Error; err != nil { return nil, fmt.Errorf("failed to create sub-account: %w", err) } } return subAccount, nil } // UpdateSubAccount updates an existing sub-account func (s *SubAccountService) UpdateSubAccount(userID uint, parentID, subID uint, input UpdateSubAccountInput) (*models.Account, error) { // Validate parent account if err := s.ValidateParentAccount(userID, parentID); err != nil { return nil, err } // Get sub-account var subAccount models.Account err := s.db.First(&subAccount, subID).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrSubAccountNotFound } return nil, fmt.Errorf("failed to get sub-account: %w", err) } // Verify sub-account belongs to parent if subAccount.ParentAccountID == nil || *subAccount.ParentAccountID != parentID { return nil, ErrSubAccountNotBelongTo } // Update fields if input.Name != "" { subAccount.Name = input.Name } if input.Icon != "" { subAccount.Icon = input.Icon } if input.TargetAmount != nil { subAccount.TargetAmount = input.TargetAmount } if input.TargetDate != nil { subAccount.TargetDate = input.TargetDate } if input.AnnualRate != nil { subAccount.AnnualRate = input.AnnualRate } if input.InterestEnabled != nil { subAccount.InterestEnabled = *input.InterestEnabled } if err := s.db.Save(&subAccount).Error; err != nil { return nil, fmt.Errorf("failed to update sub-account: %w", err) } return &subAccount, nil } // DeleteSubAccount deletes a sub-account and transfers balance back to parent func (s *SubAccountService) DeleteSubAccount(userID uint, parentID, subID uint) error { // Validate parent account if err := s.ValidateParentAccount(userID, parentID); err != nil { return err } return s.db.Transaction(func(tx *gorm.DB) error { // Get sub-account var subAccount models.Account err := tx.First(&subAccount, subID).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrSubAccountNotFound } return fmt.Errorf("failed to get sub-account: %w", err) } // Verify sub-account belongs to parent if subAccount.ParentAccountID == nil || *subAccount.ParentAccountID != parentID { return ErrSubAccountNotBelongTo } // Get parent account var parent models.Account if err := tx.First(&parent, parentID).Error; err != nil { return fmt.Errorf("failed to get parent account: %w", err) } // Transfer balance back to parent if subAccount.Balance > 0 { if subAccount.SubAccountType != nil && *subAccount.SubAccountType == models.SubAccountTypeSavingsPot { // For savings pot, move from frozen to available parent.FrozenBalance -= subAccount.Balance parent.AvailableBalance += subAccount.Balance } else { // For other sub-accounts, add to available balance parent.AvailableBalance += subAccount.Balance } if err := tx.Save(&parent).Error; err != nil { return fmt.Errorf("failed to update parent account: %w", err) } } // Delete sub-account if err := tx.Delete(&subAccount).Error; err != nil { return fmt.Errorf("failed to delete sub-account: %w", err) } return nil }) } // GetSubAccount retrieves a specific sub-account func (s *SubAccountService) GetSubAccount(userID uint, parentID, subID uint) (*models.Account, error) { // Validate parent account if err := s.ValidateParentAccount(userID, parentID); err != nil { return nil, err } var subAccount models.Account err := s.db.First(&subAccount, subID).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrSubAccountNotFound } return nil, fmt.Errorf("failed to get sub-account: %w", err) } // Verify sub-account belongs to parent if subAccount.ParentAccountID == nil || *subAccount.ParentAccountID != parentID { return nil, ErrSubAccountNotBelongTo } return &subAccount, nil } // isValidSubAccountType checks if the sub-account type is valid func isValidSubAccountType(t models.SubAccountType) bool { switch t { case models.SubAccountTypeSavingsPot, models.SubAccountTypeMoneyFund, models.SubAccountTypeInvestment: return true default: return false } }