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
|
||||
builder.Services.AddHttpClient<ITransactionAICategorizer, TransactionAICategorizer>();
|
||||
|
||||
// Financial audit API service
|
||||
builder.Services.AddScoped<IFinancialAuditService, FinancialAuditService>();
|
||||
|
||||
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();
|
||||
|
||||
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