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:
137
MoneyMap/Models/Api/FinancialAuditModels.cs
Normal file
137
MoneyMap/Models/Api/FinancialAuditModels.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
491
MoneyMap/Services/FinancialAuditService.cs
Normal file
491
MoneyMap/Services/FinancialAuditService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user