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:
@@ -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&L activity
|
||||
|
||||
Reference in New Issue
Block a user