Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/AccountingAiService.cs
T
2026-04-23 21:38:24 -04:00

906 lines
40 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<AccountingAiService> _logger;
private const string Model = "claude-sonnet-4-6";
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true
};
/// <summary>
/// Initializes a new instance of <see cref="AccountingAiService"/> with the application
/// configuration and logger. The Anthropic API key is read lazily per call via
/// <see cref="GetApiKey"/> so that configuration changes at runtime (e.g. via user secrets)
/// are picked up without restarting the application.
/// </summary>
public AccountingAiService(IConfiguration config, ILogger<AccountingAiService> logger)
{
_config = config;
_logger = logger;
}
// ── Helper ────────────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
private string? GetApiKey()
{
var key = _config["AI:Anthropic:ApiKey"];
return string.IsNullOrWhiteSpace(key) || key.StartsWith("your-") ? null : key;
}
/// <summary>
/// Removes the optional <c>```json … ```</c> markdown code fence that Claude (and other
/// models) sometimes wrap around JSON responses despite being instructed not to. Stripping
/// it here lets downstream <see cref="System.Text.Json.JsonSerializer.Deserialize"/> calls
/// succeed without requiring each feature method to duplicate the cleanup logic.
/// </summary>
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) };
/// <summary>
/// Sends a Claude API request with a 60-second hard timeout enforced via a
/// <see cref="CancellationTokenSource"/>. 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 <see cref="OperationCanceledException"/>
/// and return a user-friendly error result.
/// </summary>
private static async Task<MessageResponse> SendAsync(AnthropicClient client, MessageParameters parameters)
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
return await client.Messages.GetClaudeMessageAsync(parameters, cts.Token);
}
/// <summary>
/// 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 <c>DocumentContent</c>
/// 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.
/// </summary>
private static async Task<string> 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 ───────────────────────────────────────────
/// <summary>
/// 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 <see cref="ScanWithRawHttpAsync"/> 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 <c>suggestedAccountId</c>
/// 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.
/// </summary>
public async Task<ReceiptScanResult> ScanReceiptAsync(
byte[] imageData,
string mimeType,
List<AccountSummary> 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<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase>
{
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<TextContent>().FirstOrDefault()?.Text
?? "";
}
if (string.IsNullOrWhiteSpace(rawText1))
return new ReceiptScanResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText1);
var parsed = JsonSerializer.Deserialize<ClaudeReceiptResponse>(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 ─────────────────────────────────
/// <summary>
/// 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 3160 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.
/// </summary>
public async Task<ArFollowUpResult> 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<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText2 = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText2))
return new ArFollowUpResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText2);
var parsed = JsonSerializer.Deserialize<ClaudeArEmailResponse>(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 ───────────────────────────────
/// <summary>
/// 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.
/// </summary>
public async Task<AccountSuggestionResult> 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<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt.ToString() } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText3 = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText3))
return new AccountSuggestionResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText3);
var parsed = JsonSerializer.Deserialize<ClaudeAccountSuggestionResponse>(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 ────────────────────────────
/// <summary>
/// Generates a short plain-English financial health summary (46 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.
/// </summary>
public async Task<FinancialSummaryResult> 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<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText4 = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText4))
return new FinancialSummaryResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText4);
var parsed = JsonSerializer.Deserialize<ClaudeFinancialSummaryResponse>(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<string>(),
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 ────────────────────────────────────────
/// <summary>
/// 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
/// 6090 day window because jobs nearing completion represent probable inflows that would
/// not appear in open AR yet. The <c>MaxTokens</c> 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.
/// </summary>
public async Task<CashFlowForecastResult> 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<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText))
return new CashFlowForecastResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText);
var parsed = JsonSerializer.Deserialize<ClaudeCashFlowResponse>(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 ─────────────────────────────
/// <summary>
/// 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 <c>flags</c> enumeration. Severity is normalized to lowercase after
/// parsing to guard against Claude casing inconsistencies (e.g., "Critical" vs "critical").
/// </summary>
public async Task<AnomalyDetectionResult> 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<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText))
return new AnomalyDetectionResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText);
var parsed = JsonSerializer.Deserialize<ClaudeAnomalyResponse>(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." };
}
}
}