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:
2026-05-10 19:22:49 -04:00
parent e2f9e9ae4f
commit 959e323f3a
16 changed files with 1679 additions and 4 deletions
@@ -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 01 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 612 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 2832 days for 6 consecutive months is
/// high confidence; 23 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 2535 days
- quarterly: bills occurring every 80100 days
- biannual: bills occurring every 170195 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"": 23 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 612 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." };
}
}
}