Initial commit: Books accounting system with EventFlow CQRS

Backend (.NET 10):
- EventFlow CQRS/Event Sourcing with PostgreSQL
- GraphQL.NET API with mutations and queries
- Custom ReadModelSqlGenerator for snake_case PostgreSQL columns
- Hangfire for background job processing
- Integration tests with isolated test databases

Frontend (React/Vite):
- Initial project structure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nicolaj Hartmann 2026-01-18 02:52:30 +01:00
commit 66f6fa138d
126 changed files with 24741 additions and 0 deletions

View file

@ -0,0 +1,355 @@
# Backend Krav - Dansk Bogføringssystem
## Overblik
Dette dokument beskriver backend-kravene for det danske bogføringssystem. Frontend er implementeret i React/TypeScript med Ant Design og Zustand state management.
---
## 1. Regnskabsår (Fiscal Years)
### 1.1 Data Model
```typescript
interface FiscalYear {
id: string;
companyId: string;
name: string; // "2025" eller "2024/2025"
startDate: string; // ISO date "YYYY-MM-DD"
endDate: string; // ISO date "YYYY-MM-DD"
status: 'open' | 'closed' | 'locked';
openingBalancePosted: boolean;
closingDate?: string; // Når året blev lukket
closedBy?: string; // Bruger ID
createdAt: string;
updatedAt: string;
}
```
### 1.2 API Endpoints
| Endpoint | Metode | Beskrivelse |
|----------|--------|-------------|
| `/api/fiscal-years` | GET | Hent alle regnskabsår for aktiv virksomhed |
| `/api/fiscal-years` | POST | Opret nyt regnskabsår |
| `/api/fiscal-years/:id` | GET | Hent specifikt regnskabsår |
| `/api/fiscal-years/:id` | PATCH | Opdater regnskabsår |
| `/api/fiscal-years/:id/close` | POST | Luk regnskabsår (årsafslutning) |
| `/api/fiscal-years/:id/reopen` | POST | Genåbn lukket regnskabsår |
| `/api/fiscal-years/:id/lock` | POST | Lås regnskabsår permanent |
### 1.3 Forretningsregler
#### Oprettelse
- Nye regnskabsår må IKKE overlappe med eksisterende
- Regnskabsår der "rører" ved grænsen (slutter/starter samme dag) er TILLADT
- Understøt både kalenderår (jan-dec) og skæve regnskabsår (fx jul-jun)
- Valider at regnskabsåret er mellem 300-400 dage (advarsel, ikke fejl)
#### Årsafslutning (Year-End Closing)
**KRITISK**: Ved årsafslutning skal backend:
1. **Validere** at regnskabsåret kan lukkes:
- Ikke allerede låst
- Advar om åbne perioder (men tillad lukning)
- Advar om ikke-afstemte transaktioner
2. **Oprette lukkeposter** (closing entries):
- Luk alle indtægtskonti til resultatoverførselskonto
- Luk alle udgiftskonti til resultatoverførselskonto
- Gem som normale transaktioner med særlig markering (`isClosingEntry: true`)
3. **Generere åbningsbalancer** til næste regnskabsår:
- Beregn slutsaldo for alle balancekonti (aktiver, passiver, egenkapital)
- Nulstil resultatkonti (indtægter, udgifter, vareforbrug, personale, finansielle)
- Opret åbningsposteringer i næste regnskabsår (`isOpeningBalance: true`)
4. **Opdatere status**:
- Sæt regnskabsår status til 'closed' eller 'locked'
- Luk alle tilhørende regnskabsperioder
- Sæt `openingBalancePosted: true` på næste regnskabsår
#### Genåbning
- Kun 'closed' regnskabsår kan genåbnes (ikke 'locked')
- Ved genåbning skal åbningsbalancer i næste år opdateres automatisk
- Log hvem der genåbnede og hvornår
### 1.4 Dynamiske Åbningsbalancer
Frontend forventer at åbningsbalancer opdateres automatisk når der bogføres i et tidligere år (som Dinero):
```
Scenarie:
1. Regnskabsår 2024 er lukket, 2025 er åbent
2. Revisor finder fejl og bogfører korrektion i 2024
3. Backend skal automatisk genberegne åbningsbalancen for 2025
```
---
## 2. Regnskabsperioder (Accounting Periods)
### 2.1 Data Model
```typescript
interface AccountingPeriod {
id: string;
fiscalYearId: string;
companyId: string;
name: string; // "Januar 2025", "Q1 2025"
periodNumber: number; // 1-12 for månedlig
startDate: string;
endDate: string;
status: 'future' | 'open' | 'closed' | 'locked';
closedAt?: string;
closedBy?: string;
reopenedAt?: string;
reopenedBy?: string;
lockedAt?: string;
lockedBy?: string;
createdAt: string;
updatedAt: string;
}
type PeriodFrequency = 'monthly' | 'quarterly' | 'half-yearly' | 'yearly';
```
### 2.2 API Endpoints
| Endpoint | Metode | Beskrivelse |
|----------|--------|-------------|
| `/api/periods` | GET | Hent perioder (filtrer på fiscalYearId) |
| `/api/periods/:id` | PATCH | Opdater periode |
| `/api/periods/:id/close` | POST | Luk periode |
| `/api/periods/:id/reopen` | POST | Genåbn periode |
| `/api/periods/:id/lock` | POST | Lås periode |
### 2.3 Forretningsregler
- Perioder genereres automatisk ved oprettelse af regnskabsår
- Understøt: månedlig (12), kvartalsvis (4), halvårlig (2), årlig (1)
- Perioder kan kun lukkes i rækkefølge (periode 2 kræver periode 1 lukket)
- Låste perioder kan IKKE genåbnes
---
## 3. Transaktioner med Periode-Reference
### 3.1 Udvidet Transaction Model
```typescript
interface Transaction {
id: string;
companyId: string;
fiscalYearId: string; // PÅKRÆVET - hvilket regnskabsår
periodId: string; // PÅKRÆVET - hvilken periode
journalEntryNumber: number;
date: string;
description: string;
reference?: string;
lines: TransactionLine[];
isVoided: boolean;
isReconciled: boolean;
isClosingEntry?: boolean; // NY - markering for lukkepost
isOpeningBalance?: boolean; // NY - markering for åbningsbalance
createdAt: string;
updatedAt: string;
createdBy: string;
}
```
### 3.2 Forretningsregler for Bogføring
```typescript
interface PostingValidation {
allowed: boolean;
reason?: string;
reasonDanish?: string;
}
```
Backend skal validere ved bogføring:
1. Find periode for transaktionsdato
2. Tjek om perioden er åben
3. Tjek om regnskabsåret er åbent
4. Returner fejl med dansk besked hvis ikke tilladt
---
## 4. Kontoplan (Chart of Accounts)
### 4.1 Data Model
```typescript
interface Account {
id: string;
companyId: string;
accountNumber: string; // "1000", "3900"
name: string;
type: AccountType;
parentId?: string;
description?: string;
vatCodeId?: string;
isActive: boolean;
isSystemAccount: boolean; // Kan ikke slettes
balance: number; // Beregnet felt
createdAt: string;
updatedAt: string;
}
type AccountType =
| 'asset' // Aktiver
| 'liability' // Passiver
| 'equity' // Egenkapital
| 'revenue' // Indtægter
| 'cogs' // Vareforbrug
| 'expense' // Udgifter
| 'personnel' // Personaleomkostninger
| 'financial' // Finansielle poster
| 'extraordinary'; // Ekstraordinære poster
```
### 4.2 Systemkonti
Følgende konti skal oprettes automatisk:
- `3900` - Overført resultat (equity) - bruges til årsafslutning
- Åbningsbalancekonto for primo-posteringer
---
## 5. Moms (VAT)
### 5.1 Data Model
```typescript
interface VATCode {
id: string;
companyId: string;
code: string; // "S25", "K25"
name: string;
rate: number; // 0.25 for 25%
type: 'sales' | 'purchase' | 'eu_sales' | 'eu_purchase' | 'reverse_charge';
accountId: string; // Momskonto
isActive: boolean;
}
interface VATPeriod {
id: string;
companyId: string;
startDate: string;
endDate: string;
dueDate: string; // Indberetningsfrist
status: 'open' | 'submitted' | 'paid';
salesVAT: number; // Beregnet
purchaseVAT: number; // Beregnet
netVAT: number; // salesVAT - purchaseVAT
submittedAt?: string;
paidAt?: string;
}
```
### 5.2 SKAT Integration (Fremtidig)
- Momsindberetning til SKAT via NemVirksomhed API
- Kvartalsvise eller månedlige momsperioder baseret på virksomhedsstørrelse
---
## 6. Virksomhed (Company)
### 6.1 Data Model
```typescript
interface Company {
id: string;
name: string;
cvr: string; // Dansk CVR-nummer
address?: string;
postalCode?: string;
city?: string;
country: string; // Default "DK"
fiscalYearStart: number; // Måned 1-12 (default 1 for januar)
currency: string; // Default "DKK"
vatRegistered: boolean;
vatPeriodFrequency: 'monthly' | 'quarterly' | 'half-yearly';
createdAt: string;
updatedAt: string;
}
```
---
## 7. Fejlhåndtering
Alle fejlbeskeder skal være tilgængelige på både engelsk og dansk:
```typescript
interface APIError {
code: string;
message: string;
messageDanish: string;
details?: Record<string, unknown>;
}
```
### Eksempler på fejlkoder
| Kode | Engelsk | Dansk |
|------|---------|-------|
| `PERIOD_LOCKED` | Period is locked | Perioden er låst |
| `FISCAL_YEAR_LOCKED` | Fiscal year is locked | Regnskabsåret er låst |
| `OVERLAP_EXISTS` | Overlaps with existing fiscal year | Overlapper med eksisterende regnskabsår |
| `UNBALANCED_ENTRY` | Debit and credit must be equal | Debet og kredit skal være ens |
| `NO_COMPANY_SELECTED` | No company selected | Ingen virksomhed valgt |
---
## 8. Prioriteret Implementeringsrækkefølge
### Fase 1: Grundlæggende (MVP)
1. Company CRUD
2. Account CRUD med standard kontoplan
3. Transaction CRUD med validering
4. FiscalYear CRUD
### Fase 2: Periode-management
1. AccountingPeriod generering og CRUD
2. Periode-validering ved bogføring
3. Periode lukning/åbning
### Fase 3: Årsafslutning
1. Closing entries generering og bogføring
2. Opening balance beregning og bogføring
3. Dynamisk åbningsbalance-opdatering
### Fase 4: Moms
1. VATCode CRUD
2. VATPeriod generering
3. Momsberegning og -rapportering
---
## 9. Tekniske Krav
### Authentication
- JWT-baseret authentication
- Multi-tenant support (bruger kan have adgang til flere virksomheder)
### Database
- PostgreSQL anbefales
- Soft delete på alle entiteter
- Audit trail (createdBy, updatedBy, deletedBy)
### API Format
- RESTful JSON API
- GraphQL som alternativ (valgfrit)
- Pagination på liste-endpoints
- Filtering og sorting support
### Valuta
- Alle beløb i øre/cents (integers) for at undgå floating point problemer
- Frontend konverterer til/fra DKK ved visning
---
*Sidst opdateret: 17. januar 2026*

