using System.Net.Http.Headers; using System.Text.Json; namespace Books.Api.AiBookkeeper; /// /// HTTP client for the AI Bookkeeper service. /// public class AiBookkeeperClient(HttpClient httpClient, ILogger logger) : IAiBookkeeperClient { public async Task 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; } /// /// Parses the AI service's single account suggestion. /// 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") }; } /// /// 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. /// 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; } }