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
@@ -115,6 +115,27 @@
</div>
</div>
<!-- AI Auto-Match panel -->
<div class="card shadow-sm mb-3 border-0 bg-light">
<div class="card-body d-flex align-items-center gap-3 flex-wrap">
<div>
<span class="fw-semibold"><i class="bi bi-robot text-primary me-1"></i>AI Auto-Match</span>
<span class="text-muted small ms-2">Let Claude suggest which transactions to clear based on amounts and dates.</span>
</div>
<button id="aiMatchBtn" class="btn btn-outline-primary btn-sm ms-auto" type="button">
<i class="bi bi-magic me-1"></i>Suggest Matches
</button>
</div>
<div id="aiMatchResult" class="d-none px-3 pb-3">
<div id="aiMatchInsights" class="mb-2 text-muted small"></div>
<div id="aiMatchActions" class="d-flex gap-2 flex-wrap">
<button id="aiMatchAccept" class="btn btn-sm btn-success d-none">
<i class="bi bi-check-all me-1"></i>Apply All Suggestions
</button>
</div>
</div>
</div>
<form asp-action="Complete" method="post" id="completeForm">
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@recon?.Id" />
@@ -184,6 +205,88 @@
});
recalculate();
// ── AI Auto-Match ──────────────────────────────────────────────────────────
let aiSuggestions = [];
document.getElementById('aiMatchBtn')?.addEventListener('click', async function() {
const btn = this;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyzing…';
try {
const resp = await fetch('/BankReconciliations/AiSuggestMatches', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'RequestVerificationToken': token
},
body: new URLSearchParams({ reconId })
});
const data = await resp.json();
const resultEl = document.getElementById('aiMatchResult');
const insightsEl = document.getElementById('aiMatchInsights');
resultEl.classList.remove('d-none');
if (!data.success) {
insightsEl.innerHTML = `<span class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>${data.errorMessage || 'AI unavailable.'}</span>`;
return;
}
aiSuggestions = data.suggestedCleared || [];
// Highlight suggested rows
aiSuggestions.forEach(s => {
const row = document.querySelector(`.recon-row[data-type="${s.entityType}"][data-id="${s.entityId}"]`);
if (row) {
row.classList.add('table-info');
const td = row.querySelector('td:last-child');
if (td) {
const pct = Math.round(s.confidence * 100);
td.insertAdjacentHTML('afterend', `<td class="small text-info" style="white-space:nowrap">${pct}% — ${s.reason}</td>`);
}
}
});
// Insights
const insights = data.insights || [];
insightsEl.innerHTML = insights.map(i => `<i class="bi bi-lightbulb me-1 text-warning"></i>${i}`).join('<br>');
if (aiSuggestions.length > 0) {
document.getElementById('aiMatchAccept').classList.remove('d-none');
} else {
insightsEl.innerHTML += '<br><span class="text-muted">No high-confidence suggestions found — review items manually.</span>';
}
} catch (err) {
document.getElementById('aiMatchInsights').innerHTML = '<span class="text-danger">Error contacting AI service.</span>';
document.getElementById('aiMatchResult').classList.remove('d-none');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-magic me-1"></i>Suggest Matches';
}
});
document.getElementById('aiMatchAccept')?.addEventListener('click', async function() {
for (const s of aiSuggestions) {
const row = document.querySelector(`.recon-row[data-type="${s.entityType}"][data-id="${s.entityId}"]`);
if (!row) continue;
const cb = row.querySelector('.cleared-checkbox');
if (!cb || cb.checked) continue;
cb.checked = true;
// Persist via the existing toggle endpoint
try {
await fetch('/BankReconciliations/ToggleCleared', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': token },
body: new URLSearchParams({ reconId, entityType: s.entityType, entityId: s.entityId, isCleared: true })
});
} catch {}
}
recalculate();
this.textContent = 'Applied';
this.disabled = true;
});
})();
</script>
}
@@ -5,7 +5,10 @@
ViewData["PageIcon"] = "bi-receipt-cutoff";
}
<div class="d-flex justify-content-end mb-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<a asp-action="RecurringDetection" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-robot me-1"></i>Detect Recurring Bills
</a>
<div class="btn-group">
<a asp-controller="Bills" asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>New Bill
@@ -0,0 +1,58 @@
@{
ViewData["Title"] = "Recurring Bill Detection";
ViewData["PageIcon"] = "bi-arrow-repeat";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="fw-semibold mb-1"><i class="bi bi-arrow-repeat text-primary me-2"></i>Recurring Bill Detection</h4>
<p class="text-muted small mb-0">Claude analyzes your last 12 months of bills to find recurring payment patterns and help you anticipate upcoming expenses.</p>
</div>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Bills
</a>
</div>
<form id="scanForm" method="post" asp-action="RunRecurringDetection">
@Html.AntiForgeryToken()
<div class="card shadow-sm mb-4 border-0 bg-light">
<div class="card-body d-flex align-items-center gap-3 flex-wrap">
<div>
<span class="fw-semibold"><i class="bi bi-robot text-primary me-1"></i>AI Analysis</span>
<span class="text-muted small ms-2">Scans up to 12 months of bills grouped by vendor to detect patterns.</span>
</div>
<button id="scanBtn" type="submit" class="btn btn-primary ms-auto">
<i class="bi bi-magic me-1"></i>Detect Recurring Bills
</button>
</div>
</div>
</form>
<div id="resultArea" class="d-none">
<div id="spinnerArea" class="text-center py-5 d-none">
<div class="spinner-border text-primary" style="width:2.5rem;height:2.5rem;" role="status"></div>
<p class="text-muted mt-3">Claude is reviewing your bill history…</p>
</div>
<div id="errorArea" class="alert alert-danger alert-permanent d-none"></div>
<div id="insightsArea" class="alert alert-info alert-permanent d-none mb-3">
<i class="bi bi-lightbulb me-2"></i><span id="insightsList"></span>
</div>
<div id="noPatterns" class="card shadow-sm d-none">
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-search fs-1 d-block mb-2"></i>
<p class="mb-0 fw-semibold">No recurring patterns detected</p>
<p class="small">Need at least 2 occurrences of a vendor bill at a similar cadence. Add more bill history and try again.</p>
</div>
</div>
<div id="patternsArea" class="d-none">
<div class="row g-3" id="patternCards"></div>
</div>
</div>
@section Scripts {
<script src="/js/recurring-detection.js"></script>
}
@@ -240,3 +240,42 @@ else
<i class="bi bi-info-circle me-1"></i>
Generated @DateTime.Now.ToString("MMM d, yyyy h:mm tt") · Includes all open invoices (excluding Draft and Voided). Age calculated from due date.
</div>
@if (Model.Customers.Any())
{
<!-- AI Late Payment Prediction -->
<div class="card shadow-sm mt-4 border-0 no-print" id="aiRiskCard">
<div class="card-header d-flex align-items-center gap-2">
<i class="bi bi-robot text-primary"></i>
<span class="fw-semibold">AI Payment Risk Prediction</span>
<button id="aiRiskBtn" class="btn btn-sm btn-outline-primary ms-auto">
<i class="bi bi-magic me-1"></i>Predict Payment Risk
</button>
</div>
<div class="card-body d-none" id="aiRiskBody">
<div id="aiRiskSpinner" class="text-center py-3 d-none">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2 small">Claude is analyzing payment behavior…</p>
</div>
<div id="aiRiskError" class="alert alert-danger alert-permanent d-none"></div>
<div id="aiRiskInsights" class="text-muted small mb-3"></div>
<div id="aiRiskTable" class="table-responsive d-none">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr>
<th>Customer</th>
<th>Risk</th>
<th>Est. Days to Payment</th>
<th>Reasoning</th>
</tr>
</thead>
<tbody id="aiRiskRows"></tbody>
</table>
</div>
</div>
</div>
}
@section Scripts {
<script src="/js/ar-aging-ai.js"></script>
}
@@ -0,0 +1,122 @@
@using System.Text.Json
@{
ViewData["Title"] = "Ask Your Financials";
ViewData["PageIcon"] = "bi-chat-dots";
var context = ViewBag.Context as PowderCoating.Application.DTOs.AI.FinancialQueryContext;
var contextJson = JsonSerializer.Serialize(context);
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="fw-semibold mb-1"><i class="bi bi-chat-dots text-primary me-2"></i>Ask Your Financials</h4>
<p class="text-muted small mb-0">Ask Claude a plain-English question about your business finances. Data as of @DateTime.Today.ToString("MMMM d, yyyy").</p>
</div>
<a asp-action="Landing" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Reports
</a>
</div>
<div class="row g-4">
<div class="col-lg-8">
<!-- Query input -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<label class="form-label fw-semibold">Your question</label>
<div class="input-group">
<input type="text" id="queryInput" class="form-control form-control-lg"
placeholder="e.g. What did we spend on powder last quarter?"
autocomplete="off" />
<button id="queryBtn" class="btn btn-primary px-4" type="button">
<i class="bi bi-send me-1"></i>Ask
</button>
</div>
<div class="mt-2 d-flex flex-wrap gap-2" id="suggestionChips">
<span class="text-muted small me-1">Try:</span>
</div>
</div>
</div>
<!-- Answer area -->
<div id="answerArea" class="d-none">
<div class="card shadow-sm border-primary border-opacity-25">
<div class="card-header bg-primary-subtle text-primary-emphasis fw-semibold">
<i class="bi bi-robot me-1"></i>Claude's Answer
</div>
<div class="card-body">
<div id="answerSpinner" class="text-center py-3 d-none">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2 small">Analyzing your financials…</p>
</div>
<div id="answerError" class="alert alert-danger alert-permanent d-none"></div>
<p id="answerText" class="mb-3 fs-6 d-none"></p>
<div id="factsArea" class="d-none">
<p class="small fw-semibold text-muted mb-1">Supporting data:</p>
<ul id="factsList" class="list-unstyled small text-muted mb-0"></ul>
</div>
<div id="followUpArea" class="mt-3 pt-3 border-top d-none">
<span class="text-muted small me-2">Follow-up suggestion:</span>
<button id="followUpBtn" class="btn btn-sm btn-outline-primary"></button>
</div>
</div>
</div>
<!-- History -->
<div id="historyArea" class="mt-3 d-none">
<p class="text-muted small fw-semibold mb-2"><i class="bi bi-clock-history me-1"></i>Earlier questions this session</p>
<div id="historyList" class="d-flex flex-column gap-2"></div>
</div>
</div>
</div>
<div class="col-lg-4">
<!-- Snapshot -->
<div class="card shadow-sm mb-3">
<div class="card-header fw-semibold text-muted small">
<i class="bi bi-graph-up me-1"></i>YTD Snapshot
</div>
<div class="card-body pb-2">
<div class="d-flex justify-content-between small mb-2">
<span class="text-muted">Revenue</span>
<span class="fw-medium">@context?.TotalRevenueYtd.ToString("C0")</span>
</div>
<div class="d-flex justify-content-between small mb-2">
<span class="text-muted">Expenses</span>
<span class="fw-medium">@context?.TotalExpensesYtd.ToString("C0")</span>
</div>
<div class="d-flex justify-content-between small mb-2 border-top pt-2">
<span class="fw-semibold">Net Income</span>
<span class="fw-bold @(context?.NetIncomeYtd >= 0 ? "text-success" : "text-danger")">
@context?.NetIncomeYtd.ToString("C0")
</span>
</div>
<div class="d-flex justify-content-between small mb-2 border-top pt-2">
<span class="text-muted">AR Outstanding</span>
<span class="fw-medium text-warning">@context?.ArOutstanding.ToString("C0")</span>
</div>
<div class="d-flex justify-content-between small mb-2">
<span class="text-muted">AP Outstanding</span>
<span class="fw-medium text-danger">@context?.ApOutstanding.ToString("C0")</span>
</div>
</div>
</div>
<!-- Tips -->
<div class="card shadow-sm border-0 bg-light">
<div class="card-body small">
<p class="fw-semibold text-muted mb-2"><i class="bi bi-lightbulb me-1 text-warning"></i>Tips</p>
<ul class="list-unstyled text-muted mb-0 small">
<li class="mb-1">Ask about specific time periods: "last month", "Q1", "this year"</li>
<li class="mb-1">Compare periods: "compared to last quarter"</li>
<li class="mb-1">Ask about vendors, categories, or customers</li>
<li>Claude only uses data it was given — it won't invent figures</li>
</ul>
</div>
</div>
</div>
</div>
<input type="hidden" id="contextJson" value="@Html.Raw(System.Text.Encodings.Web.HtmlEncoder.Default.Encode(contextJson))" />
@section Scripts {
<script src="/js/financial-query.js"></script>
}
@@ -123,6 +123,22 @@
<p>AI scans recent bills and expense trends for duplicate entries, unusual amounts, and accounts running over their historical average.</p>
<div class="report-arrow">Run analysis <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="FinancialQuery" class="report-card">
<div class="report-card-icon" style="background:#eff6ff;color:#2563eb;">
<i class="bi bi-chat-dots"></i>
</div>
<h5>Ask Your Financials</h5>
<p>Ask Claude plain-English questions about your revenue, expenses, and AR. Answers are grounded in your actual financial data.</p>
<div class="report-arrow">Ask a question <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="ArAging" class="report-card">
<div class="report-card-icon" style="background:#fef3c7;color:#d97706;">
<i class="bi bi-hourglass-split"></i>
</div>
<h5>AR Aging + Risk Prediction</h5>
<p>View outstanding invoices by aging bucket, then run AI payment risk scoring to prioritize your follow-up calls.</p>
<div class="report-arrow">View aging <i class="bi bi-arrow-right"></i></div>
</a>
</div>
</div>
@@ -0,0 +1,70 @@
(function () {
const btn = document.getElementById('aiRiskBtn');
if (!btn) return;
btn.addEventListener('click', async function () {
const body = document.getElementById('aiRiskBody');
const spinner = document.getElementById('aiRiskSpinner');
const errEl = document.getElementById('aiRiskError');
const insights = document.getElementById('aiRiskInsights');
const table = document.getElementById('aiRiskTable');
const rows = document.getElementById('aiRiskRows');
body.classList.remove('d-none');
spinner.classList.remove('d-none');
errEl.classList.add('d-none');
table.classList.add('d-none');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyzing…';
try {
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
const resp = await fetch('/Reports/PredictLatePayments', {
method: 'POST',
headers: { 'RequestVerificationToken': token }
});
const data = await resp.json();
spinner.classList.add('d-none');
if (!data.success) {
errEl.textContent = data.errorMessage || 'AI service unavailable.';
errEl.classList.remove('d-none');
return;
}
const preds = data.predictions || [];
const insightList = data.insights || [];
insights.innerHTML = insightList
.map(i => `<i class="bi bi-lightbulb text-warning me-1"></i>${i}`)
.join('<br>');
const riskBadge = level => {
if (level === 'high') return 'bg-danger text-white';
if (level === 'medium') return 'bg-warning text-dark';
return 'bg-success text-white';
};
rows.innerHTML = preds.map(p => `
<tr>
<td class="fw-medium">${p.customerName}</td>
<td><span class="badge ${riskBadge(p.riskLevel)}">${p.riskLevel.charAt(0).toUpperCase() + p.riskLevel.slice(1)}</span></td>
<td>${p.estimatedDaysToPayment > 0 ? p.estimatedDaysToPayment + ' days' : 'Soon'}</td>
<td class="text-muted small">${p.reasoning}</td>
</tr>
`).join('');
if (preds.length > 0) table.classList.remove('d-none');
else insights.innerHTML += '<br><span class="text-muted">No open invoices to predict.</span>';
} catch (err) {
spinner.classList.add('d-none');
errEl.textContent = 'Error contacting AI service.';
errEl.classList.remove('d-none');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-magic me-1"></i>Predict Payment Risk';
}
});
})();
@@ -0,0 +1,116 @@
(function () {
const queryInput = document.getElementById('queryInput');
const queryBtn = document.getElementById('queryBtn');
const answerArea = document.getElementById('answerArea');
const spinner = document.getElementById('answerSpinner');
const errEl = document.getElementById('answerError');
const answerText = document.getElementById('answerText');
const factsArea = document.getElementById('factsArea');
const factsList = document.getElementById('factsList');
const followUpArea = document.getElementById('followUpArea');
const followUpBtn = document.getElementById('followUpBtn');
const historyArea = document.getElementById('historyArea');
const historyList = document.getElementById('historyList');
const chips = document.getElementById('suggestionChips');
const contextJson = document.getElementById('contextJson')?.value ?? '{}';
const suggestions = [
'What was our revenue this year?',
'What are our biggest expenses?',
'How much do customers owe us?',
'What is our net income year to date?',
'Which month had the highest revenue?',
];
let sessionHistory = [];
suggestions.forEach(s => {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'btn btn-sm btn-outline-secondary rounded-pill';
chip.textContent = s;
chip.addEventListener('click', () => { queryInput.value = s; runQuery(s); });
chips.appendChild(chip);
});
queryBtn.addEventListener('click', () => runQuery(queryInput.value.trim()));
queryInput.addEventListener('keydown', e => { if (e.key === 'Enter') runQuery(queryInput.value.trim()); });
followUpBtn.addEventListener('click', () => { queryInput.value = followUpBtn.textContent; runQuery(followUpBtn.textContent); });
async function runQuery(question) {
if (!question) return;
// Save previous answer to history before replacing
const prevQuestion = queryInput.dataset.lastQuestion;
const prevAnswer = answerText.textContent;
if (prevQuestion && prevAnswer) {
sessionHistory.push({ question: prevQuestion, answer: prevAnswer });
renderHistory();
}
queryInput.dataset.lastQuestion = question;
answerArea.classList.remove('d-none');
spinner.classList.remove('d-none');
errEl.classList.add('d-none');
answerText.classList.add('d-none');
factsArea.classList.add('d-none');
followUpArea.classList.add('d-none');
queryBtn.disabled = true;
queryBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>';
try {
let context = {};
try { context = JSON.parse(contextJson); } catch {}
const resp = await fetch('/Reports/RunFinancialQuery', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question, context })
});
const data = await resp.json();
spinner.classList.add('d-none');
if (!data.success) {
errEl.textContent = data.errorMessage || 'AI service unavailable.';
errEl.classList.remove('d-none');
return;
}
answerText.textContent = data.answer;
answerText.classList.remove('d-none');
const facts = data.relevantFacts || [];
if (facts.length > 0) {
factsList.innerHTML = facts.map(f => `<li><i class="bi bi-dot"></i>${f}</li>`).join('');
factsArea.classList.remove('d-none');
}
if (data.followUpSuggestion) {
followUpBtn.textContent = data.followUpSuggestion;
followUpArea.classList.remove('d-none');
}
} catch {
spinner.classList.add('d-none');
errEl.textContent = 'Error contacting AI service.';
errEl.classList.remove('d-none');
} finally {
queryBtn.disabled = false;
queryBtn.innerHTML = '<i class="bi bi-send me-1"></i>Ask';
}
}
function renderHistory() {
if (sessionHistory.length === 0) return;
historyArea.classList.remove('d-none');
historyList.innerHTML = '';
sessionHistory.slice(-5).reverse().forEach(entry => {
const item = document.createElement('div');
item.className = 'card border-0 bg-light p-2 small cursor-pointer';
item.style.cursor = 'pointer';
item.innerHTML = `<span class="fw-medium text-muted">${entry.question}</span><br><span class="text-truncate d-block" style="max-width:100%">${entry.answer.substring(0, 100)}${entry.answer.length > 100 ? '…' : ''}</span>`;
item.addEventListener('click', () => { queryInput.value = entry.question; });
historyList.appendChild(item);
});
}
})();
@@ -0,0 +1,111 @@
(function () {
const form = document.getElementById('scanForm');
const scanBtn = document.getElementById('scanBtn');
const resultArea = document.getElementById('resultArea');
const spinner = document.getElementById('spinnerArea');
const errArea = document.getElementById('errorArea');
const insArea = document.getElementById('insightsArea');
const insList = document.getElementById('insightsList');
const noPatterns = document.getElementById('noPatterns');
const patternsArea = document.getElementById('patternsArea');
const patternCards = document.getElementById('patternCards');
if (!form) return;
form.addEventListener('submit', async function (e) {
e.preventDefault();
resultArea.classList.remove('d-none');
spinner.classList.remove('d-none');
errArea.classList.add('d-none');
insArea.classList.add('d-none');
noPatterns.classList.add('d-none');
patternsArea.classList.add('d-none');
patternCards.innerHTML = '';
scanBtn.disabled = true;
scanBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyzing…';
try {
const token = form.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
const resp = await fetch('/Bills/RunRecurringDetection', {
method: 'POST',
headers: { 'RequestVerificationToken': token }
});
const data = await resp.json();
spinner.classList.add('d-none');
if (!data.success) {
errArea.textContent = data.errorMessage || 'AI service unavailable.';
errArea.classList.remove('d-none');
return;
}
const insights = data.insights || [];
if (insights.length > 0) {
insList.innerHTML = insights.join('<br>');
insArea.classList.remove('d-none');
}
const patterns = data.patterns || [];
if (patterns.length === 0) {
noPatterns.classList.remove('d-none');
return;
}
patternsArea.classList.remove('d-none');
patterns.forEach(p => {
const confBadge = p.confidence === 'high' ? 'bg-success text-white'
: p.confidence === 'medium' ? 'bg-warning text-dark'
: 'bg-secondary text-white';
const freqIcon = p.frequency === 'monthly' ? 'bi-calendar-month'
: p.frequency === 'quarterly' ? 'bi-calendar3'
: p.frequency === 'annual' ? 'bi-calendar-check'
: 'bi-arrow-repeat';
const nextDate = p.nextExpectedDateIso
? new Date(p.nextExpectedDateIso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: null;
const col = document.createElement('div');
col.className = 'col-md-6 col-xl-4';
col.innerHTML = `
<div class="card shadow-sm h-100">
<div class="card-header d-flex align-items-center gap-2">
<i class="bi ${freqIcon} text-primary"></i>
<span class="fw-semibold text-truncate">${p.vendorName}</span>
<span class="badge ${confBadge} ms-auto">${p.confidence}</span>
</div>
<div class="card-body small">
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Frequency</span>
<span class="fw-medium text-capitalize">${p.frequency}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Typical amount</span>
<span class="fw-medium">${p.typicalAmount.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}</span>
</div>
${nextDate ? `<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Next expected</span>
<span class="fw-medium text-warning">${nextDate}</span>
</div>` : ''}
<p class="text-muted mb-2">${p.description}</p>
${p.suggestedAction ? `<div class="alert alert-info alert-permanent py-1 px-2 mb-0 small">
<i class="bi bi-arrow-right-circle me-1"></i>${p.suggestedAction}
</div>` : ''}
</div>
</div>
`;
patternCards.appendChild(col);
});
} catch {
spinner.classList.add('d-none');
errArea.textContent = 'Error contacting AI service.';
errArea.classList.remove('d-none');
} finally {
scanBtn.disabled = false;
scanBtn.innerHTML = '<i class="bi bi-magic me-1"></i>Detect Recurring Bills';
}
});
})();