View file

@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<!-- Test Framework -->
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<!-- Integration Testing -->
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="FluentAssertions" Version="8.3.0" />
<!-- Hangfire In-Memory for Tests -->
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
<!-- PostgreSQL for Test Database -->
<PackageReference Include="Npgsql" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Books.Api\Books.Api.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,260 @@
using Books.Api.Domain.Companies;
using Books.Api.EventFlow.Repositories;
using Books.Api.Tests.Helpers;
using Books.Api.Tests.Infrastructure;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
namespace Books.Api.Tests.GraphQL;
/// <summary>
/// Integration tests for Company GraphQL operations.
/// Each test class runs with its own isolated database.
/// </summary>
[Trait("Category", "Integration")]
public class CompanyGraphQLTests(TestWebApplicationFactory factory)
: IntegrationTestBase(factory)
{
[Fact]
public async Task Query_Companies_ReturnsEmptyList_WhenNoCompaniesExist()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
// Act
var response = await graphqlClient.QueryAsync<CompaniesResponse>("""
query {
companies {
id
name
}
}
""");
// Assert
response.EnsureNoErrors();
response.Data.Should().NotBeNull();
response.Data!.Companies.Should().BeEmpty();
}
[Fact]
public async Task Mutation_CreateCompany_CreatesCompanySuccessfully()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
// Act
var response = await graphqlClient.MutateAsync<CreateCompanyResponse>("""
mutation CreateCompany($input: CreateCompanyInput!) {
createCompany(input: $input) {
id
name
cvr
country
currency
fiscalYearStartMonth
}
}
""",
new
{
input = new
{
name = "Test Virksomhed A/S",
cvr = "12345678",
country = "DK",
currency = "DKK",
fiscalYearStartMonth = 1
}
});
// Assert
response.EnsureNoErrors();
response.Data.Should().NotBeNull();
response.Data!.CreateCompany.Should().NotBeNull();
response.Data.CreateCompany!.Name.Should().Be("Test Virksomhed A/S");
response.Data.CreateCompany.Cvr.Should().Be("12345678");
response.Data.CreateCompany.Country.Should().Be("DK");
response.Data.CreateCompany.Currency.Should().Be("DKK");
response.Data.CreateCompany.FiscalYearStartMonth.Should().Be(1);
}
[Fact]
public async Task Query_Company_ReturnsCompany_AfterCreation()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
// Create a company first
var createResponse = await graphqlClient.MutateAsync<CreateCompanyResponse>("""
mutation CreateCompany($input: CreateCompanyInput!) {
createCompany(input: $input) {
id
name
}
}
""",
new
{
input = new
{
name = "Query Test Virksomhed",
cvr = "87654321"
}
});
createResponse.EnsureNoErrors();
var companyId = createResponse.Data!.CreateCompany!.Id;
// Act - Query the company by ID (with eventual consistency)
var company = await Eventually.GetAsync(async () =>
{
var response = await graphqlClient.QueryAsync<CompanyResponse>("""
query GetCompany($id: ID!) {
company(id: $id) {
id
name
cvr
}
}
""",
new { id = companyId });
return response.Data?.Company;
});
// Assert
company.Should().NotBeNull();
company!.Name.Should().Be("Query Test Virksomhed");
company.Cvr.Should().Be("87654321");
}
[Fact]
public async Task Query_Companies_ReturnsAllCompanies_AfterMultipleCreations()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
// Create multiple companies with unique names for this test
var company1Name = $"ListTest-Virksomhed-{Guid.NewGuid():N}";
var company2Name = $"ListTest-Virksomhed-{Guid.NewGuid():N}";
await graphqlClient.MutateAsync<CreateCompanyResponse>($$"""
mutation { createCompany(input: { name: "{{company1Name}}" }) { id } }
""");
await graphqlClient.MutateAsync<CreateCompanyResponse>($$"""
mutation { createCompany(input: { name: "{{company2Name}}" }) { id } }
""");
// Act - Wait for eventual consistency (at least 2 companies with our prefix)
var companies = await Eventually.GetAsync(
async () =>
{
var response = await graphqlClient.QueryAsync<CompaniesResponse>("""
query {
companies {
id
name
}
}
""");
var allCompanies = response.Data?.Companies ?? [];
var ourCompanies = allCompanies.Where(c => c.Name.StartsWith("ListTest-")).ToList();
return ourCompanies.Count >= 2 ? ourCompanies : null;
});
// Assert - verify our specific companies are present
companies.Should().Contain(c => c.Name == company1Name);
companies.Should().Contain(c => c.Name == company2Name);
}
[Fact]
public async Task Mutation_UpdateCompany_UpdatesCompanySuccessfully()
{
// Arrange
var graphqlClient = new GraphQLTestClient(Client);
// Create a company first
var createResponse = await graphqlClient.MutateAsync<CreateCompanyResponse>("""
mutation { createCompany(input: { name: "Original Name" }) { id } }
""");
createResponse.EnsureNoErrors();
var companyId = createResponse.Data!.CreateCompany!.Id;
// Wait for the company to be created
await Eventually.GetAsync(async () =>
{
var repository = GetService<ICompanyRepository>();
return await repository.GetByIds([CompanyId.With(companyId)]);
});
// Act - Update the company
var updateResponse = await graphqlClient.MutateAsync<UpdateCompanyResponse>("""
mutation UpdateCompany($id: ID!, $input: UpdateCompanyInput!) {
updateCompany(id: $id, input: $input) {
id
name
cvr
}
}
""",
new
{
id = companyId,
input = new
{
name = "Updated Name",
cvr = "11111111"
}
});
// Assert
updateResponse.EnsureNoErrors();
// Wait for eventual consistency and verify update
var updatedCompany = await Eventually.GetAsync(async () =>
{
var repository = GetService<ICompanyRepository>();
var companies = await repository.GetByIds([CompanyId.With(companyId)]);
var company = companies.First();
return company?.Name == "Updated Name" ? company : null;
});
updatedCompany.Should().NotBeNull();
updatedCompany!.Name.Should().Be("Updated Name");
updatedCompany.Cvr.Should().Be("11111111");
}
// Response DTOs for deserialization
private class CompaniesResponse
{
public List<CompanyDto> Companies { get; set; } = [];
}
private class CompanyResponse
{
public CompanyDto? Company { get; set; }
}
private class CreateCompanyResponse
{
public CompanyDto? CreateCompany { get; set; }
}
private class UpdateCompanyResponse
{
public CompanyDto? UpdateCompany { get; set; }
}
private class CompanyDto
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Cvr { get; set; }
public string? Country { get; set; }
public string? Currency { get; set; }
public int? FiscalYearStartMonth { get; set; }
}
}

View file

@ -0,0 +1,89 @@
namespace Books.Api.Tests.Helpers;
/// <summary>
/// Helper for testing eventually consistent systems.
/// Polls a condition until it's met or times out.
/// </summary>
public static class Eventually
{
/// <summary>
/// Polls a condition until it returns true or times out.
/// </summary>
public static async Task AssertAsync(
Func<Task<bool>> condition,
TimeSpan? timeout = null,
TimeSpan? pollInterval = null,
string? failMessage = null)
{
timeout ??= TimeSpan.FromSeconds(5);
pollInterval ??= TimeSpan.FromMilliseconds(50);
var start = DateTime.UtcNow;
while (DateTime.UtcNow - start < timeout)
{
if (await condition())
return;
await Task.Delay(pollInterval.Value);
}
throw new TimeoutException(
failMessage ?? $"Condition was not met within {timeout}.");
}
/// <summary>
/// Polls until a non-null result is returned or times out.
/// </summary>
public static async Task<T> GetAsync<T>(
Func<Task<T?>> getter,
TimeSpan? timeout = null,
TimeSpan? pollInterval = null,
string? failMessage = null) where T : class
{
timeout ??= TimeSpan.FromSeconds(10);
pollInterval ??= TimeSpan.FromMilliseconds(100);
var start = DateTime.UtcNow;
while (DateTime.UtcNow - start < timeout)
{
var result = await getter();
if (result != null)
return result;
await Task.Delay(pollInterval.Value);
}
throw new TimeoutException(
failMessage ?? $"Expected non-null result was not returned within {timeout}.");
}
/// <summary>
/// Polls until a collection has the expected count or times out.
/// </summary>
public static async Task<IReadOnlyList<T>> GetListAsync<T>(
Func<Task<IReadOnlyList<T>>> getter,
int expectedCount,
TimeSpan? timeout = null,
TimeSpan? pollInterval = null,
string? failMessage = null)
{
timeout ??= TimeSpan.FromSeconds(10);
pollInterval ??= TimeSpan.FromMilliseconds(100);
var start = DateTime.UtcNow;
while (DateTime.UtcNow - start < timeout)
{
var result = await getter();
if (result.Count >= expectedCount)
return result;
await Task.Delay(pollInterval.Value);
}
throw new TimeoutException(
failMessage ?? $"Expected {expectedCount} items but condition was not met within {timeout}.");
}
}

View file

