From 3ce91f4c075ed5bcf5fffd804a34f6a11ce04e50 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 18 Dec 2025 20:07:29 -0500 Subject: [PATCH] Feature: Add Financial Audit API for AI analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add /api/audit endpoint providing comprehensive financial data: - Summary stats (income, expenses, net, daily average) - Budget statuses with period info - Category spending with budget correlation - Top 20 merchants by spending - Monthly trends with top categories - Per-account summaries - AI-friendly flags (over-budget, spending increases, etc.) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- MoneyMap/Models/Api/FinancialAuditModels.cs | 137 ++++++ MoneyMap/Program.cs | 18 + MoneyMap/Services/FinancialAuditService.cs | 491 ++++++++++++++++++++ 3 files changed, 646 insertions(+) create mode 100644 MoneyMap/Models/Api/FinancialAuditModels.cs create mode 100644 MoneyMap/Services/FinancialAuditService.cs 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(); + } +}