diff --git a/MoneyMap/Models/Api/FinancialAuditModels.cs b/MoneyMap/Models/Api/FinancialAuditModels.cs
new file mode 100644
index 0000000..4f7123e
--- /dev/null
+++ b/MoneyMap/Models/Api/FinancialAuditModels.cs
@@ -0,0 +1,137 @@
+namespace MoneyMap.Models.Api;
+
+///
+/// Complete financial audit response for AI analysis.
+///
+public class FinancialAuditResponse
+{
+ public DateTime GeneratedAt { get; set; }
+ public DateTime PeriodStart { get; set; }
+ public DateTime PeriodEnd { get; set; }
+
+ public AuditSummary Summary { get; set; } = new();
+ public List Budgets { get; set; } = new();
+ public List SpendingByCategory { get; set; } = new();
+ public List TopMerchants { get; set; } = new();
+ public List MonthlyTrends { get; set; } = new();
+ public List Accounts { get; set; } = new();
+ public List Flags { get; set; } = new();
+ public List? Transactions { get; set; }
+}
+
+///
+/// High-level financial statistics for the audit period.
+///
+public class AuditSummary
+{
+ public int TotalTransactions { get; set; }
+ public decimal TotalIncome { get; set; }
+ public decimal TotalExpenses { get; set; }
+ public decimal NetCashFlow { get; set; }
+ public decimal AverageDailySpend { get; set; }
+ public int DaysInPeriod { get; set; }
+ public int UncategorizedTransactions { get; set; }
+ public decimal UncategorizedAmount { get; set; }
+}
+
+///
+/// Budget status with period information.
+///
+public class BudgetStatusDto
+{
+ public int BudgetId { get; set; }
+ public string Category { get; set; } = "";
+ public string Period { get; set; } = "";
+ public decimal Limit { get; set; }
+ public decimal Spent { get; set; }
+ public decimal Remaining { get; set; }
+ public decimal PercentUsed { get; set; }
+ public bool IsOverBudget { get; set; }
+ public string PeriodRange { get; set; } = "";
+}
+
+///
+/// Spending breakdown by category with optional budget correlation.
+///
+public class CategorySpendingDto
+{
+ public string Category { get; set; } = "";
+ public decimal TotalSpent { get; set; }
+ public int TransactionCount { get; set; }
+ public decimal PercentOfTotal { get; set; }
+ public decimal AverageTransaction { get; set; }
+ public decimal? BudgetLimit { get; set; }
+ public decimal? BudgetRemaining { get; set; }
+ public bool? IsOverBudget { get; set; }
+}
+
+///
+/// Spending patterns by merchant.
+///
+public class MerchantSpendingDto
+{
+ public string MerchantName { get; set; } = "";
+ public string? Category { get; set; }
+ public decimal TotalSpent { get; set; }
+ public int TransactionCount { get; set; }
+ public decimal AverageTransaction { get; set; }
+ public DateTime FirstTransaction { get; set; }
+ public DateTime LastTransaction { get; set; }
+}
+
+///
+/// Monthly income/expense/net trends.
+///
+public class MonthlyTrendDto
+{
+ public string Month { get; set; } = "";
+ public int Year { get; set; }
+ public decimal Income { get; set; }
+ public decimal Expenses { get; set; }
+ public decimal NetCashFlow { get; set; }
+ public int TransactionCount { get; set; }
+ public Dictionary TopCategories { get; set; } = new();
+}
+
+///
+/// Per-account transaction summary.
+///
+public class AccountSummaryDto
+{
+ public int AccountId { get; set; }
+ public string AccountName { get; set; } = "";
+ public string Institution { get; set; } = "";
+ public string AccountType { get; set; } = "";
+ public int TransactionCount { get; set; }
+ public decimal TotalDebits { get; set; }
+ public decimal TotalCredits { get; set; }
+ public decimal NetFlow { get; set; }
+}
+
+///
+/// AI-friendly flag highlighting potential issues or observations.
+///
+public class AuditFlagDto
+{
+ public string Type { get; set; } = "";
+ public string Severity { get; set; } = "";
+ public string Message { get; set; } = "";
+ public object? Details { get; set; }
+}
+
+///
+/// Simplified transaction for export.
+///
+public class TransactionDto
+{
+ public long Id { get; set; }
+ public DateTime Date { get; set; }
+ public string Name { get; set; } = "";
+ public string? Memo { get; set; }
+ public decimal Amount { get; set; }
+ public string? Category { get; set; }
+ public string? MerchantName { get; set; }
+ public string AccountName { get; set; } = "";
+ public string? CardLabel { get; set; }
+ public bool IsTransfer { get; set; }
+}
diff --git a/MoneyMap/Program.cs b/MoneyMap/Program.cs
index 35f7833..db32201 100644
--- a/MoneyMap/Program.cs
+++ b/MoneyMap/Program.cs
@@ -66,6 +66,9 @@ builder.Services.AddScoped();
// AI categorization service
builder.Services.AddHttpClient();
+// Financial audit API service
+builder.Services.AddScoped();
+
var app = builder.Build();
// Seed default category mappings on startup
@@ -93,4 +96,19 @@ app.UseAuthorization();
app.MapRazorPages();
+// Financial Audit API endpoint
+app.MapGet("/api/audit", async (
+ IFinancialAuditService auditService,
+ DateTime? startDate,
+ DateTime? endDate,
+ bool includeTransactions = false) =>
+{
+ var end = endDate ?? DateTime.Today;
+ var start = startDate ?? end.AddDays(-90);
+
+ var result = await auditService.GenerateAuditAsync(start, end, includeTransactions);
+ return Results.Ok(result);
+})
+.WithName("GetFinancialAudit");
+
app.Run();
diff --git a/MoneyMap/Services/FinancialAuditService.cs b/MoneyMap/Services/FinancialAuditService.cs
new file mode 100644
index 0000000..2cfa2f8
--- /dev/null
+++ b/MoneyMap/Services/FinancialAuditService.cs
@@ -0,0 +1,491 @@
+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();
+ }
+}