@ -0,0 +1,125 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Books.Api.Tests.Helpers;
/// <summary>
/// Helper client for executing GraphQL queries and mutations in tests.
/// </summary>
public class GraphQLTestClient(HttpClient httpClient)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <summary>
/// Executes a GraphQL query and returns the typed result.
/// </summary>
public async Task<GraphQLResponse<T>> QueryAsync<T>(
string query,
object? variables = null,
CancellationToken cancellationToken = default)
{
return await ExecuteAsync<T>(query, variables, cancellationToken);
}
/// <summary>
/// Executes a GraphQL mutation and returns the typed result.
/// </summary>
public async Task<GraphQLResponse<T>> MutateAsync<T>(
string mutation,
object? variables = null,
CancellationToken cancellationToken = default)
{
return await ExecuteAsync<T>(mutation, variables, cancellationToken);
}
/// <summary>
/// Executes a GraphQL operation and returns the raw JSON response.
/// </summary>
public async Task<string> ExecuteRawAsync(
string query,
object? variables = null,
CancellationToken cancellationToken = default)
{
var request = new GraphQLRequest(query, variables);
var json = JsonSerializer.Serialize(request, JsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await httpClient.PostAsync("/graphql", content, cancellationToken);
return await response.Content.ReadAsStringAsync(cancellationToken);
}
private async Task<GraphQLResponse<T>> ExecuteAsync<T>(
string query,
object? variables,
CancellationToken cancellationToken)
{
var request = new GraphQLRequest(query, variables);
var json = JsonSerializer.Serialize(request, JsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await httpClient.PostAsync("/graphql", content, cancellationToken);
var responseJson = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonSerializer.Deserialize<GraphQLResponse<T>>(responseJson, JsonOptions);
return result ?? throw new InvalidOperationException("Failed to deserialize GraphQL response");
}
}
public record GraphQLRequest(
[property: JsonPropertyName("query")] string Query,
[property: JsonPropertyName("variables")] object? Variables = null);
public class GraphQLResponse<T>
{
[JsonPropertyName("data")]
public T? Data { get; set; }
[JsonPropertyName("errors")]
public List<GraphQLError>? Errors { get; set; }
public bool HasErrors => Errors?.Count > 0;
public void EnsureNoErrors()
{
if (HasErrors)
{
var errorDetails = Errors!.Select(e =>
{
var msg = e.Message;
if (e.Extensions != null)
{
foreach (var ext in e.Extensions)
{
msg += $"{Environment.NewLine} [{ext.Key}]: {ext.Value}";
}
}
return msg;
});
var messages = string.Join(Environment.NewLine, errorDetails);
throw new GraphQLException($"GraphQL errors occurred:{Environment.NewLine}{messages}", Errors!);
}
}
}
public class GraphQLError
{
[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;
[JsonPropertyName("path")]
public List<object>? Path { get; set; }
[JsonPropertyName("extensions")]
public Dictionary<string, object>? Extensions { get; set; }
}
public class GraphQLException(string message, List<GraphQLError> errors) : Exception(message)
{
public List<GraphQLError> Errors { get; } = errors;
}

View file

@ -0,0 +1,38 @@
using EventFlow;
using EventFlow.Aggregates;
using Microsoft.Extensions.DependencyInjection;
namespace Books.Api.Tests.Infrastructure;
/// <summary>
/// Base class for integration tests with isolated database per test class.
/// Uses IClassFixture pattern - each test class gets its own database.
/// </summary>
public abstract class IntegrationTestBase
: IClassFixture<TestWebApplicationFactory>, IDisposable
{
protected readonly TestWebApplicationFactory Factory;
protected readonly HttpClient Client;
private readonly IServiceScope _scope;
protected IntegrationTestBase(TestWebApplicationFactory factory)
{
Factory = factory;
Client = factory.CreateGraphQLClient();
_scope = factory.Services.CreateScope();
}
protected IServiceProvider Services => _scope.ServiceProvider;
protected ICommandBus CommandBus => Services.GetRequiredService<ICommandBus>();
protected IAggregateStore AggregateStore => Services.GetRequiredService<IAggregateStore>();
protected T GetService<T>() where T : notnull
=> Services.GetRequiredService<T>();
public void Dispose()
{
_scope?.Dispose();
GC.SuppressFinalize(this);
}
}

View file

@ -0,0 +1,121 @@
using Npgsql;
namespace Books.Api.Tests.Infrastructure;
/// <summary>
/// Creates an isolated PostgreSQL database for each test run.
/// The database is automatically dropped when disposed.
/// </summary>
public class TestDatabase : IDisposable
{
public const string DefaultConnectionString =
"Host=localhost;Port=5432;Username=postgres;Password=postgres;Database=postgres;Include Error Detail=true";
private readonly string _masterConnectionString;
private bool _disposed;
public string DatabaseName { get; }
public string ConnectionString
{
get
{
var builder = new NpgsqlConnectionStringBuilder(_masterConnectionString)
{
Database = DatabaseName
};
return builder.ToString();
}
}
public TestDatabase(string? masterConnectionString = null)
{
_masterConnectionString = masterConnectionString ?? GetConnectionStringFromEnvironment();
DatabaseName = $"books_test_{Guid.NewGuid():N}";
CreateDatabase();
}
private static string GetConnectionStringFromEnvironment()
{
return Environment.GetEnvironmentVariable("TEST_CONNECTION_STRING")
?? DefaultConnectionString;
}
private void CreateDatabase()
{
try
{
using var connection = new NpgsqlConnection(_masterConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = $"CREATE DATABASE \"{DatabaseName}\"";
command.ExecuteNonQuery();
Console.WriteLine($"[TestDatabase] Created test database: {DatabaseName}");
}
catch (Exception e)
{
throw new InvalidOperationException(
$"Failed to create test database '{DatabaseName}' using connection string: {_masterConnectionString}",
e);
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
try
{
// Step 1: Terminate all active connections to prevent "database in use" errors
using (var connection = new NpgsqlConnection(_masterConnectionString))
{
connection.Open();
using var terminateCommand = connection.CreateCommand();
terminateCommand.CommandText = $@"
SELECT pg_terminate_backend(pg_stat_activity.pid)
FROM pg_stat_activity
WHERE pg_stat_activity.datname = '{DatabaseName}'
AND pid <> pg_backend_pid();
";
terminateCommand.ExecuteNonQuery();
}
// Step 2: Drop the database
using (var connection = new NpgsqlConnection(_masterConnectionString))
{
connection.Open();
using var dropCommand = connection.CreateCommand();
dropCommand.CommandText = $"DROP DATABASE IF EXISTS \"{DatabaseName}\"";
dropCommand.ExecuteNonQuery();
}
Console.WriteLine($"[TestDatabase] Dropped test database: {DatabaseName}");
}
catch (Exception e)
{
// Log but don't throw - we don't want cleanup failures to mask test failures
Console.WriteLine($"[TestDatabase] Warning: Failed to drop test database {DatabaseName}: {e.Message}");
}
}
_disposed = true;
}
~TestDatabase()
{
Dispose(false);
}
}

View file

@ -0,0 +1,146 @@
using Books.Api.EventFlow.Infrastructure;
using EventFlow.PostgreSql;
using EventFlow.PostgreSql.Connections;
using EventFlow.PostgreSql.EventStores;
using Hangfire;
using Hangfire.InMemory;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Npgsql;
namespace Books.Api.Tests.Infrastructure;
/// <summary>
/// WebApplicationFactory configured for integration testing with:
/// - Isolated database per test class
/// - In-memory Hangfire (no real job processing)
/// - Full GraphQL endpoint support
/// </summary>
public class TestWebApplicationFactory : WebApplicationFactory<Program>
{
static TestWebApplicationFactory()
{
// Enable legacy timestamp behavior for Npgsql 6.0+
// Must be set before any Npgsql connections are created
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
}
private readonly TestDatabase _testDatabase = new();
public string TestDatabaseName => _testDatabase.DatabaseName;
public string ConnectionString => _testDatabase.ConnectionString;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Set environment to Test to avoid Development-only middleware (like Altair)
builder.UseEnvironment("Test");
builder.ConfigureAppConfiguration((context, config) =>
{
// Clear all default configuration sources
config.Sources.Clear();
// Add test configuration with isolated database
var testConfig = new Dictionary<string, string?>
{
["ConnectionStrings:Default"] = _testDatabase.ConnectionString,
["AllowedHosts"] = "*"
};
config.AddInMemoryCollection(testConfig);
Console.WriteLine($"[TestWebApplicationFactory] Using test database: {_testDatabase.DatabaseName}");
Console.WriteLine($"[TestWebApplicationFactory] Connection string: {_testDatabase.ConnectionString}");
});
builder.ConfigureServices(services =>
{
// Run database migrations on the test database BEFORE services are built
// This ensures the test database has the correct schema
MigrateTestDatabase();
// Replace the NpgsqlDataSource to use the test database
services.RemoveAll<NpgsqlDataSource>();
services.AddSingleton(new NpgsqlDataSourceBuilder(_testDatabase.ConnectionString).Build());
// CRITICAL: Replace EventFlow's PostgreSQL configuration with test connection string
// This ensures the event store uses the test database, not the original
services.RemoveAll<IPostgreSqlConfiguration>();
services.AddSingleton<IPostgreSqlConfiguration>(
PostgreSqlConfiguration.New.SetConnectionString(_testDatabase.ConnectionString));
// Replace only Hangfire storage (not EventFlow.Hangfire integration services)
// We keep EventFlow.Hangfire services like IQueueNameProvider, HangfireJobScheduler
var hangfireStorageDescriptors = services
.Where(d =>
d.ServiceType.FullName?.Contains("Hangfire.JobStorage") == true ||
d.ServiceType.FullName?.Contains("Hangfire.Server.BackgroundJobServer") == true ||
d.ImplementationType?.FullName?.Contains("Hangfire.PostgreSql") == true)
.ToList();
foreach (var descriptor in hangfireStorageDescriptors)
{
services.Remove(descriptor);
}
// Add Hangfire with in-memory storage
services.AddHangfire(config => config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseInMemoryStorage());
services.AddHangfireServer(options =>
{
options.WorkerCount = 1;
options.Queues = ["default"];
});
});
}
protected override IHost CreateHost(IHostBuilder builder)
{
var host = base.CreateHost(builder);
// Run EventFlow PostgreSQL migrations after host is built
using var scope = host.Services.CreateScope();
var eventFlowMigrator = scope.ServiceProvider.GetRequiredService<IPostgreSqlDatabaseMigrator>();
EventFlowEventStoresPostgreSql.MigrateDatabaseAsync(eventFlowMigrator, CancellationToken.None).Wait();
return host;
}
private void MigrateTestDatabase()
{
// Run DbUp migrations for read model tables
DatabaseMigrator.Migrate(_testDatabase.ConnectionString);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
// Reset Hangfire's static LogProvider before disposing to prevent
// ObjectDisposedException when the next test class creates a new factory.
Hangfire.Logging.LogProvider.SetCurrentLogProvider(null);
_testDatabase.Dispose();
}
base.Dispose(disposing);
}
/// <summary>
/// Creates an HttpClient configured for GraphQL requests.
/// </summary>
public HttpClient CreateGraphQLClient()
{
var client = CreateClient();
client.BaseAddress = new Uri("http://localhost/graphql");
return client;
}
}

View file

@ -0,0 +1,10 @@
namespace Books.Api.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View file

@ -0,0 +1,5 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeAssembly": false,
"parallelizeTestCollections": false
}

View file

@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
</PropertyGroup>
<ItemGroup>
<!-- EventFlow (local source) -->
<ProjectReference Include="../../../EventFlow/Source/EventFlow/EventFlow.csproj" />
<ProjectReference Include="../../../EventFlow/Source/EventFlow.PostgreSql/EventFlow.PostgreSql.csproj" />
<ProjectReference Include="../../../EventFlow/Source/EventFlow.Hangfire/EventFlow.Hangfire.csproj" />
<!-- Database -->
<PackageReference Include="Npgsql" Version="10.0.1" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="dbup-postgresql" Version="5.0.40" />
<!-- Hangfire -->
<PackageReference Include="Hangfire.Core" Version="1.8.22" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.22" />
<PackageReference Include="Hangfire.PostgreSql" Version="1.20.13" />
<!-- Serialization -->
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<!-- GraphQL -->
<PackageReference Include="GraphQL" Version="8.0.2" />
<PackageReference Include="GraphQL.Server.Transports.AspNetCore" Version="8.0.2" />
<PackageReference Include="GraphQL.Server.Ui.Altair" Version="8.0.2" />
<PackageReference Include="GraphQL.DataLoader" Version="8.0.2" />
<PackageReference Include="GraphQL.MicrosoftDI" Version="8.0.2" />
<!-- DI Decoration -->
<PackageReference Include="Scrutor" Version="5.0.2" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Database\Migrations\*.sql" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,6 @@
@Books.Api_HostAddress = http://localhost:5142
GET {{Books.Api_HostAddress}}/weatherforecast/
Accept: application/json
###

