Files
Novault-backend/internal/repository/report_repository.go
2026-01-25 21:59:00 +08:00

640 lines
21 KiB
Go

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
}