Audit v2: fix security, data integrity, compliance, bugs, encoding, UX

Backend Security & Data Integrity:
- Block negative debit/credit amounts that bypass balance validation
- Require document date at posting (was optional, bypassing fiscal year checks)
- Fix event sourcing anti-pattern: timestamps now stored in events, not UtcNow in Apply
- Add [Authorize] to BankingController OAuth callback
- Add company access check on attachment downloads
- Validate CVR in CompanyAggregate.Create and CompanyAggregate.Update
- Require company CVR for invoice creation (Momsloven §52)
- Delete leftover WeatherForecastController
- Fix duplicate migration number 007 (renamed to 007b)
- Remove dead code in VatCalculationService (identical if/else branches)

Accounting Compliance:
- Add missing VAT accounts to StandardDanishAccounts (5610, 5611, 5620)
- Populate SAF-T TaxInformation on transaction lines (was always null)
- Add AuditFileCountry and TaxRegistrationNumber to SAF-T header

Critical Frontend Bugs:
- Fix Dashboard <a href> causing full page reloads (now uses React Router Link)
- Wire Kassekladde filters to actual data (account, status, date range)
- Pre-populate form when editing existing Kassekladde drafts
- Add detail drawer for "Vis detaljer" action (was just a toast)
- Toggle advanced filters with "Flere filtre" button
- CloseFiscalYearWizard now actually posts closing entries via mutations
- "Create next year" checkbox now creates the next fiscal year

Danish Character Encoding (~50 fixes):
- Fix ø/æ/å across Momsindberetning, DocumentUploadModal, Bankafstemning,
  Kontooversigt, CloseFiscalYearWizard, vatCodes, periodStore, periods,
  accounting, types/periods

Dead Buttons & UX:
- Disable Momsindberetning PDF/Export buttons with tooltips
- FiscalYearSelector "Administrer" now navigates to Settings
- Settings bank tab now uses real BankConnectionsTab component
- Bankafstemning save button disabled with development tooltip
- Replace hardcoded account options with real API data (Bankafstemning, Fakturaer)
- Header help button shows info message, notification bell shows popover

Consistency & Quality:
- Remove 7 console.log statements from production code
- Adopt PageHeader on 6 remaining pages (Kreditnotaer, Settings, Admin, etc.)
- Standardize loading states to Skeleton pattern (5 pages)
- Replace deprecated bodyStyle prop on Ant Design Cards
- Standardize date format to DD-MM-YYYY
- Fix sidebar width mismatch in designTokens
- Fix Kontooversigt breadcrumb pointing to non-existent route

Accessibility:
- Add aria-label to sidebar navigation
- Add +/- prefix to AmountText for color-blind users
- Fix CompanySwitcher permanent skeleton when no companies

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nicolaj Hartmann 2026-02-06 00:18:19 +01:00
parent a1c2af6027
commit 709d0a4739
50 changed files with 676 additions and 372 deletions

View file

