From 959e323f3a33617ab0bdee1cbe7de6b9ae7e982d Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sun, 10 May 2026 19:22:49 -0400 Subject: [PATCH] Add 4 AI bookkeeping features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../DTOs/AI/AccountingAiDtos.cs | 211 ++++++++ .../Interfaces/IAccountingAiService.cs | 29 ++ .../Services/AccountingAiService.cs | 450 ++++++++++++++++++ .../Constants/AppConstants.cs | 8 +- .../BankReconciliationsController.cs | 94 +++- .../Controllers/BillsController.cs | 62 +++ .../Controllers/ReportsController.cs | 189 ++++++++ .../BankReconciliations/Reconcile.cshtml | 103 ++++ .../Views/Bills/Index.cshtml | 5 +- .../Views/Bills/RecurringDetection.cshtml | 58 +++ .../Views/Reports/ArAging.cshtml | 39 ++ .../Views/Reports/FinancialQuery.cshtml | 122 +++++ .../Views/Reports/Landing.cshtml | 16 + .../wwwroot/js/ar-aging-ai.js | 70 +++ .../wwwroot/js/financial-query.js | 116 +++++ .../wwwroot/js/recurring-detection.js | 111 +++++ 16 files changed, 1679 insertions(+), 4 deletions(-) create mode 100644 src/PowderCoating.Web/Views/Bills/RecurringDetection.cshtml create mode 100644 src/PowderCoating.Web/Views/Reports/FinancialQuery.cshtml create mode 100644 src/PowderCoating.Web/wwwroot/js/ar-aging-ai.js create mode 100644 src/PowderCoating.Web/wwwroot/js/financial-query.js create mode 100644 src/PowderCoating.Web/wwwroot/js/recurring-detection.js diff --git a/src/PowderCoating.Application/DTOs/AI/AccountingAiDtos.cs b/src/PowderCoating.Application/DTOs/AI/AccountingAiDtos.cs index 1277005..f3ef023 100644 --- a/src/PowderCoating.Application/DTOs/AI/AccountingAiDtos.cs +++ b/src/PowderCoating.Application/DTOs/AI/AccountingAiDtos.cs @@ -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 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 SuggestedCleared { get; set; } = new(); + public List Insights { get; set; } = new(); +} + +/// Internal JSON schema that Claude returns for bank rec auto-match. +public class ClaudeAutoMatchResponse +{ + public List SuggestedCleared { get; set; } = new(); + public List 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 OpenInvoices { get; set; } = new(); +} + +public class LatePaymentPredictionRequest +{ + public string CompanyName { get; set; } = string.Empty; + public List Customers { get; set; } = new(); +} + +public class LatePaymentPrediction +{ + public string CustomerName { get; set; } = string.Empty; + /// "high", "medium", or "low" + 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 Predictions { get; set; } = new(); + public List Insights { get; set; } = new(); +} + +/// Internal JSON schema that Claude returns for late payment predictions. +public class ClaudeLatePaymentResponse +{ + public List Predictions { get; set; } = new(); + public List 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 Last12Months { get; set; } = new(); + public List 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 RelevantFacts { get; set; } = new(); +} + +/// Internal JSON schema that Claude returns for financial queries. +public class ClaudeFinancialQueryResponse +{ + public string Answer { get; set; } = string.Empty; + public string? FollowUpSuggestion { get; set; } + public List 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 Bills { get; set; } = new(); +} + +public class RecurringBillPattern +{ + public string VendorName { get; set; } = string.Empty; + /// "monthly", "quarterly", "biannual", "annual" + public string Frequency { get; set; } = string.Empty; + public decimal TypicalAmount { get; set; } + public string? NextExpectedDateIso { get; set; } + /// "high", "medium", or "low" + 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 Patterns { get; set; } = new(); + public List Insights { get; set; } = new(); +} + +/// Internal JSON schema that Claude returns for recurring bill detection. +public class ClaudeRecurringBillResponse +{ + public List Patterns { get; set; } = new(); + public List 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; } +} diff --git a/src/PowderCoating.Application/Interfaces/IAccountingAiService.cs b/src/PowderCoating.Application/Interfaces/IAccountingAiService.cs index 15392d4..6a2a522 100644 --- a/src/PowderCoating.Application/Interfaces/IAccountingAiService.cs +++ b/src/PowderCoating.Application/Interfaces/IAccountingAiService.cs @@ -43,4 +43,33 @@ public interface IAccountingAiService /// Returns a ranked list of flagged items with recommended actions. /// Task DetectAnomaliesAsync(AnomalyDetectionRequest request); + + /// + /// 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. + /// + Task AutoMatchReconciliationAsync(AutoMatchRequest request); + + /// + /// 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. + /// + Task PredictLatePaymentsAsync(LatePaymentPredictionRequest request); + + /// + /// 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. + /// + Task AnswerFinancialQueryAsync(FinancialQueryRequest request); + + /// + /// 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). + /// + Task DetectRecurringBillsAsync(RecurringBillDetectionRequest request); } diff --git a/src/PowderCoating.Infrastructure/Services/AccountingAiService.cs b/src/PowderCoating.Infrastructure/Services/AccountingAiService.cs index 22bf04e..c5d436d 100644 --- a/src/PowderCoating.Infrastructure/Services/AccountingAiService.cs +++ b/src/PowderCoating.Infrastructure/Services/AccountingAiService.cs @@ -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 ──────────────────────────────────────── + + /// + /// 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. + /// + public async Task 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 + { + new Message + { + Role = RoleType.User, + Content = new List { new TextContent { Text = userPrompt } } + } + } + }; + + var response = await SendAsync(client, messageParams); + var rawText = response.FirstMessage?.Text + ?? response.Content?.OfType().FirstOrDefault()?.Text + ?? ""; + if (string.IsNullOrWhiteSpace(rawText)) + return new AutoMatchResult { Success = false, ErrorMessage = "Empty response from AI." }; + + var raw = StripJsonFences(rawText); + var parsed = JsonSerializer.Deserialize(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 ──────────────────────────────────── + + /// + /// 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. + /// + public async Task 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 + { + new Message + { + Role = RoleType.User, + Content = new List { new TextContent { Text = userPrompt } } + } + } + }; + + var response = await SendAsync(client, messageParams); + var rawText = response.FirstMessage?.Text + ?? response.Content?.OfType().FirstOrDefault()?.Text + ?? ""; + if (string.IsNullOrWhiteSpace(rawText)) + return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Empty response from AI." }; + + var raw = StripJsonFences(rawText); + var parsed = JsonSerializer.Deserialize(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 ───────────────────────── + + /// + /// 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. + /// + public async Task 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 + { + new Message + { + Role = RoleType.User, + Content = new List { new TextContent { Text = userPrompt } } + } + } + }; + + var response = await SendAsync(client, messageParams); + var rawText = response.FirstMessage?.Text + ?? response.Content?.OfType().FirstOrDefault()?.Text + ?? ""; + if (string.IsNullOrWhiteSpace(rawText)) + return new FinancialQueryResult { Success = false, ErrorMessage = "Empty response from AI." }; + + var raw = StripJsonFences(rawText); + var parsed = JsonSerializer.Deserialize(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 ────────────────────────────────── + + /// + /// 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. + /// + public async Task 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 + { + new Message + { + Role = RoleType.User, + Content = new List { new TextContent { Text = userPrompt } } + } + } + }; + + var response = await SendAsync(client, messageParams); + var rawText = response.FirstMessage?.Text + ?? response.Content?.OfType().FirstOrDefault()?.Text + ?? ""; + if (string.IsNullOrWhiteSpace(rawText)) + return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Empty response from AI." }; + + var raw = StripJsonFences(rawText); + var parsed = JsonSerializer.Deserialize(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." }; + } + } } diff --git a/src/PowderCoating.Shared/Constants/AppConstants.cs b/src/PowderCoating.Shared/Constants/AppConstants.cs index c50915a..6d08d01 100644 --- a/src/PowderCoating.Shared/Constants/AppConstants.cs +++ b/src/PowderCoating.Shared/Constants/AppConstants.cs @@ -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 diff --git a/src/PowderCoating.Web/Controllers/BankReconciliationsController.cs b/src/PowderCoating.Web/Controllers/BankReconciliationsController.cs index 5fe7f12..5b03b0a 100644 --- a/src/PowderCoating.Web/Controllers/BankReconciliationsController.cs +++ b/src/PowderCoating.Web/Controllers/BankReconciliationsController.cs @@ -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) ────────────────────────────────────────────────── + + /// + /// 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 + /// . The caller applies + /// suggestions client-side by auto-checking the corresponding table rows. + /// + [HttpPost] + [Authorize(Policy = AppConstants.Policies.CanManageJobs)] + [ValidateAntiForgeryToken] + public async Task 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(); + + (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() diff --git a/src/PowderCoating.Web/Controllers/BillsController.cs b/src/PowderCoating.Web/Controllers/BillsController.cs index c8ed864..a50743c 100644 --- a/src/PowderCoating.Web/Controllers/BillsController.cs +++ b/src/PowderCoating.Web/Controllers/BillsController.cs @@ -1136,6 +1136,68 @@ public class BillsController : Controller return Json(result); } + // ── AI: Recurring Bill Detection ────────────────────────────────────────── + + /// + /// 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 . + /// + public IActionResult RecurringDetection() => View(); + + /// + /// 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. + /// + [HttpPost] + [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)] + [ValidateAntiForgeryToken] + public async Task 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 { "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 ────────────────────────────────────────────────── /// diff --git a/src/PowderCoating.Web/Controllers/ReportsController.cs b/src/PowderCoating.Web/Controllers/ReportsController.cs index 8747bc8..3b21eb8 100644 --- a/src/PowderCoating.Web/Controllers/ReportsController.cs +++ b/src/PowderCoating.Web/Controllers/ReportsController.cs @@ -2118,6 +2118,195 @@ public class ReportsController : Controller } } + // ── AI: Late Payment Prediction ─────────────────────────────────────────── + + /// + /// 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 . + /// + [HttpPost] + [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)] + public async Task 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 ──────────────────────────────── + + /// + /// 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. + /// + public async Task FinancialQuery() + { + if (!AllowAccounting()) return RedirectToAction(nameof(Landing)); + ViewBag.Context = await BuildFinancialQueryContextAsync(); + return View(); + } + + /// + /// 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. + /// + [HttpPost] + [EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)] + public async Task 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); + } + + /// + /// Builds a 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. + /// + private async Task 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(); + 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 /// /// Budget vs. Actual report: compares a budget's monthly line amounts against real P&L activity diff --git a/src/PowderCoating.Web/Views/BankReconciliations/Reconcile.cshtml b/src/PowderCoating.Web/Views/BankReconciliations/Reconcile.cshtml index d3d1498..d6a9677 100644 --- a/src/PowderCoating.Web/Views/BankReconciliations/Reconcile.cshtml +++ b/src/PowderCoating.Web/Views/BankReconciliations/Reconcile.cshtml @@ -115,6 +115,27 @@ + +
+
+
+ AI Auto-Match + Let Claude suggest which transactions to clear based on amounts and dates. +
+ +
+
+
+
+ +
+
+
+
@Html.AntiForgeryToken() @@ -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 = '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 = `${data.errorMessage || 'AI unavailable.'}`; + 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', `${pct}% — ${s.reason}`); + } + } + }); + + // Insights + const insights = data.insights || []; + insightsEl.innerHTML = insights.map(i => `${i}`).join('
'); + + if (aiSuggestions.length > 0) { + document.getElementById('aiMatchAccept').classList.remove('d-none'); + } else { + insightsEl.innerHTML += '
No high-confidence suggestions found — review items manually.'; + } + } catch (err) { + document.getElementById('aiMatchInsights').innerHTML = 'Error contacting AI service.'; + document.getElementById('aiMatchResult').classList.remove('d-none'); + } finally { + btn.disabled = false; + btn.innerHTML = '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; + }); })(); } diff --git a/src/PowderCoating.Web/Views/Bills/Index.cshtml b/src/PowderCoating.Web/Views/Bills/Index.cshtml index e377a40..1107a17 100644 --- a/src/PowderCoating.Web/Views/Bills/Index.cshtml +++ b/src/PowderCoating.Web/Views/Bills/Index.cshtml @@ -5,7 +5,10 @@ ViewData["PageIcon"] = "bi-receipt-cutoff"; } -
+
+ + Detect Recurring Bills +
New Bill diff --git a/src/PowderCoating.Web/Views/Bills/RecurringDetection.cshtml b/src/PowderCoating.Web/Views/Bills/RecurringDetection.cshtml new file mode 100644 index 0000000..9dc14df --- /dev/null +++ b/src/PowderCoating.Web/Views/Bills/RecurringDetection.cshtml @@ -0,0 +1,58 @@ +@{ + ViewData["Title"] = "Recurring Bill Detection"; + ViewData["PageIcon"] = "bi-arrow-repeat"; +} + + + + + @Html.AntiForgeryToken() +
+
+
+ AI Analysis + Scans up to 12 months of bills grouped by vendor to detect patterns. +
+ +
+
+ + +
+
+
+

