319 lines
10 KiB
Go
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
|
|
}
|
|
}
|