Add 4 AI bookkeeping features
Feature 7: Bank Rec Auto-Match — AiSuggestMatches endpoint scores uncleared transactions vs statement ending balance; AI Auto-Match panel in Reconcile.cshtml with confidence highlights and Apply All button. Feature 8: Late Payment Prediction — PredictLatePayments endpoint scores open AR customers by risk (high/medium/low) using historical avg-days-to-pay + late rate; rendered as badge table in AR Aging view via ar-aging-ai.js. Feature 9: Natural Language Financial Queries — FinancialQuery GET page + RunFinancialQuery POST; 12-month context snapshot pre-loaded; answers grounded in real data with supporting facts, follow-up suggestions, session history, and example chips. Feature 10: Recurring Bill Detection — RunRecurringDetection scans 12 months of bills for vendor payment patterns (monthly/quarterly/annual); card grid view in Bills/RecurringDetection.cshtml with confidence badges, next-expected-date, and suggested actions. Supporting: 4 new DTO groups in AccountingAiDtos.cs, 4 method signatures in IAccountingAiService.cs, 4 implementations in AccountingAiService.cs, 4 new AiFeatures constants, 2 new Landing page AI report cards. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2118,6 +2118,195 @@ public class ReportsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// ── AI: Late Payment Prediction ───────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// AJAX POST — loads all open AR invoices with customer payment history, then asks Claude
|
||||
/// to score each customer's payment risk. Avg days to pay and late rate are pre-computed
|
||||
/// from the full invoice history rather than open invoices only, so customers with only
|
||||
/// one open invoice still get meaningful risk scoring based on prior behavior.
|
||||
/// Gated behind <see cref="AllowAccounting"/>.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||||
public async Task<IActionResult> PredictLatePayments()
|
||||
{
|
||||
if (!AllowAccounting()) return Json(new { success = false, error = "Accounting module is not enabled." });
|
||||
try
|
||||
{
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
var today = DateTime.Today;
|
||||
|
||||
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
|
||||
var activeInvoices = allInvoices.Where(i =>
|
||||
i.Status != InvoiceStatus.Voided &&
|
||||
i.Status != InvoiceStatus.WrittenOff).ToList();
|
||||
|
||||
static string CustomerDisplayName(Invoice i) =>
|
||||
i.Customer?.CompanyName ?? (i.Customer != null
|
||||
? $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim()
|
||||
: $"Customer #{i.CustomerId}");
|
||||
|
||||
var outstandingByCustomer = activeInvoices
|
||||
.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Paid)
|
||||
.GroupBy(i => CustomerDisplayName(i))
|
||||
.ToList();
|
||||
|
||||
// Pre-compute per-customer historical behavior from all paid invoices
|
||||
var historyByCustomer = activeInvoices
|
||||
.Where(i => i.Status == InvoiceStatus.Paid && i.PaidDate.HasValue && i.SentDate.HasValue)
|
||||
.GroupBy(i => CustomerDisplayName(i))
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => new
|
||||
{
|
||||
AvgDaysToPay = g.Average(i => (i.PaidDate!.Value - i.SentDate!.Value).TotalDays),
|
||||
TotalInvoices = g.Count(),
|
||||
LateInvoices = g.Count(i => i.DueDate.HasValue && i.PaidDate!.Value > i.DueDate.Value)
|
||||
});
|
||||
|
||||
var customerData = outstandingByCustomer.Select(g =>
|
||||
{
|
||||
var history = historyByCustomer.GetValueOrDefault(g.Key);
|
||||
return new LatePaymentCustomerData
|
||||
{
|
||||
CustomerName = g.Key,
|
||||
TotalOwed = g.Sum(i => i.BalanceDue),
|
||||
AvgDaysToPay = history?.AvgDaysToPay ?? 30,
|
||||
TotalInvoicesAllTime = history?.TotalInvoices ?? 0,
|
||||
LateInvoicesAllTime = history?.LateInvoices ?? 0,
|
||||
OpenInvoices = g.Select(i => new OpenInvoiceSummary
|
||||
{
|
||||
InvoiceNumber = i.InvoiceNumber,
|
||||
BalanceDue = i.BalanceDue,
|
||||
DueDateIso = i.DueDate?.ToString("yyyy-MM-dd"),
|
||||
DaysOverdue = i.DueDate.HasValue && i.DueDate.Value < today
|
||||
? (today - i.DueDate.Value).Days : 0
|
||||
}).ToList()
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
if (!customerData.Any())
|
||||
return Json(new LatePaymentPredictionResult { Success = true, Insights = new() { "No outstanding invoices to analyze." } });
|
||||
|
||||
var result = await _accountingAi.PredictLatePaymentsAsync(new LatePaymentPredictionRequest
|
||||
{
|
||||
CompanyName = companyName,
|
||||
Customers = customerData
|
||||
});
|
||||
|
||||
var lpCid = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _lpC) ? _lpC : 0;
|
||||
await _usageLogger.LogAsync(lpCid, User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "", AppConstants.AiFeatures.LatePaymentPrediction, result.Success);
|
||||
return Json(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error predicting late payments");
|
||||
return Json(new LatePaymentPredictionResult { Success = false, ErrorMessage = "An error occurred while analyzing payment risk." });
|
||||
}
|
||||
}
|
||||
|
||||
// ── AI: Natural Language Financial Queries ────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// GET page for the natural language financial query tool. Pre-loads the financial context
|
||||
/// snapshot so the first query does not have a visible data-fetch delay — the context is
|
||||
/// serialized into a hidden field and passed back on the POST.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> FinancialQuery()
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
ViewBag.Context = await BuildFinancialQueryContextAsync();
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AJAX POST — receives the user's plain-English question and the pre-built context object,
|
||||
/// then sends both to Claude. The context is passed from client to server (rather than
|
||||
/// re-fetched on every request) so that rapid follow-up questions do not trigger additional
|
||||
/// database round-trips. The context JSON is validated server-side before passing to the AI
|
||||
/// service so a corrupted hidden field cannot cause a crash.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||||
public async Task<IActionResult> RunFinancialQuery([FromBody] FinancialQueryRequest? request)
|
||||
{
|
||||
if (!AllowAccounting()) return Json(new { success = false, error = "Accounting module is not enabled." });
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.Question))
|
||||
return Json(new FinancialQueryResult { Success = false, ErrorMessage = "Please enter a question." });
|
||||
|
||||
// If context is empty (e.g. client didn't pass it), rebuild from DB
|
||||
if (request.Context == null || string.IsNullOrWhiteSpace(request.Context.CompanyName))
|
||||
request.Context = await BuildFinancialQueryContextAsync();
|
||||
|
||||
var result = await _accountingAi.AnswerFinancialQueryAsync(request);
|
||||
var fqCid = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _fqC) ? _fqC : 0;
|
||||
await _usageLogger.LogAsync(fqCid, User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "", AppConstants.AiFeatures.FinancialQuery, result.Success);
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="FinancialQueryContext"/> snapshot from live DB data covering
|
||||
/// YTD totals, last 12 months of monthly revenue/expense summaries, and current
|
||||
/// AR/AP outstanding. This is factored out so both the GET page load and the fallback
|
||||
/// POST path share identical context-building logic.
|
||||
/// </summary>
|
||||
private async Task<FinancialQueryContext> BuildFinancialQueryContextAsync()
|
||||
{
|
||||
var companyName = await GetCompanyNameAsync();
|
||||
var now = DateTime.UtcNow;
|
||||
var startOfYear = new DateTime(now.Year, 1, 1);
|
||||
|
||||
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments))
|
||||
.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff)
|
||||
.ToList();
|
||||
|
||||
var ytdRevenue = allInvoices.Where(i => i.InvoiceDate >= startOfYear).Sum(i => i.Total);
|
||||
var arOutstanding = allInvoices.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Paid).Sum(i => i.BalanceDue);
|
||||
|
||||
var allBills = await _operationalReports.GetActiveBillsAsync();
|
||||
var ytdExpenses = allBills.Where(b => b.BillDate >= startOfYear).Sum(b => b.Total);
|
||||
var apOutstanding = allBills.Where(b => b.BalanceDue > 0).Sum(b => b.BalanceDue);
|
||||
|
||||
// Monthly summaries for last 12 months
|
||||
var monthly = new List<MonthlyFinancialSummary>();
|
||||
for (var i = 11; i >= 0; i--)
|
||||
{
|
||||
var monthStart = new DateTime(now.Year, now.Month, 1).AddMonths(-i);
|
||||
var monthEnd = monthStart.AddMonths(1);
|
||||
var rev = allInvoices.Where(inv => inv.InvoiceDate >= monthStart && inv.InvoiceDate < monthEnd).Sum(inv => inv.Total);
|
||||
var exp = allBills.Where(b => b.BillDate >= monthStart && b.BillDate < monthEnd).Sum(b => b.Total);
|
||||
monthly.Add(new MonthlyFinancialSummary
|
||||
{
|
||||
Month = monthStart.ToString("yyyy-MM"),
|
||||
Revenue = rev,
|
||||
Expenses = exp,
|
||||
NetIncome = rev - exp
|
||||
});
|
||||
}
|
||||
|
||||
// Expense breakdown from bills by account
|
||||
var expensesByAccount = allBills
|
||||
.GroupBy(b => b.Memo ?? "Uncategorized")
|
||||
.Select(g => new ExpenseByCategory { Category = g.Key, Amount = g.Sum(b => b.Total) })
|
||||
.OrderByDescending(e => e.Amount)
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
return new FinancialQueryContext
|
||||
{
|
||||
CompanyName = companyName,
|
||||
AsOfDate = now.ToString("yyyy-MM-dd"),
|
||||
TotalRevenueYtd = ytdRevenue,
|
||||
TotalExpensesYtd = ytdExpenses,
|
||||
NetIncomeYtd = ytdRevenue - ytdExpenses,
|
||||
ArOutstanding = arOutstanding,
|
||||
ApOutstanding = apOutstanding,
|
||||
Last12Months = monthly,
|
||||
ExpensesByCategory = expensesByAccount
|
||||
};
|
||||
}
|
||||
|
||||
// GET: /Reports/BudgetVsActual
|
||||
/// <summary>
|
||||
/// Budget vs. Actual report: compares a budget's monthly line amounts against real P&L activity
|
||||
|
||||
Reference in New Issue
Block a user