This commit includes all previously untracked backend files: Domain: - Accounts, Attachments, BankConnections, Customers - FiscalYears, Invoices, JournalEntryDrafts - Orders, Products, UserAccess Commands & Handlers: - Full CQRS command structure for all domains Repositories: - PostgreSQL repositories for all read models - Bank transaction and ledger repositories GraphQL: - Input types, scalars, and types for all entities - Mutations and queries Infrastructure: - Banking integration (Enable Banking client) - File storage, Invoicing, Reporting, SAF-T export - Database migrations (003-029) Tests: - Integration tests for GraphQL endpoints - Domain tests - Invoicing and reporting tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
340 lines
14 KiB
C#
340 lines
14 KiB
C#
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;
|
|
}
|
|
}
|