Files
Novault-backend/internal/service/account_service.go
2026-01-25 21:59:00 +08:00

384 lines
12 KiB
Go

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
})
}