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
@@ -322,3 +322,214 @@ public class ClaudeAnomalyFlag
public string? RecommendedAction { get; set; }
public string? BillNumber { get; set; }
}
// ── Feature 7: Bank Rec Auto-Match ───────────────────────────────────────────
public class BankRecMatchItem
{
public string EntityType { get; set; } = string.Empty; // "Payment", "BillPayment", "Expense"
public int EntityId { get; set; }
public string Date { get; set; } = string.Empty; // ISO 8601
public string Reference { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string Direction { get; set; } = string.Empty; // "deposit" or "payment"
}
public class AutoMatchRequest
{
public List<BankRecMatchItem> UnclearedItems { get; set; } = new();
public decimal BeginningBalance { get; set; }
public decimal StatementEndingBalance { get; set; }
}
public class AutoMatchSuggestion
{
public string EntityType { get; set; } = string.Empty;
public int EntityId { get; set; }
public double Confidence { get; set; } // 0.01.0
public string Reason { get; set; } = string.Empty;
}
public class AutoMatchResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public List<AutoMatchSuggestion> SuggestedCleared { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
/// <summary>Internal JSON schema that Claude returns for bank rec auto-match.</summary>
public class ClaudeAutoMatchResponse
{
public List<ClaudeAutoMatchSuggestion> SuggestedCleared { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
public class ClaudeAutoMatchSuggestion
{
public string EntityType { get; set; } = string.Empty;
public int EntityId { get; set; }
public double Confidence { get; set; }
public string Reason { get; set; } = string.Empty;
}
// ── Feature 8: Late Payment Prediction ───────────────────────────────────────
public class OpenInvoiceSummary
{
public string InvoiceNumber { get; set; } = string.Empty;
public decimal BalanceDue { get; set; }
public string? DueDateIso { get; set; }
public int DaysOverdue { get; set; }
}
public class LatePaymentCustomerData
{
public string CustomerName { get; set; } = string.Empty;
public decimal TotalOwed { get; set; }
public double AvgDaysToPay { get; set; } // historical average
public int TotalInvoicesAllTime { get; set; }
public int LateInvoicesAllTime { get; set; }
public List<OpenInvoiceSummary> OpenInvoices { get; set; } = new();
}
public class LatePaymentPredictionRequest
{
public string CompanyName { get; set; } = string.Empty;
public List<LatePaymentCustomerData> Customers { get; set; } = new();
}
public class LatePaymentPrediction
{
public string CustomerName { get; set; } = string.Empty;
/// <summary>"high", "medium", or "low"</summary>
public string RiskLevel { get; set; } = "medium";
public int EstimatedDaysToPayment { get; set; }
public string Reasoning { get; set; } = string.Empty;
}
public class LatePaymentPredictionResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public List<LatePaymentPrediction> Predictions { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
/// <summary>Internal JSON schema that Claude returns for late payment predictions.</summary>
public class ClaudeLatePaymentResponse
{
public List<ClaudeLatePaymentPrediction> Predictions { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
public class ClaudeLatePaymentPrediction
{
public string CustomerName { get; set; } = string.Empty;
public string RiskLevel { get; set; } = "medium";
public int EstimatedDaysToPayment { get; set; }
public string Reasoning { get; set; } = string.Empty;
}
// ── Feature 9: Natural Language Financial Queries ─────────────────────────────
public class MonthlyFinancialSummary
{
public string Month { get; set; } = string.Empty; // "YYYY-MM"
public decimal Revenue { get; set; }
public decimal Expenses { get; set; }
public decimal NetIncome { get; set; }
}
public class FinancialQueryContext
{
public string CompanyName { get; set; } = string.Empty;
public string AsOfDate { get; set; } = string.Empty;
public decimal TotalRevenueYtd { get; set; }
public decimal TotalExpensesYtd { get; set; }
public decimal NetIncomeYtd { get; set; }
public decimal ArOutstanding { get; set; }
public decimal ApOutstanding { get; set; }
public List<MonthlyFinancialSummary> Last12Months { get; set; } = new();
public List<ExpenseByCategory> ExpensesByCategory { get; set; } = new();
}
public class FinancialQueryRequest
{
public string Question { get; set; } = string.Empty;
public FinancialQueryContext Context { get; set; } = new();
}
public class FinancialQueryResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public string Answer { get; set; } = string.Empty;
public string? FollowUpSuggestion { get; set; }
public List<string> RelevantFacts { get; set; } = new();
}
/// <summary>Internal JSON schema that Claude returns for financial queries.</summary>
public class ClaudeFinancialQueryResponse
{
public string Answer { get; set; } = string.Empty;
public string? FollowUpSuggestion { get; set; }
public List<string> RelevantFacts { get; set; } = new();
}
// ── Feature 10: Recurring Bill Detection ─────────────────────────────────────
public class RecurringBillHistoryItem
{
public string VendorName { get; set; } = string.Empty;
public string BillNumber { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string DateIso { get; set; } = string.Empty;
public string? Memo { get; set; }
}
public class RecurringBillDetectionRequest
{
public string CompanyName { get; set; } = string.Empty;
public List<RecurringBillHistoryItem> Bills { get; set; } = new();
}
public class RecurringBillPattern
{
public string VendorName { get; set; } = string.Empty;
/// <summary>"monthly", "quarterly", "biannual", "annual"</summary>
public string Frequency { get; set; } = string.Empty;
public decimal TypicalAmount { get; set; }
public string? NextExpectedDateIso { get; set; }
/// <summary>"high", "medium", or "low"</summary>
public string Confidence { get; set; } = "medium";
public string Description { get; set; } = string.Empty;
public string? SuggestedAction { get; set; }
}
public class RecurringBillDetectionResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public List<RecurringBillPattern> Patterns { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
/// <summary>Internal JSON schema that Claude returns for recurring bill detection.</summary>
public class ClaudeRecurringBillResponse
{
public List<ClaudeRecurringPattern> Patterns { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
public class ClaudeRecurringPattern
{
public string VendorName { get; set; } = string.Empty;
public string Frequency { get; set; } = string.Empty;
public decimal TypicalAmount { get; set; }
public string? NextExpectedDateIso { get; set; }
public string Confidence { get; set; } = "medium";
public string Description { get; set; } = string.Empty;
public string? SuggestedAction { get; set; }
}
@@ -43,4 +43,33 @@ public interface IAccountingAiService
/// Returns a ranked list of flagged items with recommended actions.
/// </summary>
Task<AnomalyDetectionResult> DetectAnomaliesAsync(AnomalyDetectionRequest request);
/// <summary>
/// Suggests which uncleared bank rec items should be marked as cleared to reconcile
/// a statement. Returns a ranked list of suggestions with confidence scores based on
/// amount/date patterns and the gap between the current cleared balance and the
/// statement ending balance.
/// </summary>
Task<AutoMatchResult> AutoMatchReconciliationAsync(AutoMatchRequest request);
/// <summary>
/// Predicts likelihood of late payment for each open AR customer using their historical
/// payment behavior (avg days to pay, late rate) combined with current overdue status.
/// Returns risk levels (high/medium/low) and estimated days to collection.
/// </summary>
Task<LatePaymentPredictionResult> PredictLatePaymentsAsync(LatePaymentPredictionRequest request);
/// <summary>
/// Answers a plain-English financial question (e.g. "What did we spend on powder last quarter?")
/// using pre-loaded company financial context. Returns a direct answer, supporting facts,
/// and an optional follow-up question suggestion.
/// </summary>
Task<FinancialQueryResult> AnswerFinancialQueryAsync(FinancialQueryRequest request);
/// <summary>
/// Analyzes 612 months of bill history to detect recurring payment patterns per vendor.
/// Returns detected patterns with frequency, typical amount, next expected date, and
/// suggested actions (e.g. set a reminder, create a template).
/// </summary>
Task<RecurringBillDetectionResult> DetectRecurringBillsAsync(RecurringBillDetectionRequest request);
}
@@ -902,4 +902,454 @@ Account Spend Trends (this month vs historical):
return new AnomalyDetectionResult { Success = false, ErrorMessage = "An error occurred while running the analysis." };
}
}
// ── Feature 7: Bank Rec Auto-Match ────────────────────────────────────────
/// <summary>
/// Suggests which uncleared bank rec transactions to mark as cleared to close the gap
/// between the current running balance and the statement ending balance. The items list
/// includes both deposits and payments with their direction tag so Claude can reason about
/// net effect. Confidence scores reflect how cleanly each item contributes to reaching the
/// target ending balance — items that together sum close to the required difference score
/// higher than items that alone overshoot. MaxTokens is 1024; the response is typically
/// compact because we only need entity-type/id pairs plus a short reason per item.
/// </summary>
public async Task<AutoMatchResult> AutoMatchReconciliationAsync(AutoMatchRequest request)
{
var apiKey = GetApiKey();
if (apiKey == null)
return new AutoMatchResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
try
{
var systemPrompt = @"You are a bank reconciliation assistant for a powder coating business.
Given a list of uncleared transactions and a target statement ending balance, suggest which transactions
to mark as cleared so that: Beginning Balance + cleared deposits - cleared payments = Statement Ending Balance.
Respond ONLY with a valid JSON object — no markdown, no explanation.
Schema:
{
""suggestedCleared"": [
{
""entityType"": ""Payment"" | ""BillPayment"" | ""Expense"",
""entityId"": number,
""confidence"": number (0.0 to 1.0),
""reason"": ""string — one sentence why this item should be cleared""
}
],
""insights"": [""string"", ...]
}
Rules:
- Select the combination of items whose net effect (deposits minus payments) gets closest to the difference needed
- Difference needed = statementEndingBalance - beginningBalance
- confidence 0.9-1.0: item clearly belongs in this period (date and amount both fit)
- confidence 0.6-0.89: likely but not certain
- confidence below 0.6: possible but uncertain — include only if needed to close the gap
- insights: 2-4 observations about patterns or items that need manual review
- Do NOT suggest clearing items you are uncertain about just to force a zero balance";
var itemsJson = JsonSerializer.Serialize(request.UnclearedItems);
var needed = request.StatementEndingBalance - request.BeginningBalance;
var userPrompt = $@"Suggest which transactions to clear for this bank reconciliation.
Beginning Balance: {request.BeginningBalance:F2}
Statement Ending Balance: {request.StatementEndingBalance:F2}
Difference needed (deposits - payments): {needed:F2}
Uncleared transactions:
{itemsJson}";
var client = new AnthropicClient(apiKey);
var messageParams = new MessageParameters
{
Model = Model,
MaxTokens = 1024,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText))
return new AutoMatchResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText);
var parsed = JsonSerializer.Deserialize<ClaudeAutoMatchResponse>(raw, JsonOpts);
if (parsed == null)
return new AutoMatchResult { Success = false, ErrorMessage = "Could not parse AI response." };
return new AutoMatchResult
{
Success = true,
SuggestedCleared = (parsed.SuggestedCleared ?? new()).Select(s => new AutoMatchSuggestion
{
EntityType = s.EntityType,
EntityId = s.EntityId,
Confidence = s.Confidence,
Reason = s.Reason
}).ToList(),
Insights = parsed.Insights ?? new()
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Claude AI bank rec auto-match timed out after 60 seconds");
return new AutoMatchResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error running bank rec auto-match with AI");
return new AutoMatchResult { Success = false, ErrorMessage = "An error occurred while running auto-match." };
}
}
// ── Feature 8: Late Payment Prediction ────────────────────────────────────
/// <summary>
/// Predicts payment risk per open AR customer by combining current overdue status with
/// historical behavior metrics (avg days to pay, late rate). The late rate is pre-calculated
/// as LateInvoicesAllTime / TotalInvoicesAllTime so Claude receives a 01 ratio rather than
/// raw counts, which produces more consistent confidence scoring across customers with very
/// different invoice volumes. Risk levels are validated against the three allowed values and
/// default to "medium" when Claude returns anything outside the expected set.
/// </summary>
public async Task<LatePaymentPredictionResult> PredictLatePaymentsAsync(LatePaymentPredictionRequest request)
{
var apiKey = GetApiKey();
if (apiKey == null)
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
try
{
var systemPrompt = @"You are an accounts receivable risk analyst for a powder coating business.
Given open AR data and each customer's historical payment behavior, predict payment risk for each customer.
Respond ONLY with a valid JSON object — no markdown, no explanation.
Schema:
{
""predictions"": [
{
""customerName"": ""string"",
""riskLevel"": ""high"" | ""medium"" | ""low"",
""estimatedDaysToPayment"": number,
""reasoning"": ""string — one sentence explaining the prediction""
}
],
""insights"": [""string"", ...]
}
Rules:
- riskLevel ""high"": customer has a history of late payment AND is already overdue, or has a very high late rate
- riskLevel ""medium"": customer is overdue but has reasonable historical performance, or is current but has a spotty history
- riskLevel ""low"": customer typically pays on time and is not severely overdue
- estimatedDaysToPayment: realistic estimate of additional days until payment, based on history and overdue status
- insights: 2-4 portfolio-level observations (e.g. which customers need immediate follow-up)
- Only include predictions for customers with open invoices";
var customersJson = JsonSerializer.Serialize(request.Customers.Select(c => new
{
c.CustomerName,
c.TotalOwed,
c.AvgDaysToPay,
LatePaymentRate = c.TotalInvoicesAllTime > 0
? Math.Round((double)c.LateInvoicesAllTime / c.TotalInvoicesAllTime, 2)
: 0,
c.OpenInvoices
}));
var userPrompt = $@"Predict payment risk for open AR customers of {request.CompanyName}.
Customer data (includes historical payment behavior):
{customersJson}";
var client = new AnthropicClient(apiKey);
var messageParams = new MessageParameters
{
Model = Model,
MaxTokens = 1024,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText))
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText);
var parsed = JsonSerializer.Deserialize<ClaudeLatePaymentResponse>(raw, JsonOpts);
if (parsed == null)
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Could not parse AI response." };
var validRiskLevels = new[] { "high", "medium", "low" };
var predictions = (parsed.Predictions ?? new()).Select(p => new LatePaymentPrediction
{
CustomerName = p.CustomerName,
RiskLevel = validRiskLevels.Contains(p.RiskLevel?.ToLowerInvariant()) ? p.RiskLevel!.ToLowerInvariant() : "medium",
EstimatedDaysToPayment = p.EstimatedDaysToPayment,
Reasoning = p.Reasoning
}).ToList();
return new LatePaymentPredictionResult
{
Success = true,
Predictions = predictions,
Insights = parsed.Insights ?? new()
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Claude AI late payment prediction timed out after 60 seconds");
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error predicting late payments with AI");
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "An error occurred while predicting payment risk." };
}
}
// ── Feature 9: Natural Language Financial Queries ─────────────────────────
/// <summary>
/// Answers a free-text financial question using a pre-loaded snapshot of the company's
/// financial data. The context object is serialized to JSON and embedded in the user prompt
/// so Claude has concrete numbers to reason over rather than fabricating estimates. The
/// system prompt explicitly constrains Claude to the data provided and forbids it from
/// making up figures outside the snapshot — this prevents hallucination of specific dollar
/// amounts. RelevantFacts is a list of supporting data points Claude pulled from the context
/// to justify the answer, displayed below the answer in the UI so users can verify.
/// MaxTokens is raised to 1500 to accommodate answers with multiple supporting facts.
/// </summary>
public async Task<FinancialQueryResult> AnswerFinancialQueryAsync(FinancialQueryRequest request)
{
var apiKey = GetApiKey();
if (apiKey == null)
return new FinancialQueryResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
try
{
var systemPrompt = @"You are a financial analyst assistant for a powder coating business.
Answer plain-English financial questions using ONLY the data provided in the context.
Respond ONLY with a valid JSON object — no markdown, no explanation.
Schema:
{
""answer"": ""string — direct, plain-English answer to the question"",
""followUpSuggestion"": ""string — one optional follow-up question the user might want to ask next, or null"",
""relevantFacts"": [""string"", ...]
}
Rules:
- answer: be direct and specific with dollar amounts and percentages from the data
- If the data does not contain enough information to answer the question, say so clearly in the answer
- Do NOT invent or estimate figures that are not in the provided data
- relevantFacts: 2-5 specific data points from the context that support the answer (formatted as ""Label: $X"" or ""Label: X%"")
- followUpSuggestion: suggest the natural next question the user would want to ask, or null if not obvious
- Keep the answer under 100 words — be concise";
var contextJson = JsonSerializer.Serialize(request.Context);
var userPrompt = $@"Question: {request.Question}
Financial context:
{contextJson}";
var client = new AnthropicClient(apiKey);
var messageParams = new MessageParameters
{
Model = Model,
MaxTokens = 1500,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText))
return new FinancialQueryResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText);
var parsed = JsonSerializer.Deserialize<ClaudeFinancialQueryResponse>(raw, JsonOpts);
if (parsed == null)
return new FinancialQueryResult { Success = false, ErrorMessage = "Could not parse AI response." };
return new FinancialQueryResult
{
Success = true,
Answer = parsed.Answer ?? string.Empty,
FollowUpSuggestion = parsed.FollowUpSuggestion,
RelevantFacts = parsed.RelevantFacts ?? new()
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Claude AI financial query timed out after 60 seconds");
return new FinancialQueryResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error answering financial query with AI");
return new FinancialQueryResult { Success = false, ErrorMessage = "An error occurred while answering your question." };
}
}
// ── Feature 10: Recurring Bill Detection ──────────────────────────────────
/// <summary>
/// Analyzes 612 months of historical bills to detect recurring payment patterns per vendor.
/// Bills are grouped by vendor in the prompt so Claude can see the full chronological series
/// for each vendor at a glance. The confidence field ("high"/"medium"/"low") reflects how
/// regular the cadence is — a bill appearing every 2832 days for 6 consecutive months is
/// high confidence; 23 occurrences at similar amounts is medium. NextExpectedDateIso is
/// calculated by Claude from the pattern's most recent date plus the detected period length.
/// MaxTokens is 1500 to accommodate multi-vendor response objects with multiple patterns.
/// </summary>
public async Task<RecurringBillDetectionResult> DetectRecurringBillsAsync(RecurringBillDetectionRequest request)
{
var apiKey = GetApiKey();
if (apiKey == null)
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
try
{
var systemPrompt = @"You are a recurring expense analyst for a powder coating business.
Analyze the provided bill history to detect recurring payment patterns per vendor.
Respond ONLY with a valid JSON object — no markdown, no explanation.
Schema:
{
""patterns"": [
{
""vendorName"": ""string"",
""frequency"": ""monthly"" | ""quarterly"" | ""biannual"" | ""annual"" | ""irregular"",
""typicalAmount"": number,
""nextExpectedDateIso"": ""YYYY-MM-DD or null"",
""confidence"": ""high"" | ""medium"" | ""low"",
""description"": ""string — one sentence describing the pattern"",
""suggestedAction"": ""string — one specific action to take, or null""
}
],
""insights"": [""string"", ...]
}
Rules:
- Only report patterns with at least 2 occurrences
- monthly: bills occurring every 2535 days
- quarterly: bills occurring every 80100 days
- biannual: bills occurring every 170195 days
- annual: bills occurring roughly once per year
- irregular: a vendor bills regularly but the cadence is inconsistent
- confidence ""high"": 4+ occurrences with consistent timing (within ±5 days of the period)
- confidence ""medium"": 23 occurrences with consistent timing, or 4+ with variable timing
- confidence ""low"": pattern is weak but worth monitoring
- nextExpectedDateIso: estimate based on the last bill date + the detected period; null if irregular or low confidence
- suggestedAction: e.g. ""Set a monthly reminder for this bill"" or ""Create a recurring bill template"" or null
- insights: 2-4 portfolio-level observations about the company's recurring expense profile
- If no recurring patterns are found, return an empty patterns array";
// Group bills by vendor for clarity in the prompt
var grouped = request.Bills
.GroupBy(b => b.VendorName)
.Select(g => new
{
VendorName = g.Key,
Bills = g.OrderBy(b => b.DateIso).Select(b => new { b.DateIso, b.Amount, b.BillNumber, b.Memo })
});
var billsJson = JsonSerializer.Serialize(grouped);
var userPrompt = $@"Detect recurring bill patterns for {request.CompanyName}.
Data covers the last 612 months of bills, grouped by vendor.
Bill history by vendor:
{billsJson}";
var client = new AnthropicClient(apiKey);
var messageParams = new MessageParameters
{
Model = Model,
MaxTokens = 1500,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText))
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText);
var parsed = JsonSerializer.Deserialize<ClaudeRecurringBillResponse>(raw, JsonOpts);
if (parsed == null)
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Could not parse AI response." };
var validConfidence = new[] { "high", "medium", "low" };
var validFrequency = new[] { "monthly", "quarterly", "biannual", "annual", "irregular" };
return new RecurringBillDetectionResult
{
Success = true,
Patterns = (parsed.Patterns ?? new()).Select(p => new RecurringBillPattern
{
VendorName = p.VendorName,
Frequency = validFrequency.Contains(p.Frequency?.ToLowerInvariant()) ? p.Frequency!.ToLowerInvariant() : "irregular",
TypicalAmount = p.TypicalAmount,
NextExpectedDateIso = p.NextExpectedDateIso,
Confidence = validConfidence.Contains(p.Confidence?.ToLowerInvariant()) ? p.Confidence!.ToLowerInvariant() : "medium",
Description = p.Description,
SuggestedAction = p.SuggestedAction
}).ToList(),
Insights = parsed.Insights ?? new()
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Claude AI recurring bill detection timed out after 60 seconds");
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error detecting recurring bills with AI");
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "An error occurred while analyzing bill patterns." };
}
}
}
@@ -101,8 +101,12 @@ public static class AppConstants
public const string AccountSuggest = "AccountSuggest";
public const string ArFollowUp = "ArFollowUp";
public const string FinancialSummary = "FinancialSummary";
public const string CashFlowForecast = "CashFlowForecast";
public const string AnomalyDetection = "AnomalyDetection";
public const string CashFlowForecast = "CashFlowForecast";
public const string AnomalyDetection = "AnomalyDetection";
public const string BankRecAutoMatch = "BankRecAutoMatch";
public const string LatePaymentPrediction = "LatePaymentPrediction";
public const string FinancialQuery = "FinancialQuery";
public const string RecurringBillDetection = "RecurringBillDetection";
}
public static class Legal
@@ -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';
}
});
})();