Full product audit: fix security, compliance, UX, and wire broken features

Security (Phase 1):
- Add authentication middleware on /graphql endpoint
- Filter company queries by user access (prevent IDOR)
- Add role-based authorization on mutations (owner/accountant)
- Reduce API key cache TTL from 24h to 5 minutes
- Hide exception details in production GraphQL errors
- Fix RBAC in frontend companyStore (was hardcoded)

Wiring broken features (Phase 2):
- Wire Kassekladde submit/void/copy to GraphQL mutations
- Wire Kontooversigt account creation to createAccount mutation
- Wire Settings save to updateCompany mutation
- Wire CreateFiscalYearModal and CloseFiscalYearWizard to mutations
- Replace Momsindberetning mock data with real useVatReport query
- Remove Dashboard hardcoded percentages and fake VAT deadline
- Fix Kreditnotaer invoice selector to use real data
- Fix mutation retry from 1 to 0 (prevent duplicate operations)

Accounting compliance (Phase 3):
- Add balanced entry validation (debit==credit) in JournalEntryDraftAggregate
- Add fiscal year boundary enforcement (status, date range checks)
- Add PostedAt timestamp to posted events (Bogføringsloven §7)
- Add account number uniqueness check within company
- Add fiscal year overlap and gap checks
- Add sequential invoice auto-numbering
- Fix InvoiceLine VAT rate to use canonical VatCodes
- Fix SAF-T account type mapping (financial → Expense)
- Add DraftLine validation (cannot have both debit and credit > 0)

UX improvements (Phase 4):
- Fix Danish character encoding across 15+ files (ø, æ, å)
- Deploy DemoDataDisclaimer on pages with mock/incomplete data
- Adopt PageHeader component universally across all pages
- Standardize active/inactive filtering to Switch pattern
- Fix dead buttons in Header (Help, Notifications)
- Remove hardcoded mock data from Settings
- Fix Sidebar controlled state and Kontooversigt navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nicolaj Hartmann 2026-02-05 21:35:26 +01:00
parent effb06fc44
commit 8e05171b66
49 changed files with 1537 additions and 1192 deletions

View file

