init
This commit is contained in:
383
internal/service/account_service.go
Normal file
383
internal/service/account_service.go
Normal file
@@ -0,0 +1,383 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user