Add all backend domain, commands, repositories, and tests

This commit includes all previously untracked backend files:

Domain:
- Accounts, Attachments, BankConnections, Customers
- FiscalYears, Invoices, JournalEntryDrafts
- Orders, Products, UserAccess

Commands & Handlers:
- Full CQRS command structure for all domains

Repositories:
- PostgreSQL repositories for all read models
- Bank transaction and ledger repositories

GraphQL:
- Input types, scalars, and types for all entities
- Mutations and queries

Infrastructure:
- Banking integration (Enable Banking client)
- File storage, Invoicing, Reporting, SAF-T export
- Database migrations (003-029)

Tests:
- Integration tests for GraphQL endpoints
- Domain tests
- Invoicing and reporting tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nicolaj Hartmann 2026-01-30 22:19:42 +01:00
parent b552a6e29f
commit 1f75c5d791
339 changed files with 33378 additions and 0 deletions

View file

@ -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"}

View file

@ -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;
/// <summary>
/// Unit tests for AiBookkeeperClient.
/// Tests JSON parsing and suggestion generation from extraction data.
/// </summary>
[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<HttpMessageHandler>();
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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<AiBookkeeperClient>.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 }
]
};
}
}

View file

@ -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;
/// <summary>
/// Unit tests for ChartOfAccountsProvider.
/// Verifies that accounts are filtered and sorted correctly.
/// </summary>
[Trait("Category", "Unit")]
public class ChartOfAccountsProviderTests
{
private readonly Mock<IAccountRepository> _accountRepository;
private readonly ChartOfAccountsProvider _sut;
public ChartOfAccountsProviderTests()
{
_accountRepository = new Mock<IAccountRepository>();
_sut = new ChartOfAccountsProvider(_accountRepository.Object);
}
[Fact]
public async Task GetChartOfAccountsAsync_ReturnsOnlyExpenseTypeAccounts()
{
// Arrange
var accounts = new List<AccountReadModelDto>
{
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<string>(), It.IsAny<CancellationToken>()))
.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<AccountReadModelDto>
{
CreateAccount("1000", "Bank", "asset", null),
CreateAccount("1200", "Varelager", "asset", null),
CreateAccount("7000", "Kontorudgifter", "expense", "I25")
};
_accountRepository.Setup(r => r.GetActiveByCompanyIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.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<AccountReadModelDto>
{
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<string>(), It.IsAny<CancellationToken>()))
.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<AccountReadModelDto>
{
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<string>(), It.IsAny<CancellationToken>()))
.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<AccountReadModelDto>
{
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<string>(), It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.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<AccountReadModelDto>
{
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<string>(), It.IsAny<CancellationToken>()))
.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<string>(), It.IsAny<CancellationToken>()))
.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
};
}
}

View file

@ -0,0 +1,214 @@
using Books.Api.AiBookkeeper;
using AwesomeAssertions;
namespace Books.Api.Tests.AiBookkeeper;
/// <summary>
/// Unit tests for ToonFormatConverter.
/// Verifies that ChartOfAccountsDto is correctly converted to .toon format.
/// </summary>
[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
};
}
}

View file

@ -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;
/// <summary>
/// Integration tests for ApiKey domain operations.
/// Tests the ApiKeyAggregate via CommandBus since ApiKeys are not exposed via GraphQL.
/// </summary>
[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<IApiKeyRepository>();
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<IApiKeyRepository>();
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<Exception>()
.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<IApiKeyRepository>();
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<IApiKeyRepository>();
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<IApiKeyRepository>();
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<IApiKeyRepository>();
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<Exception>()
.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<IApiKeyRepository>();
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<IApiKeyRepository>();
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<IApiKeyRepository>();
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<IApiKeyRepository>();
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<IApiKeyRepository>();
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<IApiKeyRepository>();
var validationDto = await repo.GetByIdForValidationAsync(nonExistentId.Value);
// Assert
validationDto.Should().BeNull();
}
}

View file

