Files
MoneyMap/MoneyMap.Core/Services/DashboardService.cs
T
2026-04-20 18:18:20 -04:00

269 lines
8.8 KiB
C#

using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models.Dashboard;
namespace MoneyMap.Services
{
/// <summary>
/// Service for retrieving dashboard data.
/// </summary>
public interface IDashboardService
{
Task<DashboardData> 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<DashboardData> 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
};
}
}
/// <summary>
/// Calculates dashboard statistics.
/// </summary>
public interface IDashboardStatsCalculator
{
Task<DashboardStats> CalculateAsync();
}
public class DashboardStatsCalculator : IDashboardStatsCalculator
{
private readonly MoneyMapContext _db;
public DashboardStatsCalculator(MoneyMapContext db)
{
_db = db;
}
public async Task<DashboardStats> 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<TransactionStatsInternal> 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; }
}
}
/// <summary>
/// Provides top spending categories.
/// </summary>
public interface ITopCategoriesProvider
{
Task<List<TopCategoryRow>> GetTopCategoriesAsync(int count = 8, int lastDays = 90);
}
public class TopCategoriesProvider : ITopCategoriesProvider
{
private readonly MoneyMapContext _db;
public TopCategoriesProvider(MoneyMapContext db)
{
_db = db;
}
public async Task<List<TopCategoryRow>> 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;
}
}
/// <summary>
/// Provides recent transactions.
/// </summary>
public interface IRecentTransactionsProvider
{
Task<List<RecentTransactionRow>> GetRecentTransactionsAsync(int count = 20);
}
public class RecentTransactionsProvider : IRecentTransactionsProvider
{
private readonly MoneyMapContext _db;
public RecentTransactionsProvider(MoneyMapContext db)
{
_db = db;
}
public async Task<List<RecentTransactionRow>> 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();
}
}
/// <summary>
/// Provides spending trends over time.
/// </summary>
public interface ISpendTrendsProvider
{
Task<SpendTrends> GetDailyTrendsAsync(int lastDays = 30);
}
public class SpendTrendsProvider : ISpendTrendsProvider
{
private readonly MoneyMapContext _db;
public SpendTrendsProvider(MoneyMapContext db)
{
_db = db;
}
public async Task<SpendTrends> 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<string>();
var debitsAbs = new List<decimal>();
var credits = new List<decimal>();
var net = new List<decimal>();
var runningBalance = new List<decimal>();
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
};
}
}
}