using Microsoft.EntityFrameworkCore; using MoneyMap.Data; using MoneyMap.Models.Dashboard; namespace MoneyMap.Services { /// /// Service for retrieving dashboard data. /// public interface IDashboardService { Task GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20); } public class DashboardService : IDashboardService { private readonly IDashboardStatsCalculator _statsCalculator; private readonly ITopCategoriesProvider _topCategoriesProvider; private readonly IRecentTransactionsProvider _recentTransactionsProvider; private readonly ISpendTrendsProvider _spendTrendsProvider; public DashboardService( IDashboardStatsCalculator statsCalculator, ITopCategoriesProvider topCategoriesProvider, IRecentTransactionsProvider recentTransactionsProvider, ISpendTrendsProvider spendTrendsProvider) { _statsCalculator = statsCalculator; _topCategoriesProvider = topCategoriesProvider; _recentTransactionsProvider = recentTransactionsProvider; _spendTrendsProvider = spendTrendsProvider; } public async Task GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20) { var stats = await _statsCalculator.CalculateAsync(); var topCategories = await _topCategoriesProvider.GetTopCategoriesAsync(topCategoriesCount); var recent = await _recentTransactionsProvider.GetRecentTransactionsAsync(recentTransactionsCount); var trends = await _spendTrendsProvider.GetDailyTrendsAsync(30); return new DashboardData { Stats = stats, TopCategories = topCategories, RecentTransactions = recent, Trends = trends }; } } /// /// Calculates dashboard statistics. /// public interface IDashboardStatsCalculator { Task CalculateAsync(); } public class DashboardStatsCalculator : IDashboardStatsCalculator { private readonly MoneyMapContext _db; public DashboardStatsCalculator(MoneyMapContext db) { _db = db; } public async Task CalculateAsync() { var transactionStats = await GetTransactionStatsAsync(); var receiptsCount = await _db.Receipts.CountAsync(); var cardsCount = await _db.Cards.CountAsync(); return new DashboardStats( transactionStats.Total, transactionStats.Credits, transactionStats.Debits, transactionStats.Uncategorized, receiptsCount, cardsCount ); } private async Task GetTransactionStatsAsync() { var stats = await _db.Transactions .GroupBy(_ => 1) .Select(g => new TransactionStatsInternal { Total = g.Count(), Credits = g.Count(t => t.Amount > 0), Debits = g.Count(t => t.Amount < 0), Uncategorized = g.Count(t => t.Category == null || t.Category == "") }) .FirstOrDefaultAsync(); return stats ?? new TransactionStatsInternal(); } private class TransactionStatsInternal { public int Total { get; set; } public int Credits { get; set; } public int Debits { get; set; } public int Uncategorized { get; set; } } } /// /// Provides top spending categories. /// public interface ITopCategoriesProvider { Task> GetTopCategoriesAsync(int count = 8, int lastDays = 90); } public class TopCategoriesProvider : ITopCategoriesProvider { private readonly MoneyMapContext _db; public TopCategoriesProvider(MoneyMapContext db) { _db = db; } public async Task> GetTopCategoriesAsync(int count = 8, int lastDays = 90) { var since = DateTime.UtcNow.Date.AddDays(-lastDays); var expenseTransactions = await _db.Transactions .Where(t => t.Date >= since && t.Amount < 0) .ExcludeTransfers() .ToListAsync(); var totalSpend = expenseTransactions.Sum(t => -t.Amount); var topCategories = expenseTransactions .GroupBy(t => t.Category ?? "") .Select(g => new TopCategoryRow { Category = g.Key, TotalSpend = g.Sum(x => -x.Amount), Count = g.Count(), PercentageOfTotal = totalSpend > 0 ? (g.Sum(x => -x.Amount) / totalSpend) * 100 : 0, AveragePerTransaction = g.Count() > 0 ? g.Sum(x => -x.Amount) / g.Count() : 0 }) .OrderByDescending(x => x.TotalSpend) .Take(count) .ToList(); return topCategories; } } /// /// Provides recent transactions. /// public interface IRecentTransactionsProvider { Task> GetRecentTransactionsAsync(int count = 20); } public class RecentTransactionsProvider : IRecentTransactionsProvider { private readonly MoneyMapContext _db; public RecentTransactionsProvider(MoneyMapContext db) { _db = db; } public async Task> GetRecentTransactionsAsync(int count = 20) { return await _db.Transactions .Include(t => t.Card) .OrderByDescending(t => t.Date) .ThenByDescending(t => t.Id) .Select(t => new RecentTransactionRow { Id = t.Id, Date = t.Date, Name = t.Name, Memo = t.Memo, Amount = t.Amount, Category = t.Category ?? "", CardLabel = t.PaymentMethodLabel, ReceiptCount = t.Receipts.Count() }) .Take(count) .AsNoTracking() .ToListAsync(); } } /// /// Provides spending trends over time. /// public interface ISpendTrendsProvider { Task GetDailyTrendsAsync(int lastDays = 30); } public class SpendTrendsProvider : ISpendTrendsProvider { private readonly MoneyMapContext _db; public SpendTrendsProvider(MoneyMapContext db) { _db = db; } public async Task GetDailyTrendsAsync(int lastDays = 30) { var today = DateTime.UtcNow.Date; var since = today.AddDays(-(lastDays - 1)); var raw = await _db.Transactions .Where(t => t.Date >= since) .ExcludeTransfers() .GroupBy(t => t.Date.Date) .Select(g => new { Date = g.Key, Debits = g.Where(t => t.Amount < 0).Sum(t => t.Amount), Credits = g.Where(t => t.Amount > 0).Sum(t => t.Amount) }) .ToListAsync(); var dict = raw.ToDictionary(x => x.Date, x => x); var labels = new List(); var debitsAbs = new List(); var credits = new List(); var net = new List(); var runningBalance = new List(); decimal cumulative = 0; for (var d = since; d <= today; d = d.AddDays(1)) { labels.Add(d.ToString("MMM d")); if (dict.TryGetValue(d, out var v)) { var debit = v.Debits; var credit = v.Credits; debitsAbs.Add(Math.Abs(debit)); credits.Add(credit); net.Add(credit + debit); cumulative += credit + debit; } else { debitsAbs.Add(0); credits.Add(0); net.Add(0); } runningBalance.Add(cumulative); } return new SpendTrends { Labels = labels, DebitsAbs = debitsAbs, Credits = credits, Net = net, RunningBalance = runningBalance }; } } }