diff --git a/internal/models/models.go b/internal/models/models.go index 2dea65f..6d149bd 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -286,11 +286,16 @@ func (Account) TableName() string { return "accounts" } +// TotalBalance calculates the total balance including sub-accounts +// Feature: financial-core-upgrade +// Validates: Requirements 1.3 // TotalBalance calculates the total balance including sub-accounts // Feature: financial-core-upgrade // Validates: Requirements 1.3 func (a *Account) TotalBalance() float64 { - total := a.AvailableBalance + a.FrozenBalance + // Balance is treated as Available Balance (liquid funds) + // FrozenBalance represents funds locked in savings pots/piggy banks + total := a.Balance + a.FrozenBalance for _, sub := range a.SubAccounts { if sub.SubAccountType != nil && *sub.SubAccountType != SubAccountTypeSavingsPot { total += sub.Balance diff --git a/internal/repository/account_repository.go b/internal/repository/account_repository.go index dab2cc1..2ba39c0 100644 --- a/internal/repository/account_repository.go +++ b/internal/repository/account_repository.go @@ -146,10 +146,12 @@ func (r *AccountRepository) GetTotalBalance(userID uint) (assets float64, liabil } for _, account := range accounts { - if account.Balance >= 0 { - assets += account.Balance + // Calculate true total for the account + accountTotal := account.Balance + account.FrozenBalance + if accountTotal >= 0 { + assets += accountTotal } else { - liabilities += -account.Balance // Convert to positive for liabilities + liabilities += -accountTotal // Convert to positive for liabilities } } diff --git a/internal/service/piggy_bank_service.go b/internal/service/piggy_bank_service.go index 5eb64ea..319d1e8 100644 --- a/internal/service/piggy_bank_service.go +++ b/internal/service/piggy_bank_service.go @@ -256,6 +256,8 @@ func (s *PiggyBankService) DeletePiggyBank(userID, id uint) error { return nil } +// Deposit adds money to a piggy bank +// If fromAccountID is provided, it will deduct from that account // Deposit adds money to a piggy bank // If fromAccountID is provided, it will deduct from that account func (s *PiggyBankService) Deposit(userID, id uint, input DepositInput) (*models.PiggyBank, error) { @@ -282,28 +284,60 @@ func (s *PiggyBankService) Deposit(userID, id uint, input DepositInput) (*models return nil, fmt.Errorf("failed to get piggy bank: %w", err) } - // If fromAccountID is provided, deduct from that account + // Determine source account (Input > Linked) + var sourceAccountID *uint if input.FromAccountID != nil { - var account models.Account - if err := tx.Where("user_id = ?", userID).First(&account, *input.FromAccountID).Error; err != nil { - tx.Rollback() - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrAccountNotFound - } - return nil, fmt.Errorf("failed to get account: %w", err) - } + sourceAccountID = input.FromAccountID + } else { + sourceAccountID = piggyBank.LinkedAccountID + } - // Check if account has sufficient funds (only for non-credit accounts) - if !account.IsCredit && account.Balance < input.Amount { - tx.Rollback() - return nil, ErrInsufficientAccountFunds - } + // Verify we have a source account for deduction + if sourceAccountID == nil { + tx.Rollback() + return nil, errors.New("cannot deposit without a source account (linked or specified)") + } - // Deduct from account - account.Balance -= input.Amount - if err := tx.Save(&account).Error; err != nil { + // Get Source Account + var sourceAccount models.Account + if err := tx.Where("user_id = ?", userID).First(&sourceAccount, *sourceAccountID).Error; err != nil { + tx.Rollback() + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrAccountNotFound + } + return nil, fmt.Errorf("failed to get source account: %w", err) + } + + // Check if account has sufficient funds (only for non-credit accounts) + if !sourceAccount.IsCredit && sourceAccount.Balance < input.Amount { + tx.Rollback() + return nil, ErrInsufficientAccountFunds + } + + // Deduct from Source Account + sourceAccount.Balance -= input.Amount + + // If Source is the Linked Account, we move funds to Frozen + if piggyBank.LinkedAccountID != nil && sourceAccount.ID == *piggyBank.LinkedAccountID { + sourceAccount.FrozenBalance += input.Amount + } + + if err := tx.Save(&sourceAccount).Error; err != nil { + tx.Rollback() + return nil, fmt.Errorf("failed to update source account balance: %w", err) + } + + // If Source is NOT the Linked Account (and Linked exists), we must update Linked Account's Frozen Balance + if piggyBank.LinkedAccountID != nil && sourceAccount.ID != *piggyBank.LinkedAccountID { + var linkedAccount models.Account + if err := tx.Where("user_id = ?", userID).First(&linkedAccount, *piggyBank.LinkedAccountID).Error; err != nil { tx.Rollback() - return nil, fmt.Errorf("failed to update account balance: %w", err) + return nil, fmt.Errorf("failed to get linked account: %w", err) + } + linkedAccount.FrozenBalance += input.Amount + if err := tx.Save(&linkedAccount).Error; err != nil { + tx.Rollback() + return nil, fmt.Errorf("failed to update linked account frozen balance: %w", err) } } @@ -316,6 +350,27 @@ func (s *PiggyBankService) Deposit(userID, id uint, input DepositInput) (*models return nil, fmt.Errorf("failed to update piggy bank: %w", err) } + // Create Transaction Record + var category models.Category + if err := tx.Where("user_id = ?", userID).Order("id asc").First(&category).Error; err == nil { + subTypeDeposit := models.TransactionSubTypeSavingsDeposit + transaction := models.Transaction{ + UserID: userID, + Amount: input.Amount, + Type: models.TransactionTypeTransfer, + SubType: &subTypeDeposit, + CategoryID: category.ID, + AccountID: sourceAccount.ID, + TransactionDate: time.Now(), + Note: fmt.Sprintf("Deposit to piggy bank: %s", piggyBank.Name), + } + + if err := tx.Create(&transaction).Error; err != nil { + tx.Rollback() + return nil, fmt.Errorf("failed to create transaction record: %w", err) + } + } + // Commit transaction if err := tx.Commit().Error; err != nil { return nil, fmt.Errorf("failed to commit transaction: %w", err) @@ -325,7 +380,7 @@ func (s *PiggyBankService) Deposit(userID, id uint, input DepositInput) (*models } // Withdraw removes money from a piggy bank (breaking the piggy bank) -// If toAccountID is provided, it will add to that account +// If toAccountID is provided, it will add to that account. Otherwise defaults to Linked Account. func (s *PiggyBankService) Withdraw(userID, id uint, input WithdrawInput) (*models.PiggyBank, error) { // Validate withdraw amount if input.Amount <= 0 { @@ -356,31 +411,89 @@ func (s *PiggyBankService) Withdraw(userID, id uint, input WithdrawInput) (*mode return nil, ErrInsufficientBalance } + // Determine destination account (Input > Linked) + var destinationAccountID *uint + if input.ToAccountID != nil { + destinationAccountID = input.ToAccountID + } else { + destinationAccountID = piggyBank.LinkedAccountID + } + + // Verify we have a destination account + if destinationAccountID == nil { + tx.Rollback() + return nil, errors.New("cannot withdraw without a destination account (linked or specified)") + } + + // Get Destination Account + var destinationAccount models.Account + if err := tx.Where("user_id = ?", userID).First(&destinationAccount, *destinationAccountID).Error; err != nil { + tx.Rollback() + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrAccountNotFound + } + return nil, fmt.Errorf("failed to get destination account: %w", err) + } + // Deduct from piggy bank piggyBank.CurrentAmount -= input.Amount - - // Update piggy bank if err := tx.Save(&piggyBank).Error; err != nil { tx.Rollback() return nil, fmt.Errorf("failed to update piggy bank: %w", err) } - // If toAccountID is provided, add to that account - if input.ToAccountID != nil { - var account models.Account - if err := tx.Where("user_id = ?", userID).First(&account, *input.ToAccountID).Error; err != nil { + // Add to Destination Account + destinationAccount.Balance += input.Amount + + // If Destination is the Linked Account, we move funds from Frozen (so Frozen decreases) + if piggyBank.LinkedAccountID != nil && destinationAccount.ID == *piggyBank.LinkedAccountID { + destinationAccount.FrozenBalance -= input.Amount + // Safety check for negative frozen balance + if destinationAccount.FrozenBalance < 0 { + destinationAccount.FrozenBalance = 0 + } + } + + if err := tx.Save(&destinationAccount).Error; err != nil { + tx.Rollback() + return nil, fmt.Errorf("failed to update destination account balance: %w", err) + } + + // If Destination is NOT the Linked Account (and Linked exists), we must update Linked Account's Frozen Balance + if piggyBank.LinkedAccountID != nil && destinationAccount.ID != *piggyBank.LinkedAccountID { + var linkedAccount models.Account + if err := tx.Where("user_id = ?", userID).First(&linkedAccount, *piggyBank.LinkedAccountID).Error; err != nil { tx.Rollback() - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrAccountNotFound - } - return nil, fmt.Errorf("failed to get account: %w", err) + return nil, fmt.Errorf("failed to get linked account: %w", err) + } + linkedAccount.FrozenBalance -= input.Amount + if linkedAccount.FrozenBalance < 0 { + linkedAccount.FrozenBalance = 0 + } + if err := tx.Save(&linkedAccount).Error; err != nil { + tx.Rollback() + return nil, fmt.Errorf("failed to update linked account frozen balance: %w", err) + } + } + + // Create Transaction Record + var category models.Category + if err := tx.Where("user_id = ?", userID).Order("id asc").First(&category).Error; err == nil { + subTypeWithdraw := models.TransactionSubTypeSavingsWithdraw + transaction := models.Transaction{ + UserID: userID, + Amount: input.Amount, + Type: models.TransactionTypeTransfer, + SubType: &subTypeWithdraw, + CategoryID: category.ID, + AccountID: destinationAccount.ID, + TransactionDate: time.Now(), + Note: fmt.Sprintf("Withdraw from piggy bank: %s", piggyBank.Name), } - // Add to account - account.Balance += input.Amount - if err := tx.Save(&account).Error; err != nil { + if err := tx.Create(&transaction).Error; err != nil { tx.Rollback() - return nil, fmt.Errorf("failed to update account balance: %w", err) + return nil, fmt.Errorf("failed to create transaction record: %w", err) } }