View file

@ -0,0 +1,50 @@
using Books.Api.Domain.Companies;
using EventFlow.Commands;
namespace Books.Api.Commands.Companies;
public class CreateCompanyCommandHandler : CommandHandler<CompanyAggregate, CompanyId, CreateCompanyCommand>
{
public override Task ExecuteAsync(
CompanyAggregate aggregate,
CreateCompanyCommand command,
CancellationToken cancellationToken)
{
aggregate.Create(
command.Name,
command.Cvr,
command.Address,
command.PostalCode,
command.City,
command.Country,
command.FiscalYearStartMonth,
command.Currency,
command.VatRegistered,
command.VatPeriodFrequency);
return Task.CompletedTask;
}
}
public class UpdateCompanyCommandHandler : CommandHandler<CompanyAggregate, CompanyId, UpdateCompanyCommand>
{
public override Task ExecuteAsync(
CompanyAggregate aggregate,
UpdateCompanyCommand command,
CancellationToken cancellationToken)
{
aggregate.Update(
command.Name,
command.Cvr,
command.Address,
command.PostalCode,
command.City,
command.Country,
command.FiscalYearStartMonth,
command.Currency,
command.VatRegistered,
command.VatPeriodFrequency);
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,56 @@
using Books.Api.Domain.Companies;
using EventFlow.Commands;
namespace Books.Api.Commands.Companies;
public class CreateCompanyCommand(
CompanyId aggregateId,
string name,
string? cvr,
string? address,
string? postalCode,
string? city,
string country,
int fiscalYearStartMonth,
string currency,
bool vatRegistered,
string? vatPeriodFrequency)
: Command<CompanyAggregate, CompanyId>(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 int FiscalYearStartMonth { get; } = fiscalYearStartMonth;
public string Currency { get; } = currency;
public bool VatRegistered { get; } = vatRegistered;
public string? VatPeriodFrequency { get; } = vatPeriodFrequency;
}
public class UpdateCompanyCommand(
CompanyId aggregateId,
string name,
string? cvr,
string? address,
string? postalCode,
string? city,
string country,
int fiscalYearStartMonth,
string currency,
bool vatRegistered,
string? vatPeriodFrequency)
: Command<CompanyAggregate, CompanyId>(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 int FiscalYearStartMonth { get; } = fiscalYearStartMonth;
public string Currency { get; } = currency;
public bool VatRegistered { get; } = vatRegistered;
public string? VatPeriodFrequency { get; } = vatPeriodFrequency;
}

View file

@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Mvc;
namespace Books.Api.Controllers;
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries =
[
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
];
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
}

View file

@ -0,0 +1,110 @@
-- 001_Initial.sql
-- Creates read model tables for Books API
-- NOTE: Column names use snake_case (PostgreSQL convention)
-- Custom ReadModelSqlGenerator converts C# PascalCase to snake_case
-- Company read models
CREATE TABLE IF NOT EXISTS company_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)
name VARCHAR(255) NOT NULL,
cvr VARCHAR(8),
address VARCHAR(500),
postal_code VARCHAR(10),
city VARCHAR(100),
country VARCHAR(2) NOT NULL DEFAULT 'DK',
fiscal_year_start_month SMALLINT NOT NULL DEFAULT 1,
currency VARCHAR(3) NOT NULL DEFAULT 'DKK',
vat_registered BOOLEAN NOT NULL DEFAULT FALSE,
vat_period_frequency VARCHAR(20)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_company_cvr ON company_read_models(cvr) WHERE cvr IS NOT NULL;
-- Fiscal year read models
CREATE TABLE IF NOT EXISTS fiscal_year_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,
company_id VARCHAR(255) NOT NULL,
name VARCHAR(50) NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'open',
opening_balance_posted BOOLEAN NOT NULL DEFAULT FALSE,
closing_date TIMESTAMPTZ,
closed_by VARCHAR(255),
CONSTRAINT fk_fiscal_year_company
FOREIGN KEY (company_id) REFERENCES company_read_models(aggregate_id) ON DELETE CASCADE,
CONSTRAINT chk_fiscal_year_status
CHECK (status IN ('open', 'closed', 'locked'))
);
CREATE INDEX IF NOT EXISTS idx_fiscal_year_company ON fiscal_year_read_models(company_id);
CREATE INDEX IF NOT EXISTS idx_fiscal_year_dates ON fiscal_year_read_models(start_date, end_date);
-- Accounting period read models
CREATE TABLE IF NOT EXISTS accounting_period_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,
fiscal_year_id VARCHAR(255) NOT NULL,
company_id VARCHAR(255) NOT NULL,
name VARCHAR(50) NOT NULL,
period_number SMALLINT NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'future',
closed_at TIMESTAMPTZ,
closed_by VARCHAR(255),
reopened_at TIMESTAMPTZ,
reopened_by VARCHAR(255),
locked_at TIMESTAMPTZ,
locked_by VARCHAR(255),
CONSTRAINT fk_period_fiscal_year
FOREIGN KEY (fiscal_year_id) REFERENCES fiscal_year_read_models(aggregate_id) ON DELETE CASCADE,
CONSTRAINT chk_period_status
CHECK (status IN ('future', 'open', 'closed', 'locked'))
);
CREATE INDEX IF NOT EXISTS idx_period_fiscal_year ON accounting_period_read_models(fiscal_year_id);
CREATE INDEX IF NOT EXISTS idx_period_company ON accounting_period_read_models(company_id);
-- Account read models (chart of accounts)
CREATE TABLE IF NOT EXISTS account_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,
company_id VARCHAR(255) NOT NULL,
account_number VARCHAR(10) NOT NULL,
name VARCHAR(255) NOT NULL,
account_type VARCHAR(20) NOT NULL,
parent_id VARCHAR(255),
description TEXT,
vat_code_id VARCHAR(255),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_system_account BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT fk_account_company
FOREIGN KEY (company_id) REFERENCES company_read_models(aggregate_id) ON DELETE CASCADE,
CONSTRAINT chk_account_type
CHECK (account_type IN ('asset', 'liability', 'equity', 'revenue', 'cogs', 'expense', 'personnel', 'financial', 'extraordinary'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_number
ON account_read_models(company_id, account_number);
CREATE INDEX IF NOT EXISTS idx_account_company ON account_read_models(company_id);
CREATE INDEX IF NOT EXISTS idx_account_type ON account_read_models(account_type);

View file

@ -0,0 +1,81 @@
using Books.Api.Domain.Companies.Events;
using EventFlow.Aggregates;
namespace Books.Api.Domain.Companies;
public class CompanyAggregate(CompanyId id) : AggregateRoot<CompanyAggregate, CompanyId>(id)
{
private bool _isCreated;
public void Apply(CompanyCreatedEvent e) => _isCreated = true;
public void Apply(CompanyUpdatedEvent e) { }
public void Create(
string name,
string? cvr,
string? address,
string? postalCode,
string? city,
string country,
int fiscalYearStartMonth,
string currency,
bool vatRegistered,
string? vatPeriodFrequency)
{
if (_isCreated)
throw new DomainException("Company already exists");
if (string.IsNullOrWhiteSpace(name))
throw new DomainException("Company name is required");
if (fiscalYearStartMonth < 1 || fiscalYearStartMonth > 12)
throw new DomainException("Fiscal year start month must be between 1 and 12");
Emit(new CompanyCreatedEvent(
name.Trim(),
cvr?.Trim(),
address?.Trim(),
postalCode?.Trim(),
city?.Trim(),
country,
fiscalYearStartMonth,
currency,
vatRegistered,
vatPeriodFrequency));
}
public void Update(
string name,
string? cvr,
string? address,
string? postalCode,
string? city,
string country,
int fiscalYearStartMonth,
string currency,
bool vatRegistered,
string? vatPeriodFrequency)
{
if (!_isCreated)
throw new DomainException("Company does not exist");
if (string.IsNullOrWhiteSpace(name))
throw new DomainException("Company name is required");
if (fiscalYearStartMonth < 1 || fiscalYearStartMonth > 12)
throw new DomainException("Fiscal year start month must be between 1 and 12");
Emit(new CompanyUpdatedEvent(
name.Trim(),
cvr?.Trim(),
address?.Trim(),
postalCode?.Trim(),
city?.Trim(),
country,
fiscalYearStartMonth,
currency,
vatRegistered,
vatPeriodFrequency));
}
}

View file

@ -0,0 +1,8 @@
using EventFlow.Core;
namespace Books.Api.Domain.Companies;
public class CompanyId : Identity<CompanyId>
{
public CompanyId(string value) : base(value) { }
}

View file

@ -0,0 +1,27 @@
using EventFlow.Aggregates;
namespace Books.Api.Domain.Companies.Events;
public class CompanyCreatedEvent(
string name,
string? cvr,
string? address,
string? postalCode,
string? city,
string country,
int fiscalYearStartMonth,
string currency,
bool vatRegistered,
string? vatPeriodFrequency) : AggregateEvent<CompanyAggregate, CompanyId>
{
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 int FiscalYearStartMonth { get; } = fiscalYearStartMonth;
public string Currency { get; } = currency;
public bool VatRegistered { get; } = vatRegistered;
public string? VatPeriodFrequency { get; } = vatPeriodFrequency;
}

View file

@ -0,0 +1,27 @@
using EventFlow.Aggregates;
namespace Books.Api.Domain.Companies.Events;
public class CompanyUpdatedEvent(
string name,
string? cvr,
string? address,
string? postalCode,
string? city,
string country,
int fiscalYearStartMonth,
string currency,
bool vatRegistered,
string? vatPeriodFrequency) : AggregateEvent<CompanyAggregate, CompanyId>
{
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 int FiscalYearStartMonth { get; } = fiscalYearStartMonth;
public string Currency { get; } = currency;
public bool VatRegistered { get; } = vatRegistered;
public string? VatPeriodFrequency { get; } = vatPeriodFrequency;
}

View file

@ -0,0 +1,20 @@
namespace Books.Api.Domain;
public class DomainException : Exception
{
public string Code { get; }
public string? MessageDanish { get; }
public DomainException(string message)
: base(message)
{
Code = "DOMAIN_ERROR";
}
public DomainException(string code, string message, string messageDanish)
: base(message)
{
Code = code;
MessageDanish = messageDanish;
}
}

View file

@ -0,0 +1,31 @@
using System.Reflection;
namespace Books.Api.EventFlow.Customs;
public static class Casing
{
public static string ToSnakeCase(this string input)
{
if (string.IsNullOrEmpty(input))
return input;
var stringBuilder = new System.Text.StringBuilder();
for (var i = 0; i < input.Length; i++)
{
var c = input[i];
if (char.IsUpper(c))
{
if (i > 0)
{
stringBuilder.Append('_');
}
stringBuilder.Append(char.ToLower(c));
}
else
{
stringBuilder.Append(c);
}
}
return stringBuilder.ToString();
}
}

View file

@ -0,0 +1,221 @@
// The MIT License (MIT)
//
// Copyright (c) 2015-2025 Rasmus Mikkelsen
// https://github.com/eventflow/EventFlow
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
using System.Collections.Concurrent;
using System.ComponentModel.DataAnnotations.Schema;
using System.Reflection;
using EventFlow.Extensions;
using EventFlow.ReadStores;
using EventFlow.Sql.ReadModels;
using EventFlow.Sql.ReadModels.Attributes;
namespace Books.Api.EventFlow.Customs;
/// <summary>
/// Custom SQL generator for PostgreSQL that uses snake_case column names
/// while keeping PascalCase property names for Dapper parameter binding.
/// </summary>
public class ReadModelSqlGenerator : IReadModelSqlGenerator
{
private static readonly ConcurrentDictionary<Type, string> TableNames = new();
private static readonly ConcurrentDictionary<Type, IReadOnlyCollection<PropertyInfo>> PropertyInfos = new();
private static readonly ConcurrentDictionary<Type, string> IdentityColumns = new();
private static readonly ConcurrentDictionary<Type, string> IdentityProperties = new();
private static readonly ConcurrentDictionary<Type, string> VersionColumns = new();
private readonly ConcurrentDictionary<Type, string?> _insertSqls = new();
private readonly ConcurrentDictionary<Type, string> _purgeSqls = new();
private readonly ConcurrentDictionary<Type, string> _deleteSqls = new();
private readonly ConcurrentDictionary<Type, string> _selectSqls = new();
private readonly ConcurrentDictionary<Type, string> _updateSqls = new();
public string? CreateInsertSql<TReadModel>() where TReadModel : IReadModel
{
var readModelType = typeof(TReadModel);
if (_insertSqls.TryGetValue(readModelType, out var sql))
{
return sql;
}
var properties = GetPropertyInfos(readModelType);
// Column names in snake_case
var columnList = string.Join(", ", properties.Select(p => p.Name.ToSnakeCase()));
// Parameter names use property names (PascalCase) for Dapper binding
var parameterList = string.Join(", ", properties.Select(p => $"@{p.Name}"));
sql = $"INSERT INTO {GetTableName<TReadModel>()} ({columnList}) VALUES ({parameterList})";
_insertSqls[readModelType] = sql;
return sql;
}
public string CreateSelectSql<TReadModel>() where TReadModel : IReadModel
{
var readModelType = typeof(TReadModel);
if (_selectSqls.TryGetValue(readModelType, out var sql))
{
return sql;
}
var tableName = GetTableName<TReadModel>();
var identityColumn = GetIdentityColumn<TReadModel>();
var properties = GetPropertyInfos(readModelType);
// Use explicit column aliases to map snake_case columns to PascalCase properties
var columnList = string.Join(", ", properties.Select(p => $"{p.Name.ToSnakeCase()} AS \"{p.Name}\""));
sql = $"SELECT {columnList} FROM {tableName} WHERE {identityColumn} = @EventFlowReadModelId";
_selectSqls[readModelType] = sql;
return sql;
}
public string CreateDeleteSql<TReadModel>() where TReadModel : IReadModel
{
var readModelType = typeof(TReadModel);
if (_deleteSqls.TryGetValue(readModelType, out var sql))
{
return sql;
}
sql = $"DELETE FROM {GetTableName<TReadModel>()} WHERE {GetIdentityColumn<TReadModel>()} = @EventFlowReadModelId";
_deleteSqls[readModelType] = sql;
return sql;
}
public string CreateUpdateSql<TReadModel>() where TReadModel : IReadModel
{
var readModelType = typeof(TReadModel);
if (_updateSqls.TryGetValue(readModelType, out var sql))
{
return sql;
}
var identityColumn = GetIdentityColumn<TReadModel>();
var identityProperty = GetIdentityProperty<TReadModel>();
var versionColumn = GetVersionColumn<TReadModel>();
var versionCheck = string.IsNullOrEmpty(versionColumn)
? string.Empty
: $"AND {versionColumn} = @_PREVIOUS_VERSION";
var properties = GetPropertyInfos(readModelType);
var updateColumns = properties
.Where(p => p.Name.ToSnakeCase() != identityColumn)
.Select(p => $"{p.Name.ToSnakeCase()} = @{p.Name}");
var tableName = GetTableName<TReadModel>();
sql = $"UPDATE {tableName} SET {string.Join(", ", updateColumns)} WHERE {identityColumn} = @{identityProperty} {versionCheck}";
_updateSqls[readModelType] = sql;
return sql;
}
public string CreatePurgeSql<TReadModel>() where TReadModel : IReadModel
{
return _purgeSqls.GetOrCreate(typeof(TReadModel), t => $"DELETE FROM {GetTableName(t)}");
}
public string GetTableName<TReadModel>() where TReadModel : IReadModel
{
return GetTableName(typeof(TReadModel));
}
private static string GetTableName(Type readModelType)
{
return TableNames.GetOrAdd(
readModelType,
t =>
{
var tableAttribute = t.GetTypeInfo().GetCustomAttribute<TableAttribute>(false);
var table = string.IsNullOrEmpty(tableAttribute?.Name)
? $"ReadModel-{t.Name.Replace("ReadModel", string.Empty)}"
: tableAttribute.Name;
return string.IsNullOrEmpty(tableAttribute?.Schema)
? $"\"{table}\""
: $"\"{tableAttribute?.Schema}\".\"{table}\"";
});
}
private static string GetIdentityColumn<TReadModel>()
{
return IdentityColumns.GetOrAdd(
typeof(TReadModel),
t =>
{
var propertyInfo = GetPropertyInfos(t)
.SingleOrDefault(
pi => pi.GetCustomAttributes().Any(a => a is SqlReadModelIdentityColumnAttribute));
return (propertyInfo?.Name ?? "AggregateId").ToSnakeCase();
});
}
private static string GetIdentityProperty<TReadModel>()
{
return IdentityProperties.GetOrAdd(
typeof(TReadModel),
t =>
{
var propertyInfo = GetPropertyInfos(t)
.SingleOrDefault(
pi => pi.GetCustomAttributes().Any(a => a is SqlReadModelIdentityColumnAttribute));
return propertyInfo?.Name ?? "AggregateId";
});
}
private static string GetVersionColumn<TReadModel>()
{
return VersionColumns.GetOrAdd(
typeof(TReadModel),
t =>
{
var propertyInfo = GetPropertyInfos(t)
.SingleOrDefault(
pi => pi.GetCustomAttributes().Any(a => a is SqlReadModelVersionColumnAttribute));
if (propertyInfo != null)
{
return propertyInfo.Name.ToSnakeCase();
}
return GetPropertyInfos(t).Any(n => n.Name == "LastAggregateSequenceNumber")
? "last_aggregate_sequence_number"
: string.Empty;
});
}
private static IReadOnlyCollection<PropertyInfo> GetPropertyInfos(Type readModelType)
{
return PropertyInfos.GetOrAdd(
readModelType,
t => t.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Where(p => !p.GetCustomAttributes().Any(a => a is SqlReadModelIgnoreColumnAttribute))
.OrderBy(p => p.Name)
.ToList());
}
}

View file

@ -0,0 +1,30 @@
using Books.Api.EventFlow.ReadModels;
using Books.Api.EventFlow.Repositories;
using EventFlow;
using EventFlow.Extensions;
using EventFlow.PostgreSql.Extensions;
using EventFlow.Sql.ReadModels;
using ReadModelSqlGenerator = Books.Api.EventFlow.Customs.ReadModelSqlGenerator;
namespace Books.Api.EventFlow.Extensions;
public static class ReadModelRegistrationExtensions
{
public static IEventFlowOptions AddReadModels(this IEventFlowOptions options)
{
return options
.UsePostgreSqlReadModel<CompanyReadModel, CompanyReadModelLocator>()
.RegisterServices( sr => sr.AddSingleton<IReadModelSqlGenerator>(new ReadModelSqlGenerator()));
}
public static IServiceCollection AddRepositories(this IServiceCollection services)
{
// Register locators
services.AddTransient<CompanyReadModelLocator>();
// Register repositories
services.AddScoped<ICompanyRepository, CompanyRepository>();
return services;
}
}

View file

@ -0,0 +1,43 @@
using System.Reflection;
using Dapper;
using DbUp;
using Npgsql;
namespace Books.Api.EventFlow.Infrastructure;
public static class DatabaseMigrator
{
public static void Migrate(string connectionString)
{
EnsureDatabaseExists(connectionString);
var upgrader = DeployChanges.To
.PostgresqlDatabase(connectionString)
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
.LogToConsole()
.Build();
var result = upgrader.PerformUpgrade();
if (!result.Successful)
throw result.Error;
}
private static void EnsureDatabaseExists(string connectionString)
{
var builder = new NpgsqlConnectionStringBuilder(connectionString);
var database = builder.Database;
builder.Database = "postgres";
using var connection = new NpgsqlConnection(builder.ConnectionString);
connection.Open();
var exists = connection.ExecuteScalar<bool>(
"SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = @db)",
new { db = database });
if (!exists)
{
connection.Execute($"CREATE DATABASE \"{database}\"");
}
}
}

View file

@ -0,0 +1,238 @@
using System.Reflection;
using Books.Api.Infrastructure;
using EventFlow.Aggregates;
using EventFlow.EventStores;
using EventFlow.Subscribers;
using Hangfire;
namespace Books.Api.EventFlow.Infrastructure;
#pragma warning disable CS9113 // Parameter is unread
public class DispatchToSubscriberResilienceStrategy(
ILogger<DispatchToSubscriberResilienceStrategy> logger,
IScheduler scheduler,
IServiceProvider serviceProvider,
IEventJsonSerializer eventJsonSerializer) : IDispatchToSubscriberResilienceStrategy
#pragma warning restore CS9113
{
public Task BeforeHandleEventAsync(
ISubscribe subscriberTo,
IDomainEvent domainEvent,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task HandleEventFailedAsync(
ISubscribe subscriberTo,
IDomainEvent domainEvent,
Exception exception,
bool swallowException,
CancellationToken cancellationToken)
{
var subscriberType = GetSubscriberType(subscriberTo);
var eventType = domainEvent.EventType.Name;
var aggregateId = domainEvent.GetIdentity()?.Value ?? "unknown";
logger.LogError(exception,
"[RESILIENCE] Subscriber {SubscriberType} failed to handle event {EventType} for aggregate {AggregateId}",
subscriberType.Name,
eventType,
aggregateId);
try
{
var serializedEvent = eventJsonSerializer.Serialize(domainEvent);
var domainEventInterface = domainEvent.GetType().GetTypeInfo()
.GetInterfaces()
.FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDomainEvent<,,>));
if (domainEventInterface != null)
{
scheduler.EnqueueJob<SubscriberRetryJob>(
job => job.RetryEventDispatchAsync(
subscriberType.AssemblyQualifiedName!,
domainEventInterface.AssemblyQualifiedName!,
serializedEvent.SerializedData,
serializedEvent.SerializedMetadata),
TimeSpan.FromSeconds(30));
logger.LogWarning(
"[RESILIENCE] Scheduled retry job for {SubscriberType} / {EventType} / {AggregateId}",
subscriberType.Name,
eventType,
aggregateId);
}
}
catch (Exception e)
{
logger.LogError(e,
"[RESILIENCE] Failed to schedule retry for {SubscriberType} / {EventType}. " +
"This event will NOT be retried and may cause data inconsistency.",
subscriberType.Name,
eventType);
}
return Task.CompletedTask;
}
public Task HandleEventSucceededAsync(
ISubscribe subscriberTo,
IDomainEvent domainEvent,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task BeforeDispatchToSubscribersAsync(
IDomainEvent domainEvent,
IReadOnlyCollection<IDomainEvent> domainEvents,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task DispatchToSubscribersSucceededAsync(
IDomainEvent domainEvent,
IReadOnlyCollection<IDomainEvent> domainEvents,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task<bool> HandleDispatchToSubscribersFailedAsync(
IDomainEvent domainEvent,
IReadOnlyCollection<IDomainEvent> domainEvents,
Exception exception,
CancellationToken cancellationToken)
{
return Task.FromResult(true);
}
private static Type GetSubscriberType(ISubscribe subscriberTo)
{
if (subscriberTo is ISubscribeDecorator decorator)
{
return GetSubscriberType(decorator.InnerInstance as ISubscribe
?? throw new InvalidOperationException("InnerInstance is not ISubscribe"));
}
return subscriberTo.GetType();
}
}
public interface ISubscribeDecorator
{
object InnerInstance { get; }
}
public class SubscriberRetryJob(
ILogger<SubscriberRetryJob> logger,
IServiceProvider serviceProvider,
IEventJsonSerializer eventJsonSerializer)
{
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])]
public async Task RetryEventDispatchAsync(
string subscriberTypeName,
string domainEventTypeName,
string serializedData,
string serializedMetadata)
{
logger.LogInformation(
"[RETRY] Retrying event dispatch to {SubscriberType}",
subscriberTypeName);
try
{
var domainEvent = eventJsonSerializer.Deserialize(serializedData, serializedMetadata);
var subscriberType = Type.GetType(subscriberTypeName);
if (subscriberType == null)
{
logger.LogError("[RETRY] Could not resolve subscriber type: {SubscriberType}", subscriberTypeName);
return;
}
var subscriber = FindSubscriberInstance(subscriberType, domainEvent);
if (subscriber == null)
{
logger.LogError("[RETRY] Could not find subscriber instance for {SubscriberType}", subscriberTypeName);
return;
}
var domainEventType = domainEvent.GetType();
var handleAsyncMethod = subscriber.GetType().GetMethods()
.FirstOrDefault(m => m.Name == "HandleAsync" &&
m.GetParameters().Any(p => p.ParameterType.IsAssignableFrom(domainEventType)));
if (handleAsyncMethod != null)
{
await (Task)handleAsyncMethod.Invoke(subscriber, [domainEvent, CancellationToken.None])!;
logger.LogInformation("[RETRY] Successfully retried event dispatch to {SubscriberType}", subscriberTypeName);
}
else
{
logger.LogError(
"[RETRY] No matching HandleAsync method found for {SubscriberType} and {DomainEventType}",
subscriberTypeName,
domainEventTypeName);
}
}
catch (Exception ex)
{
logger.LogError(ex, "[RETRY] Failed to retry event dispatch to {SubscriberType}", subscriberTypeName);
throw;
}
}
private object? FindSubscriberInstance(Type subscriberType, object domainEvent)
{
var interfaceType = ExtractSubscriberInterface(subscriberType, domainEvent);
if (interfaceType == null)
return null;
var instances = serviceProvider.GetServices(interfaceType);
return instances.FirstOrDefault(x =>
x?.GetType() == subscriberType ||
HasInnerType(x, subscriberType));
}
private static Type? ExtractSubscriberInterface(Type subscriberType, object domainEvent)
{
var domainEventType = domainEvent.GetType();
var interfaces = subscriberType.GetInterfaces()
.Where(i => i.IsGenericType &&
(i.GetGenericTypeDefinition() == typeof(ISubscribeAsynchronousTo<,,>) ||
i.GetGenericTypeDefinition() == typeof(ISubscribeSynchronousTo<,,>)));
foreach (var iface in interfaces)
{
var genericArguments = iface.GetGenericArguments();
var eventGenericArgs = domainEventType.GenericTypeArguments;
if (eventGenericArgs.Length >= 3 && genericArguments[2] == eventGenericArgs[2])
{
return iface;
}
}
return null;
}
private static bool HasInnerType(object? obj, Type concreteType)
{
if (obj == null) return false;
var decoratorType = obj.GetType();
var field = decoratorType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance)
.FirstOrDefault(x => x.Name.Contains("inner", StringComparison.InvariantCultureIgnoreCase));
var property = decoratorType.GetProperties(BindingFlags.NonPublic | BindingFlags.Instance)
.FirstOrDefault(x => x.Name.Contains("inner", StringComparison.InvariantCultureIgnoreCase));
if (field?.GetValue(obj)?.GetType() == concreteType)
return true;
if (property?.GetValue(obj)?.GetType() == concreteType)
return true;
return false;
}
}