Claude is reviewing your bill history…

+
+ +
+ +
+ +
+ +
+
+ +

No recurring patterns detected

+

Need at least 2 occurrences of a vendor bill at a similar cadence. Add more bill history and try again.

+
+
+ +
+
+
+
+ +@section Scripts { + +} diff --git a/src/PowderCoating.Web/Views/Reports/ArAging.cshtml b/src/PowderCoating.Web/Views/Reports/ArAging.cshtml index 404a7bc..ae63e85 100644 --- a/src/PowderCoating.Web/Views/Reports/ArAging.cshtml +++ b/src/PowderCoating.Web/Views/Reports/ArAging.cshtml @@ -240,3 +240,42 @@ else Generated @DateTime.Now.ToString("MMM d, yyyy h:mm tt") · Includes all open invoices (excluding Draft and Voided). Age calculated from due date.
+ +@if (Model.Customers.Any()) +{ + +
+
+ + AI Payment Risk Prediction + +
+
+
+
+

Claude is analyzing payment behavior…

+
+
+
+
+ + + + + + + + + + +
CustomerRiskEst. Days to PaymentReasoning
+
+
+
+} + +@section Scripts { + +} diff --git a/src/PowderCoating.Web/Views/Reports/FinancialQuery.cshtml b/src/PowderCoating.Web/Views/Reports/FinancialQuery.cshtml new file mode 100644 index 0000000..4d8d975 --- /dev/null +++ b/src/PowderCoating.Web/Views/Reports/FinancialQuery.cshtml @@ -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); +} + +
+
+

