package service import ( "fmt" "time" "accounting-app/internal/models" "accounting-app/internal/repository" ) // ReportService handles business logic for reports type ReportService struct { reportRepo *repository.ReportRepository exchangeRateRepo *repository.ExchangeRateRepository } // NewReportService creates a new ReportService instance func NewReportService(reportRepo *repository.ReportRepository, exchangeRateRepo *repository.ExchangeRateRepository) *ReportService { return &ReportService{ reportRepo: reportRepo, exchangeRateRepo: exchangeRateRepo, } } // MultiCurrencySummary represents a summary that can be displayed in multiple ways type MultiCurrencySummary struct { // Separated by currency ByCurrency []CurrencySummary `json:"by_currency"` // Converted to a single currency (if requested) Unified *UnifiedSummary `json:"unified,omitempty"` } // CurrencySummary represents summary for a single currency type CurrencySummary struct { Currency models.Currency `json:"currency"` TotalIncome float64 `json:"total_income"` TotalExpense float64 `json:"total_expense"` Balance float64 `json:"balance"` Count int64 `json:"count"` } // UnifiedSummary represents summary converted to a single currency type UnifiedSummary struct { TargetCurrency models.Currency `json:"target_currency"` TotalIncome float64 `json:"total_income"` TotalExpense float64 `json:"total_expense"` Balance float64 `json:"balance"` ConversionDate time.Time `json:"conversion_date"` } // CategorySummary represents category-level summary type CategorySummary struct { CategoryID uint `json:"category_id"` CategoryName string `json:"category_name"` Currency models.Currency `json:"currency,omitempty"` TotalAmount float64 `json:"total_amount"` Count int64 `json:"count"` Percentage float64 `json:"percentage,omitempty"` } // MultiCurrencyCategorySummary represents category summary with multi-currency support type MultiCurrencyCategorySummary struct { // Separated by currency ByCurrency []CategorySummary `json:"by_currency"` // Converted to a single currency (if requested) Unified []CategorySummary `json:"unified,omitempty"` } // GetTransactionSummary retrieves transaction summary with multi-currency support func (s *ReportService) GetTransactionSummary(userID uint, startDate, endDate time.Time, targetCurrency *models.Currency, conversionDate *time.Time) (*MultiCurrencySummary, error) { // Get summary by currency summaries, err := s.reportRepo.GetTransactionSummaryByCurrency(userID, startDate, endDate) if err != nil { return nil, fmt.Errorf("failed to get transaction summary: %w", err) } // Convert to response format result := &MultiCurrencySummary{ ByCurrency: make([]CurrencySummary, 0, len(summaries)), } for _, summary := range summaries { result.ByCurrency = append(result.ByCurrency, CurrencySummary{ Currency: summary.Currency, TotalIncome: summary.TotalIncome, TotalExpense: summary.TotalExpense, Balance: summary.Balance, Count: summary.Count, }) } // If target currency is specified, convert all to that currency if targetCurrency != nil { unified, err := s.convertToUnifiedSummary(summaries, *targetCurrency, conversionDate) if err != nil { return nil, fmt.Errorf("failed to convert to unified summary: %w", err) } result.Unified = unified } return result, nil } // GetCategorySummary retrieves category summary with multi-currency support func (s *ReportService) GetCategorySummary(userID uint, startDate, endDate time.Time, transactionType models.TransactionType, targetCurrency *models.Currency, conversionDate *time.Time) (*MultiCurrencyCategorySummary, error) { // Get summary by currency summaries, err := s.reportRepo.GetCategorySummaryByCurrency(userID, startDate, endDate, transactionType) if err != nil { return nil, fmt.Errorf("failed to get category summary: %w", err) } // Convert to response format result := &MultiCurrencyCategorySummary{ ByCurrency: make([]CategorySummary, 0, len(summaries)), } // Calculate total for percentage var totalByCurrency = make(map[models.Currency]float64) for _, summary := range summaries { totalByCurrency[summary.Currency] += summary.TotalAmount } for _, summary := range summaries { percentage := 0.0 if totalByCurrency[summary.Currency] > 0 { percentage = (summary.TotalAmount / totalByCurrency[summary.Currency]) * 100 } result.ByCurrency = append(result.ByCurrency, CategorySummary{ CategoryID: summary.CategoryID, CategoryName: summary.CategoryName, Currency: summary.Currency, TotalAmount: summary.TotalAmount, Count: summary.Count, Percentage: percentage, }) } // If target currency is specified, convert all to that currency if targetCurrency != nil { unified, err := s.convertToUnifiedCategorySummary(summaries, *targetCurrency, conversionDate) if err != nil { return nil, fmt.Errorf("failed to convert to unified category summary: %w", err) } // Calculate percentages for unified view var total float64 for _, cat := range unified { total += cat.TotalAmount } for i := range unified { if total > 0 { unified[i].Percentage = (unified[i].TotalAmount / total) * 100 } } result.Unified = unified } return result, nil } // convertToUnifiedSummary converts multiple currency summaries to a single currency func (s *ReportService) convertToUnifiedSummary(summaries []repository.TransactionSummary, targetCurrency models.Currency, conversionDate *time.Time) (*UnifiedSummary, error) { // Use current date if not specified date := time.Now() if conversionDate != nil { date = *conversionDate } unified := &UnifiedSummary{ TargetCurrency: targetCurrency, ConversionDate: date, } for _, summary := range summaries { // If already in target currency, no conversion needed if summary.Currency == targetCurrency { unified.TotalIncome += summary.TotalIncome unified.TotalExpense += summary.TotalExpense continue } // Get exchange rate rate, err := s.exchangeRateRepo.GetByCurrencyPairAndDate(summary.Currency, targetCurrency, date) if err != nil { // Try inverse rate inverseRate, inverseErr := s.exchangeRateRepo.GetByCurrencyPairAndDate(targetCurrency, summary.Currency, date) if inverseErr != nil { return nil, fmt.Errorf("exchange rate not found for %s to %s on %s: %w", summary.Currency, targetCurrency, date.Format("2006-01-02"), err) } rate = &models.ExchangeRate{ FromCurrency: summary.Currency, ToCurrency: targetCurrency, Rate: 1.0 / inverseRate.Rate, } } // Convert amounts unified.TotalIncome += summary.TotalIncome * rate.Rate unified.TotalExpense += summary.TotalExpense * rate.Rate } unified.Balance = unified.TotalIncome - unified.TotalExpense return unified, nil } // convertToUnifiedCategorySummary converts multiple currency category summaries to a single currency func (s *ReportService) convertToUnifiedCategorySummary(summaries []repository.CategorySummary, targetCurrency models.Currency, conversionDate *time.Time) ([]CategorySummary, error) { // Use current date if not specified date := time.Now() if conversionDate != nil { date = *conversionDate } // Group by category categoryMap := make(map[uint]*CategorySummary) for _, summary := range summaries { // Initialize category if not exists if categoryMap[summary.CategoryID] == nil { categoryMap[summary.CategoryID] = &CategorySummary{ CategoryID: summary.CategoryID, CategoryName: summary.CategoryName, TotalAmount: 0, Count: 0, } } // If already in target currency, no conversion needed if summary.Currency == targetCurrency { categoryMap[summary.CategoryID].TotalAmount += summary.TotalAmount categoryMap[summary.CategoryID].Count += summary.Count continue } // Get exchange rate rate, err := s.exchangeRateRepo.GetByCurrencyPairAndDate(summary.Currency, targetCurrency, date) if err != nil { // Try inverse rate inverseRate, inverseErr := s.exchangeRateRepo.GetByCurrencyPairAndDate(targetCurrency, summary.Currency, date) if inverseErr != nil { return nil, fmt.Errorf("exchange rate not found for %s to %s on %s: %w", summary.Currency, targetCurrency, date.Format("2006-01-02"), err) } rate = &models.ExchangeRate{ FromCurrency: summary.Currency, ToCurrency: targetCurrency, Rate: 1.0 / inverseRate.Rate, } } // Convert amount categoryMap[summary.CategoryID].TotalAmount += summary.TotalAmount * rate.Rate categoryMap[summary.CategoryID].Count += summary.Count } // Convert map to slice result := make([]CategorySummary, 0, len(categoryMap)) for _, cat := range categoryMap { result = append(result, *cat) } return result, nil } // PeriodType represents the time period for trend analysis type PeriodType string const ( PeriodTypeDay PeriodType = "day" PeriodTypeWeek PeriodType = "week" PeriodTypeMonth PeriodType = "month" PeriodTypeYear PeriodType = "year" ) // TrendData represents trend analysis data type TrendData struct { Period PeriodType `json:"period"` Currency *models.Currency `json:"currency,omitempty"` DataPoints []repository.TrendDataPoint `json:"data_points"` } // GetTrendData retrieves trend data for the specified period func (s *ReportService) GetTrendData(userID uint, startDate, endDate time.Time, period PeriodType, currency *models.Currency) (*TrendData, error) { var dataPoints []repository.TrendDataPoint var err error switch period { case PeriodTypeDay: dataPoints, err = s.reportRepo.GetTrendDataByDay(userID, startDate, endDate, currency) case PeriodTypeWeek: dataPoints, err = s.reportRepo.GetTrendDataByWeek(userID, startDate, endDate, currency) case PeriodTypeMonth: dataPoints, err = s.reportRepo.GetTrendDataByMonth(userID, startDate, endDate, currency) case PeriodTypeYear: dataPoints, err = s.reportRepo.GetTrendDataByYear(userID, startDate, endDate, currency) default: return nil, fmt.Errorf("invalid period type: %s", period) } if err != nil { return nil, fmt.Errorf("failed to get trend data: %w", err) } return &TrendData{ Period: period, Currency: currency, DataPoints: dataPoints, }, nil } // ComparisonData represents comparison analysis data (YoY and MoM) type ComparisonData struct { Current PeriodSummary `json:"current"` Previous PeriodSummary `json:"previous"` YearAgo PeriodSummary `json:"year_ago,omitempty"` Changes Changes `json:"changes"` } // PeriodSummary represents summary for a specific period type PeriodSummary struct { StartDate time.Time `json:"start_date"` EndDate time.Time `json:"end_date"` TotalIncome float64 `json:"total_income"` TotalExpense float64 `json:"total_expense"` Balance float64 `json:"balance"` } // Changes represents the changes between periods type Changes struct { IncomeChange float64 `json:"income_change"` IncomeChangePercent float64 `json:"income_change_percent"` ExpenseChange float64 `json:"expense_change"` ExpenseChangePercent float64 `json:"expense_change_percent"` BalanceChange float64 `json:"balance_change"` BalanceChangePercent float64 `json:"balance_change_percent"` YoYIncomeChange float64 `json:"yoy_income_change,omitempty"` YoYIncomeChangePercent float64 `json:"yoy_income_change_percent,omitempty"` YoYExpenseChange float64 `json:"yoy_expense_change,omitempty"` YoYExpenseChangePercent float64 `json:"yoy_expense_change_percent,omitempty"` } // GetComparisonData retrieves comparison data (MoM and YoY) func (s *ReportService) GetComparisonData(userID uint, currentStart, currentEnd time.Time, currency *models.Currency) (*ComparisonData, error) { // Calculate previous period (same duration) duration := currentEnd.Sub(currentStart) previousEnd := currentStart.AddDate(0, 0, -1) previousStart := previousEnd.Add(-duration) // Calculate year ago period yearAgoStart := currentStart.AddDate(-1, 0, 0) yearAgoEnd := currentEnd.AddDate(-1, 0, 0) // Get current period summary currentSummary, err := s.getPeriodSummary(userID, currentStart, currentEnd, currency) if err != nil { return nil, fmt.Errorf("failed to get current period summary: %w", err) } // Get previous period summary previousSummary, err := s.getPeriodSummary(userID, previousStart, previousEnd, currency) if err != nil { return nil, fmt.Errorf("failed to get previous period summary: %w", err) } // Get year ago period summary yearAgoSummary, err := s.getPeriodSummary(userID, yearAgoStart, yearAgoEnd, currency) if err != nil { return nil, fmt.Errorf("failed to get year ago period summary: %w", err) } // Calculate changes changes := Changes{ IncomeChange: currentSummary.TotalIncome - previousSummary.TotalIncome, ExpenseChange: currentSummary.TotalExpense - previousSummary.TotalExpense, BalanceChange: currentSummary.Balance - previousSummary.Balance, } // Calculate percentage changes (MoM) if previousSummary.TotalIncome > 0 { changes.IncomeChangePercent = (changes.IncomeChange / previousSummary.TotalIncome) * 100 } if previousSummary.TotalExpense > 0 { changes.ExpenseChangePercent = (changes.ExpenseChange / previousSummary.TotalExpense) * 100 } if previousSummary.Balance != 0 { changes.BalanceChangePercent = (changes.BalanceChange / previousSummary.Balance) * 100 } // Calculate YoY changes changes.YoYIncomeChange = currentSummary.TotalIncome - yearAgoSummary.TotalIncome changes.YoYExpenseChange = currentSummary.TotalExpense - yearAgoSummary.TotalExpense if yearAgoSummary.TotalIncome > 0 { changes.YoYIncomeChangePercent = (changes.YoYIncomeChange / yearAgoSummary.TotalIncome) * 100 } if yearAgoSummary.TotalExpense > 0 { changes.YoYExpenseChangePercent = (changes.YoYExpenseChange / yearAgoSummary.TotalExpense) * 100 } return &ComparisonData{ Current: *currentSummary, Previous: *previousSummary, YearAgo: *yearAgoSummary, Changes: changes, }, nil } // getPeriodSummary is a helper function to get summary for a specific period func (s *ReportService) getPeriodSummary(userID uint, startDate, endDate time.Time, currency *models.Currency) (*PeriodSummary, error) { var totalIncome, totalExpense float64 // If currency is specified, filter by currency if currency != nil { // Get transactions for the period and currency dataPoints, err := s.reportRepo.GetTrendDataByDay(userID, startDate, endDate, currency) if err != nil { return nil, err } // Sum up the data points for _, dp := range dataPoints { totalIncome += dp.TotalIncome totalExpense += dp.TotalExpense } } else { // Get all currencies summaries, err := s.reportRepo.GetTransactionSummaryByCurrency(userID, startDate, endDate) if err != nil { return nil, err } // Sum up all currencies for _, summary := range summaries { totalIncome += summary.TotalIncome totalExpense += summary.TotalExpense } } return &PeriodSummary{ StartDate: startDate, EndDate: endDate, TotalIncome: totalIncome, TotalExpense: totalExpense, Balance: totalIncome - totalExpense, }, nil } // AssetsSummaryResponse represents assets and liabilities overview type AssetsSummaryResponse struct { ByCurrency []AssetsCurrencySummary `json:"by_currency"` Unified *UnifiedAssetsSummary `json:"unified,omitempty"` } // AssetsCurrencySummary represents assets summary for a single currency type AssetsCurrencySummary struct { Currency models.Currency `json:"currency"` TotalAssets float64 `json:"total_assets"` TotalLiabilities float64 `json:"total_liabilities"` NetAssets float64 `json:"net_assets"` AccountCount int64 `json:"account_count"` } // UnifiedAssetsSummary represents assets summary converted to a single currency type UnifiedAssetsSummary struct { TargetCurrency models.Currency `json:"target_currency"` TotalAssets float64 `json:"total_assets"` TotalLiabilities float64 `json:"total_liabilities"` NetAssets float64 `json:"net_assets"` ConversionDate time.Time `json:"conversion_date"` } // GetAssetsSummary retrieves assets and liabilities overview func (s *ReportService) GetAssetsSummary(userID uint, targetCurrency *models.Currency, conversionDate *time.Time) (*AssetsSummaryResponse, error) { // Get summary by currency summaries, err := s.reportRepo.GetAssetsSummaryByCurrency(userID) if err != nil { return nil, fmt.Errorf("failed to get assets summary: %w", err) } // Convert to response format result := &AssetsSummaryResponse{ ByCurrency: make([]AssetsCurrencySummary, 0, len(summaries)), } for _, summary := range summaries { result.ByCurrency = append(result.ByCurrency, AssetsCurrencySummary{ Currency: summary.Currency, TotalAssets: summary.TotalAssets, TotalLiabilities: summary.TotalLiabilities, NetAssets: summary.NetAssets, AccountCount: summary.AccountCount, }) } // If target currency is specified, convert all to that currency if targetCurrency != nil { unified, err := s.convertToUnifiedAssetsSummary(summaries, *targetCurrency, conversionDate) if err != nil { return nil, fmt.Errorf("failed to convert to unified assets summary: %w", err) } result.Unified = unified } return result, nil } // convertToUnifiedAssetsSummary converts multiple currency assets summaries to a single currency func (s *ReportService) convertToUnifiedAssetsSummary(summaries []repository.AssetsSummary, targetCurrency models.Currency, conversionDate *time.Time) (*UnifiedAssetsSummary, error) { // Use current date if not specified date := time.Now() if conversionDate != nil { date = *conversionDate } unified := &UnifiedAssetsSummary{ TargetCurrency: targetCurrency, ConversionDate: date, } for _, summary := range summaries { // If already in target currency, no conversion needed if summary.Currency == targetCurrency { unified.TotalAssets += summary.TotalAssets unified.TotalLiabilities += summary.TotalLiabilities continue } // Get exchange rate rate, err := s.exchangeRateRepo.GetByCurrencyPairAndDate(summary.Currency, targetCurrency, date) if err != nil { // Try inverse rate inverseRate, inverseErr := s.exchangeRateRepo.GetByCurrencyPairAndDate(targetCurrency, summary.Currency, date) if inverseErr != nil { return nil, fmt.Errorf("exchange rate not found for %s to %s on %s: %w", summary.Currency, targetCurrency, date.Format("2006-01-02"), err) } rate = &models.ExchangeRate{ FromCurrency: summary.Currency, ToCurrency: targetCurrency, Rate: 1.0 / inverseRate.Rate, } } // Convert amounts unified.TotalAssets += summary.TotalAssets * rate.Rate unified.TotalLiabilities += summary.TotalLiabilities * rate.Rate } unified.NetAssets = unified.TotalAssets - unified.TotalLiabilities return unified, nil } // ConsumptionHabit represents consumption habit analysis type ConsumptionHabit struct { PeakHours []HourSummary `json:"peak_hours"` CommonScenarios []ScenarioSummary `json:"common_scenarios"` } // HourSummary represents spending by hour of day type HourSummary struct { Hour int `json:"hour"` TotalAmount float64 `json:"total_amount"` Count int64 `json:"count"` AvgAmount float64 `json:"avg_amount"` } // ScenarioSummary represents spending by category (scenario) type ScenarioSummary struct { CategoryID uint `json:"category_id"` CategoryName string `json:"category_name"` TotalAmount float64 `json:"total_amount"` Count int64 `json:"count"` Frequency float64 `json:"frequency"` // transactions per day } // GetConsumptionHabits analyzes consumption habits func (s *ReportService) GetConsumptionHabits(userID uint, startDate, endDate time.Time, currency *models.Currency) (*ConsumptionHabit, error) { // Get peak hours peakHoursRepo, err := s.reportRepo.GetSpendingByHour(userID, startDate, endDate, currency) if err != nil { return nil, fmt.Errorf("failed to get peak hours: %w", err) } // Convert repository types to service types peakHours := make([]HourSummary, len(peakHoursRepo)) for i, h := range peakHoursRepo { peakHours[i] = HourSummary{ Hour: h.Hour, TotalAmount: h.TotalAmount, Count: h.Count, AvgAmount: h.AvgAmount, } } // Get common scenarios (categories) scenariosRepo, err := s.reportRepo.GetCommonScenarios(userID, startDate, endDate, currency) if err != nil { return nil, fmt.Errorf("failed to get common scenarios: %w", err) } // Calculate frequency (transactions per day) days := endDate.Sub(startDate).Hours() / 24 if days < 1 { days = 1 } scenarios := make([]ScenarioSummary, len(scenariosRepo)) for i, sc := range scenariosRepo { scenarios[i] = ScenarioSummary{ CategoryID: sc.CategoryID, CategoryName: sc.CategoryName, TotalAmount: sc.TotalAmount, Count: sc.Count, Frequency: float64(sc.Count) / days, } } return &ConsumptionHabit{ PeakHours: peakHours, CommonScenarios: scenarios, }, nil } // AssetLiabilityAnalysis represents asset and liability analysis type AssetLiabilityAnalysis struct { TotalAssets float64 `json:"total_assets"` TotalLiabilities float64 `json:"total_liabilities"` NetAssets float64 `json:"net_assets"` AssetAccounts []AccountSummary `json:"asset_accounts"` LiabilityAccounts []AccountSummary `json:"liability_accounts"` AssetTrend []AssetTrendPoint `json:"asset_trend,omitempty"` } // AccountSummary represents account summary for asset/liability analysis type AccountSummary struct { AccountID uint `json:"account_id"` AccountName string `json:"account_name"` AccountType models.AccountType `json:"account_type"` Balance float64 `json:"balance"` Currency models.Currency `json:"currency"` Percentage float64 `json:"percentage"` } // AssetTrendPoint represents a point in asset trend type AssetTrendPoint struct { Date time.Time `json:"date"` TotalAssets float64 `json:"total_assets"` TotalLiabilities float64 `json:"total_liabilities"` NetAssets float64 `json:"net_assets"` } // GetAssetLiabilityAnalysis gets asset and liability analysis func (s *ReportService) GetAssetLiabilityAnalysis(userID uint, includeTrend bool, trendStartDate, trendEndDate *time.Time) (*AssetLiabilityAnalysis, error) { // Get all accounts accounts, err := s.reportRepo.GetAllAccounts(userID) if err != nil { return nil, fmt.Errorf("failed to get accounts: %w", err) } result := &AssetLiabilityAnalysis{ AssetAccounts: make([]AccountSummary, 0), LiabilityAccounts: make([]AccountSummary, 0), } // Separate assets and liabilities for _, account := range accounts { accountSummary := AccountSummary{ AccountID: account.ID, AccountName: account.Name, AccountType: account.Type, Balance: account.Balance, Currency: account.Currency, } if account.Balance >= 0 { result.TotalAssets += account.Balance result.AssetAccounts = append(result.AssetAccounts, accountSummary) } else { result.TotalLiabilities += -account.Balance result.LiabilityAccounts = append(result.LiabilityAccounts, accountSummary) } } result.NetAssets = result.TotalAssets - result.TotalLiabilities // Calculate percentages for i := range result.AssetAccounts { if result.TotalAssets > 0 { result.AssetAccounts[i].Percentage = (result.AssetAccounts[i].Balance / result.TotalAssets) * 100 } } for i := range result.LiabilityAccounts { if result.TotalLiabilities > 0 { result.LiabilityAccounts[i].Percentage = (-result.LiabilityAccounts[i].Balance / result.TotalLiabilities) * 100 } } // Get asset trend if requested if includeTrend && trendStartDate != nil && trendEndDate != nil { trendRepo, err := s.reportRepo.GetAssetTrend(userID, *trendStartDate, *trendEndDate) if err != nil { return nil, fmt.Errorf("failed to get asset trend: %w", err) } // Convert repository types to service types trend := make([]AssetTrendPoint, len(trendRepo)) for i, t := range trendRepo { trend[i] = AssetTrendPoint{ Date: t.Date, TotalAssets: t.TotalAssets, TotalLiabilities: t.TotalLiabilities, NetAssets: t.NetAssets, } } result.AssetTrend = trend } return result, nil }