@ -1,4 +1,6 @@
using Books.Api.Domain;
using Books.Api.Domain.Invoices;
using Books.Api.EventFlow.Repositories;
using Books.Api.Invoicing.Services;
using EventFlow.Commands;
@ -7,9 +9,11 @@ namespace Books.Api.Commands.Invoices;
/// <summary>
/// Command handler for creating invoices.
/// Auto-assigns a sequential invoice number if one is not provided.
/// Validates the company has a CVR number (required for invoicing).
/// </summary>
public class CreateInvoiceCommandHandler(
IInvoiceNumberService invoiceNumberService)
IInvoiceNumberService invoiceNumberService,
ICompanyRepository companyRepository)
: CommandHandler<InvoiceAggregate, InvoiceId, CreateInvoiceCommand>
{
public override async Task ExecuteAsync(
@ -17,6 +21,24 @@ public class CreateInvoiceCommandHandler(
CreateInvoiceCommand command,
CancellationToken cancellationToken)
{
// Validate company has a CVR number (required for invoicing per Danish law)
var company = await companyRepository.GetByIdAsync(command.CompanyId, cancellationToken);
if (company == null)
{
throw new DomainException(
"COMPANY_NOT_FOUND",
$"Company '{command.CompanyId}' not found",
$"Virksomheden '{command.CompanyId}' blev ikke fundet");
}
if (string.IsNullOrWhiteSpace(company.Cvr))
{
throw new DomainException(
"CVR_REQUIRED_FOR_INVOICE",
"Company must have a CVR number to create invoices. Please update company settings.",
"Virksomheden skal have et CVR-nummer for at oprette fakturaer. Opdater venligst virksomhedsindstillinger.");
}
// Auto-assign invoice number if not provided
var invoiceNumber = command.InvoiceNumber;
if (string.IsNullOrWhiteSpace(invoiceNumber))

View file

@ -94,8 +94,16 @@ public class MarkJournalEntryDraftPostedCommandHandler(
$"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)
// Validate document date is set (required for posting per Bogføringsloven)
if (draft?.DocumentDate == null)
{
throw new DomainException(
"DOCUMENT_DATE_REQUIRED",
"Document date (bilagsdato) is required for posting a journal entry",
"Bilagsdato er påkrævet for bogføring af en postering");
}
// Validate document date falls within fiscal year range
{
var documentDate = DateOnly.FromDateTime(draft.DocumentDate.Value);
var fyStart = DateOnly.FromDateTime(fiscalYear.StartDate);

View file

@ -197,6 +197,20 @@ public class AttachmentController(
return BadRequest(new { error = "INVALID_PATH", message = "Invalid storage path" });
}
// Look up the attachment to verify company access
var attachment = await attachmentRepository.GetByStoragePathAsync(storagePath, cancellationToken);
if (attachment == null)
{
return NotFound(new { error = "FILE_NOT_FOUND", message = "Attachment not found" });
}
// Verify the user has access to the company that owns this attachment
var access = await companyAccess.GetAccessAsync(attachment.CompanyId, cancellationToken);
if (access == null)
{
return Forbid();
}
var file = await fileStorage.GetAsync(storagePath, cancellationToken);
if (file == null)

View file

@ -3,12 +3,14 @@ using Books.Api.Commands.BankConnections;
using Books.Api.Domain.BankConnections;
using EventFlow;
using EventFlow.Aggregates.ExecutionResults;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Books.Api.Controllers;
[ApiController]
[Route("api/banking")]
[Authorize]
public class BankingController : ControllerBase
{
private readonly ICommandBus _commandBus;

View file

@ -1,25 +0,0 @@
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

@ -29,7 +29,7 @@ public class AttachmentAggregate(AttachmentId id)
_isCreated = true;
_companyId = e.CompanyId;
_transactionId = e.TransactionId;
_uploadedAt = DateTimeOffset.UtcNow;
_uploadedAt = e.UploadedAt;
}
public void Apply(AttachmentLinkedToTransactionEvent e)
@ -127,6 +127,7 @@ public class AttachmentAggregate(AttachmentId id)
fileSize,
storagePath.Trim(),
uploadedBy,
DateTimeOffset.UtcNow,
draftId?.Trim(),
transactionId?.Trim()));
}

View file

@ -14,6 +14,7 @@ public class AttachmentUploadedEvent(
long fileSize,
string storagePath,
string uploadedBy,
DateTimeOffset uploadedAt,
string? draftId = null,
string? transactionId = null) : AggregateEvent<AttachmentAggregate, AttachmentId>
{
@ -46,6 +47,11 @@ public class AttachmentUploadedEvent(
public string UploadedBy { get; } = uploadedBy;
/// <summary>
/// Timestamp when the attachment was uploaded.
/// </summary>
public DateTimeOffset UploadedAt { get; } = uploadedAt;
/// <summary>
/// Optional reference to journal entry draft.
/// </summary>

View file

@ -32,6 +32,13 @@ public class CompanyAggregate(CompanyId id) : AggregateRoot<CompanyAggregate, Co
if (fiscalYearStartMonth < 1 || fiscalYearStartMonth > 12)
throw new DomainException("Fiscal year start month must be between 1 and 12");
// Validate CVR number if provided
if (!string.IsNullOrWhiteSpace(cvr) && !CvrValidator.IsValid(cvr.Trim()))
throw new DomainException(
"INVALID_CVR",
$"CVR number '{cvr}' is not valid. Must be 8 digits with valid checksum.",
$"CVR-nummer '{cvr}' er ugyldigt. Skal være 8 cifre med gyldig kontrolsum.");
Emit(new CompanyCreatedEvent(
name.Trim(),
cvr?.Trim(),
@ -66,6 +73,13 @@ public class CompanyAggregate(CompanyId id) : AggregateRoot<CompanyAggregate, Co
if (fiscalYearStartMonth < 1 || fiscalYearStartMonth > 12)
throw new DomainException("Fiscal year start month must be between 1 and 12");
// Validate CVR number if provided
if (!string.IsNullOrWhiteSpace(cvr) && !CvrValidator.IsValid(cvr.Trim()))
throw new DomainException(
"INVALID_CVR",
$"CVR number '{cvr}' is not valid. Must be 8 digits with valid checksum.",
$"CVR-nummer '{cvr}' er ugyldigt. Skal være 8 cifre med gyldig kontrolsum.");
Emit(new CompanyUpdatedEvent(
name.Trim(),
cvr?.Trim(),

View file

@ -255,11 +255,28 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
/// <summary>
/// Validates a single draft line.
/// Amounts must be non-negative.
/// 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)
{
throw new DomainException(
"NEGATIVE_DEBIT_AMOUNT",
$"Line {line.LineNumber} has a negative debit amount. Amounts must be non-negative.",
$"Linje {line.LineNumber} har et negativt debetbeløb. Beløb skal være ikke-negative.");
}
if (line.CreditAmount < 0)
{
throw new DomainException(
"NEGATIVE_CREDIT_AMOUNT",
$"Line {line.LineNumber} has a negative credit amount. Amounts must be non-negative.",
$"Linje {line.LineNumber} har et negativt kreditbeløb. Beløb skal være ikke-negative.");
}
if (line.DebitAmount > 0 && line.CreditAmount > 0)
{
throw new DomainException(

View file

@ -168,24 +168,11 @@ public class VatCalculationService : IVatCalculationService
// - For SALES (U25): revenue is credit, VAT should ALSO be credit (liability to SKAT)
// - For PURCHASES (I25): expense is debit, VAT should ALSO be debit (asset/receivable from SKAT)
// The key insight: VAT follows the same direction as the base transaction
if (isInputVat)
vatLine = vatLine with
{
// Purchases: VAT follows the expense direction (typically debit)
vatLine = vatLine with
{
DebitAmount = isDebit ? vatAmount : 0,
CreditAmount = !isDebit ? vatAmount : 0
};
}
else
{
// Sales: VAT follows the revenue direction (typically credit)
vatLine = vatLine with
{
DebitAmount = isDebit ? vatAmount : 0,
CreditAmount = !isDebit ? vatAmount : 0
};
}
DebitAmount = isDebit ? vatAmount : 0,
CreditAmount = !isDebit ? vatAmount : 0
};
vatLines.Add(vatLine);

View file

@ -9,12 +9,14 @@ public class UserCompanyAccessGrantedEvent(
string userId,
string companyId,
CompanyRole role,
string grantedBy) : AggregateEvent<UserCompanyAccessAggregate, UserCompanyAccessId>
string grantedBy,
DateTimeOffset grantedAt) : AggregateEvent<UserCompanyAccessAggregate, UserCompanyAccessId>
{
public string UserId { get; } = userId;
public string CompanyId { get; } = companyId;
public CompanyRole Role { get; } = role;
public string GrantedBy { get; } = grantedBy;
public DateTimeOffset GrantedAt { get; } = grantedAt;
}
/// <summary>
@ -34,7 +36,9 @@ public class UserCompanyAccessRoleChangedEvent(
/// Emitted when a user's access to a company is revoked.
/// </summary>
public class UserCompanyAccessRevokedEvent(
string revokedBy) : AggregateEvent<UserCompanyAccessAggregate, UserCompanyAccessId>
string revokedBy,
DateTimeOffset revokedAt) : AggregateEvent<UserCompanyAccessAggregate, UserCompanyAccessId>
{
public string RevokedBy { get; } = revokedBy;
public DateTimeOffset RevokedAt { get; } = revokedAt;
}

View file

@ -33,7 +33,7 @@ public class UserCompanyAccessAggregate : AggregateRoot<UserCompanyAccessAggrega
}
// If previously revoked, we're re-granting
Emit(new UserCompanyAccessGrantedEvent(userId, companyId, role, grantedBy));
Emit(new UserCompanyAccessGrantedEvent(userId, companyId, role, grantedBy, DateTimeOffset.UtcNow));
}
/// <summary>
@ -73,7 +73,7 @@ public class UserCompanyAccessAggregate : AggregateRoot<UserCompanyAccessAggrega
"Adgang er allerede tilbagekaldt");
}
Emit(new UserCompanyAccessRevokedEvent(revokedBy));
Emit(new UserCompanyAccessRevokedEvent(revokedBy, DateTimeOffset.UtcNow));
}
public void Apply(UserCompanyAccessGrantedEvent e)
@ -82,7 +82,7 @@ public class UserCompanyAccessAggregate : AggregateRoot<UserCompanyAccessAggrega
CompanyId = e.CompanyId;
Role = e.Role;
GrantedBy = e.GrantedBy;
GrantedAt = DateTimeOffset.UtcNow;
GrantedAt = e.GrantedAt;
IsActive = true;
RevokedAt = null;
RevokedBy = null;
@ -96,7 +96,7 @@ public class UserCompanyAccessAggregate : AggregateRoot<UserCompanyAccessAggrega
public void Apply(UserCompanyAccessRevokedEvent e)
{
IsActive = false;
RevokedAt = DateTimeOffset.UtcNow;
RevokedAt = e.RevokedAt;
RevokedBy = e.RevokedBy;
}
}

View file

@ -35,6 +35,19 @@ public class AttachmentRepository(NpgsqlDataSource dataSource) : IAttachmentRepo
return await connection.QuerySingleOrDefaultAsync<AttachmentReadModelDto>(sql, new { Id = id });
}
public async Task<AttachmentReadModelDto?> GetByStoragePathAsync(string storagePath, CancellationToken cancellationToken = default)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sql = $"""
SELECT {SelectColumns}
FROM attachment_read_models
WHERE storage_path = @StoragePath AND is_deleted = FALSE
""";
return await connection.QuerySingleOrDefaultAsync<AttachmentReadModelDto>(sql, new { StoragePath = storagePath });
}
public async Task<IReadOnlyList<AttachmentReadModelDto>> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);

