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