books/backend/Books.Api/AiBookkeeper/AiBookkeeperClient.cs

341 lines
14 KiB
C#
Raw Normal View History

using System.Net.Http.Headers;
using System.Text.Json;
namespace Books.Api.AiBookkeeper;
/// <summary>
/// HTTP client for the AI Bookkeeper service.
/// </summary>
public class AiBookkeeperClient(HttpClient httpClient, ILogger<AiBookkeeperClient> logger) : IAiBookkeeperClient
{
public async Task<AiBookkeeperResponse> ProcessDocumentAsync(
Stream document,
string fileName,
string contentType,
ChartOfAccountsDto chartOfAccounts,
CancellationToken cancellationToken = default)
{
try
{
using var content = new MultipartFormDataContent();
// Add the document file
var documentContent = new StreamContent(document);
documentContent.Headers.ContentType = new MediaTypeHeaderValue(contentType);
content.Add(documentContent, "Documents", fileName);
// Convert chart of accounts to .toon format and add as file
var toonContent = ToonFormatConverter.ConvertToToon(chartOfAccounts);
if (!string.IsNullOrWhiteSpace(toonContent))
{
var accountsBytes = System.Text.Encoding.UTF8.GetBytes(toonContent);
var accountsStream = new MemoryStream(accountsBytes);
var accountsContent = new StreamContent(accountsStream);
accountsContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
content.Add(accountsContent, "AccountsFile", "accounts.toon");
}
logger.LogInformation(
"Sending document {FileName} ({ContentType}) to AI Bookkeeper",
fileName, contentType);
var response = await httpClient.PostAsync("/api/v1/bookkeeping/process", content, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning(
"AI Bookkeeper returned error {StatusCode}: {Body}",
response.StatusCode, responseBody);
return new AiBookkeeperResponse
{
Success = false,
ErrorMessage = $"AI service returned {response.StatusCode}: {responseBody}"
};
}
logger.LogDebug("AI Bookkeeper response: {Response}", responseBody);
return ParseResponse(responseBody);
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error calling AI Bookkeeper service");
return new AiBookkeeperResponse
{
Success = false,
ErrorMessage = "AI-tjenesten er midlertidigt utilgængelig"
};
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
logger.LogError(ex, "Timeout calling AI Bookkeeper service");
return new AiBookkeeperResponse
{
Success = false,
ErrorMessage = "AI-tjenesten svarede ikke i tide"
};
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected error calling AI Bookkeeper service");
return new AiBookkeeperResponse
{
Success = false,
ErrorMessage = "Der opstod en uventet fejl ved dokumentanalyse"
};
}
}
private AiBookkeeperResponse ParseResponse(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var result = new AiBookkeeperResponse
{
Success = root.TryGetProperty("success", out var successProp) && successProp.GetBoolean(),
ErrorMessage = GetStringOrNull(root, "errorMessage")
};
// Parse extraction
if (root.TryGetProperty("extraction", out var extraction) && extraction.ValueKind == JsonValueKind.Object)
{
result.Extraction = new DocumentExtraction
{
DocumentType = GetStringOrNull(extraction, "documentType"),
Vendor = GetNestedStringOrDirect(extraction, "vendor", "name"),
VendorCvr = GetNestedStringOrDirect(extraction, "vendor", "cvr"),
InvoiceNumber = GetStringOrNull(extraction, "invoiceNumber"),
Date = ParseDateOnly(GetStringOrNull(extraction, "date") ?? GetStringOrNull(extraction, "invoiceDate")),
DueDate = ParseDateOnly(GetStringOrNull(extraction, "dueDate")),
TotalAmount = GetDecimalOrNull(extraction, "totalAmount") ?? GetDecimalOrNull(extraction, "total"),
AmountExVat = GetDecimalOrNull(extraction, "amountExVat") ?? GetDecimalOrNull(extraction, "subtotal"),
VatAmount = GetDecimalOrNull(extraction, "vatAmount") ?? GetDecimalOrNull(extraction, "vat"),
Currency = GetStringOrNull(extraction, "currency") ?? "DKK",
PaymentReference = GetStringOrNull(extraction, "paymentReference"),
RawText = GetStringOrNull(extraction, "rawText")
};
// Handle nested totals object (AI service may return totals as nested object)
if (extraction.TryGetProperty("totals", out var totals) && totals.ValueKind == JsonValueKind.Object)
{
result.Extraction.TotalAmount ??= GetDecimalOrNull(totals, "grandTotal");
result.Extraction.AmountExVat ??= GetDecimalOrNull(totals, "subtotal");
result.Extraction.VatAmount ??= GetDecimalOrNull(totals, "vatTotal");
}
}
// Parse suggested booking (full journal lines) or account suggestion (single account)
if (root.TryGetProperty("suggestedBooking", out var booking) && booking.ValueKind == JsonValueKind.Object)
{
result.Suggestion = ParseBookingSuggestion(booking);
}
// Parse AI's single account suggestion (the smart recommendation)
AiAccountSuggestion? aiAccountSuggestion = null;
if (root.TryGetProperty("accountSuggestion", out var accountSuggestion) && accountSuggestion.ValueKind == JsonValueKind.Object)
{
aiAccountSuggestion = ParseAiAccountSuggestion(accountSuggestion);
}
// Generate journal entry lines from extraction data, using AI's account suggestion if available
if ((result.Suggestion == null || result.Suggestion.Lines.Count == 0) && result.Extraction != null)
{
result.Suggestion = GenerateSuggestionFromExtraction(result.Extraction, aiAccountSuggestion);
}
return result;
}
/// <summary>
/// Parses the AI service's single account suggestion.
/// </summary>
private static AiAccountSuggestion? ParseAiAccountSuggestion(JsonElement element)
{
var accountNumber = GetStringOrNull(element, "accountNumber");
if (string.IsNullOrEmpty(accountNumber))
return null;
return new AiAccountSuggestion
{
AccountNumber = accountNumber,
AccountName = GetStringOrNull(element, "accountName") ?? accountNumber,
VatCode = GetStringOrNull(element, "vatCode"),
Confidence = GetDecimalOrNull(element, "confidence") ?? 0.5m,
Reasoning = GetStringOrNull(element, "reasoning")
};
}
/// <summary>
/// Generates a booking suggestion from extraction data.
/// Uses AI's account suggestion for the expense line if available, otherwise falls back to generic Vareforbrug.
/// Creates standard Danish bookkeeping entries for expense documents.
/// </summary>
private static BookkeepingSuggestion? GenerateSuggestionFromExtraction(
DocumentExtraction extraction,
AiAccountSuggestion? aiSuggestion = null)
{
// Need at least a total amount to generate suggestion
if (!extraction.TotalAmount.HasValue || extraction.TotalAmount.Value <= 0)
return null;
// Use AI's confidence if available, otherwise lower confidence for fallback
var overallConfidence = aiSuggestion?.Confidence ?? 0.7m;
var suggestion = new BookkeepingSuggestion
{
Description = extraction.Vendor ?? "Udgift",
Confidence = overallConfidence,
Lines = []
};
var totalAmount = extraction.TotalAmount.Value;
var amountExVat = extraction.AmountExVat ?? totalAmount;
var vatAmount = extraction.VatAmount ?? 0;
// Determine VAT code: use AI suggestion's VAT code, or calculate from amounts
string? vatCode = aiSuggestion?.VatCode;
if (vatCode == null && vatAmount > 0 && amountExVat > 0)
{
var vatRate = vatAmount / amountExVat;
vatCode = vatRate >= 0.24m ? "I25" : null; // 25% indgaaende moms
}
// Expense line (debit) - Use AI's account suggestion if available
// AI suggests specific account (e.g., 6080 Parkering, 7240 Telefoni)
// Otherwise fall back to Erhvervsstyrelsen standard 1610 = Varekøb (maps to account 2000)
var expenseAccountNumber = aiSuggestion?.AccountNumber ?? "1610";
var expenseAccountName = aiSuggestion?.AccountName ?? "Vareforbrug";
var expenseConfidence = aiSuggestion?.Confidence ?? 0.6m;
suggestion.Lines.Add(new SuggestedLine
{
StandardAccountNumber = expenseAccountNumber,
AccountName = expenseAccountName,
DebitAmount = amountExVat,
CreditAmount = 0,
VatCode = vatCode,
Confidence = expenseConfidence
});
// VAT line (debit to reduce liability / credit if balance is positive)
// Erhvervsstyrelsen standard 7680 = Anden gæld til SKAT (maps to company account 7900 Skyldig moms)
// For input VAT (indgående moms), we debit the moms account
if (vatAmount > 0)
{
suggestion.Lines.Add(new SuggestedLine
{
StandardAccountNumber = "7680",
AccountName = "Skyldig moms",
DebitAmount = vatAmount,
CreditAmount = 0,
VatCode = null,
Confidence = 0.9m
});
}
// Creditor line (credit)
// Erhvervsstyrelsen standard 7350 = Leverandører (maps to company account 6900)
suggestion.Lines.Add(new SuggestedLine
{
StandardAccountNumber = "7350",
AccountName = "Kreditorer",
DebitAmount = 0,
CreditAmount = totalAmount,
VatCode = null,
Confidence = 0.8m
});
return suggestion;
}
private static BookkeepingSuggestion ParseBookingSuggestion(JsonElement element)
{
var suggestion = new BookkeepingSuggestion
{
Description = GetStringOrNull(element, "description"),
Confidence = GetDecimalOrNull(element, "confidence") ?? 0
};
// Parse lines from various possible properties
JsonElement? linesElement = null;
if (element.TryGetProperty("lines", out var lines))
linesElement = lines;
else if (element.TryGetProperty("entries", out var entries))
linesElement = entries;
if (linesElement.HasValue && linesElement.Value.ValueKind == JsonValueKind.Array)
{
foreach (var line in linesElement.Value.EnumerateArray())
{
suggestion.Lines.Add(new SuggestedLine
{
StandardAccountNumber = GetStringOrNull(line, "standardAccountNumber") ?? GetStringOrNull(line, "accountNumber"),
AccountName = GetStringOrNull(line, "accountName") ?? GetStringOrNull(line, "account"),
DebitAmount = GetDecimalOrNull(line, "debit") ?? GetDecimalOrNull(line, "debitAmount") ?? 0,
CreditAmount = GetDecimalOrNull(line, "credit") ?? GetDecimalOrNull(line, "creditAmount") ?? 0,
VatCode = GetStringOrNull(line, "vatCode"),
Confidence = GetDecimalOrNull(line, "confidence") ?? 0
});
}
}
return suggestion;
}
private static string? GetStringOrNull(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var prop))
{
return prop.ValueKind switch
{
JsonValueKind.String => prop.GetString(),
JsonValueKind.Number => prop.GetRawText(),
JsonValueKind.Null => null,
_ => prop.GetRawText()
};
}
return null;
}
private static string? GetNestedStringOrDirect(JsonElement element, string propertyName, string nestedPropertyName)
{
if (element.TryGetProperty(propertyName, out var prop))
{
if (prop.ValueKind == JsonValueKind.String)
return prop.GetString();
if (prop.ValueKind == JsonValueKind.Object && prop.TryGetProperty(nestedPropertyName, out var nested))
return nested.ValueKind == JsonValueKind.String ? nested.GetString() : null;
}
return null;
}
private static decimal? GetDecimalOrNull(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.Number)
{
return prop.GetDecimal();
}
return null;
}
private static DateOnly? ParseDateOnly(string? dateStr)
{
if (string.IsNullOrEmpty(dateStr))
return null;
// Try various formats
if (DateOnly.TryParse(dateStr, out var date))
return date;
// Try parsing just the date part if it contains time
if (DateTime.TryParse(dateStr, out var dateTime))
return DateOnly.FromDateTime(dateTime);
return null;
}
}