View file

@ -5,6 +5,7 @@ namespace Books.Api.EventFlow.Repositories;
public interface IAttachmentRepository
{
Task<AttachmentReadModelDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default);
Task<AttachmentReadModelDto?> GetByStoragePathAsync(string storagePath, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AttachmentReadModelDto>> GetByCompanyIdAsync(string companyId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AttachmentReadModelDto>> GetByDraftIdAsync(string draftId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AttachmentReadModelDto>> GetByTransactionIdAsync(string transactionId, CancellationToken cancellationToken = default);

View file

@ -190,6 +190,15 @@ public static class StandardDanishAccounts
yield return new("7460", "Diverse inkl. moms", AccountType.Expense, null, "I25", "2110");
yield return new("7480", "Diverse ekskl. moms", AccountType.Expense, null, null, "2110");
// =========================================
// MOMSKONTI (VAT Accounts) - 56xx
// Standard: 5610 = Købsmoms, 5611 = Salgsmoms, 5620 = EU-erhvervelsesmoms
// Required for VAT calculation and reporting
// =========================================
yield return new("5610", "Købsmoms", AccountType.Liability, "Indgående moms (Input VAT)", null, "5610", true);
yield return new("5611", "Salgsmoms", AccountType.Liability, "Udgående moms (Output VAT)", null, "5611", true);
yield return new("5620", "EU-erhvervelsesmoms", AccountType.Liability, "EU acquisition VAT", null, "5620", true);
// =========================================
// PASSIVER - SKYLDIG SKAT OG MOMS (Tax Liabilities) - 79xx
// Standard: 7680 = Anden gæld til SKAT, 7920 = A-skat

View file

@ -1,3 +1,4 @@
using Books.Api.Domain;
using Books.Api.EventFlow.ReadModels;
using Books.Api.EventFlow.Repositories;
using Books.Api.Saft.Models;
@ -294,6 +295,9 @@ public class SaftExportService(
if (isDebit) totalDebit += entry.Amount;
else totalCredit += entry.Amount;
// Try to extract VAT information from the entry description
var taxInfo = ExtractTaxInformation(entry.Description, entry.Amount);
return new SaftTransactionLine(
(idx + 1).ToString(),
accountNumber ?? entry.AccountId.ToString(),
@ -302,7 +306,7 @@ public class SaftExportService(
creditAmount,
null, // CustomerID - could parse from reference
null, // SupplierID
null); // TaxInfo - would need VAT code tracking
taxInfo);
}).ToList();
transactions.Add(new SaftTransaction(
@ -385,6 +389,37 @@ public class SaftExportService(
return null;
}
/// <summary>
/// Extracts VAT/tax information from a ledger entry description.
/// VAT lines generated by the system contain patterns like "Moms U25 (25%)" or "Moms I25 (25%)".
/// </summary>
private static SaftTaxInformation? ExtractTaxInformation(string? description, decimal amount)
{
if (string.IsNullOrEmpty(description))
return null;
// Check for known VAT codes in the description
foreach (var vatCodeInfo in VatCodes.All)
{
if (vatCodeInfo.Code == VatCodes.INGEN)
continue;
if (description.Contains(vatCodeInfo.Code, StringComparison.OrdinalIgnoreCase))
{
var rate = VatCodes.GetRate(vatCodeInfo.Code);
var taxBase = rate > 0 ? Math.Round(amount / rate, 2, MidpointRounding.AwayFromZero) : 0m;
return new SaftTaxInformation(
vatCodeInfo.Code,
rate * 100, // TaxPercentage as whole number (25 not 0.25)
taxBase,
amount);
}
}
return null;
}
/// <summary>
/// Validates a Danish CVR number.
/// A valid CVR is exactly 8 digits.

View file

@ -47,6 +47,7 @@ public class SaftXmlBuilder
writer.WriteStartElement("Header");
writer.WriteElementString("AuditFileVersion", header.AuditFileVersion);
writer.WriteElementString("AuditFileCountry", "DK");
writer.WriteElementString("AuditFileDateCreated", header.AuditFileDateCreated);
writer.WriteElementString("SoftwareCompanyName", header.SoftwareCompanyName);
writer.WriteElementString("SoftwareID", header.SoftwareID);
@ -72,6 +73,12 @@ public class SaftXmlBuilder
WriteContact(writer, company.Contact);
}
// SAF-T DK requires TaxRegistrationNumber with "DK" prefix + CVR
if (!string.IsNullOrEmpty(company.RegistrationNumber))
{
writer.WriteElementString("TaxRegistrationNumber", "DK" + company.RegistrationNumber);
}
writer.WriteEndElement(); // Company
}

View file

@ -1,12 +0,0 @@
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; }
}