Feature: Add Financial Audit API for AI analysis

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 <noreply@anthropic.com>
This commit is contained in:
2025-12-18 20:07:29 -05:00
parent a3ca358e9a
commit 3ce91f4c07
3 changed files with 646 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
namespace MoneyMap.Models.Api;
/// <summary>
/// Complete financial audit response for AI analysis.
/// </summary>
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<BudgetStatusDto> Budgets { get; set; } = new();
public List<CategorySpendingDto> SpendingByCategory { get; set; } = new();
public List<MerchantSpendingDto> TopMerchants { get; set; } = new();
public List<MonthlyTrendDto> MonthlyTrends { get; set; } = new();
public List<AccountSummaryDto> Accounts { get; set; } = new();
public List<AuditFlagDto> Flags { get; set; } = new();
public List<TransactionDto>? Transactions { get; set; }
}
/// <summary>
/// High-level financial statistics for the audit period.
/// </summary>
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; }
}
/// <summary>
/// Budget status with period information.
/// </summary>
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; } = "";
}
/// <summary>
/// Spending breakdown by category with optional budget correlation.
/// </summary>
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; }
}
/// <summary>
/// Spending patterns by merchant.
/// </summary>
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; }
}
/// <summary>
/// Monthly income/expense/net trends.
/// </summary>
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<string, decimal> TopCategories { get; set; } = new();
}
/// <summary>
/// Per-account transaction summary.
/// </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; }
}
/// <summary>
/// AI-friendly flag highlighting potential issues or observations.
/// </summary>
public class AuditFlagDto
{
public string Type { get; set; } = "";
public string Severity { get; set; } = "";
public string Message { get; set; } = "";
public object? Details { get; set; }
}
/// <summary>
/// Simplified transaction for export.
/// </summary>
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; }
}

View File

@@ -66,6 +66,9 @@ builder.Services.AddScoped<IReceiptParser, AIReceiptParser>();
// AI categorization service // AI categorization service
builder.Services.AddHttpClient<ITransactionAICategorizer, TransactionAICategorizer>(); builder.Services.AddHttpClient<ITransactionAICategorizer, TransactionAICategorizer>();
// Financial audit API service
builder.Services.AddScoped<IFinancialAuditService, FinancialAuditService>();
var app = builder.Build(); var app = builder.Build();
// Seed default category mappings on startup // Seed default category mappings on startup
@@ -93,4 +96,19 @@ app.UseAuthorization();
app.MapRazorPages(); 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(); app.Run();

View File

@@ -0,0 +1,491 @@
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Models.Api;
namespace MoneyMap.Services;
public interface IFinancialAuditService
{
Task<FinancialAuditResponse> 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<FinancialAuditResponse> 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<AuditSummary> CalculateSummaryAsync(
IQueryable<Transaction> 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<List<BudgetStatusDto>> 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<List<CategorySpendingDto>> GetCategorySpendingAsync(
IQueryable<Transaction> transactions,
List<BudgetStatusDto> 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<List<MerchantSpendingDto>> GetMerchantSpendingAsync(
IQueryable<Transaction> 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<List<MonthlyTrendDto>> 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<List<AccountSummaryDto>> GetAccountSummariesAsync(
IQueryable<Transaction> 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<List<TransactionDto>> GetTransactionListAsync(
IQueryable<Transaction> 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<AuditFlagDto> GenerateAuditFlags(FinancialAuditResponse response)
{
var flags = new List<AuditFlagDto>();
// 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();
}
}