@ -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;
/// <summary>
/// Unit tests for BankConnectionAggregate domain logic.
/// Tests aggregate behavior without EventFlow infrastructure.
/// </summary>
[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<DomainException>()
.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<DomainException>()
.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<BankAccountInfo>
{
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<BankAccountInfo> { new("acc-1", "DK1234", "DKK", null) };
// Act
var act = () => aggregate.Establish("session-123", DateTimeOffset.UtcNow.AddDays(90), accounts);
// Assert
act.Should().Throw<DomainException>()
.Where(e => e.Code == "BANK_CONNECTION_NOT_INITIATED");
}
[Fact]
public void Establish_WithEmptySessionId_ThrowsDomainException()
{
// Arrange
var aggregate = CreateInitiatedConnection();
var accounts = new List<BankAccountInfo> { new("acc-1", "DK1234", "DKK", null) };
// Act
var act = () => aggregate.Establish(" ", DateTimeOffset.UtcNow.AddDays(90), accounts);
// Assert
act.Should().Throw<DomainException>()
.Where(e => e.Code == "SESSION_ID_REQUIRED");
}
[Fact]
public void Establish_WithNoAccounts_ThrowsDomainException()
{
// Arrange
var aggregate = CreateInitiatedConnection();
var accounts = new List<BankAccountInfo>();
// Act
var act = () => aggregate.Establish("session-123", DateTimeOffset.UtcNow.AddDays(90), accounts);
// Assert
act.Should().Throw<DomainException>()
.Where(e => e.Code == "NO_ACCOUNTS_FOUND");
}
[Fact]
public void Establish_WhenAlreadyEstablished_ThrowsDomainException()
{
// Arrange
var aggregate = CreateEstablishedConnection();
var accounts = new List<BankAccountInfo> { new("acc-1", "DK1234", "DKK", null) };
// Act
var act = () => aggregate.Establish("session-456", DateTimeOffset.UtcNow.AddDays(90), accounts);
// Assert
act.Should().Throw<DomainException>()
.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<DomainException>()
.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<DomainException>()
.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<DomainException>()
.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<DomainException>()
.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<DomainException>()
.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<DomainException>()
.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<DomainException>()
.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<BankAccountInfo>
{
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
}

View file

@ -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;
/// <summary>
/// Unit tests for JournalEntryDraftAggregate domain logic.
/// Tests aggregate behavior without EventFlow infrastructure.
/// </summary>
[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<DomainException>()
.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<DomainException>()
.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<DomainException>()
.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<DomainException>()
.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<DomainException>()
.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<DraftLine>
{
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<DraftLine>
{
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<DraftLine>
{
new(1, "account-1", 1000m, 0m, "Test", "INVALID_CODE")
};
// Act
var act = () => aggregate.Update("Name", null, null, null, lines);
// Assert
act.Should().Throw<DomainException>()
.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<DomainException>()
.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<DomainException>()
.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<DomainException>()
.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<DomainException>()
.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<DomainException>()
.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<DomainException>()
.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<DomainException>()
.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<DomainException>()
.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<DomainException>()
.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
}

View file

@ -0,0 +1,265 @@
using Books.Api.Domain;
using Books.Api.Domain.JournalEntryDrafts;
using AwesomeAssertions;
namespace Books.Api.Tests.Domain;
/// <summary>
/// Unit tests for VatCalculationService.
/// Tests Danish VAT calculation logic for SKAT compliance.
/// </summary>
[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<DraftLine>
{
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<DraftLine>
{
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<DraftLine>
{
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<DraftLine>
{
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<DraftLine>
{
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<DraftLine>
{
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<DraftLine>
{
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<DraftLine>
{
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<DraftLine>
{
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<DraftLine>
{
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<DraftLine>
{
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
}

View file

@ -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;
/// <summary>
/// Integration tests for Account GraphQL operations.
/// Each test class runs with its own isolated database.
/// </summary>
[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<AccountsResponse>("""
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<CreateAccountResponse>("""
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<CreateAccountResponse>("""
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<IAccountRepository>();
return await repo.GetByIdAsync(accountId);
});
// Act - Update the account
var updateResponse = await graphqlClient.MutateAsync<UpdateAccountResponse>("""
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<IAccountRepository>();
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<CreateAccountResponse>("""
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<IAccountRepository>();
return await repo.GetByIdAsync(accountId);
});
// Act
var deactivateResponse = await graphqlClient.MutateAsync<DeactivateAccountResponse>("""
mutation DeactivateAccount($id: ID!) {
deactivateAccount(id: $id) {
id
isActive
}
}
""",
new { id = accountId });
// Assert
deactivateResponse.EnsureNoErrors();
var deactivatedAccount = await Eventually.GetAsync(async () =>
{
var repo = GetService<IAccountRepository>();
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<CreateAccountResponse>("""
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<IAccountRepository>();
return await repo.GetByIdAsync(accountId);
});
// Deactivate first
await graphqlClient.MutateAsync<DeactivateAccountResponse>("""
mutation { deactivateAccount(id: $id) { id } }
""".Replace("$id", $"\"{accountId}\""));
await Eventually.GetAsync(async () =>
{
var repo = GetService<IAccountRepository>();
var account = await repo.GetByIdAsync(accountId);
return account?.IsActive == false ? account : null;
});
// Act
var reactivateResponse = await graphqlClient.MutateAsync<ReactivateAccountResponse>("""
mutation ReactivateAccount($id: ID!) {
reactivateAccount(id: $id) {
id
isActive
}
}
""",
new { id = accountId });
// Assert
reactivateResponse.EnsureNoErrors();
var reactivatedAccount = await Eventually.GetAsync(async () =>
{
var repo = GetService<IAccountRepository>();
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<IAccountRepository>();
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<IAccountRepository>();
return await repo.GetByIdAsync(customAccountId);
});
// Deactivate the custom account
await graphqlClient.MutateAsync<DeactivateAccountResponse>(
$"mutation {{ deactivateAccount(id: \"{customAccountId}\") {{ id }} }}");
await Eventually.GetAsync(async () =>
{
var repo = GetService<IAccountRepository>();
var account = await repo.GetByIdAsync(customAccountId);
return account?.IsActive == false ? account : null;
});
// Act
var response = await graphqlClient.QueryAsync<ActiveAccountsResponse>("""
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<CreateAccountResponse>("""
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<IAccountRepository>();
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<UpdateAccountResponse>("""
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<IAccountRepository>();
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<DeactivateAccountResponse>("""
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<IAccountRepository>();
return await repo.GetByIdAsync(accountId);
});
// First deactivation - should succeed
await graphqlClient.MutateAsync<DeactivateAccountResponse>("""
mutation DeactivateAccount($id: ID!) {
deactivateAccount(id: $id) { id }
}
""",
new { id = accountId });
await Eventually.GetAsync(async () =>
{
var repo = GetService<IAccountRepository>();
var account = await repo.GetByIdAsync(accountId);
return account?.IsActive == false ? account : null;
});
// Act - Try to deactivate again
var response = await graphqlClient.MutateAsync<DeactivateAccountResponse>("""
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<IAccountRepository>();
return await repo.GetByIdAsync(accountId);
});
// Act - Try to reactivate an already active account
var response = await graphqlClient.MutateAsync<ReactivateAccountResponse>("""
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<CreateAccountResponse>("""
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<CreateAccountResponse>("""
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<CreateAccountResponse>("""
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<IAccountRepository>();
return await repo.GetByIdAsync(firstId);
});
// Second attempt with same input
var response2 = await graphqlClient.MutateAsync<CreateAccountResponse>("""
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<UpdateAccountResponse>("""
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<DeactivateAccountResponse>("""
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<ReactivateAccountResponse>("""
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<string> CreateCompanyAsync(GraphQLTestClient client, string name)
{
var response = await client.MutateAsync<CreateCompanyResponse>("""
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<string> CreateAccountAsync(GraphQLTestClient client, string companyId, string number, string name)
{
var response = await client.MutateAsync<CreateAccountResponse>("""
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<AccountDto> Accounts { get; set; } = []; }
private class ActiveAccountsResponse { public List<AccountDto> 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; }
}

View file

@ -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;
/// <summary>
/// Integration tests for ChartOfAccountsInitializer.
/// Verifies that creating a company automatically bootstraps the standard Danish chart of accounts.
/// </summary>
[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<CreateCompanyResponse>("""
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<IAccountRepository>();
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<CreateCompanyResponse>("""
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<IAccountRepository>();
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<CreateCompanyResponse>("""
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<IAccountRepository>();
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<CreateCompanyResponse>("""
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<IAccountRepository>();
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<CreateCompanyResponse>("""
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<IAccountRepository>();
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<CreateCompanyResponse>("""
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<IAccountRepository>();
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<CreateCompanyResponse>("""
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<IAccountRepository>();
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<AccountsResponse>("""
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<CreateCompanyResponse>("""
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<IAccountRepository>();
return await repo.GetByCompanyIdAsync(companyId);
},
expectedAccountCount,
timeout: TimeSpan.FromSeconds(30));
initialAccounts.Should().HaveCount(expectedAccountCount);
// Act - Call initializer again (should be idempotent)
var initializer = GetService<IChartOfAccountsInitializer>();
await initializer.InitializeAsync(companyId);
// Assert - Should still have the same number of accounts (not doubled)
var accountsAfterSecondInit = await Eventually.GetAsync(async () =>
{
var repo = GetService<IAccountRepository>();
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<CreateCompanyResponse>("""
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<IAccountRepository>();
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<CreateCompanyResponse>("""
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<IAccountRepository>();
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<CreateCompanyResponse>("""
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<IAccountRepository>();
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<CreateCompanyResponse>("""
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<IAccountRepository>();
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<AccountDto> 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; }
}
}

File diff suppressed because it is too large Load diff

View file

@ -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;
/// <summary>
/// Integration tests for JournalEntryDraft (Kassekladde) GraphQL operations.
/// </summary>
[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<CreateDraftResponse>("""
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<CreateDraftResponse>("""
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<CreateDraftResponse>("""
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<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
// Act
var response = await graphqlClient.MutateAsync<UpdateDraftResponse>("""
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<UpdateDraftResponse>("""
mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) {
updateJournalEntryDraft(input: $input) {
id
}
}
""",
new
{
input = new
{
id = nonExistentId,
name = "Test",
lines = Array.Empty<object>()
}
});
// Assert
response.HasErrors.Should().BeTrue();
var errorInfo = string.Join(" ", response.Errors!.Select(e =>
e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? "")));
errorInfo.Should().Contain("not found");
}
#endregion
#region Query Tests
[Fact]
public async Task Query_JournalEntryDrafts_ReturnsActiveDrafts()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
var companyId = await CreateCompanyAsync(graphqlClient, "Query Drafts Test Company");
// Create multiple drafts
await CreateDraftAsync(graphqlClient, companyId, "Draft 1");
await CreateDraftAsync(graphqlClient, companyId, "Draft 2");
await CreateDraftAsync(graphqlClient, companyId, "Draft 3");
// Wait for eventual consistency
var drafts = await Eventually.GetListAsync(async () =>
{
var repo = GetService<IJournalEntryDraftRepository>();
return await repo.GetActiveByCompanyIdAsync(companyId);
}, 3, timeout: TimeSpan.FromSeconds(10));
// Act
var response = await graphqlClient.QueryAsync<DraftsResponse>("""
query Drafts($companyId: ID!) {
journalEntryDrafts(companyId: $companyId) {
id
name
status
}
}
""",
new { companyId });
// Assert
response.EnsureNoErrors();
response.Data!.JournalEntryDrafts.Should().HaveCount(3);
response.Data.JournalEntryDrafts.Should().AllSatisfy(d => d.Status.Should().Be("active"));
}
[Fact]
public async Task Query_JournalEntryDraft_ReturnsSingleDraft()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
var companyId = await CreateCompanyAsync(graphqlClient, "Single Draft Query Company");
var draftId = await CreateDraftAsync(graphqlClient, companyId, "Query Test Draft");
await Eventually.GetAsync(async () =>
{
var repo = GetService<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
// Act
var response = await graphqlClient.QueryAsync<SingleDraftResponse>("""
query Draft($id: ID!) {
journalEntryDraft(id: $id) {
id
name
companyId
status
lines {
lineNumber
}
}
}
""",
new { id = draftId });
// Assert
response.EnsureNoErrors();
response.Data!.JournalEntryDraft.Should().NotBeNull();
response.Data.JournalEntryDraft!.Name.Should().Be("Query Test Draft");
response.Data.JournalEntryDraft.CompanyId.Should().Be(companyId);
}
[Fact]
public async Task Query_JournalEntryDrafts_FailsWithoutCompanyHeader()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
// Not setting company header
// Act
var response = await graphqlClient.QueryAsync<DraftsResponse>("""
query Drafts($companyId: ID!) {
journalEntryDrafts(companyId: $companyId) {
id
}
}
""",
new { companyId = "some-id" });
// Assert
response.HasErrors.Should().BeTrue();
}
#endregion
#region Discard Draft Tests
[Fact]
public async Task Mutation_DiscardJournalEntryDraft_DiscardsSuccessfully()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
var companyId = await CreateCompanyAsync(graphqlClient, "Discard Draft Company");
var draftId = await CreateDraftAsync(graphqlClient, companyId, "To Be Discarded");
await Eventually.GetAsync(async () =>
{
var repo = GetService<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
// Act
var response = await graphqlClient.MutateAsync<DiscardDraftResponse>("""
mutation DiscardDraft($id: ID!) {
discardJournalEntryDraft(id: $id) {
id
status
}
}
""",
new { id = draftId });
// Assert
response.EnsureNoErrors();
response.Data!.DiscardJournalEntryDraft!.Status.Should().Be("discarded");
// Verify it's no longer in active drafts
var activeDrafts = await Eventually.GetAsync(async () =>
{
var repo = GetService<IJournalEntryDraftRepository>();
var drafts = await repo.GetActiveByCompanyIdAsync(companyId);
return drafts.Any(d => d.Id == draftId) ? null : drafts;
});
activeDrafts.Should().NotContain(d => d.Id == draftId);
}
[Fact]
public async Task Mutation_DiscardJournalEntryDraft_FailsForAlreadyDiscarded()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
var companyId = await CreateCompanyAsync(graphqlClient, "Double Discard Company");
var draftId = await CreateDraftAsync(graphqlClient, companyId, "Discard Twice");
await Eventually.GetAsync(async () =>
{
var repo = GetService<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
// Discard once
await graphqlClient.MutateAsync<DiscardDraftResponse>("""
mutation DiscardDraft($id: ID!) {
discardJournalEntryDraft(id: $id) { id status }
}
""",
new { id = draftId });
await Eventually.GetAsync(async () =>
{
var repo = GetService<IJournalEntryDraftRepository>();
var draft = await repo.GetByIdAsync(draftId);
return draft?.Status == "discarded" ? draft : null;
});
// Act - Try to discard again
var response = await graphqlClient.MutateAsync<DiscardDraftResponse>("""
mutation DiscardDraft($id: ID!) {
discardJournalEntryDraft(id: $id) { id status }
}
""",
new { id = draftId });
// Assert
response.HasErrors.Should().BeTrue();
var errorInfo = string.Join(" ", response.Errors!.Select(e =>
e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? "")));
errorInfo.Should().Contain("has been discarded");
}
#endregion
#region Post Draft Tests
[Fact]
public async Task Mutation_PostJournalEntryDraft_FailsWithUnbalancedLines()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
var companyId = await CreateCompanyAsync(graphqlClient, "Unbalanced Post Company");
var draftId = await CreateDraftAsync(graphqlClient, companyId, "Unbalanced Draft");
// Wait for standard accounts
var accounts = await Eventually.GetListAsync(async () =>
{
var repo = GetService<IAccountRepository>();
return await repo.GetActiveByCompanyIdAsync(companyId);
}, 10, timeout: TimeSpan.FromSeconds(30));
var accountId1 = accounts.First().Id;
var accountId2 = accounts.Skip(1).First().Id;
await Eventually.GetAsync(async () =>
{
var repo = GetService<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
// Create fiscal year for posting
var fiscalYearId = await CreateFiscalYearAsync(graphqlClient, companyId);
// Update with unbalanced lines
await graphqlClient.MutateAsync<UpdateDraftResponse>("""
mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) {
updateJournalEntryDraft(input: $input) { id }
}
""",
new
{
input = new
{
id = draftId,
fiscalYearId,
lines = new[]
{
new { lineNumber = 1, accountId = accountId1, debitAmount = 1000m, creditAmount = 0m },
new { lineNumber = 2, accountId = accountId2, debitAmount = 0m, creditAmount = 500m } // Unbalanced!
}
}
});
await Task.Delay(500); // Wait for update
// Act - Try to post unbalanced draft
var response = await graphqlClient.MutateAsync<PostDraftResponse>("""
mutation PostDraft($id: ID!) {
postJournalEntryDraft(id: $id) {
id
status
transactionId
}
}
""",
new { id = draftId });
// Assert
response.HasErrors.Should().BeTrue();
var errorInfo = string.Join(" ", response.Errors!.Select(e =>
e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? "")));
errorInfo.Should().Contain("must equal credits");
}
[Fact]
public async Task Mutation_PostJournalEntryDraft_FailsWithoutFiscalYear()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
var companyId = await CreateCompanyAsync(graphqlClient, "No Fiscal Year Company");
var draftId = await CreateDraftAsync(graphqlClient, companyId, "No Fiscal Year Draft");
// Wait for standard accounts
var accounts = await Eventually.GetListAsync(async () =>
{
var repo = GetService<IAccountRepository>();
return await repo.GetActiveByCompanyIdAsync(companyId);
}, 10, timeout: TimeSpan.FromSeconds(30));
var accountId1 = accounts.First().Id;
var accountId2 = accounts.Skip(1).First().Id;
await Eventually.GetAsync(async () =>
{
var repo = GetService<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
// Update with balanced lines but NO fiscal year
await graphqlClient.MutateAsync<UpdateDraftResponse>("""
mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) {
updateJournalEntryDraft(input: $input) { id }
}
""",
new
{
input = new
{
id = draftId,
// No fiscalYearId!
lines = new[]
{
new { lineNumber = 1, accountId = accountId1, debitAmount = 1000m, creditAmount = 0m },
new { lineNumber = 2, accountId = accountId2, debitAmount = 0m, creditAmount = 1000m }
}
}
});
await Task.Delay(500);
// Act
var response = await graphqlClient.MutateAsync<PostDraftResponse>("""
mutation PostDraft($id: ID!) {
postJournalEntryDraft(id: $id) {
id
status
}
}
""",
new { id = draftId });
// Assert
response.HasErrors.Should().BeTrue();
var errorInfo = string.Join(" ", response.Errors!.Select(e =>
e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? "")));
errorInfo.Should().Contain("Fiscal year is required");
}
[Fact]
public async Task Mutation_PostJournalEntryDraft_FailsWithMissingAccounts()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
var companyId = await CreateCompanyAsync(graphqlClient, "Missing Account Company");
var draftId = await CreateDraftAsync(graphqlClient, companyId, "Missing Account Draft");
await Eventually.GetAsync(async () =>
{
var repo = GetService<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
var fiscalYearId = await CreateFiscalYearAsync(graphqlClient, companyId);
// Update with balanced lines but missing account IDs
await graphqlClient.MutateAsync<UpdateDraftResponse>("""
mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) {
updateJournalEntryDraft(input: $input) { id }
}
""",
new
{
input = new
{
id = draftId,
fiscalYearId,
lines = new[]
{
new { lineNumber = 1, accountId = (string?)null, debitAmount = 1000m, creditAmount = 0m },
new { lineNumber = 2, accountId = (string?)null, debitAmount = 0m, creditAmount = 1000m }
}
}
});
await Task.Delay(500);
// Act
var response = await graphqlClient.MutateAsync<PostDraftResponse>("""
mutation PostDraft($id: ID!) {
postJournalEntryDraft(id: $id) {
id
}
}
""",
new { id = draftId });
// Assert
response.HasErrors.Should().BeTrue();
var errorInfo = string.Join(" ", response.Errors!.Select(e =>
e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? "")));
errorInfo.Should().Contain("must have an account");
}
[Fact(Skip = "Requires Ledger period setup which is complex; domain logic is tested in JournalEntryDraftAggregateTests")]
public async Task Mutation_UpdateJournalEntryDraft_FailsForPostedDraft()
{
// Arrange - Create and post a draft first
var graphqlClient = new GraphQLTestClient(Client);
var companyId = await CreateCompanyAsync(graphqlClient, "Posted Update Company");
var draftId = await CreateDraftAsync(graphqlClient, companyId, "To Be Posted Then Updated");
// Wait for accounts
var accounts = await Eventually.GetListAsync(async () =>
{
var repo = GetService<IAccountRepository>();
return await repo.GetActiveByCompanyIdAsync(companyId);
}, 10, timeout: TimeSpan.FromSeconds(30));
var accountId1 = accounts.First().Id;
var accountId2 = accounts.Skip(1).First().Id;
await Eventually.GetAsync(async () =>
{
var repo = GetService<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
var fiscalYearId = await CreateFiscalYearAsync(graphqlClient, companyId);
// Update draft with valid data for posting
await graphqlClient.MutateAsync<UpdateDraftResponse>("""
mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) {
updateJournalEntryDraft(input: $input) { id }
}
""",
new
{
input = new
{
id = draftId,
fiscalYearId,
lines = new[]
{
new { lineNumber = 1, accountId = accountId1, debitAmount = 1000m, creditAmount = 0m },
new { lineNumber = 2, accountId = accountId2, debitAmount = 0m, creditAmount = 1000m }
}
}
});
await Task.Delay(500);
// Post the draft
var postResponse = await graphqlClient.MutateAsync<PostDraftResponse>("""
mutation PostDraft($id: ID!) {
postJournalEntryDraft(id: $id) { id status }
}
""",
new { id = draftId });
postResponse.EnsureNoErrors();
await Eventually.GetAsync(async () =>
{
var repo = GetService<IJournalEntryDraftRepository>();
var draft = await repo.GetByIdAsync(draftId);
return draft?.Status == "posted" ? draft : null;
});
// Act - Try to update the posted draft
var response = await graphqlClient.MutateAsync<UpdateDraftResponse>("""
mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) {
updateJournalEntryDraft(input: $input) { id }
}
""",
new
{
input = new
{
id = draftId,
name = "Should Fail",
lines = Array.Empty<object>()
}
});
// Assert
response.HasErrors.Should().BeTrue();
var errorInfo = string.Join(" ", response.Errors!.Select(e =>
e.Message + " " + (e.Extensions?.GetValueOrDefault("details")?.ToString() ?? "")));
errorInfo.Should().Contain("already been posted");
}
#endregion
#region Helper Methods
private async Task<string> CreateCompanyAsync(GraphQLTestClient client, string name)
{
var response = await client.MutateAsync<CreateCompanyResponse>("""
mutation CreateCompany($input: CreateCompanyInput!) {
createCompany(input: $input) { id }
}
""",
new { input = new { name } });
response.EnsureNoErrors();
var companyId = response.Data!.CreateCompany!.Id;
client.SetCompanyId(companyId);
return companyId;
}
private async Task<string> CreateDraftAsync(GraphQLTestClient client, string companyId, string name)
{
var response = await client.MutateAsync<CreateDraftResponse>("""
mutation CreateDraft($input: CreateJournalEntryDraftInput!) {
createJournalEntryDraft(input: $input) { id }
}
""",
new
{
input = new
{
companyId,
name
}
});
response.EnsureNoErrors();
return response.Data!.CreateJournalEntryDraft!.Id;
}
private async Task<string> CreateFiscalYearAsync(GraphQLTestClient client, string companyId)
{
var response = await client.MutateAsync<CreateFiscalYearResponse>("""
mutation CreateFiscalYear($input: CreateFiscalYearInput!) {
createFiscalYear(input: $input) { id }
}
""",
new
{
input = new
{
companyId,
name = "2025",
startDate = "2025-01-01T00:00:00",
endDate = "2025-12-31T00:00:00",
isFirstFiscalYear = true
}
});
response.EnsureNoErrors();
var fiscalYearId = response.Data!.CreateFiscalYear!.Id;
// Wait for fiscal year to be created
await Eventually.GetAsync(async () =>
{
var repo = GetService<IFiscalYearRepository>();
return await repo.GetByIdAsync(fiscalYearId);
});
return fiscalYearId;
}
#endregion
#region Response DTOs
private class CreateDraftResponse { public DraftDto? CreateJournalEntryDraft { get; set; } }
private class UpdateDraftResponse { public DraftDto? UpdateJournalEntryDraft { get; set; } }
private class PostDraftResponse { public DraftDto? PostJournalEntryDraft { get; set; } }
private class DiscardDraftResponse { public DraftDto? DiscardJournalEntryDraft { get; set; } }
private class DraftsResponse { public List<DraftDto> JournalEntryDrafts { get; set; } = []; }
private class SingleDraftResponse { public DraftDto? JournalEntryDraft { get; set; } }
private class CreateCompanyResponse { public CompanyDto? CreateCompany { get; set; } }
private class CreateFiscalYearResponse { public FiscalYearDto? CreateFiscalYear { get; set; } }
private class DraftDto
{
public string Id { get; set; } = string.Empty;
public string CompanyId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? DocumentDate { get; set; }
public string? Description { get; set; }
public string? FiscalYearId { get; set; }
public string Status { get; set; } = string.Empty;
public string? TransactionId { get; set; }
public string CreatedBy { get; set; } = string.Empty;
public List<DraftLineDto>? Lines { get; set; }
}
private class DraftLineDto
{
public int LineNumber { get; set; }
public string? AccountId { get; set; }
public decimal DebitAmount { get; set; }
public decimal CreditAmount { get; set; }
public string? Description { get; set; }
}
private class CompanyDto { public string Id { get; set; } = string.Empty; }
private class FiscalYearDto { public string Id { get; set; } = string.Empty; }
#endregion
}

View file

@ -0,0 +1,51 @@
namespace Books.Api.Tests.Helpers;
/// <summary>
/// Generates valid Danish CVR numbers for testing.
/// </summary>
public static class CvrGenerator
{
private static readonly int[] Weights = [2, 7, 6, 5, 4, 3, 2, 1];
private static readonly Random Random = new();
/// <summary>
/// Generates a random valid CVR number with correct modulus 11 checksum.
/// </summary>
public static string Generate()
{
// Generate 7 random digits
var digits = new int[8];
for (var i = 0; i < 7; i++)
{
digits[i] = Random.Next(0, 10);
}
// Ensure first digit is not 0 (CVR numbers don't start with 0)
if (digits[0] == 0) digits[0] = Random.Next(1, 10);
// Calculate checksum digit using modulus 11
// Sum of (digit * weight) for first 7 digits
var sum = 0;
for (var i = 0; i < 7; i++)
{
sum += digits[i] * Weights[i];
}
// Find the last digit that makes sum % 11 == 0
// We need: (sum + digit * 1) % 11 == 0
// So: digit = (11 - (sum % 11)) % 11
var remainder = sum % 11;
var checkDigit = (11 - remainder) % 11;
// If checkDigit is 10, we can't use it (single digit only)
// So regenerate
if (checkDigit == 10)
{
return Generate();
}
digits[7] = checkDigit;
return string.Join("", digits);
}
}

View file

@ -0,0 +1,12 @@
namespace Books.Api.Tests.Infrastructure;
/// <summary>
/// Collection definition for integration tests.
/// All test classes decorated with [Collection(Name)] will run serially
/// to avoid Hangfire in-memory storage conflicts between parallel tests.
/// </summary>
[CollectionDefinition(Name)]
public class IntegrationTestCollection : ICollectionFixture<TestWebApplicationFactory>
{
public const string Name = "Integration";
}

View file

@ -0,0 +1,44 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Books.Api.Tests.Infrastructure;
/// <summary>
/// Test authentication handler that auto-authenticates all requests.
/// Used in integration tests to simulate an authenticated user.
/// </summary>
public class TestAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string TestScheme = "TestScheme";
public const string TestUserId = "test-user-001";
public const string TestUserEmail = "test@example.com";
public const string TestUserName = "Test User";
public TestAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, TestUserId),
new Claim(ClaimTypes.Email, TestUserEmail),
new Claim(ClaimTypes.Name, TestUserName),
new Claim(ClaimTypes.Role, "user"),
};
var identity = new ClaimsIdentity(claims, TestScheme);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, TestScheme);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View file

@ -0,0 +1,260 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using AwesomeAssertions;
using Books.Api.EventFlow.Repositories;
using Books.Api.Tests.Helpers;
using Books.Api.Tests.Infrastructure;
namespace Books.Api.Tests.Integration;
/// <summary>
/// Integration tests for document processing with AI Bookkeeper.
/// These tests require the AI Bookkeeper service to be running at localhost:8080.
/// Mark with [Trait("Category", "ExternalService")] to allow filtering.
/// </summary>
[Collection(IntegrationTestCollection.Name)]
[Trait("Category", "ExternalService")]
public class DocumentProcessingIntegrationTests : IntegrationTestBase
{
private readonly GraphQLTestClient _graphqlClient;
public DocumentProcessingIntegrationTests(TestWebApplicationFactory factory) : base(factory)
{
_graphqlClient = new GraphQLTestClient(Client);
}
[Fact]
public async Task ProcessDocument_WithRealInvoice_ReturnsSuggestedJournalLines()
{
// Skip if test invoice doesn't exist
var invoicePath = GetTestInvoicePath();
if (!File.Exists(invoicePath))
{
Console.WriteLine($"SKIP: Test invoice not found at: {invoicePath}");
return;
}
// Arrange - Create company (which auto-creates chart of accounts)
var companyId = await CreateTestCompanyAsync();
// Wait for accounts to be created
await WaitForAccountsAsync(companyId);
// Create HTTP client for REST API (not GraphQL)
using var httpClient = Factory.CreateClient();
// Create multipart form content with invoice
using var content = new MultipartFormDataContent();
await using var fileStream = File.OpenRead(invoicePath);
var fileContent = new StreamContent(fileStream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "document", "test-invoice.pdf");
// Act - Call document processing endpoint
var response = await httpClient.PostAsync(
$"/api/documents/process?companyId={companyId}",
content);
// Assert
var responseBody = await response.Content.ReadAsStringAsync();
Console.WriteLine($"Response status: {response.StatusCode}");
Console.WriteLine($"Response body: {responseBody}");
// If AI service is unavailable, show helpful message
if (response.StatusCode == HttpStatusCode.ServiceUnavailable)
{
var error = JsonSerializer.Deserialize<JsonElement>(responseBody);
var code = error.GetProperty("code").GetString();
if (code == "AI_UNAVAILABLE")
{
Console.WriteLine("\n*** AI Bookkeeper service is not running at localhost:8080 ***");
Console.WriteLine("Start the AI service to run this test.\n");
return;
}
}
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = JsonSerializer.Deserialize<JsonElement>(responseBody);
// Verify extraction data
result.TryGetProperty("extraction", out var extraction).Should().BeTrue("should have extraction data");
extraction.TryGetProperty("vendor", out var vendorProp).Should().BeTrue("should extract vendor");
extraction.TryGetProperty("amount", out var amount).Should().BeTrue("should extract amount");
amount.GetDecimal().Should().BeGreaterThan(0, "amount should be positive");
// Verify suggested journal lines (the main thing we're testing!)
result.TryGetProperty("suggestedLines", out var suggestedLines).Should().BeTrue(
"should have suggestedLines - this is what we're testing!");
suggestedLines.GetArrayLength().Should().BeGreaterThanOrEqualTo(2,
"should have at least 2 lines (expense and creditor)");
// Verify lines are balanced
var totalDebits = 0m;
var totalCredits = 0m;
Console.WriteLine("\n=== Suggested Journal Lines ===");
foreach (var line in suggestedLines.EnumerateArray())
{
var accountNumber = line.TryGetProperty("accountNumber", out var an) ? an.GetString() : "-";
var accountName = line.GetProperty("accountName").GetString();
var debit = line.GetProperty("debitAmount").GetDecimal();
var credit = line.GetProperty("creditAmount").GetDecimal();
var vatCode = line.TryGetProperty("vatCode", out var vc) && vc.ValueKind != JsonValueKind.Null
? vc.GetString() : null;
totalDebits += debit;
totalCredits += credit;
Console.WriteLine($" {accountNumber,-6} {accountName,-30} Debit: {debit,10:N2} Credit: {credit,10:N2} VAT: {vatCode ?? "-"}");
}
totalDebits.Should().Be(totalCredits, "journal entry must be balanced");
// Verify draft was created
result.TryGetProperty("draftId", out var draftId).Should().BeTrue();
draftId.GetString().Should().NotBeNullOrEmpty();
Console.WriteLine($"\n=== Document Processing Result ===");
Console.WriteLine($"Draft ID: {draftId.GetString()}");
Console.WriteLine($"Vendor: {vendorProp.GetString()}");
Console.WriteLine($"Total Amount: {amount.GetDecimal():N2}");
Console.WriteLine($"Suggested Lines: {suggestedLines.GetArrayLength()}");
Console.WriteLine($"Total Debits: {totalDebits:N2}");
Console.WriteLine($"Total Credits: {totalCredits:N2}");
Console.WriteLine($"BALANCED: {(totalDebits == totalCredits ? "YES" : "NO")}");
}
[Fact]
public async Task ProcessDocument_WithParkingReceipt_GeneratesSuggestionFromExtraction()
{
// This test uses a receipt to verify our fallback suggestion generation
// works when AI doesn't provide suggestedBooking
var receiptPath = GetTestInvoicePath("parking_receipt_2582441883.pdf");
if (!File.Exists(receiptPath))
{
Console.WriteLine($"SKIP: Test receipt not found at: {receiptPath}");
return;
}
// Arrange
var companyId = await CreateTestCompanyAsync();
await WaitForAccountsAsync(companyId);
using var httpClient = Factory.CreateClient();
using var content = new MultipartFormDataContent();
await using var fileStream = File.OpenRead(receiptPath);
var fileContent = new StreamContent(fileStream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "document", "parking-receipt.pdf");
// Act
var response = await httpClient.PostAsync(
$"/api/documents/process?companyId={companyId}",
content);
var responseBody = await response.Content.ReadAsStringAsync();
Console.WriteLine($"Response: {responseBody}");
if (response.StatusCode == HttpStatusCode.ServiceUnavailable)
{
Console.WriteLine("\n*** AI Bookkeeper service is not running ***\n");
return;
}
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = JsonSerializer.Deserialize<JsonElement>(responseBody);
result.TryGetProperty("suggestedLines", out var lines).Should().BeTrue(
"should generate suggested lines from extraction data");
// Print the suggestions for manual verification
Console.WriteLine("\n=== Generated Suggestions (from extraction fallback) ===");
foreach (var line in lines.EnumerateArray())
{
var accountNumber = line.TryGetProperty("accountNumber", out var an) ? an.GetString() : "-";
var accountName = line.GetProperty("accountName").GetString();
var debit = line.GetProperty("debitAmount").GetDecimal();
var credit = line.GetProperty("creditAmount").GetDecimal();
var vatCode = line.TryGetProperty("vatCode", out var vc) && vc.ValueKind != JsonValueKind.Null
? vc.GetString() : null;
Console.WriteLine($" {accountNumber,-6} {accountName,-30} Debit: {debit,10:N2} Credit: {credit,10:N2} VAT: {vatCode ?? "-"}");
}
}
private async Task<string> CreateTestCompanyAsync()
{
var createResponse = await _graphqlClient.MutateAsync<CreateCompanyResponse>("""
mutation CreateCompany($input: CreateCompanyInput!) {
createCompany(input: $input) {
id
name
}
}
""",
new
{
input = new
{
name = $"AI Test Company {Guid.NewGuid():N}"[..30],
cvr = CvrGenerator.Generate()
}
});
createResponse.EnsureNoErrors();
return createResponse.Data!.CreateCompany!.Id;
}
private async Task WaitForAccountsAsync(string companyId)
{
// Wait for chart of accounts to be initialized (async event handler)
await Eventually.GetAsync(async () =>
{
var repo = GetService<IAccountRepository>();
var accounts = await repo.GetByCompanyIdAsync(companyId);
// Need at least the key accounts for booking: expense, VAT, creditor
return accounts.Count >= 50 ? accounts : null;
}, timeout: TimeSpan.FromSeconds(30));
}
private static string GetTestInvoicePath(string fileName = "invoice-F175490.pdf")
{
// Try to find test invoice relative to test assembly
var assemblyDir = Path.GetDirectoryName(typeof(DocumentProcessingIntegrationTests).Assembly.Location)!;
// Navigate up to find the test-invoices folder
var searchPaths = new[]
{
Path.Combine(assemblyDir, "..", "..", "..", "..", "..", "..", "account-suggestions", "test-invoices", fileName),
Path.Combine(assemblyDir, "..", "..", "..", "..", "account-suggestions", "test-invoices", fileName),
$"/Users/nicolajhartmann/projects/books/account-suggestions/test-invoices/{fileName}"
};
foreach (var path in searchPaths)
{
var fullPath = Path.GetFullPath(path);
if (File.Exists(fullPath))
{
return fullPath;
}
}
return searchPaths[^1]; // Return last (absolute) path for error message
}
// Response DTOs
private class CreateCompanyResponse
{
public CompanyDto? CreateCompany { get; set; }
}
private class CompanyDto
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
}
}

View file

@ -0,0 +1,100 @@
using Books.Api.Banking;
using AwesomeAssertions;
using Microsoft.Extensions.Logging.Abstractions;
namespace Books.Api.Tests.Integration;
/// <summary>
/// Integration tests for Enable Banking API client.
/// These tests call the real Enable Banking API.
/// </summary>
[Trait("Category", "Integration")]
public class EnableBankingClientTests : IDisposable
{
private readonly EnableBankingClient _client;
private readonly HttpClient _httpClient;
public EnableBankingClientTests()
{
var options = new EnableBankingOptions
{
ApplicationId = "0bafa28d-41ea-4275-a4d5-221a78c72350",
KeyId = "0bafa28d-41ea-4275-a4d5-221a78c72350",
PrivateKey = File.ReadAllText(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"projects/ledger/private.key"))
};
_httpClient = new HttpClient
{
BaseAddress = new Uri("https://api.enablebanking.com")
};
_client = new EnableBankingClient(_httpClient, options, NullLogger<EnableBankingClient>.Instance);
}
public void Dispose()
{
_client.Dispose();
_httpClient.Dispose();
}
[Fact]
public async Task GetAspsps_ReturnsDanishBanks()
{
// Act
var banks = await _client.GetAspspsAsync("DK");
// Assert
banks.Should().NotBeEmpty();
banks.Should().Contain(b => b.Name.Contains("Danske", StringComparison.OrdinalIgnoreCase));
}
[Fact(Skip = "Requires redirect URL to be registered in Enable Banking dashboard")]
public async Task StartAuthorization_WithRegisteredRedirectUrl_ReturnsAuthorizationUrl()
{
// Arrange - First get actual bank name from API
var banks = await _client.GetAspspsAsync("DK");
var danskeBank = banks.FirstOrDefault(b => b.Name.Contains("Danske", StringComparison.OrdinalIgnoreCase));
danskeBank.Should().NotBeNull("Danske Bank should exist in available banks");
// Note: This redirect URL must be registered in Enable Banking dashboard
// Configure at: https://enablebanking.com/dashboard
var redirectUrl = "https://your-registered-url.com/callback";
var state = Guid.NewGuid().ToString();
// Act
var result = await _client.StartAuthorizationAsync(
aspspName: danskeBank!.Name,
redirectUrl: redirectUrl,
state: state,
psuType: "personal");
// Assert
result.Should().NotBeNull();
result.AuthorizationId.Should().NotBeNullOrEmpty();
result.Url.Should().StartWith("https://");
}
[Fact]
public async Task StartAuthorization_WithUnregisteredRedirectUrl_ThrowsExpectedError()
{
// Arrange
var banks = await _client.GetAspspsAsync("DK");
var bank = banks.First();
var unregisteredRedirectUrl = "https://unregistered-domain.example.com/callback";
var state = Guid.NewGuid().ToString();
// Act
var act = async () => await _client.StartAuthorizationAsync(
aspspName: bank.Name,
redirectUrl: unregisteredRedirectUrl,
state: state,
psuType: "personal");
// Assert - Should get REDIRECT_URI_NOT_ALLOWED error (proves API auth works)
var exception = await act.Should().ThrowAsync<HttpRequestException>();
exception.Which.Message.Should().Contain("REDIRECT_URI_NOT_ALLOWED");
}
}

View file

@ -0,0 +1,419 @@
using Books.Api.EventFlow.Infrastructure;
using Books.Api.EventFlow.ReadModels;
using Books.Api.EventFlow.Repositories;
using Books.Api.Tests.Helpers;
using Books.Api.Tests.Infrastructure;
using Dapper;
using AwesomeAssertions;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
namespace Books.Api.Tests.Integration;
/// <summary>
/// Integration tests for the read model auto-repair/repopulation system.
/// Tests verify that corrupt or out-of-sync read models can be repaired
/// by replaying events from the event store.
/// </summary>
[Trait("Category", "Integration")]
public class ReadModelRepopulationTests(TestWebApplicationFactory factory)
: IntegrationTestBase(factory)
{
[Fact]
public async Task RepopulateReadModel_FixesCorruptedStatus()
{
// Arrange: Create a draft through normal flow
var graphqlClient = new GraphQLTestClient(Client);
var companyId = await CreateCompanyAsync(graphqlClient, "Repopulation Test Company");
var draftId = await CreateDraftAsync(graphqlClient, companyId, "Test Draft for Repopulation");
// Wait for read model to be created
var originalDraft = await Eventually.GetAsync(async () =>
{
var repo = GetService<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
originalDraft.Should().NotBeNull();
originalDraft!.Status.Should().Be("active");
// Corrupt the read model: Set status to something wrong
await CorruptReadModelStatusAsync(draftId, "posted");
// Verify corruption
var corruptedDraft = await GetService<IJournalEntryDraftRepository>().GetByIdAsync(draftId);
corruptedDraft!.Status.Should().Be("posted"); // Corrupted!
// Act: Repopulate the read model
var response = await graphqlClient.MutateAsync<RepopulateResponse>("""
mutation Repopulate($aggregateId: String!, $readModelType: String!) {
repopulateReadModel(aggregateId: $aggregateId, readModelType: $readModelType)
}
""",
new
{
aggregateId = draftId,
readModelType = "JournalEntryDraftReadModel"
});
// Assert: Repopulation succeeded
response.EnsureNoErrors();
response.Data!.RepopulateReadModel.Should().BeTrue();
// Verify read model is now fixed
var fixedDraft = await GetService<IJournalEntryDraftRepository>().GetByIdAsync(draftId);
fixedDraft!.Status.Should().Be("active"); // Fixed!
}
[Fact]
public async Task RepopulateReadModel_FixesMissingName()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
var companyId = await CreateCompanyAsync(graphqlClient, "Missing Name Test Company");
var draftId = await CreateDraftAsync(graphqlClient, companyId, "Original Draft Name");
await Eventually.GetAsync(async () =>
{
var repo = GetService<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
// Corrupt: Clear the name
await CorruptReadModelNameAsync(draftId, "");
var corruptedDraft = await GetService<IJournalEntryDraftRepository>().GetByIdAsync(draftId);
corruptedDraft!.Name.Should().BeEmpty();
// Act
var response = await graphqlClient.MutateAsync<RepopulateResponse>("""
mutation Repopulate($aggregateId: String!, $readModelType: String!) {
repopulateReadModel(aggregateId: $aggregateId, readModelType: $readModelType)
}
""",
new
{
aggregateId = draftId,
readModelType = "JournalEntryDraftReadModel"
});
// Assert
response.EnsureNoErrors();
var fixedDraft = await GetService<IJournalEntryDraftRepository>().GetByIdAsync(draftId);
fixedDraft!.Name.Should().Be("Original Draft Name");
}
[Fact]
public async Task RepopulateReadModel_RestoresUpdatedData()
{
// Arrange: Create and update a draft
var graphqlClient = new GraphQLTestClient(Client);
var companyId = await CreateCompanyAsync(graphqlClient, "Updated Data Test Company");
var draftId = await CreateDraftAsync(graphqlClient, companyId, "Initial Name");
await Eventually.GetAsync(async () =>
{
var repo = GetService<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
// Update the draft with new data
await graphqlClient.MutateAsync<UpdateDraftResponse>("""
mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) {
updateJournalEntryDraft(input: $input) { id name description }
}
""",
new
{
input = new
{
id = draftId,
name = "Updated Name",
description = "Test Description",
documentDate = "2025-01-15",
lines = Array.Empty<object>()
}
});
// Wait for update
await Eventually.GetAsync(async () =>
{
var repo = GetService<IJournalEntryDraftRepository>();
var draft = await repo.GetByIdAsync(draftId);
return draft?.Name == "Updated Name" ? draft : null;
});
// Corrupt: Revert to old data
await CorruptReadModelNameAsync(draftId, "Wrong Name");
await CorruptReadModelDescriptionAsync(draftId, "Wrong Description");
var corruptedDraft = await GetService<IJournalEntryDraftRepository>().GetByIdAsync(draftId);
corruptedDraft!.Name.Should().Be("Wrong Name");
// Act
var response = await graphqlClient.MutateAsync<RepopulateResponse>("""
mutation Repopulate($aggregateId: String!, $readModelType: String!) {
repopulateReadModel(aggregateId: $aggregateId, readModelType: $readModelType)
}
""",
new
{
aggregateId = draftId,
readModelType = "JournalEntryDraftReadModel"
});
// Assert
response.EnsureNoErrors();
var fixedDraft = await GetService<IJournalEntryDraftRepository>().GetByIdAsync(draftId);
fixedDraft!.Name.Should().Be("Updated Name");
fixedDraft.Description.Should().Be("Test Description");
}
[Fact]
public async Task RepopulateReadModel_FixesOutOfSyncSequenceNumber()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
var companyId = await CreateCompanyAsync(graphqlClient, "Sequence Number Test Company");
var draftId = await CreateDraftAsync(graphqlClient, companyId, "Sequence Test Draft");
await Eventually.GetAsync(async () =>
{
var repo = GetService<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
// Update draft multiple times to increase sequence number
for (int i = 0; i < 3; i++)
{
await graphqlClient.MutateAsync<UpdateDraftResponse>("""
mutation UpdateDraft($input: UpdateJournalEntryDraftInput!) {
updateJournalEntryDraft(input: $input) { id }
}
""",
new
{
input = new
{
id = draftId,
name = $"Update {i + 1}",
lines = Array.Empty<object>()
}
});
await Task.Delay(100);
}
await Eventually.GetAsync(async () =>
{
var repo = GetService<IJournalEntryDraftRepository>();
var draft = await repo.GetByIdAsync(draftId);
return draft?.Name == "Update 3" ? draft : null;
});
// Corrupt: Set sequence number to 1 (old)
await CorruptReadModelSequenceNumberAsync(draftId, 1);
await CorruptReadModelNameAsync(draftId, "Old Name");
// Act
var response = await graphqlClient.MutateAsync<RepopulateResponse>("""
mutation Repopulate($aggregateId: String!, $readModelType: String!) {
repopulateReadModel(aggregateId: $aggregateId, readModelType: $readModelType)
}
""",
new
{
aggregateId = draftId,
readModelType = "JournalEntryDraftReadModel"
});
// Assert
response.EnsureNoErrors();
var fixedDraft = await GetService<IJournalEntryDraftRepository>().GetByIdAsync(draftId);
fixedDraft!.Name.Should().Be("Update 3"); // Latest name from events
}
[Fact]
public async Task RepopulateReadModel_FailsForNonExistentAggregate()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
var companyId = await CreateCompanyAsync(graphqlClient, "Non-existent Aggregate Company");
var nonExistentId = $"journalentrydraft-{Guid.NewGuid():D}";
// Act
var response = await graphqlClient.MutateAsync<RepopulateResponse>("""
mutation Repopulate($aggregateId: String!, $readModelType: String!) {
repopulateReadModel(aggregateId: $aggregateId, readModelType: $readModelType)
}
""",
new
{
aggregateId = nonExistentId,
readModelType = "JournalEntryDraftReadModel"
});
// Assert: Should fail or succeed without effect (no events to replay)
// The behavior depends on implementation - either error or silent success
// Since there are no events, it should complete but read model won't exist
if (!response.HasErrors)
{
var draft = await GetService<IJournalEntryDraftRepository>().GetByIdAsync(nonExistentId);
draft.Should().BeNull();
}
}
[Fact]
public async Task RepopulateReadModel_FailsForInvalidReadModelType()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
var companyId = await CreateCompanyAsync(graphqlClient, "Invalid Type Company");
var draftId = await CreateDraftAsync(graphqlClient, companyId, "Invalid Type Test");
await Eventually.GetAsync(async () =>
{
var repo = GetService<IJournalEntryDraftRepository>();
return await repo.GetByIdAsync(draftId);
});
// Act
var response = await graphqlClient.MutateAsync<RepopulateNullableResponse>("""
mutation Repopulate($aggregateId: String!, $readModelType: String!) {
repopulateReadModel(aggregateId: $aggregateId, readModelType: $readModelType)
}
""",
new
{
aggregateId = draftId,
readModelType = "NonExistentReadModel"
});
// Assert
response.HasErrors.Should().BeTrue();
var errorDetails = response.Errors!
.SelectMany(e => new[] { e.Message, e.Extensions?.GetValueOrDefault("details")?.ToString() ?? "" });
var errorInfo = string.Join(" ", errorDetails);
errorInfo.Should().Contain("Unknown read model type");
}
[Fact]
public async Task ListReadModelTypes_ReturnsAvailableTypes()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
var companyId = await CreateCompanyAsync(graphqlClient, "List Types Company");
// Act - listReadModelTypes is a mutation field (admin endpoint)
var response = await graphqlClient.MutateAsync<ListTypesResponse>("""
mutation {
listReadModelTypes
}
""");
// Assert
response.EnsureNoErrors();
response.Data!.ListReadModelTypes.Should().Contain("JournalEntryDraftReadModel");
response.Data.ListReadModelTypes.Should().Contain("AccountReadModel");
response.Data.ListReadModelTypes.Should().Contain("FiscalYearReadModel");
response.Data.ListReadModelTypes.Should().Contain("CompanyReadModel");
}
#region Database Corruption Helpers
private async Task CorruptReadModelStatusAsync(string aggregateId, string newStatus)
{
var dataSource = GetService<NpgsqlDataSource>();
await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync(
"UPDATE journal_entry_draft_read_models SET status = @status WHERE aggregate_id = @id",
new { status = newStatus, id = aggregateId });
}
private async Task CorruptReadModelNameAsync(string aggregateId, string newName)
{
var dataSource = GetService<NpgsqlDataSource>();
await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync(
"UPDATE journal_entry_draft_read_models SET name = @name WHERE aggregate_id = @id",
new { name = newName, id = aggregateId });
}
private async Task CorruptReadModelDescriptionAsync(string aggregateId, string newDescription)
{
var dataSource = GetService<NpgsqlDataSource>();
await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync(
"UPDATE journal_entry_draft_read_models SET description = @description WHERE aggregate_id = @id",
new { description = newDescription, id = aggregateId });
}
private async Task CorruptReadModelSequenceNumberAsync(string aggregateId, int newSequenceNumber)
{
var dataSource = GetService<NpgsqlDataSource>();
await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync(
"UPDATE journal_entry_draft_read_models SET last_aggregate_sequence_number = @seq WHERE aggregate_id = @id",
new { seq = newSequenceNumber, id = aggregateId });
}
#endregion
#region Helper Methods
private async Task<string> CreateCompanyAsync(GraphQLTestClient client, string name)
{
var response = await client.MutateAsync<CreateCompanyResponse>("""
mutation CreateCompany($input: CreateCompanyInput!) {
createCompany(input: $input) { id }
}
""",
new { input = new { name } });
response.EnsureNoErrors();
var companyId = response.Data!.CreateCompany!.Id;
client.SetCompanyId(companyId);
return companyId;
}
private async Task<string> CreateDraftAsync(GraphQLTestClient client, string companyId, string name)
{
var response = await client.MutateAsync<CreateDraftResponse>("""
mutation CreateDraft($input: CreateJournalEntryDraftInput!) {
createJournalEntryDraft(input: $input) { id }
}
""",
new
{
input = new
{
companyId,
name
}
});
response.EnsureNoErrors();
return response.Data!.CreateJournalEntryDraft!.Id;
}
#endregion
#region Response DTOs
private class RepopulateResponse { public bool RepopulateReadModel { get; set; } }
private class RepopulateNullableResponse { public bool? RepopulateReadModel { get; set; } }
private class ListTypesResponse { public List<string> ListReadModelTypes { get; set; } = []; }
private class CreateCompanyResponse { public CompanyDto? CreateCompany { get; set; } }
private class CreateDraftResponse { public DraftDto? CreateJournalEntryDraft { get; set; } }
private class UpdateDraftResponse { public DraftDto? UpdateJournalEntryDraft { get; set; } }
private class CompanyDto { public string Id { get; set; } = string.Empty; }
private class DraftDto
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string Status { get; set; } = string.Empty;
}
#endregion
}

View file

@ -0,0 +1,484 @@
using Books.Api.Invoicing.Pdf;
using AwesomeAssertions;
using QuestPDF.Infrastructure;
namespace Books.Api.Tests.Invoicing;
/// <summary>
/// Unit tests for the InvoicePdfGenerator.
/// </summary>
[Trait("Category", "Unit")]
public class InvoicePdfGeneratorTests
{
private readonly InvoicePdfGenerator _generator;
public InvoicePdfGeneratorTests()
{
// Ensure QuestPDF license is set for tests
QuestPDF.Settings.License = LicenseType.Community;
_generator = new InvoicePdfGenerator();
}
[Fact]
public void Generate_WithValidData_ProducesPdfBytes()
{
// Arrange
var data = CreateSampleInvoiceData();
// Act
var pdfBytes = _generator.Generate(data);
// Assert
pdfBytes.Should().NotBeNullOrEmpty();
pdfBytes.Should().StartWith([0x25, 0x50, 0x44, 0x46]); // PDF magic bytes "%PDF"
}
[Fact]
public void Generate_WithMultipleLines_ProducesPdf()
{
// Arrange
var data = CreateSampleInvoiceData(lineCount: 10);
// Act
var pdfBytes = _generator.Generate(data);
// Assert
pdfBytes.Should().NotBeNullOrEmpty();
pdfBytes.Length.Should().BeGreaterThan(1000); // PDF should be substantial
}
[Fact]
public void Generate_WithSingleLine_ProducesPdf()
{
// Arrange
var data = CreateSampleInvoiceData(lineCount: 1);
// Act
var pdfBytes = _generator.Generate(data);
// Assert
pdfBytes.Should().NotBeNullOrEmpty();
}
[Fact]
public void Generate_WithDiscount_ProducesPdf()
{
// Arrange
var data = CreateSampleInvoiceData() with
{
Lines =
[
new InvoicePdfLineItem
{
LineNumber = 1,
Description = "Produkt med rabat",
Quantity = 10,
Unit = "stk",
UnitPrice = 100m,
DiscountPercent = 15m,
VatCode = "U25",
AmountExVat = 850m,
AmountVat = 212.50m,
AmountTotal = 1062.50m
}
],
AmountExVat = 850m,
AmountVat = 212.50m,
AmountTotal = 1062.50m,
VatSummary =
[
new VatSummaryLine
{
VatCode = "U25",
VatRate = 0.25m,
BasisAmount = 850m,
VatAmount = 212.50m
}
]
};
// Act
var pdfBytes = _generator.Generate(data);
// Assert
pdfBytes.Should().NotBeNullOrEmpty();
}
[Fact]
public void Generate_WithMultipleVatCodes_ShowsVatSummary()
{
// Arrange
var data = CreateDataWithMixedVatCodes();
// Act
var pdfBytes = _generator.Generate(data);
// Assert
pdfBytes.Should().NotBeNullOrEmpty();
}
[Fact]
public void Generate_WithDanishCharacters_HandlesEncoding()
{
// Arrange
var data = CreateSampleInvoiceData() with
{
CompanyName = "Møller & Sønner ApS",
CustomerName = "Børge Åkeson",
Notes = "Tak for ordren! Vi ses igen. Æbler, øl og åben dør."
};
// Act
var pdfBytes = _generator.Generate(data);
// Assert
pdfBytes.Should().NotBeNullOrEmpty();
}
[Fact]
public void Generate_WithLongDescription_HandlesWrapping()
{
// Arrange
var data = CreateSampleInvoiceData() with
{
Lines =
[
new InvoicePdfLineItem
{
LineNumber = 1,
Description = "Dette er en meget lang beskrivelse af en ydelse der inkluderer mange detaljer om hvad der er leveret, hvornår det er leveret, og hvordan det er udført. Beskrivelsen fortsætter for at teste tekst-wrapping funktionaliteten i PDF generatoren.",
Quantity = 1,
Unit = "stk",
UnitPrice = 5000m,
DiscountPercent = 0,
VatCode = "U25",
AmountExVat = 5000m,
AmountVat = 1250m,
AmountTotal = 6250m
}
],
AmountExVat = 5000m,
AmountVat = 1250m,
AmountTotal = 6250m,
VatSummary =
[
new VatSummaryLine { VatCode = "U25", VatRate = 0.25m, BasisAmount = 5000m, VatAmount = 1250m }
]
};
// Act
var pdfBytes = _generator.Generate(data);
// Assert
pdfBytes.Should().NotBeNullOrEmpty();
}
[Fact]
public void Generate_WithNotes_IncludesNotesSection()
{
// Arrange
var data = CreateSampleInvoiceData() with
{
Notes = "Betalingsfrist overholdes venligst. Ved forsinket betaling beregnes morarenter."
};
// Act
var pdfBytes = _generator.Generate(data);
// Assert
pdfBytes.Should().NotBeNullOrEmpty();
}
[Fact]
public void Generate_WithReference_IncludesReference()
{
// Arrange
var data = CreateSampleInvoiceData() with
{
Reference = "PO-2024-001234"
};
// Act
var pdfBytes = _generator.Generate(data);
// Assert
pdfBytes.Should().NotBeNullOrEmpty();
}
[Fact]
public void Generate_WithZeroVatLines_HandlesCorrectly()
{
// Arrange
var data = CreateSampleInvoiceData() with
{
Lines =
[
new InvoicePdfLineItem
{
LineNumber = 1,
Description = "EU-ydelse (momsfri)",
Quantity = 1,
Unit = "stk",
UnitPrice = 10000m,
DiscountPercent = 0,
VatCode = "UEU",
AmountExVat = 10000m,
AmountVat = 0m,
AmountTotal = 10000m
}
],
AmountExVat = 10000m,
AmountVat = 0m,
AmountTotal = 10000m,
VatSummary =
[
new VatSummaryLine { VatCode = "UEU", VatRate = 0m, BasisAmount = 10000m, VatAmount = 0m }
]
};
// Act
var pdfBytes = _generator.Generate(data);
// Assert
pdfBytes.Should().NotBeNullOrEmpty();
}
[Fact]
public void Generate_WithDecimalQuantity_FormatsCorrectly()
{
// Arrange
var data = CreateSampleInvoiceData() with
{
Lines =
[
new InvoicePdfLineItem
{
LineNumber = 1,
Description = "Konsulentydelse",
Quantity = 7.5m,
Unit = "timer",
UnitPrice = 850m,
DiscountPercent = 0,
VatCode = "U25",
AmountExVat = 6375m,
AmountVat = 1593.75m,
AmountTotal = 7968.75m
}
],
AmountExVat = 6375m,
AmountVat = 1593.75m,
AmountTotal = 7968.75m,
VatSummary =
[
new VatSummaryLine { VatCode = "U25", VatRate = 0.25m, BasisAmount = 6375m, VatAmount = 1593.75m }
]
};
// Act
var pdfBytes = _generator.Generate(data);
// Assert
pdfBytes.Should().NotBeNullOrEmpty();
}
[Fact]
public void Generate_WithBankDetails_IncludesPaymentInfo()
{
// Arrange
var data = CreateSampleInvoiceData() with
{
CompanyBankName = "Nordea",
CompanyBankRegNo = "2191",
CompanyBankAccountNo = "0012345678",
CompanyIban = "DK8920000012345678",
CompanyBic = "NDEADKKK"
};
// Act
var pdfBytes = _generator.Generate(data);
// Assert
pdfBytes.Should().NotBeNullOrEmpty();
pdfBytes.Should().StartWith([0x25, 0x50, 0x44, 0x46]); // PDF magic bytes
}
[Fact]
public void Generate_WithPartialBankDetails_ProducesPdf()
{
// Arrange - Only Danish bank details, no IBAN/BIC
var data = CreateSampleInvoiceData() with
{
CompanyBankName = "Jyske Bank",
CompanyBankRegNo = "5064",
CompanyBankAccountNo = "1234567",
CompanyIban = null,
CompanyBic = null
};
// Act
var pdfBytes = _generator.Generate(data);
// Assert
pdfBytes.Should().NotBeNullOrEmpty();
}
[Fact]
public void Generate_WithNoBankDetails_ProducesPdf()
{
// Arrange - No bank details at all
var data = CreateSampleInvoiceData() with
{
CompanyBankName = null,
CompanyBankRegNo = null,
CompanyBankAccountNo = null,
CompanyIban = null,
CompanyBic = null
};
// Act
var pdfBytes = _generator.Generate(data);
// Assert
pdfBytes.Should().NotBeNullOrEmpty();
}
#region Test Data Helpers
private static InvoicePdfData CreateSampleInvoiceData(int lineCount = 3)
{
var lines = Enumerable.Range(1, lineCount)
.Select(i => new InvoicePdfLineItem
{
LineNumber = i,
Description = $"Konsulentydelse - uge {i}",
Quantity = 40,
Unit = "timer",
UnitPrice = 850m,
DiscountPercent = 0,
VatCode = "U25",
AmountExVat = 34000m,
AmountVat = 8500m,
AmountTotal = 42500m
})
.ToList();
var totalExVat = lines.Sum(l => l.AmountExVat);
var totalVat = lines.Sum(l => l.AmountVat);
var total = lines.Sum(l => l.AmountTotal);
return new InvoicePdfData
{
CompanyName = "Test Firma ApS",
CompanyCvr = "12345678",
CompanyAddress = "Testvej 123",
CompanyPostalCode = "2100",
CompanyCity = "København Ø",
CompanyCountry = "DK",
// Bank details
CompanyBankName = "Danske Bank",
CompanyBankRegNo = "3409",
CompanyBankAccountNo = "1234567890",
CompanyIban = "DK5000400440116243",
CompanyBic = "DABADKKK",
CustomerName = "Kunde ApS",
CustomerCvr = "87654321",
CustomerAddress = "Kundevej 456",
CustomerPostalCode = "8000",
CustomerCity = "Aarhus C",
CustomerCountry = "DK",
CustomerNumber = "K-001",
InvoiceNumber = "2024-0001",
InvoiceDate = new DateOnly(2024, 1, 15),
DueDate = new DateOnly(2024, 2, 14),
PaymentTermsDays = 30,
Currency = "DKK",
Lines = lines,
AmountExVat = totalExVat,
AmountVat = totalVat,
AmountTotal = total,
VatSummary =
[
new VatSummaryLine
{
VatCode = "U25",
VatRate = 0.25m,
BasisAmount = totalExVat,
VatAmount = totalVat
}
]
};
}
private static InvoicePdfData CreateDataWithMixedVatCodes()
{
var lines = new List<InvoicePdfLineItem>
{
new()
{
LineNumber = 1,
Description = "Dansk ydelse (25% moms)",
Quantity = 1,
Unit = "stk",
UnitPrice = 10000m,
DiscountPercent = 0,
VatCode = "U25",
AmountExVat = 10000m,
AmountVat = 2500m,
AmountTotal = 12500m
},
new()
{
LineNumber = 2,
Description = "EU-ydelse (0% moms)",
Quantity = 1,
Unit = "stk",
UnitPrice = 5000m,
DiscountPercent = 0,
VatCode = "UEU",
AmountExVat = 5000m,
AmountVat = 0m,
AmountTotal = 5000m
}
};
return new InvoicePdfData
{
CompanyName = "Mixed VAT Company ApS",
CompanyCvr = "11111111",
CompanyAddress = "Momsvej 1",
CompanyPostalCode = "1000",
CompanyCity = "København K",
CompanyCountry = "DK",
CustomerName = "EU Customer GmbH",
CustomerCvr = "DE123456789",
CustomerAddress = "Hauptstraße 1",
CustomerPostalCode = "10115",
CustomerCity = "Berlin",
CustomerCountry = "DE",
CustomerNumber = "K-EU-001",
InvoiceNumber = "2024-0002",
InvoiceDate = new DateOnly(2024, 2, 1),
DueDate = new DateOnly(2024, 3, 1),
PaymentTermsDays = 30,
Currency = "DKK",
Lines = lines,
AmountExVat = 15000m,
AmountVat = 2500m,
AmountTotal = 17500m,
VatSummary =
[
new VatSummaryLine { VatCode = "U25", VatRate = 0.25m, BasisAmount = 10000m, VatAmount = 2500m },
new VatSummaryLine { VatCode = "UEU", VatRate = 0m, BasisAmount = 5000m, VatAmount = 0m }
]
};
}
#endregion
}

View file

@ -0,0 +1,368 @@
using AutoFixture;
using AutoFixture.AutoMoq;
using AwesomeAssertions;
using Books.Api.EventFlow.ReadModels;
using Books.Api.EventFlow.Repositories;
using Books.Api.Reporting;
using Ledger.Core.Models;
using Ledger.Core.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace Books.Api.Tests.Reporting;
/// <summary>
/// Unit tests for VatReportService.
/// Tests VAT report generation from ledger data for SKAT compliance.
/// </summary>
[Trait("Category", "Unit")]
public class VatReportServiceTests
{
private readonly IFixture _fixture;
private readonly Mock<IAccountRepository> _accountRepository;
private readonly Mock<ILedgerService> _ledgerService;
private readonly VatReportService _sut;
private const string CompanyId = "company-123";
private const string InputVatAccountNumber = "5610";
private const string OutputVatAccountNumber = "5611";
public VatReportServiceTests()
{
_fixture = new Fixture().Customize(new AutoMoqCustomization());
_accountRepository = new Mock<IAccountRepository>();
_ledgerService = new Mock<ILedgerService>();
_sut = new VatReportService(
_accountRepository.Object,
_ledgerService.Object,
NullLogger<VatReportService>.Instance);
}
#region Period Validation Tests
[Fact]
public async Task GenerateReportAsync_PeriodEndBeforeStart_ThrowsArgumentException()
{
// Arrange
var periodStart = new DateOnly(2024, 3, 1);
var periodEnd = new DateOnly(2024, 2, 1);
// Act
var act = () => _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd);
// Assert
await act.Should().ThrowAsync<ArgumentException>()
.WithMessage("*startdato*");
}
[Fact]
public async Task GenerateReportAsync_PeriodExceedsOneYear_ThrowsArgumentException()
{
// Arrange
var periodStart = new DateOnly(2024, 1, 1);
var periodEnd = new DateOnly(2025, 2, 1); // 397 days
// Act
var act = () => _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd);
// Assert
await act.Should().ThrowAsync<ArgumentException>()
.WithMessage("*366*");
}
[Fact]
public async Task GenerateReportAsync_ValidPeriod_DoesNotThrow()
{
// Arrange
var periodStart = new DateOnly(2024, 1, 1);
var periodEnd = new DateOnly(2024, 12, 31); // 365 days - valid
SetupNoVatAccounts();
SetupEmptyLedgerResponse();
// Act
var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd);
// Assert - should not throw, returns empty report
result.Should().NotBeNull();
}
#endregion
#region No VAT Accounts Tests
[Fact]
public async Task GenerateReportAsync_NoVatAccounts_ReturnsEmptyReport()
{
// Arrange
var periodStart = new DateOnly(2024, 1, 1);
var periodEnd = new DateOnly(2024, 3, 31);
SetupNoVatAccounts();
// Act
var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd);
// Assert
result.BoxA.Should().Be(0);
result.BoxB.Should().Be(0);
result.BoxC.Should().Be(0);
result.BoxD.Should().Be(0);
result.NetVat.Should().Be(0);
result.TransactionCount.Should().Be(0);
result.PeriodStart.Should().Be(periodStart);
result.PeriodEnd.Should().Be(periodEnd);
}
#endregion
#region Output VAT Tests
[Fact]
public async Task GenerateReportAsync_OnlyOutputVat_ReturnsCorrectBoxA()
{
// Arrange
var periodStart = new DateOnly(2024, 1, 1);
var periodEnd = new DateOnly(2024, 3, 31);
var outputGuid = Guid.NewGuid();
SetupInputVatAccount(null);
SetupOutputVatAccount(outputGuid, "Salgsmoms");
SetupLedgerResponse(new AccountPeriodBalance
{
AccountId = outputGuid,
TotalCredits = 25000m,
TotalDebits = 0m,
NetChange = -25000m,
EntryCount = 50,
Currency = "DKK"
});
// Act
var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd);
// Assert
result.BoxA.Should().Be(25000m);
result.BoxB.Should().Be(0);
result.TotalOutputVat.Should().Be(25000m);
result.TotalInputVat.Should().Be(0);
result.NetVat.Should().Be(25000m);
result.TransactionCount.Should().Be(50);
}
#endregion
#region Input VAT Tests
[Fact]
public async Task GenerateReportAsync_OnlyInputVat_ReturnsCorrectBoxB()
{
// Arrange
var periodStart = new DateOnly(2024, 1, 1);
var periodEnd = new DateOnly(2024, 3, 31);
var inputGuid = Guid.NewGuid();
SetupInputVatAccount(inputGuid, "Købsmoms");
SetupOutputVatAccount(null);
SetupLedgerResponse(new AccountPeriodBalance
{
AccountId = inputGuid,
TotalDebits = 15000m,
TotalCredits = 0m,
NetChange = 15000m,
EntryCount = 30,
Currency = "DKK"
});
// Act
var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd);
// Assert
result.BoxA.Should().Be(0);
result.BoxB.Should().Be(15000m);
result.TotalOutputVat.Should().Be(0);
result.TotalInputVat.Should().Be(15000m);
result.NetVat.Should().Be(-15000m); // Negative = refund
result.TransactionCount.Should().Be(30);
}
#endregion
#region Mixed VAT Tests
[Fact]
public async Task GenerateReportAsync_BothInputAndOutputVat_CalculatesNetCorrectly()
{
// Arrange
var periodStart = new DateOnly(2024, 1, 1);
var periodEnd = new DateOnly(2024, 3, 31);
var inputGuid = Guid.NewGuid();
var outputGuid = Guid.NewGuid();
SetupInputVatAccount(inputGuid, "Købsmoms");
SetupOutputVatAccount(outputGuid, "Salgsmoms");
SetupLedgerResponse(
new AccountPeriodBalance
{
AccountId = inputGuid,
TotalDebits = 10000m,
TotalCredits = 0m,
NetChange = 10000m,
EntryCount = 20,
Currency = "DKK"
},
new AccountPeriodBalance
{
AccountId = outputGuid,
TotalCredits = 25000m,
TotalDebits = 0m,
NetChange = -25000m,
EntryCount = 40,
Currency = "DKK"
});
// Act
var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd);
// Assert
result.BoxA.Should().Be(25000m);
result.BoxB.Should().Be(10000m);
result.TotalOutputVat.Should().Be(25000m);
result.TotalInputVat.Should().Be(10000m);
result.NetVat.Should().Be(15000m); // 25000 - 10000 = to pay
result.TransactionCount.Should().Be(60);
}
#endregion
#region Basis Calculation Tests
[Fact]
public async Task GenerateReportAsync_WithOutputVat_CalculatesBasis1From25PercentRate()
{
// Arrange
var periodStart = new DateOnly(2024, 1, 1);
var periodEnd = new DateOnly(2024, 3, 31);
var outputGuid = Guid.NewGuid();
SetupInputVatAccount(null);
SetupOutputVatAccount(outputGuid, "Salgsmoms");
SetupLedgerResponse(new AccountPeriodBalance
{
AccountId = outputGuid,
TotalCredits = 2500m, // 25% VAT on 10000
TotalDebits = 0m,
NetChange = -2500m,
EntryCount = 10,
Currency = "DKK"
});
// Act
var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd);
// Assert
result.BoxA.Should().Be(2500m);
result.Basis1.Should().Be(10000m); // 2500 / 0.25 = 10000
}
#endregion
#region Empty Period Tests
[Fact]
public async Task GenerateReportAsync_EmptyPeriod_ReturnsZeroAmounts()
{
// Arrange
var periodStart = new DateOnly(2024, 1, 1);
var periodEnd = new DateOnly(2024, 3, 31);
var inputGuid = Guid.NewGuid();
var outputGuid = Guid.NewGuid();
SetupInputVatAccount(inputGuid, "Købsmoms");
SetupOutputVatAccount(outputGuid, "Salgsmoms");
SetupEmptyLedgerResponse();
// Act
var result = await _sut.GenerateReportAsync(CompanyId, periodStart, periodEnd);
// Assert
result.BoxA.Should().Be(0);
result.BoxB.Should().Be(0);
result.TransactionCount.Should().Be(0);
result.NetVat.Should().Be(0);
}
#endregion
#region Helper Methods
private void SetupNoVatAccounts()
{
_accountRepository
.Setup(x => x.GetByCompanyAndNumberAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((AccountReadModelDto?)null);
}
private void SetupEmptyLedgerResponse()
{
_ledgerService
.Setup(x => x.QueryEntriesAsync(
It.IsAny<EntriesQuery>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new EntriesQueryResult { Aggregates = [] });
}
private void SetupLedgerResponse(params AccountPeriodBalance[] balances)
{
_ledgerService
.Setup(x => x.QueryEntriesAsync(
It.IsAny<EntriesQuery>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new EntriesQueryResult { Aggregates = balances.ToList() });
}
private void SetupInputVatAccount(Guid? accountGuid, string name = "Købsmoms")
{
var account = accountGuid.HasValue
? new AccountReadModelDto
{
Id = $"account-{accountGuid.Value}",
CompanyId = CompanyId,
AccountNumber = InputVatAccountNumber,
Name = name
}
: null;
_accountRepository
.Setup(x => x.GetByCompanyAndNumberAsync(
CompanyId,
InputVatAccountNumber,
It.IsAny<CancellationToken>()))
.ReturnsAsync(account);
}
private void SetupOutputVatAccount(Guid? accountGuid, string name = "Salgsmoms")
{
var account = accountGuid.HasValue
? new AccountReadModelDto
{
Id = $"account-{accountGuid.Value}",
CompanyId = CompanyId,
AccountNumber = OutputVatAccountNumber,
Name = name
}
: null;
_accountRepository
.Setup(x => x.GetByCompanyAndNumberAsync(
CompanyId,
OutputVatAccountNumber,
It.IsAny<CancellationToken>()))
.ReturnsAsync(account);
}
#endregion
}

View file

@ -0,0 +1,126 @@
using Books.Api.EventFlow.ReadModels;
using Books.Api.EventFlow.Repositories;
namespace Books.Api.AiBookkeeper;
/// <summary>
/// Maps standard account numbers to company-specific accounts.
/// Uses the standardAccountNumber field on accounts for matching.
/// </summary>
public class AccountMappingService(
IAccountRepository accountRepository,
ILogger<AccountMappingService> logger) : IAccountMappingService
{
public async Task<AccountReadModelDto?> FindByStandardAccountNumberAsync(
string companyId,
string standardAccountNumber,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(standardAccountNumber))
{
return null;
}
var accounts = await accountRepository.GetActiveByCompanyIdAsync(companyId, cancellationToken);
// First, try exact match on standardAccountNumber
var exactMatch = accounts.FirstOrDefault(a =>
a.StandardAccountNumber != null &&
a.StandardAccountNumber.Equals(standardAccountNumber, StringComparison.OrdinalIgnoreCase));
if (exactMatch != null)
{
logger.LogDebug(
"Found exact match for standard account {StandardAccount}: {AccountNumber} - {AccountName}",
standardAccountNumber, exactMatch.AccountNumber, exactMatch.Name);
return exactMatch;
}
// Try prefix match (standard account numbers can be hierarchical)
var prefixMatch = accounts
.Where(a =>
a.StandardAccountNumber != null &&
standardAccountNumber.StartsWith(a.StandardAccountNumber, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(a => a.StandardAccountNumber?.Length ?? 0)
.FirstOrDefault();
if (prefixMatch != null)
{
logger.LogDebug(
"Found prefix match for standard account {StandardAccount}: {AccountNumber} - {AccountName}",
standardAccountNumber, prefixMatch.AccountNumber, prefixMatch.Name);
return prefixMatch;
}
logger.LogDebug("No match found for standard account {StandardAccount}", standardAccountNumber);
return null;
}
public async Task<List<MappedSuggestedLine>> MapSuggestedLinesAsync(
string companyId,
List<SuggestedLine> suggestedLines,
CancellationToken cancellationToken = default)
{
var accounts = await accountRepository.GetActiveByCompanyIdAsync(companyId, cancellationToken);
// Index accounts by both StandardAccountNumber and direct AccountNumber
var accountsByStandardNumber = accounts
.Where(a => !string.IsNullOrEmpty(a.StandardAccountNumber))
.GroupBy(a => a.StandardAccountNumber!)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var accountsByNumber = accounts
.ToDictionary(a => a.AccountNumber, a => a, StringComparer.OrdinalIgnoreCase);
var result = new List<MappedSuggestedLine>();
foreach (var line in suggestedLines)
{
AccountReadModelDto? mappedAccount = null;
if (!string.IsNullOrEmpty(line.StandardAccountNumber))
{
// First, try direct account number match (AI service returns company account numbers)
if (accountsByNumber.TryGetValue(line.StandardAccountNumber, out var directMatch))
{
mappedAccount = directMatch;
}
// Then try Erhvervsstyrelsen standard account number match
else if (accountsByStandardNumber.TryGetValue(line.StandardAccountNumber, out var exactMatch))
{
mappedAccount = exactMatch;
}
else
{
// Try prefix match on standard numbers
mappedAccount = accountsByStandardNumber
.Where(kvp => line.StandardAccountNumber.StartsWith(kvp.Key, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(kvp => kvp.Key.Length)
.Select(kvp => kvp.Value)
.FirstOrDefault();
}
}
result.Add(new MappedSuggestedLine
{
Original = line,
MappedAccount = mappedAccount
});
if (mappedAccount != null)
{
logger.LogDebug(
"Mapped standard account {StandardAccount} to {AccountNumber} - {AccountName}",
line.StandardAccountNumber, mappedAccount.AccountNumber, mappedAccount.Name);
}
else
{
logger.LogDebug(
"Could not map standard account {StandardAccount}",
line.StandardAccountNumber);
}
}
return result;
}
}

View file

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

View file

@ -0,0 +1,24 @@
namespace Books.Api.AiBookkeeper;
/// <summary>
/// Configuration options for the AI Bookkeeper service.
/// </summary>
public class AiBookkeeperOptions
{
public const string ConfigurationSection = "AiBookkeeper";
/// <summary>
/// Base URL for the AI Bookkeeper API.
/// </summary>
public string BaseUrl { get; set; } = "https://ai-bookkeeper.softwarehuset.com";
/// <summary>
/// API key for authentication.
/// </summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>
/// Request timeout in seconds.
/// </summary>
public int TimeoutSeconds { get; set; } = 60;
}

View file

@ -0,0 +1,96 @@
using Books.Api.EventFlow.ReadModels;
using Books.Api.EventFlow.Repositories;
namespace Books.Api.AiBookkeeper;
/// <summary>
/// Matches documents to pending bank transactions based on amount.
/// </summary>
public class BankTransactionMatcher(
IBankTransactionRepository transactionRepository,
ILogger<BankTransactionMatcher> logger) : IBankTransactionMatcher
{
public async Task<BankTransactionDto?> FindMatchingTransactionAsync(
string companyId,
decimal amount,
decimal tolerance = 0.01m,
CancellationToken cancellationToken = default)
{
// For invoice/expense: document shows positive amount, but bank transaction is negative (money leaving)
// We need to match on the absolute value
var targetAmount = amount;
logger.LogDebug(
"Searching for pending transaction matching amount {Amount} (±{Tolerance}) for company {CompanyId}",
targetAmount, tolerance, companyId);
var pendingTransactions = await transactionRepository.GetPendingByCompanyIdAsync(companyId, cancellationToken);
if (pendingTransactions.Count == 0)
{
logger.LogDebug("No pending transactions found for company {CompanyId}", companyId);
return null;
}
// Find transactions matching the amount within tolerance
// For expenses: document amount is positive, bank amount is negative
// So we compare: |bank amount| matches document amount
var matchingTransactions = pendingTransactions
.Where(t => IsAmountMatch(t.Amount, targetAmount, tolerance))
.OrderBy(t => t.TransactionDate) // Oldest first
.ThenBy(t => t.CreatedAt)
.ToList();
if (matchingTransactions.Count == 0)
{
logger.LogDebug(
"No matching transactions found for amount {Amount} (checked {Count} pending)",
targetAmount, pendingTransactions.Count);
return null;
}
var match = matchingTransactions.First();
logger.LogInformation(
"Found matching transaction {TransactionId}: {Amount} on {Date} ({Description})",
match.Id, match.Amount, match.TransactionDate.ToString("yyyy-MM-dd"), match.Description);
if (matchingTransactions.Count > 1)
{
logger.LogDebug(
"Multiple matches found ({Count}), returning oldest",
matchingTransactions.Count);
}
return match;
}
private static bool IsAmountMatch(decimal bankAmount, decimal documentAmount, decimal tolerance)
{
// For expenses: document amount is typically positive (e.g., invoice for 1000 DKK)
// Bank transaction is negative (e.g., -1000 DKK outgoing payment)
// So we need to check if the absolute values match
// Option 1: Exact match on absolute values (most common for invoice matching)
var absBank = Math.Abs(bankAmount);
var absDoc = Math.Abs(documentAmount);
if (Math.Abs(absBank - absDoc) <= tolerance)
{
return true;
}
// Option 2: Direct match (same sign and value)
if (Math.Abs(bankAmount - documentAmount) <= tolerance)
{
return true;
}
// Option 3: Opposite sign match (document positive, bank negative or vice versa)
if (Math.Abs(bankAmount + documentAmount) <= tolerance)
{
return true;
}
return false;
}
}

View file

@ -0,0 +1,23 @@
namespace Books.Api.AiBookkeeper;
/// <summary>
/// Chart of accounts data for AI Bookkeeper processing.
/// Contains the list of expense accounts that AI can suggest for document booking.
/// </summary>
public class ChartOfAccountsDto
{
public required string CompanyId { get; init; }
public required IReadOnlyList<AiAccountDto> Accounts { get; init; }
}
/// <summary>
/// Account information for AI Bookkeeper.
/// </summary>
public class AiAccountDto
{
public required string AccountNumber { get; init; }
public required string Name { get; init; }
public required string AccountType { get; init; }
public string? VatCodeId { get; init; }
public string? StandardAccountNumber { get; init; }
}

View file

@ -0,0 +1,36 @@
using Books.Api.EventFlow.Repositories;
namespace Books.Api.AiBookkeeper;
/// <summary>
/// Provides chart of accounts data for AI Bookkeeper processing.
/// Fetches accounts from repository and filters to expense-type accounts.
/// </summary>
public class ChartOfAccountsProvider(IAccountRepository accountRepository) : IChartOfAccountsProvider
{
public async Task<ChartOfAccountsDto> GetChartOfAccountsAsync(
string companyId, CancellationToken cancellationToken = default)
{
var accounts = await accountRepository.GetActiveByCompanyIdAsync(companyId, cancellationToken);
// Filter to expense-type accounts that AI can suggest
var expenseAccounts = accounts
.Where(a => a.AccountType is "expense" or "cogs" or "personnel" or "financial")
.OrderBy(a => a.AccountNumber)
.Select(a => new AiAccountDto
{
AccountNumber = a.AccountNumber,
Name = a.Name,
AccountType = a.AccountType,
VatCodeId = a.VatCodeId,
StandardAccountNumber = a.StandardAccountNumber
})
.ToList();
return new ChartOfAccountsDto
{
CompanyId = companyId,
Accounts = expenseAccounts
};
}
}

View file

@ -0,0 +1,54 @@
using Books.Api.EventFlow.ReadModels;
namespace Books.Api.AiBookkeeper;
/// <summary>
/// Maps standard account numbers (from Erhvervsstyrelsen) to company-specific accounts.
/// </summary>
public interface IAccountMappingService
{
/// <summary>
/// Find the company's account that matches a standard account number.
/// </summary>
/// <param name="companyId">Company ID</param>
/// <param name="standardAccountNumber">Standard account number from Erhvervsstyrelsen</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The matching account, or null if not found</returns>
Task<AccountReadModelDto?> FindByStandardAccountNumberAsync(
string companyId,
string standardAccountNumber,
CancellationToken cancellationToken = default);
/// <summary>
/// Map AI-suggested lines to company accounts.
/// </summary>
/// <param name="companyId">Company ID</param>
/// <param name="suggestedLines">Lines from AI suggestion</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Mapped lines with company account IDs</returns>
Task<List<MappedSuggestedLine>> MapSuggestedLinesAsync(
string companyId,
List<SuggestedLine> suggestedLines,
CancellationToken cancellationToken = default);
}
/// <summary>
/// A suggested line with the mapped company account.
/// </summary>
public class MappedSuggestedLine
{
/// <summary>
/// Original suggested line from AI.
/// </summary>
public required SuggestedLine Original { get; init; }
/// <summary>
/// Mapped company account (if found).
/// </summary>
public AccountReadModelDto? MappedAccount { get; init; }
/// <summary>
/// Whether the line was successfully mapped to a company account.
/// </summary>
public bool IsMapped => MappedAccount != null;
}

View file

@ -0,0 +1,246 @@
using System.Text.Json.Serialization;
namespace Books.Api.AiBookkeeper;
/// <summary>
/// Client for the AI Bookkeeper service that analyzes documents
/// and suggests bookkeeping entries.
/// </summary>
public interface IAiBookkeeperClient
{
/// <summary>
/// Process a document (invoice, receipt, etc.) and get AI-suggested bookkeeping.
/// </summary>
/// <param name="document">The document file stream</param>
/// <param name="fileName">Original filename</param>
/// <param name="contentType">MIME type of the document</param>
/// <param name="chartOfAccounts">Chart of accounts data</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>AI extraction and suggested bookkeeping</returns>
Task<AiBookkeeperResponse> ProcessDocumentAsync(
Stream document,
string fileName,
string contentType,
ChartOfAccountsDto chartOfAccounts,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Response from AI Bookkeeper document processing.
/// </summary>
public class AiBookkeeperResponse
{
/// <summary>
/// Whether the document was successfully analyzed.
/// </summary>
public bool Success { get; set; }
/// <summary>
/// Error message if processing failed.
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// Extracted document information.
/// </summary>
public DocumentExtraction? Extraction { get; set; }
/// <summary>
/// Suggested bookkeeping entry.
/// </summary>
public BookkeepingSuggestion? Suggestion { get; set; }
}
/// <summary>
/// Extracted information from the document.
/// </summary>
public class DocumentExtraction
{
/// <summary>
/// Detected document type (invoice, receipt, credit_note, etc.)
/// </summary>
[JsonPropertyName("documentType")]
public string? DocumentType { get; set; }
/// <summary>
/// Vendor/supplier name.
/// </summary>
[JsonPropertyName("vendor")]
public string? Vendor { get; set; }
/// <summary>
/// Vendor CVR number (Danish company registration).
/// </summary>
[JsonPropertyName("vendorCvr")]
public string? VendorCvr { get; set; }
/// <summary>
/// Invoice/receipt number.
/// </summary>
[JsonPropertyName("invoiceNumber")]
public string? InvoiceNumber { get; set; }
/// <summary>
/// Document date.
/// </summary>
[JsonPropertyName("date")]
public DateOnly? Date { get; set; }
/// <summary>
/// Due date (for invoices).
/// </summary>
[JsonPropertyName("dueDate")]
public DateOnly? DueDate { get; set; }
/// <summary>
/// Total amount including VAT.
/// </summary>
[JsonPropertyName("totalAmount")]
public decimal? TotalAmount { get; set; }
/// <summary>
/// Amount excluding VAT.
/// </summary>
[JsonPropertyName("amountExVat")]
public decimal? AmountExVat { get; set; }
/// <summary>
/// VAT amount.
/// </summary>
[JsonPropertyName("vatAmount")]
public decimal? VatAmount { get; set; }
/// <summary>
/// Currency code (default DKK).
/// </summary>
[JsonPropertyName("currency")]
public string Currency { get; set; } = "DKK";
/// <summary>
/// Extracted line items.
/// </summary>
[JsonPropertyName("lineItems")]
public List<ExtractedLineItem> LineItems { get; set; } = [];
/// <summary>
/// Payment reference (FIK, girocard number, etc.)
/// </summary>
[JsonPropertyName("paymentReference")]
public string? PaymentReference { get; set; }
/// <summary>
/// Raw text extracted from the document.
/// </summary>
[JsonPropertyName("rawText")]
public string? RawText { get; set; }
}
/// <summary>
/// Extracted line item from invoice/receipt.
/// </summary>
public class ExtractedLineItem
{
[JsonPropertyName("description")]
public string? Description { get; set; }
[JsonPropertyName("quantity")]
public decimal? Quantity { get; set; }
[JsonPropertyName("unitPrice")]
public decimal? UnitPrice { get; set; }
[JsonPropertyName("amount")]
public decimal? Amount { get; set; }
[JsonPropertyName("vatRate")]
public decimal? VatRate { get; set; }
}
/// <summary>
/// AI-suggested bookkeeping entry.
/// </summary>
public class BookkeepingSuggestion
{
/// <summary>
/// Suggested description for the journal entry.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Suggested account lines.
/// </summary>
public List<SuggestedLine> Lines { get; set; } = [];
/// <summary>
/// Overall confidence in the suggestion (0.0 - 1.0).
/// </summary>
public decimal Confidence { get; set; }
}
/// <summary>
/// A suggested bookkeeping line with standard account number.
/// </summary>
public class SuggestedLine
{
/// <summary>
/// Standard account number from Erhvervsstyrelsen.
/// </summary>
public string? StandardAccountNumber { get; set; }
/// <summary>
/// Suggested account name/description.
/// </summary>
public string? AccountName { get; set; }
/// <summary>
/// Debit amount.
/// </summary>
public decimal DebitAmount { get; set; }
/// <summary>
/// Credit amount.
/// </summary>
public decimal CreditAmount { get; set; }
/// <summary>
/// VAT code (I25, U25, etc.)
/// </summary>
public string? VatCode { get; set; }
/// <summary>
/// Confidence in this specific line (0.0 - 1.0).
/// </summary>
public decimal Confidence { get; set; }
}
/// <summary>
/// AI service's single account suggestion.
/// The AI analyzes the document and suggests the most appropriate expense account.
/// </summary>
public class AiAccountSuggestion
{
/// <summary>
/// Company account number suggested by AI (e.g., "6080" for parking).
/// </summary>
public required string AccountNumber { get; init; }
/// <summary>
/// Account name (e.g., "Parkering (gulplade)").
/// </summary>
public required string AccountName { get; init; }
/// <summary>
/// VAT code for this account (e.g., "I25" for 25% input VAT).
/// </summary>
public string? VatCode { get; init; }
/// <summary>
/// Confidence in the suggestion (0.0 - 1.0).
/// </summary>
public decimal Confidence { get; init; }
/// <summary>
/// AI's reasoning for why this account was chosen.
/// </summary>
public string? Reasoning { get; init; }
}

View file

@ -0,0 +1,25 @@
using Books.Api.EventFlow.ReadModels;
namespace Books.Api.AiBookkeeper;
/// <summary>
/// Matches documents to pending bank transactions based on amount.
/// </summary>
public interface IBankTransactionMatcher
{
/// <summary>
/// Find the oldest pending bank transaction matching the given amount.
/// For expenses (negative amounts), matches transactions where the bank amount is negative.
/// For income (positive amounts), matches transactions where the bank amount is positive.
/// </summary>
/// <param name="companyId">Company ID</param>
/// <param name="amount">Document amount (positive for income, negative for expense)</param>
/// <param name="tolerance">Amount tolerance (default ±0.01)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Matching bank transaction, or null if not found</returns>
Task<BankTransactionDto?> FindMatchingTransactionAsync(
string companyId,
decimal amount,
decimal tolerance = 0.01m,
CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,17 @@
namespace Books.Api.AiBookkeeper;
/// <summary>
/// Provides chart of accounts data for AI Bookkeeper processing.
/// </summary>
public interface IChartOfAccountsProvider
{
/// <summary>
/// Get the chart of accounts for a company.
/// Returns only expense-type accounts (expense, cogs, personnel, financial) that AI can suggest.
/// </summary>
/// <param name="companyId">Company ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Chart of accounts with filtered expense accounts</returns>
Task<ChartOfAccountsDto> GetChartOfAccountsAsync(
string companyId, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,22 @@
namespace Books.Api.AiBookkeeper;
/// <summary>
/// Stub implementation of IAiBookkeeperClient used when the API key is not configured.
/// Returns an error message indicating the service is unavailable.
/// </summary>
public class StubAiBookkeeperClient : IAiBookkeeperClient
{
public Task<AiBookkeeperResponse> ProcessDocumentAsync(
Stream document,
string fileName,
string contentType,
ChartOfAccountsDto chartOfAccounts,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new AiBookkeeperResponse
{
Success = false,
ErrorMessage = "AI Bookkeeper er ikke konfigureret. Kontakt administrator."
});
}
}

View file

@ -0,0 +1,115 @@
using System.Text;
namespace Books.Api.AiBookkeeper;
/// <summary>
/// Converts ChartOfAccountsDto to .toon format for AI Bookkeeper.
/// The .toon format is a structured text format with meta and accounts sections.
/// </summary>
public static class ToonFormatConverter
{
/// <summary>
/// Convert a chart of accounts to .toon format string.
/// </summary>
public static string ConvertToToon(ChartOfAccountsDto chartOfAccounts)
{
var sb = new StringBuilder();
// Header comment
sb.AppendLine("# Chart of Accounts for AI Bookkeeper");
sb.AppendLine();
// Meta section
sb.AppendLine("meta:");
sb.AppendLine(" source: Books API");
sb.AppendLine($" organizationId: {chartOfAccounts.CompanyId}");
sb.AppendLine(" accountType: expense");
sb.AppendLine($" totalAccounts: {chartOfAccounts.Accounts.Count}");
sb.AppendLine();
// Accounts section
// Format: number,name,category,vatCode,region,vatRubric,suggestions
sb.AppendLine($"accounts[{chartOfAccounts.Accounts.Count}]{{number,name,category,vatCode,region,vatRubric,suggestions}}:");
foreach (var account in chartOfAccounts.Accounts)
{
var vatCode = MapVatCode(account.VatCodeId);
var region = DetermineRegion(account.VatCodeId);
var category = MapCategory(account.AccountType);
var suggestions = GenerateSuggestions(account.Name, account.AccountNumber);
// Format: number,name,category,vatCode,region,vatRubric,suggestions
sb.AppendLine($" {account.AccountNumber},{EscapeCommas(account.Name)},{category},{vatCode},{region},,{suggestions}");
}
return sb.ToString();
}
public static string MapVatCode(string? vatCodeId)
{
if (string.IsNullOrEmpty(vatCodeId))
return "";
return vatCodeId.ToUpperInvariant() switch
{
"I25" => "I25",
"U25" => "", // Output VAT not relevant for expense accounts
"INGEN" => "",
_ => vatCodeId
};
}
public static string DetermineRegion(string? vatCodeId)
{
if (string.IsNullOrEmpty(vatCodeId))
return "";
return vatCodeId.ToUpperInvariant() switch
{
"IEUV" => "EU", // EU goods
"IEUY" => "EU", // EU services
"IVV" => "WORLD", // World goods
"IVY" => "WORLD", // World services
_ => "" // Empty = available for all regions
};
}
public static string MapCategory(string accountType)
{
return accountType switch
{
"expense" => "Administrationsomkostninger",
"cogs" => "Variable omkostninger",
"personnel" => "Lønomkostninger",
"financial" => "Renteudgifter",
_ => "Øvrige omkostninger"
};
}
public static string GenerateSuggestions(string name, string accountNumber)
{
// Generate search keywords from account name
var suggestions = new List<string>();
// Add words from name (lowercase, no special chars)
var words = name.ToLowerInvariant()
.Replace(",", " ")
.Replace(".", " ")
.Replace("-", " ")
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Where(w => w.Length > 2);
suggestions.AddRange(words);
// Add account number as suggestion
suggestions.Add(accountNumber);
return string.Join("|", suggestions.Distinct());
}
private static string EscapeCommas(string value)
{
// The .toon format uses commas as delimiters, so we need to handle commas in values
return value.Replace(",", " ");
}
}

View file

@ -0,0 +1,130 @@
using Books.Api.Domain.UserAccess;
using Books.Api.EventFlow.Repositories;
namespace Books.Api.Authorization;
/// <summary>
/// Service for checking company access permissions.
/// </summary>
public interface ICompanyAccessService
{
/// <summary>
/// Check if the current user has access to the specified company with the required role.
/// Throws UnauthorizedAccessException if access is denied.
/// </summary>
Task RequireAccessAsync(
string companyId,
CompanyRole minimumRole,
CancellationToken cancellationToken = default);
/// <summary>
/// Check if the current user has access to the specified company.
/// Returns the access DTO if granted, null if denied.
/// </summary>
Task<UserCompanyAccessDto?> GetAccessAsync(
string companyId,
CancellationToken cancellationToken = default);
/// <summary>
/// Get all companies the current user has access to.
/// </summary>
Task<IReadOnlyList<UserCompanyAccessDto>> GetUserCompaniesAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Check if the current user has write access (Owner or Accountant) to the company.
/// </summary>
Task<bool> CanWriteAsync(
string companyId,
CancellationToken cancellationToken = default);
/// <summary>
/// Check if the current user can manage users for the company (Owner only).
/// </summary>
Task<bool> CanManageUsersAsync(
string companyId,
CancellationToken cancellationToken = default);
}
public class CompanyAccessService(
IHttpContextAccessor httpContextAccessor,
IUserCompanyAccessRepository accessRepository) : ICompanyAccessService
{
public async Task RequireAccessAsync(
string companyId,
CompanyRole minimumRole,
CancellationToken cancellationToken = default)
{
var userId = GetCurrentUserId();
if (userId == null)
{
throw new UnauthorizedAccessException("User is not authenticated");
}
var hasAccess = await accessRepository.HasAccessAsync(userId, companyId, minimumRole, cancellationToken);
if (!hasAccess)
{
var roleDescription = minimumRole switch
{
CompanyRole.Owner => "ejer",
CompanyRole.Accountant => "bogholder",
CompanyRole.Viewer => "læser",
_ => "ukendt"
};
throw new Domain.DomainException(
"ACCESS_DENIED",
$"You do not have {minimumRole} access to this company",
$"Du har ikke {roleDescription}-adgang til denne virksomhed");
}
}
public async Task<UserCompanyAccessDto?> GetAccessAsync(
string companyId,
CancellationToken cancellationToken = default)
{
var userId = GetCurrentUserId();
if (userId == null) return null;
return await accessRepository.GetAccessAsync(userId, companyId, cancellationToken);
}
public async Task<IReadOnlyList<UserCompanyAccessDto>> GetUserCompaniesAsync(
CancellationToken cancellationToken = default)
{
var userId = GetCurrentUserId();
if (userId == null) return [];
return await accessRepository.GetByUserIdAsync(userId, cancellationToken);
}
public async Task<bool> CanWriteAsync(
string companyId,
CancellationToken cancellationToken = default)
{
var userId = GetCurrentUserId();
if (userId == null) return false;
return await accessRepository.HasAccessAsync(userId, companyId, CompanyRole.Accountant, cancellationToken);
}
public async Task<bool> CanManageUsersAsync(
string companyId,
CancellationToken cancellationToken = default)
{
var userId = GetCurrentUserId();
if (userId == null) return false;
return await accessRepository.HasAccessAsync(userId, companyId, CompanyRole.Owner, cancellationToken);
}
private string? GetCurrentUserId()
{
var user = httpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true) return null;
// For API keys, use the API key ID as the user ID
// For OIDC users, use the NameIdentifier claim (Keycloak user ID)
return user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
}
}

View file

@ -0,0 +1,82 @@
using Books.Api.Domain.UserAccess;
namespace Books.Api.Authorization;
/// <summary>
/// Context for the currently selected company in a request.
/// Extracted from X-Company-Id header and validated against user access.
/// </summary>
public class CompanyContext
{
/// <summary>
/// The currently selected company ID.
/// </summary>
public string? CompanyId { get; init; }
/// <summary>
/// The user's role for the selected company.
/// </summary>
public CompanyRole? Role { get; init; }
/// <summary>
/// Whether a valid company is selected and the user has access.
/// </summary>
public bool HasCompanySelected => CompanyId != null && Role != null;
/// <summary>
/// Check if the user has at least the required role for the selected company.
/// </summary>
public bool HasRole(CompanyRole minimumRole)
{
if (Role == null) return false;
return minimumRole switch
{
CompanyRole.Viewer => true,
CompanyRole.Accountant => Role is CompanyRole.Owner or CompanyRole.Accountant,
CompanyRole.Owner => Role is CompanyRole.Owner,
_ => false
};
}
/// <summary>
/// Require the user to have at least the specified role.
/// Throws DomainException if not.
/// </summary>
public void RequireRole(CompanyRole minimumRole)
{
if (!HasCompanySelected)
{
throw new Domain.DomainException(
"NO_COMPANY_SELECTED",
"No company selected. Set X-Company-Id header.",
"Ingen virksomhed valgt. Sæt X-Company-Id header.");
}
if (!HasRole(minimumRole))
{
var roleDescription = minimumRole switch
{
CompanyRole.Owner => "ejer",
CompanyRole.Accountant => "bogholder",
CompanyRole.Viewer => "læser",
_ => "ukendt"
};
throw new Domain.DomainException(
"INSUFFICIENT_PERMISSIONS",
$"This operation requires {minimumRole} role",
$"Denne handling kræver {roleDescription}-rolle");
}
}
/// <summary>
/// Require that the user can write (Owner or Accountant).
/// </summary>
public void RequireWrite() => RequireRole(CompanyRole.Accountant);
/// <summary>
/// Require that the user is Owner.
/// </summary>
public void RequireOwner() => RequireRole(CompanyRole.Owner);
}

View file

@ -0,0 +1,72 @@
using Books.Api.EventFlow.Repositories;
namespace Books.Api.Authorization;
/// <summary>
/// Middleware that extracts the X-Company-Id header and validates user access.
/// Stores the result in HttpContext.Items for use by GraphQL resolvers.
/// </summary>
public class CompanyContextMiddleware(RequestDelegate next)
{
public const string HeaderName = "X-Company-Id";
public const string ContextKey = "CompanyContext";
public async Task InvokeAsync(HttpContext context, IUserCompanyAccessRepository accessRepository)
{
var companyContext = new CompanyContext();
// Only process if user is authenticated
if (context.User.Identity?.IsAuthenticated == true)
{
var userId = context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (userId != null && context.Request.Headers.TryGetValue(HeaderName, out var companyIdHeader))
{
var companyId = companyIdHeader.ToString();
if (!string.IsNullOrWhiteSpace(companyId))
{
// Validate user has access to this company
var access = await accessRepository.GetAccessAsync(userId, companyId);
if (access != null)
{
companyContext = new CompanyContext
{
CompanyId = companyId,
Role = access.Role
};
}
}
}
}
// Store in HttpContext.Items for resolvers to access
context.Items[ContextKey] = companyContext;
await next(context);
}
}
/// <summary>
/// Extension methods for accessing CompanyContext.
/// </summary>
public static class CompanyContextExtensions
{
/// <summary>
/// Get the CompanyContext from HttpContext.
/// </summary>
public static CompanyContext GetCompanyContext(this HttpContext context)
{
return context.Items[CompanyContextMiddleware.ContextKey] as CompanyContext
?? new CompanyContext();
}
/// <summary>
/// Add the CompanyContext middleware to the pipeline.
/// </summary>
public static IApplicationBuilder UseCompanyContext(this IApplicationBuilder app)
{
return app.UseMiddleware<CompanyContextMiddleware>();
}
}

View file

@ -0,0 +1,221 @@
using Books.Api.EventFlow.ReadModels;
using Books.Api.EventFlow.Repositories;
using Hangfire;
namespace Books.Api.Banking;
/// <summary>
/// Hangfire job for syncing bank transactions from Enable Banking.
/// Runs every 30 minutes to fetch new transactions for all active bank connections.
/// </summary>
public class BankTransactionSyncJob(
IBankConnectionRepository connectionRepository,
IBankTransactionRepository transactionRepository,
IEnableBankingClient bankingClient,
ILogger<BankTransactionSyncJob> logger)
{
/// <summary>
/// Sync all active bank connections for all companies.
/// Called by Hangfire recurring job.
/// </summary>
[DisableConcurrentExecution(timeoutInSeconds: 300)]
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [60, 120, 300])]
public async Task SyncAllActiveConnectionsAsync(CancellationToken cancellationToken = default)
{
logger.LogInformation("Starting bank transaction sync for all active connections");
var result = new BankTransactionSyncResult();
try
{
// Get all companies with active connections
// Note: This is a simplified approach - in production you might want to
// iterate through companies more efficiently
var connections = await GetAllActiveConnectionsAsync(cancellationToken);
result.TotalConnections = connections.Count;
foreach (var connection in connections)
{
try
{
var connectionResult = await SyncConnectionAsync(connection, cancellationToken);
result.TotalAccounts += connectionResult.TotalAccounts;
result.NewTransactions += connectionResult.NewTransactions;
result.SkippedDuplicates += connectionResult.SkippedDuplicates;
}
catch (Exception ex)
{
logger.LogError(ex, "Error syncing connection {ConnectionId}", connection.Id);
result.Errors++;
result.ErrorMessages.Add($"Connection {connection.Id}: {ex.Message}");
}
}
logger.LogInformation(
"Bank transaction sync completed: {Connections} connections, {Accounts} accounts, " +
"{New} new transactions, {Skipped} duplicates, {Errors} errors",
result.TotalConnections, result.TotalAccounts, result.NewTransactions,
result.SkippedDuplicates, result.Errors);
}
catch (Exception ex)
{
logger.LogError(ex, "Fatal error during bank transaction sync");
throw;
}
}
/// <summary>
/// Sync transactions for a specific company (manual trigger from UI).
/// </summary>
public async Task<BankTransactionSyncResult> SyncForCompanyAsync(
string companyId,
CancellationToken cancellationToken = default)
{
logger.LogInformation("Starting manual bank transaction sync for company {CompanyId}", companyId);
var result = new BankTransactionSyncResult();
var connections = await connectionRepository.GetActiveByCompanyIdAsync(companyId, cancellationToken);
result.TotalConnections = connections.Count;
foreach (var connection in connections)
{
try
{
var connectionResult = await SyncConnectionAsync(connection, cancellationToken);
result.TotalAccounts += connectionResult.TotalAccounts;
result.NewTransactions += connectionResult.NewTransactions;
result.SkippedDuplicates += connectionResult.SkippedDuplicates;
}
catch (Exception ex)
{
logger.LogError(ex, "Error syncing connection {ConnectionId}", connection.Id);
result.Errors++;
result.ErrorMessages.Add($"Connection {connection.Id}: {ex.Message}");
}
}
logger.LogInformation(
"Manual sync for company {CompanyId} completed: {New} new transactions",
companyId, result.NewTransactions);
return result;
}
private async Task<BankTransactionSyncResult> SyncConnectionAsync(
BankConnectionReadModelDto connection,
CancellationToken cancellationToken)
{
var result = new BankTransactionSyncResult();
if (string.IsNullOrEmpty(connection.SessionId))
{
logger.LogWarning("Connection {ConnectionId} has no session ID", connection.Id);
return result;
}
if (connection.Accounts == null || connection.Accounts.Count == 0)
{
logger.LogWarning("Connection {ConnectionId} has no accounts", connection.Id);
return result;
}
result.TotalAccounts = connection.Accounts.Count;
var dateTo = DateOnly.FromDateTime(DateTime.UtcNow);
foreach (var account in connection.Accounts)
{
try
{
// Check if we have any transactions for this account
var hasTransactions = await transactionRepository.HasAnyAsync(account.AccountId, cancellationToken);
// If new account, fetch 1 year back. If existing, just refresh last 7 days.
var dateFrom = hasTransactions ? dateTo.AddDays(-7) : dateTo.AddDays(-365);
logger.LogInformation(
"Fetching transactions for account {AccountId} from {DateFrom} to {DateTo} (hasExisting: {HasExisting})",
account.AccountId, dateFrom, dateTo, hasTransactions);
var response = await bankingClient.GetTransactionsAsync(
connection.SessionId,
account.AccountId,
dateFrom,
dateTo,
cancellationToken);
logger.LogInformation(
"Enable Banking returned {Count} transactions for account {AccountId}",
response.Transactions.Count, account.AccountId);
if (response.Transactions.Count == 0)
{
continue;
}
var transactionsToUpsert = new List<BankTransactionDto>();
foreach (var tx in response.Transactions)
{
var dto = MapToDto(tx, connection, account.AccountId);
transactionsToUpsert.Add(dto);
}
if (transactionsToUpsert.Count > 0)
{
await transactionRepository.InsertBatchAsync(transactionsToUpsert, cancellationToken);
result.NewTransactions += transactionsToUpsert.Count; // This counts all upserts (inserts + updates)
logger.LogInformation(
"Synced {Count} transactions for account {AccountId} (from {From} to {To})",
transactionsToUpsert.Count, account.AccountId, dateFrom, dateTo);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error fetching transactions for account {AccountId}", account.AccountId);
result.Errors++;
result.ErrorMessages.Add($"Account {account.AccountId}: {ex.Message}");
}
}
return result;
}
private async Task<IReadOnlyList<BankConnectionReadModelDto>> GetAllActiveConnectionsAsync(
CancellationToken cancellationToken)
{
return await connectionRepository.GetAllActiveAsync(cancellationToken);
}
private static BankTransactionDto MapToDto(
Transaction tx,
BankConnectionReadModelDto connection,
string bankAccountId)
{
var now = DateTime.UtcNow;
return new BankTransactionDto
{
Id = $"banktx-{Guid.NewGuid()}",
CompanyId = connection.CompanyId,
BankConnectionId = connection.Id,
BankAccountId = bankAccountId,
ExternalId = tx.TransactionId,
Amount = tx.Amount,
Currency = tx.Currency,
TransactionDate = tx.BookingDate.ToDateTime(TimeOnly.MinValue),
BookingDate = tx.BookingDate.ToDateTime(TimeOnly.MinValue),
ValueDate = tx.ValueDate?.ToDateTime(TimeOnly.MinValue),
Description = tx.RemittanceInformation,
CounterpartyName = tx.CreditorName ?? tx.DebtorName,
CreditorName = tx.CreditorName,
DebtorName = tx.DebtorName,
Reference = tx.EndToEndId,
Status = "pending",
CreatedAt = now,
UpdatedAt = now
};
}
}

View file

@ -0,0 +1,399 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.IdentityModel.Tokens;
namespace Books.Api.Banking;
public class EnableBankingClient : IEnableBankingClient, IDisposable
{
private readonly HttpClient _httpClient;
private readonly EnableBankingOptions _options;
private readonly ILogger<EnableBankingClient> _logger;
private readonly JsonSerializerOptions _jsonOptions;
private readonly RSA _rsaKey;
private readonly SigningCredentials _signingCredentials;
public EnableBankingClient(
HttpClient httpClient,
EnableBankingOptions options,
ILogger<EnableBankingClient> logger)
{
_httpClient = httpClient;
_options = options;
_logger = logger;
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
// Parse RSA key once at startup and cache it
_rsaKey = RSA.Create();
if (!string.IsNullOrEmpty(options.PrivateKey))
{
_rsaKey.ImportFromPem(options.PrivateKey);
_signingCredentials = new SigningCredentials(
new RsaSecurityKey(_rsaKey) { KeyId = options.KeyId },
SecurityAlgorithms.RsaSha256)
{
CryptoProviderFactory = new CryptoProviderFactory { CacheSignatureProviders = false }
};
}
else
{
_signingCredentials = null!;
}
}
public void Dispose()
{
_rsaKey?.Dispose();
}
public async Task<IReadOnlyList<Aspsp>> GetAspspsAsync(string country = "DK", CancellationToken ct = default)
{
var request = new HttpRequestMessage(HttpMethod.Get, $"/aspsps?country={country}");
AddAuthHeader(request);
var response = await _httpClient.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<AspspsApiResponse>(_jsonOptions, ct);
return result?.Aspsps?.Select(MapAspsp).ToList() ?? [];
}
public async Task<AuthorizationResponse> StartAuthorizationAsync(
string aspspName,
string redirectUrl,
string state,
string psuType = "personal",
string? psuIpAddress = null,
string? psuUserAgent = null,
CancellationToken ct = default)
{
var request = new HttpRequestMessage(HttpMethod.Post, "/auth");
AddAuthHeader(request);
AddPsuHeaders(request, psuIpAddress, psuUserAgent);
var body = new
{
access = new
{
valid_until = DateTimeOffset.UtcNow.AddDays(90).ToString("o")
},
aspsp = new { name = aspspName, country = "DK" },
state,
redirect_url = redirectUrl,
psu_type = psuType
};
request.Content = JsonContent.Create(body, options: _jsonOptions);
_logger.LogDebug(
"Starting authorization for {Bank} with PSU IP: {PsuIp}, PSU UA: {PsuUa}",
aspspName, psuIpAddress ?? "(not provided)", psuUserAgent ?? "(not provided)");
var response = await _httpClient.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(ct);
throw new HttpRequestException($"Enable Banking auth error {response.StatusCode}: {errorContent}");
}
var result = await response.Content.ReadFromJsonAsync<AuthApiResponse>(_jsonOptions, ct);
return new AuthorizationResponse(result!.AuthorizationId, result.Url);
}
public async Task<SessionResponse> CreateSessionAsync(
string authorizationCode,
string? psuIpAddress = null,
string? psuUserAgent = null,
CancellationToken ct = default)
{
var request = new HttpRequestMessage(HttpMethod.Post, "/sessions");
AddAuthHeader(request);
AddPsuHeaders(request, psuIpAddress, psuUserAgent);
_logger.LogDebug(
"Creating session with PSU IP: {PsuIp}, PSU UA: {PsuUa}",
psuIpAddress ?? "(not provided)", psuUserAgent ?? "(not provided)");
request.Content = JsonContent.Create(new { code = authorizationCode }, options: _jsonOptions);
var response = await _httpClient.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
// Log raw response for debugging
var rawJson = await response.Content.ReadAsStringAsync(ct);
_logger.LogInformation("Raw session response: {RawJson}", rawJson);
var result = System.Text.Json.JsonSerializer.Deserialize<SessionApiResponse>(rawJson, _jsonOptions);
var sessionId = result!.SessionId;
var aspspName = result.Aspsp?.Name ?? "";
_logger.LogDebug(
"Session created: {SessionId}, Bank: {Bank}, Accounts in response: {AccountCount}",
sessionId, aspspName, result.Accounts?.Count ?? 0);
// Map accounts from session response
// Use Uid as AccountId - this is required for fetching transactions/balances
// Filter out accounts without uid as they cannot be used for API calls
var accounts = result.Accounts?
.Where(a => !string.IsNullOrEmpty(a.Uid))
.Select(a => new SessionAccount(
a.Uid!,
a.AccountId?.Iban ?? "",
a.Currency ?? "DKK",
a.Name)).ToList() ?? [];
_logger.LogDebug(
"Mapped {MappedCount} accounts with valid uid from {TotalCount} accounts in response",
accounts.Count, result.Accounts?.Count ?? 0);
// Note: There is NO separate /accounts endpoint in Enable Banking API
// The session response is the ONLY source for account information
// If accounts array is empty, it may be because:
// 1. Wrong psuType (personal vs business)
// 2. Bank doesn't provide account list for this connection type
if (accounts.Count == 0)
{
_logger.LogWarning(
"No accounts with valid uid in session response for {Bank}. " +
"This may indicate wrong psuType or bank limitation. Session: {SessionId}",
aspspName, sessionId);
}
// valid_until is nested inside the access object in the API response
var validUntil = result.Access?.ValidUntil ?? DateTimeOffset.UtcNow.AddDays(90);
return new SessionResponse(sessionId, aspspName, accounts, validUntil);
}
public async Task<AccountDetails> GetAccountDetailsAsync(string sessionId, string accountId, CancellationToken ct = default)
{
var request = new HttpRequestMessage(HttpMethod.Get, $"/accounts/{accountId}/details");
AddAuthHeader(request, sessionId);
var response = await _httpClient.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<AccountDetailsApiResponse>(_jsonOptions, ct);
return new AccountDetails(
accountId,
result?.Iban ?? "",
result?.Currency ?? "DKK",
result?.Name,
result?.OwnerName,
result?.Product);
}
public async Task<IReadOnlyList<Balance>> GetBalancesAsync(string sessionId, string accountId, CancellationToken ct = default)
{
var request = new HttpRequestMessage(HttpMethod.Get, $"/accounts/{accountId}/balances");
AddAuthHeader(request, sessionId);
var response = await _httpClient.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<BalancesApiResponse>(_jsonOptions, ct);
return result?.Balances?.Select(b => new Balance(
b.BalanceType ?? "",
b.BalanceAmount?.Amount ?? 0,
b.BalanceAmount?.Currency ?? "DKK",
b.ReferenceDate)).ToList() ?? [];
}
public async Task<TransactionsResponse> GetTransactionsAsync(
string sessionId,
string accountId,
DateOnly? dateFrom = null,
DateOnly? dateTo = null,
CancellationToken ct = default)
{
var from = dateFrom ?? DateOnly.FromDateTime(DateTime.Today.AddMonths(-3));
var to = dateTo ?? DateOnly.FromDateTime(DateTime.Today);
var url = $"/accounts/{accountId}/transactions?date_from={from:yyyy-MM-dd}&date_to={to:yyyy-MM-dd}";
var request = new HttpRequestMessage(HttpMethod.Get, url);
AddAuthHeader(request, sessionId);
var response = await _httpClient.SendAsync(request, ct);
var rawJson = await response.Content.ReadAsStringAsync(ct);
_logger.LogInformation("Transactions API response ({Status}): {RawJson}", response.StatusCode, rawJson);
response.EnsureSuccessStatusCode();
var result = JsonSerializer.Deserialize<TransactionsApiResponse>(rawJson, _jsonOptions);
return new TransactionsResponse(
result?.Transactions?.Select(t => new Transaction(
t.TransactionId ?? t.EntryReference ?? GenerateDeterministicId(t),
t.IsDebit ? -(t.TransactionAmount?.Amount ?? 0) : (t.TransactionAmount?.Amount ?? 0),
t.TransactionAmount?.Currency ?? "DKK",
t.BookingDate ?? DateOnly.FromDateTime(DateTime.Today),
t.ValueDate,
t.CreditorName,
t.DebtorName,
t.RemittanceInformationUnstructured,
t.EndToEndId,
t.IsDebit)).ToList() ?? [],
result?.ContinuationKey);
}
private static string GenerateDeterministicId(TransactionApiModel t)
{
// Create a deterministic ID based on transaction content
// Used when bank doesn't provide a unique ID
var content = string.Join("|",
t.TransactionAmount?.Amount.ToString(System.Globalization.CultureInfo.InvariantCulture),
t.TransactionAmount?.Currency,
t.BookingDate?.ToString("yyyy-MM-dd"),
t.RemittanceInformationUnstructured?.Trim(),
t.CreditorName?.Trim(),
t.DebtorName?.Trim());
using var sha256 = System.Security.Cryptography.SHA256.Create();
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
var hash = sha256.ComputeHash(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private void AddAuthHeader(HttpRequestMessage request, string? sessionId = null)
{
var jwt = GenerateJwt(sessionId);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", jwt);
}
private void AddPsuHeaders(HttpRequestMessage request, string? psuIpAddress, string? psuUserAgent)
{
if (!string.IsNullOrEmpty(psuIpAddress))
request.Headers.Add("psu-ip-address", psuIpAddress);
if (!string.IsNullOrEmpty(psuUserAgent))
request.Headers.Add("psu-user-agent", psuUserAgent);
}
private string GenerateJwt(string? sessionId = null)
{
var now = DateTime.UtcNow;
// Build claims list - only add access claim if session is provided
List<Claim>? claims = null;
if (!string.IsNullOrEmpty(sessionId))
{
claims = [new Claim("access", JsonSerializer.Serialize(new { session_id = sessionId }))];
}
// Create header with kid explicitly set
var header = new JwtHeader(_signingCredentials)
{
["kid"] = _options.ApplicationId
};
// Create payload matching Enable Banking's expected format
var payload = new JwtPayload(
issuer: "enablebanking.com",
audience: "api.enablebanking.com",
claims: claims,
notBefore: null,
expires: now.AddHours(1),
issuedAt: now);
var token = new JwtSecurityToken(header, payload);
return new JwtSecurityTokenHandler().WriteToken(token);
}
private static Aspsp MapAspsp(AspspApiModel a) => new(
a.Name ?? "",
a.Country ?? "",
a.Logo ?? "",
a.PsuTypes ?? [],
a.PsuTypes?.Contains("business") ?? false,
a.PsuTypes?.Contains("personal") ?? false);
// API Response models
private record AspspsApiResponse(List<AspspApiModel>? Aspsps);
private record AspspApiModel(string? Name, string? Country, string? Logo, List<string>? PsuTypes);
private record AuthApiResponse(string AuthorizationId, string Url);
private record AccessApiModel(DateTimeOffset? ValidUntil);
private record SessionApiResponse(
string SessionId,
AspspApiModel? Aspsp,
List<AccountApiModel>? Accounts,
AccessApiModel? Access);
private record AccountApiModel(
AccountIdApiModel? AccountId,
string? Uid,
string? Currency,
string? Name);
private record AccountIdApiModel(string? Iban);
private record AccountDetailsApiResponse(
string? Iban,
string? Currency,
string? Name,
string? OwnerName,
string? Product);
private record BalancesApiResponse(List<BalanceApiModel>? Balances);
private record BalanceApiModel(
string? BalanceType,
AmountApiModel? BalanceAmount,
DateTimeOffset? ReferenceDate);
private record AmountApiModel(
[property: JsonConverter(typeof(StringToDecimalConverter))]
decimal Amount,
string? Currency);
private class StringToDecimalConverter : JsonConverter<decimal>
{
public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.String)
{
var str = reader.GetString();
return decimal.Parse(str!, System.Globalization.CultureInfo.InvariantCulture);
}
return reader.GetDecimal();
}
public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options)
{
writer.WriteNumberValue(value);
}
}
private record TransactionsApiResponse(
List<TransactionApiModel>? Transactions,
string? ContinuationKey);
private record TransactionApiModel(
string? TransactionId,
string? EntryReference,
AmountApiModel? TransactionAmount,
DateOnly? BookingDate,
DateOnly? ValueDate,
PartyApiModel? Creditor,
PartyApiModel? Debtor,
List<string>? RemittanceInformation,
string? EndToEndId,
string? CreditDebitIndicator)
{
public string? CreditorName => Creditor?.Name;
public string? DebtorName => Debtor?.Name;
public string? RemittanceInformationUnstructured =>
RemittanceInformation != null && RemittanceInformation.Count > 0
? string.Join(" ", RemittanceInformation)
: null;
public bool IsDebit => CreditDebitIndicator == "DBIT";
}
private record PartyApiModel(string? Name);
}
public class EnableBankingOptions
{
public string ApplicationId { get; set; } = "";
public string KeyId { get; set; } = "";
public string PrivateKey { get; set; } = "";
}

View file

@ -0,0 +1,110 @@
namespace Books.Api.Banking;
/// <summary>
/// Client for Enable Banking Open Banking API
/// https://enablebanking.com/docs/api/reference/
/// </summary>
public interface IEnableBankingClient
{
/// <summary>
/// Get list of available ASPSPs (banks) for a country
/// </summary>
Task<IReadOnlyList<Aspsp>> GetAspspsAsync(string country = "DK", CancellationToken ct = default);
/// <summary>
/// Start authorization flow for connecting a bank account
/// </summary>
Task<AuthorizationResponse> StartAuthorizationAsync(
string aspspName,
string redirectUrl,
string state,
string psuType = "personal",
string? psuIpAddress = null,
string? psuUserAgent = null,
CancellationToken ct = default);
/// <summary>
/// Complete authorization and create a session
/// </summary>
Task<SessionResponse> CreateSessionAsync(
string authorizationCode,
string? psuIpAddress = null,
string? psuUserAgent = null,
CancellationToken ct = default);
/// <summary>
/// Get account details
/// </summary>
Task<AccountDetails> GetAccountDetailsAsync(string sessionId, string accountId, CancellationToken ct = default);
/// <summary>
/// Get account balances
/// </summary>
Task<IReadOnlyList<Balance>> GetBalancesAsync(string sessionId, string accountId, CancellationToken ct = default);
/// <summary>
/// Get account transactions
/// </summary>
Task<TransactionsResponse> GetTransactionsAsync(
string sessionId,
string accountId,
DateOnly? dateFrom = null,
DateOnly? dateTo = null,
CancellationToken ct = default);
}
// DTOs for Enable Banking API responses
public record Aspsp(
string Name,
string Country,
string Logo,
IReadOnlyList<string> PsuTypes,
bool BusinessAccounts,
bool PersonalAccounts);
public record AuthorizationResponse(
string AuthorizationId,
string Url);
public record SessionResponse(
string SessionId,
string AspspName,
IReadOnlyList<SessionAccount> Accounts,
DateTimeOffset ValidUntil);
public record SessionAccount(
string AccountId,
string Iban,
string Currency,
string? AccountName);
public record AccountDetails(
string AccountId,
string Iban,
string Currency,
string? Name,
string? OwnerName,
string? Product);
public record Balance(
string BalanceType,
decimal Amount,
string Currency,
DateTimeOffset? ReferenceDate);
public record TransactionsResponse(
IReadOnlyList<Transaction> Transactions,
string? ContinuationKey);
public record Transaction(
string TransactionId,
decimal Amount,
string Currency,
DateOnly BookingDate,
DateOnly? ValueDate,
string? CreditorName,
string? DebtorName,
string? RemittanceInformation,
string? EndToEndId,
bool IsDebit);

View file

@ -0,0 +1,67 @@
using Books.Api.Domain.Accounts;
using EventFlow.Commands;
namespace Books.Api.Commands.Accounts;
public class CreateAccountCommandHandler : CommandHandler<AccountAggregate, AccountId, CreateAccountCommand>
{
public override Task ExecuteAsync(
AccountAggregate aggregate,
CreateAccountCommand command,
CancellationToken cancellationToken)
{
aggregate.Create(
command.CompanyId,
command.AccountNumber,
command.Name,
command.AccountType,
command.ParentId,
command.Description,
command.VatCodeId,
command.IsSystemAccount,
command.StandardAccountNumber);
return Task.CompletedTask;
}
}
public class UpdateAccountCommandHandler : CommandHandler<AccountAggregate, AccountId, UpdateAccountCommand>
{
public override Task ExecuteAsync(
AccountAggregate aggregate,
UpdateAccountCommand command,
CancellationToken cancellationToken)
{
aggregate.Update(
command.Name,
command.ParentId,
command.Description,
command.VatCodeId);
return Task.CompletedTask;
}
}
public class DeactivateAccountCommandHandler : CommandHandler<AccountAggregate, AccountId, DeactivateAccountCommand>
{
public override Task ExecuteAsync(
AccountAggregate aggregate,
DeactivateAccountCommand command,
CancellationToken cancellationToken)
{
aggregate.Deactivate();
return Task.CompletedTask;
}
}
public class ReactivateAccountCommandHandler : CommandHandler<AccountAggregate, AccountId, ReactivateAccountCommand>
{
public override Task ExecuteAsync(
AccountAggregate aggregate,
ReactivateAccountCommand command,
CancellationToken cancellationToken)
{
aggregate.Reactivate();
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,51 @@
using Books.Api.Domain.Accounts;
using EventFlow.Commands;
namespace Books.Api.Commands.Accounts;
public class CreateAccountCommand(
AccountId aggregateId,
string companyId,
string accountNumber,
string name,
AccountType accountType,
string? parentId,
string? description,
string? vatCodeId,
bool isSystemAccount = false,
string? standardAccountNumber = null)
: Command<AccountAggregate, AccountId>(aggregateId)
{
public string CompanyId { get; } = companyId;
public string AccountNumber { get; } = accountNumber;
public string Name { get; } = name;
public AccountType AccountType { get; } = accountType;
public string? ParentId { get; } = parentId;
public string? Description { get; } = description;
public string? VatCodeId { get; } = vatCodeId;
public bool IsSystemAccount { get; } = isSystemAccount;
/// <summary>
/// Erhvervsstyrelsens standardkontonummer for SAF-T rapportering.
/// </summary>
public string? StandardAccountNumber { get; } = standardAccountNumber;
}
public class UpdateAccountCommand(
AccountId aggregateId,
string name,
string? parentId,
string? description,
string? vatCodeId)
: Command<AccountAggregate, AccountId>(aggregateId)
{
public string Name { get; } = name;
public string? ParentId { get; } = parentId;
public string? Description { get; } = description;
public string? VatCodeId { get; } = vatCodeId;
}
public class DeactivateAccountCommand(AccountId aggregateId)
: Command<AccountAggregate, AccountId>(aggregateId);
public class ReactivateAccountCommand(AccountId aggregateId)
: Command<AccountAggregate, AccountId>(aggregateId);

View file

@ -0,0 +1,55 @@
using Books.Api.Domain.Attachments;
using EventFlow.Commands;
namespace Books.Api.Commands.Attachments;
public class UploadAttachmentCommandHandler
: CommandHandler<AttachmentAggregate, AttachmentId, UploadAttachmentCommand>
{
public override Task ExecuteAsync(
AttachmentAggregate aggregate,
UploadAttachmentCommand command,
CancellationToken cancellationToken)
{
aggregate.Upload(
command.CompanyId,
command.FileName,
command.OriginalFileName,
command.ContentType,
command.FileSize,
command.StoragePath,
command.UploadedBy,
command.DraftId,
command.TransactionId);
return Task.CompletedTask;
}
}
public class LinkAttachmentToTransactionCommandHandler
: CommandHandler<AttachmentAggregate, AttachmentId, LinkAttachmentToTransactionCommand>
{
public override Task ExecuteAsync(
AttachmentAggregate aggregate,
LinkAttachmentToTransactionCommand command,
CancellationToken cancellationToken)
{
aggregate.LinkToTransaction(command.TransactionId);
return Task.CompletedTask;
}
}
public class DeleteAttachmentCommandHandler
: CommandHandler<AttachmentAggregate, AttachmentId, DeleteAttachmentCommand>
{
public override Task ExecuteAsync(
AttachmentAggregate aggregate,
DeleteAttachmentCommand command,
CancellationToken cancellationToken)
{
aggregate.Delete(command.DeletedBy, command.Reason);
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,58 @@
using Books.Api.Domain.Attachments;
using EventFlow.Commands;
namespace Books.Api.Commands.Attachments;
/// <summary>
/// Command to upload an attachment (bilag).
/// Required by Bogføringsloven § 6 for document retention.
/// </summary>
public class UploadAttachmentCommand(
AttachmentId aggregateId,
string companyId,
string fileName,
string originalFileName,
string contentType,
long fileSize,
string storagePath,
string uploadedBy,
string? draftId = null,
string? transactionId = null)
: Command<AttachmentAggregate, AttachmentId>(aggregateId)
{
public string CompanyId { get; } = companyId;
public string FileName { get; } = fileName;
public string OriginalFileName { get; } = originalFileName;
public string ContentType { get; } = contentType;
public long FileSize { get; } = fileSize;
public string StoragePath { get; } = storagePath;
public string UploadedBy { get; } = uploadedBy;
public string? DraftId { get; } = draftId;
public string? TransactionId { get; } = transactionId;
}
/// <summary>
/// Command to link an attachment to a posted transaction.
/// </summary>
public class LinkAttachmentToTransactionCommand(
AttachmentId aggregateId,
string transactionId)
: Command<AttachmentAggregate, AttachmentId>(aggregateId)
{
public string TransactionId { get; } = transactionId;
}
/// <summary>
/// Command to delete an attachment.
/// Note: Per Bogføringsloven § 6, this should only be used after
/// the 5-year retention period.
/// </summary>
public class DeleteAttachmentCommand(
AttachmentId aggregateId,
string deletedBy,
string reason)
: Command<AttachmentAggregate, AttachmentId>(aggregateId)
{
public string DeletedBy { get; } = deletedBy;
public string Reason { get; } = reason;
}

View file

@ -0,0 +1,124 @@
using Books.Api.Domain.BankConnections;
using EventFlow.Commands;
namespace Books.Api.Commands.BankConnections;
public class InitiateBankConnectionCommandHandler
: CommandHandler<BankConnectionAggregate, BankConnectionId, InitiateBankConnectionCommand>
{
public override Task ExecuteAsync(
BankConnectionAggregate aggregate,
InitiateBankConnectionCommand command,
CancellationToken cancellationToken)
{
aggregate.Initiate(
command.CompanyId,
command.AspspName,
command.AuthorizationId,
command.RedirectUrl,
command.State);
return Task.CompletedTask;
}
}
public class EstablishBankConnectionCommandHandler
: CommandHandler<BankConnectionAggregate, BankConnectionId, EstablishBankConnectionCommand>
{
public override Task ExecuteAsync(
BankConnectionAggregate aggregate,
EstablishBankConnectionCommand command,
CancellationToken cancellationToken)
{
aggregate.Establish(
command.SessionId,
command.ValidUntil,
command.Accounts);
return Task.CompletedTask;
}
}
public class FailBankConnectionCommandHandler
: CommandHandler<BankConnectionAggregate, BankConnectionId, FailBankConnectionCommand>
{
public override Task ExecuteAsync(
BankConnectionAggregate aggregate,
FailBankConnectionCommand command,
CancellationToken cancellationToken)
{
aggregate.Fail(command.Reason);
return Task.CompletedTask;
}
}
public class DisconnectBankConnectionCommandHandler
: CommandHandler<BankConnectionAggregate, BankConnectionId, DisconnectBankConnectionCommand>
{
public override Task ExecuteAsync(
BankConnectionAggregate aggregate,
DisconnectBankConnectionCommand command,
CancellationToken cancellationToken)
{
aggregate.Disconnect(command.Reason);
return Task.CompletedTask;
}
}
public class RefreshBankConnectionCommandHandler
: CommandHandler<BankConnectionAggregate, BankConnectionId, RefreshBankConnectionCommand>
{
public override Task ExecuteAsync(
BankConnectionAggregate aggregate,
RefreshBankConnectionCommand command,
CancellationToken cancellationToken)
{
aggregate.Refresh(command.NewSessionId, command.ValidUntil);
return Task.CompletedTask;
}
}
public class LinkBankAccountCommandHandler
: CommandHandler<BankConnectionAggregate, BankConnectionId, LinkBankAccountCommand>
{
public override Task ExecuteAsync(
BankConnectionAggregate aggregate,
LinkBankAccountCommand command,
CancellationToken cancellationToken)
{
aggregate.LinkBankAccount(command.BankAccountId, command.LinkedAccountId, command.ImportFromDate);
return Task.CompletedTask;
}
}
public class ReInitiateBankConnectionCommandHandler
: CommandHandler<BankConnectionAggregate, BankConnectionId, ReInitiateBankConnectionCommand>
{
public override Task ExecuteAsync(
BankConnectionAggregate aggregate,
ReInitiateBankConnectionCommand command,
CancellationToken cancellationToken)
{
aggregate.ReInitiate(command.AuthorizationId, command.RedirectUrl, command.State);
return Task.CompletedTask;
}
}
public class ArchiveBankConnectionCommandHandler
: CommandHandler<BankConnectionAggregate, BankConnectionId, ArchiveBankConnectionCommand>
{
public override Task ExecuteAsync(
BankConnectionAggregate aggregate,
ArchiveBankConnectionCommand command,
CancellationToken cancellationToken)
{
aggregate.Archive(command.Reason);
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,91 @@
using Books.Api.Domain.BankConnections;
using Books.Api.Domain.BankConnections.Events;
using EventFlow.Commands;
namespace Books.Api.Commands.BankConnections;
public class InitiateBankConnectionCommand(
BankConnectionId aggregateId,
string companyId,
string aspspName,
string authorizationId,
string redirectUrl,
string state)
: Command<BankConnectionAggregate, BankConnectionId>(aggregateId)
{
public string CompanyId { get; } = companyId;
public string AspspName { get; } = aspspName;
public string AuthorizationId { get; } = authorizationId;
public string RedirectUrl { get; } = redirectUrl;
public string State { get; } = state;
}
public class EstablishBankConnectionCommand(
BankConnectionId aggregateId,
string sessionId,
DateTimeOffset validUntil,
IReadOnlyList<BankAccountInfo> accounts)
: Command<BankConnectionAggregate, BankConnectionId>(aggregateId)
{
public string SessionId { get; } = sessionId;
public DateTimeOffset ValidUntil { get; } = validUntil;
public IReadOnlyList<BankAccountInfo> Accounts { get; } = accounts;
}
public class FailBankConnectionCommand(
BankConnectionId aggregateId,
string reason)
: Command<BankConnectionAggregate, BankConnectionId>(aggregateId)
{
public string Reason { get; } = reason;
}
public class DisconnectBankConnectionCommand(
BankConnectionId aggregateId,
string reason = "User requested disconnection")
: Command<BankConnectionAggregate, BankConnectionId>(aggregateId)
{
public string Reason { get; } = reason;
}
public class RefreshBankConnectionCommand(
BankConnectionId aggregateId,
string newSessionId,
DateTimeOffset validUntil)
: Command<BankConnectionAggregate, BankConnectionId>(aggregateId)
{
public string NewSessionId { get; } = newSessionId;
public DateTimeOffset ValidUntil { get; } = validUntil;
}
public class LinkBankAccountCommand(
BankConnectionId aggregateId,
string bankAccountId,
string linkedAccountId,
DateOnly? importFromDate = null)
: Command<BankConnectionAggregate, BankConnectionId>(aggregateId)
{
public string BankAccountId { get; } = bankAccountId;
public string LinkedAccountId { get; } = linkedAccountId;
public DateOnly? ImportFromDate { get; } = importFromDate;
}
public class ReInitiateBankConnectionCommand(
BankConnectionId aggregateId,
string authorizationId,
string redirectUrl,
string state)
: Command<BankConnectionAggregate, BankConnectionId>(aggregateId)
{
public string AuthorizationId { get; } = authorizationId;
public string RedirectUrl { get; } = redirectUrl;
public string State { get; } = state;
}
public class ArchiveBankConnectionCommand(
BankConnectionId aggregateId,
string reason = "User requested archival")
: Command<BankConnectionAggregate, BankConnectionId>(aggregateId)
{
public string Reason { get; } = reason;
}

View file

@ -0,0 +1,78 @@
using Books.Api.Domain.Customers;
using EventFlow.Commands;
namespace Books.Api.Commands.Customers;
public class CreateCustomerCommandHandler : CommandHandler<CustomerAggregate, CustomerId, CreateCustomerCommand>
{
public override Task ExecuteAsync(
CustomerAggregate aggregate,
CreateCustomerCommand command,
CancellationToken cancellationToken)
{
aggregate.Create(
command.CompanyId,
command.CustomerNumber,
command.CustomerType,
command.Name,
command.Cvr,
command.Address,
command.PostalCode,
command.City,
command.Country,
command.Email,
command.Phone,
command.PaymentTermsDays,
command.DefaultRevenueAccountId,
command.SubLedgerAccountId);
return Task.CompletedTask;
}
}
public class UpdateCustomerCommandHandler : CommandHandler<CustomerAggregate, CustomerId, UpdateCustomerCommand>
{
public override Task ExecuteAsync(
CustomerAggregate aggregate,
UpdateCustomerCommand command,
CancellationToken cancellationToken)
{
aggregate.Update(
command.Name,
command.Cvr,
command.Address,
command.PostalCode,
command.City,
command.Country,
command.Email,
command.Phone,
command.PaymentTermsDays,
command.DefaultRevenueAccountId);
return Task.CompletedTask;
}
}
public class DeactivateCustomerCommandHandler : CommandHandler<CustomerAggregate, CustomerId, DeactivateCustomerCommand>
{
public override Task ExecuteAsync(
CustomerAggregate aggregate,
DeactivateCustomerCommand command,
CancellationToken cancellationToken)
{
aggregate.Deactivate();
return Task.CompletedTask;
}
}
public class ReactivateCustomerCommandHandler : CommandHandler<CustomerAggregate, CustomerId, ReactivateCustomerCommand>
{
public override Task ExecuteAsync(
CustomerAggregate aggregate,
ReactivateCustomerCommand command,
CancellationToken cancellationToken)
{
aggregate.Reactivate();
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,70 @@
using Books.Api.Domain.Customers;
using EventFlow.Commands;
namespace Books.Api.Commands.Customers;
public class CreateCustomerCommand(
CustomerId aggregateId,
string companyId,
string customerNumber,
CustomerType customerType,
string name,
string? cvr,
string? address,
string? postalCode,
string? city,
string country,
string? email,
string? phone,
int paymentTermsDays,
string? defaultRevenueAccountId,
string subLedgerAccountId)
: Command<CustomerAggregate, CustomerId>(aggregateId)
{
public string CompanyId { get; } = companyId;
public string CustomerNumber { get; } = customerNumber;
public CustomerType CustomerType { get; } = customerType;
public string Name { get; } = name;
public string? Cvr { get; } = cvr;
public string? Address { get; } = address;
public string? PostalCode { get; } = postalCode;
public string? City { get; } = city;
public string Country { get; } = country;
public string? Email { get; } = email;
public string? Phone { get; } = phone;
public int PaymentTermsDays { get; } = paymentTermsDays;
public string? DefaultRevenueAccountId { get; } = defaultRevenueAccountId;
public string SubLedgerAccountId { get; } = subLedgerAccountId;
}
public class UpdateCustomerCommand(
CustomerId aggregateId,
string name,
string? cvr,
string? address,
string? postalCode,
string? city,
string country,
string? email,
string? phone,
int paymentTermsDays,
string? defaultRevenueAccountId)
: Command<CustomerAggregate, CustomerId>(aggregateId)
{
public string Name { get; } = name;
public string? Cvr { get; } = cvr;
public string? Address { get; } = address;
public string? PostalCode { get; } = postalCode;
public string? City { get; } = city;
public string Country { get; } = country;
public string? Email { get; } = email;
public string? Phone { get; } = phone;
public int PaymentTermsDays { get; } = paymentTermsDays;
public string? DefaultRevenueAccountId { get; } = defaultRevenueAccountId;
}
public class DeactivateCustomerCommand(CustomerId aggregateId)
: Command<CustomerAggregate, CustomerId>(aggregateId);
public class ReactivateCustomerCommand(CustomerId aggregateId)
: Command<CustomerAggregate, CustomerId>(aggregateId);

View file

@ -0,0 +1,71 @@
using Books.Api.Domain.FiscalYears;
using EventFlow.Commands;
namespace Books.Api.Commands.FiscalYears;
public class CreateFiscalYearCommandHandler : CommandHandler<FiscalYearAggregate, FiscalYearId, CreateFiscalYearCommand>
{
public override Task ExecuteAsync(
FiscalYearAggregate aggregate,
CreateFiscalYearCommand command,
CancellationToken cancellationToken)
{
aggregate.Create(
command.CompanyId,
command.Name,
command.StartDate,
command.EndDate,
command.IsFirstFiscalYear,
command.IsReorganization);
return Task.CompletedTask;
}
}
public class CloseFiscalYearCommandHandler : CommandHandler<FiscalYearAggregate, FiscalYearId, CloseFiscalYearCommand>
{
public override Task ExecuteAsync(
FiscalYearAggregate aggregate,
CloseFiscalYearCommand command,
CancellationToken cancellationToken)
{
aggregate.Close(command.ClosedBy);
return Task.CompletedTask;
}
}
public class ReopenFiscalYearCommandHandler : CommandHandler<FiscalYearAggregate, FiscalYearId, ReopenFiscalYearCommand>
{
public override Task ExecuteAsync(
FiscalYearAggregate aggregate,
ReopenFiscalYearCommand command,
CancellationToken cancellationToken)
{
aggregate.Reopen(command.ReopenedBy);
return Task.CompletedTask;
}
}
public class LockFiscalYearCommandHandler : CommandHandler<FiscalYearAggregate, FiscalYearId, LockFiscalYearCommand>
{
public override Task ExecuteAsync(
FiscalYearAggregate aggregate,
LockFiscalYearCommand command,
CancellationToken cancellationToken)
{
aggregate.Lock(command.LockedBy);
return Task.CompletedTask;
}
}
public class MarkOpeningBalancePostedCommandHandler : CommandHandler<FiscalYearAggregate, FiscalYearId, MarkOpeningBalancePostedCommand>
{
public override Task ExecuteAsync(
FiscalYearAggregate aggregate,
MarkOpeningBalancePostedCommand command,
CancellationToken cancellationToken)
{
aggregate.MarkOpeningBalancePosted();
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,57 @@
using Books.Api.Domain.FiscalYears;
using EventFlow.Commands;
namespace Books.Api.Commands.FiscalYears;
public class CreateFiscalYearCommand(
FiscalYearId aggregateId,
string companyId,
string name,
DateOnly startDate,
DateOnly endDate,
bool isFirstFiscalYear = false,
bool isReorganization = false)
: Command<FiscalYearAggregate, FiscalYearId>(aggregateId)
{
public string CompanyId { get; } = companyId;
public string Name { get; } = name;
public DateOnly StartDate { get; } = startDate;
public DateOnly EndDate { get; } = endDate;
/// <summary>
/// Per Årsregnskabsloven §15: First fiscal year can be shorter than 12 months (6-18 months allowed)
/// </summary>
public bool IsFirstFiscalYear { get; } = isFirstFiscalYear;
/// <summary>
/// Per Årsregnskabsloven §15: Reorganization allows fiscal year up to 18 months
/// </summary>
public bool IsReorganization { get; } = isReorganization;
}
public class CloseFiscalYearCommand(
FiscalYearId aggregateId,
string closedBy)
: Command<FiscalYearAggregate, FiscalYearId>(aggregateId)
{
public string ClosedBy { get; } = closedBy;
}
public class ReopenFiscalYearCommand(
FiscalYearId aggregateId,
string reopenedBy)
: Command<FiscalYearAggregate, FiscalYearId>(aggregateId)
{
public string ReopenedBy { get; } = reopenedBy;
}
public class LockFiscalYearCommand(
FiscalYearId aggregateId,
string lockedBy)
: Command<FiscalYearAggregate, FiscalYearId>(aggregateId)
{
public string LockedBy { get; } = lockedBy;
}
public class MarkOpeningBalancePostedCommand(FiscalYearId aggregateId)
: Command<FiscalYearAggregate, FiscalYearId>(aggregateId);

View file

@ -0,0 +1,77 @@
using Books.Api.Domain.Invoices;
using EventFlow.Commands;
namespace Books.Api.Commands.Invoices;
/// <summary>
/// Creates a new credit note draft for a customer.
/// Credit note number is assigned at creation (Momsloven §52).
/// </summary>
public class CreateCreditNoteCommand(
InvoiceId invoiceId,
string companyId,
string fiscalYearId,
string customerId,
string customerName,
string customerNumber,
string creditNoteNumber,
DateOnly creditNoteDate,
string currency,
string? vatCode,
string? notes,
string? reference,
string createdBy,
string? originalInvoiceId = null,
string? originalInvoiceNumber = null,
string? creditReason = null) : Command<InvoiceAggregate, InvoiceId>(invoiceId)
{
public string CompanyId { get; } = companyId;
public string FiscalYearId { get; } = fiscalYearId;
public string CustomerId { get; } = customerId;
public string CustomerName { get; } = customerName;
public string CustomerNumber { get; } = customerNumber;
public string CreditNoteNumber { get; } = creditNoteNumber;
public DateOnly CreditNoteDate { get; } = creditNoteDate;
public string Currency { get; } = currency;
public string? VatCode { get; } = vatCode;
public string? Notes { get; } = notes;
public string? Reference { get; } = reference;
public string CreatedBy { get; } = createdBy;
public string? OriginalInvoiceId { get; } = originalInvoiceId;
public string? OriginalInvoiceNumber { get; } = originalInvoiceNumber;
public string? CreditReason { get; } = creditReason;
}
/// <summary>
/// Issues a credit note (posts to ledger).
/// This is the credit note equivalent of MarkInvoiceSentCommand.
/// Should be called after successfully posting to the ledger.
/// </summary>
public class IssueCreditNoteCommand(
InvoiceId invoiceId,
string ledgerTransactionId,
string issuedBy) : Command<InvoiceAggregate, InvoiceId>(invoiceId)
{
public string LedgerTransactionId { get; } = ledgerTransactionId;
public string IssuedBy { get; } = issuedBy;
}
/// <summary>
/// Applies a credit note to an invoice, reducing the invoice's outstanding balance.
/// </summary>
public class ApplyCreditNoteCommand(
InvoiceId creditNoteId,
string targetInvoiceId,
string targetInvoiceNumber,
decimal amount,
DateOnly appliedDate,
string appliedBy,
string? ledgerTransactionId = null) : Command<InvoiceAggregate, InvoiceId>(creditNoteId)
{
public string TargetInvoiceId { get; } = targetInvoiceId;
public string TargetInvoiceNumber { get; } = targetInvoiceNumber;
public decimal Amount { get; } = amount;
public DateOnly AppliedDate { get; } = appliedDate;
public string AppliedBy { get; } = appliedBy;
public string? LedgerTransactionId { get; } = ledgerTransactionId;
}

View file

@ -0,0 +1,211 @@
using Books.Api.Domain.Invoices;
using EventFlow.Commands;
namespace Books.Api.Commands.Invoices;
public class CreateInvoiceCommandHandler
: CommandHandler<InvoiceAggregate, InvoiceId, CreateInvoiceCommand>
{
public override Task ExecuteAsync(
InvoiceAggregate aggregate,
CreateInvoiceCommand command,
CancellationToken cancellationToken)
{
aggregate.Create(
command.CompanyId,
command.FiscalYearId,
command.CustomerId,
command.CustomerName,
command.CustomerNumber,
command.InvoiceNumber,
command.InvoiceDate,
command.DueDate,
command.PaymentTermsDays,
command.Currency,
command.VatCode,
command.Notes,
command.Reference,
command.CreatedBy);
return Task.CompletedTask;
}
}
public class AddInvoiceLineCommandHandler
: CommandHandler<InvoiceAggregate, InvoiceId, AddInvoiceLineCommand>
{
public override Task ExecuteAsync(
InvoiceAggregate aggregate,
AddInvoiceLineCommand command,
CancellationToken cancellationToken)
{
aggregate.AddLine(
command.Description,
command.Quantity,
command.UnitPrice,
command.VatCode,
command.AccountId,
command.Unit,
command.DiscountPercent);
return Task.CompletedTask;
}
}
public class UpdateInvoiceLineCommandHandler
: CommandHandler<InvoiceAggregate, InvoiceId, UpdateInvoiceLineCommand>
{
public override Task ExecuteAsync(
InvoiceAggregate aggregate,
UpdateInvoiceLineCommand command,
CancellationToken cancellationToken)
{
aggregate.UpdateLine(
command.LineNumber,
command.Description,
command.Quantity,
command.UnitPrice,
command.VatCode,
command.AccountId,
command.Unit,
command.DiscountPercent);
return Task.CompletedTask;
}
}
public class RemoveInvoiceLineCommandHandler
: CommandHandler<InvoiceAggregate, InvoiceId, RemoveInvoiceLineCommand>
{
public override Task ExecuteAsync(
InvoiceAggregate aggregate,
RemoveInvoiceLineCommand command,
CancellationToken cancellationToken)
{
aggregate.RemoveLine(command.LineNumber);
return Task.CompletedTask;
}
}
public class MarkInvoiceSentCommandHandler
: CommandHandler<InvoiceAggregate, InvoiceId, MarkInvoiceSentCommand>
{
public override Task ExecuteAsync(
InvoiceAggregate aggregate,
MarkInvoiceSentCommand command,
CancellationToken cancellationToken)
{
aggregate.Send(
command.LedgerTransactionId,
command.SentBy);
return Task.CompletedTask;
}
}
public class ReceiveInvoicePaymentCommandHandler
: CommandHandler<InvoiceAggregate, InvoiceId, ReceiveInvoicePaymentCommand>
{
public override Task ExecuteAsync(
InvoiceAggregate aggregate,
ReceiveInvoicePaymentCommand command,
CancellationToken cancellationToken)
{
aggregate.ReceivePayment(
command.Amount,
command.BankTransactionId,
command.LedgerTransactionId,
command.PaymentReference,
command.PaymentDate,
command.RecordedBy);
return Task.CompletedTask;
}
}
public class VoidInvoiceCommandHandler
: CommandHandler<InvoiceAggregate, InvoiceId, VoidInvoiceCommand>
{
public override Task ExecuteAsync(
InvoiceAggregate aggregate,
VoidInvoiceCommand command,
CancellationToken cancellationToken)
{
aggregate.Void(
command.Reason,
command.ReversalLedgerTransactionId,
command.VoidedBy);
return Task.CompletedTask;
}
}
// =====================================================
// CREDIT NOTE COMMAND HANDLERS
// =====================================================
public class CreateCreditNoteCommandHandler
: CommandHandler<InvoiceAggregate, InvoiceId, CreateCreditNoteCommand>
{
public override Task ExecuteAsync(
InvoiceAggregate aggregate,
CreateCreditNoteCommand command,
CancellationToken cancellationToken)
{
aggregate.CreateCreditNote(
command.CompanyId,
command.FiscalYearId,
command.CustomerId,
command.CustomerName,
command.CustomerNumber,
command.CreditNoteNumber,
command.CreditNoteDate,
command.Currency,
command.VatCode,
command.Notes,
command.Reference,
command.CreatedBy,
command.OriginalInvoiceId,
command.OriginalInvoiceNumber,
command.CreditReason);
return Task.CompletedTask;
}
}
public class IssueCreditNoteCommandHandler
: CommandHandler<InvoiceAggregate, InvoiceId, IssueCreditNoteCommand>
{
public override Task ExecuteAsync(
InvoiceAggregate aggregate,
IssueCreditNoteCommand command,
CancellationToken cancellationToken)
{
aggregate.Issue(
command.LedgerTransactionId,
command.IssuedBy);
return Task.CompletedTask;
}
}
public class ApplyCreditNoteCommandHandler
: CommandHandler<InvoiceAggregate, InvoiceId, ApplyCreditNoteCommand>
{
public override Task ExecuteAsync(
InvoiceAggregate aggregate,
ApplyCreditNoteCommand command,
CancellationToken cancellationToken)
{
aggregate.ApplyCredit(
command.TargetInvoiceId,
command.TargetInvoiceNumber,
command.Amount,
command.AppliedDate,
command.AppliedBy,
command.LedgerTransactionId);
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,144 @@
using Books.Api.Domain.Invoices;
using EventFlow.Commands;
namespace Books.Api.Commands.Invoices;
/// <summary>
/// Creates a new invoice draft for a customer.
/// Invoice number is assigned at creation (Momsloven §52).
/// </summary>
public class CreateInvoiceCommand(
InvoiceId invoiceId,
string companyId,
string fiscalYearId,
string customerId,
string customerName,
string customerNumber,
string invoiceNumber,
DateOnly invoiceDate,
DateOnly dueDate,
int paymentTermsDays,
string currency,
string? vatCode,
string? notes,
string? reference,
string createdBy) : Command<InvoiceAggregate, InvoiceId>(invoiceId)
{
public string CompanyId { get; } = companyId;
public string FiscalYearId { get; } = fiscalYearId;
public string CustomerId { get; } = customerId;
public string CustomerName { get; } = customerName;
public string CustomerNumber { get; } = customerNumber;
public string InvoiceNumber { get; } = invoiceNumber;
public DateOnly InvoiceDate { get; } = invoiceDate;
public DateOnly DueDate { get; } = dueDate;
public int PaymentTermsDays { get; } = paymentTermsDays;
public string Currency { get; } = currency;
public string? VatCode { get; } = vatCode;
public string? Notes { get; } = notes;
public string? Reference { get; } = reference;
public string CreatedBy { get; } = createdBy;
}
/// <summary>
/// Adds a line to an invoice draft.
/// </summary>
public class AddInvoiceLineCommand(
InvoiceId invoiceId,
string description,
decimal quantity,
decimal unitPrice,
string vatCode,
string? accountId = null,
string? unit = null,
decimal discountPercent = 0) : Command<InvoiceAggregate, InvoiceId>(invoiceId)
{
public string Description { get; } = description;
public decimal Quantity { get; } = quantity;
public decimal UnitPrice { get; } = unitPrice;
public string VatCode { get; } = vatCode;
public string? AccountId { get; } = accountId;
public string? Unit { get; } = unit;
public decimal DiscountPercent { get; } = discountPercent;
}
/// <summary>
/// Updates a line on an invoice draft.
/// </summary>
public class UpdateInvoiceLineCommand(
InvoiceId invoiceId,
int lineNumber,
string description,
decimal quantity,
decimal unitPrice,
string vatCode,
string? accountId = null,
string? unit = null,
decimal discountPercent = 0) : Command<InvoiceAggregate, InvoiceId>(invoiceId)
{
public int LineNumber { get; } = lineNumber;
public string Description { get; } = description;
public decimal Quantity { get; } = quantity;
public decimal UnitPrice { get; } = unitPrice;
public string VatCode { get; } = vatCode;
public string? AccountId { get; } = accountId;
public string? Unit { get; } = unit;
public decimal DiscountPercent { get; } = discountPercent;
}
/// <summary>
/// Removes a line from an invoice draft.
/// </summary>
public class RemoveInvoiceLineCommand(
InvoiceId invoiceId,
int lineNumber) : Command<InvoiceAggregate, InvoiceId>(invoiceId)
{
public int LineNumber { get; } = lineNumber;
}
/// <summary>
/// Marks an invoice as sent and records the ledger transaction.
/// Should be called after successfully posting to the ledger.
/// </summary>
public class MarkInvoiceSentCommand(
InvoiceId invoiceId,
string ledgerTransactionId,
string sentBy) : Command<InvoiceAggregate, InvoiceId>(invoiceId)
{
public string LedgerTransactionId { get; } = ledgerTransactionId;
public string SentBy { get; } = sentBy;
}
/// <summary>
/// Records a payment received for an invoice.
/// </summary>
public class ReceiveInvoicePaymentCommand(
InvoiceId invoiceId,
decimal amount,
string? bankTransactionId,
string? ledgerTransactionId,
string? paymentReference,
DateOnly paymentDate,
string recordedBy) : Command<InvoiceAggregate, InvoiceId>(invoiceId)
{
public decimal Amount { get; } = amount;
public string? BankTransactionId { get; } = bankTransactionId;
public string? LedgerTransactionId { get; } = ledgerTransactionId;
public string? PaymentReference { get; } = paymentReference;
public DateOnly PaymentDate { get; } = paymentDate;
public string RecordedBy { get; } = recordedBy;
}
/// <summary>
/// Voids an invoice. If already sent, includes the reversal transaction ID.
/// </summary>
public class VoidInvoiceCommand(
InvoiceId invoiceId,
string reason,
string? reversalLedgerTransactionId,
string voidedBy) : Command<InvoiceAggregate, InvoiceId>(invoiceId)
{
public string Reason { get; } = reason;
public string? ReversalLedgerTransactionId { get; } = reversalLedgerTransactionId;
public string VoidedBy { get; } = voidedBy;
}

View file

@ -0,0 +1,73 @@
using Books.Api.Domain.JournalEntryDrafts;
using EventFlow.Commands;
namespace Books.Api.Commands.JournalEntryDrafts;
public class CreateJournalEntryDraftCommandHandler
: CommandHandler<JournalEntryDraftAggregate, JournalEntryDraftId, CreateJournalEntryDraftCommand>
{
public override Task ExecuteAsync(
JournalEntryDraftAggregate aggregate,
CreateJournalEntryDraftCommand command,
CancellationToken cancellationToken)
{
aggregate.Create(
command.CompanyId,
command.Name,
command.CreatedBy,
command.VoucherNumber,
command.ExtractionData);
return Task.CompletedTask;
}
}
public class UpdateJournalEntryDraftCommandHandler
: CommandHandler<JournalEntryDraftAggregate, JournalEntryDraftId, UpdateJournalEntryDraftCommand>
{
public override Task ExecuteAsync(
JournalEntryDraftAggregate aggregate,
UpdateJournalEntryDraftCommand command,
CancellationToken cancellationToken)
{
aggregate.Update(
command.Name,
command.DocumentDate,
command.Description,
command.FiscalYearId,
command.Lines,
command.AttachmentIds);
return Task.CompletedTask;
}
}
public class MarkJournalEntryDraftPostedCommandHandler
: CommandHandler<JournalEntryDraftAggregate, JournalEntryDraftId, MarkJournalEntryDraftPostedCommand>
{
public override Task ExecuteAsync(
JournalEntryDraftAggregate aggregate,
MarkJournalEntryDraftPostedCommand command,
CancellationToken cancellationToken)
{
aggregate.MarkPosted(
command.TransactionId,
command.PostedBy);
return Task.CompletedTask;
}
}
public class DiscardJournalEntryDraftCommandHandler
: CommandHandler<JournalEntryDraftAggregate, JournalEntryDraftId, DiscardJournalEntryDraftCommand>
{
public override Task ExecuteAsync(
JournalEntryDraftAggregate aggregate,
DiscardJournalEntryDraftCommand command,
CancellationToken cancellationToken)
{
aggregate.Discard(command.DiscardedBy);
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,77 @@
using Books.Api.Domain.JournalEntryDrafts;
using EventFlow.Commands;
namespace Books.Api.Commands.JournalEntryDrafts;
/// <summary>
/// Command to create a new journal entry draft.
/// VoucherNumber is required by Bogføringsloven § 7, Stk. 4.
/// </summary>
public class CreateJournalEntryDraftCommand(
JournalEntryDraftId aggregateId,
string companyId,
string name,
string createdBy,
string voucherNumber,
string? extractionData = null)
: Command<JournalEntryDraftAggregate, JournalEntryDraftId>(aggregateId)
{
public string CompanyId { get; } = companyId;
public string Name { get; } = name;
public string CreatedBy { get; } = createdBy;
/// <summary>
/// Bilagsnummer - unique document number per company.
/// </summary>
public string VoucherNumber { get; } = voucherNumber;
/// <summary>
/// Full AI extraction data as JSON string.
/// Contains vendor CVR, amounts, VAT, due date, payment reference, line items, etc.
/// </summary>
public string? ExtractionData { get; } = extractionData;
}
/// <summary>
/// Command to update a journal entry draft (auto-save).
/// Includes VAT codes per line and attachment references.
/// </summary>
public class UpdateJournalEntryDraftCommand(
JournalEntryDraftId aggregateId,
string? name,
DateOnly? documentDate,
string? description,
string? fiscalYearId,
List<DraftLine> lines,
List<string>? attachmentIds = null)
: Command<JournalEntryDraftAggregate, JournalEntryDraftId>(aggregateId)
{
public string? Name { get; } = name;
/// <summary>
/// Bilagsdato - the date of the transaction/document (e.g., invoice date)
/// </summary>
public DateOnly? DocumentDate { get; } = documentDate;
public string? Description { get; } = description;
public string? FiscalYearId { get; } = fiscalYearId;
public List<DraftLine> Lines { get; } = lines;
/// <summary>
/// References to attached documents (bilag) - required by Bogføringsloven § 6.
/// </summary>
public List<string> AttachmentIds { get; } = attachmentIds ?? [];
}
public class MarkJournalEntryDraftPostedCommand(
JournalEntryDraftId aggregateId,
string transactionId,
string postedBy)
: Command<JournalEntryDraftAggregate, JournalEntryDraftId>(aggregateId)
{
public string TransactionId { get; } = transactionId;
public string PostedBy { get; } = postedBy;
}
public class DiscardJournalEntryDraftCommand(
JournalEntryDraftId aggregateId,
string discardedBy)
: Command<JournalEntryDraftAggregate, JournalEntryDraftId>(aggregateId)
{
public string DiscardedBy { get; } = discardedBy;
}

View file

@ -0,0 +1,136 @@
using Books.Api.Domain.Orders;
using EventFlow.Commands;
namespace Books.Api.Commands.Orders;
public class CreateOrderCommandHandler
: CommandHandler<OrderAggregate, OrderId, CreateOrderCommand>
{
public override Task ExecuteAsync(
OrderAggregate aggregate,
CreateOrderCommand command,
CancellationToken cancellationToken)
{
aggregate.Create(
command.CompanyId,
command.FiscalYearId,
command.CustomerId,
command.CustomerName,
command.CustomerNumber,
command.OrderNumber,
command.OrderDate,
command.ExpectedDeliveryDate,
command.Currency,
command.VatCode,
command.Notes,
command.Reference,
command.CreatedBy);
return Task.CompletedTask;
}
}
public class AddOrderLineCommandHandler
: CommandHandler<OrderAggregate, OrderId, AddOrderLineCommand>
{
public override Task ExecuteAsync(
OrderAggregate aggregate,
AddOrderLineCommand command,
CancellationToken cancellationToken)
{
aggregate.AddLine(
command.Description,
command.Quantity,
command.UnitPrice,
command.VatCode,
command.AccountId,
command.Unit,
command.DiscountPercent,
command.ProductId);
return Task.CompletedTask;
}
}
public class UpdateOrderLineCommandHandler
: CommandHandler<OrderAggregate, OrderId, UpdateOrderLineCommand>
{
public override Task ExecuteAsync(
OrderAggregate aggregate,
UpdateOrderLineCommand command,
CancellationToken cancellationToken)
{
aggregate.UpdateLine(
command.LineNumber,
command.Description,
command.Quantity,
command.UnitPrice,
command.VatCode,
command.AccountId,
command.Unit,
command.DiscountPercent,
command.ProductId);
return Task.CompletedTask;
}
}
public class RemoveOrderLineCommandHandler
: CommandHandler<OrderAggregate, OrderId, RemoveOrderLineCommand>
{
public override Task ExecuteAsync(
OrderAggregate aggregate,
RemoveOrderLineCommand command,
CancellationToken cancellationToken)
{
aggregate.RemoveLine(command.LineNumber);
return Task.CompletedTask;
}
}
public class ConfirmOrderCommandHandler
: CommandHandler<OrderAggregate, OrderId, ConfirmOrderCommand>
{
public override Task ExecuteAsync(
OrderAggregate aggregate,
ConfirmOrderCommand command,
CancellationToken cancellationToken)
{
aggregate.Confirm(command.ConfirmedBy);
return Task.CompletedTask;
}
}
public class MarkOrderLinesInvoicedCommandHandler
: CommandHandler<OrderAggregate, OrderId, MarkOrderLinesInvoicedCommand>
{
public override Task ExecuteAsync(
OrderAggregate aggregate,
MarkOrderLinesInvoicedCommand command,
CancellationToken cancellationToken)
{
aggregate.MarkLinesAsInvoiced(
command.InvoiceId,
command.InvoiceNumber,
command.LineNumbers,
command.InvoicedBy);
return Task.CompletedTask;
}
}
public class CancelOrderCommandHandler
: CommandHandler<OrderAggregate, OrderId, CancelOrderCommand>
{
public override Task ExecuteAsync(
OrderAggregate aggregate,
CancelOrderCommand command,
CancellationToken cancellationToken)
{
aggregate.Cancel(command.Reason, command.CancelledBy);
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,136 @@
using Books.Api.Domain.Orders;
using EventFlow.Commands;
namespace Books.Api.Commands.Orders;
/// <summary>
/// Creates a new order draft for a customer.
/// </summary>
public class CreateOrderCommand(
OrderId orderId,
string companyId,
string fiscalYearId,
string customerId,
string customerName,
string customerNumber,
string orderNumber,
DateOnly orderDate,
DateOnly? expectedDeliveryDate,
string currency,
string? vatCode,
string? notes,
string? reference,
string createdBy) : Command<OrderAggregate, OrderId>(orderId)
{
public string CompanyId { get; } = companyId;
public string FiscalYearId { get; } = fiscalYearId;
public string CustomerId { get; } = customerId;
public string CustomerName { get; } = customerName;
public string CustomerNumber { get; } = customerNumber;
public string OrderNumber { get; } = orderNumber;
public DateOnly OrderDate { get; } = orderDate;
public DateOnly? ExpectedDeliveryDate { get; } = expectedDeliveryDate;
public string Currency { get; } = currency;
public string? VatCode { get; } = vatCode;
public string? Notes { get; } = notes;
public string? Reference { get; } = reference;
public string CreatedBy { get; } = createdBy;
}
/// <summary>
/// Adds a line to an order draft.
/// </summary>
public class AddOrderLineCommand(
OrderId orderId,
string description,
decimal quantity,
decimal unitPrice,
string vatCode,
string? accountId = null,
string? unit = null,
decimal discountPercent = 0,
string? productId = null) : Command<OrderAggregate, OrderId>(orderId)
{
public string Description { get; } = description;
public decimal Quantity { get; } = quantity;
public decimal UnitPrice { get; } = unitPrice;
public string VatCode { get; } = vatCode;
public string? AccountId { get; } = accountId;
public string? Unit { get; } = unit;
public decimal DiscountPercent { get; } = discountPercent;
public string? ProductId { get; } = productId;
}
/// <summary>
/// Updates a line on an order draft.
/// </summary>
public class UpdateOrderLineCommand(
OrderId orderId,
int lineNumber,
string description,
decimal quantity,
decimal unitPrice,
string vatCode,
string? accountId = null,
string? unit = null,
decimal discountPercent = 0,
string? productId = null) : Command<OrderAggregate, OrderId>(orderId)
{
public int LineNumber { get; } = lineNumber;
public string Description { get; } = description;
public decimal Quantity { get; } = quantity;
public decimal UnitPrice { get; } = unitPrice;
public string VatCode { get; } = vatCode;
public string? AccountId { get; } = accountId;
public string? Unit { get; } = unit;
public decimal DiscountPercent { get; } = discountPercent;
public string? ProductId { get; } = productId;
}
/// <summary>
/// Removes a line from an order draft.
/// </summary>
public class RemoveOrderLineCommand(
OrderId orderId,
int lineNumber) : Command<OrderAggregate, OrderId>(orderId)
{
public int LineNumber { get; } = lineNumber;
}
/// <summary>
/// Confirms an order.
/// </summary>
public class ConfirmOrderCommand(
OrderId orderId,
string confirmedBy) : Command<OrderAggregate, OrderId>(orderId)
{
public string ConfirmedBy { get; } = confirmedBy;
}
/// <summary>
/// Marks specified lines on an order as invoiced.
/// </summary>
public class MarkOrderLinesInvoicedCommand(
OrderId orderId,
string invoiceId,
string invoiceNumber,
IReadOnlyList<int> lineNumbers,
string invoicedBy) : Command<OrderAggregate, OrderId>(orderId)
{
public string InvoiceId { get; } = invoiceId;
public string InvoiceNumber { get; } = invoiceNumber;
public IReadOnlyList<int> LineNumbers { get; } = lineNumbers;
public string InvoicedBy { get; } = invoicedBy;
}
/// <summary>
/// Cancels an order.
/// </summary>
public class CancelOrderCommand(
OrderId orderId,
string reason,
string cancelledBy) : Command<OrderAggregate, OrderId>(orderId)
{
public string Reason { get; } = reason;
public string CancelledBy { get; } = cancelledBy;
}

View file

@ -0,0 +1,73 @@
using Books.Api.Domain.Products;
using EventFlow.Commands;
namespace Books.Api.Commands.Products;
public class CreateProductCommandHandler : CommandHandler<ProductAggregate, ProductId, CreateProductCommand>
{
public override Task ExecuteAsync(
ProductAggregate aggregate,
CreateProductCommand command,
CancellationToken cancellationToken)
{
aggregate.Create(
command.CompanyId,
command.ProductNumber,
command.Name,
command.Description,
command.UnitPrice,
command.VatCode,
command.Unit,
command.DefaultAccountId,
command.Ean,
command.Manufacturer);
return Task.CompletedTask;
}
}
public class UpdateProductCommandHandler : CommandHandler<ProductAggregate, ProductId, UpdateProductCommand>
{
public override Task ExecuteAsync(
ProductAggregate aggregate,
UpdateProductCommand command,
CancellationToken cancellationToken)
{
aggregate.Update(
command.ProductNumber,
command.Name,
command.Description,
command.UnitPrice,
command.VatCode,
command.Unit,
command.DefaultAccountId,
command.Ean,
command.Manufacturer);
return Task.CompletedTask;
}
}
public class DeactivateProductCommandHandler : CommandHandler<ProductAggregate, ProductId, DeactivateProductCommand>
{
public override Task ExecuteAsync(
ProductAggregate aggregate,
DeactivateProductCommand command,
CancellationToken cancellationToken)
{
aggregate.Deactivate();
return Task.CompletedTask;
}
}
public class ReactivateProductCommandHandler : CommandHandler<ProductAggregate, ProductId, ReactivateProductCommand>
{
public override Task ExecuteAsync(
ProductAggregate aggregate,
ReactivateProductCommand command,
CancellationToken cancellationToken)
{
aggregate.Reactivate();
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,60 @@
using Books.Api.Domain.Products;
using EventFlow.Commands;
namespace Books.Api.Commands.Products;
public class CreateProductCommand(
ProductId aggregateId,
string companyId,
string? productNumber,
string name,
string? description,
decimal unitPrice,
string vatCode,
string? unit,
string? defaultAccountId,
string? ean,
string? manufacturer)
: Command<ProductAggregate, ProductId>(aggregateId)
{
public string CompanyId { get; } = companyId;
public string? ProductNumber { get; } = productNumber;
public string Name { get; } = name;
public string? Description { get; } = description;
public decimal UnitPrice { get; } = unitPrice;
public string VatCode { get; } = vatCode;
public string? Unit { get; } = unit;
public string? DefaultAccountId { get; } = defaultAccountId;
public string? Ean { get; } = ean;
public string? Manufacturer { get; } = manufacturer;
}
public class UpdateProductCommand(
ProductId aggregateId,
string? productNumber,
string name,
string? description,
decimal unitPrice,
string vatCode,
string? unit,
string? defaultAccountId,
string? ean,
string? manufacturer)
: Command<ProductAggregate, ProductId>(aggregateId)
{
public string? ProductNumber { get; } = productNumber;
public string Name { get; } = name;
public string? Description { get; } = description;
public decimal UnitPrice { get; } = unitPrice;
public string VatCode { get; } = vatCode;
public string? Unit { get; } = unit;
public string? DefaultAccountId { get; } = defaultAccountId;
public string? Ean { get; } = ean;
public string? Manufacturer { get; } = manufacturer;
}
public class DeactivateProductCommand(ProductId aggregateId)
: Command<ProductAggregate, ProductId>(aggregateId);
public class ReactivateProductCommand(ProductId aggregateId)
: Command<ProductAggregate, ProductId>(aggregateId);

View file

@ -0,0 +1,43 @@
using Books.Api.Domain.UserAccess;
using EventFlow.Commands;
namespace Books.Api.Commands.UserAccess;
public class GrantUserCompanyAccessCommandHandler
: CommandHandler<UserCompanyAccessAggregate, UserCompanyAccessId, GrantUserCompanyAccessCommand>
{
public override Task ExecuteAsync(
UserCompanyAccessAggregate aggregate,
GrantUserCompanyAccessCommand command,
CancellationToken cancellationToken)
{
aggregate.GrantAccess(command.UserId, command.CompanyId, command.Role, command.GrantedBy);
return Task.CompletedTask;
}
}
public class ChangeUserCompanyAccessRoleCommandHandler
: CommandHandler<UserCompanyAccessAggregate, UserCompanyAccessId, ChangeUserCompanyAccessRoleCommand>
{
public override Task ExecuteAsync(
UserCompanyAccessAggregate aggregate,
ChangeUserCompanyAccessRoleCommand command,
CancellationToken cancellationToken)
{
aggregate.ChangeRole(command.NewRole, command.ChangedBy);
return Task.CompletedTask;
}
}
public class RevokeUserCompanyAccessCommandHandler
: CommandHandler<UserCompanyAccessAggregate, UserCompanyAccessId, RevokeUserCompanyAccessCommand>
{
public override Task ExecuteAsync(
UserCompanyAccessAggregate aggregate,
RevokeUserCompanyAccessCommand command,
CancellationToken cancellationToken)
{
aggregate.RevokeAccess(command.RevokedBy);
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,33 @@
using Books.Api.Domain.UserAccess;
using EventFlow.Commands;
namespace Books.Api.Commands.UserAccess;
public class GrantUserCompanyAccessCommand(
UserCompanyAccessId aggregateId,
string userId,
string companyId,
CompanyRole role,
string grantedBy) : Command<UserCompanyAccessAggregate, UserCompanyAccessId>(aggregateId)
{
public string UserId { get; } = userId;
public string CompanyId { get; } = companyId;
public CompanyRole Role { get; } = role;
public string GrantedBy { get; } = grantedBy;
}
public class ChangeUserCompanyAccessRoleCommand(
UserCompanyAccessId aggregateId,
CompanyRole newRole,
string changedBy) : Command<UserCompanyAccessAggregate, UserCompanyAccessId>(aggregateId)
{
public CompanyRole NewRole { get; } = newRole;
public string ChangedBy { get; } = changedBy;
}
public class RevokeUserCompanyAccessCommand(
UserCompanyAccessId aggregateId,
string revokedBy) : Command<UserCompanyAccessAggregate, UserCompanyAccessId>(aggregateId)
{
public string RevokedBy { get; } = revokedBy;
}

View file

@ -0,0 +1,269 @@
using System.Security.Claims;
using Books.Api.Authorization;
using Books.Api.Commands.Attachments;
using Books.Api.Domain;
using Books.Api.Domain.Attachments;
using Books.Api.EventFlow.ReadModels;
using Books.Api.EventFlow.Repositories;
using Books.Api.Infrastructure.FileStorage;
using EventFlow;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Books.Api.Controllers;
/// <summary>
/// REST API for attachment (bilag) file operations.
/// GraphQL doesn't handle file uploads well, so we use REST for this.
/// </summary>
[ApiController]
[Route("api/attachments")]
[Authorize]
public class AttachmentController(
ICommandBus commandBus,
IAttachmentRepository attachmentRepository,
IFileStorageService fileStorage,
ICompanyAccessService companyAccess,
ILogger<AttachmentController> logger) : ControllerBase
{
/// <summary>
/// Upload one or more attachments for a company.
/// </summary>
/// <param name="companyId">Company ID</param>
/// <param name="draftId">Optional draft ID to link attachments to</param>
/// <param name="files">Files to upload</param>
[HttpPost("upload")]
[RequestSizeLimit(50 * 1024 * 1024)] // 50MB max total
public async Task<IActionResult> Upload(
[FromQuery] string companyId,
[FromQuery] string? draftId,
[FromForm] List<IFormFile> files,
CancellationToken cancellationToken)
{
// Validate company access
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
{
return Unauthorized(new { error = "NOT_AUTHENTICATED", message = "You must be authenticated to upload attachments" });
}
// Check if user can write (Accountant or Owner role)
var canWrite = await companyAccess.CanWriteAsync(companyId, cancellationToken);
if (!canWrite)
{
return Forbid();
}
if (files.Count == 0)
{
return BadRequest(new { error = "NO_FILES", message = "No files provided" });
}
var results = new List<object>();
foreach (var file in files)
{
try
{
// Validate file size (10MB per file)
if (file.Length > 10 * 1024 * 1024)
{
results.Add(new
{
success = false,
fileName = file.FileName,
error = "FILE_TOO_LARGE",
message = $"File '{file.FileName}' exceeds 10MB limit"
});
continue;
}
// Store file
await using var stream = file.OpenReadStream();
var storageResult = await fileStorage.StoreAsync(
companyId,
file.FileName,
file.ContentType,
stream,
cancellationToken);
// Create attachment aggregate
var attachmentId = AttachmentId.New;
var command = new UploadAttachmentCommand(
attachmentId,
companyId,
storageResult.StoredFileName,
file.FileName,
file.ContentType,
storageResult.FileSize,
storageResult.StoragePath,
userId,
draftId);
await commandBus.PublishAsync(command, cancellationToken);
// Wait briefly for read model to be updated (eventual consistency)
await Task.Delay(100, cancellationToken);
var attachment = await attachmentRepository.GetByIdAsync(attachmentId.Value, cancellationToken);
results.Add(new
{
success = true,
id = attachmentId.Value,
fileName = file.FileName,
fileType = file.ContentType,
fileSize = storageResult.FileSize,
uploadedAt = attachment?.UploadedAt ?? DateTimeOffset.UtcNow,
url = fileStorage.GetDownloadUrl(storageResult.StoragePath)
});
}
catch (DomainException ex)
{
logger.LogWarning(ex, "Failed to upload file {FileName}", file.FileName);
results.Add(new
{
success = false,
fileName = file.FileName,
error = ex.Code,
message = ex.MessageDanish
});
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected error uploading file {FileName}", file.FileName);
results.Add(new
{
success = false,
fileName = file.FileName,
error = "UPLOAD_FAILED",
message = "An unexpected error occurred"
});
}
}
return Ok(new { attachments = results });
}
/// <summary>
/// Get attachments for a draft.
/// </summary>
[HttpGet("draft/{draftId}")]
public async Task<IActionResult> GetByDraft(string draftId, CancellationToken cancellationToken)
{
var attachments = await attachmentRepository.GetByDraftIdAsync(draftId, cancellationToken);
if (attachments.Count == 0)
{
return Ok(new { attachments = Array.Empty<object>() });
}
// Validate company access (use first attachment's company)
var access = await companyAccess.GetAccessAsync(attachments[0].CompanyId, cancellationToken);
if (access == null)
{
return Forbid();
}
var result = attachments.Select(a => new
{
id = a.Id,
fileName = a.OriginalFileName,
fileType = a.ContentType,
fileSize = a.FileSize,
uploadedAt = a.UploadedAt.ToString("O"),
uploadedBy = a.UploadedBy,
url = fileStorage.GetDownloadUrl(a.StoragePath)
});
return Ok(new { attachments = result });
}
/// <summary>
/// Download an attachment by storage path.
/// </summary>
[HttpGet("{*storagePath}")]
public async Task<IActionResult> Download(string storagePath, CancellationToken cancellationToken)
{
// Validate path to prevent directory traversal attacks
if (string.IsNullOrWhiteSpace(storagePath) ||
storagePath.Contains("..") ||
storagePath.Contains("~") ||
Path.IsPathRooted(storagePath) ||
storagePath.StartsWith("/") ||
storagePath.StartsWith("\\"))
{
logger.LogWarning("Attempted path traversal attack with path: {StoragePath}", storagePath);
return BadRequest(new { error = "INVALID_PATH", message = "Invalid storage path" });
}
var file = await fileStorage.GetAsync(storagePath, cancellationToken);
if (file == null)
{
return NotFound(new { error = "FILE_NOT_FOUND", message = "Attachment not found" });
}
return File(file.Content, file.ContentType, file.FileName);
}
/// <summary>
/// Delete an attachment.
/// Note: Per Bogføringsloven § 6, attachments linked to transactions
/// cannot be deleted within the 5-year retention period.
/// </summary>
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(
string id,
[FromQuery] string reason,
CancellationToken cancellationToken)
{
var attachment = await attachmentRepository.GetByIdAsync(id, cancellationToken);
if (attachment == null)
{
return NotFound(new { error = "ATTACHMENT_NOT_FOUND", message = "Attachment not found" });
}
// Validate company access - need write permission to delete
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
{
return Unauthorized();
}
var canWrite = await companyAccess.CanWriteAsync(attachment.CompanyId, cancellationToken);
if (!canWrite)
{
return Forbid();
}
if (string.IsNullOrWhiteSpace(reason))
{
return BadRequest(new
{
error = "REASON_REQUIRED",
message = "A reason for deletion is required (Bogføringsloven § 6)"
});
}
try
{
var command = new DeleteAttachmentCommand(
new AttachmentId(id),
userId,
reason);
await commandBus.PublishAsync(command, cancellationToken);
// Also delete the physical file
await fileStorage.DeleteAsync(attachment.StoragePath, cancellationToken);
return Ok(new { success = true, message = "Attachment deleted" });
}
catch (DomainException ex)
{
return BadRequest(new { error = ex.Code, message = ex.MessageDanish });
}
}
}

View file

@ -0,0 +1,105 @@
using Books.Api.Banking;
using Books.Api.Commands.BankConnections;
using Books.Api.Domain.BankConnections;
using EventFlow;
using EventFlow.Aggregates.ExecutionResults;
using Microsoft.AspNetCore.Mvc;
namespace Books.Api.Controllers;
[ApiController]
[Route("api/banking")]
public class BankingController : ControllerBase
{
private readonly ICommandBus _commandBus;
private readonly IEnableBankingClient _bankingClient;
private readonly ILogger<BankingController> _logger;
private readonly IConfiguration _configuration;
public BankingController(
ICommandBus commandBus,
IEnableBankingClient bankingClient,
ILogger<BankingController> logger,
IConfiguration configuration)
{
_commandBus = commandBus;
_bankingClient = bankingClient;
_logger = logger;
_configuration = configuration;
}
/// <summary>
/// OAuth callback from Enable Banking after user authorizes bank connection.
/// </summary>
[HttpGet("callback")]
public async Task<IActionResult> Callback(
[FromQuery] string? code,
[FromQuery] string? state,
[FromQuery] string? error,
[FromQuery] string? error_description,
CancellationToken ct)
{
var frontendBaseUrl = _configuration["Frontend:BaseUrl"] ?? "http://localhost:3000";
var redirectUrl = $"{frontendBaseUrl}/indstillinger?tab=bankAccounts";
// Handle error from bank
if (!string.IsNullOrEmpty(error))
{
_logger.LogWarning("Bank authorization failed: {Error} - {Description}", error, error_description);
return Redirect($"{redirectUrl}&error={Uri.EscapeDataString(error_description ?? error)}");
}
// Validate required parameters
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
{
_logger.LogWarning("Missing code or state in callback");
return Redirect($"{redirectUrl}&error=missing_parameters");
}
try
{
// State contains the connection ID (set during StartBankConnection)
var connectionId = state;
// Get PSU headers from HttpContext (required by Enable Banking API)
var psuIpAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var psuUserAgent = Request.Headers.UserAgent.ToString();
// Exchange authorization code for session
var session = await _bankingClient.CreateSessionAsync(code, psuIpAddress, psuUserAgent, ct);
_logger.LogInformation(
"Bank session created: {SessionId}, Bank: {Bank}, Accounts: {AccountCount}",
session.SessionId,
session.AspspName,
session.Accounts.Count);
// Complete the bank connection
var command = new EstablishBankConnectionCommand(
BankConnectionId.With(connectionId),
session.SessionId,
session.ValidUntil,
session.Accounts.Select(a => new BankAccountInfo(
a.AccountId,
a.Iban,
a.Currency,
a.AccountName)).ToList());
var result = await _commandBus.PublishAsync(command, ct);
if (result is FailedExecutionResult failed)
{
_logger.LogError("Failed to complete bank connection: {Errors}", string.Join(", ", failed.Errors));
return Redirect($"{redirectUrl}&error=completion_failed");
}
_logger.LogInformation("Bank connection {ConnectionId} completed successfully", connectionId);
return Redirect($"{redirectUrl}&success=true");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error completing bank connection");
return Redirect($"{redirectUrl}&error=internal_error");
}
}
}

View file

@ -0,0 +1,455 @@
using System.Linq;
using System.Security.Claims;
using System.Security.Cryptography;
using Books.Api.AiBookkeeper;
using Books.Api.Authorization;
using Books.Api.Commands.Attachments;
using Books.Api.Commands.JournalEntryDrafts;
using Books.Api.Domain.Attachments;
using Books.Api.Domain.JournalEntryDrafts;
using Books.Api.EventFlow.Repositories;
using Books.Api.Infrastructure.FileStorage;
using EventFlow;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Books.Api.Controllers;
/// <summary>
/// REST API for AI-powered document processing.
/// Handles document upload, AI analysis, draft creation, and bank transaction matching.
/// </summary>
[ApiController]
[Route("api/documents")]
[Authorize]
public class DocumentProcessingController(
ICommandBus commandBus,
IAiBookkeeperClient aiClient,
IChartOfAccountsProvider chartProvider,
IAccountMappingService accountMapping,
IBankTransactionMatcher transactionMatcher,
IDocumentHashRepository hashRepository,
IBankTransactionRepository bankTransactionRepository,
IVoucherNumberService voucherNumberService,
IFileStorageService fileStorage,
ICompanyAccessService companyAccess,
IWebHostEnvironment environment,
ILogger<DocumentProcessingController> logger) : ControllerBase
{
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
private static readonly HashSet<string> AllowedContentTypes =
[
"application/pdf",
"image/png",
"image/jpeg",
"image/jpg",
"image/gif"
];
/// <summary>
/// Process a document using AI and optionally match to a bank transaction.
/// </summary>
/// <param name="companyId">Company ID</param>
/// <param name="document">Document file (PDF, PNG, JPG)</param>
/// <param name="cancellationToken">Cancellation token</param>
[HttpPost("process")]
[RequestSizeLimit(MaxFileSize)]
public async Task<IActionResult> ProcessDocument(
[FromQuery] string companyId,
[FromForm] IFormFile document,
CancellationToken cancellationToken)
{
// Validate authentication
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
{
return Unauthorized(new DocumentProcessingError("NOT_AUTHENTICATED", "Du skal være logget ind"));
}
// Validate company access
var canWrite = await companyAccess.CanWriteAsync(companyId, cancellationToken);
if (!canWrite)
{
return Forbid();
}
// Validate file
if (document.Length == 0)
{
return BadRequest(new DocumentProcessingError("NO_FILE", "Ingen fil uploadet"));
}
if (document.Length > MaxFileSize)
{
return BadRequest(new DocumentProcessingError("FILE_TOO_LARGE", "Filen er for stor (maks 10MB)"));
}
if (!AllowedContentTypes.Contains(document.ContentType.ToLowerInvariant()))
{
return BadRequest(new DocumentProcessingError(
"INVALID_FILE_TYPE",
"Kun PDF og billeder (PNG, JPG) er tilladt"));
}
try
{
// 1. Calculate content hash
string contentHash;
await using (var hashStream = document.OpenReadStream())
{
contentHash = await ComputeHashAsync(hashStream, cancellationToken);
}
// 2. Check for duplicate
var existingHash = await hashRepository.GetByHashAsync(companyId, contentHash, cancellationToken);
if (existingHash != null)
{
logger.LogInformation(
"Duplicate document detected for company {CompanyId}: {Hash}",
companyId, contentHash);
return Ok(new DocumentProcessingResult
{
IsDuplicate = true,
DraftId = existingHash.DraftId,
AttachmentId = existingHash.AttachmentId,
Message = "Dokumentet er allerede behandlet"
});
}
// 3. Get chart of accounts
var chartOfAccounts = await chartProvider.GetChartOfAccountsAsync(companyId, cancellationToken);
// 4. Call AI Bookkeeper
AiBookkeeperResponse aiResponse;
await using (var aiStream = document.OpenReadStream())
{
aiResponse = await aiClient.ProcessDocumentAsync(
aiStream,
document.FileName,
document.ContentType,
chartOfAccounts,
cancellationToken);
}
if (!aiResponse.Success)
{
logger.LogWarning(
"AI Bookkeeper failed for document {FileName}: {Error}",
document.FileName, aiResponse.ErrorMessage);
return StatusCode(503, new DocumentProcessingError(
"AI_UNAVAILABLE",
aiResponse.ErrorMessage ?? "AI-tjenesten er midlertidigt utilgængelig"));
}
// 5. Store attachment
string attachmentId;
await using (var storageStream = document.OpenReadStream())
{
var storageResult = await fileStorage.StoreAsync(
companyId,
document.FileName,
document.ContentType,
storageStream,
cancellationToken);
var attId = AttachmentId.New;
attachmentId = attId.Value;
var uploadCommand = new UploadAttachmentCommand(
attId,
companyId,
storageResult.StoredFileName,
document.FileName,
document.ContentType,
storageResult.FileSize,
storageResult.StoragePath,
userId,
null); // draftId will be set later
await commandBus.PublishAsync(uploadCommand, cancellationToken);
}
// 6. Map standard accounts to company accounts
List<MappedSuggestedLine>? mappedLines = null;
if (aiResponse.Suggestion?.Lines != null)
{
mappedLines = await accountMapping.MapSuggestedLinesAsync(
companyId,
aiResponse.Suggestion.Lines,
cancellationToken);
}
// 7. Create JournalEntryDraft
var draftId = JournalEntryDraftId.New();
var voucherNumber = await voucherNumberService.GetNextVoucherNumberAsync(companyId, null, cancellationToken);
// Serialize full extraction data to preserve all AI-extracted fields
string? extractionDataJson = null;
if (aiResponse.Extraction != null)
{
extractionDataJson = System.Text.Json.JsonSerializer.Serialize(aiResponse.Extraction,
new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase });
}
var draftName = aiResponse.Extraction?.Vendor ?? document.FileName;
var createDraftCommand = new CreateJournalEntryDraftCommand(
draftId,
companyId,
draftName,
userId,
voucherNumber,
extractionDataJson);
await commandBus.PublishAsync(createDraftCommand, cancellationToken);
// Update draft with AI-suggested lines
var draftLines = CreateDraftLines(mappedLines);
var updateDraftCommand = new UpdateJournalEntryDraftCommand(
draftId,
draftName,
aiResponse.Extraction?.Date,
aiResponse.Extraction?.InvoiceNumber ?? aiResponse.Suggestion?.Description,
null, // fiscalYearId - let the system determine
draftLines,
[attachmentId]);
await commandBus.PublishAsync(updateDraftCommand, cancellationToken);
// 8. Find matching bank transaction
var matchedTransaction = await FindMatchingTransaction(
companyId,
aiResponse.Extraction?.TotalAmount,
cancellationToken);
// 9. Link draft to transaction if matched
if (matchedTransaction != null)
{
await bankTransactionRepository.UpdateStatusAsync(
matchedTransaction.Id,
"booked",
draftId.Value,
cancellationToken);
logger.LogInformation(
"Linked draft {DraftId} to bank transaction {TransactionId}",
draftId.Value, matchedTransaction.Id);
}
// 10. Save content hash
await hashRepository.InsertAsync(
companyId,
contentHash,
document.FileName,
attachmentId,
draftId.Value,
cancellationToken);
// 11. Build response
var result = new DocumentProcessingResult
{
DraftId = draftId.Value,
AttachmentId = attachmentId,
IsDuplicate = false,
Extraction = aiResponse.Extraction != null ? new ExtractionResult
{
Vendor = aiResponse.Extraction.Vendor,
VendorCvr = aiResponse.Extraction.VendorCvr,
Amount = aiResponse.Extraction.TotalAmount,
AmountExVat = aiResponse.Extraction.AmountExVat,
VatAmount = aiResponse.Extraction.VatAmount,
Date = aiResponse.Extraction.Date?.ToString("yyyy-MM-dd"),
DueDate = aiResponse.Extraction.DueDate?.ToString("yyyy-MM-dd"),
InvoiceNumber = aiResponse.Extraction.InvoiceNumber,
DocumentType = aiResponse.Extraction.DocumentType,
Currency = aiResponse.Extraction.Currency,
PaymentReference = aiResponse.Extraction.PaymentReference,
LineItems = aiResponse.Extraction.LineItems?.Select(li => new ExtractedLineItemResult
{
Description = li.Description,
Quantity = li.Quantity,
UnitPrice = li.UnitPrice,
Amount = li.Amount,
VatRate = li.VatRate
}).ToList()
} : null,
AccountSuggestion = mappedLines != null && mappedLines.Any(l => l.IsMapped)
? new AccountSuggestionResult
{
MappedAccountId = mappedLines.FirstOrDefault(l => l.IsMapped)?.MappedAccount?.Id,
MappedAccountNumber = mappedLines.FirstOrDefault(l => l.IsMapped)?.MappedAccount?.AccountNumber,
MappedAccountName = mappedLines.FirstOrDefault(l => l.IsMapped)?.MappedAccount?.Name,
Confidence = aiResponse.Suggestion?.Confidence ?? 0
}
: null,
BankTransactionMatch = matchedTransaction != null
? new BankTransactionMatchResult
{
TransactionId = matchedTransaction.Id,
Amount = matchedTransaction.Amount,
Date = matchedTransaction.TransactionDate.ToString("yyyy-MM-dd"),
Description = matchedTransaction.Description,
Counterparty = matchedTransaction.DisplayCounterparty
}
: null,
SuggestedLines = mappedLines?.Select(ml => new SuggestedJournalLine
{
AccountId = ml.MappedAccount?.Id,
AccountNumber = ml.MappedAccount?.AccountNumber,
AccountName = ml.MappedAccount?.Name ?? ml.Original.AccountName,
DebitAmount = ml.Original.DebitAmount,
CreditAmount = ml.Original.CreditAmount,
VatCode = ml.Original.VatCode
}).ToList()
};
logger.LogInformation(
"Successfully processed document {FileName} for company {CompanyId}. Draft: {DraftId}, Match: {HasMatch}",
document.FileName, companyId, draftId.Value, matchedTransaction != null);
return Ok(result);
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected error processing document {FileName}", document.FileName);
var errorMessage = environment.IsDevelopment()
? $"{ex.Message}\n\nStack trace:\n{ex.StackTrace}"
: "Der opstod en uventet fejl ved behandling af dokumentet";
return StatusCode(500, new DocumentProcessingError(
"PROCESSING_FAILED",
errorMessage));
}
}
private static async Task<string> ComputeHashAsync(Stream stream, CancellationToken cancellationToken)
{
using var sha256 = SHA256.Create();
var hashBytes = await sha256.ComputeHashAsync(stream, cancellationToken);
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
private async Task<EventFlow.ReadModels.BankTransactionDto?> FindMatchingTransaction(
string companyId,
decimal? amount,
CancellationToken cancellationToken)
{
if (!amount.HasValue || amount.Value == 0)
{
return null;
}
// For expenses (invoices to pay), the document amount is positive
// The bank transaction will be negative (money leaving the account)
// So we search for -amount
return await transactionMatcher.FindMatchingTransactionAsync(
companyId,
-amount.Value, // Negate for expense matching
0.01m,
cancellationToken);
}
private static List<DraftLine> CreateDraftLines(List<MappedSuggestedLine>? mappedLines)
{
if (mappedLines == null || mappedLines.Count == 0)
{
return [];
}
var lineNumber = 1;
var result = new List<DraftLine>();
foreach (var mapped in mappedLines)
{
result.Add(new DraftLine(
lineNumber++,
mapped.MappedAccount?.Id,
mapped.Original.DebitAmount,
mapped.Original.CreditAmount,
mapped.Original.AccountName,
mapped.Original.VatCode));
}
return result;
}
}
// Response DTOs
public class DocumentProcessingResult
{
public string? DraftId { get; set; }
public string? AttachmentId { get; set; }
public bool IsDuplicate { get; set; }
public string? Message { get; set; }
public ExtractionResult? Extraction { get; set; }
public AccountSuggestionResult? AccountSuggestion { get; set; }
public BankTransactionMatchResult? BankTransactionMatch { get; set; }
public List<SuggestedJournalLine>? SuggestedLines { get; set; }
}
public class SuggestedJournalLine
{
public string? AccountId { get; set; }
public string? AccountNumber { get; set; }
public string? AccountName { get; set; }
public decimal DebitAmount { get; set; }
public decimal CreditAmount { get; set; }
public string? VatCode { get; set; }
}
public class ExtractionResult
{
public string? Vendor { get; set; }
public string? VendorCvr { get; set; }
public decimal? Amount { get; set; }
public decimal? AmountExVat { get; set; }
public decimal? VatAmount { get; set; }
public string? Date { get; set; }
public string? DueDate { get; set; }
public string? InvoiceNumber { get; set; }
public string? DocumentType { get; set; }
public string? Currency { get; set; }
public string? PaymentReference { get; set; }
public List<ExtractedLineItemResult>? LineItems { get; set; }
}
public class ExtractedLineItemResult
{
public string? Description { get; set; }
public decimal? Quantity { get; set; }
public decimal? UnitPrice { get; set; }
public decimal? Amount { get; set; }
public decimal? VatRate { get; set; }
}
public class AccountSuggestionResult
{
public string? MappedAccountId { get; set; }
public string? MappedAccountNumber { get; set; }
public string? MappedAccountName { get; set; }
public decimal Confidence { get; set; }
}
public class BankTransactionMatchResult
{
public string? TransactionId { get; set; }
public decimal Amount { get; set; }
public string? Date { get; set; }
public string? Description { get; set; }
public string? Counterparty { get; set; }
}
public class DocumentProcessingError
{
public string Code { get; set; }
public string Message { get; set; }
public DocumentProcessingError(string code, string message)
{
Code = code;
Message = message;
}
}

View file

@ -0,0 +1,5 @@
-- Index for fiscal year date range queries including company_id for overlap checks
-- The existing idx_fiscal_year_dates from 001_Initial.sql only has (start_date, end_date)
-- This adds company_id for efficient company-scoped overlap queries
CREATE INDEX IF NOT EXISTS idx_fiscal_year_company_dates
ON fiscal_year_read_models(company_id, start_date, end_date);

View file

@ -0,0 +1,14 @@
-- Add audit fields for fiscal year state transitions
-- Tracks who and when reopened/locked fiscal years
ALTER TABLE fiscal_year_read_models
ADD COLUMN IF NOT EXISTS reopened_date TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS reopened_by VARCHAR(255),
ADD COLUMN IF NOT EXISTS locked_date TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS locked_by VARCHAR(255);
-- Add comment for documentation
COMMENT ON COLUMN fiscal_year_read_models.reopened_date IS 'Timestamp when the fiscal year was last reopened';
COMMENT ON COLUMN fiscal_year_read_models.reopened_by IS 'User who reopened the fiscal year';
COMMENT ON COLUMN fiscal_year_read_models.locked_date IS 'Timestamp when the fiscal year was locked';
COMMENT ON COLUMN fiscal_year_read_models.locked_by IS 'User who locked the fiscal year';

View file

@ -0,0 +1,33 @@
-- User Company Access table for multi-tenant authorization
-- Maps users to companies with specific roles
CREATE TABLE IF NOT EXISTS user_company_access_read_models (
aggregate_id VARCHAR(255) PRIMARY KEY,
create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_aggregate_sequence_number INT NOT NULL DEFAULT 1,
user_id VARCHAR(255) NOT NULL, -- Keycloak user ID or email
company_id VARCHAR(255) NOT NULL, -- Reference to company aggregate
role VARCHAR(50) NOT NULL, -- owner, accountant, viewer
granted_by VARCHAR(255) NOT NULL, -- Who granted this access
granted_at TIMESTAMPTZ NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
revoked_at TIMESTAMPTZ,
revoked_by VARCHAR(255),
-- Ensure unique user-company combination (only one active access per user per company)
CONSTRAINT uq_user_company_active UNIQUE (user_id, company_id),
-- Ensure valid role values
CONSTRAINT chk_role CHECK (role IN ('owner', 'accountant', 'viewer'))
);
-- Index for looking up all companies a user has access to
CREATE INDEX IF NOT EXISTS idx_user_access_user ON user_company_access_read_models(user_id) WHERE is_active = true;
-- Index for looking up all users with access to a company
CREATE INDEX IF NOT EXISTS idx_user_access_company ON user_company_access_read_models(company_id) WHERE is_active = true;
-- Composite index for efficient access checks
CREATE INDEX IF NOT EXISTS idx_user_access_check ON user_company_access_read_models(user_id, company_id, role) WHERE is_active = true;

View file

@ -0,0 +1,30 @@
-- Migration: 006_JournalEntryDrafts
-- Description: Create journal entry draft read models table for kassekladde feature
CREATE TABLE IF NOT EXISTS journal_entry_draft_read_models (
aggregate_id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
name TEXT NOT NULL,
date DATE,
description TEXT,
fiscal_year_id TEXT,
lines TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'active',
transaction_id TEXT,
created_by TEXT NOT NULL,
create_time TIMESTAMPTZ NOT NULL,
updated_time TIMESTAMPTZ NOT NULL,
last_aggregate_sequence_number INT NOT NULL
);
-- Index for efficient queries by company and status
CREATE INDEX IF NOT EXISTS idx_journal_entry_draft_company_status
ON journal_entry_draft_read_models(company_id, status);
-- Index for efficient queries by company ordered by updated time
CREATE INDEX IF NOT EXISTS idx_journal_entry_draft_company_updated
ON journal_entry_draft_read_models(company_id, updated_time DESC);
COMMENT ON TABLE journal_entry_draft_read_models IS 'Journal entry drafts (kassekladder) - work in progress entries before posting to ledger';
COMMENT ON COLUMN journal_entry_draft_read_models.status IS 'active = work in progress, posted = sent to ledger, discarded = deleted';
COMMENT ON COLUMN journal_entry_draft_read_models.lines IS 'JSON array (stored as TEXT) of draft lines with accountId, debitAmount, creditAmount, description';

View file

@ -0,0 +1,34 @@
-- Migration: 007_BankConnections
-- Description: Create bank connection read models table for Open Banking integration
CREATE TABLE IF NOT EXISTS bank_connection_read_models (
aggregate_id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
aspsp_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'initiated',
session_id TEXT,
valid_until TIMESTAMPTZ,
accounts_json TEXT,
failure_reason TEXT,
create_time TIMESTAMPTZ NOT NULL,
updated_time TIMESTAMPTZ NOT NULL,
last_aggregate_sequence_number INT NOT NULL
);
-- Index for efficient queries by company
CREATE INDEX IF NOT EXISTS idx_bank_connection_company
ON bank_connection_read_models(company_id);
-- Index for efficient queries by company and status
CREATE INDEX IF NOT EXISTS idx_bank_connection_company_status
ON bank_connection_read_models(company_id, status);
-- Index for finding active connections (established and not expired)
CREATE INDEX IF NOT EXISTS idx_bank_connection_active
ON bank_connection_read_models(company_id, status, valid_until)
WHERE status = 'established';
COMMENT ON TABLE bank_connection_read_models IS 'Bank connections via Enable Banking Open Banking API';
COMMENT ON COLUMN bank_connection_read_models.status IS 'initiated = OAuth started, established = active connection, failed = authorization failed, disconnected = user disconnected';
COMMENT ON COLUMN bank_connection_read_models.accounts_json IS 'JSON array of available bank accounts from the connection';
COMMENT ON COLUMN bank_connection_read_models.session_id IS 'Enable Banking session ID - used for API calls (not exposed to client)';

View file

@ -0,0 +1,45 @@
-- Migration: 007_JournalEntryDraftCompliance
-- Description: Add Danish accounting law compliance fields to journal entry drafts
-- - VoucherNumber (Bilagsnummer) - required by Bogføringsloven § 7, Stk. 4
-- - AttachmentIds - for document references required by Bogføringsloven § 6
-- - DocumentDate (Bilagsdato) - renamed from generic "date" for clarity
-- - Voucher sequence table for auto-generation of bilagsnumre
-- Add voucher_number column to existing table
ALTER TABLE journal_entry_draft_read_models
ADD COLUMN IF NOT EXISTS voucher_number TEXT NOT NULL DEFAULT '';
-- Add attachment_ids column (JSON array of references)
ALTER TABLE journal_entry_draft_read_models
ADD COLUMN IF NOT EXISTS attachment_ids TEXT NOT NULL DEFAULT '[]';
-- Rename date column to document_date (Bilagsdato) for semantic clarity
-- document_date = the date on the source document (e.g., invoice date)
-- This is different from posting_date which is when it's booked in the ledger
ALTER TABLE journal_entry_draft_read_models
RENAME COLUMN date TO document_date;
-- Create voucher number sequence table per company/fiscal year
-- This ensures unique, sequential bilagsnumre as required by law
CREATE TABLE IF NOT EXISTS voucher_number_sequences (
company_id TEXT NOT NULL,
fiscal_year_id TEXT NOT NULL,
last_number INT NOT NULL DEFAULT 0,
prefix TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (company_id, fiscal_year_id)
);
-- Index for efficient lookup
CREATE INDEX IF NOT EXISTS idx_voucher_sequence_company
ON voucher_number_sequences(company_id);
-- Index for efficient queries by voucher number
CREATE INDEX IF NOT EXISTS idx_journal_entry_draft_voucher
ON journal_entry_draft_read_models(company_id, voucher_number);
COMMENT ON TABLE voucher_number_sequences IS 'Sequence generator for bilagsnumre (voucher numbers) per company and fiscal year';
COMMENT ON COLUMN voucher_number_sequences.prefix IS 'Optional prefix for voucher numbers, e.g. "2025-" for year-based numbering';
COMMENT ON COLUMN journal_entry_draft_read_models.voucher_number IS 'Bilagsnummer - unique document number required by Bogføringsloven § 7, Stk. 4';
COMMENT ON COLUMN journal_entry_draft_read_models.attachment_ids IS 'JSON array of attachment IDs for document references required by Bogføringsloven § 6';

View file

@ -0,0 +1,20 @@
-- Migration: 008_FixJournalEntryDraftColumns
-- Description: Fix column types for journal_entry_draft_read_models
-- - Change lines from jsonb to text (matches C# string serialization)
-- - Rename date to document_date if not already done
-- Fix lines column type (EventFlow may have created it as jsonb)
ALTER TABLE journal_entry_draft_read_models
ALTER COLUMN lines TYPE text USING lines::text;
-- Ensure document_date column exists (rename from date if needed)
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'journal_entry_draft_read_models'
AND column_name = 'date'
) THEN
ALTER TABLE journal_entry_draft_read_models RENAME COLUMN date TO document_date;
END IF;
END $$;

View file

@ -0,0 +1,63 @@
-- Migration: 009_BankTransactions
-- Description: Create bank_transactions table for storing synced transactions from Enable Banking
-- Used by BankTransactionSyncJob (Hangfire) and displayed in Hurtig Bogføring
CREATE TABLE IF NOT EXISTS bank_transactions (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
bank_connection_id TEXT NOT NULL,
bank_account_id TEXT NOT NULL,
external_id TEXT NOT NULL, -- Transaction ID from Enable Banking (for idempotency)
-- Amount and currency
amount DECIMAL(18,2) NOT NULL,
currency TEXT NOT NULL DEFAULT 'DKK',
-- Dates
transaction_date DATE NOT NULL,
booking_date DATE,
value_date DATE,
-- Transaction details
description TEXT,
counterparty_name TEXT,
counterparty_account TEXT,
reference TEXT,
creditor_name TEXT,
debtor_name TEXT,
-- Status tracking
status TEXT NOT NULL DEFAULT 'pending', -- pending | booked | ignored
journal_entry_draft_id TEXT, -- Reference to kassekladde when booked
-- Raw data for debugging/auditing
raw_data JSONB,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Ensure no duplicate transactions per company
UNIQUE(company_id, external_id)
);
-- Index for fetching pending transactions by company (main query)
CREATE INDEX IF NOT EXISTS idx_bank_tx_company_status
ON bank_transactions(company_id, status);
-- Index for date-based queries
CREATE INDEX IF NOT EXISTS idx_bank_tx_company_date
ON bank_transactions(company_id, transaction_date DESC);
-- Index for bank account filtering
CREATE INDEX IF NOT EXISTS idx_bank_tx_bank_account
ON bank_transactions(bank_account_id, status);
-- Index for checking existing transactions during sync
CREATE INDEX IF NOT EXISTS idx_bank_tx_external_id
ON bank_transactions(company_id, external_id);
COMMENT ON TABLE bank_transactions IS 'Bank transactions synced from Enable Banking API via Hangfire job';
COMMENT ON COLUMN bank_transactions.external_id IS 'Unique transaction ID from Enable Banking, used for deduplication';
COMMENT ON COLUMN bank_transactions.status IS 'pending = not yet booked, booked = linked to journal entry, ignored = manually skipped';
COMMENT ON COLUMN bank_transactions.journal_entry_draft_id IS 'Reference to journal_entry_draft_read_models.aggregate_id when booked';

View file

@ -0,0 +1,28 @@
-- 010_SyncFiscalYearsToLedger.sql
-- Syncs existing fiscal years from Books.Api to Ledger's accounting_periods table.
-- Required because the LedgerPeriodSyncSubscriber was added after fiscal years were created.
-- Only run if both tables exist (Ledger schema must be set up first)
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'accounting_periods')
AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'fiscal_year_read_models') THEN
-- Extract GUID from aggregate_id (format: "fiscalyear-{guid}")
INSERT INTO accounting_periods (id, name, start_date, end_date, is_locked, created_at)
SELECT
uuid(substring(aggregate_id from 12))::uuid as id,
name,
start_date,
end_date,
(status = 'locked') as is_locked,
create_time as created_at
FROM fiscal_year_read_models
WHERE aggregate_id LIKE 'fiscalyear-%'
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
start_date = EXCLUDED.start_date,
end_date = EXCLUDED.end_date,
is_locked = EXCLUDED.is_locked;
END IF;
END $$;

View file

@ -0,0 +1,84 @@
-- 011_Customers.sql
-- Creates customer read model tables and number series for invoicing support
-- Supports B2B (business with CVR) and B2C (private) customers
-- Each customer gets a sub-ledger account under 1900 Debitorer
-- Customer read models
CREATE TABLE IF NOT EXISTS customer_read_models (
-- EventFlow standard columns
aggregate_id VARCHAR(255) PRIMARY KEY,
create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_aggregate_sequence_number INT NOT NULL DEFAULT 1,
-- Business columns (snake_case)
company_id VARCHAR(255) NOT NULL,
customer_number VARCHAR(20) NOT NULL,
customer_type VARCHAR(20) NOT NULL, -- 'Business' or 'Private'
name VARCHAR(255) NOT NULL,
cvr VARCHAR(8), -- Required for Business, optional for Private
address VARCHAR(500),
postal_code VARCHAR(10),
city VARCHAR(100),
country VARCHAR(2) NOT NULL DEFAULT 'DK',
email VARCHAR(255),
phone VARCHAR(50),
payment_terms_days INT NOT NULL DEFAULT 30,
default_revenue_account_id VARCHAR(255),
sub_ledger_account_id VARCHAR(255) NOT NULL, -- Auto-created 1900-XXXX account
is_active BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT fk_customer_company
FOREIGN KEY (company_id) REFERENCES company_read_models(aggregate_id) ON DELETE CASCADE,
CONSTRAINT chk_customer_type
CHECK (customer_type IN ('Business', 'Private')),
CONSTRAINT chk_payment_terms
CHECK (payment_terms_days >= 0 AND payment_terms_days <= 365)
);
-- Unique customer number per company
CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_number
ON customer_read_models(company_id, customer_number);
-- Lookup by company
CREATE INDEX IF NOT EXISTS idx_customer_company
ON customer_read_models(company_id);
-- Lookup by CVR (for B2B customers)
CREATE INDEX IF NOT EXISTS idx_customer_cvr
ON customer_read_models(cvr) WHERE cvr IS NOT NULL;
-- Search by name
CREATE INDEX IF NOT EXISTS idx_customer_name
ON customer_read_models(company_id, name);
-- Filter active customers
CREATE INDEX IF NOT EXISTS idx_customer_active
ON customer_read_models(company_id, is_active) WHERE is_active = TRUE;
-- Number series for sequential numbering (customers, invoices, credit notes)
-- Uses atomic UPSERT for thread-safe number generation
CREATE TABLE IF NOT EXISTS number_series (
id SERIAL PRIMARY KEY,
company_id VARCHAR(255) NOT NULL,
sequence_key VARCHAR(100) NOT NULL, -- e.g., 'customer', 'invoice-2024', 'creditnote-2024'
last_number INT NOT NULL DEFAULT 0,
prefix VARCHAR(20), -- e.g., 'INV-2024-', 'CN-2024-', ''
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(company_id, sequence_key),
CONSTRAINT fk_number_series_company
FOREIGN KEY (company_id) REFERENCES company_read_models(aggregate_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_number_series_company
ON number_series(company_id);
-- Comments for documentation
COMMENT ON TABLE customer_read_models IS 'Customer master data supporting B2B and B2C customers';
COMMENT ON COLUMN customer_read_models.customer_type IS 'Business (requires CVR) or Private (no CVR required)';
COMMENT ON COLUMN customer_read_models.sub_ledger_account_id IS 'Auto-created sub-ledger account (1900-XXXX) for customer receivables';
COMMENT ON COLUMN customer_read_models.payment_terms_days IS 'Default payment terms in days, used to calculate invoice due dates';
COMMENT ON TABLE number_series IS 'Sequential number generation for customers, invoices, and credit notes (Momsloven §52)';
COMMENT ON COLUMN number_series.sequence_key IS 'Identifies the sequence: customer, invoice-YYYY, creditnote-YYYY';

View file

@ -0,0 +1,86 @@
-- Migration: 012_Invoices.sql
-- Description: Invoice read model for invoicing system
-- Date: 2026-01-18
-- Phase: 2 of Invoicing Implementation
-- Invoice read model table
CREATE TABLE IF NOT EXISTS invoice_read_models (
-- EventFlow standard fields
aggregate_id VARCHAR(255) PRIMARY KEY,
last_aggregate_sequence_number BIGINT NOT NULL DEFAULT 0,
create_time TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
update_time TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Company and fiscal year
company_id VARCHAR(255) NOT NULL,
fiscal_year_id VARCHAR(255),
-- Customer reference
customer_id VARCHAR(255) NOT NULL,
customer_name VARCHAR(255) NOT NULL,
customer_number VARCHAR(20) NOT NULL,
-- Invoice identification (Momsloven §52: Sequential per year)
invoice_number VARCHAR(50) NOT NULL,
invoice_date DATE,
due_date DATE,
-- Status: draft, sent, partially_paid, paid, voided
status VARCHAR(20) NOT NULL DEFAULT 'draft',
-- Amounts (calculated from lines)
amount_ex_vat DECIMAL(18, 2) NOT NULL DEFAULT 0,
amount_vat DECIMAL(18, 2) NOT NULL DEFAULT 0,
amount_total DECIMAL(18, 2) NOT NULL DEFAULT 0,
amount_paid DECIMAL(18, 2) NOT NULL DEFAULT 0,
amount_remaining DECIMAL(18, 2) NOT NULL DEFAULT 0,
-- Currency (default DKK)
currency VARCHAR(3) NOT NULL DEFAULT 'DKK',
-- VAT settings
vat_code VARCHAR(20),
-- Payment terms
payment_terms_days INT NOT NULL DEFAULT 30,
-- Invoice lines as JSONB for flexibility
-- Structure: [{ lineNumber, description, quantity, unitPrice, vatCode, amountExVat, amountVat, amountTotal, accountId }]
lines JSONB NOT NULL DEFAULT '[]',
-- Ledger integration
ledger_transaction_id VARCHAR(255),
-- Notes and reference
notes TEXT,
reference VARCHAR(255),
-- Timestamps for status changes
sent_at TIMESTAMP WITH TIME ZONE,
paid_at TIMESTAMP WITH TIME ZONE,
voided_at TIMESTAMP WITH TIME ZONE,
voided_reason TEXT,
voided_by VARCHAR(255),
-- Audit
created_by VARCHAR(255) NOT NULL,
updated_by VARCHAR(255)
);
-- Indexes for common queries
CREATE INDEX IF NOT EXISTS idx_invoice_company_id ON invoice_read_models(company_id);
CREATE INDEX IF NOT EXISTS idx_invoice_customer_id ON invoice_read_models(customer_id);
CREATE INDEX IF NOT EXISTS idx_invoice_status ON invoice_read_models(status);
CREATE INDEX IF NOT EXISTS idx_invoice_fiscal_year_id ON invoice_read_models(fiscal_year_id);
CREATE INDEX IF NOT EXISTS idx_invoice_number ON invoice_read_models(company_id, invoice_number);
CREATE INDEX IF NOT EXISTS idx_invoice_date ON invoice_read_models(invoice_date);
CREATE INDEX IF NOT EXISTS idx_invoice_due_date ON invoice_read_models(due_date);
-- Unique constraint on invoice number per company (Momsloven §52 compliance)
CREATE UNIQUE INDEX IF NOT EXISTS idx_invoice_number_unique ON invoice_read_models(company_id, invoice_number);
-- Comment explaining the table
COMMENT ON TABLE invoice_read_models IS 'Invoice read model for the Books accounting system. Supports B2B and B2C invoicing with Danish tax compliance (Momsloven §52).';
COMMENT ON COLUMN invoice_read_models.invoice_number IS 'Sequential invoice number per year, format: INV-YYYY-NNNN (Momsloven §52)';
COMMENT ON COLUMN invoice_read_models.lines IS 'Invoice lines as JSONB array with lineNumber, description, quantity, unitPrice, vatCode, amounts, accountId';
COMMENT ON COLUMN invoice_read_models.ledger_transaction_id IS 'Reference to the Ledger transaction when invoice is sent/posted';

View file

@ -0,0 +1,84 @@
-- Migration: 013_CreditNotes.sql
-- Description: Credit notes for reversing/correcting invoices
-- Date: 2026-01-18
-- Credit note read models
CREATE TABLE IF NOT EXISTS credit_note_read_models (
aggregate_id VARCHAR(255) PRIMARY KEY,
company_id VARCHAR(255) NOT NULL,
fiscal_year_id VARCHAR(255),
customer_id VARCHAR(255) NOT NULL,
customer_name VARCHAR(255) NOT NULL,
credit_note_number VARCHAR(50) NOT NULL,
original_invoice_id VARCHAR(255),
original_invoice_number VARCHAR(50),
credit_note_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'Draft',
-- Amounts
amount_ex_vat DECIMAL(18, 2) NOT NULL DEFAULT 0,
amount_vat DECIMAL(18, 2) NOT NULL DEFAULT 0,
amount_total DECIMAL(18, 2) NOT NULL DEFAULT 0,
amount_applied DECIMAL(18, 2) NOT NULL DEFAULT 0,
amount_remaining DECIMAL(18, 2) NOT NULL DEFAULT 0,
-- Lines stored as JSONB for flexibility
lines JSONB NOT NULL DEFAULT '[]'::jsonb,
-- Ledger integration
ledger_transaction_id VARCHAR(255),
-- Audit fields
reason TEXT,
issued_at TIMESTAMP WITH TIME ZONE,
voided_at TIMESTAMP WITH TIME ZONE,
voided_reason TEXT,
-- EventFlow fields
create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
update_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
last_aggregate_sequence_number BIGINT NOT NULL DEFAULT 0
);
-- Indexes for common queries
CREATE INDEX IF NOT EXISTS idx_credit_note_read_models_company_id
ON credit_note_read_models(company_id);
CREATE INDEX IF NOT EXISTS idx_credit_note_read_models_customer_id
ON credit_note_read_models(customer_id);
CREATE INDEX IF NOT EXISTS idx_credit_note_read_models_status
ON credit_note_read_models(status);
CREATE INDEX IF NOT EXISTS idx_credit_note_read_models_original_invoice
ON credit_note_read_models(original_invoice_id);
CREATE INDEX IF NOT EXISTS idx_credit_note_read_models_date
ON credit_note_read_models(credit_note_date);
-- Unique constraint: credit note number must be unique per company
CREATE UNIQUE INDEX IF NOT EXISTS idx_credit_note_read_models_number_unique
ON credit_note_read_models(company_id, credit_note_number);
-- Credit note applications (when credit is applied to invoices)
CREATE TABLE IF NOT EXISTS credit_note_applications (
id TEXT PRIMARY KEY,
company_id VARCHAR(255) NOT NULL,
credit_note_id VARCHAR(255) NOT NULL,
invoice_id VARCHAR(255) NOT NULL,
amount DECIMAL(18, 2) NOT NULL,
applied_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
applied_by VARCHAR(255) NOT NULL,
ledger_transaction_id VARCHAR(255),
CONSTRAINT fk_credit_note_applications_credit_note
FOREIGN KEY (credit_note_id) REFERENCES credit_note_read_models(aggregate_id),
CONSTRAINT fk_credit_note_applications_invoice
FOREIGN KEY (invoice_id) REFERENCES invoice_read_models(aggregate_id)
);
CREATE INDEX IF NOT EXISTS idx_credit_note_applications_credit_note
ON credit_note_applications(credit_note_id);
CREATE INDEX IF NOT EXISTS idx_credit_note_applications_invoice
ON credit_note_applications(invoice_id);

View file

@ -0,0 +1,104 @@
-- Migration: 014_PaymentAllocations.sql
-- Description: Payment allocations and suggested matches for bank reconciliation
-- Date: 2026-01-18
-- Phase: 3 of Invoicing Implementation
-- Payment allocations table
-- Records confirmed matches between bank transactions and invoices/credit notes
CREATE TABLE IF NOT EXISTS payment_allocations (
id TEXT PRIMARY KEY,
company_id VARCHAR(255) NOT NULL,
-- Source: bank transaction
bank_transaction_id VARCHAR(255) NOT NULL,
-- Target: invoice or credit note (one must be set)
invoice_id VARCHAR(255),
credit_note_id VARCHAR(255),
-- Allocation details
amount DECIMAL(18, 2) NOT NULL,
allocation_type VARCHAR(20) NOT NULL, -- 'payment', 'credit_note', 'refund'
-- Match metadata
match_method VARCHAR(50) NOT NULL, -- 'manual', 'auto_amount', 'auto_reference', 'auto_name'
match_confidence DECIMAL(3, 2), -- 0.00 to 1.00
-- Ledger integration
ledger_transaction_id VARCHAR(255),
-- Audit
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by VARCHAR(255) NOT NULL,
-- Constraints
CONSTRAINT chk_allocation_target CHECK (
(invoice_id IS NOT NULL AND credit_note_id IS NULL) OR
(invoice_id IS NULL AND credit_note_id IS NOT NULL)
),
CONSTRAINT chk_allocation_type CHECK (
allocation_type IN ('payment', 'credit_note', 'refund')
)
);
-- Indexes for payment allocations
CREATE INDEX IF NOT EXISTS idx_payment_allocation_company ON payment_allocations(company_id);
CREATE INDEX IF NOT EXISTS idx_payment_allocation_bank_tx ON payment_allocations(bank_transaction_id);
CREATE INDEX IF NOT EXISTS idx_payment_allocation_invoice ON payment_allocations(invoice_id) WHERE invoice_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_payment_allocation_credit_note ON payment_allocations(credit_note_id) WHERE credit_note_id IS NOT NULL;
-- Unique constraint: one allocation per bank transaction per invoice
CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_allocation_unique
ON payment_allocations(bank_transaction_id, invoice_id)
WHERE invoice_id IS NOT NULL;
-- Suggested payment matches table
-- Stores AI/algorithm suggested matches for user review
CREATE TABLE IF NOT EXISTS suggested_payment_matches (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
company_id VARCHAR(255) NOT NULL,
-- Source: bank transaction
bank_transaction_id VARCHAR(255) NOT NULL,
-- Target: invoice
invoice_id VARCHAR(255) NOT NULL,
-- Match scoring
confidence DECIMAL(3, 2) NOT NULL, -- 0.00 to 1.00
match_reasons JSONB DEFAULT '[]', -- Array of { reason, score }
suggested_amount DECIMAL(18, 2) NOT NULL,
-- Status tracking
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- 'pending', 'accepted', 'rejected', 'expired'
-- Timestamps
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
reviewed_at TIMESTAMP WITH TIME ZONE,
reviewed_by VARCHAR(255),
-- Constraints
CONSTRAINT chk_match_status CHECK (
status IN ('pending', 'accepted', 'rejected', 'expired')
),
CONSTRAINT chk_confidence_range CHECK (
confidence >= 0 AND confidence <= 1
)
);
-- Indexes for suggested matches
CREATE INDEX IF NOT EXISTS idx_suggested_match_company ON suggested_payment_matches(company_id);
CREATE INDEX IF NOT EXISTS idx_suggested_match_bank_tx ON suggested_payment_matches(bank_transaction_id);
CREATE INDEX IF NOT EXISTS idx_suggested_match_invoice ON suggested_payment_matches(invoice_id);
CREATE INDEX IF NOT EXISTS idx_suggested_match_status ON suggested_payment_matches(status);
CREATE INDEX IF NOT EXISTS idx_suggested_match_confidence ON suggested_payment_matches(confidence DESC);
-- Unique constraint: one pending suggestion per bank transaction per invoice
CREATE UNIQUE INDEX IF NOT EXISTS idx_suggested_match_unique
ON suggested_payment_matches(bank_transaction_id, invoice_id)
WHERE status = 'pending';
-- Comments
COMMENT ON TABLE payment_allocations IS 'Confirmed matches between bank transactions and invoices/credit notes';
COMMENT ON TABLE suggested_payment_matches IS 'AI/algorithm suggested matches for bank reconciliation';
COMMENT ON COLUMN suggested_payment_matches.match_reasons IS 'JSON array of matching reasons: [{"reason": "exact_amount", "score": 0.8}, {"reason": "reference_match", "score": 0.15}]';

View file

@ -0,0 +1,17 @@
-- 015_AddStandardAccountNumber.sql
-- Adds Erhvervsstyrelsens standardkontonummer field for SAF-T 2.0 compliance
-- Required by January 2027 per Danish bookkeeping law (bogføringsloven)
-- Add the standard_account_number column
ALTER TABLE account_read_models
ADD COLUMN IF NOT EXISTS standard_account_number VARCHAR(10);
-- Create index for efficient lookups by standard account number
-- Useful for SAF-T export and reporting
CREATE INDEX IF NOT EXISTS idx_account_standard_number
ON account_read_models(company_id, standard_account_number)
WHERE standard_account_number IS NOT NULL;
-- Add comment for documentation
COMMENT ON COLUMN account_read_models.standard_account_number IS
'Erhvervsstyrelsens standardkontonummer for SAF-T rapportering. Mapping til officiel dansk kontoplan.';

View file

@ -0,0 +1,15 @@
-- 016_CompanyBankDetails.sql
-- Adds bank account details to company read models for invoice payment information.
ALTER TABLE company_read_models
ADD COLUMN IF NOT EXISTS bank_name VARCHAR(100),
ADD COLUMN IF NOT EXISTS bank_reg_no VARCHAR(10),
ADD COLUMN IF NOT EXISTS bank_account_no VARCHAR(20),
ADD COLUMN IF NOT EXISTS bank_iban VARCHAR(34),
ADD COLUMN IF NOT EXISTS bank_bic VARCHAR(11);
COMMENT ON COLUMN company_read_models.bank_name IS 'Bank name for payment information';
COMMENT ON COLUMN company_read_models.bank_reg_no IS 'Danish bank registration number (4 digits)';
COMMENT ON COLUMN company_read_models.bank_account_no IS 'Danish bank account number (7-10 digits)';
COMMENT ON COLUMN company_read_models.bank_iban IS 'International Bank Account Number';
COMMENT ON COLUMN company_read_models.bank_bic IS 'Bank Identifier Code / SWIFT code';

View file

@ -0,0 +1,10 @@
-- Add is_archived column to bank_connection_read_models table
-- This allows users to hide old/unused bank connections from the UI
ALTER TABLE bank_connection_read_models
ADD COLUMN IF NOT EXISTS is_archived BOOLEAN NOT NULL DEFAULT FALSE;
-- Create index for efficient filtering of non-archived connections
CREATE INDEX IF NOT EXISTS idx_bank_connection_read_models_company_archived
ON bank_connection_read_models (company_id, is_archived)
WHERE is_archived = FALSE;

View file

@ -0,0 +1,140 @@
-- Migration: 018_InvoiceTypeConsolidation.sql
-- Description: Consolidate credit notes into invoice table with type discrimination
-- Date: 2026-01-19
-- Breaking change: Credit notes become invoices with type='credit_note'
-- Add invoice type discrimination column
ALTER TABLE invoice_read_models
ADD COLUMN IF NOT EXISTS invoice_type VARCHAR(20) NOT NULL DEFAULT 'invoice';
-- Add credit note specific columns
ALTER TABLE invoice_read_models
ADD COLUMN IF NOT EXISTS original_invoice_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS original_invoice_number VARCHAR(50),
ADD COLUMN IF NOT EXISTS credit_reason TEXT,
ADD COLUMN IF NOT EXISTS amount_applied DECIMAL(18, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS issued_at TIMESTAMP WITH TIME ZONE;
-- Create index for invoice type filtering
CREATE INDEX IF NOT EXISTS idx_invoice_type ON invoice_read_models(invoice_type);
-- Create index for credit note -> original invoice lookup
CREATE INDEX IF NOT EXISTS idx_invoice_original_invoice ON invoice_read_models(original_invoice_id)
WHERE original_invoice_id IS NOT NULL;
-- Migrate existing credit notes to invoice table
-- Note: This assumes credit_note_read_models table exists
DO $$
BEGIN
IF EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'credit_note_read_models'
) THEN
-- Insert credit notes as invoices with type='credit_note'
INSERT INTO invoice_read_models (
aggregate_id,
last_aggregate_sequence_number,
create_time,
update_time,
company_id,
fiscal_year_id,
customer_id,
customer_name,
customer_number,
invoice_number, -- Using credit_note_number
invoice_date, -- Using credit_note_date
due_date, -- Same as invoice_date for credit notes
status,
amount_ex_vat,
amount_vat,
amount_total,
amount_paid,
amount_remaining,
currency,
vat_code,
payment_terms_days,
lines,
ledger_transaction_id,
notes,
reference,
sent_at,
voided_at,
voided_reason,
created_by,
-- New credit note specific columns
invoice_type,
original_invoice_id,
original_invoice_number,
credit_reason,
amount_applied,
issued_at
)
SELECT
aggregate_id,
last_aggregate_sequence_number,
create_time,
update_time,
company_id,
fiscal_year_id,
customer_id,
customer_name,
'', -- customer_number not in credit notes, will need to be populated
credit_note_number,
credit_note_date,
credit_note_date, -- due_date = issue date for credit notes
-- Map credit note status to invoice status
CASE status
WHEN 'Draft' THEN 'draft'
WHEN 'Issued' THEN 'issued'
WHEN 'PartiallyApplied' THEN 'partially_applied'
WHEN 'FullyApplied' THEN 'fully_applied'
WHEN 'Voided' THEN 'voided'
ELSE LOWER(status)
END,
-- Amounts are negative for credit notes
-ABS(amount_ex_vat),
-ABS(amount_vat),
-ABS(amount_total),
0, -- amount_paid (credit notes use amount_applied instead)
-ABS(amount_remaining),
'DKK', -- Default currency
NULL, -- vat_code
0, -- payment_terms_days
lines,
ledger_transaction_id,
reason, -- notes = reason
NULL, -- reference
issued_at, -- sent_at = issued_at for credit notes
voided_at,
voided_reason,
'system', -- created_by (not tracked in old model)
-- Credit note specific
'credit_note',
original_invoice_id,
original_invoice_number,
reason,
amount_applied,
issued_at
FROM credit_note_read_models
ON CONFLICT (aggregate_id) DO NOTHING;
RAISE NOTICE 'Migrated credit notes to invoice table';
END IF;
END $$;
-- Update credit_note_applications to reference invoice table
-- (keeping for historical reference, can be dropped after verification)
COMMENT ON TABLE credit_note_applications IS 'DEPRECATED: Credit note applications are now tracked via invoice events. Table kept for migration reference.';
-- Drop credit note read models table after successful migration
-- Uncomment these after verifying migration success:
-- DROP TABLE IF EXISTS credit_note_applications;
-- DROP TABLE IF EXISTS credit_note_read_models;
-- Add comment explaining the consolidation
COMMENT ON COLUMN invoice_read_models.invoice_type IS 'Document type: invoice or credit_note';
COMMENT ON COLUMN invoice_read_models.original_invoice_id IS 'For credit notes: reference to the original invoice being credited';
COMMENT ON COLUMN invoice_read_models.original_invoice_number IS 'For credit notes: the invoice number of the original invoice';
COMMENT ON COLUMN invoice_read_models.credit_reason IS 'For credit notes: reason for issuing the credit';
COMMENT ON COLUMN invoice_read_models.amount_applied IS 'For credit notes: total credit amount applied to invoices';
COMMENT ON COLUMN invoice_read_models.issued_at IS 'For credit notes: when the credit note was issued (equivalent to sent_at for invoices)';

View file

@ -0,0 +1,6 @@
-- Migration: 019_FixInvoiceLinesColumn
-- Description: Change lines column from jsonb to text (matches C# string serialization)
-- This is required because Dapper doesn't automatically convert string to jsonb.
ALTER TABLE invoice_read_models
ALTER COLUMN lines TYPE text USING lines::text;

View file

@ -0,0 +1,29 @@
-- Migration: 020_Products
-- Description: Create product_read_models table for product catalog
CREATE TABLE IF NOT EXISTS product_read_models (
aggregate_id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
product_number TEXT,
name TEXT NOT NULL,
description TEXT,
unit_price DECIMAL(18,2) NOT NULL DEFAULT 0,
vat_code TEXT NOT NULL DEFAULT 'U25',
unit TEXT,
default_account_id TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
update_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_aggregate_sequence_number INTEGER NOT NULL DEFAULT 0
);
-- Index for company queries
CREATE INDEX IF NOT EXISTS idx_product_company ON product_read_models(company_id);
-- Unique constraint on product_number within a company (when product_number is not null)
CREATE UNIQUE INDEX IF NOT EXISTS idx_product_number_unique ON product_read_models(company_id, product_number)
WHERE product_number IS NOT NULL;
-- Index for active products
CREATE INDEX IF NOT EXISTS idx_product_active ON product_read_models(company_id, is_active)
WHERE is_active = TRUE;

View file

@ -0,0 +1,14 @@
-- Add EAN (barcode) and Manufacturer fields to products
-- Manufacturer will be used for autocomplete across products in a company
ALTER TABLE product_read_models
ADD COLUMN IF NOT EXISTS ean TEXT,
ADD COLUMN IF NOT EXISTS manufacturer TEXT;
-- Index for EAN lookup (useful for scanning barcodes)
CREATE INDEX IF NOT EXISTS idx_product_ean
ON product_read_models (company_id, ean) WHERE ean IS NOT NULL;
-- Index for manufacturer autocomplete
CREATE INDEX IF NOT EXISTS idx_product_manufacturer
ON product_read_models (company_id, manufacturer) WHERE manufacturer IS NOT NULL;

View file

@ -0,0 +1,11 @@
-- 022_DropPeriodNameUniqueConstraint.sql
-- Fixes multi-tenant issue where multiple companies can have periods with same name.
-- The Ledger's accounting_periods table had a global unique constraint on 'name',
-- but in a multi-tenant system each company needs their own "2025", "2026" etc. periods.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'accounting_periods') THEN
ALTER TABLE accounting_periods DROP CONSTRAINT IF EXISTS uq_period_name;
END IF;
END $$;

View file

@ -0,0 +1,25 @@
-- 023_ResyncFiscalYearsToLedger.sql
-- Re-syncs all fiscal years that are missing from accounting_periods.
-- This fixes periods that failed to sync due to the unique constraint on name
-- (which was removed in migration 022).
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'accounting_periods')
AND EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'fiscal_year_read_models') THEN
-- Insert any missing fiscal years
INSERT INTO accounting_periods (id, name, start_date, end_date, is_locked, created_at)
SELECT
uuid(substring(aggregate_id from 12))::uuid as id,
name,
start_date,
end_date,
(status = 'locked') as is_locked,
create_time as created_at
FROM fiscal_year_read_models
WHERE aggregate_id LIKE 'fiscalyear-%'
AND uuid(substring(aggregate_id from 12))::uuid NOT IN (SELECT id FROM accounting_periods)
ON CONFLICT (id) DO NOTHING;
END IF;
END $$;

View file

@ -0,0 +1,17 @@
-- 024_CleanOrphanIdempotencyKeys.sql
-- Removes orphaned idempotency keys for invoices that were never successfully posted.
-- These occur when the process_transaction function fails AFTER inserting the idempotency key
-- but BEFORE creating ledger entries (e.g., due to missing period).
-- Only run if the processed_transactions table exists (it's in ledger schema)
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'processed_transactions') THEN
DELETE FROM processed_transactions pt
WHERE pt.idempotency_key LIKE 'invoice-send-%'
AND NOT EXISTS (
SELECT 1 FROM ledger_entries le
WHERE le.idempotency_key = pt.idempotency_key
);
END IF;
END $$;

View file

@ -0,0 +1,92 @@
-- Migration: 025_Orders
-- Description: Create order_read_models table for order management
CREATE TABLE IF NOT EXISTS order_read_models (
-- EventFlow standard columns
aggregate_id TEXT PRIMARY KEY,
create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
update_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
last_aggregate_sequence_number INTEGER NOT NULL DEFAULT 0,
-- Company and fiscal year
company_id TEXT NOT NULL,
fiscal_year_id TEXT NOT NULL,
-- Customer reference
customer_id TEXT NOT NULL,
customer_name TEXT NOT NULL,
customer_number TEXT NOT NULL,
-- Order identification
order_number TEXT NOT NULL,
order_date DATE,
expected_delivery_date DATE,
-- Status: draft, confirmed, partially_invoiced, fully_invoiced, cancelled
status TEXT NOT NULL DEFAULT 'draft',
-- Amounts (set when confirmed)
amount_ex_vat DECIMAL(18, 2) NOT NULL DEFAULT 0,
amount_vat DECIMAL(18, 2) NOT NULL DEFAULT 0,
amount_total DECIMAL(18, 2) NOT NULL DEFAULT 0,
-- Currency
currency TEXT NOT NULL DEFAULT 'DKK',
-- Lines (stored as JSON, includes invoicing status per line)
lines TEXT NOT NULL DEFAULT '[]',
-- Invoice reference (latest invoice created from this order)
invoice_id TEXT,
invoice_number TEXT,
-- Notes and reference
notes TEXT,
reference TEXT,
-- Status timestamps
confirmed_at TIMESTAMP WITH TIME ZONE,
confirmed_by TEXT,
invoiced_at TIMESTAMP WITH TIME ZONE,
invoiced_by TEXT,
cancelled_at TIMESTAMP WITH TIME ZONE,
cancelled_by TEXT,
cancelled_reason TEXT,
-- Audit
created_by TEXT NOT NULL,
updated_by TEXT
);
-- Indexes for common queries
CREATE INDEX IF NOT EXISTS idx_order_read_models_company_id
ON order_read_models(company_id);
CREATE INDEX IF NOT EXISTS idx_order_read_models_customer_id
ON order_read_models(customer_id);
CREATE INDEX IF NOT EXISTS idx_order_read_models_fiscal_year_id
ON order_read_models(fiscal_year_id);
CREATE INDEX IF NOT EXISTS idx_order_read_models_status
ON order_read_models(status);
CREATE INDEX IF NOT EXISTS idx_order_read_models_order_date
ON order_read_models(order_date DESC);
CREATE INDEX IF NOT EXISTS idx_order_read_models_invoice_id
ON order_read_models(invoice_id)
WHERE invoice_id IS NOT NULL;
-- Unique constraint for order number per company
CREATE UNIQUE INDEX IF NOT EXISTS idx_order_read_models_company_order_number
ON order_read_models(company_id, order_number);
-- Composite index for listing orders by company and status
CREATE INDEX IF NOT EXISTS idx_order_read_models_company_status_date
ON order_read_models(company_id, status, order_date DESC);
COMMENT ON TABLE order_read_models IS 'Read model for orders (Ordrer)';
COMMENT ON COLUMN order_read_models.order_number IS 'Order number format: ORD-YYYY-NNNN (Ordrenummer)';
COMMENT ON COLUMN order_read_models.status IS 'Order status: draft, confirmed, partially_invoiced, fully_invoiced, cancelled';
COMMENT ON COLUMN order_read_models.lines IS 'JSON array of order lines with invoicing status';

View file

@ -0,0 +1,27 @@
-- Migration: 026_DocumentContentHashes
-- Description: Create table for tracking document content hashes to prevent duplicate processing
CREATE TABLE IF NOT EXISTS document_content_hashes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id TEXT NOT NULL,
content_hash TEXT NOT NULL, -- SHA-256 hash of file content
original_filename TEXT NOT NULL,
attachment_id TEXT, -- Reference to attachment_read_models
draft_id TEXT, -- Reference to journal_entry_draft_read_models
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Ensure unique hash per company (same document can exist in different companies)
CONSTRAINT uq_document_hash_company UNIQUE(company_id, content_hash)
);
-- Index for fast lookups by company
CREATE INDEX IF NOT EXISTS idx_document_content_hashes_company_id
ON document_content_hashes(company_id);
-- Index for hash lookups
CREATE INDEX IF NOT EXISTS idx_document_content_hashes_content_hash
ON document_content_hashes(content_hash);
COMMENT ON TABLE document_content_hashes IS 'Tracks document content hashes to prevent duplicate processing of the same document';
COMMENT ON COLUMN document_content_hashes.content_hash IS 'SHA-256 hash of the file content';
COMMENT ON COLUMN document_content_hashes.draft_id IS 'Reference to the journal entry draft created from this document';

View file

@ -0,0 +1,36 @@
-- 027_Attachments.sql
-- Creates attachment read model table for document/file storage
-- Used by AI Bookkeeper for uploaded invoices/receipts
CREATE TABLE IF NOT EXISTS attachment_read_models (
-- EventFlow standard columns
aggregate_id VARCHAR(255) PRIMARY KEY,
create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_aggregate_sequence_number INT NOT NULL DEFAULT 1,
-- Business columns
company_id VARCHAR(255) NOT NULL,
file_name VARCHAR(500) NOT NULL,
original_file_name VARCHAR(500) NOT NULL,
content_type VARCHAR(100) NOT NULL,
file_size BIGINT NOT NULL,
storage_path VARCHAR(1000) NOT NULL,
uploaded_by VARCHAR(255) NOT NULL,
uploaded_at TIMESTAMPTZ NOT NULL,
draft_id VARCHAR(255),
transaction_id VARCHAR(255),
retention_end_date TIMESTAMPTZ NOT NULL,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
deleted_by VARCHAR(255),
delete_reason TEXT,
deleted_at TIMESTAMPTZ,
CONSTRAINT fk_attachment_company
FOREIGN KEY (company_id) REFERENCES company_read_models(aggregate_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_attachment_company ON attachment_read_models(company_id);
CREATE INDEX IF NOT EXISTS idx_attachment_draft ON attachment_read_models(draft_id) WHERE draft_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_attachment_transaction ON attachment_read_models(transaction_id) WHERE transaction_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_attachment_not_deleted ON attachment_read_models(company_id, is_deleted) WHERE is_deleted = FALSE;

View file

@ -0,0 +1,8 @@
-- 028_DraftExtractionData.sql
-- Adds extraction_data column to store full AI extraction
ALTER TABLE journal_entry_draft_read_models
ADD COLUMN IF NOT EXISTS extraction_data JSONB;
COMMENT ON COLUMN journal_entry_draft_read_models.extraction_data IS
'Full AI extraction data: vendor, cvr, amounts, vat, due date, payment ref, line items';

View file

@ -0,0 +1,5 @@
-- 029_FixExtractionDataColumnType.sql
-- Fix extraction_data column type from JSONB to TEXT for Dapper compatibility
ALTER TABLE journal_entry_draft_read_models
ALTER COLUMN extraction_data TYPE TEXT USING extraction_data::TEXT;

View file

@ -0,0 +1,106 @@
using System.Text.RegularExpressions;
using Books.Api.Domain.Accounts.Events;
using EventFlow.Aggregates;
namespace Books.Api.Domain.Accounts;
public class AccountAggregate(AccountId id) : AggregateRoot<AccountAggregate, AccountId>(id),
IEmit<AccountCreatedEvent>,
IEmit<AccountUpdatedEvent>,
IEmit<AccountDeactivatedEvent>,
IEmit<AccountReactivatedEvent>
{
private bool _isCreated;
private bool _isActive = true;
private bool _isSystemAccount;
public void Apply(AccountCreatedEvent e)
{
_isCreated = true;
_isSystemAccount = e.IsSystemAccount;
}
public void Apply(AccountUpdatedEvent e) { }
public void Apply(AccountDeactivatedEvent e) => _isActive = false;
public void Apply(AccountReactivatedEvent e) => _isActive = true;
public void Create(
string companyId,
string accountNumber,
string name,
AccountType accountType,
string? parentId,
string? description,
string? vatCodeId,
bool isSystemAccount = false,
string? standardAccountNumber = null)
{
if (_isCreated)
throw new DomainException("ACCOUNT_EXISTS", "Account already exists", "Konto eksisterer allerede");
if (string.IsNullOrWhiteSpace(companyId))
throw new DomainException("COMPANY_REQUIRED", "Company ID is required", "Virksomheds-ID er paakraevet");
if (string.IsNullOrWhiteSpace(accountNumber))
throw new DomainException("ACCOUNT_NUMBER_REQUIRED", "Account number is required", "Kontonummer er paakraevet");
if (string.IsNullOrWhiteSpace(name))
throw new DomainException("ACCOUNT_NAME_REQUIRED", "Account name is required", "Kontonavn er paakraevet");
// Validate account number format (4-10 digits, Danish standard)
if (!Regex.IsMatch(accountNumber.Trim(), @"^\d{4,10}$"))
throw new DomainException("INVALID_ACCOUNT_NUMBER", "Account number must be 4-10 digits", "Kontonummer skal vaere 4-10 cifre");
Emit(new AccountCreatedEvent(
companyId,
accountNumber.Trim(),
name.Trim(),
accountType,
parentId,
description?.Trim(),
vatCodeId,
isSystemAccount,
standardAccountNumber?.Trim()));
}
public void Update(string name, string? parentId, string? description, string? vatCodeId)
{
if (!_isCreated)
throw new DomainException("ACCOUNT_NOT_FOUND", "Account does not exist", "Konto findes ikke");
if (_isSystemAccount)
throw new DomainException("SYSTEM_ACCOUNT_READONLY", "System accounts cannot be modified", "Systemkonti kan ikke aendres");
if (string.IsNullOrWhiteSpace(name))
throw new DomainException("ACCOUNT_NAME_REQUIRED", "Account name is required", "Kontonavn er paakraevet");
Emit(new AccountUpdatedEvent(name.Trim(), parentId, description?.Trim(), vatCodeId));
}
public void Deactivate()
{
if (!_isCreated)
throw new DomainException("ACCOUNT_NOT_FOUND", "Account does not exist", "Konto findes ikke");
if (_isSystemAccount)
throw new DomainException("SYSTEM_ACCOUNT_READONLY", "System accounts cannot be deactivated", "Systemkonti kan ikke deaktiveres");
if (!_isActive)
throw new DomainException("ACCOUNT_ALREADY_INACTIVE", "Account is already inactive", "Konto er allerede inaktiv");
Emit(new AccountDeactivatedEvent());
}
public void Reactivate()
{
if (!_isCreated)
throw new DomainException("ACCOUNT_NOT_FOUND", "Account does not exist", "Konto findes ikke");
if (_isActive)
throw new DomainException("ACCOUNT_ALREADY_ACTIVE", "Account is already active", "Konto er allerede aktiv");
Emit(new AccountReactivatedEvent());
}
}

View file

@ -0,0 +1,31 @@
using System.Security.Cryptography;
using System.Text;
using EventFlow.Core;
namespace Books.Api.Domain.Accounts;
public class AccountId(string value) : Identity<AccountId>(value)
{
/// <summary>
/// Creates a deterministic AccountId based on company and account number.
/// This prevents duplicate accounts even with race conditions.
/// Uses SHA256 to generate a deterministic GUID from the input.
/// </summary>
public static AccountId FromCompanyAndNumber(string companyId, string accountNumber)
{
// Create a deterministic GUID from the company ID and account number
var input = $"{companyId}:{accountNumber}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
// Use the first 16 bytes of the hash to create a GUID
var guidBytes = new byte[16];
Array.Copy(hash, guidBytes, 16);
// Set version (4) and variant bits to make it a valid UUID
guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x40); // Version 4
guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80); // Variant 1
var deterministicGuid = new Guid(guidBytes);
return new AccountId($"account-{deterministicGuid:D}");
}
}

View file

@ -0,0 +1,35 @@
namespace Books.Api.Domain.Accounts;
/// <summary>
/// Danish Standard Chart of Accounts (Standardkontoplan) account types.
/// Account number ranges follow Danish conventions.
/// </summary>
public enum AccountType
{
/// <summary>Aktiver (1000-1999)</summary>
Asset,
/// <summary>Passiver (2000-2999)</summary>
Liability,
/// <summary>Egenkapital (3000-3999)</summary>
Equity,
/// <summary>Omsaetning (4000-4999)</summary>
Revenue,
/// <summary>Vareforbrug (5000-5999)</summary>
Cogs,
/// <summary>Driftsomkostninger (6000-6999)</summary>
Expense,
/// <summary>Personaleomkostninger (7000-7999)</summary>
Personnel,
/// <summary>Finansielle poster (8000-8999)</summary>
Financial,
/// <summary>Ekstraordinaere poster (9000-9999)</summary>
Extraordinary
}

View file

@ -0,0 +1,28 @@
using EventFlow.Aggregates;
namespace Books.Api.Domain.Accounts.Events;
public class AccountCreatedEvent(
string companyId,
string accountNumber,
string name,
AccountType accountType,
string? parentId,
string? description,
string? vatCodeId,
bool isSystemAccount,
string? standardAccountNumber = null) : AggregateEvent<AccountAggregate, AccountId>
{
public string CompanyId { get; } = companyId;
public string AccountNumber { get; } = accountNumber;
public string Name { get; } = name;
public AccountType AccountType { get; } = accountType;
public string? ParentId { get; } = parentId;
public string? Description { get; } = description;
public string? VatCodeId { get; } = vatCodeId;
public bool IsSystemAccount { get; } = isSystemAccount;
/// <summary>
/// Erhvervsstyrelsens standardkontonummer for SAF-T rapportering.
/// </summary>
public string? StandardAccountNumber { get; } = standardAccountNumber;
}

View file

@ -0,0 +1,5 @@
using EventFlow.Aggregates;
namespace Books.Api.Domain.Accounts.Events;
public class AccountDeactivatedEvent() : AggregateEvent<AccountAggregate, AccountId>;

View file

@ -0,0 +1,5 @@
using EventFlow.Aggregates;
namespace Books.Api.Domain.Accounts.Events;
public class AccountReactivatedEvent() : AggregateEvent<AccountAggregate, AccountId>;

View file

@ -0,0 +1,15 @@
using EventFlow.Aggregates;
namespace Books.Api.Domain.Accounts.Events;
public class AccountUpdatedEvent(
string name,
string? parentId,
string? description,
string? vatCodeId) : AggregateEvent<AccountAggregate, AccountId>
{
public string Name { get; } = name;
public string? ParentId { get; } = parentId;
public string? Description { get; } = description;
public string? VatCodeId { get; } = vatCodeId;
}

View file

@ -0,0 +1,211 @@
using Books.Api.Domain.Attachments.Events;
using EventFlow.Aggregates;
namespace Books.Api.Domain.Attachments;
/// <summary>
/// Aggregate for managing document attachments (bilag).
/// Required by Bogføringsloven § 6 - documents must be retained for 5 years.
/// </summary>
public class AttachmentAggregate(AttachmentId id)
: AggregateRoot<AttachmentAggregate, AttachmentId>(id),
IEmit<AttachmentUploadedEvent>,
IEmit<AttachmentLinkedToTransactionEvent>,
IEmit<AttachmentDeletedEvent>
{
private bool _isCreated;
private bool _isDeleted;
private string _companyId = string.Empty;
private string? _transactionId;
private DateTimeOffset _uploadedAt;
public string CompanyId => _companyId;
public bool IsDeleted => _isDeleted;
#region Apply Methods
public void Apply(AttachmentUploadedEvent e)
{
_isCreated = true;
_companyId = e.CompanyId;
_transactionId = e.TransactionId;
_uploadedAt = DateTimeOffset.UtcNow;
}
public void Apply(AttachmentLinkedToTransactionEvent e)
{
_transactionId = e.TransactionId;
}
public void Apply(AttachmentDeletedEvent e)
{
_isDeleted = true;
}
#endregion
#region Command Methods
/// <summary>
/// Upload a new attachment.
/// </summary>
public void Upload(
string companyId,
string fileName,
string originalFileName,
string contentType,
long fileSize,
string storagePath,
string uploadedBy,
string? draftId = null,
string? transactionId = null)
{
if (_isCreated)
throw new DomainException(
"ATTACHMENT_ALREADY_EXISTS",
"Attachment already exists",
"Bilag eksisterer allerede");
if (string.IsNullOrWhiteSpace(companyId))
throw new DomainException(
"COMPANY_ID_REQUIRED",
"Company ID is required",
"Virksomheds-ID er påkrævet");
if (string.IsNullOrWhiteSpace(fileName))
throw new DomainException(
"FILE_NAME_REQUIRED",
"File name is required",
"Filnavn er påkrævet");
if (string.IsNullOrWhiteSpace(contentType))
throw new DomainException(
"CONTENT_TYPE_REQUIRED",
"Content type is required",
"Filtype er påkrævet");
if (fileSize <= 0)
throw new DomainException(
"INVALID_FILE_SIZE",
"File size must be greater than 0",
"Filstørrelse skal være større end 0");
if (string.IsNullOrWhiteSpace(storagePath))
throw new DomainException(
"STORAGE_PATH_REQUIRED",
"Storage path is required",
"Lagringssti er påkrævet");
if (string.IsNullOrWhiteSpace(uploadedBy))
throw new DomainException(
"UPLOADED_BY_REQUIRED",
"Uploaded by is required",
"Uploadet af er påkrævet");
// Validate allowed file types
var allowedTypes = new[]
{
"application/pdf",
"image/png", "image/jpeg", "image/gif", "image/webp",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
};
if (!allowedTypes.Contains(contentType.ToLowerInvariant()))
throw new DomainException(
"INVALID_FILE_TYPE",
$"File type '{contentType}' is not allowed. Allowed types: PDF, images, Word, Excel",
$"Filtypen '{contentType}' er ikke tilladt. Tilladte typer: PDF, billeder, Word, Excel");
Emit(new AttachmentUploadedEvent(
companyId.Trim(),
fileName.Trim(),
originalFileName.Trim(),
contentType.ToLowerInvariant(),
fileSize,
storagePath.Trim(),
uploadedBy,
draftId?.Trim(),
transactionId?.Trim()));
}
/// <summary>
/// Link attachment to a posted transaction.
/// Called after draft is posted to ledger.
/// </summary>
public void LinkToTransaction(string transactionId)
{
EnsureExists();
if (string.IsNullOrWhiteSpace(transactionId))
throw new DomainException(
"TRANSACTION_ID_REQUIRED",
"Transaction ID is required",
"Transaktions-ID er påkrævet");
if (!string.IsNullOrEmpty(_transactionId))
throw new DomainException(
"ALREADY_LINKED",
"Attachment is already linked to a transaction",
"Bilaget er allerede knyttet til en transaktion");
Emit(new AttachmentLinkedToTransactionEvent(transactionId.Trim()));
}
/// <summary>
/// Delete an attachment.
/// Note: Per Bogføringsloven § 6, this should only be used for
/// administrative cleanup after the 5-year retention period.
/// </summary>
public void Delete(string deletedBy, string reason)
{
EnsureExists();
if (string.IsNullOrWhiteSpace(deletedBy))
throw new DomainException(
"DELETED_BY_REQUIRED",
"Deleted by is required",
"Slettet af er påkrævet");
if (string.IsNullOrWhiteSpace(reason))
throw new DomainException(
"DELETE_REASON_REQUIRED",
"A reason for deletion is required (Bogføringsloven § 6)",
"En begrundelse for sletning er påkrævet (Bogføringsloven § 6)");
// Check retention period
var retentionYears = 5;
var retentionEndDate = _uploadedAt.AddYears(retentionYears);
if (DateTimeOffset.UtcNow < retentionEndDate && !string.IsNullOrEmpty(_transactionId))
throw new DomainException(
"RETENTION_PERIOD_ACTIVE",
$"Cannot delete attachment linked to transaction - retention period ends {retentionEndDate:yyyy-MM-dd} (Bogføringsloven § 6)",
$"Kan ikke slette bilag knyttet til transaktion - opbevaringsperiode udløber {retentionEndDate:dd-MM-yyyy} (Bogføringsloven § 6)");
Emit(new AttachmentDeletedEvent(deletedBy, reason.Trim()));
}
#endregion
#region Private Methods
private void EnsureExists()
{
if (!_isCreated)
throw new DomainException(
"ATTACHMENT_NOT_FOUND",
"Attachment does not exist",
"Bilaget findes ikke");
if (_isDeleted)
throw new DomainException(
"ATTACHMENT_DELETED",
"Attachment has been deleted",
"Bilaget er blevet slettet");
}
#endregion
}

View file

@ -0,0 +1,13 @@
using EventFlow.Core;
using EventFlow.ValueObjects;
namespace Books.Api.Domain.Attachments;
/// <summary>
/// Identity for Attachment aggregate.
/// Bilag (attachments) are required by Bogføringsloven § 6.
/// </summary>
public class AttachmentId : Identity<AttachmentId>
{
public AttachmentId(string value) : base(value) { }
}

View file

@ -0,0 +1,80 @@
using EventFlow.Aggregates;
namespace Books.Api.Domain.Attachments.Events;
/// <summary>
/// Event raised when an attachment (bilag) is uploaded.
/// Required by Bogføringsloven § 6 for document retention.
/// </summary>
public class AttachmentUploadedEvent(
string companyId,
string fileName,
string originalFileName,
string contentType,
long fileSize,
string storagePath,
string uploadedBy,
string? draftId = null,
string? transactionId = null) : AggregateEvent<AttachmentAggregate, AttachmentId>
{
public string CompanyId { get; } = companyId;
/// <summary>
/// Stored filename (sanitized/unique).
/// </summary>
public string FileName { get; } = fileName;
/// <summary>
/// Original filename as uploaded by user.
/// </summary>
public string OriginalFileName { get; } = originalFileName;
/// <summary>
/// MIME type (e.g., application/pdf, image/png).
/// </summary>
public string ContentType { get; } = contentType;
/// <summary>
/// File size in bytes.
/// </summary>
public long FileSize { get; } = fileSize;
/// <summary>
/// Path to the stored file (local path or blob URL).
/// </summary>
public string StoragePath { get; } = storagePath;
public string UploadedBy { get; } = uploadedBy;
/// <summary>
/// Optional reference to journal entry draft.
/// </summary>
public string? DraftId { get; } = draftId;
/// <summary>
/// Optional reference to posted transaction.
/// </summary>
public string? TransactionId { get; } = transactionId;
}
/// <summary>
/// Event raised when an attachment is linked to a transaction after posting.
/// </summary>
public class AttachmentLinkedToTransactionEvent(
string transactionId) : AggregateEvent<AttachmentAggregate, AttachmentId>
{
public string TransactionId { get; } = transactionId;
}
/// <summary>
/// Event raised when an attachment is deleted.
/// Note: Per Bogføringsloven § 6, attachments should be retained for 5 years.
/// This event is for administrative cleanup only.
/// </summary>
public class AttachmentDeletedEvent(
string deletedBy,
string reason) : AggregateEvent<AttachmentAggregate, AttachmentId>
{
public string DeletedBy { get; } = deletedBy;
public string Reason { get; } = reason;
}

Some files were not shown because too many files have changed in this diff Show more