package service import ( "errors" "fmt" "time" "accounting-app/internal/models" "accounting-app/internal/repository" "gorm.io/gorm" "gorm.io/gorm/clause" ) // Reimbursement service errors var ( ErrInvalidReimbursementAmount = errors.New("reimbursement amount must be greater than 0 and not exceed original amount") ErrNotExpenseTransaction = errors.New("only expense transactions can be reimbursed") ErrNotPendingReimbursement = errors.New("transaction is not in pending reimbursement status") ErrAlreadyReimbursed = errors.New("transaction is already reimbursed") ErrReimbursementCategoryNotFound = errors.New("reimbursement system category not found") ) // ReimbursementService handles business logic for reimbursement operations // Feature: accounting-feature-upgrade // Validates: Requirements 8.1-8.9 type ReimbursementService struct { db *gorm.DB transactionRepo *repository.TransactionRepository accountRepo *repository.AccountRepository } // NewReimbursementService creates a new ReimbursementService instance func NewReimbursementService( db *gorm.DB, transactionRepo *repository.TransactionRepository, accountRepo *repository.AccountRepository, ) *ReimbursementService { return &ReimbursementService{ db: db, transactionRepo: transactionRepo, accountRepo: accountRepo, } } // ApplyReimbursement applies for reimbursement on an expense transaction // Feature: accounting-feature-upgrade // Validates: Requirements 8.2, 8.3, 8.4 func (s *ReimbursementService) ApplyReimbursement(userID uint, transactionID uint, amount float64) (*models.Transaction, error) { var transaction *models.Transaction err := s.db.Transaction(func(tx *gorm.DB) error { txTransactionRepo := repository.NewTransactionRepository(tx) // Get the transaction with lock var err error transaction, err = txTransactionRepo.GetByID(userID, transactionID) if err != nil { if errors.Is(err, repository.ErrTransactionNotFound) { return ErrTransactionNotFound } return fmt.Errorf("failed to get transaction: %w", err) } // Lock the transaction for update if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). First(&models.Transaction{}, transactionID).Error; err != nil { return fmt.Errorf("failed to lock transaction: %w", err) } // Validate: must be an expense transaction if transaction.Type != models.TransactionTypeExpense { return ErrNotExpenseTransaction } // Validate: cannot reapply if already reimbursed if transaction.ReimbursementStatus == "completed" { return ErrAlreadyReimbursed } // Validate: amount must be positive and not exceed original amount if amount <= 0 || amount > transaction.Amount { return ErrInvalidReimbursementAmount } // Update transaction to pending reimbursement status updates := map[string]interface{}{ "reimbursement_status": "pending", "reimbursement_amount": amount, } if err := tx.Model(&models.Transaction{}). Where("id = ?", transactionID). Updates(updates).Error; err != nil { return fmt.Errorf("failed to update transaction: %w", err) } // Reload the transaction to get updated values transaction, err = txTransactionRepo.GetByID(userID, transactionID) if err != nil { return fmt.Errorf("failed to reload transaction: %w", err) } return nil }) if err != nil { return nil, err } return transaction, nil } // ConfirmReimbursement confirms a pending reimbursement and creates the income record // Feature: accounting-feature-upgrade // Validates: Requirements 8.5, 8.6, 8.28 func (s *ReimbursementService) ConfirmReimbursement(userID uint, transactionID uint) (*models.Transaction, error) { var reimbursementIncome *models.Transaction err := s.db.Transaction(func(tx *gorm.DB) error { txTransactionRepo := repository.NewTransactionRepository(tx) txAccountRepo := repository.NewAccountRepository(tx) // Get the original transaction with lock originalTxn, err := txTransactionRepo.GetByID(userID, transactionID) if err != nil { if errors.Is(err, repository.ErrTransactionNotFound) { return ErrTransactionNotFound } return fmt.Errorf("failed to get transaction: %w", err) } // Lock the transaction for update if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). First(&models.Transaction{}, transactionID).Error; err != nil { return fmt.Errorf("failed to lock transaction: %w", err) } // Validate: must be in pending reimbursement status if originalTxn.ReimbursementStatus != "pending" { return ErrNotPendingReimbursement } // Validate: must have reimbursement amount if originalTxn.ReimbursementAmount == nil || *originalTxn.ReimbursementAmount <= 0 { return ErrInvalidReimbursementAmount } // Get the reimbursement system category var reimbursementCategory models.SystemCategory if err := tx.Where("code = ?", "reimbursement").First(&reimbursementCategory).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrReimbursementCategoryNotFound } return fmt.Errorf("failed to get reimbursement category: %w", err) } // Get the account to update balance account, err := txAccountRepo.GetByID(userID, originalTxn.AccountID) if err != nil { return fmt.Errorf("failed to get account: %w", err) } // Create the reimbursement income record reimbursementIncome = &models.Transaction{ UserID: userID, Type: models.TransactionTypeIncome, Amount: *originalTxn.ReimbursementAmount, CategoryID: uint(reimbursementCategory.ID), AccountID: originalTxn.AccountID, Currency: originalTxn.Currency, TransactionDate: time.Now(), Note: fmt.Sprintf("鎶ラ攢 - %s", originalTxn.Note), IncomeType: "reimbursement", OriginalTransactionID: &transactionID, LedgerID: originalTxn.LedgerID, // Same ledger as original transaction } if err := txTransactionRepo.Create(reimbursementIncome); err != nil { return fmt.Errorf("failed to create reimbursement income: %w", err) } // Update the original transaction status updates := map[string]interface{}{ "reimbursement_status": "completed", "reimbursement_income_id": reimbursementIncome.ID, } if err := tx.Model(&models.Transaction{}). Where("id = ?", transactionID). Updates(updates).Error; err != nil { return fmt.Errorf("failed to update transaction status: %w", err) } // Update account balance (add the reimbursement income) newBalance := account.Balance + *originalTxn.ReimbursementAmount if err := txAccountRepo.UpdateBalance(userID, originalTxn.AccountID, newBalance); err != nil { return fmt.Errorf("failed to update account balance: %w", err) } return nil }) if err != nil { return nil, err } return reimbursementIncome, nil } // CancelReimbursement cancels a pending reimbursement // Feature: accounting-feature-upgrade // Validates: Requirements 8.9 func (s *ReimbursementService) CancelReimbursement(userID uint, transactionID uint) (*models.Transaction, error) { var transaction *models.Transaction err := s.db.Transaction(func(tx *gorm.DB) error { txTransactionRepo := repository.NewTransactionRepository(tx) // Get the transaction with lock var err error transaction, err = txTransactionRepo.GetByID(userID, transactionID) if err != nil { if errors.Is(err, repository.ErrTransactionNotFound) { return ErrTransactionNotFound } return fmt.Errorf("failed to get transaction: %w", err) } // Lock the transaction for update if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). First(&models.Transaction{}, transactionID).Error; err != nil { return fmt.Errorf("failed to lock transaction: %w", err) } // Validate: must be in pending status to cancel if transaction.ReimbursementStatus != "pending" { return ErrNotPendingReimbursement } // Reset reimbursement fields updates := map[string]interface{}{ "reimbursement_status": "none", "reimbursement_amount": nil, "reimbursement_income_id": nil, } if err := tx.Model(&models.Transaction{}). Where("id = ?", transactionID). Updates(updates).Error; err != nil { return fmt.Errorf("failed to update transaction: %w", err) } // Reload the transaction to get updated values transaction, err = txTransactionRepo.GetByID(userID, transactionID) if err != nil { return fmt.Errorf("failed to reload transaction: %w", err) } return nil }) if err != nil { return nil, err } return transaction, nil }