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:
2026-05-10 19:22:49 -04:00
parent e2f9e9ae4f
commit 959e323f3a
16 changed files with 1679 additions and 4 deletions
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
@@ -15,13 +16,19 @@ public class BankReconciliationsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly IAccountingAiService _accountingAi;
private readonly IAiUsageLogger _usageLogger;
public BankReconciliationsController(
IUnitOfWork unitOfWork,
ITenantContext tenantContext)
ITenantContext tenantContext,
IAccountingAiService accountingAi,
IAiUsageLogger usageLogger)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_accountingAi = accountingAi;
_usageLogger = usageLogger;
}
private bool AllowAccounting() =>
@@ -269,6 +276,91 @@ public class BankReconciliationsController : Controller
return View(recon);
}
// ── AI Auto-Match (AJAX) ──────────────────────────────────────────────────
/// <summary>
/// AJAX endpoint. Passes uncleared bank rec items to Claude and returns suggested items
/// to mark as cleared. The controller assembles all three transaction types (deposits,
/// bill payments, expenses) for the reconciliation's account, then delegates scoring to
/// <see cref="IAccountingAiService.AutoMatchReconciliationAsync"/>. The caller applies
/// suggestions client-side by auto-checking the corresponding table rows.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AiSuggestMatches(int reconId)
{
if (!AllowAccounting()) return Forbid();
var recon = (await _unitOfWork.BankReconciliations.FindAsync(
br => br.Id == reconId, false, br => br.Account))
.FirstOrDefault();
if (recon == null) return NotFound();
var accountId = recon.AccountId;
var statementDate = recon.StatementDate;
var items = new List<BankRecMatchItem>();
(await _unitOfWork.Payments.FindAsync(
p => p.DepositAccountId == accountId && p.PaymentDate <= statementDate && !p.IsCleared))
.ToList()
.ForEach(p => items.Add(new BankRecMatchItem
{
EntityType = "Payment",
EntityId = p.Id,
Date = p.PaymentDate.ToString("yyyy-MM-dd"),
Reference = p.Reference ?? $"PMT-{p.Id}",
Description = $"Payment #{p.InvoiceId}",
Amount = p.Amount,
Direction = "deposit"
}));
(await _unitOfWork.BillPayments.FindAsync(
bp => bp.BankAccountId == accountId && bp.PaymentDate <= statementDate && !bp.IsCleared))
.ToList()
.ForEach(bp => items.Add(new BankRecMatchItem
{
EntityType = "BillPayment",
EntityId = bp.Id,
Date = bp.PaymentDate.ToString("yyyy-MM-dd"),
Reference = bp.PaymentNumber,
Description = bp.Memo ?? bp.BillId.ToString(),
Amount = bp.Amount,
Direction = "payment"
}));
(await _unitOfWork.Expenses.FindAsync(
e => e.PaymentAccountId == accountId && e.Date <= statementDate && !e.IsCleared))
.ToList()
.ForEach(e => items.Add(new BankRecMatchItem
{
EntityType = "Expense",
EntityId = e.Id,
Date = e.Date.ToString("yyyy-MM-dd"),
Reference = e.ExpenseNumber,
Description = e.Memo ?? string.Empty,
Amount = e.Amount,
Direction = "payment"
}));
if (!items.Any())
return Json(new { success = false, errorMessage = "No uncleared transactions to analyze." });
var request = new AutoMatchRequest
{
UnclearedItems = items,
BeginningBalance = recon.BeginningBalance,
StatementEndingBalance = recon.EndingBalance
};
var result = await _accountingAi.AutoMatchReconciliationAsync(request);
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
await _usageLogger.LogAsync(recon.CompanyId, userId, AppConstants.AiFeatures.BankRecAutoMatch, result.Success);
return Json(result);
}
// ── Helpers ──────────────────────────────────────────────────────────────
private async Task PopulateAccountDropdownAsync()
@@ -1136,6 +1136,68 @@ public class BillsController : Controller
return Json(result);
}
// ── AI: Recurring Bill Detection ──────────────────────────────────────────
/// <summary>
/// GET page — displays the recurring bill detection tool. No data is pre-fetched here;
/// the user triggers the scan by clicking a button which calls <see cref="RunRecurringDetection"/>.
/// </summary>
public IActionResult RecurringDetection() => View();
/// <summary>
/// AJAX POST — loads up to 12 months of bill history for the company and passes it to
/// Claude for recurring pattern analysis. Only posted bills (Draft/Open/Partial/Paid) are
/// included; Voided bills are excluded so cancelled payments do not distort the pattern.
/// Results are returned as JSON for client-side rendering in the view.
/// </summary>
[HttpPost]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RunRecurringDetection()
{
try
{
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var cutoff = DateTime.Today.AddMonths(-12);
var bills = (await _unitOfWork.Bills.GetAllAsync(false, b => b.Vendor))
.Where(b => b.Status != BillStatus.Voided && b.BillDate >= cutoff)
.ToList();
if (!bills.Any())
return Json(new RecurringBillDetectionResult
{
Success = true,
Insights = new List<string> { "No bill history found in the last 12 months." }
});
var companyName = (await _unitOfWork.Companies.GetByIdAsync(companyId))?.CompanyName ?? "Your Company";
var request = new RecurringBillDetectionRequest
{
CompanyName = companyName,
Bills = bills.Select(b => new RecurringBillHistoryItem
{
VendorName = b.Vendor?.CompanyName ?? $"Vendor #{b.VendorId}",
BillNumber = b.BillNumber,
Amount = b.Total,
DateIso = b.BillDate.ToString("yyyy-MM-dd"),
Memo = b.Memo
}).ToList()
};
var result = await _accountingAi.DetectRecurringBillsAsync(request);
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.RecurringBillDetection, result.Success);
return Json(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error running recurring bill detection");
return Json(new RecurringBillDetectionResult { Success = false, ErrorMessage = "An error occurred while analyzing bill patterns." });
}
}
// ── Receipt File Helpers ──────────────────────────────────────────────────
/// <summary>
@@ -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&amp;L activity