View file

@ -0,0 +1,95 @@
using Books.Api.Infrastructure;
using EventFlow.Aggregates;
using EventFlow.ReadStores;
using Hangfire;
namespace Books.Api.EventFlow.Infrastructure;
public class ReadStoresResilienceStrategy(
ILogger<ReadStoresResilienceStrategy> logger,
IScheduler scheduler) : IDispatchToReadStoresResilienceStrategy
{
public Task BeforeUpdateAsync(
IReadStoreManager readStoreManager,
IReadOnlyCollection<IDomainEvent> domainEvents,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task<bool> HandleUpdateFailedAsync(
IReadStoreManager readStoreManager,
IReadOnlyCollection<IDomainEvent> domainEvents,
Exception exception,
CancellationToken cancellationToken)
{
var firstEvent = domainEvents.FirstOrDefault();
var aggregateId = firstEvent?.GetIdentity()?.Value ?? "unknown";
var readModelType = readStoreManager.ReadModelType.Name;
logger.LogError(exception,
"[RESILIENCE] Failed to update read model {ReadModelType} for aggregate {AggregateId}. " +
"Events: {EventCount}. This may cause data inconsistency.",
readModelType,
aggregateId,
domainEvents.Count);
// Schedule a retry job to repopulate the read model
if (firstEvent != null)
{
var aggregateType = firstEvent.AggregateType.Name;
scheduler.EnqueueJob<ReadModelRepopulationJob>(
job => job.RepopulateReadModelAsync(
aggregateType,
aggregateId,
readModelType),
TimeSpan.FromSeconds(30));
logger.LogWarning(
"[RESILIENCE] Scheduled read model repopulation job for {ReadModelType} / {AggregateId}",
readModelType,
aggregateId);
}
// Return false to indicate we handled the failure (don't rethrow)
return Task.FromResult(false);
}
public Task UpdateSucceededAsync(
IReadStoreManager readStoreManager,
IReadOnlyCollection<IDomainEvent> domainEvents,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
public class ReadModelRepopulationJob(ILogger<ReadModelRepopulationJob> logger)
{
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])]
public Task RepopulateReadModelAsync(
string aggregateType,
string aggregateId,
string readModelType)
{
logger.LogInformation(
"[REPOPULATION] Starting read model repopulation for {ReadModelType} / {AggregateId}",
readModelType,
aggregateId);
// For now, we log the repopulation attempt
// A full implementation would:
// 1. Load all events for the aggregate from the event store
// 2. Clear the existing read model entry
// 3. Replay all events to rebuild the read model
logger.LogWarning(
"[REPOPULATION] Read model repopulation for {ReadModelType} / {AggregateId} " +
"requires manual intervention. Check data consistency.",
readModelType,
aggregateId);
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,80 @@
using System.ComponentModel.DataAnnotations.Schema;
using Books.Api.Domain.Companies;
using Books.Api.Domain.Companies.Events;
using EventFlow.Aggregates;
using EventFlow.PostgreSql.ReadStores.Attributes;
using EventFlow.ReadStores;
namespace Books.Api.EventFlow.ReadModels;
[Table("company_read_models")]
public class CompanyReadModel : IReadModel,
IAmReadModelFor<CompanyAggregate, CompanyId, CompanyCreatedEvent>,
IAmReadModelFor<CompanyAggregate, CompanyId, CompanyUpdatedEvent>
{
// EventFlow standard columns
[PostgreSqlReadModelIdentityColumn]
public string AggregateId { get; set; } = string.Empty;
public DateTimeOffset CreateTime { get; set; }
public DateTimeOffset UpdatedTime { get; set; }
[PostgreSqlReadModelVersionColumn]
public int LastAggregateSequenceNumber { get; set; }
// Business columns
public string Name { get; set; } = string.Empty;
public string? Cvr { get; set; }
public string? Address { get; set; }
public string? PostalCode { get; set; }
public string? City { get; set; }
public string Country { get; set; } = "DK";
public int FiscalYearStartMonth { get; set; } = 1;
public string Currency { get; set; } = "DKK";
public bool VatRegistered { get; set; }
public string? VatPeriodFrequency { get; set; }
public Task ApplyAsync(
IReadModelContext context,
IDomainEvent<CompanyAggregate, CompanyId, CompanyCreatedEvent> domainEvent,
CancellationToken cancellationToken)
{
var e = domainEvent.AggregateEvent;
AggregateId = domainEvent.AggregateIdentity.Value;
CreateTime = domainEvent.Timestamp;
UpdatedTime = domainEvent.Timestamp;
LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber;
Name = e.Name;
Cvr = e.Cvr;
Address = e.Address;
PostalCode = e.PostalCode;
City = e.City;
Country = e.Country;
FiscalYearStartMonth = e.FiscalYearStartMonth;
Currency = e.Currency;
VatRegistered = e.VatRegistered;
VatPeriodFrequency = e.VatPeriodFrequency;
return Task.CompletedTask;
}
public Task ApplyAsync(
IReadModelContext context,
IDomainEvent<CompanyAggregate, CompanyId, CompanyUpdatedEvent> domainEvent,
CancellationToken cancellationToken)
{
var e = domainEvent.AggregateEvent;
UpdatedTime = domainEvent.Timestamp;
LastAggregateSequenceNumber = (int)domainEvent.AggregateSequenceNumber;
Name = e.Name;
Cvr = e.Cvr;
Address = e.Address;
PostalCode = e.PostalCode;
City = e.City;
Country = e.Country;
FiscalYearStartMonth = e.FiscalYearStartMonth;
Currency = e.Currency;
VatRegistered = e.VatRegistered;
VatPeriodFrequency = e.VatPeriodFrequency;
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,24 @@
namespace Books.Api.EventFlow.ReadModels;
/// <summary>
/// DTO for reading company data from the database.
/// Uses a class with properties instead of a positional record because
/// PostgreSQL returns column names in lowercase, and Dapper matches
/// properties case-insensitively but requires exact constructor parameter names.
/// </summary>
public class CompanyReadModelDto
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Cvr { get; set; }
public string? Address { get; set; }
public string? PostalCode { get; set; }
public string? City { get; set; }
public string Country { get; set; } = string.Empty;
public int FiscalYearStartMonth { get; set; }
public string Currency { get; set; } = string.Empty;
public bool VatRegistered { get; set; }
public string? VatPeriodFrequency { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}

View file

@ -0,0 +1,16 @@
using Books.Api.Domain.Companies;
using EventFlow.Aggregates;
using EventFlow.ReadStores;
namespace Books.Api.EventFlow.ReadModels;
public class CompanyReadModelLocator : IReadModelLocator
{
public IEnumerable<string> GetReadModelIds(IDomainEvent domainEvent)
{
if (domainEvent is IDomainEvent<CompanyAggregate, CompanyId> typedEvent)
{
yield return typedEvent.AggregateIdentity.Value;
}
}
}

View file

@ -0,0 +1,89 @@
using Books.Api.Domain.Companies;
using Books.Api.EventFlow.ReadModels;
using Dapper;
using Npgsql;
namespace Books.Api.EventFlow.Repositories;
public class CompanyRepository(NpgsqlDataSource dataSource) : ICompanyRepository
{
public async Task<CompanyReadModelDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
const string sql = """
SELECT
aggregate_id AS Id,
name AS Name,
cvr AS Cvr,
address AS Address,
postal_code AS PostalCode,
city AS City,
country AS Country,
fiscal_year_start_month AS FiscalYearStartMonth,
currency AS Currency,
vat_registered AS VatRegistered,
vat_period_frequency AS VatPeriodFrequency,
create_time AS CreatedAt,
updated_time AS UpdatedAt
FROM company_read_models
WHERE aggregate_id = @Id
""";
return await connection.QuerySingleOrDefaultAsync<CompanyReadModelDto>(sql, new { Id = id });
}
public async Task<IEnumerable<CompanyReadModelDto>> GetByIds(List<CompanyId> ids,
CancellationToken cancellationToken = default)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
const string sql = """
SELECT
aggregate_id AS Id,
name AS Name,
cvr AS Cvr,
address AS Address,
postal_code AS PostalCode,
city AS City,
country AS Country,
fiscal_year_start_month AS FiscalYearStartMonth,
currency AS Currency,
vat_registered AS VatRegistered,
vat_period_frequency AS VatPeriodFrequency,
create_time AS CreatedAt,
updated_time AS UpdatedAt
FROM company_read_models
WHERE aggregate_id = ANY(@Ids)
""";
return await connection.QueryAsync<CompanyReadModelDto>(sql, new { Ids = ids.Select(i => i.Value).ToArray() });
}
public async Task<IReadOnlyList<CompanyReadModelDto>> GetAllAsync(CancellationToken cancellationToken = default)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
const string sql = """
SELECT
aggregate_id AS Id,
name AS Name,
cvr AS Cvr,
address AS Address,
postal_code AS PostalCode,
city AS City,
country AS Country,
fiscal_year_start_month AS FiscalYearStartMonth,
currency AS Currency,
vat_registered AS VatRegistered,
vat_period_frequency AS VatPeriodFrequency,
create_time AS CreatedAt,
updated_time AS UpdatedAt
FROM company_read_models
ORDER BY name
""";
var result = await connection.QueryAsync<CompanyReadModelDto>(sql);
return result.ToList();
}
}

View file

@ -0,0 +1,11 @@
using Books.Api.Domain.Companies;
using Books.Api.EventFlow.ReadModels;
namespace Books.Api.EventFlow.Repositories;
public interface ICompanyRepository
{
Task<CompanyReadModelDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default);
Task<IEnumerable<CompanyReadModelDto>> GetByIds(List<CompanyId> ids, CancellationToken cancellationToken = default);
Task<IReadOnlyList<CompanyReadModelDto>> GetAllAsync(CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,14 @@
using Books.Api.GraphQL.Mutations;
using Books.Api.GraphQL.Queries;
using GraphQL.Types;
namespace Books.Api.GraphQL;
public class BooksSchema : Schema
{
public BooksSchema(IServiceProvider serviceProvider) : base(serviceProvider)
{
Query = serviceProvider.GetRequiredService<BooksQuery>();
Mutation = serviceProvider.GetRequiredService<BooksMutation>();
}
}

View file

@ -0,0 +1,37 @@
using GraphQL.Types;
namespace Books.Api.GraphQL.InputTypes;
public class CreateCompanyInputType : InputObjectGraphType<CreateCompanyInput>
{
public CreateCompanyInputType()
{
Name = "CreateCompanyInput";
Description = "Input for creating a new company";
Field(x => x.Name).Description("Company name (required)");
Field(x => x.Cvr, nullable: true).Description("Danish CVR number");
Field(x => x.Address, nullable: true).Description("Street address");
Field(x => x.PostalCode, nullable: true).Description("Postal code");
Field(x => x.City, nullable: true).Description("City");
Field(x => x.Country, nullable: true).Description("Country code (default: DK)");
Field(x => x.FiscalYearStartMonth, nullable: true).Description("Month when fiscal year starts (default: 1)");
Field(x => x.Currency, nullable: true).Description("Default currency (default: DKK)");
Field(x => x.VatRegistered, nullable: true).Description("Whether VAT registered (default: false)");
Field(x => x.VatPeriodFrequency, nullable: true).Description("VAT reporting frequency");
}
}
public class CreateCompanyInput
{
public string Name { get; set; } = string.Empty;
public string? Cvr { get; set; }
public string? Address { get; set; }
public string? PostalCode { get; set; }
public string? City { get; set; }
public string? Country { get; set; }
public int? FiscalYearStartMonth { get; set; }
public string? Currency { get; set; }
public bool? VatRegistered { get; set; }
public string? VatPeriodFrequency { get; set; }
}

View file

@ -0,0 +1,37 @@
using GraphQL.Types;
namespace Books.Api.GraphQL.InputTypes;
public class UpdateCompanyInputType : InputObjectGraphType<UpdateCompanyInput>
{
public UpdateCompanyInputType()
{
Name = "UpdateCompanyInput";
Description = "Input for updating an existing company";
Field(x => x.Name).Description("Company name (required)");
Field(x => x.Cvr, nullable: true).Description("Danish CVR number");
Field(x => x.Address, nullable: true).Description("Street address");
Field(x => x.PostalCode, nullable: true).Description("Postal code");
Field(x => x.City, nullable: true).Description("City");
Field(x => x.Country, nullable: true).Description("Country code");
Field(x => x.FiscalYearStartMonth, nullable: true).Description("Month when fiscal year starts");
Field(x => x.Currency, nullable: true).Description("Default currency");
Field(x => x.VatRegistered, nullable: true).Description("Whether VAT registered");
Field(x => x.VatPeriodFrequency, nullable: true).Description("VAT reporting frequency");
}
}
public class UpdateCompanyInput
{
public string Name { get; set; } = string.Empty;
public string? Cvr { get; set; }
public string? Address { get; set; }
public string? PostalCode { get; set; }
public string? City { get; set; }
public string? Country { get; set; }
public int? FiscalYearStartMonth { get; set; }
public string? Currency { get; set; }
public bool? VatRegistered { get; set; }
public string? VatPeriodFrequency { get; set; }
}

View file

@ -0,0 +1,82 @@
using Books.Api.Commands.Companies;
using Books.Api.Domain.Companies;
using Books.Api.EventFlow.Repositories;
using Books.Api.GraphQL.InputTypes;
using Books.Api.GraphQL.Types;
using EventFlow;
using GraphQL;
using GraphQL.Types;
namespace Books.Api.GraphQL.Mutations;
public class BooksMutation : ObjectGraphType
{
public BooksMutation()
{
Name = "Mutation";
Description = "Root mutation for the Books API";
// createCompany(input: CreateCompanyInput!): CompanyType
Field<CompanyType>("createCompany")
.Description("Create a new company")
.Argument<NonNullGraphType<CreateCompanyInputType>>("input", "The company data")
.ResolveAsync(async ctx =>
{
var input = ctx.GetArgument<CreateCompanyInput>("input");
var commandBus = ctx.RequestServices!.GetRequiredService<ICommandBus>();
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
var companyId = CompanyId.New;
var command = new CreateCompanyCommand(
companyId,
input.Name,
input.Cvr,
input.Address,
input.PostalCode,
input.City,
input.Country ?? "DK",
input.FiscalYearStartMonth ?? 1,
input.Currency ?? "DKK",
input.VatRegistered ?? false,
input.VatPeriodFrequency);
await commandBus.PublishAsync(command, ctx.CancellationToken);
// Return the created company (eventually consistent)
return await repository.GetByIdAsync(companyId.Value, ctx.CancellationToken);
});
// updateCompany(id: ID!, input: UpdateCompanyInput!): CompanyType
Field<CompanyType>("updateCompany")
.Description("Update an existing company")
.Argument<NonNullGraphType<IdGraphType>>("id", "The company ID")
.Argument<NonNullGraphType<UpdateCompanyInputType>>("input", "The updated company data")
.ResolveAsync(async ctx =>
{
var id = ctx.GetArgument<string>("id");
var input = ctx.GetArgument<UpdateCompanyInput>("input");
var commandBus = ctx.RequestServices!.GetRequiredService<ICommandBus>();
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
var companyId = CompanyId.With(id);
var command = new UpdateCompanyCommand(
companyId,
input.Name,
input.Cvr,
input.Address,
input.PostalCode,
input.City,
input.Country ?? "DK",
input.FiscalYearStartMonth ?? 1,
input.Currency ?? "DKK",
input.VatRegistered ?? false,
input.VatPeriodFrequency);
await commandBus.PublishAsync(command, ctx.CancellationToken);
return await repository.GetByIdAsync(companyId.Value, ctx.CancellationToken);
});
}
}

View file

@ -0,0 +1,37 @@
using Books.Api.Domain.Companies;
using Books.Api.EventFlow.Repositories;
using Books.Api.GraphQL.Types;
using GraphQL;
using GraphQL.Types;
namespace Books.Api.GraphQL.Queries;
public class BooksQuery : ObjectGraphType
{
public BooksQuery()
{
Name = "Query";
Description = "Root query for the Books API";
// companies: [CompanyType]
Field<ListGraphType<CompanyType>>("companies")
.Description("Get all companies")
.ResolveAsync(async ctx =>
{
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
return await repository.GetAllAsync(ctx.CancellationToken);
});
// company(id: ID!): CompanyType
Field<CompanyType>("company")
.Description("Get a company by ID")
.Argument<NonNullGraphType<IdGraphType>>("id", "The company ID")
.ResolveAsync(async ctx =>
{
var id = ctx.GetArgument<string>("id");
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
var companies = await repository.GetByIds([CompanyId.With(id)], ctx.CancellationToken);
return companies.FirstOrDefault();
});
}
}

View file

@ -0,0 +1,31 @@
using Books.Api.EventFlow.ReadModels;
using GraphQL.Types;
namespace Books.Api.GraphQL.Types;
public class CompanyType : ObjectGraphType<CompanyReadModelDto>
{
public CompanyType()
{
Name = "Company";
Description = "A company/organization for bookkeeping";
Field(x => x.Id).Description("Unique identifier");
Field(x => x.Name).Description("Company name");
Field(x => x.Cvr, nullable: true).Description("Danish CVR number");
Field(x => x.Address, nullable: true).Description("Street address");
Field(x => x.PostalCode, nullable: true).Description("Postal code");
Field(x => x.City, nullable: true).Description("City");
Field(x => x.Country).Description("Country code (e.g., DK)");
Field(x => x.FiscalYearStartMonth).Description("Month when fiscal year starts (1-12)");
Field(x => x.Currency).Description("Default currency (e.g., DKK)");
Field(x => x.VatRegistered).Description("Whether the company is VAT registered");
Field(x => x.VatPeriodFrequency, nullable: true).Description("VAT reporting frequency");
Field(x => x.CreatedAt).Description("When the company was created");
Field(x => x.UpdatedAt).Description("When the company was last updated");
// Nested fields will be added later:
// - fiscalYears: [FiscalYearType] via DataLoader
// - accounts: [AccountType] via DataLoader
}
}

View file

@ -0,0 +1,27 @@
using System.Linq.Expressions;
using Hangfire;
namespace Books.Api.Infrastructure;
public class HangfireScheduler(IBackgroundJobClient jobClient, IRecurringJobManager recurringJobManager) : IScheduler
{
public void EnqueueJob<T>(Expression<Func<T, Task>> methodCall)
{
jobClient.Enqueue(methodCall);
}
public void EnqueueJob<T>(Expression<Func<T, Task>> methodCall, TimeSpan delay)
{
jobClient.Schedule(methodCall, delay);
}
public void EnqueueJob(Expression<Func<Task>> methodCall)
{
jobClient.Enqueue(methodCall);
}
public void AddOrUpdateScheduledJob<T>(string title, Expression<Func<T?, Task>> methodCall, string cron)
{
recurringJobManager.AddOrUpdate(title, methodCall, cron);
}
}

View file

@ -0,0 +1,11 @@
using System.Linq.Expressions;
namespace Books.Api.Infrastructure;
public interface IScheduler
{
void EnqueueJob<T>(Expression<Func<T, Task>> methodCall);
void EnqueueJob<T>(Expression<Func<T, Task>> methodCall, TimeSpan delay);
void EnqueueJob(Expression<Func<Task>> methodCall);
void AddOrUpdateScheduledJob<T>(string title, Expression<Func<T?, Task>> methodCall, string cron);
}

View file

@ -0,0 +1,13 @@
namespace Books.Api.Logging;
public static class ServiceCollectionExtensions
{
public static IServiceCollection DecorateAsyncEventHandlersWithLogging(this IServiceCollection services)
{
// Decoration will be set up once we have event handlers registered
// The SubscribeAsynchronousToDecorator wraps handlers with logging
// This is a placeholder for now - implement when we have actual event handlers
return services;
}
}

View file

@ -0,0 +1,48 @@
using System.Diagnostics;
using EventFlow.Aggregates;
using EventFlow.Core;
using EventFlow.Subscribers;
namespace Books.Api.Logging;
public class SubscribeAsynchronousToDecorator<TAggregate, TIdentity, TEvent>(
ISubscribeAsynchronousTo<TAggregate, TIdentity, TEvent> inner,
ILogger<SubscribeAsynchronousToDecorator<TAggregate, TIdentity, TEvent>> logger)
: ISubscribeAsynchronousTo<TAggregate, TIdentity, TEvent>
where TAggregate : IAggregateRoot<TIdentity>
where TIdentity : IIdentity
where TEvent : IAggregateEvent<TAggregate, TIdentity>
{
public async Task HandleAsync(
IDomainEvent<TAggregate, TIdentity, TEvent> domainEvent,
CancellationToken cancellationToken)
{
var eventName = typeof(TEvent).Name;
var handlerName = inner.GetType().Name;
var aggregateId = domainEvent.AggregateIdentity.Value;
logger.LogDebug(
"Handling {EventName} for {AggregateId} with {HandlerName}",
eventName, aggregateId, handlerName);
var stopwatch = Stopwatch.StartNew();
try
{
await inner.HandleAsync(domainEvent, cancellationToken);
stopwatch.Stop();
logger.LogInformation(
"Handled {EventName} for {AggregateId} with {HandlerName} in {ElapsedMs}ms",
eventName, aggregateId, handlerName, stopwatch.ElapsedMilliseconds);
}
catch (Exception ex)
{
stopwatch.Stop();
logger.LogError(ex,
"Failed to handle {EventName} for {AggregateId} with {HandlerName} after {ElapsedMs}ms",
eventName, aggregateId, handlerName, stopwatch.ElapsedMilliseconds);
throw;
}
}
}

View file

@ -0,0 +1,35 @@
using Books.Api;
using Books.Api.GraphQL;
using GraphQL;
using GraphQL.Server.Ui.Altair;
using Hangfire;
// Enable legacy timestamp behavior for Npgsql 6.0+
// This allows DateTimeOffset with non-UTC offsets to be written to timestamptz columns
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
var builder = WebApplication.CreateBuilder(args);
// Configure EventFlow, Hangfire, GraphQL and all services
Startup.ConfigureServices(builder.Services, builder.Configuration, builder.Environment);
var app = builder.Build();
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.UseHangfireDashboard();
}
app.UseHttpsRedirection();
// GraphQL endpoint
app.UseGraphQL<BooksSchema>("/graphql");
// GraphQL UI (development only) - use external tools like Altair, Insomnia, or GraphiQL
if (app.Environment.IsDevelopment())
{
app.UseGraphQLAltair("/graphql/ui");
}
app.Run();

View file

@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5142",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7141;http://localhost:5142",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -0,0 +1,77 @@
using Books.Api.EventFlow.Extensions;
using Books.Api.EventFlow.Infrastructure;
using Books.Api.GraphQL;
using Books.Api.Infrastructure;
using Books.Api.Logging;
using EventFlow;
using EventFlow.Configuration;
using EventFlow.Extensions;
using EventFlow.Hangfire.Extensions;
using EventFlow.PostgreSql.Connections;
using EventFlow.PostgreSql.Extensions;
using EventFlow.ReadStores;
using EventFlow.Subscribers;
using GraphQL;
using Hangfire;
using Hangfire.PostgreSql;
using Npgsql;
namespace Books.Api;
public static class Startup
{
public static void ConfigureServices(IServiceCollection services, IConfiguration config, IHostEnvironment? environment = null)
{
var connectionString = config.GetConnectionString("Default")
?? throw new InvalidOperationException("Connection string 'Default' not found");
// Run database migrations (skipped in Test environment where migrations run separately with test connection string)
var isTestEnvironment = environment?.EnvironmentName == "Test";
if (!isTestEnvironment)
{
DatabaseMigrator.Migrate(connectionString);
}
// PostgreSQL data source
var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
services.AddSingleton(dataSource);
// Hangfire
services.AddHangfire(c => c
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UsePostgreSqlStorage(o => o.UseNpgsqlConnection(connectionString)));
services.AddHangfireServer();
// Scheduler abstraction over Hangfire
services.AddSingleton<IScheduler, HangfireScheduler>();
// EventFlow
services.AddEventFlow(o => o
.UsePostgreSqlEventStore()
.ConfigurePostgreSql(PostgreSqlConfiguration.New.SetConnectionString(connectionString))
.AddDefaults(typeof(Startup).Assembly)
.AddReadModels()
.Configure(c => c.IsAsynchronousSubscribersEnabled = true)
.UseHangfireJobScheduler());
// Resilience strategies
services.AddSingleton<IDispatchToReadStoresResilienceStrategy, ReadStoresResilienceStrategy>();
services.AddSingleton<IDispatchToSubscriberResilienceStrategy, DispatchToSubscriberResilienceStrategy>();
// Read model repositories
services.AddRepositories();
// Logging decorators
services.DecorateAsyncEventHandlersWithLogging();
// GraphQL
services.AddGraphQL(builder => builder
.AddSchema<BooksSchema>()
.AddSystemTextJson()
.AddDataLoader()
.AddGraphTypes(typeof(BooksSchema).Assembly)
.AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = true));
}
}

