init
This commit is contained in:
723
internal/service/report_service.go
Normal file
723
internal/service/report_service.go
Normal file
@@ -0,0 +1,723 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user