Ask Your Financials

+

Ask Claude a plain-English question about your business finances. Data as of @DateTime.Today.ToString("MMMM d, yyyy").

+
+ + Reports + +
+ +
+
+ +
+
+ +
+ + +
+
+ Try: +
+
+
+ + +
+
+
+ Claude's Answer +
+
+
+
+

Analyzing your financials…

+
+
+

+
+

Supporting data:

+
    +
    +
    + Follow-up suggestion: + +
    +
    +
    + + +
    +

    Earlier questions this session

    +
    +
    +
    +
    + +
    + +
    +
    + YTD Snapshot +
    +
    +
    + Revenue + @context?.TotalRevenueYtd.ToString("C0") +
    +
    + Expenses + @context?.TotalExpensesYtd.ToString("C0") +
    +
    + Net Income + + @context?.NetIncomeYtd.ToString("C0") + +
    +
    + AR Outstanding + @context?.ArOutstanding.ToString("C0") +
    +
    + AP Outstanding + @context?.ApOutstanding.ToString("C0") +
    +
    +
    + + +
    +
    +

    Tips

    +
      +
    • Ask about specific time periods: "last month", "Q1", "this year"
    • +
    • Compare periods: "compared to last quarter"
    • +
    • Ask about vendors, categories, or customers
    • +
    • Claude only uses data it was given — it won't invent figures
    • +
    +
    +
    +
    +
    + + + +@section Scripts { + +} diff --git a/src/PowderCoating.Web/Views/Reports/Landing.cshtml b/src/PowderCoating.Web/Views/Reports/Landing.cshtml index 786d09d..44c0c48 100644 --- a/src/PowderCoating.Web/Views/Reports/Landing.cshtml +++ b/src/PowderCoating.Web/Views/Reports/Landing.cshtml @@ -123,6 +123,22 @@

    AI scans recent bills and expense trends for duplicate entries, unusual amounts, and accounts running over their historical average.

    Run analysis
    + +
    + +
    +
    Ask Your Financials
    +

    Ask Claude plain-English questions about your revenue, expenses, and AR. Answers are grounded in your actual financial data.

    +
    Ask a question
    +
    + +
    + +
    +
    AR Aging + Risk Prediction
    +

    View outstanding invoices by aging bucket, then run AI payment risk scoring to prioritize your follow-up calls.

    +
    View aging
    +
    diff --git a/src/PowderCoating.Web/wwwroot/js/ar-aging-ai.js b/src/PowderCoating.Web/wwwroot/js/ar-aging-ai.js new file mode 100644 index 0000000..ac5ac82 --- /dev/null +++ b/src/PowderCoating.Web/wwwroot/js/ar-aging-ai.js @@ -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 = '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}`) + .join('
    '); + + 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 => ` + + ${p.customerName} + ${p.riskLevel.charAt(0).toUpperCase() + p.riskLevel.slice(1)} + ${p.estimatedDaysToPayment > 0 ? p.estimatedDaysToPayment + ' days' : 'Soon'} + ${p.reasoning} + + `).join(''); + + if (preds.length > 0) table.classList.remove('d-none'); + else insights.innerHTML += '
    No open invoices to predict.'; + + } catch (err) { + spinner.classList.add('d-none'); + errEl.textContent = 'Error contacting AI service.'; + errEl.classList.remove('d-none'); + } finally { + btn.disabled = false; + btn.innerHTML = 'Predict Payment Risk'; + } + }); +})(); diff --git a/src/PowderCoating.Web/wwwroot/js/financial-query.js b/src/PowderCoating.Web/wwwroot/js/financial-query.js new file mode 100644 index 0000000..fb853ae --- /dev/null +++ b/src/PowderCoating.Web/wwwroot/js/financial-query.js @@ -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 = ''; + + 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 => `
  • ${f}
  • `).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 = '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 = `${entry.question}
    ${entry.answer.substring(0, 100)}${entry.answer.length > 100 ? '…' : ''}`; + item.addEventListener('click', () => { queryInput.value = entry.question; }); + historyList.appendChild(item); + }); + } +})(); diff --git a/src/PowderCoating.Web/wwwroot/js/recurring-detection.js b/src/PowderCoating.Web/wwwroot/js/recurring-detection.js new file mode 100644 index 0000000..cc12d63 --- /dev/null +++ b/src/PowderCoating.Web/wwwroot/js/recurring-detection.js @@ -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 = '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('
    '); + 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 = ` +
    +
    + + ${p.vendorName} + ${p.confidence} +
    +
    +
    + Frequency + ${p.frequency} +
    +
    + Typical amount + ${p.typicalAmount.toLocaleString('en-US', { style: 'currency', currency: 'USD' })} +
    + ${nextDate ? `
    + Next expected + ${nextDate} +
    ` : ''} +

    ${p.description}

    + ${p.suggestedAction ? `
    + ${p.suggestedAction} +
    ` : ''} +
    +
    + `; + 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 = 'Detect Recurring Bills'; + } + }); +})();