@ -243,26 +243,27 @@ public class JournalEntryDraftAggregateTests
public void MarkPosted_WhenActive_EmitsPostedEvent()
{
// Arrange
var aggregate = CreateActiveDraft();
var aggregate = CreateActiveDraftWithLines();
// Act
aggregate.MarkPosted("transaction-123", "user@example.com");
// Assert
var uncommittedEvents = aggregate.UncommittedEvents.ToList();
uncommittedEvents.Should().HaveCount(2); // Created + Posted
uncommittedEvents.Should().HaveCount(3); // Created + Updated + Posted
var postedEvent = uncommittedEvents[1].AggregateEvent as JournalEntryDraftPostedEvent;
var postedEvent = uncommittedEvents[2].AggregateEvent as JournalEntryDraftPostedEvent;
postedEvent.Should().NotBeNull();
postedEvent!.TransactionId.Should().Be("transaction-123");
postedEvent.PostedBy.Should().Be("user@example.com");
postedEvent.PostedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public void MarkPosted_WithEmptyTransactionId_ThrowsDomainException()
{
// Arrange
var aggregate = CreateActiveDraft();
var aggregate = CreateActiveDraftWithLines();
// Act
var act = () => aggregate.MarkPosted(" ", "user@example.com");
@ -276,7 +277,7 @@ public class JournalEntryDraftAggregateTests
public void MarkPosted_WithEmptyPostedBy_ThrowsDomainException()
{
// Arrange
var aggregate = CreateActiveDraft();
var aggregate = CreateActiveDraftWithLines();
// Act
var act = () => aggregate.MarkPosted("transaction-123", "");
@ -375,9 +376,21 @@ public class JournalEntryDraftAggregateTests
return aggregate;
}
private static JournalEntryDraftAggregate CreatePostedDraft()
private static JournalEntryDraftAggregate CreateActiveDraftWithLines()
{
var aggregate = CreateActiveDraft();
var lines = new List<DraftLine>
{
new(1, "account-1", 1000m, 0m, "Debet"),
new(2, "account-2", 0m, 1000m, "Kredit")
};
aggregate.Update("Test Draft", DateOnly.FromDateTime(DateTime.Today), "Description", "fiscalyear-1", lines);
return aggregate;
}
private static JournalEntryDraftAggregate CreatePostedDraft()
{
var aggregate = CreateActiveDraftWithLines();
aggregate.MarkPosted("transaction-123", "user@example.com");
return aggregate;
}

View file

@ -12,7 +12,7 @@ namespace Books.Api.Authentication;
public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
{
public string HeaderName { get; set; } = ApiKeyDefaults.HeaderName;
public TimeSpan CacheDuration { get; set; } = TimeSpan.FromHours(24);
public TimeSpan CacheDuration { get; set; } = TimeSpan.FromMinutes(5);
}
public class ApiKeyAuthenticationHandler(

View file

@ -1,15 +1,35 @@
using Books.Api.Domain;
using Books.Api.Domain.Accounts;
using Books.Api.EventFlow.Repositories;
using EventFlow.Commands;
namespace Books.Api.Commands.Accounts;
public class CreateAccountCommandHandler : CommandHandler<AccountAggregate, AccountId, CreateAccountCommand>
/// <summary>
/// Command handler for creating a new account.
/// Validates that the account number is unique within the company.
/// </summary>
public class CreateAccountCommandHandler(
IAccountRepository accountRepository)
: CommandHandler<AccountAggregate, AccountId, CreateAccountCommand>
{
public override Task ExecuteAsync(
public override async Task ExecuteAsync(
AccountAggregate aggregate,
CreateAccountCommand command,
CancellationToken cancellationToken)
{
// Check if an account with the same number already exists for this company
var existingAccount = await accountRepository.GetByCompanyAndNumberAsync(
command.CompanyId, command.AccountNumber, cancellationToken);
if (existingAccount != null)
{
throw new DomainException(
"ACCOUNT_NUMBER_EXISTS",
$"Account number {command.AccountNumber} already exists for this company",
$"Kontonummer {command.AccountNumber} eksisterer allerede");
}
aggregate.Create(
command.CompanyId,
command.AccountNumber,
@ -20,8 +40,6 @@ public class CreateAccountCommandHandler : CommandHandler<AccountAggregate, Acco
command.VatCodeId,
command.IsSystemAccount,
command.StandardAccountNumber);
return Task.CompletedTask;
}
}

View file

@ -1,15 +1,64 @@
using Books.Api.Domain;
using Books.Api.Domain.FiscalYears;
using Books.Api.EventFlow.Repositories;
using EventFlow.Commands;
namespace Books.Api.Commands.FiscalYears;
public class CreateFiscalYearCommandHandler : CommandHandler<FiscalYearAggregate, FiscalYearId, CreateFiscalYearCommand>
/// <summary>
/// Command handler for creating a new fiscal year.
/// Validates overlap with existing fiscal years and checks for gaps.
/// </summary>
public class CreateFiscalYearCommandHandler(
IFiscalYearRepository fiscalYearRepository)
: CommandHandler<FiscalYearAggregate, FiscalYearId, CreateFiscalYearCommand>
{
public override Task ExecuteAsync(
public override async Task ExecuteAsync(
FiscalYearAggregate aggregate,
CreateFiscalYearCommand command,
CancellationToken cancellationToken)
{
// Check for overlapping fiscal years
var hasOverlap = await fiscalYearRepository.HasOverlappingYearAsync(
command.CompanyId,
command.StartDate,
command.EndDate,
excludeId: null,
cancellationToken);
if (hasOverlap)
{
throw new DomainException(
"FISCAL_YEAR_OVERLAP",
"The fiscal year overlaps with an existing fiscal year",
"Regnskabsåret overlapper med et eksisterende regnskabsår");
}
// Check for gaps: verify the new start date follows the latest existing fiscal year
if (!command.IsFirstFiscalYear)
{
var existingYears = await fiscalYearRepository.GetByCompanyIdAsync(
command.CompanyId, cancellationToken);
if (existingYears.Count > 0)
{
// Find the latest end date among existing fiscal years
var latestEndDate = existingYears
.Select(fy => DateOnly.FromDateTime(fy.EndDate))
.Max();
var expectedStartDate = latestEndDate.AddDays(1);
if (command.StartDate != expectedStartDate)
{
throw new DomainException(
"FISCAL_YEAR_GAP",
$"Fiscal year must start on {expectedStartDate:yyyy-MM-dd} (day after previous year ends on {latestEndDate:yyyy-MM-dd}). No gaps are allowed between fiscal years.",
$"Regnskabsåret skal starte den {expectedStartDate:yyyy-MM-dd} (dagen efter forrige år slutter den {latestEndDate:yyyy-MM-dd}). Der må ikke være huller mellem regnskabsår.");
}
}
}
aggregate.Create(
command.CompanyId,
command.Name,
@ -17,8 +66,6 @@ public class CreateFiscalYearCommandHandler : CommandHandler<FiscalYearAggregate
command.EndDate,
command.IsFirstFiscalYear,
command.IsReorganization);
return Task.CompletedTask;
}
}

View file

@ -1,23 +1,39 @@
using Books.Api.Domain.Invoices;
using Books.Api.Invoicing.Services;
using EventFlow.Commands;
namespace Books.Api.Commands.Invoices;
public class CreateInvoiceCommandHandler
/// <summary>
/// Command handler for creating invoices.
/// Auto-assigns a sequential invoice number if one is not provided.
/// </summary>
public class CreateInvoiceCommandHandler(
IInvoiceNumberService invoiceNumberService)
: CommandHandler<InvoiceAggregate, InvoiceId, CreateInvoiceCommand>
{
public override Task ExecuteAsync(
public override async Task ExecuteAsync(
InvoiceAggregate aggregate,
CreateInvoiceCommand command,
CancellationToken cancellationToken)
{
// Auto-assign invoice number if not provided
var invoiceNumber = command.InvoiceNumber;
if (string.IsNullOrWhiteSpace(invoiceNumber))
{
invoiceNumber = await invoiceNumberService.GetNextInvoiceNumberAsync(
command.CompanyId,
command.InvoiceDate.Year,
cancellationToken);
}
aggregate.Create(
command.CompanyId,
command.FiscalYearId,
command.CustomerId,
command.CustomerName,
command.CustomerNumber,
command.InvoiceNumber,
invoiceNumber,
command.InvoiceDate,
command.DueDate,
command.PaymentTermsDays,
@ -26,8 +42,6 @@ public class CreateInvoiceCommandHandler
command.Notes,
command.Reference,
command.CreatedBy);
return Task.CompletedTask;
}
}

View file

@ -1,4 +1,6 @@
using Books.Api.Domain;
using Books.Api.Domain.JournalEntryDrafts;
using Books.Api.EventFlow.Repositories;
using EventFlow.Commands;
namespace Books.Api.Commands.JournalEntryDrafts;
@ -42,19 +44,75 @@ public class UpdateJournalEntryDraftCommandHandler
}
}
public class MarkJournalEntryDraftPostedCommandHandler
/// <summary>
/// Command handler for posting a journal entry draft.
/// Validates fiscal year status and date range before allowing the post.
/// </summary>
public class MarkJournalEntryDraftPostedCommandHandler(
IJournalEntryDraftRepository draftRepository,
IFiscalYearRepository fiscalYearRepository)
: CommandHandler<JournalEntryDraftAggregate, JournalEntryDraftId, MarkJournalEntryDraftPostedCommand>
{
public override Task ExecuteAsync(
public override async Task ExecuteAsync(
JournalEntryDraftAggregate aggregate,
MarkJournalEntryDraftPostedCommand command,
CancellationToken cancellationToken)
{
// Load the draft read model to get fiscal year and document date
var draft = await draftRepository.GetByIdAsync(
aggregate.Id.Value, cancellationToken);
var fiscalYearId = draft?.FiscalYearId ?? aggregate.FiscalYearId;
// Validate fiscal year is set
if (string.IsNullOrWhiteSpace(fiscalYearId))
{
throw new DomainException(
"FISCAL_YEAR_REQUIRED",
"Fiscal year is required for posting a journal entry",
"Regnskabsår er påkrævet for bogføring af en postering");
}
// Fetch and validate fiscal year
var fiscalYear = await fiscalYearRepository.GetByIdAsync(
fiscalYearId, cancellationToken);
if (fiscalYear == null)
{
throw new DomainException(
"FISCAL_YEAR_NOT_FOUND",
$"Fiscal year '{fiscalYearId}' not found",
$"Regnskabsår '{fiscalYearId}' blev ikke fundet");
}
// Validate fiscal year is open (not Closed or Locked)
if (fiscalYear.Status != "Open")
{
throw new DomainException(
"FISCAL_YEAR_NOT_OPEN",
$"Fiscal year is {fiscalYear.Status}. Only open fiscal years allow posting.",
$"Regnskabsåret er {fiscalYear.Status}. Kun åbne regnskabsår tillader bogføring.");
}
// Validate document date falls within fiscal year range (if document date is set)
if (draft?.DocumentDate != null)
{
var documentDate = DateOnly.FromDateTime(draft.DocumentDate.Value);
var fyStart = DateOnly.FromDateTime(fiscalYear.StartDate);
var fyEnd = DateOnly.FromDateTime(fiscalYear.EndDate);
if (documentDate < fyStart || documentDate > fyEnd)
{
throw new DomainException(
"DOCUMENT_DATE_OUTSIDE_FISCAL_YEAR",
$"Document date {documentDate:yyyy-MM-dd} falls outside the fiscal year ({fyStart:yyyy-MM-dd} to {fyEnd:yyyy-MM-dd})",
$"Bilagsdato {documentDate:yyyy-MM-dd} ligger uden for regnskabsåret ({fyStart:yyyy-MM-dd} til {fyEnd:yyyy-MM-dd})");
}
}
aggregate.MarkPosted(
command.TransactionId,
command.PostedBy);
return Task.CompletedTask;
}
}

View file

@ -58,6 +58,10 @@ public class BankingController : ControllerBase
try
{
// TODO: Add proper CSRF/state validation. Currently the state parameter
// is used as the connection ID, but it should also include a CSRF token
// that is validated against the user session to prevent cross-site request
// forgery attacks on the OAuth callback.
// State contains the connection ID (set during StartBankConnection)
var connectionId = state;

View file

@ -0,0 +1,9 @@
-- Migration: 030_AddPostedAtColumn
-- Description: Add posted_at column to journal_entry_draft_read_models
-- for audit trail compliance (exact timestamp when draft was posted to ledger)
ALTER TABLE journal_entry_draft_read_models
ADD COLUMN IF NOT EXISTS posted_at TIMESTAMPTZ;
COMMENT ON COLUMN journal_entry_draft_read_models.posted_at IS
'Exact timestamp when the journal entry draft was posted to the ledger';

View file

@ -1,3 +1,5 @@
using Books.Api.Domain;
namespace Books.Api.Domain.Invoices;
/// <summary>
@ -65,15 +67,19 @@ public sealed record InvoiceLine
/// <summary>
/// Gets the VAT rate for this line based on VatCode.
/// Delegates to the canonical VatCodes.GetRate() to ensure consistency.
/// </summary>
private decimal GetVatRate() => VatCode switch
private decimal GetVatRate()
{
"U25" or "I25" => 0.25m, // Danish standard 25%
"UEU" or "IEU" => 0m, // EU sales (reverse charge)
"UEXP" or "IEXP" => 0m, // Export (no VAT)
"INGEN" => 0m, // No VAT
_ => 0.25m // Default to Danish standard
};
if (!VatCodes.IsValid(VatCode))
{
throw new InvalidOperationException(
$"Unknown VAT code '{VatCode}' on invoice line {LineNumber}. " +
$"Valid codes: U25, UEU, UEXP, I25, IEUV, IEUY, IVV, IVY, REP, INGEN");
}
return VatCodes.GetRate(VatCode);
}
/// <summary>
/// Creates an InvoiceLine with validation.

View file

@ -2,10 +2,20 @@ using EventFlow.Aggregates;
namespace Books.Api.Domain.JournalEntryDrafts.Events;
/// <summary>
/// Event emitted when a journal entry draft is posted to the ledger.
/// Includes PostedAt timestamp for audit trail compliance.
/// </summary>
public class JournalEntryDraftPostedEvent(
string transactionId,
string postedBy) : AggregateEvent<JournalEntryDraftAggregate, JournalEntryDraftId>
string postedBy,
DateTimeOffset postedAt) : AggregateEvent<JournalEntryDraftAggregate, JournalEntryDraftId>
{
public string TransactionId { get; } = transactionId;
public string PostedBy { get; } = postedBy;
/// <summary>
/// The exact timestamp when the draft was posted to the ledger.
/// </summary>
public DateTimeOffset PostedAt { get; } = postedAt;
}

View file

@ -10,14 +10,31 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
IEmit<JournalEntryDraftPostedEvent>,
IEmit<JournalEntryDraftDiscardedEvent>
{
/// <summary>
/// Tolerance for floating-point rounding when comparing debit/credit totals.
/// </summary>
private const decimal BalanceTolerance = 0.01m;
private bool _isCreated;
private DraftStatus _status = DraftStatus.Active;
private string _companyId = string.Empty;
private string _voucherNumber = string.Empty;
private string? _fiscalYearId;
private List<DraftLine> _lines = [];
public string CompanyId => _companyId;
public string VoucherNumber => _voucherNumber;
/// <summary>
/// The fiscal year ID assigned during the last update.
/// </summary>
public string? FiscalYearId => _fiscalYearId;
/// <summary>
/// The current draft lines (populated from the last update event).
/// </summary>
public IReadOnlyList<DraftLine> Lines => _lines.AsReadOnly();
#region Apply Methods
public void Apply(JournalEntryDraftCreatedEvent e)
@ -30,7 +47,8 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
public void Apply(JournalEntryDraftUpdatedEvent e)
{
// State is stored in read model, not in aggregate
_fiscalYearId = e.FiscalYearId;
_lines = e.Lines.ToList();
}
public void Apply(JournalEntryDraftPostedEvent e)
@ -97,6 +115,8 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
/// <summary>
/// Updates a journal entry draft (auto-save).
/// Validates that each line has either DebitAmount or CreditAmount (not both),
/// and that VAT codes are valid.
/// </summary>
/// <param name="name">Draft name</param>
/// <param name="documentDate">Bilagsdato - the date of the transaction/document (e.g., invoice date)</param>
@ -126,6 +146,12 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
}
}
// Validate individual lines: cannot have both debit and credit amounts
foreach (var line in lines)
{
ValidateDraftLine(line);
}
Emit(new JournalEntryDraftUpdatedEvent(
name?.Trim(),
documentDate,
@ -135,6 +161,13 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
attachmentIds));
}
/// <summary>
/// Marks the draft as posted after validation.
/// Enforces double-entry bookkeeping: total debits must equal total credits.
/// Requires at least 2 lines with valid account IDs.
/// </summary>
/// <param name="transactionId">The ledger transaction ID</param>
/// <param name="postedBy">User who posted the draft</param>
public void MarkPosted(string transactionId, string postedBy)
{
EnsureCanModify();
@ -151,7 +184,35 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
"Posted by is required",
"Bogført af er påkrævet");
Emit(new JournalEntryDraftPostedEvent(transactionId, postedBy));
// Validate minimum number of lines for double-entry bookkeeping
if (_lines.Count < 2)
throw new DomainException(
"INSUFFICIENT_LINES",
"A journal entry must have at least 2 lines for double-entry bookkeeping",
"En postering skal have mindst 2 linjer for dobbelt bogholderi");
// Validate all lines have account IDs assigned
var linesWithoutAccounts = _lines.Where(l => string.IsNullOrWhiteSpace(l.AccountId)).ToList();
if (linesWithoutAccounts.Count > 0)
{
var lineNumbers = string.Join(", ", linesWithoutAccounts.Select(l => l.LineNumber));
throw new DomainException(
"ACCOUNT_REQUIRED",
$"All lines must have an account. Lines without account: {lineNumbers}",
$"Alle linjer skal have en konto. Linjer uden konto: {lineNumbers}");
}
// Validate debit/credit balance (fundamental double-entry accounting principle)
var totalDebit = _lines.Sum(l => l.DebitAmount);
var totalCredit = _lines.Sum(l => l.CreditAmount);
if (Math.Abs(totalDebit - totalCredit) > BalanceTolerance)
throw new DomainException(
"UNBALANCED_ENTRY",
$"Total debits must equal credits. Debit: {totalDebit:N2}, Credit: {totalCredit:N2}",
$"Debet og kredit skal balancere. Debet: {totalDebit:N2}, Kredit: {totalCredit:N2}");
Emit(new JournalEntryDraftPostedEvent(transactionId, postedBy, DateTimeOffset.UtcNow));
}
public void Discard(string discardedBy)
@ -192,5 +253,29 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
"Kassekladden er blevet kasseret");
}
/// <summary>
/// Validates a single draft line.
/// A line cannot have both DebitAmount > 0 AND CreditAmount > 0.
/// At least one of DebitAmount or CreditAmount must be > 0.
/// </summary>
private static void ValidateDraftLine(DraftLine line)
{
if (line.DebitAmount > 0 && line.CreditAmount > 0)
{
throw new DomainException(
"INVALID_LINE_AMOUNTS",
$"Line {line.LineNumber} cannot have both debit and credit amounts. Use separate lines.",
$"Linje {line.LineNumber} kan ikke have både debet- og kreditbeløb. Brug separate linjer.");
}
if (line.DebitAmount <= 0 && line.CreditAmount <= 0)
{
throw new DomainException(
"MISSING_LINE_AMOUNT",
$"Line {line.LineNumber} must have either a debit or credit amount greater than zero",
$"Linje {line.LineNumber} skal have enten et debet- eller kreditbeløb større end nul");
}
}
#endregion
}

