init
This commit is contained in:
318
internal/service/sub_account_service.go
Normal file
318
internal/service/sub_account_service.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"accounting-app/internal/models"
|
||||
"accounting-app/internal/repository"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Sub-account service errors
|
||||
var (
|
||||
ErrSubAccountNotFound = errors.New("sub-account not found")
|
||||
ErrParentAccountNotFound = errors.New("parent account not found")
|
||||
ErrParentIsSubAccount = errors.New("cannot create sub-account under another sub-account")
|
||||
ErrSubAccountNotBelongTo = errors.New("sub-account does not belong to this parent account")
|
||||
ErrInvalidSubAccountType = errors.New("invalid sub-account type")
|
||||
ErrSavingsPotWithdrawLimit = errors.New("withdrawal amount exceeds savings pot balance")
|
||||
ErrSavingsPotDepositLimit = errors.New("deposit amount exceeds available balance")
|
||||
ErrSavingsPotNotFound = errors.New("savings pot not found")
|
||||
ErrNotASavingsPot = errors.New("account is not a savings pot")
|
||||
ErrInsufficientAvailableBalance = errors.New("insufficient available balance")
|
||||
ErrInsufficientSavingsPotBalance = errors.New("insufficient savings pot balance")
|
||||
)
|
||||
|
||||
// CreateSubAccountInput represents the input for creating a sub-account
|
||||
type CreateSubAccountInput struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
SubAccountType models.SubAccountType `json:"sub_account_type" binding:"required"`
|
||||
Balance float64 `json:"balance"`
|
||||
Currency string `json:"currency"`
|
||||
Icon string `json:"icon"`
|
||||
TargetAmount *float64 `json:"target_amount,omitempty"`
|
||||
TargetDate *time.Time `json:"target_date,omitempty"`
|
||||
AnnualRate *float64 `json:"annual_rate,omitempty"`
|
||||
InterestEnabled bool `json:"interest_enabled"`
|
||||
}
|
||||
|
||||
// UpdateSubAccountInput represents the input for updating a sub-account
|
||||
type UpdateSubAccountInput struct {
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
TargetAmount *float64 `json:"target_amount,omitempty"`
|
||||
TargetDate *time.Time `json:"target_date,omitempty"`
|
||||
AnnualRate *float64 `json:"annual_rate,omitempty"`
|
||||
InterestEnabled *bool `json:"interest_enabled,omitempty"`
|
||||
}
|
||||
|
||||
// SubAccountService handles business logic for sub-accounts
|
||||
// Feature: financial-core-upgrade
|
||||
// Validates: Requirements 1.1, 1.4, 1.6, 1.7
|
||||
type SubAccountService struct {
|
||||
repo *repository.AccountRepository
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewSubAccountService creates a new SubAccountService instance
|
||||
func NewSubAccountService(repo *repository.AccountRepository, db *gorm.DB) *SubAccountService {
|
||||
return &SubAccountService{
|
||||
repo: repo,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateParentAccount ensures the parent account exists and is not itself a sub-account
|
||||
func (s *SubAccountService) ValidateParentAccount(userID uint, parentID uint) error {
|
||||
parent, err := s.repo.GetByID(userID, parentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrAccountNotFound) {
|
||||
return ErrParentAccountNotFound
|
||||
}
|
||||
return fmt.Errorf("failed to get parent account: %w", err)
|
||||
}
|
||||
|
||||
// Check if parent is already a sub-account (max depth = 1)
|
||||
if parent.ParentAccountID != nil {
|
||||
return ErrParentIsSubAccount
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListSubAccounts retrieves all sub-accounts for a parent account
|
||||
func (s *SubAccountService) ListSubAccounts(userID uint, parentID uint) ([]models.Account, error) {
|
||||
// Validate parent account exists
|
||||
if err := s.ValidateParentAccount(userID, parentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var subAccounts []models.Account
|
||||
err := s.db.Where("parent_account_id = ?", parentID).
|
||||
Order("sort_order ASC, created_at ASC").
|
||||
Find(&subAccounts).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list sub-accounts: %w", err)
|
||||
}
|
||||
|
||||
return subAccounts, nil
|
||||
}
|
||||
|
||||
// CreateSubAccount creates a new sub-account under a parent account
|
||||
func (s *SubAccountService) CreateSubAccount(userID uint, parentID uint, input CreateSubAccountInput) (*models.Account, error) {
|
||||
// Validate parent account
|
||||
if err := s.ValidateParentAccount(userID, parentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate sub-account type
|
||||
if !isValidSubAccountType(input.SubAccountType) {
|
||||
return nil, ErrInvalidSubAccountType
|
||||
}
|
||||
|
||||
// Get parent account for currency default
|
||||
parent, err := s.repo.GetByID(userID, parentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get parent account: %w", err)
|
||||
}
|
||||
|
||||
// Set default currency from parent if not provided
|
||||
currency := models.Currency(input.Currency)
|
||||
if currency == "" {
|
||||
currency = parent.Currency
|
||||
}
|
||||
|
||||
// Create sub-account
|
||||
subAccountType := input.SubAccountType
|
||||
subAccount := &models.Account{
|
||||
UserID: userID,
|
||||
Name: input.Name,
|
||||
Type: parent.Type, // Inherit type from parent
|
||||
Balance: input.Balance,
|
||||
Currency: currency,
|
||||
Icon: input.Icon,
|
||||
ParentAccountID: &parentID,
|
||||
SubAccountType: &subAccountType,
|
||||
TargetAmount: input.TargetAmount,
|
||||
TargetDate: input.TargetDate,
|
||||
AnnualRate: input.AnnualRate,
|
||||
InterestEnabled: input.InterestEnabled,
|
||||
}
|
||||
|
||||
// For savings pot, initialize frozen balance on parent
|
||||
if input.SubAccountType == models.SubAccountTypeSavingsPot && input.Balance > 0 {
|
||||
// Use transaction to ensure atomicity
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// Check if parent has enough available balance
|
||||
if parent.AvailableBalance < input.Balance {
|
||||
return ErrSavingsPotDepositLimit
|
||||
}
|
||||
|
||||
// Update parent account balances
|
||||
parent.AvailableBalance -= input.Balance
|
||||
parent.FrozenBalance += input.Balance
|
||||
if err := tx.Save(parent).Error; err != nil {
|
||||
return fmt.Errorf("failed to update parent account: %w", err)
|
||||
}
|
||||
|
||||
// Create sub-account
|
||||
if err := tx.Create(subAccount).Error; err != nil {
|
||||
return fmt.Errorf("failed to create sub-account: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// For non-savings pot, just create the sub-account
|
||||
if err := s.db.Create(subAccount).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to create sub-account: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return subAccount, nil
|
||||
}
|
||||
|
||||
// UpdateSubAccount updates an existing sub-account
|
||||
func (s *SubAccountService) UpdateSubAccount(userID uint, parentID, subID uint, input UpdateSubAccountInput) (*models.Account, error) {
|
||||
// Validate parent account
|
||||
if err := s.ValidateParentAccount(userID, parentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get sub-account
|
||||
var subAccount models.Account
|
||||
err := s.db.First(&subAccount, subID).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrSubAccountNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get sub-account: %w", err)
|
||||
}
|
||||
|
||||
// Verify sub-account belongs to parent
|
||||
if subAccount.ParentAccountID == nil || *subAccount.ParentAccountID != parentID {
|
||||
return nil, ErrSubAccountNotBelongTo
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if input.Name != "" {
|
||||
subAccount.Name = input.Name
|
||||
}
|
||||
if input.Icon != "" {
|
||||
subAccount.Icon = input.Icon
|
||||
}
|
||||
if input.TargetAmount != nil {
|
||||
subAccount.TargetAmount = input.TargetAmount
|
||||
}
|
||||
if input.TargetDate != nil {
|
||||
subAccount.TargetDate = input.TargetDate
|
||||
}
|
||||
if input.AnnualRate != nil {
|
||||
subAccount.AnnualRate = input.AnnualRate
|
||||
}
|
||||
if input.InterestEnabled != nil {
|
||||
subAccount.InterestEnabled = *input.InterestEnabled
|
||||
}
|
||||
|
||||
if err := s.db.Save(&subAccount).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to update sub-account: %w", err)
|
||||
}
|
||||
|
||||
return &subAccount, nil
|
||||
}
|
||||
|
||||
// DeleteSubAccount deletes a sub-account and transfers balance back to parent
|
||||
func (s *SubAccountService) DeleteSubAccount(userID uint, parentID, subID uint) error {
|
||||
// Validate parent account
|
||||
if err := s.ValidateParentAccount(userID, parentID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// Get sub-account
|
||||
var subAccount models.Account
|
||||
err := tx.First(&subAccount, subID).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrSubAccountNotFound
|
||||
}
|
||||
return fmt.Errorf("failed to get sub-account: %w", err)
|
||||
}
|
||||
|
||||
// Verify sub-account belongs to parent
|
||||
if subAccount.ParentAccountID == nil || *subAccount.ParentAccountID != parentID {
|
||||
return ErrSubAccountNotBelongTo
|
||||
}
|
||||
|
||||
// Get parent account
|
||||
var parent models.Account
|
||||
if err := tx.First(&parent, parentID).Error; err != nil {
|
||||
return fmt.Errorf("failed to get parent account: %w", err)
|
||||
}
|
||||
|
||||
// Transfer balance back to parent
|
||||
if subAccount.Balance > 0 {
|
||||
if subAccount.SubAccountType != nil && *subAccount.SubAccountType == models.SubAccountTypeSavingsPot {
|
||||
// For savings pot, move from frozen to available
|
||||
parent.FrozenBalance -= subAccount.Balance
|
||||
parent.AvailableBalance += subAccount.Balance
|
||||
} else {
|
||||
// For other sub-accounts, add to available balance
|
||||
parent.AvailableBalance += subAccount.Balance
|
||||
}
|
||||
|
||||
if err := tx.Save(&parent).Error; err != nil {
|
||||
return fmt.Errorf("failed to update parent account: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete sub-account
|
||||
if err := tx.Delete(&subAccount).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete sub-account: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetSubAccount retrieves a specific sub-account
|
||||
func (s *SubAccountService) GetSubAccount(userID uint, parentID, subID uint) (*models.Account, error) {
|
||||
// Validate parent account
|
||||
if err := s.ValidateParentAccount(userID, parentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var subAccount models.Account
|
||||
err := s.db.First(&subAccount, subID).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrSubAccountNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get sub-account: %w", err)
|
||||
}
|
||||
|
||||
// Verify sub-account belongs to parent
|
||||
if subAccount.ParentAccountID == nil || *subAccount.ParentAccountID != parentID {
|
||||
return nil, ErrSubAccountNotBelongTo
|
||||
}
|
||||
|
||||
return &subAccount, nil
|
||||
}
|
||||
|
||||
// isValidSubAccountType checks if the sub-account type is valid
|
||||
func isValidSubAccountType(t models.SubAccountType) bool {
|
||||
switch t {
|
||||
case models.SubAccountTypeSavingsPot,
|
||||
models.SubAccountTypeMoneyFund,
|
||||
models.SubAccountTypeInvestment:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user