724 lines
24 KiB
Go
724 lines
24 KiB
Go
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
|
|
}
|