Add 4 AI bookkeeping features
Feature 7: Bank Rec Auto-Match — AiSuggestMatches endpoint scores uncleared transactions vs statement ending balance; AI Auto-Match panel in Reconcile.cshtml with confidence highlights and Apply All button. Feature 8: Late Payment Prediction — PredictLatePayments endpoint scores open AR customers by risk (high/medium/low) using historical avg-days-to-pay + late rate; rendered as badge table in AR Aging view via ar-aging-ai.js. Feature 9: Natural Language Financial Queries — FinancialQuery GET page + RunFinancialQuery POST; 12-month context snapshot pre-loaded; answers grounded in real data with supporting facts, follow-up suggestions, session history, and example chips. Feature 10: Recurring Bill Detection — RunRecurringDetection scans 12 months of bills for vendor payment patterns (monthly/quarterly/annual); card grid view in Bills/RecurringDetection.cshtml with confidence badges, next-expected-date, and suggested actions. Supporting: 4 new DTO groups in AccountingAiDtos.cs, 4 method signatures in IAccountingAiService.cs, 4 implementations in AccountingAiService.cs, 4 new AiFeatures constants, 2 new Landing page AI report cards. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -902,4 +902,454 @@ Account Spend Trends (this month vs historical):
|
||||
return new AnomalyDetectionResult { Success = false, ErrorMessage = "An error occurred while running the analysis." };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feature 7: Bank Rec Auto-Match ────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Suggests which uncleared bank rec transactions to mark as cleared to close the gap
|
||||
/// between the current running balance and the statement ending balance. The items list
|
||||
/// includes both deposits and payments with their direction tag so Claude can reason about
|
||||
/// net effect. Confidence scores reflect how cleanly each item contributes to reaching the
|
||||
/// target ending balance — items that together sum close to the required difference score
|
||||
/// higher than items that alone overshoot. MaxTokens is 1024; the response is typically
|
||||
/// compact because we only need entity-type/id pairs plus a short reason per item.
|
||||
/// </summary>
|
||||
public async Task<AutoMatchResult> AutoMatchReconciliationAsync(AutoMatchRequest request)
|
||||
{
|
||||
var apiKey = GetApiKey();
|
||||
if (apiKey == null)
|
||||
return new AutoMatchResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
|
||||
|
||||
try
|
||||
{
|
||||
var systemPrompt = @"You are a bank reconciliation assistant for a powder coating business.
|
||||
Given a list of uncleared transactions and a target statement ending balance, suggest which transactions
|
||||
to mark as cleared so that: Beginning Balance + cleared deposits - cleared payments = Statement Ending Balance.
|
||||
|
||||
Respond ONLY with a valid JSON object — no markdown, no explanation.
|
||||
|
||||
Schema:
|
||||
{
|
||||
""suggestedCleared"": [
|
||||
{
|
||||
""entityType"": ""Payment"" | ""BillPayment"" | ""Expense"",
|
||||
""entityId"": number,
|
||||
""confidence"": number (0.0 to 1.0),
|
||||
""reason"": ""string — one sentence why this item should be cleared""
|
||||
}
|
||||
],
|
||||
""insights"": [""string"", ...]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Select the combination of items whose net effect (deposits minus payments) gets closest to the difference needed
|
||||
- Difference needed = statementEndingBalance - beginningBalance
|
||||
- confidence 0.9-1.0: item clearly belongs in this period (date and amount both fit)
|
||||
- confidence 0.6-0.89: likely but not certain
|
||||
- confidence below 0.6: possible but uncertain — include only if needed to close the gap
|
||||
- insights: 2-4 observations about patterns or items that need manual review
|
||||
- Do NOT suggest clearing items you are uncertain about just to force a zero balance";
|
||||
|
||||
var itemsJson = JsonSerializer.Serialize(request.UnclearedItems);
|
||||
var needed = request.StatementEndingBalance - request.BeginningBalance;
|
||||
|
||||
var userPrompt = $@"Suggest which transactions to clear for this bank reconciliation.
|
||||
|
||||
Beginning Balance: {request.BeginningBalance:F2}
|
||||
Statement Ending Balance: {request.StatementEndingBalance:F2}
|
||||
Difference needed (deposits - payments): {needed:F2}
|
||||
|
||||
Uncleared transactions:
|
||||
{itemsJson}";
|
||||
|
||||
var client = new AnthropicClient(apiKey);
|
||||
var messageParams = new MessageParameters
|
||||
{
|
||||
Model = Model,
|
||||
MaxTokens = 1024,
|
||||
SystemMessage = systemPrompt,
|
||||
Messages = new List<Message>
|
||||
{
|
||||
new Message
|
||||
{
|
||||
Role = RoleType.User,
|
||||
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await SendAsync(client, messageParams);
|
||||
var rawText = response.FirstMessage?.Text
|
||||
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||||
?? "";
|
||||
if (string.IsNullOrWhiteSpace(rawText))
|
||||
return new AutoMatchResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||||
|
||||
var raw = StripJsonFences(rawText);
|
||||
var parsed = JsonSerializer.Deserialize<ClaudeAutoMatchResponse>(raw, JsonOpts);
|
||||
if (parsed == null)
|
||||
return new AutoMatchResult { Success = false, ErrorMessage = "Could not parse AI response." };
|
||||
|
||||
return new AutoMatchResult
|
||||
{
|
||||
Success = true,
|
||||
SuggestedCleared = (parsed.SuggestedCleared ?? new()).Select(s => new AutoMatchSuggestion
|
||||
{
|
||||
EntityType = s.EntityType,
|
||||
EntityId = s.EntityId,
|
||||
Confidence = s.Confidence,
|
||||
Reason = s.Reason
|
||||
}).ToList(),
|
||||
Insights = parsed.Insights ?? new()
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Claude AI bank rec auto-match timed out after 60 seconds");
|
||||
return new AutoMatchResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error running bank rec auto-match with AI");
|
||||
return new AutoMatchResult { Success = false, ErrorMessage = "An error occurred while running auto-match." };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feature 8: Late Payment Prediction ────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Predicts payment risk per open AR customer by combining current overdue status with
|
||||
/// historical behavior metrics (avg days to pay, late rate). The late rate is pre-calculated
|
||||
/// as LateInvoicesAllTime / TotalInvoicesAllTime so Claude receives a 0–1 ratio rather than
|
||||
/// raw counts, which produces more consistent confidence scoring across customers with very
|
||||
/// different invoice volumes. Risk levels are validated against the three allowed values and
|
||||
/// default to "medium" when Claude returns anything outside the expected set.
|
||||
/// </summary>
|
||||
public async Task<LatePaymentPredictionResult> PredictLatePaymentsAsync(LatePaymentPredictionRequest request)
|
||||
{
|
||||
var apiKey = GetApiKey();
|
||||
if (apiKey == null)
|
||||
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
|
||||
|
||||
try
|
||||
{
|
||||
var systemPrompt = @"You are an accounts receivable risk analyst for a powder coating business.
|
||||
Given open AR data and each customer's historical payment behavior, predict payment risk for each customer.
|
||||
Respond ONLY with a valid JSON object — no markdown, no explanation.
|
||||
|
||||
Schema:
|
||||
{
|
||||
""predictions"": [
|
||||
{
|
||||
""customerName"": ""string"",
|
||||
""riskLevel"": ""high"" | ""medium"" | ""low"",
|
||||
""estimatedDaysToPayment"": number,
|
||||
""reasoning"": ""string — one sentence explaining the prediction""
|
||||
}
|
||||
],
|
||||
""insights"": [""string"", ...]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- riskLevel ""high"": customer has a history of late payment AND is already overdue, or has a very high late rate
|
||||
- riskLevel ""medium"": customer is overdue but has reasonable historical performance, or is current but has a spotty history
|
||||
- riskLevel ""low"": customer typically pays on time and is not severely overdue
|
||||
- estimatedDaysToPayment: realistic estimate of additional days until payment, based on history and overdue status
|
||||
- insights: 2-4 portfolio-level observations (e.g. which customers need immediate follow-up)
|
||||
- Only include predictions for customers with open invoices";
|
||||
|
||||
var customersJson = JsonSerializer.Serialize(request.Customers.Select(c => new
|
||||
{
|
||||
c.CustomerName,
|
||||
c.TotalOwed,
|
||||
c.AvgDaysToPay,
|
||||
LatePaymentRate = c.TotalInvoicesAllTime > 0
|
||||
? Math.Round((double)c.LateInvoicesAllTime / c.TotalInvoicesAllTime, 2)
|
||||
: 0,
|
||||
c.OpenInvoices
|
||||
}));
|
||||
|
||||
var userPrompt = $@"Predict payment risk for open AR customers of {request.CompanyName}.
|
||||
|
||||
Customer data (includes historical payment behavior):
|
||||
{customersJson}";
|
||||
|
||||
var client = new AnthropicClient(apiKey);
|
||||
var messageParams = new MessageParameters
|
||||
{
|
||||
Model = Model,
|
||||
MaxTokens = 1024,
|
||||
SystemMessage = systemPrompt,
|
||||
Messages = new List<Message>
|
||||
{
|
||||
new Message
|
||||
{
|
||||
Role = RoleType.User,
|
||||
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await SendAsync(client, messageParams);
|
||||
var rawText = response.FirstMessage?.Text
|
||||
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||||
?? "";
|
||||
if (string.IsNullOrWhiteSpace(rawText))
|
||||
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||||
|
||||
var raw = StripJsonFences(rawText);
|
||||
var parsed = JsonSerializer.Deserialize<ClaudeLatePaymentResponse>(raw, JsonOpts);
|
||||
if (parsed == null)
|
||||
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Could not parse AI response." };
|
||||
|
||||
var validRiskLevels = new[] { "high", "medium", "low" };
|
||||
var predictions = (parsed.Predictions ?? new()).Select(p => new LatePaymentPrediction
|
||||
{
|
||||
CustomerName = p.CustomerName,
|
||||
RiskLevel = validRiskLevels.Contains(p.RiskLevel?.ToLowerInvariant()) ? p.RiskLevel!.ToLowerInvariant() : "medium",
|
||||
EstimatedDaysToPayment = p.EstimatedDaysToPayment,
|
||||
Reasoning = p.Reasoning
|
||||
}).ToList();
|
||||
|
||||
return new LatePaymentPredictionResult
|
||||
{
|
||||
Success = true,
|
||||
Predictions = predictions,
|
||||
Insights = parsed.Insights ?? new()
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Claude AI late payment prediction timed out after 60 seconds");
|
||||
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error predicting late payments with AI");
|
||||
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "An error occurred while predicting payment risk." };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feature 9: Natural Language Financial Queries ─────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Answers a free-text financial question using a pre-loaded snapshot of the company's
|
||||
/// financial data. The context object is serialized to JSON and embedded in the user prompt
|
||||
/// so Claude has concrete numbers to reason over rather than fabricating estimates. The
|
||||
/// system prompt explicitly constrains Claude to the data provided and forbids it from
|
||||
/// making up figures outside the snapshot — this prevents hallucination of specific dollar
|
||||
/// amounts. RelevantFacts is a list of supporting data points Claude pulled from the context
|
||||
/// to justify the answer, displayed below the answer in the UI so users can verify.
|
||||
/// MaxTokens is raised to 1500 to accommodate answers with multiple supporting facts.
|
||||
/// </summary>
|
||||
public async Task<FinancialQueryResult> AnswerFinancialQueryAsync(FinancialQueryRequest request)
|
||||
{
|
||||
var apiKey = GetApiKey();
|
||||
if (apiKey == null)
|
||||
return new FinancialQueryResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
|
||||
|
||||
try
|
||||
{
|
||||
var systemPrompt = @"You are a financial analyst assistant for a powder coating business.
|
||||
Answer plain-English financial questions using ONLY the data provided in the context.
|
||||
Respond ONLY with a valid JSON object — no markdown, no explanation.
|
||||
|
||||
Schema:
|
||||
{
|
||||
""answer"": ""string — direct, plain-English answer to the question"",
|
||||
""followUpSuggestion"": ""string — one optional follow-up question the user might want to ask next, or null"",
|
||||
""relevantFacts"": [""string"", ...]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- answer: be direct and specific with dollar amounts and percentages from the data
|
||||
- If the data does not contain enough information to answer the question, say so clearly in the answer
|
||||
- Do NOT invent or estimate figures that are not in the provided data
|
||||
- relevantFacts: 2-5 specific data points from the context that support the answer (formatted as ""Label: $X"" or ""Label: X%"")
|
||||
- followUpSuggestion: suggest the natural next question the user would want to ask, or null if not obvious
|
||||
- Keep the answer under 100 words — be concise";
|
||||
|
||||
var contextJson = JsonSerializer.Serialize(request.Context);
|
||||
var userPrompt = $@"Question: {request.Question}
|
||||
|
||||
Financial context:
|
||||
{contextJson}";
|
||||
|
||||
var client = new AnthropicClient(apiKey);
|
||||
var messageParams = new MessageParameters
|
||||
{
|
||||
Model = Model,
|
||||
MaxTokens = 1500,
|
||||
SystemMessage = systemPrompt,
|
||||
Messages = new List<Message>
|
||||
{
|
||||
new Message
|
||||
{
|
||||
Role = RoleType.User,
|
||||
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await SendAsync(client, messageParams);
|
||||
var rawText = response.FirstMessage?.Text
|
||||
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||||
?? "";
|
||||
if (string.IsNullOrWhiteSpace(rawText))
|
||||
return new FinancialQueryResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||||
|
||||
var raw = StripJsonFences(rawText);
|
||||
var parsed = JsonSerializer.Deserialize<ClaudeFinancialQueryResponse>(raw, JsonOpts);
|
||||
if (parsed == null)
|
||||
return new FinancialQueryResult { Success = false, ErrorMessage = "Could not parse AI response." };
|
||||
|
||||
return new FinancialQueryResult
|
||||
{
|
||||
Success = true,
|
||||
Answer = parsed.Answer ?? string.Empty,
|
||||
FollowUpSuggestion = parsed.FollowUpSuggestion,
|
||||
RelevantFacts = parsed.RelevantFacts ?? new()
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Claude AI financial query timed out after 60 seconds");
|
||||
return new FinancialQueryResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error answering financial query with AI");
|
||||
return new FinancialQueryResult { Success = false, ErrorMessage = "An error occurred while answering your question." };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feature 10: Recurring Bill Detection ──────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes 6–12 months of historical bills to detect recurring payment patterns per vendor.
|
||||
/// Bills are grouped by vendor in the prompt so Claude can see the full chronological series
|
||||
/// for each vendor at a glance. The confidence field ("high"/"medium"/"low") reflects how
|
||||
/// regular the cadence is — a bill appearing every 28–32 days for 6 consecutive months is
|
||||
/// high confidence; 2–3 occurrences at similar amounts is medium. NextExpectedDateIso is
|
||||
/// calculated by Claude from the pattern's most recent date plus the detected period length.
|
||||
/// MaxTokens is 1500 to accommodate multi-vendor response objects with multiple patterns.
|
||||
/// </summary>
|
||||
public async Task<RecurringBillDetectionResult> DetectRecurringBillsAsync(RecurringBillDetectionRequest request)
|
||||
{
|
||||
var apiKey = GetApiKey();
|
||||
if (apiKey == null)
|
||||
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
|
||||
|
||||
try
|
||||
{
|
||||
var systemPrompt = @"You are a recurring expense analyst for a powder coating business.
|
||||
Analyze the provided bill history to detect recurring payment patterns per vendor.
|
||||
Respond ONLY with a valid JSON object — no markdown, no explanation.
|
||||
|
||||
Schema:
|
||||
{
|
||||
""patterns"": [
|
||||
{
|
||||
""vendorName"": ""string"",
|
||||
""frequency"": ""monthly"" | ""quarterly"" | ""biannual"" | ""annual"" | ""irregular"",
|
||||
""typicalAmount"": number,
|
||||
""nextExpectedDateIso"": ""YYYY-MM-DD or null"",
|
||||
""confidence"": ""high"" | ""medium"" | ""low"",
|
||||
""description"": ""string — one sentence describing the pattern"",
|
||||
""suggestedAction"": ""string — one specific action to take, or null""
|
||||
}
|
||||
],
|
||||
""insights"": [""string"", ...]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Only report patterns with at least 2 occurrences
|
||||
- monthly: bills occurring every 25–35 days
|
||||
- quarterly: bills occurring every 80–100 days
|
||||
- biannual: bills occurring every 170–195 days
|
||||
- annual: bills occurring roughly once per year
|
||||
- irregular: a vendor bills regularly but the cadence is inconsistent
|
||||
- confidence ""high"": 4+ occurrences with consistent timing (within ±5 days of the period)
|
||||
- confidence ""medium"": 2–3 occurrences with consistent timing, or 4+ with variable timing
|
||||
- confidence ""low"": pattern is weak but worth monitoring
|
||||
- nextExpectedDateIso: estimate based on the last bill date + the detected period; null if irregular or low confidence
|
||||
- suggestedAction: e.g. ""Set a monthly reminder for this bill"" or ""Create a recurring bill template"" or null
|
||||
- insights: 2-4 portfolio-level observations about the company's recurring expense profile
|
||||
- If no recurring patterns are found, return an empty patterns array";
|
||||
|
||||
// Group bills by vendor for clarity in the prompt
|
||||
var grouped = request.Bills
|
||||
.GroupBy(b => b.VendorName)
|
||||
.Select(g => new
|
||||
{
|
||||
VendorName = g.Key,
|
||||
Bills = g.OrderBy(b => b.DateIso).Select(b => new { b.DateIso, b.Amount, b.BillNumber, b.Memo })
|
||||
});
|
||||
|
||||
var billsJson = JsonSerializer.Serialize(grouped);
|
||||
|
||||
var userPrompt = $@"Detect recurring bill patterns for {request.CompanyName}.
|
||||
Data covers the last 6–12 months of bills, grouped by vendor.
|
||||
|
||||
Bill history by vendor:
|
||||
{billsJson}";
|
||||
|
||||
var client = new AnthropicClient(apiKey);
|
||||
var messageParams = new MessageParameters
|
||||
{
|
||||
Model = Model,
|
||||
MaxTokens = 1500,
|
||||
SystemMessage = systemPrompt,
|
||||
Messages = new List<Message>
|
||||
{
|
||||
new Message
|
||||
{
|
||||
Role = RoleType.User,
|
||||
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await SendAsync(client, messageParams);
|
||||
var rawText = response.FirstMessage?.Text
|
||||
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||||
?? "";
|
||||
if (string.IsNullOrWhiteSpace(rawText))
|
||||
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||||
|
||||
var raw = StripJsonFences(rawText);
|
||||
var parsed = JsonSerializer.Deserialize<ClaudeRecurringBillResponse>(raw, JsonOpts);
|
||||
if (parsed == null)
|
||||
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Could not parse AI response." };
|
||||
|
||||
var validConfidence = new[] { "high", "medium", "low" };
|
||||
var validFrequency = new[] { "monthly", "quarterly", "biannual", "annual", "irregular" };
|
||||
|
||||
return new RecurringBillDetectionResult
|
||||
{
|
||||
Success = true,
|
||||
Patterns = (parsed.Patterns ?? new()).Select(p => new RecurringBillPattern
|
||||
{
|
||||
VendorName = p.VendorName,
|
||||
Frequency = validFrequency.Contains(p.Frequency?.ToLowerInvariant()) ? p.Frequency!.ToLowerInvariant() : "irregular",
|
||||
TypicalAmount = p.TypicalAmount,
|
||||
NextExpectedDateIso = p.NextExpectedDateIso,
|
||||
Confidence = validConfidence.Contains(p.Confidence?.ToLowerInvariant()) ? p.Confidence!.ToLowerInvariant() : "medium",
|
||||
Description = p.Description,
|
||||
SuggestedAction = p.SuggestedAction
|
||||
}).ToList(),
|
||||
Insights = parsed.Insights ?? new()
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Claude AI recurring bill detection timed out after 60 seconds");
|
||||
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error detecting recurring bills with AI");
|
||||
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "An error occurred while analyzing bill patterns." };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user