153 lines
4.7 KiB
Go
153 lines
4.7 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"accounting-app/internal/models"
|
|
"accounting-app/internal/repository"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// Refund service errors
|
|
var (
|
|
ErrInvalidRefundAmount = errors.New("refund amount must be greater than 0 and not exceed original amount")
|
|
ErrAlreadyRefunded = errors.New("transaction already refunded")
|
|
ErrRefundCategoryNotFound = errors.New("refund system category not found")
|
|
)
|
|
|
|
// RefundService handles business logic for refund operations
|
|
// Feature: accounting-feature-upgrade
|
|
// Validates: Requirements 8.10-8.18
|
|
type RefundService struct {
|
|
db *gorm.DB
|
|
transactionRepo *repository.TransactionRepository
|
|
accountRepo *repository.AccountRepository
|
|
}
|
|
|
|
// NewRefundService creates a new RefundService instance
|
|
func NewRefundService(
|
|
db *gorm.DB,
|
|
transactionRepo *repository.TransactionRepository,
|
|
accountRepo *repository.AccountRepository,
|
|
) *RefundService {
|
|
return &RefundService{
|
|
db: db,
|
|
transactionRepo: transactionRepo,
|
|
accountRepo: accountRepo,
|
|
}
|
|
}
|
|
|
|
// ProcessRefund processes a refund on an expense transaction
|
|
// This automatically creates a refund income record and updates the original transaction
|
|
// Feature: accounting-feature-upgrade
|
|
// Validates: Requirements 8.10-8.18, 8.28
|
|
func (s *RefundService) ProcessRefund(userID uint, transactionID uint, refundAmount float64) (*models.Transaction, error) {
|
|
var refundIncome *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 an expense transaction
|
|
if originalTxn.Type != models.TransactionTypeExpense {
|
|
return ErrNotExpenseTransaction
|
|
}
|
|
|
|
// Validate: cannot refund if already refunded
|
|
if originalTxn.RefundStatus != "none" {
|
|
return ErrAlreadyRefunded
|
|
}
|
|
|
|
// Validate: refund amount must be positive and not exceed original amount
|
|
if refundAmount <= 0 || refundAmount > originalTxn.Amount {
|
|
return ErrInvalidRefundAmount
|
|
}
|
|
|
|
// Get the refund system category
|
|
var refundCategory models.SystemCategory
|
|
if err := tx.Where("code = ?", "refund").First(&refundCategory).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrRefundCategoryNotFound
|
|
}
|
|
return fmt.Errorf("failed to get refund 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 refund income record
|
|
refundIncome = &models.Transaction{
|
|
UserID: userID,
|
|
Type: models.TransactionTypeIncome,
|
|
Amount: refundAmount,
|
|
CategoryID: uint(refundCategory.ID),
|
|
AccountID: originalTxn.AccountID,
|
|
Currency: originalTxn.Currency,
|
|
TransactionDate: time.Now(),
|
|
Note: fmt.Sprintf("閫€娆?- %s", originalTxn.Note),
|
|
IncomeType: "refund",
|
|
OriginalTransactionID: &transactionID,
|
|
LedgerID: originalTxn.LedgerID, // Same ledger as original transaction (Requirement 8.28)
|
|
}
|
|
|
|
if err := txTransactionRepo.Create(refundIncome); err != nil {
|
|
return fmt.Errorf("failed to create refund income: %w", err)
|
|
}
|
|
|
|
// Determine refund status: partial or full
|
|
refundStatus := "partial"
|
|
if refundAmount == originalTxn.Amount {
|
|
refundStatus = "full"
|
|
}
|
|
|
|
// Update the original transaction status
|
|
updates := map[string]interface{}{
|
|
"refund_status": refundStatus,
|
|
"refund_amount": refundAmount,
|
|
"refund_income_id": refundIncome.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 refund income)
|
|
newBalance := account.Balance + refundAmount
|
|
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 refundIncome, nil
|
|
}
|