using System.Net.Http; using System.Text; using System.Text.Json; using Anthropic.SDK; using Anthropic.SDK.Messaging; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using PowderCoating.Application.DTOs.AI; using PowderCoating.Application.Interfaces; namespace PowderCoating.Infrastructure.Services; public class AccountingAiService : IAccountingAiService { private readonly IConfiguration _config; private readonly ILogger _logger; private const string Model = "claude-sonnet-4-6"; private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; /// /// Initializes a new instance of with the application /// configuration and logger. The Anthropic API key is read lazily per call via /// so that configuration changes at runtime (e.g. via user secrets) /// are picked up without restarting the application. /// public AccountingAiService(IConfiguration config, ILogger logger) { _config = config; _logger = logger; } // ── Helper ──────────────────────────────────────────────────────────────── /// /// Reads the Anthropic API key from configuration and returns null if it is absent or /// still set to the placeholder value. Returning null (instead of throwing) allows each /// feature method to return a friendly error result rather than crash, which is important /// when the key has not yet been configured in a fresh tenant environment. /// private string? GetApiKey() { var key = _config["AI:Anthropic:ApiKey"]; return string.IsNullOrWhiteSpace(key) || key.StartsWith("your-") ? null : key; } /// /// Removes the optional ```json … ``` markdown code fence that Claude (and other /// models) sometimes wrap around JSON responses despite being instructed not to. Stripping /// it here lets downstream calls /// succeed without requiring each feature method to duplicate the cleanup logic. /// private static string StripJsonFences(string text) { // Remove optional ```json ... ``` wrapper that some models add var trimmed = text.Trim(); if (trimmed.StartsWith("```")) { var firstNewline = trimmed.IndexOf('\n'); if (firstNewline >= 0) trimmed = trimmed[(firstNewline + 1)..]; if (trimmed.EndsWith("```")) trimmed = trimmed[..^3]; } return trimmed.Trim(); } // Uses raw HTTP to call the Anthropic API with a document (PDF) content block, // since the SDK 4.0.0 does not yet have DocumentContent support. private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(30) }; /// /// Sends a Claude API request with a 60-second hard timeout enforced via a /// . The SDK itself does not provide a built-in /// per-call timeout, so wrapping every call here prevents AI slowdowns from holding /// up request threads indefinitely. Callers catch /// and return a user-friendly error result. /// private static async Task SendAsync(AnthropicClient client, MessageParameters parameters) { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); return await client.Messages.GetClaudeMessageAsync(parameters, cts.Token); } /// /// Sends a multimodal document (PDF) message to the Anthropic API using raw HTTP rather /// than the SDK, because Anthropic SDK 4.0.0 does not yet expose a DocumentContent /// block type. The document is base64-encoded and sent as an Anthropic-native document /// content block alongside a text instruction. The method extracts the first text block /// from the response envelope and returns it as a plain string for downstream JSON parsing. /// private static async Task ScanWithRawHttpAsync( string apiKey, string systemPrompt, string userText, byte[] fileData, string mediaType) { var payload = new { model = Model, max_tokens = 1024, system = systemPrompt, messages = new[] { new { role = "user", content = new object[] { new { type = "document", source = new { type = "base64", media_type = mediaType, data = Convert.ToBase64String(fileData) } }, new { type = "text", text = userText } } } } }; var json = JsonSerializer.Serialize(payload); using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages"); request.Headers.Add("x-api-key", apiKey); request.Headers.Add("anthropic-version", "2023-06-01"); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); using var response = await _http.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(body); var content = doc.RootElement .GetProperty("content")[0] .GetProperty("text") .GetString() ?? ""; return content; } // ── Feature 1: Receipt Scanning ─────────────────────────────────────────── /// /// Analyzes a receipt or invoice image (JPEG, PNG, GIF, WebP, or PDF) using Claude vision /// and returns structured extraction data: vendor name, date, total, invoice number, and /// line items with suggested expense account mappings. /// /// PDFs are routed through because the Anthropic SDK /// 4.0.0 does not support the document content block type required for PDF input. Image /// formats use the standard SDK image content block. The system prompt embeds the company's /// full chart of accounts as JSON so Claude can return a concrete suggestedAccountId /// for each line item rather than a free-text category name, which avoids a second round-trip /// lookup. Account matching rules are kept broad (materials, utilities, equipment, freight, /// office) since receipts in a powder coating shop follow predictable categories. /// public async Task ScanReceiptAsync( byte[] imageData, string mimeType, List availableAccounts) { var apiKey = GetApiKey(); if (apiKey == null) return new ReceiptScanResult { Success = false, ErrorMessage = "Anthropic API key is not configured." }; try { var accountsJson = JsonSerializer.Serialize(availableAccounts.Select(a => new { a.Id, a.AccountNumber, a.Name, a.AccountType, a.AccountSubType })); var systemPrompt = @"You are a receipt/invoice scanner for a powder coating business accounting system. Analyze the provided receipt or invoice image and extract all data. Respond ONLY with a valid JSON object — no markdown, no explanation. Schema: { ""vendorName"": ""string or null"", ""date"": ""ISO 8601 date (YYYY-MM-DD) or null"", ""total"": number or null, ""invoiceNumber"": ""string or null"", ""lineItems"": [ { ""description"": ""string"", ""amount"": number, ""suggestedAccountId"": number or null, ""suggestedAccountName"": ""string or null"" } ] } Account matching rules: - Materials, powder, supplies → match to a CostOfGoods or Materials account - Utilities (electricity, gas, water) → match to a Utilities expense account - Equipment, tools → match to Equipment or Supplies account - Freight, shipping → match to Freight or Shipping account - Office supplies → match to Office Supplies account - If unsure, leave suggestedAccountId null Use the provided accounts list to find the best match ID."; var userText = $"Please scan this receipt/invoice and extract all data.\n\nAvailable accounts:\n{accountsJson}"; string rawText1; var normalizedMime = mimeType.ToLowerInvariant(); if (normalizedMime == "application/pdf") { // PDFs use the Anthropic document API (not supported by SDK 4.0.0, use raw HTTP) rawText1 = await ScanWithRawHttpAsync(apiKey, systemPrompt, userText, imageData, "application/pdf"); } else { var imageMime = normalizedMime switch { "image/jpg" => "image/jpeg", "image/jpeg" => "image/jpeg", "image/png" => "image/png", "image/gif" => "image/gif", "image/webp" => "image/webp", _ => "image/jpeg" }; 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 ImageContent { Source = new ImageSource { MediaType = imageMime, Data = Convert.ToBase64String(imageData) } }, new TextContent { Text = userText } } } } }; var response = await SendAsync(client, messageParams); rawText1 = response.FirstMessage?.Text ?? response.Content?.OfType().FirstOrDefault()?.Text ?? ""; } if (string.IsNullOrWhiteSpace(rawText1)) return new ReceiptScanResult { Success = false, ErrorMessage = "Empty response from AI." }; var raw = StripJsonFences(rawText1); var parsed = JsonSerializer.Deserialize(raw, JsonOpts); if (parsed == null) return new ReceiptScanResult { Success = false, ErrorMessage = "Could not parse AI response." }; return new ReceiptScanResult { Success = true, VendorName = parsed.VendorName, Date = parsed.Date, Total = parsed.Total, InvoiceNumber = parsed.InvoiceNumber, LineItems = parsed.LineItems.Select(li => new ScannedLineItem { Description = li.Description, Amount = li.Amount, SuggestedAccountId = li.SuggestedAccountId, SuggestedAccountName = li.SuggestedAccountName }).ToList() }; } catch (OperationCanceledException) { _logger.LogWarning("Claude AI receipt scan timed out after 60 seconds"); return new ReceiptScanResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." }; } catch (Exception ex) { _logger.LogError(ex, "Error scanning receipt with AI"); return new ReceiptScanResult { Success = false, ErrorMessage = "An error occurred while scanning the receipt." }; } } // ── Feature 2: AR Follow-up Email Drafts ───────────────────────────────── /// /// Drafts a professional accounts-receivable follow-up email for one or more overdue /// invoices using Claude, with the tone calibrated automatically to the aging bucket: /// gentle for ≤30 days, firm for 31–60 days, and serious/urgent for 61+ days. This /// avoids a one-size-fits-all message that would either annoy recent late payers or /// under-communicate urgency to severely overdue accounts. The prompt explicitly forbids /// placeholder text (e.g., "[phone number]") so the drafted email is ready to send as-is /// without the user needing to fill in blanks. All invoice numbers and the total owing /// are included so the customer has full context without needing to call in. /// public async Task DraftFollowUpEmailAsync(ArFollowUpRequest request) { var apiKey = GetApiKey(); if (apiKey == null) return new ArFollowUpResult { Success = false, ErrorMessage = "Anthropic API key is not configured." }; try { var tone = request.DaysOverdue <= 30 ? "gentle and friendly — a polite reminder" : request.DaysOverdue <= 60 ? "firm and professional — clearly requesting action" : "serious and direct — emphasizing urgency and potential consequences"; var invoiceList = string.Join("\n", request.Invoices.Select(i => $" - Invoice {i.InvoiceNumber}: ${i.Amount:F2} ({i.DaysOverdue} days overdue)")); var systemPrompt = @"You are an accounts receivable specialist for a powder coating business. Draft professional follow-up emails for overdue invoices. Respond ONLY with a valid JSON object — no markdown, no explanation. Schema: { ""subject"": ""string"", ""body"": ""string (plain text, use \n for line breaks)"" }"; var userPrompt = $@"Draft a follow-up email for an overdue account. Tone: {tone}. Customer: {request.CustomerName} Our company: {request.CompanyName} Total amount owed: ${request.AmountOwed:F2} Days overdue: {request.DaysOverdue} days Outstanding invoices: {invoiceList} Requirements: - Address the customer by name - List all invoice numbers - State the total amount due clearly - Match the tone: {tone} - Sign off with {request.CompanyName} — Accounts Receivable - Keep it concise (under 200 words for body) - Do NOT include any placeholders like [phone number] — omit contact details if unknown"; 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 rawText2 = response.FirstMessage?.Text ?? response.Content?.OfType().FirstOrDefault()?.Text ?? ""; if (string.IsNullOrWhiteSpace(rawText2)) return new ArFollowUpResult { Success = false, ErrorMessage = "Empty response from AI." }; var raw = StripJsonFences(rawText2); var parsed = JsonSerializer.Deserialize(raw, JsonOpts); if (parsed == null) return new ArFollowUpResult { Success = false, ErrorMessage = "Could not parse AI response." }; return new ArFollowUpResult { Success = true, Subject = parsed.Subject, Body = parsed.Body }; } catch (OperationCanceledException) { _logger.LogWarning("Claude AI AR follow-up draft timed out after 60 seconds"); return new ArFollowUpResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." }; } catch (Exception ex) { _logger.LogError(ex, "Error drafting AR follow-up email with AI"); return new ArFollowUpResult { Success = false, ErrorMessage = "An error occurred while drafting the email." }; } } // ── Feature 3: Smart Account Categorization ─────────────────────────────── /// /// Suggests the best-matching expense account from the company's chart of accounts for /// a single bill line item, given vendor name, description, and amount. Returns the /// primary suggestion plus up to three ranked alternatives with confidence scores and /// one-sentence reasoning for each. Alternatives with confidence below 0.3 are suppressed /// to avoid presenting noise as options. This is called on blur of each line-item /// description field in the Bills and Expenses forms; a keyword cache in the calling /// controller avoids repeated API calls for identical descriptions within the same session. /// The chart of accounts is serialized and sent in the user prompt (not the system prompt) /// so that it varies per company without requiring a per-company system prompt build. /// public async Task SuggestAccountAsync(AccountSuggestionRequest request) { var apiKey = GetApiKey(); if (apiKey == null) return new AccountSuggestionResult { Success = false, ErrorMessage = "Anthropic API key is not configured." }; try { var accountsJson = JsonSerializer.Serialize(request.AvailableAccounts.Select(a => new { a.Id, a.AccountNumber, a.Name, a.AccountType, a.AccountSubType })); var systemPrompt = @"You are an accounting expert for a powder coating business. Given a vendor, description, and amount, suggest the best matching expense account from the provided list. Respond ONLY with a valid JSON object — no markdown, no explanation. Schema: { ""suggestedAccountId"": number or null, ""suggestedAccountName"": ""string or null"", ""reasoning"": ""string — one sentence explaining the match"", ""alternatives"": [ { ""accountId"": number, ""accountName"": ""string"", ""confidence"": number (0.0 to 1.0), ""reasoning"": ""string"" } ] } Return up to 3 alternatives (not including the primary suggestion). Confidence: 1.0 = perfect match, 0.0 = no match. Only include alternatives with confidence > 0.3."; var userPrompt = new StringBuilder(); userPrompt.AppendLine("Suggest the best expense account for this transaction:"); if (!string.IsNullOrWhiteSpace(request.VendorName)) userPrompt.AppendLine($"Vendor: {request.VendorName}"); if (!string.IsNullOrWhiteSpace(request.Description)) userPrompt.AppendLine($"Description: {request.Description}"); userPrompt.AppendLine($"Amount: ${request.Amount:F2}"); userPrompt.AppendLine(); userPrompt.AppendLine("Available accounts:"); userPrompt.AppendLine(accountsJson); 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.ToString() } } } } }; var response = await SendAsync(client, messageParams); var rawText3 = response.FirstMessage?.Text ?? response.Content?.OfType().FirstOrDefault()?.Text ?? ""; if (string.IsNullOrWhiteSpace(rawText3)) return new AccountSuggestionResult { Success = false, ErrorMessage = "Empty response from AI." }; var raw = StripJsonFences(rawText3); var parsed = JsonSerializer.Deserialize(raw, JsonOpts); if (parsed == null) return new AccountSuggestionResult { Success = false, ErrorMessage = "Could not parse AI response." }; return new AccountSuggestionResult { Success = true, SuggestedAccountId = parsed.SuggestedAccountId, SuggestedAccountName = parsed.SuggestedAccountName, Reasoning = parsed.Reasoning, Alternatives = parsed.Alternatives.Select(a => new AccountSuggestion { AccountId = a.AccountId, AccountName = a.AccountName, Confidence = a.Confidence, Reasoning = a.Reasoning }).ToList() }; } catch (OperationCanceledException) { _logger.LogWarning("Claude AI account suggestion timed out after 60 seconds"); return new AccountSuggestionResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." }; } catch (Exception ex) { _logger.LogError(ex, "Error suggesting account with AI"); return new AccountSuggestionResult { Success = false, ErrorMessage = "An error occurred while suggesting an account." }; } } // ── Feature 4: Plain-English Financial Summary ──────────────────────────── /// /// Generates a short plain-English financial health summary (4–6 bullet points) and an /// overall sentiment classification ("positive", "neutral", or "concerning") from /// month-to-date financial metrics. Only the top 8 expense categories by amount are sent /// to Claude to keep the prompt compact and focus attention on material line items rather /// than minor cost buckets. Revenue change versus the prior month is pre-calculated as a /// percentage before being included in the prompt because Claude handles numeric reasoning /// better when the computation is already done. The system prompt explicitly bans markdown /// formatting inside bullets so the output renders cleanly in the HTML card view without /// additional sanitization. Sentiment is validated server-side against the three allowed /// values and defaults to "neutral" if Claude returns anything outside the expected set. /// public async Task GenerateFinancialSummaryAsync(FinancialSummaryRequest request) { var apiKey = GetApiKey(); if (apiKey == null) return new FinancialSummaryResult { Success = false, ErrorMessage = "Anthropic API key is not configured." }; try { var systemPrompt = @"You are a financial advisor for a powder coating business. Analyze the provided financial data and generate a plain-English summary. Respond ONLY with a valid JSON object — no markdown, no explanation. Schema: { ""bullets"": [""string"", ...], // 4 to 6 plain-English bullet points ""sentiment"": ""positive"" | ""neutral"" | ""concerning"" } Rules: - Each bullet must be one clear sentence in plain English — no accounting jargon - Cover revenue trends, expense levels, AR health, and any notable risks or opportunities - sentiment = ""positive"" if the business is clearly profitable and AR is healthy - sentiment = ""concerning"" if net income is negative, AR overdue is high, or expenses grew significantly - sentiment = ""neutral"" otherwise - Do NOT use markdown formatting inside the bullets (no **bold**, no #headings)"; var expensesJson = JsonSerializer.Serialize(request.ExpensesByCategory .OrderByDescending(e => e.Amount) .Take(8) .Select(e => new { e.Category, Amount = e.Amount })); var revenueChange = request.PriorMonthRevenue > 0 ? (request.TotalRevenue - request.PriorMonthRevenue) / request.PriorMonthRevenue * 100m : 0m; var userPrompt = $@"Generate a financial health summary for {request.CompanyName}. Period: {request.Period} Total Revenue: ${request.TotalRevenue:F2} Total Expenses: ${request.TotalExpenses:F2} Net Income: ${request.NetIncome:F2} Prior Month Revenue: ${request.PriorMonthRevenue:F2} Prior Month Expenses: ${request.PriorMonthExpenses:F2} Revenue Change vs Prior Month: {revenueChange:F1}% Accounts Receivable: - Total Outstanding: ${request.TotalArOutstanding:F2} - Amount Overdue >30 days: ${request.ArOverdue30Days:F2} - Overdue Invoice Count: {request.OverdueInvoiceCount} Top Expense Categories: {expensesJson}"; 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 rawText4 = response.FirstMessage?.Text ?? response.Content?.OfType().FirstOrDefault()?.Text ?? ""; if (string.IsNullOrWhiteSpace(rawText4)) return new FinancialSummaryResult { Success = false, ErrorMessage = "Empty response from AI." }; var raw = StripJsonFences(rawText4); var parsed = JsonSerializer.Deserialize(raw, JsonOpts); if (parsed == null) return new FinancialSummaryResult { Success = false, ErrorMessage = "Could not parse AI response." }; var validSentiments = new[] { "positive", "neutral", "concerning" }; var sentiment = validSentiments.Contains(parsed.Sentiment?.ToLowerInvariant()) ? parsed.Sentiment!.ToLowerInvariant() : "neutral"; return new FinancialSummaryResult { Success = true, Bullets = parsed.Bullets ?? new List(), Sentiment = sentiment }; } catch (OperationCanceledException) { _logger.LogWarning("Claude AI financial summary timed out after 60 seconds"); return new FinancialSummaryResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." }; } catch (Exception ex) { _logger.LogError(ex, "Error generating financial summary with AI"); return new FinancialSummaryResult { Success = false, ErrorMessage = "An error occurred while generating the summary." }; } } // ── Feature 5: Cash Flow Forecast ──────────────────────────────────────── /// /// Generates a 30/60/90-day cash flow forecast from open AR invoices, outstanding vendor /// bills, and the active job pipeline. The three data sets are serialized as JSON and /// passed verbatim in the user prompt so Claude can reason over the actual invoice due /// dates and amounts rather than summary statistics. Active jobs are included in the /// 60–90 day window because jobs nearing completion represent probable inflows that would /// not appear in open AR yet. The MaxTokens is raised to 1500 (versus 1024 for /// other features) to accommodate the larger structured output with three period objects /// and multiple key-item arrays. The outlook enum ("strong", "moderate", "tight", /// "concerning") is validated server-side and defaults to "moderate" on unexpected values. /// public async Task GenerateCashFlowForecastAsync(CashFlowForecastRequest request) { var apiKey = GetApiKey(); if (apiKey == null) return new CashFlowForecastResult { Success = false, ErrorMessage = "Anthropic API key is not configured." }; try { var systemPrompt = @"You are a cash flow analyst for a powder coating business. Given open AR invoices, outstanding vendor bills, and active jobs in the pipeline, project the business's 30, 60, and 90-day cash position. Respond ONLY with a valid JSON object — no markdown, no explanation. Schema: { ""next30Days"": { ""expectedInflows"": number, ""expectedOutflows"": number, ""netCashFlow"": number, ""keyItems"": [""string"", ...] }, ""next60Days"": { same structure }, ""next90Days"": { same structure }, ""insights"": [""string"", ...], ""outlook"": ""strong"" | ""moderate"" | ""tight"" | ""concerning"" } Rules: - Base inflow estimates on invoice due dates and typical days-to-pay patterns provided - Include pipeline jobs as probable inflows in the 60-90 day window (jobs near completion sooner) - Base outflow estimates on bill due dates - keyItems: 2-4 specific items driving the period's numbers (e.g. ""$3,200 from Acme Corp invoice INV-2601-0012 due in 15 days"") - insights: 3-5 plain-English observations about cash position, timing risks, or opportunities - outlook: overall 90-day cash health assessment - All dollar amounts must be numbers (not strings) - Do NOT include currency symbols inside number fields"; var arJson = JsonSerializer.Serialize(request.OpenInvoices); var apJson = JsonSerializer.Serialize(request.OpenBills); var jobJson = JsonSerializer.Serialize(request.ActiveJobs); var userPrompt = $@"Generate a 30/60/90-day cash flow forecast for {request.CompanyName} as of {request.AsOfDate}. Open AR Invoices (money coming in): {arJson} Outstanding Bills / AP (money going out): {apJson} Active Job Pipeline (expected future invoicing): {jobJson}"; 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 CashFlowForecastResult { Success = false, ErrorMessage = "Empty response from AI." }; var raw = StripJsonFences(rawText); var parsed = JsonSerializer.Deserialize(raw, JsonOpts); if (parsed == null) return new CashFlowForecastResult { Success = false, ErrorMessage = "Could not parse AI response." }; var validOutlooks = new[] { "strong", "moderate", "tight", "concerning" }; var outlook = validOutlooks.Contains(parsed.Outlook?.ToLowerInvariant()) ? parsed.Outlook!.ToLowerInvariant() : "moderate"; return new CashFlowForecastResult { Success = true, Next30Days = new CashFlowPeriod { ExpectedInflows = parsed.Next30Days.ExpectedInflows, ExpectedOutflows = parsed.Next30Days.ExpectedOutflows, NetCashFlow = parsed.Next30Days.NetCashFlow, KeyItems = parsed.Next30Days.KeyItems ?? new() }, Next60Days = new CashFlowPeriod { ExpectedInflows = parsed.Next60Days.ExpectedInflows, ExpectedOutflows = parsed.Next60Days.ExpectedOutflows, NetCashFlow = parsed.Next60Days.NetCashFlow, KeyItems = parsed.Next60Days.KeyItems ?? new() }, Next90Days = new CashFlowPeriod { ExpectedInflows = parsed.Next90Days.ExpectedInflows, ExpectedOutflows = parsed.Next90Days.ExpectedOutflows, NetCashFlow = parsed.Next90Days.NetCashFlow, KeyItems = parsed.Next90Days.KeyItems ?? new() }, Insights = parsed.Insights ?? new(), Outlook = outlook }; } catch (OperationCanceledException) { _logger.LogWarning("Claude AI cash flow forecast timed out after 60 seconds"); return new CashFlowForecastResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." }; } catch (Exception ex) { _logger.LogError(ex, "Error generating cash flow forecast with AI"); return new CashFlowForecastResult { Success = false, ErrorMessage = "An error occurred while generating the forecast." }; } } // ── Feature 6: Anomaly / Duplicate Detection ───────────────────────────── /// /// Scans the last 90 days of vendor bills for four categories of anomaly: potential /// duplicate payments (same vendor and near-identical amount within 30 days, or repeated /// invoice number), amount spikes (a bill more than 2.5× the vendor's average), unusual /// new vendors (first-time vendor with a bill over $500), and account overruns (an expense /// account running more than 50% above its historical monthly average). Severity levels /// are critical (duplicates, extreme overruns), warning (spikes, unusual vendors, moderate /// overruns), and info. The thresholds (2.5×, 50%, $500) are embedded in the system prompt /// and were chosen to reduce false positives in a typical powder coating shop where /// occasional large material orders are normal. When no anomalies are found Claude is /// instructed to return an empty flags array rather than null, preventing null-reference /// errors on the flags enumeration. Severity is normalized to lowercase after /// parsing to guard against Claude casing inconsistencies (e.g., "Critical" vs "critical"). /// public async Task DetectAnomaliesAsync(AnomalyDetectionRequest request) { var apiKey = GetApiKey(); if (apiKey == null) return new AnomalyDetectionResult { Success = false, ErrorMessage = "Anthropic API key is not configured." }; try { var systemPrompt = @"You are an accounting auditor for a powder coating business. Analyze the provided bills, vendor history, and account trends for anomalies. Respond ONLY with a valid JSON object — no markdown, no explanation. Schema: { ""flags"": [ { ""type"": ""duplicate"" | ""amount_spike"" | ""unusual_vendor"" | ""account_overrun"", ""severity"": ""critical"" | ""warning"" | ""info"", ""title"": ""string — short headline"", ""description"": ""string — 1-2 sentences explaining what was found"", ""recommendedAction"": ""string — one clear action the user should take, or null"", ""billNumber"": ""string or null — bill number if this flag relates to a specific bill"" } ] } Detection rules: - duplicate: same vendor + same or near-identical amount within 30 days, or same vendor invoice number appearing more than once - amount_spike: a single bill is more than 2.5x that vendor's average invoice amount - unusual_vendor: a vendor that has never appeared before (not in history) with a bill over $500 - account_overrun: an expense account running more than 50% above its average monthly spend this month - severity critical: duplicates (risk of double payment), account_overrun > 100% over average - severity warning: amount_spike, account_overrun 50-100% over average, unusual_vendor - severity info: minor anomalies worth noting but low risk - Only flag real anomalies — do not flag items when data is insufficient to draw a conclusion - If no anomalies are found, return an empty flags array"; var billsJson = JsonSerializer.Serialize(request.RecentBills); var historyJson = JsonSerializer.Serialize(request.VendorHistory); var trendsJson = JsonSerializer.Serialize(request.AccountTrends); var userPrompt = $@"Analyze the following data for anomalies for {request.CompanyName}. Recent Bills (last 90 days): {billsJson} Vendor History (averages): {historyJson} Account Spend Trends (this month vs historical): {trendsJson}"; 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 AnomalyDetectionResult { Success = false, ErrorMessage = "Empty response from AI." }; var raw = StripJsonFences(rawText); var parsed = JsonSerializer.Deserialize(raw, JsonOpts); if (parsed == null) return new AnomalyDetectionResult { Success = false, ErrorMessage = "Could not parse AI response." }; var flags = (parsed.Flags ?? new()).Select(f => new AnomalyFlag { Type = f.Type, Severity = f.Severity?.ToLowerInvariant() ?? "warning", Title = f.Title, Description = f.Description, RecommendedAction = f.RecommendedAction, BillNumber = f.BillNumber }).ToList(); return new AnomalyDetectionResult { Success = true, Flags = flags, CriticalCount = flags.Count(f => f.Severity == "critical"), WarningCount = flags.Count(f => f.Severity == "warning"), InfoCount = flags.Count(f => f.Severity == "info") }; } catch (OperationCanceledException) { _logger.LogWarning("Claude AI anomaly detection timed out after 60 seconds"); return new AnomalyDetectionResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." }; } catch (Exception ex) { _logger.LogError(ex, "Error running anomaly detection with AI"); 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." }; } } }