From 49fcce531d083fc1303c17d7e052935fb611daae Mon Sep 17 00:00:00 2001 From: 12975 <1297598740@qq.com> Date: Sun, 1 Feb 2026 18:54:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E8=B4=A2=E5=8A=A1=E6=A8=A1=E5=9E=8B=E3=80=81=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=E5=AD=98=E5=82=A8=E5=BA=93=E5=92=8C=E5=AD=98=E9=92=B1=E7=BD=90?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/models/models.go | 7 +- internal/repository/account_repository.go | 8 +- internal/service/piggy_bank_service.go | 179 ++++++++++++++++++---- 3 files changed, 157 insertions(+), 37 deletions(-) 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) } }