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? RecommendedAction { get; set; }
|
||||||
public string? BillNumber { 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.
|
/// Returns a ranked list of flagged items with recommended actions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<AnomalyDetectionResult> DetectAnomaliesAsync(AnomalyDetectionRequest request);
|
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." };
|
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 AccountSuggest = "AccountSuggest";
|
||||||
public const string ArFollowUp = "ArFollowUp";
|
public const string ArFollowUp = "ArFollowUp";
|
||||||
public const string FinancialSummary = "FinancialSummary";
|
public const string FinancialSummary = "FinancialSummary";
|
||||||
public const string CashFlowForecast = "CashFlowForecast";
|
public const string CashFlowForecast = "CashFlowForecast";
|
||||||
public const string AnomalyDetection = "AnomalyDetection";
|
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
|
public static class Legal
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PowderCoating.Application.DTOs.AI;
|
||||||
using PowderCoating.Application.Interfaces;
|
using PowderCoating.Application.Interfaces;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
using PowderCoating.Core.Enums;
|
using PowderCoating.Core.Enums;
|
||||||
@@ -15,13 +16,19 @@ public class BankReconciliationsController : Controller
|
|||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly ITenantContext _tenantContext;
|
private readonly ITenantContext _tenantContext;
|
||||||
|
private readonly IAccountingAiService _accountingAi;
|
||||||
|
private readonly IAiUsageLogger _usageLogger;
|
||||||
|
|
||||||
public BankReconciliationsController(
|
public BankReconciliationsController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
ITenantContext tenantContext)
|
ITenantContext tenantContext,
|
||||||
|
IAccountingAiService accountingAi,
|
||||||
|
IAiUsageLogger usageLogger)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_tenantContext = tenantContext;
|
_tenantContext = tenantContext;
|
||||||
|
_accountingAi = accountingAi;
|
||||||
|
_usageLogger = usageLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool AllowAccounting() =>
|
private bool AllowAccounting() =>
|
||||||
@@ -269,6 +276,91 @@ public class BankReconciliationsController : Controller
|
|||||||
return View(recon);
|
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 ──────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async Task PopulateAccountDropdownAsync()
|
private async Task PopulateAccountDropdownAsync()
|
||||||
|
|||||||
@@ -1136,6 +1136,68 @@ public class BillsController : Controller
|
|||||||
return Json(result);
|
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 ──────────────────────────────────────────────────
|
// ── Receipt File Helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <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
|
// GET: /Reports/BudgetVsActual
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Budget vs. Actual report: compares a budget's monthly line amounts against real P&L activity
|
/// Budget vs. Actual report: compares a budget's monthly line amounts against real P&L activity
|
||||||
|
|||||||
@@ -115,6 +115,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<form asp-action="Complete" method="post" id="completeForm">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<input type="hidden" name="id" value="@recon?.Id" />
|
<input type="hidden" name="id" value="@recon?.Id" />
|
||||||
@@ -184,6 +205,88 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
recalculate();
|
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>
|
</script>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
ViewData["PageIcon"] = "bi-receipt-cutoff";
|
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">
|
<div class="btn-group">
|
||||||
<a asp-controller="Bills" asp-action="Create" class="btn btn-primary">
|
<a asp-controller="Bills" asp-action="Create" class="btn btn-primary">
|
||||||
<i class="bi bi-plus-lg me-1"></i>New Bill
|
<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>
|
<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.
|
Generated @DateTime.Now.ToString("MMM d, yyyy h:mm tt") · Includes all open invoices (excluding Draft and Voided). Age calculated from due date.
|
||||||
</div>
|
</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>
|
<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>
|
<div class="report-arrow">Run analysis <i class="bi bi-arrow-right"></i></div>
|
||||||
</a>
|
</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>
|
||||||
</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