diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl
index 6b8f3ea..458fd2e 100644
--- a/.beads/issues.jsonl
+++ b/.beads/issues.jsonl
@@ -1,3 +1,4 @@
+{"id":"books-0rs","title":"fix whitescreen at http://localhost:3000","status":"in_progress","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T22:15:47.598939+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T22:16:09.572428+01:00"}
{"id":"books-1rp","title":"http://localhost:3000/kunder","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:30:29.369137+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:47:52.484243+01:00","closed_at":"2026-01-30T14:47:52.484243+01:00","close_reason":"Closed"}
{"id":"books-5tg","title":"opret et backend job med hangfir\ne som sikrer, at bankkonto altid stemmer med den pågældende konto","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:24:01.505911+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:37:08.293897+01:00","closed_at":"2026-01-30T14:37:08.293897+01:00","close_reason":"Closed"}
{"id":"books-8ea","title":"fjern brugers navn fra højre hjørne ved profile ikonet","status":"closed","priority":2,"issue_type":"task","owner":"nhh@softwarehuset.com","created_at":"2026-01-30T14:20:16.406033+01:00","created_by":"Nicolaj Hartmann","updated_at":"2026-01-30T14:22:59.64468+01:00","closed_at":"2026-01-30T14:22:59.64468+01:00","close_reason":"Closed"}
diff --git a/backend/Books.Api.Tests/AiBookkeeper/AiBookkeeperClientTests.cs b/backend/Books.Api.Tests/AiBookkeeper/AiBookkeeperClientTests.cs
new file mode 100644
index 0000000..9b3d77e
--- /dev/null
+++ b/backend/Books.Api.Tests/AiBookkeeper/AiBookkeeperClientTests.cs
@@ -0,0 +1,365 @@
+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 }
+ ]
+ };
+ }
+}
diff --git a/backend/Books.Api.Tests/AiBookkeeper/ChartOfAccountsProviderTests.cs b/backend/Books.Api.Tests/AiBookkeeper/ChartOfAccountsProviderTests.cs
new file mode 100644
index 0000000..3ee8b15
--- /dev/null
+++ b/backend/Books.Api.Tests/AiBookkeeper/ChartOfAccountsProviderTests.cs
@@ -0,0 +1,220 @@
+using Books.Api.AiBookkeeper;
+using Books.Api.EventFlow.ReadModels;
+using Books.Api.EventFlow.Repositories;
+using Moq;
+using AwesomeAssertions;
+
+namespace Books.Api.Tests.AiBookkeeper;
+
+///
+/// Unit tests for ChartOfAccountsProvider.
+/// Verifies that accounts are filtered and sorted correctly.
+///
+[Trait("Category", "Unit")]
+public class ChartOfAccountsProviderTests
+{
+ private readonly Mock _accountRepository;
+ private readonly ChartOfAccountsProvider _sut;
+
+ public ChartOfAccountsProviderTests()
+ {
+ _accountRepository = new Mock();
+ _sut = new ChartOfAccountsProvider(_accountRepository.Object);
+ }
+
+ [Fact]
+ public async Task GetChartOfAccountsAsync_ReturnsOnlyExpenseTypeAccounts()
+ {
+ // Arrange
+ var accounts = new List
+ {
+ CreateAccount("1000", "Bank", "asset", null),
+ CreateAccount("2000", "Leverandørgæld", "liability", null),
+ CreateAccount("3000", "Egenkapital", "equity", null),
+ CreateAccount("4000", "Omsætning", "income", null),
+ CreateAccount("5000", "Vareforbrug", "cogs", "I25"),
+ CreateAccount("6000", "Løn", "personnel", "INGEN"),
+ CreateAccount("7000", "Kontorudgifter", "expense", "I25"),
+ CreateAccount("8000", "Renteudgifter", "financial", null)
+ };
+ _accountRepository.Setup(r => r.GetActiveByCompanyIdAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(accounts);
+
+ // Act
+ var result = await _sut.GetChartOfAccountsAsync("company-1");
+
+ // Assert - Only expense-type accounts should be included
+ result.Accounts.Should().HaveCount(4);
+ result.Accounts.Select(a => a.AccountType).Should().OnlyContain(t =>
+ t == "cogs" || t == "personnel" || t == "expense" || t == "financial");
+ }
+
+ [Fact]
+ public async Task GetChartOfAccountsAsync_ExcludesAssetAccounts()
+ {
+ // Arrange
+ var accounts = new List
+ {
+ CreateAccount("1000", "Bank", "asset", null),
+ CreateAccount("1200", "Varelager", "asset", null),
+ CreateAccount("7000", "Kontorudgifter", "expense", "I25")
+ };
+ _accountRepository.Setup(r => r.GetActiveByCompanyIdAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(accounts);
+
+ // Act
+ var result = await _sut.GetChartOfAccountsAsync("company-1");
+
+ // Assert
+ result.Accounts.Should().HaveCount(1);
+ result.Accounts.Should().NotContain(a => a.AccountType == "asset");
+ }
+
+ [Fact]
+ public async Task GetChartOfAccountsAsync_ExcludesLiabilityAccounts()
+ {
+ // Arrange
+ var accounts = new List
+ {
+ CreateAccount("2000", "Leverandørgæld", "liability", null),
+ CreateAccount("2100", "Skyldig moms", "liability", null),
+ CreateAccount("7000", "Kontorudgifter", "expense", "I25")
+ };
+ _accountRepository.Setup(r => r.GetActiveByCompanyIdAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(accounts);
+
+ // Act
+ var result = await _sut.GetChartOfAccountsAsync("company-1");
+
+ // Assert
+ result.Accounts.Should().HaveCount(1);
+ result.Accounts.Should().NotContain(a => a.AccountType == "liability");
+ }
+
+ [Fact]
+ public async Task GetChartOfAccountsAsync_ExcludesIncomeAccounts()
+ {
+ // Arrange
+ var accounts = new List
+ {
+ CreateAccount("4000", "Omsætning DK", "income", "U25"),
+ CreateAccount("4100", "Omsætning EU", "income", "U25EU"),
+ CreateAccount("7000", "Kontorudgifter", "expense", "I25")
+ };
+ _accountRepository.Setup(r => r.GetActiveByCompanyIdAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(accounts);
+
+ // Act
+ var result = await _sut.GetChartOfAccountsAsync("company-1");
+
+ // Assert
+ result.Accounts.Should().HaveCount(1);
+ result.Accounts.Should().NotContain(a => a.AccountType == "income");
+ }
+
+ [Fact]
+ public async Task GetChartOfAccountsAsync_SortsAccountsByNumber()
+ {
+ // Arrange
+ var accounts = new List
+ {
+ CreateAccount("7200", "IT-udgifter", "expense", "I25"),
+ CreateAccount("5000", "Vareforbrug", "cogs", "I25"),
+ CreateAccount("7100", "Kontorudgifter", "expense", "I25"),
+ CreateAccount("6000", "Løn", "personnel", null)
+ };
+ _accountRepository.Setup(r => r.GetActiveByCompanyIdAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(accounts);
+
+ // Act
+ var result = await _sut.GetChartOfAccountsAsync("company-1");
+
+ // Assert
+ var accountNumbers = result.Accounts.Select(a => a.AccountNumber).ToList();
+ accountNumbers.Should().BeInAscendingOrder();
+ accountNumbers.Should().Equal(["5000", "6000", "7100", "7200"]);
+ }
+
+ [Fact]
+ public async Task GetChartOfAccountsAsync_ReturnsCorrectCompanyId()
+ {
+ // Arrange
+ _accountRepository.Setup(r => r.GetActiveByCompanyIdAsync("company-42", It.IsAny()))
+ .ReturnsAsync([]);
+
+ // Act
+ var result = await _sut.GetChartOfAccountsAsync("company-42");
+
+ // Assert
+ result.CompanyId.Should().Be("company-42");
+ }
+
+ [Fact]
+ public async Task GetChartOfAccountsAsync_MapsAllAccountProperties()
+ {
+ // Arrange
+ var accounts = new List
+ {
+ new()
+ {
+ Id = "acc-1",
+ CompanyId = "company-1",
+ AccountNumber = "7320",
+ Name = "Software abonnementer",
+ AccountType = "expense",
+ VatCodeId = "I25",
+ StandardAccountNumber = "11400",
+ IsActive = true,
+ IsSystemAccount = false,
+ CreatedAt = DateTime.UtcNow,
+ UpdatedAt = DateTime.UtcNow
+ }
+ };
+ _accountRepository.Setup(r => r.GetActiveByCompanyIdAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(accounts);
+
+ // Act
+ var result = await _sut.GetChartOfAccountsAsync("company-1");
+
+ // Assert
+ result.Accounts.Should().HaveCount(1);
+ var account = result.Accounts[0];
+ account.AccountNumber.Should().Be("7320");
+ account.Name.Should().Be("Software abonnementer");
+ account.AccountType.Should().Be("expense");
+ account.VatCodeId.Should().Be("I25");
+ account.StandardAccountNumber.Should().Be("11400");
+ }
+
+ [Fact]
+ public async Task GetChartOfAccountsAsync_HandlesEmptyAccountList()
+ {
+ // Arrange
+ _accountRepository.Setup(r => r.GetActiveByCompanyIdAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync([]);
+
+ // Act
+ var result = await _sut.GetChartOfAccountsAsync("company-1");
+
+ // Assert
+ result.Accounts.Should().BeEmpty();
+ result.CompanyId.Should().Be("company-1");
+ }
+
+ private static AccountReadModelDto CreateAccount(string number, string name, string type, string? vatCode)
+ {
+ return new AccountReadModelDto
+ {
+ Id = $"account-{number}",
+ CompanyId = "company-1",
+ AccountNumber = number,
+ Name = name,
+ AccountType = type,
+ VatCodeId = vatCode,
+ IsActive = true,
+ IsSystemAccount = false,
+ CreatedAt = DateTime.UtcNow,
+ UpdatedAt = DateTime.UtcNow
+ };
+ }
+}
diff --git a/backend/Books.Api.Tests/AiBookkeeper/ToonFormatConverterTests.cs b/backend/Books.Api.Tests/AiBookkeeper/ToonFormatConverterTests.cs
new file mode 100644
index 0000000..55b4219
--- /dev/null
+++ b/backend/Books.Api.Tests/AiBookkeeper/ToonFormatConverterTests.cs
@@ -0,0 +1,214 @@
+using Books.Api.AiBookkeeper;
+using AwesomeAssertions;
+
+namespace Books.Api.Tests.AiBookkeeper;
+
+///
+/// Unit tests for ToonFormatConverter.
+/// Verifies that ChartOfAccountsDto is correctly converted to .toon format.
+///
+[Trait("Category", "Unit")]
+public class ToonFormatConverterTests
+{
+ [Fact]
+ public void ConvertToToon_EuGoodsAccount_HasEuRegion()
+ {
+ // Arrange
+ var chartOfAccounts = CreateChartOfAccounts(
+ CreateAccount("2050", "EU-erhvervelser varer", "cogs", "IEUV"));
+
+ // Act
+ var result = ToonFormatConverter.ConvertToToon(chartOfAccounts);
+
+ // Assert - Account 2050 should have region EU
+ result.Should().Contain("2050,EU-erhvervelser varer,Variable omkostninger,IEUV,EU,,");
+ }
+
+ [Fact]
+ public void ConvertToToon_EuServicesAccount_HasEuRegion()
+ {
+ // Arrange
+ var chartOfAccounts = CreateChartOfAccounts(
+ CreateAccount("2100", "EU-erhvervelser ydelser", "cogs", "IEUY"));
+
+ // Act
+ var result = ToonFormatConverter.ConvertToToon(chartOfAccounts);
+
+ // Assert - Account 2100 should have region EU
+ result.Should().Contain("2100,EU-erhvervelser ydelser,Variable omkostninger,IEUY,EU,,");
+ }
+
+ [Fact]
+ public void ConvertToToon_WorldGoodsAccount_HasWorldRegion()
+ {
+ // Arrange
+ var chartOfAccounts = CreateChartOfAccounts(
+ CreateAccount("2150", "Varekøb verden", "cogs", "IVV"));
+
+ // Act
+ var result = ToonFormatConverter.ConvertToToon(chartOfAccounts);
+
+ // Assert - Account 2150 should have region WORLD
+ result.Should().Contain("2150,Varekøb verden,Variable omkostninger,IVV,WORLD,,");
+ }
+
+ [Fact]
+ public void ConvertToToon_WorldServicesAccount_HasWorldRegion()
+ {
+ // Arrange
+ var chartOfAccounts = CreateChartOfAccounts(
+ CreateAccount("2200", "Ydelseskøb verden", "cogs", "IVY"));
+
+ // Act
+ var result = ToonFormatConverter.ConvertToToon(chartOfAccounts);
+
+ // Assert - Account 2200 should have region WORLD
+ result.Should().Contain("2200,Ydelseskøb verden,Variable omkostninger,IVY,WORLD,,");
+ }
+
+ [Fact]
+ public void ConvertToToon_DomesticAccount_HasEmptyRegion()
+ {
+ // Arrange
+ var chartOfAccounts = CreateChartOfAccounts(
+ CreateAccount("7320", "Køb af software", "expense", "I25"));
+
+ // Act
+ var result = ToonFormatConverter.ConvertToToon(chartOfAccounts);
+
+ // Assert - Account 7320 should have empty region (available for all regions)
+ result.Should().Contain("7320,Køb af software,Administrationsomkostninger,I25,,,");
+ }
+
+ [Fact]
+ public void ConvertToToon_AccountWithNoVatCode_HasEmptyRegion()
+ {
+ // Arrange
+ var chartOfAccounts = CreateChartOfAccounts(
+ CreateAccount("7000", "Kontorartikler", "expense", null));
+
+ // Act
+ var result = ToonFormatConverter.ConvertToToon(chartOfAccounts);
+
+ // Assert - Account with no VAT code should have empty region
+ result.Should().Contain("7000,Kontorartikler,Administrationsomkostninger,,,,");
+ }
+
+ [Fact]
+ public void ConvertToToon_MixedAccounts_CorrectRegionsAssigned()
+ {
+ // Arrange
+ var chartOfAccounts = CreateChartOfAccounts(
+ CreateAccount("2000", "Vareforbrug", "cogs", "I25"),
+ CreateAccount("2050", "EU-erhvervelser varer", "cogs", "IEUV"),
+ CreateAccount("2150", "Varekøb verden", "cogs", "IVV"),
+ CreateAccount("7320", "Køb af software", "expense", "I25"));
+
+ // Act
+ var result = ToonFormatConverter.ConvertToToon(chartOfAccounts);
+
+ // Assert
+ result.Should().Contain("2000,Vareforbrug,Variable omkostninger,I25,,,"); // Empty region
+ result.Should().Contain("2050,EU-erhvervelser varer,Variable omkostninger,IEUV,EU,,"); // EU
+ result.Should().Contain("2150,Varekøb verden,Variable omkostninger,IVV,WORLD,,"); // WORLD
+ result.Should().Contain("7320,Køb af software,Administrationsomkostninger,I25,,,"); // Empty region
+ }
+
+ [Fact]
+ public void ConvertToToon_ContainsMetaSection()
+ {
+ // Arrange
+ var chartOfAccounts = CreateChartOfAccounts(
+ CreateAccount("7000", "Kontorartikler", "expense", "I25"));
+
+ // Act
+ var result = ToonFormatConverter.ConvertToToon(chartOfAccounts);
+
+ // Assert
+ result.Should().Contain("meta:");
+ result.Should().Contain("source: Books API");
+ result.Should().Contain("organizationId: company-1");
+ result.Should().Contain("accountType: expense");
+ result.Should().Contain("totalAccounts: 1");
+ }
+
+ [Fact]
+ public void ConvertToToon_ContainsAccountsHeader()
+ {
+ // Arrange
+ var chartOfAccounts = CreateChartOfAccounts(
+ CreateAccount("7000", "Kontorartikler", "expense", "I25"),
+ CreateAccount("7100", "Porto", "expense", "I25"));
+
+ // Act
+ var result = ToonFormatConverter.ConvertToToon(chartOfAccounts);
+
+ // Assert
+ result.Should().Contain("accounts[2]{number,name,category,vatCode,region,vatRubric,suggestions}:");
+ }
+
+ [Fact]
+ public void MapCategory_ExpenseAccount_ReturnsAdministrationsomkostninger()
+ {
+ var result = ToonFormatConverter.MapCategory("expense");
+ result.Should().Be("Administrationsomkostninger");
+ }
+
+ [Fact]
+ public void MapCategory_CogsAccount_ReturnsVariableOmkostninger()
+ {
+ var result = ToonFormatConverter.MapCategory("cogs");
+ result.Should().Be("Variable omkostninger");
+ }
+
+ [Fact]
+ public void MapCategory_PersonnelAccount_ReturnsLønomkostninger()
+ {
+ var result = ToonFormatConverter.MapCategory("personnel");
+ result.Should().Be("Lønomkostninger");
+ }
+
+ [Fact]
+ public void MapCategory_FinancialAccount_ReturnsRenteudgifter()
+ {
+ var result = ToonFormatConverter.MapCategory("financial");
+ result.Should().Be("Renteudgifter");
+ }
+
+ [Fact]
+ public void GenerateSuggestions_IncludesWordsFromName()
+ {
+ var result = ToonFormatConverter.GenerateSuggestions("Køb af software", "7320");
+ result.Should().Contain("køb");
+ result.Should().Contain("software");
+ result.Should().Contain("7320");
+ }
+
+ [Fact]
+ public void GenerateSuggestions_ExcludesShortWords()
+ {
+ var result = ToonFormatConverter.GenerateSuggestions("IT og software", "7320");
+ result.Should().NotContain("og"); // "og" has only 2 characters
+ result.Should().Contain("software");
+ }
+
+ private static ChartOfAccountsDto CreateChartOfAccounts(params AiAccountDto[] accounts)
+ {
+ return new ChartOfAccountsDto
+ {
+ CompanyId = "company-1",
+ Accounts = accounts.ToList()
+ };
+ }
+
+ private static AiAccountDto CreateAccount(string number, string name, string type, string? vatCode)
+ {
+ return new AiAccountDto
+ {
+ AccountNumber = number,
+ Name = name,
+ AccountType = type,
+ VatCodeId = vatCode
+ };
+ }
+}
diff --git a/backend/Books.Api.Tests/Domain/ApiKeyIntegrationTests.cs b/backend/Books.Api.Tests/Domain/ApiKeyIntegrationTests.cs
new file mode 100644
index 0000000..0784f6a
--- /dev/null
+++ b/backend/Books.Api.Tests/Domain/ApiKeyIntegrationTests.cs
@@ -0,0 +1,282 @@
+using Books.Api.Commands.ApiKeys;
+using Books.Api.Domain.ApiKeys;
+using Books.Api.EventFlow.Repositories;
+using Books.Api.Tests.Helpers;
+using Books.Api.Tests.Infrastructure;
+using AwesomeAssertions;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Books.Api.Tests.Domain;
+
+///
+/// Integration tests for ApiKey domain operations.
+/// Tests the ApiKeyAggregate via CommandBus since ApiKeys are not exposed via GraphQL.
+///
+[Trait("Category", "Integration")]
+public class ApiKeyIntegrationTests(TestWebApplicationFactory factory)
+ : IntegrationTestBase(factory)
+{
+ [Fact]
+ public async Task CreateApiKey_CreatesKeySuccessfully()
+ {
+ // Arrange
+ var apiKeyId = ApiKeyId.New;
+ var keyHash = Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData(
+ System.Text.Encoding.UTF8.GetBytes($"test-key-{Guid.NewGuid()}")));
+ var companyId = $"company-{Guid.NewGuid():N}";
+
+ var command = new CreateApiKeyCommand(
+ apiKeyId,
+ "Test API Key",
+ keyHash,
+ companyId,
+ "test-user");
+
+ // Act
+ await CommandBus.PublishAsync(command, CancellationToken.None);
+
+ // Assert
+ var apiKeys = await Eventually.GetListAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByCompanyIdAsync(companyId);
+ }, 1);
+
+ apiKeys.Should().ContainSingle();
+ apiKeys.First().Name.Should().Be("Test API Key");
+ apiKeys.First().CompanyId.Should().Be(companyId);
+ apiKeys.First().IsActive.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task CreateApiKey_FailsForDuplicate()
+ {
+ // Arrange
+ var apiKeyId = ApiKeyId.New;
+ var keyHash = Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData(
+ System.Text.Encoding.UTF8.GetBytes($"test-key-{Guid.NewGuid()}")));
+ var companyId = $"company-{Guid.NewGuid():N}";
+
+ var command = new CreateApiKeyCommand(
+ apiKeyId,
+ "First API Key",
+ keyHash,
+ companyId,
+ "test-user");
+
+ await CommandBus.PublishAsync(command, CancellationToken.None);
+
+ // Wait for first key to be created
+ await Eventually.GetListAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByCompanyIdAsync(companyId);
+ }, 1);
+
+ // Act - Try to create with same ID
+ var duplicateCommand = new CreateApiKeyCommand(
+ apiKeyId, // Same ID
+ "Duplicate API Key",
+ keyHash,
+ companyId,
+ "test-user");
+
+ var act = async () => await CommandBus.PublishAsync(duplicateCommand, CancellationToken.None);
+
+ // Assert
+ await act.Should().ThrowAsync()
+ .Where(e => e.Message.Contains("APIKEY_EXISTS") ||
+ (e.InnerException != null && e.InnerException.Message.Contains("APIKEY_EXISTS")) ||
+ e.Message.Contains("already exists"));
+ }
+
+ [Fact]
+ public async Task RevokeApiKey_RevokesSuccessfully()
+ {
+ // Arrange
+ var apiKeyId = ApiKeyId.New;
+ var keyHash = Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData(
+ System.Text.Encoding.UTF8.GetBytes($"test-key-{Guid.NewGuid()}")));
+ var companyId = $"company-{Guid.NewGuid():N}";
+
+ await CommandBus.PublishAsync(new CreateApiKeyCommand(
+ apiKeyId, "Key To Revoke", keyHash, companyId, "test-user"), CancellationToken.None);
+
+ await Eventually.GetListAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByCompanyIdAsync(companyId);
+ }, 1);
+
+ // Act
+ var revokeCommand = new RevokeApiKeyCommand(apiKeyId, "admin-user");
+ await CommandBus.PublishAsync(revokeCommand, CancellationToken.None);
+
+ // Assert
+ var revokedKey = await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var keys = await repo.GetByCompanyIdAsync(companyId);
+ var key = keys.FirstOrDefault(k => k.Id == apiKeyId.Value);
+ return key?.IsActive == false ? key : null;
+ });
+
+ revokedKey.Should().NotBeNull();
+ revokedKey!.IsActive.Should().BeFalse();
+ revokedKey.RevokedBy.Should().Be("admin-user");
+ }
+
+ [Fact]
+ public async Task RevokeApiKey_FailsForAlreadyRevoked()
+ {
+ // Arrange
+ var apiKeyId = ApiKeyId.New;
+ var keyHash = Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData(
+ System.Text.Encoding.UTF8.GetBytes($"test-key-{Guid.NewGuid()}")));
+ var companyId = $"company-{Guid.NewGuid():N}";
+
+ await CommandBus.PublishAsync(new CreateApiKeyCommand(
+ apiKeyId, "Key To Double Revoke", keyHash, companyId, "test-user"), CancellationToken.None);
+
+ await Eventually.GetListAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByCompanyIdAsync(companyId);
+ }, 1);
+
+ // Revoke first time
+ await CommandBus.PublishAsync(new RevokeApiKeyCommand(apiKeyId, "admin-user"), CancellationToken.None);
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var keys = await repo.GetByCompanyIdAsync(companyId);
+ var key = keys.FirstOrDefault(k => k.Id == apiKeyId.Value);
+ return key?.IsActive == false ? key : null;
+ });
+
+ // Act - Try to revoke again
+ var act = async () => await CommandBus.PublishAsync(
+ new RevokeApiKeyCommand(apiKeyId, "admin-user"), CancellationToken.None);
+
+ // Assert
+ await act.Should().ThrowAsync()
+ .Where(e => e.Message.Contains("APIKEY_REVOKED") ||
+ (e.InnerException != null && e.InnerException.Message.Contains("APIKEY_REVOKED")) ||
+ e.Message.Contains("already revoked"));
+ }
+
+ [Fact]
+ public async Task GetByCompanyId_ReturnsKeysForCompany()
+ {
+ // Arrange
+ var companyId = $"company-{Guid.NewGuid():N}";
+ var otherCompanyId = $"company-{Guid.NewGuid():N}";
+
+ // Create keys for our company
+ for (var i = 0; i < 3; i++)
+ {
+ var keyHash = Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData(
+ System.Text.Encoding.UTF8.GetBytes($"key-{i}-{Guid.NewGuid()}")));
+ await CommandBus.PublishAsync(new CreateApiKeyCommand(
+ ApiKeyId.New, $"Key {i}", keyHash, companyId, "test-user"), CancellationToken.None);
+ }
+
+ // Create key for other company
+ var otherKeyHash = Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData(
+ System.Text.Encoding.UTF8.GetBytes($"other-key-{Guid.NewGuid()}")));
+ await CommandBus.PublishAsync(new CreateApiKeyCommand(
+ ApiKeyId.New, "Other Key", otherKeyHash, otherCompanyId, "test-user"), CancellationToken.None);
+
+ // Act
+ var keys = await Eventually.GetListAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByCompanyIdAsync(companyId);
+ }, 3);
+
+ // Assert
+ keys.Should().HaveCount(3);
+ keys.Should().AllSatisfy(k => k.CompanyId.Should().Be(companyId));
+ }
+
+ [Fact]
+ public async Task GetByIdForValidation_ReturnsActiveKey()
+ {
+ // Arrange
+ var apiKeyId = ApiKeyId.New;
+ var keyHash = Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData(
+ System.Text.Encoding.UTF8.GetBytes($"validation-key-{Guid.NewGuid()}")));
+ var companyId = $"company-{Guid.NewGuid():N}";
+
+ await CommandBus.PublishAsync(new CreateApiKeyCommand(
+ apiKeyId, "Validation Key", keyHash, companyId, "test-user"), CancellationToken.None);
+
+ // Act
+ var validationDto = await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdForValidationAsync(apiKeyId.Value);
+ });
+
+ // Assert
+ validationDto.Should().NotBeNull();
+ validationDto!.ApiKeyId.Should().Be(apiKeyId.Value);
+ validationDto.Name.Should().Be("Validation Key");
+ validationDto.KeyHash.Should().Be(keyHash);
+ validationDto.CompanyId.Should().Be(companyId);
+ validationDto.IsActive.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task GetByIdForValidation_ReturnsNull_ForRevokedKey()
+ {
+ // Arrange
+ var apiKeyId = ApiKeyId.New;
+ var keyHash = Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData(
+ System.Text.Encoding.UTF8.GetBytes($"revoked-validation-key-{Guid.NewGuid()}")));
+ var companyId = $"company-{Guid.NewGuid():N}";
+
+ await CommandBus.PublishAsync(new CreateApiKeyCommand(
+ apiKeyId, "Revoked Validation Key", keyHash, companyId, "test-user"), CancellationToken.None);
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdForValidationAsync(apiKeyId.Value);
+ });
+
+ // Revoke the key
+ await CommandBus.PublishAsync(new RevokeApiKeyCommand(apiKeyId, "admin-user"), CancellationToken.None);
+
+ // Wait for revocation
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var keys = await repo.GetByCompanyIdAsync(companyId);
+ var key = keys.FirstOrDefault(k => k.Id == apiKeyId.Value);
+ return key?.IsActive == false ? key : null;
+ });
+
+ // Act
+ var repo = GetService();
+ var validationDto = await repo.GetByIdForValidationAsync(apiKeyId.Value);
+
+ // Assert - Revoked keys should not be returned for validation
+ validationDto.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task GetByIdForValidation_ReturnsNull_ForNonExistentKey()
+ {
+ // Arrange
+ var nonExistentId = ApiKeyId.New;
+
+ // Act
+ var repo = GetService();
+ var validationDto = await repo.GetByIdForValidationAsync(nonExistentId.Value);
+
+ // Assert
+ validationDto.Should().BeNull();
+ }
+}
diff --git a/backend/Books.Api.Tests/Domain/BankConnectionAggregateTests.cs b/backend/Books.Api.Tests/Domain/BankConnectionAggregateTests.cs
new file mode 100644
index 0000000..979431b
--- /dev/null
+++ b/backend/Books.Api.Tests/Domain/BankConnectionAggregateTests.cs
@@ -0,0 +1,485 @@
+using Books.Api.Domain;
+using Books.Api.Domain.BankConnections;
+using Books.Api.Domain.BankConnections.Events;
+using AwesomeAssertions;
+
+namespace Books.Api.Tests.Domain;
+
+///
+/// Unit tests for BankConnectionAggregate domain logic.
+/// Tests aggregate behavior without EventFlow infrastructure.
+///
+[Trait("Category", "Unit")]
+public class BankConnectionAggregateTests
+{
+ #region Initiate Tests
+
+ [Fact]
+ public void Initiate_WithValidData_EmitsInitiatedEvent()
+ {
+ // Arrange
+ var aggregate = new BankConnectionAggregate(BankConnectionId.New);
+
+ // Act
+ aggregate.Initiate("company-123", "Danske Bank", "auth-456", "https://callback.url", "state-789");
+
+ // Assert
+ var uncommittedEvents = aggregate.UncommittedEvents.ToList();
+ uncommittedEvents.Should().ContainSingle();
+
+ var initiatedEvent = uncommittedEvents[0].AggregateEvent as BankConnectionInitiatedEvent;
+ initiatedEvent.Should().NotBeNull();
+ initiatedEvent!.CompanyId.Should().Be("company-123");
+ initiatedEvent.AspspName.Should().Be("Danske Bank");
+ initiatedEvent.AuthorizationId.Should().Be("auth-456");
+ initiatedEvent.RedirectUrl.Should().Be("https://callback.url");
+ initiatedEvent.State.Should().Be("state-789");
+ }
+
+ [Fact]
+ public void Initiate_WithEmptyAspspName_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = new BankConnectionAggregate(BankConnectionId.New);
+
+ // Act
+ var act = () => aggregate.Initiate("company-123", " ", "auth-456", "https://callback.url", "state-789");
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "ASPSP_NAME_REQUIRED");
+ }
+
+ [Fact]
+ public void Initiate_WhenAlreadyInitiated_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = CreateInitiatedConnection();
+
+ // Act
+ var act = () => aggregate.Initiate("company-123", "Nordea", "auth-999", "https://other.url", "state-111");
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "BANK_CONNECTION_ALREADY_INITIATED");
+ }
+
+ #endregion
+
+ #region Establish Tests
+
+ [Fact]
+ public void Establish_WhenInitiated_EmitsEstablishedEvent()
+ {
+ // Arrange
+ var aggregate = CreateInitiatedConnection();
+ var accounts = new List
+ {
+ new("acc-1", "DK1234567890123456", "DKK", "Lønkonto"),
+ new("acc-2", "DK9876543210987654", "DKK", "Opsparingskonto")
+ };
+ var validUntil = DateTimeOffset.UtcNow.AddDays(90);
+
+ // Act
+ aggregate.Establish("session-123", validUntil, accounts);
+
+ // Assert
+ var uncommittedEvents = aggregate.UncommittedEvents.ToList();
+ uncommittedEvents.Should().HaveCount(2); // Initiated + Established
+
+ var establishedEvent = uncommittedEvents[1].AggregateEvent as BankConnectionEstablishedEvent;
+ establishedEvent.Should().NotBeNull();
+ establishedEvent!.SessionId.Should().Be("session-123");
+ establishedEvent.ValidUntil.Should().Be(validUntil);
+ establishedEvent.Accounts.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public void Establish_WhenNotInitiated_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = new BankConnectionAggregate(BankConnectionId.New);
+ var accounts = new List { new("acc-1", "DK1234", "DKK", null) };
+
+ // Act
+ var act = () => aggregate.Establish("session-123", DateTimeOffset.UtcNow.AddDays(90), accounts);
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "BANK_CONNECTION_NOT_INITIATED");
+ }
+
+ [Fact]
+ public void Establish_WithEmptySessionId_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = CreateInitiatedConnection();
+ var accounts = new List { new("acc-1", "DK1234", "DKK", null) };
+
+ // Act
+ var act = () => aggregate.Establish(" ", DateTimeOffset.UtcNow.AddDays(90), accounts);
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "SESSION_ID_REQUIRED");
+ }
+
+ [Fact]
+ public void Establish_WithNoAccounts_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = CreateInitiatedConnection();
+ var accounts = new List();
+
+ // Act
+ var act = () => aggregate.Establish("session-123", DateTimeOffset.UtcNow.AddDays(90), accounts);
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "NO_ACCOUNTS_FOUND");
+ }
+
+ [Fact]
+ public void Establish_WhenAlreadyEstablished_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = CreateEstablishedConnection();
+ var accounts = new List { new("acc-1", "DK1234", "DKK", null) };
+
+ // Act
+ var act = () => aggregate.Establish("session-456", DateTimeOffset.UtcNow.AddDays(90), accounts);
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "BANK_CONNECTION_ALREADY_ESTABLISHED");
+ }
+
+ #endregion
+
+ #region Fail Tests
+
+ [Fact]
+ public void Fail_WhenInitiated_EmitsFailedEvent()
+ {
+ // Arrange
+ var aggregate = CreateInitiatedConnection();
+
+ // Act
+ aggregate.Fail("User cancelled authorization");
+
+ // Assert
+ var uncommittedEvents = aggregate.UncommittedEvents.ToList();
+ uncommittedEvents.Should().HaveCount(2); // Initiated + Failed
+
+ var failedEvent = uncommittedEvents[1].AggregateEvent as BankConnectionFailedEvent;
+ failedEvent.Should().NotBeNull();
+ failedEvent!.Reason.Should().Be("User cancelled authorization");
+ }
+
+ [Fact]
+ public void Fail_WhenNotInitiated_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = new BankConnectionAggregate(BankConnectionId.New);
+
+ // Act
+ var act = () => aggregate.Fail("Some reason");
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "BANK_CONNECTION_NOT_INITIATED");
+ }
+
+ #endregion
+
+ #region Disconnect Tests
+
+ [Fact]
+ public void Disconnect_WhenEstablished_EmitsDisconnectedEvent()
+ {
+ // Arrange
+ var aggregate = CreateEstablishedConnection();
+
+ // Act
+ aggregate.Disconnect("User requested disconnection");
+
+ // Assert
+ var uncommittedEvents = aggregate.UncommittedEvents.ToList();
+ uncommittedEvents.Should().HaveCount(3); // Initiated + Established + Disconnected
+
+ var disconnectedEvent = uncommittedEvents[2].AggregateEvent as BankConnectionDisconnectedEvent;
+ disconnectedEvent.Should().NotBeNull();
+ disconnectedEvent!.Reason.Should().Be("User requested disconnection");
+ }
+
+ [Fact]
+ public void Disconnect_WhenInitiated_EmitsDisconnectedEvent()
+ {
+ // Arrange
+ var aggregate = CreateInitiatedConnection();
+
+ // Act
+ aggregate.Disconnect("User cancelled flow");
+
+ // Assert
+ var uncommittedEvents = aggregate.UncommittedEvents.ToList();
+ uncommittedEvents.Should().HaveCount(2); // Initiated + Disconnected
+
+ var disconnectedEvent = uncommittedEvents[1].AggregateEvent as BankConnectionDisconnectedEvent;
+ disconnectedEvent.Should().NotBeNull();
+ disconnectedEvent!.Reason.Should().Be("User cancelled flow");
+ }
+
+ [Fact]
+ public void Disconnect_WhenAlreadyDisconnected_Idempotent_DoesNothing()
+ {
+ // Arrange
+ var aggregate = CreateDisconnectedConnection();
+ var initialEventCount = aggregate.UncommittedEvents.Count();
+
+ // Act
+ aggregate.Disconnect("Trying again");
+
+ // Assert
+ aggregate.UncommittedEvents.Count().Should().Be(initialEventCount);
+ }
+
+ #endregion
+
+ #region LinkBankAccount Tests
+
+ [Fact]
+ public void LinkBankAccount_WhenEstablished_EmitsLinkedEvent()
+ {
+ // Arrange
+ var aggregate = CreateEstablishedConnection();
+ var importDate = new DateOnly(2023, 1, 1);
+
+ // Act
+ aggregate.LinkBankAccount("acc-1", "ledger-1000", importDate);
+
+ // Assert
+ var uncommittedEvents = aggregate.UncommittedEvents.ToList();
+ var linkedEvent = uncommittedEvents.Last().AggregateEvent as BankAccountLinkedEvent;
+
+ linkedEvent.Should().NotBeNull();
+ linkedEvent!.BankAccountId.Should().Be("acc-1");
+ linkedEvent.LinkedAccountId.Should().Be("ledger-1000");
+ linkedEvent.ImportFromDate.Should().Be(importDate);
+ }
+
+ [Fact]
+ public void LinkBankAccount_WhenNotEstablished_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = CreateInitiatedConnection();
+
+ // Act
+ var act = () => aggregate.LinkBankAccount("acc-1", "ledger-1000");
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "BANK_CONNECTION_NOT_ACTIVE");
+ }
+
+ [Fact]
+ public void LinkBankAccount_WithUnknownAccountId_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = CreateEstablishedConnection();
+
+ // Act
+ var act = () => aggregate.LinkBankAccount("unknown-acc", "ledger-1000");
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "BANK_ACCOUNT_NOT_FOUND");
+ }
+
+ #endregion
+
+ #region ReInitiate Tests
+
+ [Fact]
+ public void ReInitiate_WhenDisconnected_EmitsReInitiatedEvent()
+ {
+ // Arrange
+ var aggregate = CreateDisconnectedConnection();
+
+ // Act
+ aggregate.ReInitiate("new-auth", "url", "new-state");
+
+ // Assert
+ var uncommittedEvents = aggregate.UncommittedEvents.ToList();
+ var event_ = uncommittedEvents.Last().AggregateEvent as BankConnectionReInitiatedEvent;
+
+ event_.Should().NotBeNull();
+ event_!.AuthorizationId.Should().Be("new-auth");
+ event_.State.Should().Be("new-state");
+ }
+
+ [Fact]
+ public void ReInitiate_WhenActive_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = CreateEstablishedConnection();
+
+ // Act
+ var act = () => aggregate.ReInitiate("auth", "url", "state");
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "BANK_CONNECTION_STILL_ACTIVE");
+ }
+
+ #endregion
+
+ #region Archive Tests
+
+ [Fact]
+ public void Archive_WhenDisconnected_EmitsArchivedEvent()
+ {
+ // Arrange
+ var aggregate = CreateDisconnectedConnection();
+
+ // Act
+ aggregate.Archive();
+
+ // Assert
+ var uncommittedEvents = aggregate.UncommittedEvents.ToList();
+ var event_ = uncommittedEvents.Last().AggregateEvent as BankConnectionArchivedEvent;
+
+ event_.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void Archive_WhenActive_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = CreateEstablishedConnection();
+
+ // Act
+ var act = () => aggregate.Archive();
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "BANK_CONNECTION_STILL_ACTIVE");
+ }
+
+ #endregion
+
+ #region Refresh Tests
+
+ [Fact]
+ public void Refresh_WhenEstablished_EmitsRefreshedEvent()
+ {
+ // Arrange
+ var aggregate = CreateEstablishedConnection();
+ var newValidUntil = DateTimeOffset.UtcNow.AddDays(180);
+
+ // Act
+ aggregate.Refresh("new-session-456", newValidUntil);
+
+ // Assert
+ var uncommittedEvents = aggregate.UncommittedEvents.ToList();
+ uncommittedEvents.Should().HaveCount(3); // Initiated + Established + Refreshed
+
+ var refreshedEvent = uncommittedEvents[2].AggregateEvent as BankConnectionRefreshedEvent;
+ refreshedEvent.Should().NotBeNull();
+ refreshedEvent!.NewSessionId.Should().Be("new-session-456");
+ refreshedEvent.ValidUntil.Should().Be(newValidUntil);
+ }
+
+ [Fact]
+ public void Refresh_WhenNotEstablished_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = CreateInitiatedConnection();
+
+ // Act
+ var act = () => aggregate.Refresh("new-session-456", DateTimeOffset.UtcNow.AddDays(90));
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "BANK_CONNECTION_NOT_ACTIVE");
+ }
+
+ [Fact]
+ public void Refresh_WhenDisconnected_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = CreateDisconnectedConnection();
+
+ // Act
+ var act = () => aggregate.Refresh("new-session-456", DateTimeOffset.UtcNow.AddDays(90));
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "BANK_CONNECTION_NOT_ACTIVE");
+ }
+
+ #endregion
+
+ #region IsActive Property Tests
+
+ [Fact]
+ public void IsActive_WhenEstablishedAndNotExpired_ReturnsTrue()
+ {
+ // Arrange
+ var aggregate = CreateEstablishedConnection();
+
+ // Assert
+ aggregate.IsActive.Should().BeTrue();
+ }
+
+ [Fact]
+ public void IsActive_WhenNotEstablished_ReturnsFalse()
+ {
+ // Arrange
+ var aggregate = CreateInitiatedConnection();
+
+ // Assert
+ aggregate.IsActive.Should().BeFalse();
+ }
+
+ [Fact]
+ public void IsActive_WhenDisconnected_ReturnsFalse()
+ {
+ // Arrange
+ var aggregate = CreateDisconnectedConnection();
+
+ // Assert
+ aggregate.IsActive.Should().BeFalse();
+ }
+
+ #endregion
+
+ #region Helper Methods
+
+ private static BankConnectionAggregate CreateInitiatedConnection()
+ {
+ var aggregate = new BankConnectionAggregate(BankConnectionId.New);
+ aggregate.Initiate("company-123", "Danske Bank", "auth-456", "https://callback.url", "state-789");
+ return aggregate;
+ }
+
+ private static BankConnectionAggregate CreateEstablishedConnection()
+ {
+ var aggregate = CreateInitiatedConnection();
+ var accounts = new List
+ {
+ new("acc-1", "DK1234567890123456", "DKK", "Lønkonto")
+ };
+ aggregate.Establish("session-123", DateTimeOffset.UtcNow.AddDays(90), accounts);
+ return aggregate;
+ }
+
+ private static BankConnectionAggregate CreateDisconnectedConnection()
+ {
+ var aggregate = CreateEstablishedConnection();
+ aggregate.Disconnect("User requested disconnection");
+ return aggregate;
+ }
+
+ #endregion
+}
diff --git a/backend/Books.Api.Tests/Domain/JournalEntryDraftAggregateTests.cs b/backend/Books.Api.Tests/Domain/JournalEntryDraftAggregateTests.cs
new file mode 100644
index 0000000..0870032
--- /dev/null
+++ b/backend/Books.Api.Tests/Domain/JournalEntryDraftAggregateTests.cs
@@ -0,0 +1,393 @@
+using Books.Api.Domain;
+using Books.Api.Domain.JournalEntryDrafts;
+using Books.Api.Domain.JournalEntryDrafts.Events;
+using AwesomeAssertions;
+
+namespace Books.Api.Tests.Domain;
+
+///
+/// Unit tests for JournalEntryDraftAggregate domain logic.
+/// Tests aggregate behavior without EventFlow infrastructure.
+///
+[Trait("Category", "Unit")]
+public class JournalEntryDraftAggregateTests
+{
+ #region Create Tests
+
+ [Fact]
+ public void Create_WithValidData_EmitsCreatedEvent()
+ {
+ // Arrange
+ var aggregate = new JournalEntryDraftAggregate(JournalEntryDraftId.New());
+
+ // Act
+ aggregate.Create("company-123", "Test Draft", "user@example.com", "K-0001");
+
+ // Assert
+ var uncommittedEvents = aggregate.UncommittedEvents.ToList();
+ uncommittedEvents.Should().ContainSingle();
+
+ var createdEvent = uncommittedEvents[0].AggregateEvent as JournalEntryDraftCreatedEvent;
+ createdEvent.Should().NotBeNull();
+ createdEvent!.CompanyId.Should().Be("company-123");
+ createdEvent.Name.Should().Be("Test Draft");
+ createdEvent.CreatedBy.Should().Be("user@example.com");
+ createdEvent.VoucherNumber.Should().Be("K-0001");
+ }
+
+ [Fact]
+ public void Create_TrimsWhitespace()
+ {
+ // Arrange
+ var aggregate = new JournalEntryDraftAggregate(JournalEntryDraftId.New());
+
+ // Act
+ aggregate.Create(" company-123 ", " Trimmed Name ", "user@example.com", " K-0002 ");
+
+ // Assert
+ var createdEvent = aggregate.UncommittedEvents.First().AggregateEvent as JournalEntryDraftCreatedEvent;
+ createdEvent!.CompanyId.Should().Be("company-123");
+ createdEvent.Name.Should().Be("Trimmed Name");
+ createdEvent.VoucherNumber.Should().Be("K-0002");
+ }
+
+ [Fact]
+ public void Create_WithEmptyName_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = new JournalEntryDraftAggregate(JournalEntryDraftId.New());
+
+ // Act
+ var act = () => aggregate.Create("company-123", " ", "user@example.com", "K-0001");
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "DRAFT_NAME_REQUIRED");
+ }
+
+ [Fact]
+ public void Create_WithEmptyCompanyId_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = new JournalEntryDraftAggregate(JournalEntryDraftId.New());
+
+ // Act
+ var act = () => aggregate.Create("", "Test Draft", "user@example.com", "K-0001");
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "COMPANY_ID_REQUIRED");
+ }
+
+ [Fact]
+ public void Create_WithEmptyCreatedBy_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = new JournalEntryDraftAggregate(JournalEntryDraftId.New());
+
+ // Act
+ var act = () => aggregate.Create("company-123", "Test Draft", " ", "K-0001");
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "CREATED_BY_REQUIRED");
+ }
+
+ [Fact]
+ public void Create_WithEmptyVoucherNumber_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = new JournalEntryDraftAggregate(JournalEntryDraftId.New());
+
+ // Act
+ var act = () => aggregate.Create("company-123", "Test Draft", "user@example.com", " ");
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "VOUCHER_NUMBER_REQUIRED");
+ }
+
+ [Fact]
+ public void Create_WhenAlreadyCreated_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = new JournalEntryDraftAggregate(JournalEntryDraftId.New());
+ aggregate.Create("company-123", "First Draft", "user@example.com", "K-0001");
+
+ // Act
+ var act = () => aggregate.Create("company-123", "Second Draft", "user@example.com", "K-0002");
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "DRAFT_ALREADY_EXISTS");
+ }
+
+ #endregion
+
+ #region Update Tests
+
+ [Fact]
+ public void Update_WhenActive_EmitsUpdatedEvent()
+ {
+ // Arrange
+ var aggregate = CreateActiveDraft();
+ var lines = new List
+ {
+ new(1, "account-1", 1000m, 0m, "Debet"),
+ new(2, "account-2", 0m, 1000m, "Kredit")
+ };
+
+ // Act
+ aggregate.Update("New Name", DateOnly.FromDateTime(DateTime.Today), "Description", "fiscalyear-1", lines);
+
+ // Assert
+ var uncommittedEvents = aggregate.UncommittedEvents.ToList();
+ uncommittedEvents.Should().HaveCount(2); // Created + Updated
+
+ var updatedEvent = uncommittedEvents[1].AggregateEvent as JournalEntryDraftUpdatedEvent;
+ updatedEvent.Should().NotBeNull();
+ updatedEvent!.Name.Should().Be("New Name");
+ updatedEvent.DocumentDate.Should().Be(DateOnly.FromDateTime(DateTime.Today));
+ updatedEvent.Description.Should().Be("Description");
+ updatedEvent.FiscalYearId.Should().Be("fiscalyear-1");
+ updatedEvent.Lines.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public void Update_WithVatCode_IncludesVatCodeInEvent()
+ {
+ // Arrange
+ var aggregate = CreateActiveDraft();
+ var lines = new List
+ {
+ new(1, "account-1", 1000m, 0m, "Salg", "U25"),
+ new(2, "account-2", 0m, 1000m, "Moms", "I25")
+ };
+
+ // Act
+ aggregate.Update("Draft with VAT", DateOnly.FromDateTime(DateTime.Today), "Description", "fiscalyear-1", lines);
+
+ // Assert
+ var uncommittedEvents = aggregate.UncommittedEvents.ToList();
+ var updatedEvent = uncommittedEvents[1].AggregateEvent as JournalEntryDraftUpdatedEvent;
+ updatedEvent.Should().NotBeNull();
+ updatedEvent!.Lines[0].VatCode.Should().Be("U25");
+ updatedEvent.Lines[1].VatCode.Should().Be("I25");
+ }
+
+ [Fact]
+ public void Update_WithInvalidVatCode_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = CreateActiveDraft();
+ var lines = new List
+ {
+ new(1, "account-1", 1000m, 0m, "Test", "INVALID_CODE")
+ };
+
+ // Act
+ var act = () => aggregate.Update("Name", null, null, null, lines);
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "INVALID_VAT_CODE");
+ }
+
+ [Fact]
+ public void Update_WhenNotCreated_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = new JournalEntryDraftAggregate(JournalEntryDraftId.New());
+
+ // Act
+ var act = () => aggregate.Update("Name", null, null, null, []);
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "DRAFT_NOT_FOUND");
+ }
+
+ [Fact]
+ public void Update_WhenPosted_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = CreatePostedDraft();
+
+ // Act
+ var act = () => aggregate.Update("New Name", null, null, null, []);
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "DRAFT_ALREADY_POSTED");
+ }
+
+ [Fact]
+ public void Update_WhenDiscarded_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = CreateDiscardedDraft();
+
+ // Act
+ var act = () => aggregate.Update("New Name", null, null, null, []);
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "DRAFT_ALREADY_DISCARDED");
+ }
+
+ #endregion
+
+ #region MarkPosted Tests
+
+ [Fact]
+ public void MarkPosted_WhenActive_EmitsPostedEvent()
+ {
+ // Arrange
+ var aggregate = CreateActiveDraft();
+
+ // Act
+ aggregate.MarkPosted("transaction-123", "user@example.com");
+
+ // Assert
+ var uncommittedEvents = aggregate.UncommittedEvents.ToList();
+ uncommittedEvents.Should().HaveCount(2); // Created + Posted
+
+ var postedEvent = uncommittedEvents[1].AggregateEvent as JournalEntryDraftPostedEvent;
+ postedEvent.Should().NotBeNull();
+ postedEvent!.TransactionId.Should().Be("transaction-123");
+ postedEvent.PostedBy.Should().Be("user@example.com");
+ }
+
+ [Fact]
+ public void MarkPosted_WithEmptyTransactionId_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = CreateActiveDraft();
+
+ // Act
+ var act = () => aggregate.MarkPosted(" ", "user@example.com");
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "TRANSACTION_ID_REQUIRED");
+ }
+
+ [Fact]
+ public void MarkPosted_WithEmptyPostedBy_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = CreateActiveDraft();
+
+ // Act
+ var act = () => aggregate.MarkPosted("transaction-123", "");
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "POSTED_BY_REQUIRED");
+ }
+
+ [Fact]
+ public void MarkPosted_WhenAlreadyPosted_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = CreatePostedDraft();
+
+ // Act
+ var act = () => aggregate.MarkPosted("transaction-456", "user@example.com");
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "DRAFT_ALREADY_POSTED");
+ }
+
+ #endregion
+
+ #region Discard Tests
+
+ [Fact]
+ public void Discard_WhenActive_EmitsDiscardedEvent()
+ {
+ // Arrange
+ var aggregate = CreateActiveDraft();
+
+ // Act
+ aggregate.Discard("user@example.com");
+
+ // Assert
+ var uncommittedEvents = aggregate.UncommittedEvents.ToList();
+ uncommittedEvents.Should().HaveCount(2); // Created + Discarded
+
+ var discardedEvent = uncommittedEvents[1].AggregateEvent as JournalEntryDraftDiscardedEvent;
+ discardedEvent.Should().NotBeNull();
+ discardedEvent!.DiscardedBy.Should().Be("user@example.com");
+ }
+
+ [Fact]
+ public void Discard_WithEmptyDiscardedBy_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = CreateActiveDraft();
+
+ // Act
+ var act = () => aggregate.Discard(" ");
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "DISCARDED_BY_REQUIRED");
+ }
+
+ [Fact]
+ public void Discard_WhenAlreadyDiscarded_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = CreateDiscardedDraft();
+
+ // Act
+ var act = () => aggregate.Discard("user@example.com");
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "DRAFT_ALREADY_DISCARDED");
+ }
+
+ [Fact]
+ public void Discard_WhenPosted_ThrowsDomainException()
+ {
+ // Arrange
+ var aggregate = CreatePostedDraft();
+
+ // Act
+ var act = () => aggregate.Discard("user@example.com");
+
+ // Assert
+ act.Should().Throw()
+ .Where(e => e.Code == "DRAFT_ALREADY_POSTED");
+ }
+
+ #endregion
+
+ #region Helper Methods
+
+ private static JournalEntryDraftAggregate CreateActiveDraft()
+ {
+ var aggregate = new JournalEntryDraftAggregate(JournalEntryDraftId.New());
+ aggregate.Create("company-123", "Test Draft", "user@example.com", "K-0001");
+ return aggregate;
+ }
+
+ private static JournalEntryDraftAggregate CreatePostedDraft()
+ {
+ var aggregate = CreateActiveDraft();
+ aggregate.MarkPosted("transaction-123", "user@example.com");
+ return aggregate;
+ }
+
+ private static JournalEntryDraftAggregate CreateDiscardedDraft()
+ {
+ var aggregate = CreateActiveDraft();
+ aggregate.Discard("user@example.com");
+ return aggregate;
+ }
+
+ #endregion
+}
diff --git a/backend/Books.Api.Tests/Domain/VatCalculationServiceTests.cs b/backend/Books.Api.Tests/Domain/VatCalculationServiceTests.cs
new file mode 100644
index 0000000..f05ad45
--- /dev/null
+++ b/backend/Books.Api.Tests/Domain/VatCalculationServiceTests.cs
@@ -0,0 +1,265 @@
+using Books.Api.Domain;
+using Books.Api.Domain.JournalEntryDrafts;
+using AwesomeAssertions;
+
+namespace Books.Api.Tests.Domain;
+
+///
+/// Unit tests for VatCalculationService.
+/// Tests Danish VAT calculation logic for SKAT compliance.
+///
+[Trait("Category", "Unit")]
+public class VatCalculationServiceTests
+{
+ private readonly VatCalculationService _sut = new();
+ private const string InputVatAccount = "5610"; // Købsmoms (indgående moms)
+ private const string OutputVatAccount = "5611"; // Salgsmoms (udgående moms)
+
+ #region Sales (U25) Tests
+
+ [Fact]
+ public void CalculateVat_SalesWithU25_Exclusive_CalculatesCorrectVat()
+ {
+ // Arrange - Sale of 1000 kr excl. VAT
+ var lines = new List
+ {
+ new(1, "1000", 0m, 1000m, "Salg af varer", "U25") // Credit = sales revenue
+ };
+
+ // Act
+ var result = _sut.CalculateVat(lines, VatCalculationMode.Exclusive, InputVatAccount, OutputVatAccount);
+
+ // Assert
+ result.VatLines.Should().HaveCount(1);
+ var vatLine = result.VatLines[0];
+ vatLine.CreditAmount.Should().Be(250m); // 25% of 1000
+ vatLine.DebitAmount.Should().Be(0m);
+ vatLine.AccountId.Should().Be(OutputVatAccount);
+ vatLine.VatCode.Should().Be("U25");
+
+ result.VatSummary.Should().HaveCount(1);
+ result.VatSummary[0].BaseAmount.Should().Be(1000m);
+ result.VatSummary[0].VatAmount.Should().Be(250m);
+ result.VatSummary[0].IsInputVat.Should().BeFalse();
+ }
+
+ [Fact]
+ public void CalculateVat_SalesWithU25_Inclusive_ExtractsCorrectVat()
+ {
+ // Arrange - Sale of 1250 kr incl. VAT (1000 + 250 VAT)
+ var lines = new List
+ {
+ new(1, "1000", 0m, 1250m, "Salg inkl moms", "U25")
+ };
+
+ // Act
+ var result = _sut.CalculateVat(lines, VatCalculationMode.Inclusive, InputVatAccount, OutputVatAccount);
+
+ // Assert
+ var vatLine = result.VatLines[0];
+ vatLine.CreditAmount.Should().Be(250m); // 1250 * 0.25 / 1.25 = 250
+ vatLine.DebitAmount.Should().Be(0m);
+
+ result.VatSummary[0].BaseAmount.Should().Be(1000m); // 1250 - 250
+ result.VatSummary[0].VatAmount.Should().Be(250m);
+ }
+
+ #endregion
+
+ #region Purchases (I25) Tests
+
+ [Fact]
+ public void CalculateVat_PurchaseWithI25_Exclusive_CalculatesCorrectVat()
+ {
+ // Arrange - Purchase of 1000 kr excl. VAT
+ var lines = new List
+ {
+ new(1, "2000", 1000m, 0m, "Køb af varer", "I25") // Debit = expense
+ };
+
+ // Act
+ var result = _sut.CalculateVat(lines, VatCalculationMode.Exclusive, InputVatAccount, OutputVatAccount);
+
+ // Assert
+ result.VatLines.Should().HaveCount(1);
+ var vatLine = result.VatLines[0];
+ vatLine.DebitAmount.Should().Be(250m); // 25% of 1000 - debit because it's an asset (receivable)
+ vatLine.CreditAmount.Should().Be(0m);
+ vatLine.AccountId.Should().Be(InputVatAccount);
+ vatLine.VatCode.Should().Be("I25");
+
+ result.VatSummary[0].IsInputVat.Should().BeTrue();
+ }
+
+ [Fact]
+ public void CalculateVat_PurchaseWithI25_Inclusive_ExtractsCorrectVat()
+ {
+ // Arrange - Purchase of 1250 kr incl. VAT
+ var lines = new List
+ {
+ new(1, "2000", 1250m, 0m, "Køb inkl moms", "I25")
+ };
+
+ // Act
+ var result = _sut.CalculateVat(lines, VatCalculationMode.Inclusive, InputVatAccount, OutputVatAccount);
+
+ // Assert
+ var vatLine = result.VatLines[0];
+ vatLine.DebitAmount.Should().Be(250m);
+ vatLine.CreditAmount.Should().Be(0m);
+
+ result.VatSummary[0].BaseAmount.Should().Be(1000m);
+ result.VatSummary[0].VatAmount.Should().Be(250m);
+ }
+
+ #endregion
+
+ #region No VAT Tests
+
+ [Fact]
+ public void CalculateVat_NoVatCode_ReturnsNoVatLines()
+ {
+ // Arrange
+ var lines = new List
+ {
+ new(1, "1000", 0m, 1000m, "Momsfrit salg") // No VAT code
+ };
+
+ // Act
+ var result = _sut.CalculateVat(lines, VatCalculationMode.Exclusive, InputVatAccount, OutputVatAccount);
+
+ // Assert
+ result.VatLines.Should().BeEmpty();
+ result.VatSummary.Should().BeEmpty();
+ result.TotalVatAmount.Should().Be(0m);
+ }
+
+ [Fact]
+ public void CalculateVat_WithINGEN_ReturnsNoVatLines()
+ {
+ // Arrange
+ var lines = new List
+ {
+ new(1, "1000", 0m, 1000m, "Momsfrit", "INGEN")
+ };
+
+ // Act
+ var result = _sut.CalculateVat(lines, VatCalculationMode.Exclusive, InputVatAccount, OutputVatAccount);
+
+ // Assert
+ result.VatLines.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void CalculateVat_WithUEU_ReturnsNoVatLines()
+ {
+ // Arrange - EU sale (no VAT charged)
+ var lines = new List
+ {
+ new(1, "1000", 0m, 10000m, "EU-salg af varer", "UEU")
+ };
+
+ // Act
+ var result = _sut.CalculateVat(lines, VatCalculationMode.Exclusive, InputVatAccount, OutputVatAccount);
+
+ // Assert - UEU has 0% rate, so no VAT line generated
+ result.VatLines.Should().BeEmpty();
+ }
+
+ #endregion
+
+ #region Multiple Lines Tests
+
+ [Fact]
+ public void CalculateVat_MultipleLines_AggregatesByVatCode()
+ {
+ // Arrange
+ var lines = new List
+ {
+ new(1, "1000", 0m, 1000m, "Salg 1", "U25"),
+ new(2, "1000", 0m, 2000m, "Salg 2", "U25"),
+ new(3, "2000", 500m, 0m, "Køb 1", "I25")
+ };
+
+ // Act
+ var result = _sut.CalculateVat(lines, VatCalculationMode.Exclusive, InputVatAccount, OutputVatAccount);
+
+ // Assert
+ result.VatLines.Should().HaveCount(3);
+
+ // Check VAT summary aggregates correctly
+ result.VatSummary.Should().HaveCount(2); // U25 and I25
+
+ var u25Summary = result.VatSummary.First(s => s.VatCode == "U25");
+ u25Summary.BaseAmount.Should().Be(3000m); // 1000 + 2000
+ u25Summary.VatAmount.Should().Be(750m); // 25% of 3000
+
+ var i25Summary = result.VatSummary.First(s => s.VatCode == "I25");
+ i25Summary.BaseAmount.Should().Be(500m);
+ i25Summary.VatAmount.Should().Be(125m); // 25% of 500
+ }
+
+ #endregion
+
+ #region Rounding Tests
+
+ [Fact]
+ public void CalculateVat_OddAmount_RoundsCorrectly()
+ {
+ // Arrange - Amount that results in rounding
+ var lines = new List
+ {
+ new(1, "1000", 0m, 100.01m, "Salg med øreafrunding", "U25")
+ };
+
+ // Act
+ var result = _sut.CalculateVat(lines, VatCalculationMode.Exclusive, InputVatAccount, OutputVatAccount);
+
+ // Assert
+ // 100.01 * 0.25 = 25.0025, rounded to 25.00
+ result.VatLines[0].CreditAmount.Should().Be(25.00m);
+ }
+
+ [Fact]
+ public void CalculateVat_InclusiveOddAmount_RoundsCorrectly()
+ {
+ // Arrange - 125.01 incl VAT
+ var lines = new List
+ {
+ new(1, "1000", 0m, 125.01m, "Salg inkl moms", "U25")
+ };
+
+ // Act
+ var result = _sut.CalculateVat(lines, VatCalculationMode.Inclusive, InputVatAccount, OutputVatAccount);
+
+ // Assert
+ // 125.01 * 0.25 / 1.25 = 25.002, rounded to 25.00
+ result.VatLines[0].CreditAmount.Should().Be(25.00m);
+ result.VatSummary[0].BaseAmount.Should().Be(100.01m); // 125.01 - 25.00
+ }
+
+ #endregion
+
+ #region Total VAT Tests
+
+ [Fact]
+ public void CalculateVat_MixedInputOutput_CalculatesTotalCorrectly()
+ {
+ // Arrange - Sales and purchases
+ var lines = new List
+ {
+ new(1, "1000", 0m, 1000m, "Salg", "U25"), // Output VAT: 250 credit
+ new(2, "2000", 400m, 0m, "Køb", "I25") // Input VAT: 100 debit
+ };
+
+ // Act
+ var result = _sut.CalculateVat(lines, VatCalculationMode.Exclusive, InputVatAccount, OutputVatAccount);
+
+ // Assert
+ // Total = debit (input VAT asset) - credit (output VAT liability)
+ // = 100 - 250 = -150 (negative means net VAT payable to SKAT)
+ result.TotalVatAmount.Should().Be(-150m);
+ }
+
+ #endregion
+}
diff --git a/backend/Books.Api.Tests/GraphQL/AccountGraphQLTests.cs b/backend/Books.Api.Tests/GraphQL/AccountGraphQLTests.cs
new file mode 100644
index 0000000..2a479a6
--- /dev/null
+++ b/backend/Books.Api.Tests/GraphQL/AccountGraphQLTests.cs
@@ -0,0 +1,819 @@
+using Books.Api.EventFlow.Repositories;
+using Books.Api.Tests.Helpers;
+using Books.Api.Tests.Infrastructure;
+using AwesomeAssertions;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Books.Api.Tests.GraphQL;
+
+///
+/// Integration tests for Account GraphQL operations.
+/// Each test class runs with its own isolated database.
+///
+[Trait("Category", "Integration")]
+public class AccountGraphQLTests(TestWebApplicationFactory factory)
+ : IntegrationTestBase(factory)
+{
+ [Fact]
+ public async Task Query_Accounts_FailsWithoutCompanyHeader()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ // Note: We're NOT setting X-Company-Id header
+
+ // Act - query accounts without X-Company-Id header
+ var response = await graphqlClient.QueryAsync("""
+ query {
+ accounts(companyId: "nonexistent") {
+ id
+ name
+ }
+ }
+ """);
+
+ // Assert - Should fail with authorization error
+ response.HasErrors.Should().BeTrue();
+ var errorInfo = string.Join(" ", response.Errors!.Select(e =>
+ e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? "")));
+ errorInfo.Should().Contain("No company selected");
+ }
+
+ [Fact]
+ public async Task Mutation_CreateAccount_CreatesAccountSuccessfully()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+
+ // First create a company (which doesn't auto-create accounts in this test)
+ var companyId = await CreateCompanyAsync(graphqlClient, "Account Test Company");
+
+ // Act - Create an account
+ var response = await graphqlClient.MutateAsync("""
+ mutation CreateAccount($input: CreateAccountInput!) {
+ createAccount(input: $input) {
+ id
+ companyId
+ accountNumber
+ name
+ accountType
+ isActive
+ isSystemAccount
+ }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ accountNumber = "9999",
+ name = "Test Konto",
+ accountType = "EXPENSE",
+ description = "En testkonto"
+ }
+ });
+
+ // Assert
+ response.EnsureNoErrors();
+ response.Data.Should().NotBeNull();
+ response.Data!.CreateAccount.Should().NotBeNull();
+ response.Data.CreateAccount!.AccountNumber.Should().Be("9999");
+ response.Data.CreateAccount.Name.Should().Be("Test Konto");
+ response.Data.CreateAccount.AccountType.Should().Be("expense");
+ response.Data.CreateAccount.IsActive.Should().BeTrue();
+ response.Data.CreateAccount.IsSystemAccount.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task Mutation_UpdateAccount_UpdatesAccountSuccessfully()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "Update Account Test");
+
+ // Create an account
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateAccount($input: CreateAccountInput!) {
+ createAccount(input: $input) { id name }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ accountNumber = "8888",
+ name = "Original Name",
+ accountType = "EXPENSE"
+ }
+ });
+
+ createResponse.EnsureNoErrors();
+ var accountId = createResponse.Data!.CreateAccount!.Id;
+
+ // Wait for eventual consistency
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdAsync(accountId);
+ });
+
+ // Act - Update the account
+ var updateResponse = await graphqlClient.MutateAsync("""
+ mutation UpdateAccount($id: ID!, $input: UpdateAccountInput!) {
+ updateAccount(id: $id, input: $input) {
+ id
+ name
+ description
+ }
+ }
+ """,
+ new
+ {
+ id = accountId,
+ input = new
+ {
+ name = "Updated Name",
+ description = "Updated description"
+ }
+ });
+
+ // Assert
+ updateResponse.EnsureNoErrors();
+
+ var updatedAccount = await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var account = await repo.GetByIdAsync(accountId);
+ return account?.Name == "Updated Name" ? account : null;
+ });
+
+ updatedAccount.Should().NotBeNull();
+ updatedAccount!.Name.Should().Be("Updated Name");
+ updatedAccount.Description.Should().Be("Updated description");
+ }
+
+ [Fact]
+ public async Task Mutation_DeactivateAccount_DeactivatesAccountSuccessfully()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "Deactivate Account Test");
+
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateAccount($input: CreateAccountInput!) {
+ createAccount(input: $input) { id isActive }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ accountNumber = "7777",
+ name = "Deactivate Test",
+ accountType = "EXPENSE"
+ }
+ });
+
+ createResponse.EnsureNoErrors();
+ var accountId = createResponse.Data!.CreateAccount!.Id;
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdAsync(accountId);
+ });
+
+ // Act
+ var deactivateResponse = await graphqlClient.MutateAsync("""
+ mutation DeactivateAccount($id: ID!) {
+ deactivateAccount(id: $id) {
+ id
+ isActive
+ }
+ }
+ """,
+ new { id = accountId });
+
+ // Assert
+ deactivateResponse.EnsureNoErrors();
+
+ var deactivatedAccount = await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var account = await repo.GetByIdAsync(accountId);
+ return account?.IsActive == false ? account : null;
+ });
+
+ deactivatedAccount.Should().NotBeNull();
+ deactivatedAccount!.IsActive.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task Mutation_ReactivateAccount_ReactivatesAccountSuccessfully()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "Reactivate Account Test");
+
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateAccount($input: CreateAccountInput!) {
+ createAccount(input: $input) { id }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ accountNumber = "6666",
+ name = "Reactivate Test",
+ accountType = "EXPENSE"
+ }
+ });
+
+ createResponse.EnsureNoErrors();
+ var accountId = createResponse.Data!.CreateAccount!.Id;
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdAsync(accountId);
+ });
+
+ // Deactivate first
+ await graphqlClient.MutateAsync("""
+ mutation { deactivateAccount(id: $id) { id } }
+ """.Replace("$id", $"\"{accountId}\""));
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var account = await repo.GetByIdAsync(accountId);
+ return account?.IsActive == false ? account : null;
+ });
+
+ // Act
+ var reactivateResponse = await graphqlClient.MutateAsync("""
+ mutation ReactivateAccount($id: ID!) {
+ reactivateAccount(id: $id) {
+ id
+ isActive
+ }
+ }
+ """,
+ new { id = accountId });
+
+ // Assert
+ reactivateResponse.EnsureNoErrors();
+
+ var reactivatedAccount = await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var account = await repo.GetByIdAsync(accountId);
+ return account?.IsActive == true ? account : null;
+ });
+
+ reactivatedAccount.Should().NotBeNull();
+ reactivatedAccount!.IsActive.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Query_ActiveAccounts_ExcludesDeactivatedAccounts()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "Active Accounts Test");
+
+ // Wait for standard accounts to be created
+ var initialAccounts = await Eventually.GetListAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByCompanyIdAsync(companyId);
+ }, 50, timeout: TimeSpan.FromSeconds(30)); // At least 50 standard accounts
+
+ var initialActiveCount = initialAccounts.Count(a => a.IsActive);
+
+ // Create a custom account
+ var customAccountId = await CreateAccountAsync(graphqlClient, companyId, "5553", "Custom To Deactivate");
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdAsync(customAccountId);
+ });
+
+ // Deactivate the custom account
+ await graphqlClient.MutateAsync(
+ $"mutation {{ deactivateAccount(id: \"{customAccountId}\") {{ id }} }}");
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var account = await repo.GetByIdAsync(customAccountId);
+ return account?.IsActive == false ? account : null;
+ });
+
+ // Act
+ var response = await graphqlClient.QueryAsync("""
+ query ActiveAccounts($companyId: ID!) {
+ activeAccounts(companyId: $companyId) {
+ id
+ name
+ isActive
+ }
+ }
+ """,
+ new { companyId });
+
+ // Assert
+ response.EnsureNoErrors();
+ // Should have initial active accounts (deactivated one is excluded)
+ response.Data!.ActiveAccounts.Should().HaveCount(initialActiveCount);
+ response.Data.ActiveAccounts.Should().AllSatisfy(a => a.IsActive.Should().BeTrue());
+ response.Data.ActiveAccounts.Should().NotContain(a => a.Id == customAccountId);
+ }
+
+ [Fact]
+ public async Task Mutation_CreateAccount_FailsWithInvalidAccountNumber()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "Invalid Number Test");
+
+ // Act - Try to create account with invalid number (too short)
+ var response = await graphqlClient.MutateAsync("""
+ mutation CreateAccount($input: CreateAccountInput!) {
+ createAccount(input: $input) {
+ id
+ }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ accountNumber = "12", // Invalid - must be 4-10 digits
+ name = "Invalid Account",
+ accountType = "EXPENSE"
+ }
+ });
+
+ // Assert - Domain validation error is wrapped by GraphQL
+ response.HasErrors.Should().BeTrue();
+ // Check that the error is related to account validation (could be in message or extensions)
+ var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
+ var hasAccountError = response.Errors!.Any(e =>
+ e.Message.Contains("INVALID_ACCOUNT_NUMBER") ||
+ e.Message.Contains("createAccount") ||
+ errorDetails.Contains("INVALID_ACCOUNT_NUMBER") ||
+ errorDetails.Contains("4-10 digits"));
+ hasAccountError.Should().BeTrue("Expected an error related to invalid account number");
+ }
+
+ [Fact]
+ public async Task Mutation_UpdateAccount_FailsForSystemAccount()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "System Account Update Test");
+
+ // Wait for chart of accounts to be initialized
+ var systemAccount = await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var accounts = await repo.GetByCompanyIdAsync(companyId);
+ return accounts.FirstOrDefault(a => a.IsSystemAccount);
+ }, timeout: TimeSpan.FromSeconds(30));
+
+ systemAccount.Should().NotBeNull("Expected at least one system account from chart initialization");
+
+ // Act - Try to update a system account
+ var response = await graphqlClient.MutateAsync("""
+ mutation UpdateAccount($id: ID!, $input: UpdateAccountInput!) {
+ updateAccount(id: $id, input: $input) {
+ id
+ name
+ }
+ }
+ """,
+ new
+ {
+ id = systemAccount!.Id,
+ input = new { name = "Attempting to rename system account" }
+ });
+
+ // Assert
+ response.HasErrors.Should().BeTrue();
+ var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
+ (response.Errors!.Any(e => e.Message.Contains("SYSTEM_ACCOUNT_READONLY")) ||
+ errorDetails.Contains("SYSTEM_ACCOUNT_READONLY") ||
+ errorDetails.Contains("System accounts cannot be modified")).Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Mutation_DeactivateAccount_FailsForSystemAccount()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "System Account Deactivate Test");
+
+ // Wait for chart of accounts to be initialized
+ var systemAccount = await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var accounts = await repo.GetByCompanyIdAsync(companyId);
+ return accounts.FirstOrDefault(a => a.IsSystemAccount);
+ }, timeout: TimeSpan.FromSeconds(30));
+
+ systemAccount.Should().NotBeNull("Expected at least one system account from chart initialization");
+
+ // Act - Try to deactivate a system account
+ var response = await graphqlClient.MutateAsync("""
+ mutation DeactivateAccount($id: ID!) {
+ deactivateAccount(id: $id) {
+ id
+ isActive
+ }
+ }
+ """,
+ new { id = systemAccount!.Id });
+
+ // Assert
+ response.HasErrors.Should().BeTrue();
+ var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
+ (response.Errors!.Any(e => e.Message.Contains("SYSTEM_ACCOUNT_READONLY")) ||
+ errorDetails.Contains("SYSTEM_ACCOUNT_READONLY") ||
+ errorDetails.Contains("System accounts cannot be deactivated")).Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Mutation_DeactivateAccount_FailsWhenAlreadyInactive()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "Double Deactivate Test");
+ var accountId = await CreateAccountAsync(graphqlClient, companyId, "4444", "Double Deactivate");
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdAsync(accountId);
+ });
+
+ // First deactivation - should succeed
+ await graphqlClient.MutateAsync("""
+ mutation DeactivateAccount($id: ID!) {
+ deactivateAccount(id: $id) { id }
+ }
+ """,
+ new { id = accountId });
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var account = await repo.GetByIdAsync(accountId);
+ return account?.IsActive == false ? account : null;
+ });
+
+ // Act - Try to deactivate again
+ var response = await graphqlClient.MutateAsync("""
+ mutation DeactivateAccount($id: ID!) {
+ deactivateAccount(id: $id) {
+ id
+ isActive
+ }
+ }
+ """,
+ new { id = accountId });
+
+ // Assert
+ response.HasErrors.Should().BeTrue();
+ var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
+ (response.Errors!.Any(e => e.Message.Contains("ACCOUNT_ALREADY_INACTIVE")) ||
+ errorDetails.Contains("ACCOUNT_ALREADY_INACTIVE") ||
+ errorDetails.Contains("already inactive")).Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Mutation_ReactivateAccount_FailsWhenAlreadyActive()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "Reactivate Active Test");
+ var accountId = await CreateAccountAsync(graphqlClient, companyId, "3333", "Already Active");
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdAsync(accountId);
+ });
+
+ // Act - Try to reactivate an already active account
+ var response = await graphqlClient.MutateAsync("""
+ mutation ReactivateAccount($id: ID!) {
+ reactivateAccount(id: $id) {
+ id
+ isActive
+ }
+ }
+ """,
+ new { id = accountId });
+
+ // Assert
+ response.HasErrors.Should().BeTrue();
+ var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
+ (response.Errors!.Any(e => e.Message.Contains("ACCOUNT_ALREADY_ACTIVE")) ||
+ errorDetails.Contains("ACCOUNT_ALREADY_ACTIVE") ||
+ errorDetails.Contains("already active")).Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Mutation_CreateAccount_AcceptsMinimumDigits()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "Min Digits Test");
+
+ // Act - Create account with exactly 4 digits (minimum allowed)
+ // Use a number not in the standard chart of accounts
+ var response = await graphqlClient.MutateAsync("""
+ mutation CreateAccount($input: CreateAccountInput!) {
+ createAccount(input: $input) {
+ id
+ accountNumber
+ }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ accountNumber = "9990", // Exactly 4 digits, not in standard chart
+ name = "Minimum Digits Account",
+ accountType = "ASSET"
+ }
+ });
+
+ // Assert - Should succeed
+ response.EnsureNoErrors();
+ response.Data!.CreateAccount!.AccountNumber.Should().Be("9990");
+ }
+
+ [Fact]
+ public async Task Mutation_CreateAccount_FailsWithTooFewDigits()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "Too Few Digits Test");
+
+ // Act - Create account with 3 digits (below minimum)
+ var response = await graphqlClient.MutateAsync("""
+ mutation CreateAccount($input: CreateAccountInput!) {
+ createAccount(input: $input) {
+ id
+ }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ accountNumber = "999", // Only 3 digits
+ name = "Too Few Digits",
+ accountType = "EXPENSE"
+ }
+ });
+
+ // Assert
+ response.HasErrors.Should().BeTrue();
+ var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
+ (response.Errors!.Any(e => e.Message.Contains("INVALID_ACCOUNT_NUMBER")) ||
+ errorDetails.Contains("INVALID_ACCOUNT_NUMBER") ||
+ errorDetails.Contains("4-10 digits")).Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Mutation_CreateAccount_IsDeterministic()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "Deterministic ID Test");
+
+ // Act - Create same account twice (use number not in standard chart)
+ var response1 = await graphqlClient.MutateAsync("""
+ mutation CreateAccount($input: CreateAccountInput!) {
+ createAccount(input: $input) {
+ id
+ accountNumber
+ }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ accountNumber = "9991",
+ name = "Deterministic Test",
+ accountType = "EXPENSE"
+ }
+ });
+
+ response1.EnsureNoErrors();
+ var firstId = response1.Data!.CreateAccount!.Id;
+
+ // Wait for eventual consistency
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdAsync(firstId);
+ });
+
+ // Second attempt with same input
+ var response2 = await graphqlClient.MutateAsync("""
+ mutation CreateAccount($input: CreateAccountInput!) {
+ createAccount(input: $input) {
+ id
+ accountNumber
+ }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ accountNumber = "9991",
+ name = "Deterministic Test",
+ accountType = "EXPENSE"
+ }
+ });
+
+ // Assert - Second call should fail with ACCOUNT_EXISTS error (idempotency)
+ response2.HasErrors.Should().BeTrue();
+ var errorDetails = response2.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
+ (response2.Errors!.Any(e => e.Message.Contains("ACCOUNT_EXISTS")) ||
+ errorDetails.Contains("ACCOUNT_EXISTS") ||
+ errorDetails.Contains("already exists")).Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Mutation_UpdateAccount_FailsForNonExistentAccount()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var nonExistentAccountId = $"account-{Guid.NewGuid():N}";
+
+ // Act - Try to update a non-existent account
+ var response = await graphqlClient.MutateAsync("""
+ mutation UpdateAccount($id: ID!, $input: UpdateAccountInput!) {
+ updateAccount(id: $id, input: $input) {
+ id
+ name
+ }
+ }
+ """,
+ new
+ {
+ id = nonExistentAccountId,
+ input = new { name = "New Name" }
+ });
+
+ // Assert - Domain exception is wrapped by GraphQL/EventFlow
+ response.HasErrors.Should().BeTrue();
+ var errorInfo = string.Join(" ", response.Errors!.Select(e =>
+ e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? "")));
+ (errorInfo.Contains("ACCOUNT_NOT_FOUND") ||
+ errorInfo.Contains("Account does not exist") ||
+ errorInfo.Contains("does not exist") ||
+ errorInfo.Contains("updateAccount")).Should().BeTrue(
+ $"Expected account not found error, got: {errorInfo}");
+ }
+
+ [Fact]
+ public async Task Mutation_DeactivateAccount_FailsForNonExistentAccount()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var nonExistentAccountId = $"account-{Guid.NewGuid():N}";
+
+ // Act - Try to deactivate a non-existent account
+ var response = await graphqlClient.MutateAsync("""
+ mutation DeactivateAccount($id: ID!) {
+ deactivateAccount(id: $id) {
+ id
+ isActive
+ }
+ }
+ """,
+ new { id = nonExistentAccountId });
+
+ // Assert - Domain exception is wrapped by GraphQL/EventFlow
+ response.HasErrors.Should().BeTrue();
+ var errorInfo = string.Join(" ", response.Errors!.Select(e =>
+ e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? "")));
+ (errorInfo.Contains("ACCOUNT_NOT_FOUND") ||
+ errorInfo.Contains("Account does not exist") ||
+ errorInfo.Contains("does not exist") ||
+ errorInfo.Contains("deactivateAccount")).Should().BeTrue(
+ $"Expected account not found error, got: {errorInfo}");
+ }
+
+ [Fact]
+ public async Task Mutation_ReactivateAccount_FailsForNonExistentAccount()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var nonExistentAccountId = $"account-{Guid.NewGuid():N}";
+
+ // Act - Try to reactivate a non-existent account
+ var response = await graphqlClient.MutateAsync("""
+ mutation ReactivateAccount($id: ID!) {
+ reactivateAccount(id: $id) {
+ id
+ isActive
+ }
+ }
+ """,
+ new { id = nonExistentAccountId });
+
+ // Assert - Domain exception is wrapped by GraphQL/EventFlow
+ response.HasErrors.Should().BeTrue();
+ var errorInfo = string.Join(" ", response.Errors!.Select(e =>
+ e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? "")));
+ (errorInfo.Contains("ACCOUNT_NOT_FOUND") ||
+ errorInfo.Contains("Account does not exist") ||
+ errorInfo.Contains("does not exist") ||
+ errorInfo.Contains("reactivateAccount")).Should().BeTrue(
+ $"Expected account not found error, got: {errorInfo}");
+ }
+
+ private async Task CreateCompanyAsync(GraphQLTestClient client, string name)
+ {
+ var response = await client.MutateAsync("""
+ mutation CreateCompany($input: CreateCompanyInput!) {
+ createCompany(input: $input) { id }
+ }
+ """,
+ new { input = new { name } });
+
+ response.EnsureNoErrors();
+ var companyId = response.Data!.CreateCompany!.Id;
+
+ // Set the company ID header for subsequent requests
+ client.SetCompanyId(companyId);
+
+ return companyId;
+ }
+
+ private async Task CreateAccountAsync(GraphQLTestClient client, string companyId, string number, string name)
+ {
+ var response = await client.MutateAsync("""
+ mutation CreateAccount($input: CreateAccountInput!) {
+ createAccount(input: $input) { id }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ accountNumber = number,
+ name,
+ accountType = "EXPENSE"
+ }
+ });
+
+ response.EnsureNoErrors();
+ return response.Data!.CreateAccount!.Id;
+ }
+
+ // Response DTOs
+ private class AccountsResponse { public List Accounts { get; set; } = []; }
+ private class ActiveAccountsResponse { public List ActiveAccounts { get; set; } = []; }
+ private class AccountResponse { public AccountDto? Account { get; set; } }
+ private class CreateAccountResponse { public AccountDto? CreateAccount { get; set; } }
+ private class UpdateAccountResponse { public AccountDto? UpdateAccount { get; set; } }
+ private class DeactivateAccountResponse { public AccountDto? DeactivateAccount { get; set; } }
+ private class ReactivateAccountResponse { public AccountDto? ReactivateAccount { get; set; } }
+ private class CreateCompanyResponse { public CompanyDto? CreateCompany { get; set; } }
+
+ private class AccountDto
+ {
+ public string Id { get; set; } = string.Empty;
+ public string CompanyId { get; set; } = string.Empty;
+ public string AccountNumber { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public string AccountType { get; set; } = string.Empty;
+ public string? Description { get; set; }
+ public bool IsActive { get; set; }
+ public bool IsSystemAccount { get; set; }
+ }
+
+ private class CompanyDto { public string Id { get; set; } = string.Empty; }
+}
diff --git a/backend/Books.Api.Tests/GraphQL/ChartOfAccountsInitializerTests.cs b/backend/Books.Api.Tests/GraphQL/ChartOfAccountsInitializerTests.cs
new file mode 100644
index 0000000..8eae285
--- /dev/null
+++ b/backend/Books.Api.Tests/GraphQL/ChartOfAccountsInitializerTests.cs
@@ -0,0 +1,475 @@
+using Books.Api.EventFlow.Repositories;
+using Books.Api.EventFlow.Subscribers;
+using Books.Api.Tests.Helpers;
+using Books.Api.Tests.Infrastructure;
+using AwesomeAssertions;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Books.Api.Tests.GraphQL;
+
+///
+/// Integration tests for ChartOfAccountsInitializer.
+/// Verifies that creating a company automatically bootstraps the standard Danish chart of accounts.
+///
+[Trait("Category", "Integration")]
+public class ChartOfAccountsInitializerTests(TestWebApplicationFactory factory)
+ : IntegrationTestBase(factory)
+{
+ [Fact]
+ public async Task CreateCompany_AutomaticallyCreatesStandardAccounts()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var expectedAccountCount = StandardDanishAccounts.GetAll().Count();
+
+ // Act - Create a company
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateCompany($input: CreateCompanyInput!) {
+ createCompany(input: $input) {
+ id
+ name
+ }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ name = "Auto-Account Test Virksomhed"
+ }
+ });
+
+ createResponse.EnsureNoErrors();
+ var companyId = createResponse.Data!.CreateCompany!.Id;
+
+ // Assert - Wait for accounts to be created (eventually consistent)
+ var accounts = await Eventually.GetListAsync(
+ async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByCompanyIdAsync(companyId);
+ },
+ expectedAccountCount,
+ timeout: TimeSpan.FromSeconds(30), // Allow more time for many accounts
+ failMessage: $"Expected {expectedAccountCount} accounts to be created");
+
+ accounts.Should().HaveCount(expectedAccountCount);
+ }
+
+ [Fact]
+ public async Task CreateCompany_CreatesRevenueAccounts()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+
+ // Act
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateCompany($input: CreateCompanyInput!) {
+ createCompany(input: $input) { id }
+ }
+ """,
+ new { input = new { name = "Revenue Test" } });
+
+ createResponse.EnsureNoErrors();
+ var companyId = createResponse.Data!.CreateCompany!.Id;
+
+ // Assert - Check revenue accounts (1xxx)
+ var accounts = await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var all = await repo.GetByCompanyIdAsync(companyId);
+ var revenue = all.Where(a => a.AccountType == "revenue").ToList();
+ return revenue.Count >= 5 ? revenue : null;
+ }, timeout: TimeSpan.FromSeconds(30));
+
+ accounts.Should().NotBeEmpty();
+ accounts.Should().Contain(a => a.AccountNumber == "1000"); // Salg af varer, DK
+ accounts.Should().Contain(a => a.AccountNumber == "1200"); // Salg af ydelser, DK
+ accounts.Should().OnlyContain(a => a.AccountType == "revenue");
+ }
+
+ [Fact]
+ public async Task CreateCompany_CreatesExpenseAccounts()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+
+ // Act
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateCompany($input: CreateCompanyInput!) {
+ createCompany(input: $input) { id }
+ }
+ """,
+ new { input = new { name = "Expense Test" } });
+
+ createResponse.EnsureNoErrors();
+ var companyId = createResponse.Data!.CreateCompany!.Id;
+
+ // Assert
+ var accounts = await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var all = await repo.GetByCompanyIdAsync(companyId);
+ var expenses = all.Where(a => a.AccountType == "expense").ToList();
+ return expenses.Count >= 10 ? expenses : null;
+ }, timeout: TimeSpan.FromSeconds(30));
+
+ accounts.Should().NotBeEmpty();
+ accounts.Should().Contain(a => a.AccountNumber == "4000"); // Annoncer og reklame
+ accounts.Should().Contain(a => a.AccountNumber == "5000"); // Husleje
+ accounts.Should().Contain(a => a.AccountNumber == "7320"); // Køb af software
+ }
+
+ [Fact]
+ public async Task CreateCompany_CreatesSystemAccounts()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+
+ // Act
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateCompany($input: CreateCompanyInput!) {
+ createCompany(input: $input) { id }
+ }
+ """,
+ new { input = new { name = "System Accounts Test" } });
+
+ createResponse.EnsureNoErrors();
+ var companyId = createResponse.Data!.CreateCompany!.Id;
+
+ // Assert - Check system accounts exist (we have many: equity, revenue, debtors, creditors, tax)
+ var accounts = await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var all = await repo.GetByCompanyIdAsync(companyId);
+ var system = all.Where(a => a.IsSystemAccount).ToList();
+ return system.Count >= 15 ? system : null;
+ }, timeout: TimeSpan.FromSeconds(30));
+
+ accounts.Should().NotBeEmpty();
+ accounts.Should().Contain(a => a.AccountNumber == "3000"); // Aktiekapital
+ accounts.Should().Contain(a => a.AccountNumber == "3900"); // Overført resultat
+ accounts.Should().Contain(a => a.AccountNumber == "3910"); // Årets resultat
+ accounts.Should().Contain(a => a.AccountNumber == "1900"); // Debitorer
+ accounts.Should().Contain(a => a.AccountNumber == "6900"); // Kreditorer
+ accounts.Should().Contain(a => a.AccountNumber == "7950"); // Skyldig selskabsskat
+ accounts.Should().OnlyContain(a => a.IsSystemAccount);
+ }
+
+ [Fact]
+ public async Task CreateCompany_CreatesLiabilityAccounts()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+
+ // Act
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateCompany($input: CreateCompanyInput!) {
+ createCompany(input: $input) { id }
+ }
+ """,
+ new { input = new { name = "Liability Test" } });
+
+ createResponse.EnsureNoErrors();
+ var companyId = createResponse.Data!.CreateCompany!.Id;
+
+ // Assert
+ var accounts = await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var all = await repo.GetByCompanyIdAsync(companyId);
+ var liabilities = all.Where(a => a.AccountType == "liability").ToList();
+ return liabilities.Count >= 5 ? liabilities : null;
+ }, timeout: TimeSpan.FromSeconds(30));
+
+ accounts.Should().NotBeEmpty();
+ accounts.Should().Contain(a => a.AccountNumber == "6900"); // Kreditorer
+ accounts.Should().Contain(a => a.AccountNumber == "7900"); // Skyldig moms
+ accounts.Should().Contain(a => a.AccountNumber == "7910"); // Skyldig A-skat
+ }
+
+ [Fact]
+ public async Task CreateCompany_AccountsHaveCorrectVatCodes()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+
+ // Act
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateCompany($input: CreateCompanyInput!) {
+ createCompany(input: $input) { id }
+ }
+ """,
+ new { input = new { name = "VAT Code Test" } });
+
+ createResponse.EnsureNoErrors();
+ var companyId = createResponse.Data!.CreateCompany!.Id;
+
+ // Assert - Check that accounts have correct VAT codes
+ var accounts = await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var all = await repo.GetByCompanyIdAsync(companyId);
+ return all.Count >= 50 ? all.ToList() : null;
+ }, timeout: TimeSpan.FromSeconds(30));
+
+ // Vareforbrug should have I25 (25% input VAT)
+ var vareforbrug = accounts!.FirstOrDefault(a => a.AccountNumber == "2000");
+ vareforbrug.Should().NotBeNull();
+ vareforbrug!.VatCodeId.Should().Be("I25");
+
+ // Salg af varer DK should have U25 (25% output VAT)
+ var salgVarer = accounts.FirstOrDefault(a => a.AccountNumber == "1000");
+ salgVarer.Should().NotBeNull();
+ salgVarer!.VatCodeId.Should().Be("U25");
+
+ // Bankrenter should have no VAT
+ var bankrenter = accounts.FirstOrDefault(a => a.AccountNumber == "9200");
+ bankrenter.Should().NotBeNull();
+ bankrenter!.VatCodeId.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task Query_AccountsViaGraphQL_ReturnsBootstrappedAccounts()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+
+ // Create company
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateCompany($input: CreateCompanyInput!) {
+ createCompany(input: $input) { id }
+ }
+ """,
+ new { input = new { name = "GraphQL Query Test" } });
+
+ createResponse.EnsureNoErrors();
+ var companyId = createResponse.Data!.CreateCompany!.Id;
+
+ // Set company ID header for subsequent requests
+ graphqlClient.SetCompanyId(companyId);
+
+ // Wait for accounts to be created
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var all = await repo.GetByCompanyIdAsync(companyId);
+ return all.Count >= 50 ? all : null;
+ }, timeout: TimeSpan.FromSeconds(30));
+
+ // Act - Query via GraphQL
+ var queryResponse = await graphqlClient.QueryAsync("""
+ query Accounts($companyId: ID!) {
+ accounts(companyId: $companyId) {
+ id
+ accountNumber
+ name
+ accountType
+ isSystemAccount
+ }
+ }
+ """,
+ new { companyId });
+
+ // Assert
+ queryResponse.EnsureNoErrors();
+ queryResponse.Data!.Accounts.Should().NotBeEmpty();
+ queryResponse.Data.Accounts.Should().Contain(a => a.AccountNumber == "2000");
+ queryResponse.Data.Accounts.Should().Contain(a => a.Name == "Vareforbrug");
+ }
+
+ [Fact]
+ public async Task ChartOfAccountsInitializer_IsIdempotent_CalledTwice()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var expectedAccountCount = StandardDanishAccounts.GetAll().Count();
+
+ // Create company (which triggers first initialization)
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateCompany($input: CreateCompanyInput!) {
+ createCompany(input: $input) { id }
+ }
+ """,
+ new { input = new { name = "Idempotency Test Company" } });
+
+ createResponse.EnsureNoErrors();
+ var companyId = createResponse.Data!.CreateCompany!.Id;
+
+ // Wait for first initialization to complete
+ var initialAccounts = await Eventually.GetListAsync(
+ async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByCompanyIdAsync(companyId);
+ },
+ expectedAccountCount,
+ timeout: TimeSpan.FromSeconds(30));
+
+ initialAccounts.Should().HaveCount(expectedAccountCount);
+
+ // Act - Call initializer again (should be idempotent)
+ var initializer = GetService();
+ await initializer.InitializeAsync(companyId);
+
+ // Assert - Should still have the same number of accounts (not doubled)
+ var accountsAfterSecondInit = await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByCompanyIdAsync(companyId);
+ });
+
+ accountsAfterSecondInit.Should().HaveCount(expectedAccountCount,
+ "Calling initializer twice should not create duplicate accounts");
+ }
+
+ #region Danish Compliance: Chart of Accounts Structure Tests
+
+ [Fact]
+ public async Task ChartOfAccounts_ContainsShareCapitalAccount()
+ {
+ // Arrange - Danish companies are required to have an equity account for share capital
+ var graphqlClient = new GraphQLTestClient(Client);
+
+ // Act
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateCompany($input: CreateCompanyInput!) {
+ createCompany(input: $input) { id }
+ }
+ """,
+ new { input = new { name = "Share Capital Test" } });
+
+ createResponse.EnsureNoErrors();
+ var companyId = createResponse.Data!.CreateCompany!.Id;
+
+ // Assert - Check that Aktiekapital (3000) exists
+ var accounts = await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var all = await repo.GetByCompanyIdAsync(companyId);
+ return all.Any(a => a.AccountNumber == "3000") ? all.ToList() : null;
+ }, timeout: TimeSpan.FromSeconds(30));
+
+ var shareCapital = accounts!.FirstOrDefault(a => a.AccountNumber == "3000");
+ shareCapital.Should().NotBeNull("Aktiekapital/Anpartskapital account (3000) is required per Selskabsloven");
+ shareCapital!.AccountType.Should().Be("equity");
+ shareCapital.IsSystemAccount.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task ChartOfAccounts_ContainsCorporateTaxAccount()
+ {
+ // Arrange - Danish companies need a liability account for corporate tax
+ var graphqlClient = new GraphQLTestClient(Client);
+
+ // Act
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateCompany($input: CreateCompanyInput!) {
+ createCompany(input: $input) { id }
+ }
+ """,
+ new { input = new { name = "Corporate Tax Test" } });
+
+ createResponse.EnsureNoErrors();
+ var companyId = createResponse.Data!.CreateCompany!.Id;
+
+ // Assert - Check that Skyldig selskabsskat (7950) exists
+ var accounts = await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var all = await repo.GetByCompanyIdAsync(companyId);
+ return all.Any(a => a.AccountNumber == "7950") ? all.ToList() : null;
+ }, timeout: TimeSpan.FromSeconds(30));
+
+ var corporateTax = accounts!.FirstOrDefault(a => a.AccountNumber == "7950");
+ corporateTax.Should().NotBeNull("Skyldig selskabsskat account (7950) is required per Selskabsloven");
+ corporateTax!.AccountType.Should().Be("liability");
+ corporateTax.IsSystemAccount.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task ChartOfAccounts_HasProperEquityRange()
+ {
+ // Arrange - Per Danish accounting standards, accounts 3000-3999 should only be Equity
+ var graphqlClient = new GraphQLTestClient(Client);
+
+ // Act
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateCompany($input: CreateCompanyInput!) {
+ createCompany(input: $input) { id }
+ }
+ """,
+ new { input = new { name = "Equity Range Test" } });
+
+ createResponse.EnsureNoErrors();
+ var companyId = createResponse.Data!.CreateCompany!.Id;
+
+ // Assert - Check that all 3xxx accounts are Equity type
+ var accounts = await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var all = await repo.GetByCompanyIdAsync(companyId);
+ var equityRange = all.Where(a =>
+ int.TryParse(a.AccountNumber, out var num) && num >= 3000 && num < 4000).ToList();
+ return equityRange.Count >= 3 ? equityRange : null;
+ }, timeout: TimeSpan.FromSeconds(30));
+
+ accounts.Should().NotBeEmpty("3xxx range should have equity accounts");
+ accounts.Should().OnlyContain(
+ a => a.AccountType == "equity",
+ "Per Danish accounting standards, 3xxx range should ONLY contain equity accounts");
+ }
+
+ [Fact]
+ public async Task ChartOfAccounts_ContainsFixedAssetAccounts()
+ {
+ // Arrange - Danish chart of accounts should include fixed asset accounts for depreciation
+ var graphqlClient = new GraphQLTestClient(Client);
+
+ // Act
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateCompany($input: CreateCompanyInput!) {
+ createCompany(input: $input) { id }
+ }
+ """,
+ new { input = new { name = "Fixed Assets Test" } });
+
+ createResponse.EnsureNoErrors();
+ var companyId = createResponse.Data!.CreateCompany!.Id;
+
+ // Assert - Check that fixed asset accounts exist (1710-1730)
+ var accounts = await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var all = await repo.GetByCompanyIdAsync(companyId);
+ var fixedAssets = all.Where(a => new[] { "1710", "1720", "1730" }.Contains(a.AccountNumber)).ToList();
+ return fixedAssets.Count >= 3 ? all.ToList() : null;
+ }, timeout: TimeSpan.FromSeconds(30));
+
+ accounts.Should().Contain(a => a.AccountNumber == "1710", "Bygninger account required");
+ accounts.Should().Contain(a => a.AccountNumber == "1720", "Maskiner og inventar account required");
+ accounts.Should().Contain(a => a.AccountNumber == "1730", "Køretøjer account required");
+
+ // Also verify corresponding depreciation accounts exist
+ accounts.Should().Contain(a => a.AccountNumber == "8010", "Afskrivning, bygninger account required");
+ accounts.Should().Contain(a => a.AccountNumber == "8020", "Afskrivning, maskiner account required");
+ accounts.Should().Contain(a => a.AccountNumber == "8030", "Afskrivning, køretøjer account required");
+ }
+
+ #endregion
+
+ // Response DTOs
+ private class CreateCompanyResponse { public CompanyDto? CreateCompany { get; set; } }
+ private class AccountsResponse { public List Accounts { get; set; } = []; }
+ private class CompanyDto { public string Id { get; set; } = string.Empty; }
+
+ private class AccountDto
+ {
+ public string Id { get; set; } = string.Empty;
+ public string AccountNumber { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public string AccountType { get; set; } = string.Empty;
+ public string? VatCodeId { get; set; }
+ public bool IsSystemAccount { get; set; }
+ }
+}
diff --git a/backend/Books.Api.Tests/GraphQL/FiscalYearGraphQLTests.cs b/backend/Books.Api.Tests/GraphQL/FiscalYearGraphQLTests.cs
new file mode 100644
index 0000000..2f350a5
--- /dev/null
+++ b/backend/Books.Api.Tests/GraphQL/FiscalYearGraphQLTests.cs
@@ -0,0 +1,1102 @@
+using Books.Api.EventFlow.Repositories;
+using Books.Api.Tests.Helpers;
+using Books.Api.Tests.Infrastructure;
+using AwesomeAssertions;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Books.Api.Tests.GraphQL;
+
+///
+/// Integration tests for FiscalYear GraphQL operations.
+///
+[Trait("Category", "Integration")]
+public class FiscalYearGraphQLTests(TestWebApplicationFactory factory)
+ : IntegrationTestBase(factory)
+{
+ [Fact]
+ public async Task Query_FiscalYears_ReturnsEmptyList_WhenNoFiscalYearsExist()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY Empty Test");
+
+ // Act
+ var response = await graphqlClient.QueryAsync("""
+ query FiscalYears($companyId: ID!) {
+ fiscalYears(companyId: $companyId) {
+ id
+ name
+ }
+ }
+ """,
+ new { companyId });
+
+ // Assert
+ response.EnsureNoErrors();
+ response.Data.Should().NotBeNull();
+ response.Data!.FiscalYears.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task Mutation_CreateFiscalYear_CreatesFiscalYearSuccessfully()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY Create Test");
+
+ // Act
+ var response = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) {
+ id
+ companyId
+ name
+ startDate
+ endDate
+ status
+ openingBalancePosted
+ }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "2024",
+ startDate = "2024-01-01T00:00:00",
+ endDate = "2025-01-01T00:00:00" // Exactly 12 months
+ }
+ });
+
+ // Assert
+ response.EnsureNoErrors();
+ response.Data.Should().NotBeNull();
+ response.Data!.CreateFiscalYear.Should().NotBeNull();
+ response.Data.CreateFiscalYear!.Name.Should().Be("2024");
+ response.Data.CreateFiscalYear.Status.Should().Be("open");
+ response.Data.CreateFiscalYear.OpeningBalancePosted.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task Mutation_CloseFiscalYear_ClosesFiscalYearSuccessfully()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY Close Test");
+
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) { id status }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "2023",
+ startDate = "2023-01-01T00:00:00",
+ endDate = "2024-01-01T00:00:00" // Exactly 12 months
+ }
+ });
+
+ createResponse.EnsureNoErrors();
+ var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id;
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdAsync(fiscalYearId);
+ });
+
+ // Act
+ var closeResponse = await graphqlClient.MutateAsync("""
+ mutation CloseFiscalYear($id: ID!) {
+ closeFiscalYear(id: $id) {
+ id
+ status
+ closingDate
+ closedBy
+ }
+ }
+ """,
+ new { id = fiscalYearId });
+
+ // Assert
+ closeResponse.EnsureNoErrors();
+
+ var closedFiscalYear = await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var fy = await repo.GetByIdAsync(fiscalYearId);
+ return fy?.Status == "closed" ? fy : null;
+ });
+
+ closedFiscalYear.Should().NotBeNull();
+ closedFiscalYear!.Status.Should().Be("closed");
+ closedFiscalYear.ClosedBy.Should().NotBeNullOrEmpty();
+ }
+
+ [Fact]
+ public async Task Mutation_ReopenFiscalYear_ReopensFiscalYearSuccessfully()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY Reopen Test");
+
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) { id }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "2022",
+ startDate = "2022-01-01T00:00:00",
+ endDate = "2023-01-01T00:00:00" // Exactly 12 months
+ }
+ });
+
+ var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id;
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdAsync(fiscalYearId);
+ });
+
+ // Close first
+ await graphqlClient.MutateAsync(
+ $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var fy = await repo.GetByIdAsync(fiscalYearId);
+ return fy?.Status == "closed" ? fy : null;
+ });
+
+ // Act
+ var reopenResponse = await graphqlClient.MutateAsync("""
+ mutation ReopenFiscalYear($id: ID!) {
+ reopenFiscalYear(id: $id) {
+ id
+ status
+ }
+ }
+ """,
+ new { id = fiscalYearId });
+
+ // Assert
+ reopenResponse.EnsureNoErrors();
+
+ var reopenedFiscalYear = await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var fy = await repo.GetByIdAsync(fiscalYearId);
+ return fy?.Status == "open" ? fy : null;
+ });
+
+ reopenedFiscalYear.Should().NotBeNull();
+ reopenedFiscalYear!.Status.Should().Be("open");
+ }
+
+ [Fact]
+ public async Task Mutation_LockFiscalYear_LocksFiscalYearSuccessfully()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY Lock Test");
+
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) { id }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "2021",
+ startDate = "2021-01-01T00:00:00",
+ endDate = "2022-01-01T00:00:00" // Exactly 12 months
+ }
+ });
+
+ var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id;
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdAsync(fiscalYearId);
+ });
+
+ // Must close before locking
+ await graphqlClient.MutateAsync(
+ $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var fy = await repo.GetByIdAsync(fiscalYearId);
+ return fy?.Status == "closed" ? fy : null;
+ });
+
+ // Act
+ var lockResponse = await graphqlClient.MutateAsync("""
+ mutation LockFiscalYear($id: ID!) {
+ lockFiscalYear(id: $id) {
+ id
+ status
+ }
+ }
+ """,
+ new { id = fiscalYearId });
+
+ // Assert
+ lockResponse.EnsureNoErrors();
+
+ var lockedFiscalYear = await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var fy = await repo.GetByIdAsync(fiscalYearId);
+ return fy?.Status == "locked" ? fy : null;
+ });
+
+ lockedFiscalYear.Should().NotBeNull();
+ lockedFiscalYear!.Status.Should().Be("locked");
+ }
+
+ [Fact]
+ public async Task Mutation_CreateFiscalYear_FailsWithInvalidDuration()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY Invalid Duration Test");
+
+ // Act - Try to create fiscal year with only 3 months (must be 12 for standard, 6-18 for first/reorg)
+ var response = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) { id }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "Q1 2024",
+ startDate = "2024-01-01T00:00:00",
+ endDate = "2024-03-31T00:00:00" // Only 3 months - invalid
+ }
+ });
+
+ // Assert - Domain validation error is wrapped by GraphQL
+ response.HasErrors.Should().BeTrue();
+ var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
+ var hasValidationError = response.Errors!.Any(e =>
+ e.Message.Contains("INVALID_DURATION") ||
+ e.Message.Contains("INVALID_STANDARD_DURATION") ||
+ errorDetails.Contains("INVALID_DURATION") ||
+ errorDetails.Contains("INVALID_STANDARD_DURATION") ||
+ errorDetails.Contains("12 months"));
+ hasValidationError.Should().BeTrue("Expected an error related to invalid fiscal year duration");
+ }
+
+ [Fact]
+ public async Task Mutation_ReopenFiscalYear_FailsWhenLocked()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY Cannot Reopen Locked Test");
+
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) { id }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "2020",
+ startDate = "2020-01-01T00:00:00",
+ endDate = "2021-01-01T00:00:00" // Exactly 12 months
+ }
+ });
+
+ var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id;
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdAsync(fiscalYearId);
+ });
+
+ // Close and lock
+ await graphqlClient.MutateAsync(
+ $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var fy = await repo.GetByIdAsync(fiscalYearId);
+ return fy?.Status == "closed" ? fy : null;
+ });
+
+ await graphqlClient.MutateAsync(
+ $"mutation {{ lockFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var fy = await repo.GetByIdAsync(fiscalYearId);
+ return fy?.Status == "locked" ? fy : null;
+ });
+
+ // Act - Try to reopen locked fiscal year
+ var reopenResponse = await graphqlClient.MutateAsync("""
+ mutation ReopenFiscalYear($id: ID!) {
+ reopenFiscalYear(id: $id) { id status }
+ }
+ """,
+ new { id = fiscalYearId });
+
+ // Assert - Domain validation error is wrapped by GraphQL
+ reopenResponse.HasErrors.Should().BeTrue();
+ var errorDetails = reopenResponse.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
+ var hasLockError = reopenResponse.Errors!.Any(e =>
+ e.Message.Contains("FISCAL_YEAR_LOCKED") ||
+ errorDetails.Contains("FISCAL_YEAR_LOCKED") ||
+ errorDetails.Contains("Locked fiscal years cannot be reopened"));
+ hasLockError.Should().BeTrue("Expected an error related to locked fiscal year");
+ }
+
+ [Fact]
+ public async Task Mutation_CreateFiscalYear_FailsWithOverlappingDates()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY Overlap Test");
+
+ // Create first fiscal year (12 months as required for standard)
+ var firstResponse = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) { id }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "2024",
+ startDate = "2024-01-01T00:00:00",
+ endDate = "2025-01-01T00:00:00" // Exactly 12 months
+ }
+ });
+
+ firstResponse.EnsureNoErrors();
+
+ // Wait for first fiscal year to be created
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdAsync(firstResponse.Data!.CreateFiscalYear!.Id);
+ });
+
+ // Act - Try to create overlapping fiscal year (also 12 months)
+ var overlapResponse = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) { id }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "2024 Overlap",
+ startDate = "2024-06-01T00:00:00",
+ endDate = "2025-06-01T00:00:00" // Overlaps with first
+ }
+ });
+
+ // Assert
+ overlapResponse.HasErrors.Should().BeTrue();
+ var errorDetails = overlapResponse.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
+ var hasOverlapError = overlapResponse.Errors!.Any(e =>
+ e.Message.Contains("OVERLAPPING_FISCAL_YEAR") ||
+ errorDetails.Contains("OVERLAPPING_FISCAL_YEAR") ||
+ errorDetails.Contains("overlaps"));
+ hasOverlapError.Should().BeTrue("Expected an error related to overlapping fiscal years");
+ }
+
+ [Fact]
+ public async Task Mutation_CreateFiscalYear_IsDeterministic()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY Deterministic Test");
+
+ // Act - Create same fiscal year twice
+ var firstResponse = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) { id }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "2025",
+ startDate = "2025-01-01T00:00:00",
+ endDate = "2026-01-01T00:00:00" // Exactly 12 months
+ }
+ });
+
+ firstResponse.EnsureNoErrors();
+ var firstId = firstResponse.Data!.CreateFiscalYear!.Id;
+
+ // Second attempt should fail (due to existing fiscal year with same ID)
+ var secondResponse = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) { id }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "2025 Second Attempt",
+ startDate = "2025-01-01T00:00:00",
+ endDate = "2025-12-31T00:00:00"
+ }
+ });
+
+ // Assert - Second attempt should fail with overlap error
+ // (because same dates = same deterministic ID AND overlap detection)
+ secondResponse.HasErrors.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Mutation_LockFiscalYear_FailsWhenNotClosed()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY Lock Open Test");
+
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) { id status }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "2019",
+ startDate = "2019-01-01T00:00:00",
+ endDate = "2020-01-01T00:00:00" // Exactly 12 months
+ }
+ });
+
+ createResponse.EnsureNoErrors();
+ var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id;
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdAsync(fiscalYearId);
+ });
+
+ // Act - Try to lock an open fiscal year (not closed)
+ var lockResponse = await graphqlClient.MutateAsync("""
+ mutation LockFiscalYear($id: ID!) {
+ lockFiscalYear(id: $id) { id status }
+ }
+ """,
+ new { id = fiscalYearId });
+
+ // Assert
+ lockResponse.HasErrors.Should().BeTrue();
+ var errorDetails = lockResponse.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
+ (lockResponse.Errors!.Any(e => e.Message.Contains("MUST_BE_CLOSED")) ||
+ errorDetails.Contains("MUST_BE_CLOSED") ||
+ errorDetails.Contains("must be closed")).Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Mutation_LockFiscalYear_FailsWhenAlreadyLocked()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY Double Lock Test");
+
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) { id }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "2018",
+ startDate = "2018-01-01T00:00:00",
+ endDate = "2019-01-01T00:00:00" // Exactly 12 months
+ }
+ });
+
+ var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id;
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdAsync(fiscalYearId);
+ });
+
+ // Close and lock first
+ await graphqlClient.MutateAsync(
+ $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var fy = await repo.GetByIdAsync(fiscalYearId);
+ return fy?.Status == "closed" ? fy : null;
+ });
+
+ await graphqlClient.MutateAsync(
+ $"mutation {{ lockFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var fy = await repo.GetByIdAsync(fiscalYearId);
+ return fy?.Status == "locked" ? fy : null;
+ });
+
+ // Act - Try to lock again
+ var lockResponse = await graphqlClient.MutateAsync("""
+ mutation LockFiscalYear($id: ID!) {
+ lockFiscalYear(id: $id) { id status }
+ }
+ """,
+ new { id = fiscalYearId });
+
+ // Assert
+ lockResponse.HasErrors.Should().BeTrue();
+ var errorDetails = lockResponse.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
+ (lockResponse.Errors!.Any(e => e.Message.Contains("ALREADY_LOCKED")) ||
+ errorDetails.Contains("ALREADY_LOCKED") ||
+ errorDetails.Contains("already locked")).Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Mutation_CloseFiscalYear_FailsWhenAlreadyClosed()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY Double Close Test");
+
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) { id }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "2017",
+ startDate = "2017-01-01T00:00:00",
+ endDate = "2018-01-01T00:00:00" // Exactly 12 months
+ }
+ });
+
+ var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id;
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdAsync(fiscalYearId);
+ });
+
+ // Close first
+ await graphqlClient.MutateAsync(
+ $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var fy = await repo.GetByIdAsync(fiscalYearId);
+ return fy?.Status == "closed" ? fy : null;
+ });
+
+ // Act - Try to close again
+ var closeResponse = await graphqlClient.MutateAsync("""
+ mutation CloseFiscalYear($id: ID!) {
+ closeFiscalYear(id: $id) { id status }
+ }
+ """,
+ new { id = fiscalYearId });
+
+ // Assert
+ closeResponse.HasErrors.Should().BeTrue();
+ var errorDetails = closeResponse.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
+ (closeResponse.Errors!.Any(e => e.Message.Contains("ALREADY_CLOSED")) ||
+ errorDetails.Contains("ALREADY_CLOSED") ||
+ errorDetails.Contains("already closed")).Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Mutation_CreateFiscalYear_FailsWhenEndDateBeforeStartDate()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY Invalid Date Range Test");
+
+ // Act - Try to create fiscal year with end date before start date
+ var response = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) { id }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "Invalid Dates",
+ startDate = "2024-12-31T00:00:00",
+ endDate = "2024-01-01T00:00:00" // End before start
+ }
+ });
+
+ // Assert
+ response.HasErrors.Should().BeTrue();
+ var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
+ (response.Errors!.Any(e => e.Message.Contains("INVALID_DATE_RANGE")) ||
+ errorDetails.Contains("INVALID_DATE_RANGE") ||
+ errorDetails.Contains("End date must be after start date")).Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Mutation_CreateFiscalYear_AcceptsExactly6Months_WhenFirstFiscalYear()
+ {
+ // Arrange - Per Årsregnskabsloven §15, first fiscal year can be 6-18 months
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY 6 Months First Test");
+
+ // Act - Create fiscal year with exactly 6 months (minimum allowed for first year)
+ // Domain calculates: (endYear - startYear) * 12 + (endMonth - startMonth)
+ // Jan to Jul = (0 * 12) + (7 - 1) = 6
+ var response = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) {
+ id
+ name
+ startDate
+ endDate
+ }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "First Year H1 2024",
+ startDate = "2024-01-01T00:00:00",
+ endDate = "2024-07-01T00:00:00", // 6 months by domain calculation
+ isFirstFiscalYear = true
+ }
+ });
+
+ // Assert - Should succeed because isFirstFiscalYear allows 6-18 months
+ response.EnsureNoErrors();
+ response.Data!.CreateFiscalYear!.Name.Should().Be("First Year H1 2024");
+ }
+
+ [Fact]
+ public async Task Mutation_CreateFiscalYear_AcceptsExactly18Months_WhenReorganization()
+ {
+ // Arrange - Per Årsregnskabsloven §15, reorganization allows up to 18 months
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY 18 Months Reorg Test");
+
+ // Act - Create fiscal year with exactly 18 months (maximum allowed for reorganization)
+ // Domain calculates: (endYear - startYear) * 12 + (endMonth - startMonth)
+ // Jan 2024 to Jul 2025 = (1 * 12) + (7 - 1) = 18
+ var response = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) {
+ id
+ name
+ }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "Reorganization 2024-2025",
+ startDate = "2024-01-01T00:00:00",
+ endDate = "2025-07-01T00:00:00", // 18 months by domain calculation
+ isReorganization = true
+ }
+ });
+
+ // Assert - Should succeed because isReorganization allows 6-18 months
+ response.EnsureNoErrors();
+ response.Data!.CreateFiscalYear!.Name.Should().Be("Reorganization 2024-2025");
+ }
+
+ #region Danish Compliance: Fiscal Year Duration Tests (Årsregnskabsloven §15)
+
+ [Fact]
+ public async Task Mutation_CreateFiscalYear_RequiresExactly12MonthsForStandardYear()
+ {
+ // Arrange - Per Årsregnskabsloven §15, standard fiscal years must be 12 months
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY Standard Duration Test");
+
+ // Act - Try to create 6-month fiscal year WITHOUT isFirstFiscalYear flag
+ var response = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) { id }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "Invalid Standard 6 Months",
+ startDate = "2024-01-01T00:00:00",
+ endDate = "2024-07-01T00:00:00" // 6 months - invalid for standard year
+ // NOT setting isFirstFiscalYear or isReorganization
+ }
+ });
+
+ // Assert - Should fail because standard years must be 12 months
+ response.HasErrors.Should().BeTrue();
+ var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
+ (response.Errors!.Any(e => e.Message.Contains("INVALID_STANDARD_DURATION")) ||
+ errorDetails.Contains("INVALID_STANDARD_DURATION") ||
+ errorDetails.Contains("12 months")).Should().BeTrue(
+ "Standard fiscal year must be exactly 12 months per Årsregnskabsloven §15");
+ }
+
+ [Fact]
+ public async Task Mutation_CreateFiscalYear_AllowsShorterForFirstYear()
+ {
+ // Arrange - Per Årsregnskabsloven §15, first fiscal year can be shorter than 12 months
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY First Year Shorter Test");
+
+ // Act - Create 9-month fiscal year WITH isFirstFiscalYear flag
+ var response = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) {
+ id
+ name
+ }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "First Year 9 Months",
+ startDate = "2024-04-01T00:00:00",
+ endDate = "2025-01-01T00:00:00", // 9 months
+ isFirstFiscalYear = true
+ }
+ });
+
+ // Assert - Should succeed because first year allows 6-18 months
+ response.EnsureNoErrors();
+ response.Data!.CreateFiscalYear!.Name.Should().Be("First Year 9 Months");
+ }
+
+ [Fact]
+ public async Task Mutation_CreateFiscalYear_Allows18MonthsOnlyForReorganization()
+ {
+ // Arrange - Per Årsregnskabsloven §15, only reorganization allows up to 18 months
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY 18 Months Standard Test");
+
+ // Act - Try to create 18-month fiscal year WITHOUT flags
+ var response = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) { id }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "Invalid Standard 18 Months",
+ startDate = "2024-01-01T00:00:00",
+ endDate = "2025-07-01T00:00:00" // 18 months - invalid for standard year
+ // NOT setting isFirstFiscalYear or isReorganization
+ }
+ });
+
+ // Assert - Should fail because standard years must be 12 months
+ response.HasErrors.Should().BeTrue();
+ var errorDetails = response.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
+ (response.Errors!.Any(e => e.Message.Contains("INVALID_STANDARD_DURATION")) ||
+ errorDetails.Contains("INVALID_STANDARD_DURATION") ||
+ errorDetails.Contains("12 months")).Should().BeTrue(
+ "Only reorganization allows 18 months per Årsregnskabsloven §15");
+ }
+
+ #endregion
+
+ [Fact]
+ public async Task Mutation_ReopenFiscalYear_FailsWhenAlreadyOpen()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY Reopen Already Open Test");
+
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) { id status }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "2014",
+ startDate = "2014-01-01T00:00:00",
+ endDate = "2015-01-01T00:00:00" // Exactly 12 months
+ }
+ });
+
+ createResponse.EnsureNoErrors();
+ var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id;
+ createResponse.Data.CreateFiscalYear.Status.Should().Be("open");
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdAsync(fiscalYearId);
+ });
+
+ // Act - Try to reopen an already open fiscal year
+ var reopenResponse = await graphqlClient.MutateAsync("""
+ mutation ReopenFiscalYear($id: ID!) {
+ reopenFiscalYear(id: $id) { id status }
+ }
+ """,
+ new { id = fiscalYearId });
+
+ // Assert
+ reopenResponse.HasErrors.Should().BeTrue();
+ var errorDetails = reopenResponse.Errors!.First().Extensions?.GetValueOrDefault("details")?.ToString() ?? "";
+ (reopenResponse.Errors!.Any(e => e.Message.Contains("ALREADY_OPEN")) ||
+ errorDetails.Contains("ALREADY_OPEN") ||
+ errorDetails.Contains("already open")).Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Query_FiscalYear_ReturnsAuditFieldsAfterReopen()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY Audit Reopen Test");
+
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) { id }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "2016",
+ startDate = "2016-01-01T00:00:00",
+ endDate = "2017-01-01T00:00:00" // Exactly 12 months
+ }
+ });
+
+ var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id;
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdAsync(fiscalYearId);
+ });
+
+ // Close first
+ await graphqlClient.MutateAsync(
+ $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var fy = await repo.GetByIdAsync(fiscalYearId);
+ return fy?.Status == "closed" ? fy : null;
+ });
+
+ // Reopen
+ await graphqlClient.MutateAsync(
+ $"mutation {{ reopenFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
+
+ // Act - Query to check audit fields
+ var queryResponse = await Eventually.GetAsync(async () =>
+ {
+ var response = await graphqlClient.QueryAsync("""
+ query FiscalYear($id: ID!) {
+ fiscalYear(id: $id) {
+ id
+ status
+ reopenedDate
+ reopenedBy
+ }
+ }
+ """,
+ new { id = fiscalYearId });
+
+ return response.Data?.FiscalYear?.ReopenedBy != null ? response : null;
+ });
+
+ // Assert
+ queryResponse.Should().NotBeNull();
+ queryResponse!.Data!.FiscalYear!.Status.Should().Be("open");
+ queryResponse.Data.FiscalYear.ReopenedBy.Should().NotBeNullOrEmpty();
+ queryResponse.Data.FiscalYear.ReopenedDate.Should().NotBeNullOrEmpty();
+ }
+
+ [Fact]
+ public async Task Query_FiscalYear_ReturnsAuditFieldsAfterLock()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "FY Audit Lock Test");
+
+ var createResponse = await graphqlClient.MutateAsync("""
+ mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
+ createFiscalYear(input: $input) { id }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "2015",
+ startDate = "2015-01-01T00:00:00",
+ endDate = "2016-01-01T00:00:00" // Exactly 12 months
+ }
+ });
+
+ var fiscalYearId = createResponse.Data!.CreateFiscalYear!.Id;
+
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdAsync(fiscalYearId);
+ });
+
+ // Close and lock
+ await graphqlClient.MutateAsync(
+ $"mutation {{ closeFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ var fy = await repo.GetByIdAsync(fiscalYearId);
+ return fy?.Status == "closed" ? fy : null;
+ });
+
+ await graphqlClient.MutateAsync(
+ $"mutation {{ lockFiscalYear(id: \"{fiscalYearId}\") {{ id }} }}");
+
+ // Act - Query to check audit fields
+ var queryResponse = await Eventually.GetAsync(async () =>
+ {
+ var response = await graphqlClient.QueryAsync("""
+ query FiscalYear($id: ID!) {
+ fiscalYear(id: $id) {
+ id
+ status
+ lockedDate
+ lockedBy
+ }
+ }
+ """,
+ new { id = fiscalYearId });
+
+ return response.Data?.FiscalYear?.LockedBy != null ? response : null;
+ });
+
+ // Assert
+ queryResponse.Should().NotBeNull();
+ queryResponse!.Data!.FiscalYear!.Status.Should().Be("locked");
+ queryResponse.Data.FiscalYear.LockedBy.Should().NotBeNullOrEmpty();
+ queryResponse.Data.FiscalYear.LockedDate.Should().NotBeNullOrEmpty();
+ }
+
+ private async Task CreateCompanyAsync(GraphQLTestClient client, string name)
+ {
+ var response = await client.MutateAsync("""
+ mutation CreateCompany($input: CreateCompanyInput!) {
+ createCompany(input: $input) { id }
+ }
+ """,
+ new { input = new { name } });
+
+ response.EnsureNoErrors();
+ var companyId = response.Data!.CreateCompany!.Id;
+
+ // Set the company ID header for subsequent requests
+ client.SetCompanyId(companyId);
+
+ return companyId;
+ }
+
+ // Response DTOs
+ private class FiscalYearsResponse { public List FiscalYears { get; set; } = []; }
+ private class FiscalYearResponse { public FiscalYearDto? FiscalYear { get; set; } }
+ private class FiscalYearQueryResponse { public FiscalYearDto? FiscalYear { get; set; } }
+ private class CreateFiscalYearResponse { public FiscalYearDto? CreateFiscalYear { get; set; } }
+ private class CloseFiscalYearResponse { public FiscalYearDto? CloseFiscalYear { get; set; } }
+ private class ReopenFiscalYearResponse { public FiscalYearDto? ReopenFiscalYear { get; set; } }
+ private class LockFiscalYearResponse { public FiscalYearDto? LockFiscalYear { get; set; } }
+ private class CreateCompanyResponse { public CompanyDto? CreateCompany { get; set; } }
+
+ private class FiscalYearDto
+ {
+ public string Id { get; set; } = string.Empty;
+ public string CompanyId { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public string StartDate { get; set; } = string.Empty;
+ public string EndDate { get; set; } = string.Empty;
+ public string Status { get; set; } = string.Empty;
+ public bool OpeningBalancePosted { get; set; }
+ public string? ClosingDate { get; set; }
+ public string? ClosedBy { get; set; }
+ public string? ReopenedDate { get; set; }
+ public string? ReopenedBy { get; set; }
+ public string? LockedDate { get; set; }
+ public string? LockedBy { get; set; }
+ }
+
+ private class CompanyDto { public string Id { get; set; } = string.Empty; }
+}
diff --git a/backend/Books.Api.Tests/GraphQL/JournalEntryDraftGraphQLTests.cs b/backend/Books.Api.Tests/GraphQL/JournalEntryDraftGraphQLTests.cs
new file mode 100644
index 0000000..f7d232e
--- /dev/null
+++ b/backend/Books.Api.Tests/GraphQL/JournalEntryDraftGraphQLTests.cs
@@ -0,0 +1,787 @@
+using Books.Api.EventFlow.Repositories;
+using Books.Api.Tests.Helpers;
+using Books.Api.Tests.Infrastructure;
+using AwesomeAssertions;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Books.Api.Tests.GraphQL;
+
+///
+/// Integration tests for JournalEntryDraft (Kassekladde) GraphQL operations.
+///
+[Trait("Category", "Integration")]
+public class JournalEntryDraftGraphQLTests(TestWebApplicationFactory factory)
+ : IntegrationTestBase(factory)
+{
+ #region Create Draft Tests
+
+ [Fact]
+ public async Task Mutation_CreateJournalEntryDraft_CreatesSuccessfully()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "Draft Test Company");
+
+ // Act
+ var response = await graphqlClient.MutateAsync("""
+ mutation CreateDraft($input: CreateJournalEntryDraftInput!) {
+ createJournalEntryDraft(input: $input) {
+ id
+ companyId
+ name
+ status
+ createdBy
+ }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = "Januar Udgifter"
+ }
+ });
+
+ // Assert
+ response.EnsureNoErrors();
+ response.Data.Should().NotBeNull();
+ response.Data!.CreateJournalEntryDraft.Should().NotBeNull();
+ response.Data.CreateJournalEntryDraft!.Name.Should().Be("Januar Udgifter");
+ response.Data.CreateJournalEntryDraft.Status.Should().Be("active");
+ response.Data.CreateJournalEntryDraft.CompanyId.Should().Be(companyId);
+ response.Data.CreateJournalEntryDraft.Id.Should().StartWith("journalentrydraft-");
+ }
+
+ [Fact]
+ public async Task Mutation_CreateJournalEntryDraft_FailsWithoutCompanyHeader()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ // Note: We're NOT setting X-Company-Id header
+
+ // Act
+ var response = await graphqlClient.MutateAsync("""
+ mutation CreateDraft($input: CreateJournalEntryDraftInput!) {
+ createJournalEntryDraft(input: $input) {
+ id
+ }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId = "some-company-id",
+ name = "Test Draft"
+ }
+ });
+
+ // Assert
+ response.HasErrors.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Mutation_CreateJournalEntryDraft_FailsWithEmptyName()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "Empty Name Test Company");
+
+ // Act
+ var response = await graphqlClient.MutateAsync("""
+ mutation CreateDraft($input: CreateJournalEntryDraftInput!) {
+ createJournalEntryDraft(input: $input) {
+ id
+ }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ companyId,
+ name = " " // Whitespace only
+ }
+ });
+
+ // Assert
+ response.HasErrors.Should().BeTrue();
+ var errorInfo = string.Join(" ", response.Errors!.Select(e =>
+ e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? "")));
+ errorInfo.Should().Contain("Draft name is required");
+ }
+
+ #endregion
+
+ #region Update Draft Tests
+
+ [Fact]
+ public async Task Mutation_UpdateJournalEntryDraft_UpdatesSuccessfully()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "Update Draft Test Company");
+ var draftId = await CreateDraftAsync(graphqlClient, companyId, "Original Name");
+
+ // Wait for eventual consistency
+ await Eventually.GetAsync(async () =>
+ {
+ var repo = GetService();
+ return await repo.GetByIdAsync(draftId);
+ });
+
+ // Act
+ var response = await graphqlClient.MutateAsync("""
+ mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) {
+ updateJournalEntryDraft(input: $input) {
+ id
+ name
+ documentDate
+ description
+ lines {
+ lineNumber
+ accountId
+ debitAmount
+ creditAmount
+ description
+ }
+ }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ id = draftId,
+ name = "Updated Name",
+ documentDate = "2025-01-15",
+ description = "Test beskrivelse",
+ lines = new[]
+ {
+ new { lineNumber = 1, accountId = (string?)null, debitAmount = 1000m, creditAmount = 0m, description = "Debet linje" },
+ new { lineNumber = 2, accountId = (string?)null, debitAmount = 0m, creditAmount = 1000m, description = "Kredit linje" }
+ }
+ }
+ });
+
+ // Assert
+ response.EnsureNoErrors();
+ response.Data!.UpdateJournalEntryDraft!.Name.Should().Be("Updated Name");
+ response.Data.UpdateJournalEntryDraft.Description.Should().Be("Test beskrivelse");
+ response.Data.UpdateJournalEntryDraft.Lines.Should().HaveCount(2);
+ response.Data.UpdateJournalEntryDraft.Lines![0].DebitAmount.Should().Be(1000m);
+ response.Data.UpdateJournalEntryDraft.Lines[1].CreditAmount.Should().Be(1000m);
+ }
+
+ [Fact]
+ public async Task Mutation_UpdateJournalEntryDraft_FailsForNonExistentDraft()
+ {
+ // Arrange
+ var graphqlClient = new GraphQLTestClient(Client);
+ var companyId = await CreateCompanyAsync(graphqlClient, "Non-existent Draft Company");
+ var nonExistentId = $"journalentrydraft-{Guid.NewGuid():D}";
+
+ // Act
+ var response = await graphqlClient.MutateAsync("""
+ mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) {
+ updateJournalEntryDraft(input: $input) {
+ id
+ }
+ }
+ """,
+ new
+ {
+ input = new
+ {
+ id = nonExistentId,
+ name = "Test",
+ lines = Array.Empty