This commit is contained in:
2026-01-25 21:59:00 +08:00
parent 7fd537bef3
commit 4cad3f0250
118 changed files with 30473 additions and 0 deletions

View File

@@ -0,0 +1,583 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"time"
"accounting-app/internal/models"
"accounting-app/internal/repository"
"gorm.io/gorm"
)
// Service layer errors for piggy banks
var (
ErrPiggyBankNotFound = errors.New("piggy bank not found")
ErrPiggyBankInUse = errors.New("piggy bank is in use and cannot be deleted")
ErrInvalidTargetAmount = errors.New("target amount must be positive")
ErrInvalidDepositAmount = errors.New("deposit amount must be positive")
ErrInvalidWithdrawAmount = errors.New("withdraw amount must be positive")
ErrInvalidPiggyBankType = errors.New("invalid piggy bank type")
ErrInvalidAutoRule = errors.New("invalid auto rule format")
ErrInsufficientAccountFunds = errors.New("insufficient funds in linked account")
)
// PiggyBankInput represents the input data for creating or updating a piggy bank
type PiggyBankInput struct {
UserID uint `json:"user_id"`
Name string `json:"name" binding:"required"`
TargetAmount float64 `json:"target_amount" binding:"required,gt=0"`
Type models.PiggyBankType `json:"type" binding:"required"`
TargetDate *time.Time `json:"target_date,omitempty"`
LinkedAccountID *uint `json:"linked_account_id,omitempty"`
AutoRule *AutoDepositRule `json:"auto_rule,omitempty"`
}
// AutoDepositRule represents the automatic deposit rule for auto piggy banks
type AutoDepositRule struct {
Frequency string `json:"frequency"` // daily, weekly, monthly
Amount float64 `json:"amount"`
DayOfWeek *int `json:"day_of_week,omitempty"` // 0-6 for weekly
DayOfMonth *int `json:"day_of_month,omitempty"` // 1-31 for monthly
}
// DepositInput represents the input for depositing to a piggy bank
type DepositInput struct {
Amount float64 `json:"amount" binding:"required,gt=0"`
FromAccountID *uint `json:"from_account_id,omitempty"`
Note string `json:"note,omitempty"`
}
// WithdrawInput represents the input for withdrawing from a piggy bank
type WithdrawInput struct {
Amount float64 `json:"amount" binding:"required,gt=0"`
ToAccountID *uint `json:"to_account_id,omitempty"`
Note string `json:"note,omitempty"`
}
// PiggyBankProgress represents the progress of a piggy bank
type PiggyBankProgress struct {
PiggyBankID uint `json:"piggy_bank_id"`
Name string `json:"name"`
TargetAmount float64 `json:"target_amount"`
CurrentAmount float64 `json:"current_amount"`
Remaining float64 `json:"remaining"`
Progress float64 `json:"progress"` // Percentage (0-100)
Type models.PiggyBankType `json:"type"`
TargetDate *time.Time `json:"target_date,omitempty"`
IsCompleted bool `json:"is_completed"`
DaysRemaining *int `json:"days_remaining,omitempty"`
LinkedAccountID *uint `json:"linked_account_id,omitempty"`
}
// PiggyBankService handles business logic for piggy banks
type PiggyBankService struct {
repo *repository.PiggyBankRepository
accountRepo *repository.AccountRepository
db *gorm.DB
}
// NewPiggyBankService creates a new PiggyBankService instance
func NewPiggyBankService(repo *repository.PiggyBankRepository, accountRepo *repository.AccountRepository, db *gorm.DB) *PiggyBankService {
return &PiggyBankService{
repo: repo,
accountRepo: accountRepo,
db: db,
}
}
// CreatePiggyBank creates a new piggy bank with business logic validation
func (s *PiggyBankService) CreatePiggyBank(input PiggyBankInput) (*models.PiggyBank, error) {
// Validate target amount
if input.TargetAmount <= 0 {
return nil, ErrInvalidTargetAmount
}
// Validate piggy bank type
if !isValidPiggyBankType(input.Type) {
return nil, ErrInvalidPiggyBankType
}
// Validate linked account if specified
if input.LinkedAccountID != nil {
exists, err := s.accountRepo.ExistsByID(input.UserID, *input.LinkedAccountID)
if err != nil {
return nil, fmt.Errorf("failed to check account existence: %w", err)
}
if !exists {
return nil, ErrAccountNotFound
}
}
// For auto piggy banks, validate auto rule
var autoRuleJSON string
if input.Type == models.PiggyBankTypeAuto || input.Type == models.PiggyBankTypeFixedDeposit || input.Type == models.PiggyBankTypeWeek52 {
if input.AutoRule != nil {
ruleBytes, err := json.Marshal(input.AutoRule)
if err != nil {
return nil, ErrInvalidAutoRule
}
autoRuleJSON = string(ruleBytes)
} else if input.Type == models.PiggyBankTypeAuto || input.Type == models.PiggyBankTypeFixedDeposit {
// Auto and fixed deposit types require auto rule
return nil, ErrInvalidAutoRule
}
}
// Create the piggy bank model
piggyBank := &models.PiggyBank{
UserID: input.UserID,
Name: input.Name,
TargetAmount: input.TargetAmount,
CurrentAmount: 0,
Type: input.Type,
TargetDate: input.TargetDate,
LinkedAccountID: input.LinkedAccountID,
AutoRule: autoRuleJSON,
}
// Save to database
if err := s.repo.Create(piggyBank); err != nil {
return nil, fmt.Errorf("failed to create piggy bank: %w", err)
}
return piggyBank, nil
}
// GetPiggyBank retrieves a piggy bank by ID and verifies ownership
func (s *PiggyBankService) GetPiggyBank(userID, id uint) (*models.PiggyBank, error) {
piggyBank, err := s.repo.GetByID(userID, id)
if err != nil {
if errors.Is(err, repository.ErrPiggyBankNotFound) {
return nil, ErrPiggyBankNotFound
}
return nil, fmt.Errorf("failed to get piggy bank: %w", err)
}
// userID check handled by repo
return piggyBank, nil
}
// GetAllPiggyBanks retrieves all piggy banks for a user
func (s *PiggyBankService) GetAllPiggyBanks(userID uint) ([]models.PiggyBank, error) {
piggyBanks, err := s.repo.GetAll(userID)
if err != nil {
return nil, fmt.Errorf("failed to get piggy banks: %w", err)
}
return piggyBanks, nil
}
// UpdatePiggyBank updates an existing piggy bank after verifying ownership
func (s *PiggyBankService) UpdatePiggyBank(userID, id uint, input PiggyBankInput) (*models.PiggyBank, error) {
// Get existing piggy bank
piggyBank, err := s.repo.GetByID(userID, id)
if err != nil {
if errors.Is(err, repository.ErrPiggyBankNotFound) {
return nil, ErrPiggyBankNotFound
}
return nil, fmt.Errorf("failed to get piggy bank: %w", err)
}
// userID check handled by repo
// Validate target amount
if input.TargetAmount <= 0 {
return nil, ErrInvalidTargetAmount
}
// Validate piggy bank type
if !isValidPiggyBankType(input.Type) {
return nil, ErrInvalidPiggyBankType
}
// Validate linked account if specified
if input.LinkedAccountID != nil {
exists, err := s.accountRepo.ExistsByID(userID, *input.LinkedAccountID)
if err != nil {
return nil, fmt.Errorf("failed to check account existence: %w", err)
}
if !exists {
return nil, ErrAccountNotFound
}
}
// For auto piggy banks, validate auto rule
var autoRuleJSON string
if input.Type == models.PiggyBankTypeAuto || input.Type == models.PiggyBankTypeFixedDeposit || input.Type == models.PiggyBankTypeWeek52 {
if input.AutoRule != nil {
ruleBytes, err := json.Marshal(input.AutoRule)
if err != nil {
return nil, ErrInvalidAutoRule
}
autoRuleJSON = string(ruleBytes)
} else if input.Type == models.PiggyBankTypeAuto || input.Type == models.PiggyBankTypeFixedDeposit {
// Auto and fixed deposit types require auto rule
return nil, ErrInvalidAutoRule
}
}
// Update fields
piggyBank.Name = input.Name
piggyBank.TargetAmount = input.TargetAmount
piggyBank.Type = input.Type
piggyBank.TargetDate = input.TargetDate
piggyBank.LinkedAccountID = input.LinkedAccountID
piggyBank.AutoRule = autoRuleJSON
// Save to database
if err := s.repo.Update(piggyBank); err != nil {
return nil, fmt.Errorf("failed to update piggy bank: %w", err)
}
return piggyBank, nil
}
// DeletePiggyBank deletes a piggy bank by ID after verifying ownership
func (s *PiggyBankService) DeletePiggyBank(userID, id uint) error {
_, err := s.repo.GetByID(userID, id)
if err != nil {
if errors.Is(err, repository.ErrPiggyBankNotFound) {
return ErrPiggyBankNotFound
}
return fmt.Errorf("failed to check piggy bank existence: %w", err)
}
// userID check handled by repo
err = s.repo.Delete(userID, id)
if err != nil {
if errors.Is(err, repository.ErrPiggyBankNotFound) {
return ErrPiggyBankNotFound
}
if errors.Is(err, repository.ErrPiggyBankInUse) {
return ErrPiggyBankInUse
}
return fmt.Errorf("failed to delete piggy bank: %w", err)
}
return nil
}
// 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) {
// Validate deposit amount
if input.Amount <= 0 {
return nil, ErrInvalidDepositAmount
}
// Start a transaction
tx := s.db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// Get the piggy bank using the transaction
var piggyBank models.PiggyBank
if err := tx.Preload("LinkedAccount").First(&piggyBank, id).Error; err != nil {
tx.Rollback()
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrPiggyBankNotFound
}
return nil, fmt.Errorf("failed to get piggy bank: %w", err)
}
// If fromAccountID is provided, deduct from that account
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)
}
// Check if account has sufficient funds (only for non-credit accounts)
if !account.IsCredit && account.Balance < input.Amount {
tx.Rollback()
return nil, ErrInsufficientAccountFunds
}
// Deduct from account
account.Balance -= input.Amount
if err := tx.Save(&account).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("failed to update account balance: %w", err)
}
}
// Add to 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)
}
// Commit transaction
if err := tx.Commit().Error; err != nil {
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
return &piggyBank, nil
}
// Withdraw removes money from a piggy bank (breaking the piggy bank)
// If toAccountID is provided, it will add to that account
func (s *PiggyBankService) Withdraw(userID, id uint, input WithdrawInput) (*models.PiggyBank, error) {
// Validate withdraw amount
if input.Amount <= 0 {
return nil, ErrInvalidWithdrawAmount
}
// Start a transaction
tx := s.db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// Get the piggy bank using the transaction
var piggyBank models.PiggyBank
if err := tx.Preload("LinkedAccount").Where("user_id = ?", userID).First(&piggyBank, id).Error; err != nil {
tx.Rollback()
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrPiggyBankNotFound
}
return nil, fmt.Errorf("failed to get piggy bank: %w", err)
}
// Check if piggy bank has sufficient balance
if piggyBank.CurrentAmount < input.Amount {
tx.Rollback()
return nil, ErrInsufficientBalance
}
// 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 {
tx.Rollback()
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAccountNotFound
}
return nil, fmt.Errorf("failed to get account: %w", err)
}
// Add to account
account.Balance += input.Amount
if err := tx.Save(&account).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("failed to update account balance: %w", err)
}
}
// Commit transaction
if err := tx.Commit().Error; err != nil {
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
return &piggyBank, nil
}
// GetPiggyBankProgress calculates and returns the progress of a piggy bank for a user
func (s *PiggyBankService) GetPiggyBankProgress(userID, id uint) (*PiggyBankProgress, error) {
// Get the piggy bank
piggyBank, err := s.repo.GetByID(userID, id)
if err != nil {
if errors.Is(err, repository.ErrPiggyBankNotFound) {
return nil, ErrPiggyBankNotFound
}
return nil, fmt.Errorf("failed to get piggy bank: %w", err)
}
// userID check handled by repo
// Calculate progress metrics
remaining := piggyBank.TargetAmount - piggyBank.CurrentAmount
if remaining < 0 {
remaining = 0
}
progress := 0.0
if piggyBank.TargetAmount > 0 {
progress = (piggyBank.CurrentAmount / piggyBank.TargetAmount) * 100
if progress > 100 {
progress = 100
}
}
isCompleted := piggyBank.CurrentAmount >= piggyBank.TargetAmount
// Calculate days remaining if target date is set
var daysRemaining *int
if piggyBank.TargetDate != nil {
days := int(time.Until(*piggyBank.TargetDate).Hours() / 24)
daysRemaining = &days
}
return &PiggyBankProgress{
PiggyBankID: piggyBank.ID,
Name: piggyBank.Name,
TargetAmount: piggyBank.TargetAmount,
CurrentAmount: piggyBank.CurrentAmount,
Remaining: remaining,
Progress: progress,
Type: piggyBank.Type,
TargetDate: piggyBank.TargetDate,
IsCompleted: isCompleted,
DaysRemaining: daysRemaining,
LinkedAccountID: piggyBank.LinkedAccountID,
}, nil
}
// GetAllPiggyBankProgress returns progress for all piggy banks for a user
func (s *PiggyBankService) GetAllPiggyBankProgress(userID uint) ([]PiggyBankProgress, error) {
piggyBanks, err := s.repo.GetAll(userID)
if err != nil {
return nil, fmt.Errorf("failed to get piggy banks: %w", err)
}
var progressList []PiggyBankProgress
for _, piggyBank := range piggyBanks {
progress, err := s.GetPiggyBankProgress(userID, piggyBank.ID)
if err != nil {
return nil, fmt.Errorf("failed to calculate progress for piggy bank %d: %w", piggyBank.ID, err)
}
progressList = append(progressList, *progress)
}
return progressList, nil
}
// GetActivePiggyBanks retrieves all piggy banks that haven't reached their target yet for a user
func (s *PiggyBankService) GetActivePiggyBanks(userID uint) ([]models.PiggyBank, error) {
piggyBanks, err := s.repo.GetActiveGoals(userID)
if err != nil {
return nil, fmt.Errorf("failed to get active piggy banks: %w", err)
}
return piggyBanks, nil
}
// GetCompletedPiggyBanks retrieves all piggy banks that have reached their target for a user
func (s *PiggyBankService) GetCompletedPiggyBanks(userID uint) ([]models.PiggyBank, error) {
piggyBanks, err := s.repo.GetCompletedGoals(userID)
if err != nil {
return nil, fmt.Errorf("failed to get completed piggy banks: %w", err)
}
return piggyBanks, nil
}
// GetPiggyBanksByType retrieves all piggy banks of a specific type for a user
func (s *PiggyBankService) GetPiggyBanksByType(userID uint, piggyBankType models.PiggyBankType) ([]models.PiggyBank, error) {
piggyBanks, err := s.repo.GetByType(userID, piggyBankType)
if err != nil {
return nil, fmt.Errorf("failed to get piggy banks by type: %w", err)
}
return piggyBanks, nil
}
// ProcessAutoDeposits processes automatic deposits for all auto piggy banks of a user
// This should be called by a scheduled job
func (s *PiggyBankService) ProcessAutoDeposits(userID uint) error {
// Get all auto piggy banks
autoPiggyBanks, err := s.repo.GetByType(userID, models.PiggyBankTypeAuto)
if err != nil {
return fmt.Errorf("failed to get auto piggy banks: %w", err)
}
fixedDepositPiggyBanks, err := s.repo.GetByType(userID, models.PiggyBankTypeFixedDeposit)
if err != nil {
return fmt.Errorf("failed to get fixed deposit piggy banks: %w", err)
}
week52PiggyBanks, err := s.repo.GetByType(userID, models.PiggyBankTypeWeek52)
if err != nil {
return fmt.Errorf("failed to get week 52 piggy banks: %w", err)
}
allAutoPiggyBanks := append(autoPiggyBanks, fixedDepositPiggyBanks...)
allAutoPiggyBanks = append(allAutoPiggyBanks, week52PiggyBanks...)
now := time.Now()
for _, piggyBank := range allAutoPiggyBanks {
// Skip if already completed
if piggyBank.CurrentAmount >= piggyBank.TargetAmount {
continue
}
// Parse auto rule
if piggyBank.AutoRule == "" {
continue
}
var rule AutoDepositRule
if err := json.Unmarshal([]byte(piggyBank.AutoRule), &rule); err != nil {
continue
}
// Check if deposit should be made based on frequency
shouldDeposit := false
depositAmount := rule.Amount
switch rule.Frequency {
case "daily":
shouldDeposit = true
case "weekly":
if rule.DayOfWeek != nil && int(now.Weekday()) == *rule.DayOfWeek {
shouldDeposit = true
}
case "monthly":
if rule.DayOfMonth != nil && now.Day() == *rule.DayOfMonth {
shouldDeposit = true
}
}
// For Week 52 type, calculate the week number and deposit amount
if piggyBank.Type == models.PiggyBankTypeWeek52 {
// Calculate week number since creation
weeksSinceCreation := int(time.Since(piggyBank.CreatedAt).Hours() / 24 / 7)
if weeksSinceCreation < 52 {
depositAmount = float64(weeksSinceCreation + 1) // Week 1: $1, Week 2: $2, etc.
shouldDeposit = int(now.Weekday()) == 1 // Monday
}
}
if shouldDeposit && piggyBank.LinkedAccountID != nil {
// Make the deposit
_, err := s.Deposit(userID, piggyBank.ID, DepositInput{
Amount: depositAmount,
FromAccountID: piggyBank.LinkedAccountID,
Note: "Automatic deposit",
})
if err != nil {
// Log error but continue with other piggy banks
fmt.Printf("Failed to process auto deposit for piggy bank %d: %v\n", piggyBank.ID, err)
}
}
}
return nil
}
// isValidPiggyBankType checks if a piggy bank type is valid
func isValidPiggyBankType(piggyBankType models.PiggyBankType) bool {
switch piggyBankType {
case models.PiggyBankTypeManual, models.PiggyBankTypeAuto, models.PiggyBankTypeFixedDeposit, models.PiggyBankTypeWeek52:
return true
default:
return false
}
}