using System.Net; using System.Text; using Books.Api.AiBookkeeper; using AwesomeAssertions; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Moq.Protected; namespace Books.Api.Tests.AiBookkeeper; /// /// Unit tests for AiBookkeeperClient. /// Tests JSON parsing and suggestion generation from extraction data. /// [Trait("Category", "Unit")] public class AiBookkeeperClientTests { [Fact] public async Task ProcessDocument_WithExtractionButNoSuggestion_GeneratesSuggestionFromExtraction() { // Arrange var json = """ { "success": true, "extraction": { "documentType": "invoice", "vendor": { "name": "Test Leverandør", "cvr": "12345678" }, "invoiceNumber": "INV-001", "date": "2024-01-15", "totalAmount": 1250.00, "amountExVat": 1000.00, "vatAmount": 250.00, "currency": "DKK" } } """; var client = CreateClientWithResponse(json); var chartOfAccounts = CreateChartOfAccounts(); // Act var result = await client.ProcessDocumentAsync( new MemoryStream([1, 2, 3]), "test.pdf", "application/pdf", chartOfAccounts); // Assert result.Success.Should().BeTrue(); result.Extraction.Should().NotBeNull(); result.Extraction!.Vendor.Should().Be("Test Leverandør"); result.Extraction.TotalAmount.Should().Be(1250.00m); result.Extraction.AmountExVat.Should().Be(1000.00m); result.Extraction.VatAmount.Should().Be(250.00m); // Suggestion should be generated result.Suggestion.Should().NotBeNull(); result.Suggestion!.Description.Should().Be("Test Leverandør"); result.Suggestion.Confidence.Should().Be(0.7m); result.Suggestion.Lines.Should().HaveCount(3); // Expense line (debit amountExVat) - Erhvervsstyrelsen standard 1610 var expenseLine = result.Suggestion.Lines[0]; expenseLine.StandardAccountNumber.Should().Be("1610"); expenseLine.DebitAmount.Should().Be(1000.00m); expenseLine.CreditAmount.Should().Be(0m); expenseLine.VatCode.Should().Be("I25"); // 25% VAT detected // VAT line (debit vatAmount) - Erhvervsstyrelsen standard 7680 var vatLine = result.Suggestion.Lines[1]; vatLine.StandardAccountNumber.Should().Be("7680"); vatLine.DebitAmount.Should().Be(250.00m); vatLine.CreditAmount.Should().Be(0m); // Creditor line (credit totalAmount) - Erhvervsstyrelsen standard 7350 var creditorLine = result.Suggestion.Lines[2]; creditorLine.StandardAccountNumber.Should().Be("7350"); creditorLine.DebitAmount.Should().Be(0m); creditorLine.CreditAmount.Should().Be(1250.00m); } [Fact] public async Task ProcessDocument_WithNestedTotals_ExtractsTotalsCorrectly() { // Arrange - AI service returns totals as nested object var json = """ { "success": true, "extraction": { "documentType": "invoice", "vendor": { "name": "Nested Totals Vendor" }, "totals": { "grandTotal": 625.00, "subtotal": 500.00, "vatTotal": 125.00 } } } """; var client = CreateClientWithResponse(json); // Act var result = await client.ProcessDocumentAsync( new MemoryStream([1, 2, 3]), "test.pdf", "application/pdf", CreateChartOfAccounts()); // Assert result.Extraction.Should().NotBeNull(); result.Extraction!.TotalAmount.Should().Be(625.00m); result.Extraction.AmountExVat.Should().Be(500.00m); result.Extraction.VatAmount.Should().Be(125.00m); // Suggestion should be generated with correct amounts result.Suggestion.Should().NotBeNull(); result.Suggestion!.Lines.Should().HaveCount(3); result.Suggestion.Lines[0].DebitAmount.Should().Be(500.00m); // Expense result.Suggestion.Lines[1].DebitAmount.Should().Be(125.00m); // VAT result.Suggestion.Lines[2].CreditAmount.Should().Be(625.00m); // Creditor } [Fact] public async Task ProcessDocument_WithoutVat_GeneratesTwoLineEntry() { // Arrange - No VAT in the extraction var json = """ { "success": true, "extraction": { "documentType": "receipt", "vendor": { "name": "No VAT Vendor" }, "totalAmount": 500.00, "amountExVat": 500.00, "vatAmount": 0 } } """; var client = CreateClientWithResponse(json); // Act var result = await client.ProcessDocumentAsync( new MemoryStream([1, 2, 3]), "test.pdf", "application/pdf", CreateChartOfAccounts()); // Assert - Should only have 2 lines (no VAT line) result.Suggestion.Should().NotBeNull(); result.Suggestion!.Lines.Should().HaveCount(2); // Expense line - Erhvervsstyrelsen standard 1610 result.Suggestion.Lines[0].StandardAccountNumber.Should().Be("1610"); result.Suggestion.Lines[0].DebitAmount.Should().Be(500.00m); result.Suggestion.Lines[0].VatCode.Should().BeNull(); // Creditor line - Erhvervsstyrelsen standard 7350 result.Suggestion.Lines[1].StandardAccountNumber.Should().Be("7350"); result.Suggestion.Lines[1].CreditAmount.Should().Be(500.00m); } [Fact] public async Task ProcessDocument_WithOnlyTotalAmount_UsesSameAmountForExpense() { // Arrange - Only total amount, no breakdown var json = """ { "success": true, "extraction": { "documentType": "receipt", "vendor": { "name": "Simple Receipt" }, "totalAmount": 299.00 } } """; var client = CreateClientWithResponse(json); // Act var result = await client.ProcessDocumentAsync( new MemoryStream([1, 2, 3]), "test.pdf", "application/pdf", CreateChartOfAccounts()); // Assert result.Suggestion.Should().NotBeNull(); result.Suggestion!.Lines.Should().HaveCount(2); // Expense line uses total (no VAT breakdown) result.Suggestion.Lines[0].DebitAmount.Should().Be(299.00m); // Creditor line result.Suggestion.Lines[1].CreditAmount.Should().Be(299.00m); } [Fact] public async Task ProcessDocument_WithNoTotalAmount_DoesNotGenerateSuggestion() { // Arrange - No amounts in extraction var json = """ { "success": true, "extraction": { "documentType": "unknown", "vendor": { "name": "Unknown Document" } } } """; var client = CreateClientWithResponse(json); // Act var result = await client.ProcessDocumentAsync( new MemoryStream([1, 2, 3]), "test.pdf", "application/pdf", CreateChartOfAccounts()); // Assert - No suggestion generated (can't book without amounts) result.Extraction.Should().NotBeNull(); result.Suggestion.Should().BeNull(); } [Fact] public async Task ProcessDocument_WithExistingSuggestion_UsesProvidedSuggestion() { // Arrange - AI provides its own suggestion var json = """ { "success": true, "extraction": { "documentType": "invoice", "vendor": { "name": "Test Vendor" }, "totalAmount": 1000.00 }, "suggestedBooking": { "description": "AI Suggested Description", "confidence": 0.95, "lines": [ { "standardAccountNumber": "7320", "accountName": "Software", "debit": 800.00, "credit": 0 }, { "standardAccountNumber": "6320", "accountName": "Moms", "debit": 200.00, "credit": 0 }, { "standardAccountNumber": "6930", "accountName": "Kreditor", "debit": 0, "credit": 1000.00 } ] } } """; var client = CreateClientWithResponse(json); // Act var result = await client.ProcessDocumentAsync( new MemoryStream([1, 2, 3]), "test.pdf", "application/pdf", CreateChartOfAccounts()); // Assert - Should use AI's suggestion, not generate our own result.Suggestion.Should().NotBeNull(); result.Suggestion!.Description.Should().Be("AI Suggested Description"); result.Suggestion.Confidence.Should().Be(0.95m); result.Suggestion.Lines[0].StandardAccountNumber.Should().Be("7320"); } [Fact] public async Task ProcessDocument_WithNoVendorName_UsesDefaultDescription() { // Arrange - No vendor name var json = """ { "success": true, "extraction": { "documentType": "receipt", "totalAmount": 150.00 } } """; var client = CreateClientWithResponse(json); // Act var result = await client.ProcessDocumentAsync( new MemoryStream([1, 2, 3]), "test.pdf", "application/pdf", CreateChartOfAccounts()); // Assert result.Suggestion.Should().NotBeNull(); result.Suggestion!.Description.Should().Be("Udgift"); } [Fact] public async Task ProcessDocument_GeneratedSuggestion_IsBalanced() { // Arrange var json = """ { "success": true, "extraction": { "documentType": "invoice", "vendor": { "name": "Balance Test" }, "totalAmount": 1875.50, "amountExVat": 1500.40, "vatAmount": 375.10 } } """; var client = CreateClientWithResponse(json); // Act var result = await client.ProcessDocumentAsync( new MemoryStream([1, 2, 3]), "test.pdf", "application/pdf", CreateChartOfAccounts()); // Assert - Total debits should equal total credits result.Suggestion.Should().NotBeNull(); var totalDebits = result.Suggestion!.Lines.Sum(l => l.DebitAmount); var totalCredits = result.Suggestion.Lines.Sum(l => l.CreditAmount); totalDebits.Should().Be(totalCredits); totalDebits.Should().Be(1875.50m); } private static AiBookkeeperClient CreateClientWithResponse(string jsonResponse) { var mockHandler = new Mock(); mockHandler .Protected() .Setup>( "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .ReturnsAsync(new HttpResponseMessage { StatusCode = HttpStatusCode.OK, Content = new StringContent(jsonResponse, Encoding.UTF8, "application/json") }); var httpClient = new HttpClient(mockHandler.Object) { BaseAddress = new Uri("http://localhost") }; return new AiBookkeeperClient(httpClient, NullLogger.Instance); } private static ChartOfAccountsDto CreateChartOfAccounts() { return new ChartOfAccountsDto { CompanyId = "test-company", Accounts = [ new AiAccountDto { AccountNumber = "1000", Name = "Vareforbrug", AccountType = "cogs", VatCodeId = "I25" }, new AiAccountDto { AccountNumber = "6320", Name = "Indgaaende moms", AccountType = "asset", VatCodeId = null }, new AiAccountDto { AccountNumber = "6930", Name = "Leverandoerer", AccountType = "liability", VatCodeId = null } ] }; } }