View file

@ -47,6 +47,10 @@ public class JournalEntryDraftReadModel : IReadModel,
public string AttachmentIds { get; set; } = "[]";
public string Status { get; set; } = "active";
public string? TransactionId { get; set; }
/// <summary>
/// The exact timestamp when the draft was posted to the ledger.
/// </summary>
public DateTimeOffset? PostedAt { get; set; }
public string CreatedBy { get; set; } = string.Empty;
/// <summary>
/// Full AI extraction data stored as JSON string.
@ -110,6 +114,7 @@ public class JournalEntryDraftReadModel : IReadModel,
Status = "posted";
TransactionId = domainEvent.AggregateEvent.TransactionId;
PostedAt = domainEvent.AggregateEvent.PostedAt;
return Task.CompletedTask;
}

View file

@ -27,6 +27,10 @@ public class JournalEntryDraftReadModelDto
public string AttachmentIds { get; set; } = "[]";
public string Status { get; set; } = "active";
public string? TransactionId { get; set; }
/// <summary>
/// The exact timestamp when the draft was posted to the ledger.
/// </summary>
public DateTimeOffset? PostedAt { get; set; }
public string CreatedBy { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }

View file

@ -18,9 +18,11 @@ public class JournalEntryDraftRepository(NpgsqlDataSource dataSource) : IJournal
attachment_ids AS AttachmentIds,
status AS Status,
transaction_id AS TransactionId,
posted_at AS PostedAt,
created_by AS CreatedBy,
create_time AS CreatedAt,
updated_time AS UpdatedAt
updated_time AS UpdatedAt,
extraction_data AS ExtractionData
""";
public async Task<JournalEntryDraftReadModelDto?> GetByIdAsync(

View file

@ -1,5 +1,9 @@
using System.Security.Claims;
using Books.Api.Authorization;
using Books.Api.Commands.Companies;
using Books.Api.Commands.UserAccess;
using Books.Api.Domain.Companies;
using Books.Api.Domain.UserAccess;
using Books.Api.EventFlow.Repositories;
using Books.Api.GraphQL.InputTypes;
using Books.Api.GraphQL.Types;
@ -43,6 +47,17 @@ public class BooksMutation : ObjectGraphType
await commandBus.PublishAsync(command, ctx.CancellationToken);
// Grant the creating user owner access to the new company
var httpContext = ctx.RequestServices!.GetRequiredService<IHttpContextAccessor>().HttpContext;
var userId = httpContext?.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userId != null)
{
var accessId = UserCompanyAccessId.FromUserAndCompany(userId, companyId.Value);
var grantCmd = new GrantUserCompanyAccessCommand(
accessId, userId, companyId.Value, CompanyRole.Owner, userId);
await commandBus.PublishAsync(grantCmd, ctx.CancellationToken);
}
// Return the created company (eventually consistent)
return await repository.GetByIdAsync(companyId.Value, ctx.CancellationToken);
});
@ -55,6 +70,11 @@ public class BooksMutation : ObjectGraphType
.ResolveAsync(async ctx =>
{
var id = ctx.GetArgument<string>("id");
// Require Owner or Accountant role to update a company
var accessService = ctx.RequestServices!.GetRequiredService<ICompanyAccessService>();
await accessService.RequireAccessAsync(id, CompanyRole.Accountant, ctx.CancellationToken);
var input = ctx.GetArgument<UpdateCompanyInput>("input");
var commandBus = ctx.RequestServices!.GetRequiredService<ICommandBus>();
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();

View file

@ -1,4 +1,6 @@
using Books.Api.Authorization;
using Books.Api.Domain.Companies;
using Books.Api.Domain.UserAccess;
using Books.Api.EventFlow.Repositories;
using Books.Api.GraphQL.Types;
using GraphQL;
@ -15,11 +17,15 @@ public class BooksQuery : ObjectGraphType
// companies: [CompanyType]
Field<ListGraphType<CompanyType>>("companies")
.Description("Get all companies")
.Description("Get all companies accessible to the current user")
.ResolveAsync(async ctx =>
{
var accessService = ctx.RequestServices!.GetRequiredService<ICompanyAccessService>();
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
return await repository.GetAllAsync(ctx.CancellationToken);
var userAccesses = await accessService.GetUserCompaniesAsync(ctx.CancellationToken);
var companyIds = userAccesses.Select(a => CompanyId.With(a.CompanyId)).ToList();
if (companyIds.Count == 0) return Enumerable.Empty<object>();
return await repository.GetByIds(companyIds, ctx.CancellationToken);
});
// company(id: ID!): CompanyType
@ -29,6 +35,8 @@ public class BooksQuery : ObjectGraphType
.ResolveAsync(async ctx =>
{
var id = ctx.GetArgument<string>("id");
var accessService = ctx.RequestServices!.GetRequiredService<ICompanyAccessService>();
await accessService.RequireAccessAsync(id, CompanyRole.Viewer, ctx.CancellationToken);
var repository = ctx.RequestServices!.GetRequiredService<ICompanyRepository>();
var companies = await repository.GetByIds([CompanyId.With(id)], ctx.CancellationToken);
return companies.FirstOrDefault();

View file

@ -1,4 +1,5 @@
using Books.Api;
using Books.Api.Authorization;
using Books.Api.GraphQL;
using GraphQL;
using GraphQL.Server.Ui.Altair;
@ -30,6 +31,25 @@ app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
// Company context middleware - extracts X-Company-Id header and validates user access
app.UseCompanyContext();
// Require authentication for the GraphQL endpoint
app.UseWhen(
context => context.Request.Path.StartsWithSegments("/graphql"),
appBuilder => appBuilder.Use(async (context, next) =>
{
if (context.User.Identity?.IsAuthenticated != true)
{
context.Response.StatusCode = 401;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync("{\"errors\":[{\"message\":\"Authentication required\"}]}");
return;
}
await next();
})
);
// Map controllers (for AuthController)
app.MapControllers();

View file

@ -14,6 +14,8 @@ public class VatReportService(
ILogger<VatReportService> logger) : IVatReportService
{
// Standard Danish VAT account numbers
// TODO: These should ideally come from company-level configuration,
// as different chart-of-accounts templates may use different numbers.
private const string InputVatAccountNumber = "5610"; // Købsmoms (indgående moms)
private const string OutputVatAccountNumber = "5611"; // Salgsmoms (udgående moms)
@ -133,13 +135,28 @@ public class VatReportService(
report.TotalInputVat = report.BoxB;
report.NetVat = report.TotalOutputVat - report.TotalInputVat;
// Basis amounts require tracking of original transaction amounts
// For now, calculate from VAT amounts assuming 25% rate
// Basis1 (Felt 1): Net domestic turnover with VAT
// TODO: Query actual net turnover from transactions with output VAT codes (U25)
// instead of back-calculating from VAT amount, which is inaccurate when
// mixed VAT rates or partial deductions are involved.
// Ideally: query revenue account balances filtered by VAT code U25.
// For now, back-calculate from output VAT assuming standard 25% rate
if (report.BoxA > 0)
{
report.Basis1 = Math.Round(report.BoxA / 0.25m, 2);
}
// TODO: Box C (EU-varekøb moms) - Requires VAT code breakdown from transactions.
// Query transactions with VAT code IEUV to compute reverse-charge VAT on EU goods.
// report.BoxC = sum of VAT calculated on IEUV transactions.
// report.Basis3 = net purchase amount for IEUV transactions.
// TODO: Box D (Ydelseskøb moms) - Requires VAT code breakdown from transactions.
// Query transactions with VAT codes IEUY, IVV, IVY to compute reverse-charge VAT
// on services purchased from abroad.
// report.BoxD = sum of VAT calculated on IEUY/IVV/IVY transactions.
// report.Basis4 = net purchase amount for IEUY/IVV/IVY transactions.
logger.LogInformation(
"VAT report generated for company {CompanyId}: OutputVAT={OutputVat}, InputVAT={InputVat}, NetVAT={NetVat}",
companyId, report.TotalOutputVat, report.TotalInputVat, report.NetVat);

View file

@ -332,6 +332,15 @@ public class SaftExportService(
journals);
}
/// <summary>
/// Maps internal account types to SAF-T standard account classifications.
/// Note: The "financial" type is ambiguous in SAF-T mapping. Financial accounts
/// can represent either income (e.g., interest income, account 8000-8499) or
/// expense (e.g., interest expense, account 8500-8999). Without the account
/// number or balance direction, we cannot determine the correct mapping.
/// A future improvement should inspect the account number range or actual
/// balance direction to choose between "Income" and "Expense".
/// </summary>
private static string MapAccountType(string accountType)
{
return accountType.ToLowerInvariant() switch
@ -343,7 +352,10 @@ public class SaftExportService(
"cogs" => "Expense",
"expense" => "Expense",
"personnel" => "Expense",
"financial" => "Income", // Could be either, defaulting to Income
// Financial accounts are ambiguous: could be income (8000-8499) or expense (8500-8999).
// Defaulting to "Expense" is safer since most financial items are costs (interest, fees).
// TODO: Determine mapping based on account number range or balance direction.
"financial" => "Expense",
"extraordinary" => "Expense",
_ => "Asset"
};

View file

@ -1,5 +1,7 @@
using Books.Api.Authentication;
using Books.Api.Authorization;
using Books.Api.EventFlow.Extensions;
using Books.Api.EventFlow.Repositories;
using Books.Api.EventFlow.Infrastructure;
using Books.Api.GraphQL;
using Books.Api.Infrastructure;
@ -67,6 +69,13 @@ public static class Startup
// Read model repositories
services.AddRepositories();
// HTTP context accessor (needed by CompanyAccessService and GraphQL resolvers)
services.AddHttpContextAccessor();
// User company access repository and access service
services.AddScoped<IUserCompanyAccessRepository, UserCompanyAccessRepository>();
services.AddScoped<ICompanyAccessService, CompanyAccessService>();
// Logging decorators
services.DecorateAsyncEventHandlersWithLogging();
@ -76,7 +85,7 @@ public static class Startup
.AddSystemTextJson()
.AddDataLoader()
.AddGraphTypes(typeof(BooksSchema).Assembly)
.AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = true));
.AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = environment?.IsDevelopment() ?? false));
// Memory cache for API key caching
services.AddMemoryCache();