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
@@ -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';
}
});
})();