Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,905 @@
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." };
}
}
}