package service import ( "errors" "fmt" "accounting-app/internal/models" "accounting-app/internal/repository" "gorm.io/gorm" ) // Service layer errors var ( ErrAccountNotFound = errors.New("account not found") ErrAccountInUse = errors.New("account is in use and cannot be deleted") ErrInsufficientBalance = errors.New("insufficient balance for this operation") ErrSameAccountTransfer = errors.New("cannot transfer to the same account") ErrInvalidTransferAmount = errors.New("transfer amount must be positive") ErrNegativeBalanceNotAllowed = errors.New("negative balance not allowed for non-credit accounts") ) // AccountInput represents the input data for creating or updating an account type AccountInput struct { UserID uint `json:"user_id"` Name string `json:"name" binding:"required"` Type models.AccountType `json:"type" binding:"required"` Balance float64 `json:"balance"` Currency models.Currency `json:"currency"` Icon string `json:"icon"` BillingDate *int `json:"billing_date,omitempty"` PaymentDate *int `json:"payment_date,omitempty"` WarningThreshold *float64 `json:"warning_threshold,omitempty"` AccountCode string `json:"account_code,omitempty"` } // TransferInput represents the input data for a transfer operation type TransferInput struct { UserID uint `json:"user_id"` FromAccountID uint `json:"from_account_id" binding:"required"` ToAccountID uint `json:"to_account_id" binding:"required"` Amount float64 `json:"amount" binding:"required,gt=0"` Note string `json:"note"` } // AssetOverview represents the asset overview response type AssetOverview struct { TotalAssets float64 `json:"total_assets"` TotalLiabilities float64 `json:"total_liabilities"` NetWorth float64 `json:"net_worth"` } // AccountService handles business logic for accounts type AccountService struct { repo *repository.AccountRepository db *gorm.DB } // NewAccountService creates a new AccountService instance func NewAccountService(repo *repository.AccountRepository, db *gorm.DB) *AccountService { return &AccountService{ repo: repo, db: db, } } // CreateAccount creates a new account with business logic validation func (s *AccountService) CreateAccount(userID uint, input AccountInput) (*models.Account, error) { // Set default currency if not provided if input.Currency == "" { input.Currency = models.CurrencyCNY } // Determine if this is a credit account type isCredit := models.IsCreditAccountType(input.Type) // Validate balance for non-credit accounts if !isCredit && input.Balance < 0 { return nil, ErrNegativeBalanceNotAllowed } // Create the account model account := &models.Account{ UserID: userID, Name: input.Name, Type: input.Type, Balance: input.Balance, Currency: input.Currency, Icon: input.Icon, BillingDate: input.BillingDate, PaymentDate: input.PaymentDate, WarningThreshold: input.WarningThreshold, AccountCode: input.AccountCode, IsCredit: isCredit, } // Save to database if err := s.repo.Create(account); err != nil { return nil, fmt.Errorf("failed to create account: %w", err) } return account, nil } // GetAccount retrieves an account by ID and verifies ownership func (s *AccountService) GetAccount(userID, id uint) (*models.Account, error) { account, err := s.repo.GetByID(userID, id) if err != nil { if errors.Is(err, repository.ErrAccountNotFound) { return nil, ErrAccountNotFound } return nil, fmt.Errorf("failed to get account: %w", err) } // Redundant check removed as repo filters by userID return account, nil } // GetAllAccounts retrieves all accounts for a specific user func (s *AccountService) GetAllAccounts(userID uint) ([]models.Account, error) { accounts, err := s.repo.GetAll(userID) if err != nil { return nil, fmt.Errorf("failed to get accounts: %w", err) } return accounts, nil } // UpdateAccount updates an existing account after verifying ownership func (s *AccountService) UpdateAccount(userID, id uint, input AccountInput) (*models.Account, error) { // Get existing account account, err := s.repo.GetByID(userID, id) if err != nil { if errors.Is(err, repository.ErrAccountNotFound) { return nil, ErrAccountNotFound } return nil, fmt.Errorf("failed to get account: %w", err) } // Redundant check removed // account.UserID match ensured by repo.GetByID(userID, id) // Determine if this is a credit account type isCredit := models.IsCreditAccountType(input.Type) // Validate balance for non-credit accounts if !isCredit && input.Balance < 0 { return nil, ErrNegativeBalanceNotAllowed } // Update fields account.Name = input.Name account.Type = input.Type account.Balance = input.Balance if input.Currency != "" { account.Currency = input.Currency } account.Icon = input.Icon account.BillingDate = input.BillingDate account.PaymentDate = input.PaymentDate account.WarningThreshold = input.WarningThreshold account.AccountCode = input.AccountCode account.IsCredit = isCredit // Save to database if err := s.repo.Update(account); err != nil { return nil, fmt.Errorf("failed to update account: %w", err) } return account, nil } // DeleteAccount deletes an account by ID after verifying ownership func (s *AccountService) DeleteAccount(userID, id uint) error { _, err := s.repo.GetByID(userID, id) if err != nil { if errors.Is(err, repository.ErrAccountNotFound) { return ErrAccountNotFound } return fmt.Errorf("failed to check account existence: %w", err) } // Redundant check removed err = s.repo.Delete(userID, id) if err != nil { if errors.Is(err, repository.ErrAccountNotFound) { return ErrAccountNotFound } if errors.Is(err, repository.ErrAccountInUse) { return ErrAccountInUse } return fmt.Errorf("failed to delete account: %w", err) } return nil } // Transfer performs an atomic transfer between two accounts // This operation is wrapped in a database transaction to ensure consistency func (s *AccountService) Transfer(userID, fromAccountID, toAccountID uint, amount float64, note string) error { // Validate transfer parameters if fromAccountID == toAccountID { return ErrSameAccountTransfer } if amount <= 0 { return ErrInvalidTransferAmount } // Execute transfer within a transaction return s.db.Transaction(func(tx *gorm.DB) error { // Create a temporary repository for this transaction txRepo := repository.NewAccountRepository(tx) // Get source account fromAccount, err := txRepo.GetByID(userID, fromAccountID) if err != nil { if errors.Is(err, repository.ErrAccountNotFound) { return fmt.Errorf("source account not found: %w", ErrAccountNotFound) } return fmt.Errorf("failed to get source account: %w", err) } // Redundant check removed // Get destination account toAccount, err := txRepo.GetByID(userID, toAccountID) if err != nil { if errors.Is(err, repository.ErrAccountNotFound) { return fmt.Errorf("destination account not found: %w", ErrAccountNotFound) } return fmt.Errorf("failed to get destination account: %w", err) } // Redundant check removed // Calculate new balances newFromBalance := fromAccount.Balance - amount newToBalance := toAccount.Balance + amount // Check if source account can have negative balance if !fromAccount.IsCredit && newFromBalance < 0 { return ErrInsufficientBalance } // Update source account balance if err := txRepo.UpdateBalance(userID, fromAccountID, newFromBalance); err != nil { return fmt.Errorf("failed to update source account balance: %w", err) } // Update destination account balance if err := txRepo.UpdateBalance(userID, toAccountID, newToBalance); err != nil { return fmt.Errorf("failed to update destination account balance: %w", err) } return nil }) } // GetAssetOverview calculates and returns the asset overview // Total Assets = sum of all positive balances // Total Liabilities = absolute value of sum of all negative balances // Net Worth = Total Assets - Total Liabilities func (s *AccountService) GetAssetOverview(userID uint) (*AssetOverview, error) { assets, liabilities, err := s.repo.GetTotalBalance(userID) if err != nil { return nil, fmt.Errorf("failed to calculate asset overview: %w", err) } return &AssetOverview{ TotalAssets: assets, TotalLiabilities: liabilities, NetWorth: assets - liabilities, }, nil } // UpdateBalance updates the balance of an account // This method validates that non-credit accounts cannot have negative balance func (s *AccountService) UpdateBalance(userID uint, id uint, newBalance float64) error { // Get the account to check if it's a credit account account, err := s.repo.GetByID(userID, id) if err != nil { if errors.Is(err, repository.ErrAccountNotFound) { return ErrAccountNotFound } return fmt.Errorf("failed to get account: %w", err) } // Validate balance for non-credit accounts if !account.IsCredit && newBalance < 0 { return ErrNegativeBalanceNotAllowed } // Update the balance if err := s.repo.UpdateBalance(userID, id, newBalance); err != nil { return fmt.Errorf("failed to update balance: %w", err) } return nil } // CanHaveNegativeBalance checks if an account can have a negative balance func (s *AccountService) CanHaveNegativeBalance(userID uint, id uint) (bool, error) { account, err := s.repo.GetByID(userID, id) if err != nil { if errors.Is(err, repository.ErrAccountNotFound) { return false, ErrAccountNotFound } return false, fmt.Errorf("failed to get account: %w", err) } return account.IsCredit, nil } // ValidateBalanceChange validates if a balance change is allowed for an account // Returns nil if the change is valid, or an error if not func (s *AccountService) ValidateBalanceChange(userID uint, id uint, balanceChange float64) error { account, err := s.repo.GetByID(userID, id) if err != nil { if errors.Is(err, repository.ErrAccountNotFound) { return ErrAccountNotFound } return fmt.Errorf("failed to get account: %w", err) } newBalance := account.Balance + balanceChange if !account.IsCredit && newBalance < 0 { return ErrInsufficientBalance } return nil } // GetCreditAccounts retrieves all credit-type accounts func (s *AccountService) GetCreditAccounts(userID uint) ([]models.Account, error) { accounts, err := s.repo.GetCreditAccounts(userID) if err != nil { return nil, fmt.Errorf("failed to get credit accounts: %w", err) } return accounts, nil } // GetAccountsByType retrieves all accounts of a specific type func (s *AccountService) GetAccountsByType(userID uint, accountType models.AccountType) ([]models.Account, error) { accounts, err := s.repo.GetByType(userID, accountType) if err != nil { return nil, fmt.Errorf("failed to get accounts by type: %w", err) } return accounts, nil } // GetAccountsByCurrency retrieves all accounts with a specific currency func (s *AccountService) GetAccountsByCurrency(userID uint, currency models.Currency) ([]models.Account, error) { accounts, err := s.repo.GetByCurrency(userID, currency) if err != nil { return nil, fmt.Errorf("failed to get accounts by currency: %w", err) } return accounts, nil } // ReorderAccountsInput represents the input for reordering accounts type ReorderAccountsInput struct { AccountIDs []uint `json:"account_ids" binding:"required"` } // ReorderAccounts updates the sort order of accounts based on the provided order // Feature: accounting-feature-upgrade // Validates: Requirements 1.3, 1.4 func (s *AccountService) ReorderAccounts(userID uint, accountIDs []uint) error { // Validate that all account IDs exist and belong to the user for _, id := range accountIDs { _, err := s.repo.GetByID(userID, id) if err != nil { if errors.Is(err, repository.ErrAccountNotFound) { return ErrAccountNotFound } return fmt.Errorf("failed to check account existence: %w", err) } } // Update sort order for each account within a transaction return s.db.Transaction(func(tx *gorm.DB) error { txRepo := repository.NewAccountRepository(tx) for i, id := range accountIDs { if err := txRepo.UpdateSortOrder(userID, id, i); err != nil { return fmt.Errorf("failed to update sort order for account %d: %w", id, err) } } return nil }) }