using Microsoft.EntityFrameworkCore; using MoneyMap.Data; using MoneyMap.Models; using MoneyMap.Models.Api; namespace MoneyMap.Services; public interface IFinancialAuditService { Task GenerateAuditAsync( DateTime startDate, DateTime endDate, bool includeTransactions = false); } public class FinancialAuditService : IFinancialAuditService { private readonly MoneyMapContext _db; private readonly IBudgetService _budgetService; public FinancialAuditService(MoneyMapContext db, IBudgetService budgetService) { _db = db; _budgetService = budgetService; } public async Task GenerateAuditAsync( DateTime startDate, DateTime endDate, bool includeTransactions = false) { var response = new FinancialAuditResponse { GeneratedAt = DateTime.UtcNow, PeriodStart = startDate.Date, PeriodEnd = endDate.Date }; // Base query for the period var periodTransactions = _db.Transactions .Include(t => t.Account) .Include(t => t.Card) .Include(t => t.Merchant) .Where(t => t.Date >= startDate.Date && t.Date <= endDate.Date) .AsNoTracking(); // Calculate all sections in parallel where possible response.Summary = await CalculateSummaryAsync(periodTransactions, startDate, endDate); response.Budgets = await GetBudgetStatusesAsync(); response.SpendingByCategory = await GetCategorySpendingAsync(periodTransactions, response.Budgets); response.TopMerchants = await GetMerchantSpendingAsync(periodTransactions); response.MonthlyTrends = await GetMonthlyTrendsAsync(startDate, endDate); response.Accounts = await GetAccountSummariesAsync(periodTransactions); response.Flags = GenerateAuditFlags(response); if (includeTransactions) { response.Transactions = await GetTransactionListAsync(periodTransactions); } return response; } private async Task CalculateSummaryAsync( IQueryable transactions, DateTime startDate, DateTime endDate) { // Exclude transfers for spending calculations var nonTransferTxns = transactions.ExcludeTransfers(); var stats = await nonTransferTxns .GroupBy(_ => 1) .Select(g => new { TotalCount = g.Count(), TotalIncome = g.Where(t => t.Amount > 0).Sum(t => t.Amount), TotalExpenses = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)), UncategorizedCount = g.Count(t => string.IsNullOrEmpty(t.Category)), UncategorizedAmount = g.Where(t => string.IsNullOrEmpty(t.Category) && t.Amount < 0) .Sum(t => Math.Abs(t.Amount)) }) .FirstOrDefaultAsync(); var daysInPeriod = (endDate.Date - startDate.Date).Days + 1; return new AuditSummary { TotalTransactions = stats?.TotalCount ?? 0, TotalIncome = stats?.TotalIncome ?? 0, TotalExpenses = stats?.TotalExpenses ?? 0, NetCashFlow = (stats?.TotalIncome ?? 0) - (stats?.TotalExpenses ?? 0), DaysInPeriod = daysInPeriod, AverageDailySpend = daysInPeriod > 0 ? (stats?.TotalExpenses ?? 0) / daysInPeriod : 0, UncategorizedTransactions = stats?.UncategorizedCount ?? 0, UncategorizedAmount = stats?.UncategorizedAmount ?? 0 }; } private async Task> GetBudgetStatusesAsync() { var statuses = await _budgetService.GetAllBudgetStatusesAsync(); return statuses.Select(s => new BudgetStatusDto { BudgetId = s.Budget.Id, Category = s.Budget.DisplayName, Period = s.Budget.Period.ToString(), Limit = s.Budget.Amount, Spent = s.Spent, Remaining = s.Remaining, PercentUsed = s.PercentUsed, IsOverBudget = s.IsOverBudget, PeriodRange = s.PeriodDisplay }).ToList(); } private async Task> GetCategorySpendingAsync( IQueryable transactions, List budgets) { var categorySpending = await transactions .ExcludeTransfers() .Where(t => t.Amount < 0 && !string.IsNullOrEmpty(t.Category)) .GroupBy(t => t.Category) .Select(g => new { Category = g.Key, TotalSpent = g.Sum(t => Math.Abs(t.Amount)), Count = g.Count() }) .OrderByDescending(x => x.TotalSpent) .ToListAsync(); var totalSpending = categorySpending.Sum(c => c.TotalSpent); // Create a lookup for budget data by category var budgetLookup = budgets .Where(b => b.Category != "Total Spending") .ToDictionary(b => b.Category.ToLowerInvariant(), b => b); return categorySpending.Select(c => { var dto = new CategorySpendingDto { Category = c.Category ?? "Uncategorized", TotalSpent = c.TotalSpent, TransactionCount = c.Count, PercentOfTotal = totalSpending > 0 ? Math.Round(c.TotalSpent / totalSpending * 100, 2) : 0, AverageTransaction = c.Count > 0 ? Math.Round(c.TotalSpent / c.Count, 2) : 0 }; // Add budget correlation if available if (budgetLookup.TryGetValue((c.Category ?? "").ToLowerInvariant(), out var budget)) { dto.BudgetLimit = budget.Limit; dto.BudgetRemaining = budget.Remaining; dto.IsOverBudget = budget.IsOverBudget; } return dto; }).ToList(); } private async Task> GetMerchantSpendingAsync( IQueryable transactions) { var merchantSpending = await transactions .ExcludeTransfers() .Where(t => t.Amount < 0 && t.MerchantId != null) .GroupBy(t => new { t.MerchantId, t.Merchant!.Name }) .Select(g => new { MerchantName = g.Key.Name, Category = g.Max(t => t.Category), TotalSpent = g.Sum(t => Math.Abs(t.Amount)), Count = g.Count(), FirstDate = g.Min(t => t.Date), LastDate = g.Max(t => t.Date) }) .OrderByDescending(x => x.TotalSpent) .Take(20) .ToListAsync(); return merchantSpending.Select(m => new MerchantSpendingDto { MerchantName = m.MerchantName, Category = m.Category, TotalSpent = m.TotalSpent, TransactionCount = m.Count, AverageTransaction = m.Count > 0 ? Math.Round(m.TotalSpent / m.Count, 2) : 0, FirstTransaction = m.FirstDate, LastTransaction = m.LastDate }).ToList(); } private async Task> GetMonthlyTrendsAsync(DateTime startDate, DateTime endDate) { var monthlyData = await _db.Transactions .Where(t => t.Date >= startDate.Date && t.Date <= endDate.Date) .ExcludeTransfers() .GroupBy(t => new { t.Date.Year, t.Date.Month }) .Select(g => new { g.Key.Year, g.Key.Month, Income = g.Where(t => t.Amount > 0).Sum(t => t.Amount), Expenses = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)), Count = g.Count() }) .OrderBy(x => x.Year) .ThenBy(x => x.Month) .ToListAsync(); // Get top categories per month var categoryByMonth = await _db.Transactions .Where(t => t.Date >= startDate.Date && t.Date <= endDate.Date) .ExcludeTransfers() .Where(t => t.Amount < 0 && !string.IsNullOrEmpty(t.Category)) .GroupBy(t => new { t.Date.Year, t.Date.Month, t.Category }) .Select(g => new { g.Key.Year, g.Key.Month, g.Key.Category, Total = g.Sum(t => Math.Abs(t.Amount)) }) .ToListAsync(); return monthlyData.Select(m => { var topCategories = categoryByMonth .Where(c => c.Year == m.Year && c.Month == m.Month) .OrderByDescending(c => c.Total) .Take(5) .ToDictionary(c => c.Category ?? "Other", c => c.Total); return new MonthlyTrendDto { Month = $"{m.Year}-{m.Month:D2}", Year = m.Year, Income = m.Income, Expenses = m.Expenses, NetCashFlow = m.Income - m.Expenses, TransactionCount = m.Count, TopCategories = topCategories }; }).ToList(); } private async Task> GetAccountSummariesAsync( IQueryable transactions) { // Use only mapped columns in the GroupBy, compute DisplayLabel in memory var accountStats = await transactions .GroupBy(t => new { t.AccountId, t.Account.Institution, t.Account.Last4, t.Account.Nickname, t.Account.AccountType }) .Select(g => new { g.Key.AccountId, g.Key.Institution, g.Key.Last4, g.Key.Nickname, g.Key.AccountType, Count = g.Count(), Debits = g.Where(t => t.Amount < 0).Sum(t => Math.Abs(t.Amount)), Credits = g.Where(t => t.Amount > 0).Sum(t => t.Amount) }) .ToListAsync(); return accountStats.Select(a => new AccountSummaryDto { AccountId = a.AccountId, AccountName = string.IsNullOrEmpty(a.Nickname) ? $"{a.Institution} {a.Last4} ({a.AccountType})" : $"{a.Nickname} ({a.Institution} {a.Last4})", Institution = a.Institution, AccountType = a.AccountType.ToString(), TransactionCount = a.Count, TotalDebits = a.Debits, TotalCredits = a.Credits, NetFlow = a.Credits - a.Debits }).ToList(); } private async Task> GetTransactionListAsync( IQueryable transactions) { // Fetch raw data without computed properties var rawTxns = await transactions .OrderByDescending(t => t.Date) .ThenByDescending(t => t.Id) .Select(t => new { t.Id, t.Date, t.Name, t.Memo, t.Amount, t.Category, MerchantName = t.Merchant != null ? t.Merchant.Name : null, AccountInstitution = t.Account.Institution, AccountLast4 = t.Account.Last4, AccountNickname = t.Account.Nickname, AccountType = t.Account.AccountType, CardIssuer = t.Card != null ? t.Card.Issuer : null, CardLast4 = t.Card != null ? t.Card.Last4 : null, CardNickname = t.Card != null ? t.Card.Nickname : null, IsTransfer = t.TransferToAccountId != null }) .ToListAsync(); // Map to DTOs with computed labels return rawTxns.Select(t => new TransactionDto { Id = t.Id, Date = t.Date, Name = t.Name, Memo = t.Memo, Amount = t.Amount, Category = t.Category, MerchantName = t.MerchantName, AccountName = string.IsNullOrEmpty(t.AccountNickname) ? $"{t.AccountInstitution} {t.AccountLast4} ({t.AccountType})" : $"{t.AccountNickname} ({t.AccountInstitution} {t.AccountLast4})", CardLabel = t.CardIssuer != null ? (string.IsNullOrEmpty(t.CardNickname) ? $"{t.CardIssuer} {t.CardLast4}" : $"{t.CardNickname} ({t.CardIssuer} {t.CardLast4})") : null, IsTransfer = t.IsTransfer }).ToList(); } private List GenerateAuditFlags(FinancialAuditResponse response) { var flags = new List(); // Flag: Over-budget categories foreach (var budget in response.Budgets.Where(b => b.IsOverBudget)) { var overBy = budget.Spent - budget.Limit; flags.Add(new AuditFlagDto { Type = "OverBudget", Severity = "Alert", Message = $"{budget.Category} budget exceeded by {overBy:C} ({budget.PercentUsed:F0}% of {budget.Limit:C} limit)", Details = new { budget.BudgetId, budget.Category, budget.Limit, budget.Spent, OverAmount = overBy, budget.PercentUsed } }); } // Flag: High budget utilization (>80% but not over) foreach (var budget in response.Budgets.Where(b => !b.IsOverBudget && b.PercentUsed >= 80)) { flags.Add(new AuditFlagDto { Type = "HighBudgetUtilization", Severity = "Warning", Message = $"{budget.Category} budget at {budget.PercentUsed:F0}% ({budget.Remaining:C} remaining)", Details = new { budget.BudgetId, budget.Category, budget.Limit, budget.Spent, budget.Remaining, budget.PercentUsed } }); } // Flag: Uncategorized transactions if (response.Summary.UncategorizedTransactions > 0) { flags.Add(new AuditFlagDto { Type = "Uncategorized", Severity = "Warning", Message = $"{response.Summary.UncategorizedTransactions} transactions ({response.Summary.UncategorizedAmount:C}) are uncategorized", Details = new { Count = response.Summary.UncategorizedTransactions, Amount = response.Summary.UncategorizedAmount } }); } // Flag: Negative net cash flow if (response.Summary.NetCashFlow < 0) { flags.Add(new AuditFlagDto { Type = "NegativeCashFlow", Severity = "Alert", Message = $"Spending exceeded income by {Math.Abs(response.Summary.NetCashFlow):C} during this period", Details = new { response.Summary.TotalIncome, response.Summary.TotalExpenses, response.Summary.NetCashFlow } }); } // Flag: Large single category spending (>30% of total) foreach (var category in response.SpendingByCategory.Where(c => c.PercentOfTotal > 30)) { flags.Add(new AuditFlagDto { Type = "HighCategoryConcentration", Severity = "Info", Message = $"{category.Category} accounts for {category.PercentOfTotal:F0}% of total spending ({category.TotalSpent:C})", Details = new { category.Category, category.TotalSpent, category.PercentOfTotal, category.TransactionCount } }); } // Flag: Month-over-month spending increases if (response.MonthlyTrends.Count >= 2) { var recentMonths = response.MonthlyTrends.TakeLast(2).ToList(); var previousMonth = recentMonths[0]; var currentMonth = recentMonths[1]; if (previousMonth.Expenses > 0) { var percentChange = (currentMonth.Expenses - previousMonth.Expenses) / previousMonth.Expenses * 100; if (percentChange > 20) { flags.Add(new AuditFlagDto { Type = "SpendingIncrease", Severity = "Warning", Message = $"Spending increased {percentChange:F0}% from {previousMonth.Month} ({previousMonth.Expenses:C}) to {currentMonth.Month} ({currentMonth.Expenses:C})", Details = new { PreviousMonth = previousMonth.Month, PreviousExpenses = previousMonth.Expenses, CurrentMonth = currentMonth.Month, CurrentExpenses = currentMonth.Expenses, PercentChange = percentChange } }); } } } // Flag: Categories without budgets (top spending categories) var topUnbudgetedCategories = response.SpendingByCategory .Where(c => c.BudgetLimit == null && c.TotalSpent > 100) .Take(3) .ToList(); if (topUnbudgetedCategories.Any()) { flags.Add(new AuditFlagDto { Type = "NoBudget", Severity = "Info", Message = $"Top spending categories without budgets: {string.Join(", ", topUnbudgetedCategories.Select(c => $"{c.Category} ({c.TotalSpent:C})"))}", Details = topUnbudgetedCategories.Select(c => new { c.Category, c.TotalSpent }).ToList() }); } return flags.OrderByDescending(f => f.Severity switch { "Alert" => 3, "Warning" => 2, "Info" => 1, _ => 0 }).ToList(); } }