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:
@@ -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.0–1.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 6–12 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 0–1 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 6–12 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 28–32 days for 6 consecutive months is
|
||||
/// high confidence; 2–3 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 25–35 days
|
||||
- quarterly: bills occurring every 80–100 days
|
||||
- biannual: bills occurring every 170–195 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"": 2–3 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 6–12 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&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';
|
||||
}
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user