package repository import ( "fmt" "time" "accounting-app/internal/models" "gorm.io/gorm" ) // ReportRepository handles database operations for reports type ReportRepository struct { db *gorm.DB } // NewReportRepository creates a new ReportRepository instance func NewReportRepository(db *gorm.DB) *ReportRepository { return &ReportRepository{db: db} } // TransactionSummary represents aggregated transaction data type TransactionSummary struct { Currency models.Currency TotalIncome float64 TotalExpense float64 Balance float64 Count int64 } // CategorySummary represents aggregated data by category type CategorySummary struct { CategoryID uint CategoryName string Currency models.Currency TotalAmount float64 Count int64 } // GetTransactionSummaryByCurrency retrieves transaction summary grouped by currency func (r *ReportRepository) GetTransactionSummaryByCurrency(userID uint, startDate, endDate time.Time) ([]TransactionSummary, error) { // Query for income incomeQuery := r.db.Model(&models.Transaction{}). Select("currency, COALESCE(SUM(amount), 0) as total_income, COUNT(*) as count"). Where("user_id = ? AND transaction_date >= ? AND transaction_date <= ? AND type = ?", userID, startDate, endDate, models.TransactionTypeIncome). Group("currency") var incomeResults []struct { Currency string TotalIncome float64 Count int64 } if err := incomeQuery.Scan(&incomeResults).Error; err != nil { return nil, fmt.Errorf("failed to get income summary: %w", err) } // Query for expense expenseQuery := r.db.Model(&models.Transaction{}). Select("currency, COALESCE(SUM(amount), 0) as total_expense, COUNT(*) as count"). Where("user_id = ? AND transaction_date >= ? AND transaction_date <= ? AND type = ?", userID, startDate, endDate, models.TransactionTypeExpense). Group("currency") var expenseResults []struct { Currency string TotalExpense float64 Count int64 } if err := expenseQuery.Scan(&expenseResults).Error; err != nil { return nil, fmt.Errorf("failed to get expense summary: %w", err) } // Merge results by currency summaryMap := make(map[models.Currency]*TransactionSummary) for _, income := range incomeResults { currency := models.Currency(income.Currency) if summaryMap[currency] == nil { summaryMap[currency] = &TransactionSummary{Currency: currency} } summaryMap[currency].TotalIncome = income.TotalIncome summaryMap[currency].Count += income.Count } for _, expense := range expenseResults { currency := models.Currency(expense.Currency) if summaryMap[currency] == nil { summaryMap[currency] = &TransactionSummary{Currency: currency} } summaryMap[currency].TotalExpense = expense.TotalExpense summaryMap[currency].Count += expense.Count } // Convert map to slice and calculate balance var summaries []TransactionSummary for _, summary := range summaryMap { summary.Balance = summary.TotalIncome - summary.TotalExpense summaries = append(summaries, *summary) } return summaries, nil } // GetCategorySummaryByCurrency retrieves category summary grouped by currency func (r *ReportRepository) GetCategorySummaryByCurrency(userID uint, startDate, endDate time.Time, transactionType models.TransactionType) ([]CategorySummary, error) { var results []CategorySummary query := r.db.Model(&models.Transaction{}). Select("transactions.category_id, categories.name as category_name, transactions.currency, COALESCE(SUM(transactions.amount), 0) as total_amount, COUNT(*) as count"). Joins("LEFT JOIN categories ON categories.id = transactions.category_id"). Where("transactions.user_id = ? AND transactions.transaction_date >= ? AND transactions.transaction_date <= ? AND transactions.type = ?", userID, startDate, endDate, transactionType). Group("transactions.category_id, categories.name, transactions.currency"). Order("total_amount DESC") if err := query.Scan(&results).Error; err != nil { return nil, fmt.Errorf("failed to get category summary: %w", err) } return results, nil } // GetTransactionSummaryAllCurrencies retrieves overall transaction summary (all currencies combined) func (r *ReportRepository) GetTransactionSummaryAllCurrencies(userID uint, startDate, endDate time.Time) (*TransactionSummary, error) { var result TransactionSummary // Get total income var incomeResult struct { Total float64 Count int64 } if err := r.db.Model(&models.Transaction{}). Select("COALESCE(SUM(amount), 0) as total, COUNT(*) as count"). Where("user_id = ? AND transaction_date >= ? AND transaction_date <= ? AND type = ?", userID, startDate, endDate, models.TransactionTypeIncome). Scan(&incomeResult).Error; err != nil { return nil, fmt.Errorf("failed to get total income: %w", err) } result.TotalIncome = incomeResult.Total // Get total expense var expenseResult struct { Total float64 Count int64 } if err := r.db.Model(&models.Transaction{}). Select("COALESCE(SUM(amount), 0) as total, COUNT(*) as count"). Where("user_id = ? AND transaction_date >= ? AND transaction_date <= ? AND type = ?", userID, startDate, endDate, models.TransactionTypeExpense). Scan(&expenseResult).Error; err != nil { return nil, fmt.Errorf("failed to get total expense: %w", err) } result.TotalExpense = expenseResult.Total result.Count = incomeResult.Count + expenseResult.Count result.Balance = result.TotalIncome - result.TotalExpense return &result, nil } // GetCategorySummaryAllCurrencies retrieves category summary (all currencies combined) func (r *ReportRepository) GetCategorySummaryAllCurrencies(userID uint, startDate, endDate time.Time, transactionType models.TransactionType) ([]CategorySummary, error) { var results []CategorySummary query := r.db.Model(&models.Transaction{}). Select("transactions.category_id, categories.name as category_name, COALESCE(SUM(transactions.amount), 0) as total_amount, COUNT(*) as count"). Joins("LEFT JOIN categories ON categories.id = transactions.category_id"). Where("transactions.user_id = ? AND transactions.transaction_date >= ? AND transactions.transaction_date <= ? AND transactions.type = ?", userID, startDate, endDate, transactionType). Group("transactions.category_id, categories.name"). Order("total_amount DESC") if err := query.Scan(&results).Error; err != nil { return nil, fmt.Errorf("failed to get category summary: %w", err) } return results, nil } // GetTransactionsByCurrency retrieves all transactions for a specific currency in a date range func (r *ReportRepository) GetTransactionsByCurrency(userID uint, startDate, endDate time.Time, currency models.Currency) ([]models.Transaction, error) { var transactions []models.Transaction if err := r.db.Where("user_id = ? AND transaction_date >= ? AND transaction_date <= ? AND currency = ?", userID, startDate, endDate, currency). Order("transaction_date DESC"). Preload("Category"). Preload("Account"). Preload("Tags"). Find(&transactions).Error; err != nil { return nil, fmt.Errorf("failed to get transactions by currency: %w", err) } return transactions, nil } // TrendDataPoint represents a single point in trend data type TrendDataPoint struct { Date time.Time TotalIncome float64 TotalExpense float64 Balance float64 Count int64 } // GetTrendDataByDay retrieves daily trend data func (r *ReportRepository) GetTrendDataByDay(userID uint, startDate, endDate time.Time, currency *models.Currency) ([]TrendDataPoint, error) { query := r.db.Model(&models.Transaction{}). Select("DATE(transaction_date) as date, "+ "COALESCE(SUM(CASE WHEN type = ? THEN amount ELSE 0 END), 0) as total_income, "+ "COALESCE(SUM(CASE WHEN type = ? THEN amount ELSE 0 END), 0) as total_expense, "+ "COUNT(*) as count", models.TransactionTypeIncome, models.TransactionTypeExpense). Where("user_id = ? AND transaction_date >= ? AND transaction_date <= ?", userID, startDate, endDate) if currency != nil { query = query.Where("currency = ?", *currency) } query = query.Group("DATE(transaction_date)").Order("date ASC") var results []struct { Date string TotalIncome float64 TotalExpense float64 Count int64 } if err := query.Scan(&results).Error; err != nil { return nil, fmt.Errorf("failed to get daily trend data: %w", err) } // Convert to TrendDataPoint trendData := make([]TrendDataPoint, 0, len(results)) for _, result := range results { date, err := parseFlexibleDate(result.Date) if err != nil { return nil, fmt.Errorf("failed to parse date: %w", err) } trendData = append(trendData, TrendDataPoint{ Date: date, TotalIncome: result.TotalIncome, TotalExpense: result.TotalExpense, Balance: result.TotalIncome - result.TotalExpense, Count: result.Count, }) } return trendData, nil } // parseFlexibleDate parses date strings in various formats func parseFlexibleDate(dateStr string) (time.Time, error) { // Try different date formats formats := []string{ "2006-01-02", "2006-01-02T15:04:05Z07:00", "2006-01-02T15:04:05+08:00", "2006-01-02T15:04:05", "2006-01-02 15:04:05", time.RFC3339, time.RFC3339Nano, } for _, format := range formats { if t, err := time.Parse(format, dateStr); err == nil { return t, nil } } // If all formats fail, try to extract just the date part if len(dateStr) >= 10 { if t, err := time.Parse("2006-01-02", dateStr[:10]); err == nil { return t, nil } } return time.Time{}, fmt.Errorf("unable to parse date: %s", dateStr) } // GetTrendDataByWeek retrieves weekly trend data func (r *ReportRepository) GetTrendDataByWeek(userID uint, startDate, endDate time.Time, currency *models.Currency) ([]TrendDataPoint, error) { query := r.db.Model(&models.Transaction{}). Select("YEARWEEK(transaction_date, 1) as week, "+ "MIN(DATE(transaction_date)) as date, "+ "COALESCE(SUM(CASE WHEN type = ? THEN amount ELSE 0 END), 0) as total_income, "+ "COALESCE(SUM(CASE WHEN type = ? THEN amount ELSE 0 END), 0) as total_expense, "+ "COUNT(*) as count", models.TransactionTypeIncome, models.TransactionTypeExpense). Where("user_id = ? AND transaction_date >= ? AND transaction_date <= ?", userID, startDate, endDate) if currency != nil { query = query.Where("currency = ?", *currency) } query = query.Group("YEARWEEK(transaction_date, 1)").Order("week ASC") var results []struct { Week string Date string TotalIncome float64 TotalExpense float64 Count int64 } if err := query.Scan(&results).Error; err != nil { return nil, fmt.Errorf("failed to get weekly trend data: %w", err) } // Convert to TrendDataPoint trendData := make([]TrendDataPoint, 0, len(results)) for _, result := range results { date, err := parseFlexibleDate(result.Date) if err != nil { return nil, fmt.Errorf("failed to parse date: %w", err) } trendData = append(trendData, TrendDataPoint{ Date: date, TotalIncome: result.TotalIncome, TotalExpense: result.TotalExpense, Balance: result.TotalIncome - result.TotalExpense, Count: result.Count, }) } return trendData, nil } // GetTrendDataByMonth retrieves monthly trend data func (r *ReportRepository) GetTrendDataByMonth(userID uint, startDate, endDate time.Time, currency *models.Currency) ([]TrendDataPoint, error) { query := r.db.Model(&models.Transaction{}). Select("DATE_FORMAT(transaction_date, '%Y-%m') as month, "+ "DATE_FORMAT(transaction_date, '%Y-%m-01') as date, "+ "COALESCE(SUM(CASE WHEN type = ? THEN amount ELSE 0 END), 0) as total_income, "+ "COALESCE(SUM(CASE WHEN type = ? THEN amount ELSE 0 END), 0) as total_expense, "+ "COUNT(*) as count", models.TransactionTypeIncome, models.TransactionTypeExpense). Where("user_id = ? AND transaction_date >= ? AND transaction_date <= ?", userID, startDate, endDate) if currency != nil { query = query.Where("currency = ?", *currency) } query = query.Group("DATE_FORMAT(transaction_date, '%Y-%m')").Order("month ASC") var results []struct { Month string Date string TotalIncome float64 TotalExpense float64 Count int64 } if err := query.Scan(&results).Error; err != nil { return nil, fmt.Errorf("failed to get monthly trend data: %w", err) } // Convert to TrendDataPoint trendData := make([]TrendDataPoint, 0, len(results)) for _, result := range results { date, err := parseFlexibleDate(result.Date) if err != nil { return nil, fmt.Errorf("failed to parse date: %w", err) } trendData = append(trendData, TrendDataPoint{ Date: date, TotalIncome: result.TotalIncome, TotalExpense: result.TotalExpense, Balance: result.TotalIncome - result.TotalExpense, Count: result.Count, }) } return trendData, nil } // GetTrendDataByYear retrieves yearly trend data func (r *ReportRepository) GetTrendDataByYear(userID uint, startDate, endDate time.Time, currency *models.Currency) ([]TrendDataPoint, error) { query := r.db.Model(&models.Transaction{}). Select("YEAR(transaction_date) as year, "+ "CONCAT(YEAR(transaction_date), '-01-01') as date, "+ "COALESCE(SUM(CASE WHEN type = ? THEN amount ELSE 0 END), 0) as total_income, "+ "COALESCE(SUM(CASE WHEN type = ? THEN amount ELSE 0 END), 0) as total_expense, "+ "COUNT(*) as count", models.TransactionTypeIncome, models.TransactionTypeExpense). Where("user_id = ? AND transaction_date >= ? AND transaction_date <= ?", userID, startDate, endDate) if currency != nil { query = query.Where("currency = ?", *currency) } query = query.Group("YEAR(transaction_date)").Order("year ASC") var results []struct { Year string Date string TotalIncome float64 TotalExpense float64 Count int64 } if err := query.Scan(&results).Error; err != nil { return nil, fmt.Errorf("failed to get yearly trend data: %w", err) } // Convert to TrendDataPoint trendData := make([]TrendDataPoint, 0, len(results)) for _, result := range results { date, err := parseFlexibleDate(result.Date) if err != nil { return nil, fmt.Errorf("failed to parse date: %w", err) } trendData = append(trendData, TrendDataPoint{ Date: date, TotalIncome: result.TotalIncome, TotalExpense: result.TotalExpense, Balance: result.TotalIncome - result.TotalExpense, Count: result.Count, }) } return trendData, nil } // AssetsSummary represents assets and liabilities summary type AssetsSummary struct { Currency models.Currency TotalAssets float64 TotalLiabilities float64 NetAssets float64 AccountCount int64 } // GetAssetsSummaryByCurrency retrieves assets and liabilities summary grouped by currency func (r *ReportRepository) GetAssetsSummaryByCurrency(userID uint) ([]AssetsSummary, error) { var results []struct { Currency string TotalAssets float64 TotalLiabilities float64 AccountCount int64 } // Query to get assets (positive balances) and liabilities (negative balances) by currency query := r.db.Model(&models.Account{}). Select("currency, "+ "COALESCE(SUM(CASE WHEN balance >= 0 THEN balance ELSE 0 END), 0) as total_assets, "+ "COALESCE(SUM(CASE WHEN balance < 0 THEN -balance ELSE 0 END), 0) as total_liabilities, "+ "COUNT(*) as account_count"). Where("user_id = ?", userID). Group("currency") if err := query.Scan(&results).Error; err != nil { return nil, fmt.Errorf("failed to get assets summary: %w", err) } // Convert to AssetsSummary summaries := make([]AssetsSummary, 0, len(results)) for _, result := range results { summaries = append(summaries, AssetsSummary{ Currency: models.Currency(result.Currency), TotalAssets: result.TotalAssets, TotalLiabilities: result.TotalLiabilities, NetAssets: result.TotalAssets - result.TotalLiabilities, AccountCount: result.AccountCount, }) } return summaries, nil } // GetAccountsByBalanceType retrieves accounts grouped by balance type (assets or liabilities) func (r *ReportRepository) GetAccountsByBalanceType(userID uint, currency *models.Currency) (assets []models.Account, liabilities []models.Account, err error) { query := r.db.Model(&models.Account{}).Where("user_id = ?", userID) if currency != nil { query = query.Where("currency = ?", *currency) } var accounts []models.Account if err := query.Find(&accounts).Error; err != nil { return nil, nil, fmt.Errorf("failed to get accounts: %w", err) } // Separate into assets and liabilities for _, account := range accounts { if account.Balance >= 0 { assets = append(assets, account) } else { liabilities = append(liabilities, account) } } return assets, liabilities, nil } // GetSpendingByHour retrieves spending grouped by hour of day func (r *ReportRepository) GetSpendingByHour(userID uint, startDate, endDate time.Time, currency *models.Currency) ([]HourSummary, error) { query := r.db.Model(&models.Transaction{}). Select("HOUR(created_at) as hour, "+ "COALESCE(SUM(amount), 0) as total_amount, "+ "COUNT(*) as count"). Where("user_id = ? AND transaction_date >= ? AND transaction_date <= ? AND type = ?", userID, startDate, endDate, models.TransactionTypeExpense) if currency != nil { query = query.Where("currency = ?", *currency) } query = query.Group("HOUR(created_at)").Order("hour ASC") var results []struct { Hour int TotalAmount float64 Count int64 } if err := query.Scan(&results).Error; err != nil { return nil, fmt.Errorf("failed to get spending by hour: %w", err) } // Convert to HourSummary hourSummaries := make([]HourSummary, 0, len(results)) for _, result := range results { avgAmount := 0.0 if result.Count > 0 { avgAmount = result.TotalAmount / float64(result.Count) } hourSummaries = append(hourSummaries, HourSummary{ Hour: result.Hour, TotalAmount: result.TotalAmount, Count: result.Count, AvgAmount: avgAmount, }) } return hourSummaries, nil } // HourSummary represents spending by hour of day type HourSummary struct { Hour int TotalAmount float64 Count int64 AvgAmount float64 } // GetCommonScenarios retrieves common spending scenarios (categories) func (r *ReportRepository) GetCommonScenarios(userID uint, startDate, endDate time.Time, currency *models.Currency) ([]ScenarioSummary, error) { query := r.db.Model(&models.Transaction{}). Select("transactions.category_id, categories.name as category_name, "+ "COALESCE(SUM(transactions.amount), 0) as total_amount, "+ "COUNT(*) as count"). Joins("LEFT JOIN categories ON categories.id = transactions.category_id"). Where("transactions.user_id = ? AND transactions.transaction_date >= ? AND transactions.transaction_date <= ? AND transactions.type = ?", userID, startDate, endDate, models.TransactionTypeExpense) if currency != nil { query = query.Where("transactions.currency = ?", *currency) } query = query.Group("transactions.category_id, categories.name"). Order("count DESC"). Limit(10) // Top 10 most frequent scenarios var results []ScenarioSummary if err := query.Scan(&results).Error; err != nil { return nil, fmt.Errorf("failed to get common scenarios: %w", err) } return results, nil } // ScenarioSummary represents spending by category (scenario) type ScenarioSummary struct { CategoryID uint CategoryName string TotalAmount float64 Count int64 Frequency float64 } // GetAllAccounts retrieves all accounts func (r *ReportRepository) GetAllAccounts(userID uint) ([]models.Account, error) { var accounts []models.Account if err := r.db.Where("user_id = ?", userID).Find(&accounts).Error; err != nil { return nil, fmt.Errorf("failed to get all accounts: %w", err) } return accounts, nil } // GetAssetTrend retrieves asset trend over time func (r *ReportRepository) GetAssetTrend(userID uint, startDate, endDate time.Time) ([]AssetTrendPoint, error) { // This is a simplified implementation that calculates daily snapshots // In a real system, you might want to store daily snapshots for better performance var trendPoints []AssetTrendPoint // Generate daily points currentDate := startDate for currentDate.Before(endDate) || currentDate.Equal(endDate) { // Calculate balance at end of this day // This requires summing all transactions up to this date var accounts []models.Account if err := r.db.Where("user_id = ?", userID).Find(&accounts).Error; err != nil { return nil, fmt.Errorf("failed to get accounts: %w", err) } var totalAssets, totalLiabilities float64 for _, account := range accounts { // Get initial balance (would need to be stored separately in real system) // For now, we'll use current balance and adjust by transactions balance := account.Balance // Adjust by transactions after currentDate var futureTransactions []models.Transaction r.db.Where("user_id = ? AND account_id = ? AND transaction_date > ?", userID, account.ID, currentDate).Find(&futureTransactions) for _, txn := range futureTransactions { if txn.Type == models.TransactionTypeIncome { balance -= txn.Amount } else if txn.Type == models.TransactionTypeExpense { balance += txn.Amount } } if balance >= 0 { totalAssets += balance } else { totalLiabilities += -balance } } trendPoints = append(trendPoints, AssetTrendPoint{ Date: currentDate, TotalAssets: totalAssets, TotalLiabilities: totalLiabilities, NetAssets: totalAssets - totalLiabilities, }) currentDate = currentDate.AddDate(0, 0, 1) } return trendPoints, nil } // AssetTrendPoint represents a point in asset trend type AssetTrendPoint struct { Date time.Time TotalAssets float64 TotalLiabilities float64 NetAssets float64 }