Files
Novault-backend/internal/service/sub_account_service.go
2026-01-25 21:59:00 +08:00

319 lines
10 KiB
Go

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
}
}