View file

@ -0,0 +1,12 @@
namespace Books.Api;
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string? Summary { get; set; }
}

View file

@ -0,0 +1,14 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"EventFlow": "Information",
"Hangfire": "Information"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Default": "Host=localhost;Database=books;Username=postgres;Password=postgres;Include Error Detail=true"
}
}

11
backend/Books.slnx Normal file
View file

@ -0,0 +1,11 @@
<Solution>
<Project Path="..\..\enable-banking-access\src\EnableBanking.Client\EnableBanking.Client.csproj" Type="Classic C#" />
<Project Path="..\..\enable-banking-access\src\EnableBanking.Core\EnableBanking.Core.csproj" Type="Classic C#" />
<Project Path="..\..\enable-banking-access\src\EnableBanking.Persistence.Postgres\EnableBanking.Persistence.Postgres.csproj" Type="Classic C#" />
<Project Path="..\..\enable-banking-access\tests\EnableBanking.Client.Tests\EnableBanking.Client.Tests.csproj" Type="Classic C#" />
<Project Path="..\..\enable-banking-access\tests\EnableBanking.Core.Tests\EnableBanking.Core.Tests.csproj" Type="Classic C#" />
<Project Path="..\..\ledger\src\Ledger.Core\Ledger.Core.csproj" Type="Classic C#" />
<Project Path="..\..\ledger\src\Ledger.Infrastructure\Ledger.Infrastructure.csproj" Type="Classic C#" />
<Project Path="Books.Api.Tests\Books.Api.Tests.csproj" Type="Classic C#" />
<Project Path="Books.Api/Books.Api.csproj" />
</Solution>