384 lines
12 KiB
Go
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
|
|
})
|
|
}
|