Audit v3: VAT alignment, security, encoding, UX, compliance

VAT System Alignment (LEGAL - Critical):
- Align frontend VAT codes with backend (S25→U25, K25→I25, etc.)
- Add missing codes: UEU, IVV, IVY, REP
- Fix output VAT account 5710→5611 to match StandardDanishAccounts
- Invoice posting now checks fiscal year status before allowing send
- Disallow custom invoice number override (always use auto-numbering)

Security:
- Fix open redirect in AuthController (validate returnUrl is local)
- Store seller CVR/name/address on invoice events (Momsloven §52)

Backend Compliance:
- Add description validation at posting (Bogføringsloven §7)
- SAF-T: add DefaultCurrencyCode, TaxAccountingBasis to header
- SAF-T: add TaxTable to MasterFiles with all VAT codes
- SAF-T: always write balance elements even when zero
- Add financial income account 9100 Renteindtægter

Danish Encoding (~25 fixes):
- Kassekladde: Bogført, Bogføring, Vælg, være, på, Tilføj, Differens
- AttachmentUpload: træk, Understøtter, påkrævet, Bogføringsloven
- keyboardShortcuts: Bogfør, Bogføring display name
- ShortcutsHelpModal: åbne
- DataTable: Genindlæs
- documentProcessing: være
- CloseFiscalYearWizard: årsafslutning

Bugs Fixed:
- Non-null assertion crashes in Kunder.tsx and Produkter.tsx (company!.id)
- StatusBadge typo "Succces"→"Succes"
- HTML entity ø in Kassekladde→proper UTF-8
- AmountText showSign prop was dead code (true || showSign)

UX Improvements:
- Add PageHeader to Bankafstemning and Dashboard loading/empty states
- Responsive columns in Bankafstemning (xs/sm/lg breakpoints)
- Disable misleading buttons: Settings preferences, Kontooversigt edit,
  Loenforstaelse export — with tooltips explaining status
- Add DemoDataDisclaimer to UserSettings
- Fix breadcrumb self-references on 3 pages
- Replace Dashboard fake progress bar with honest message
- Standardize date format DD-MM-YYYY in Bankafstemning and Ordrer
- Replace Input type="number" with InputNumber in Ordrer

Quality:
- Remove 8 redundant console.error statements
- Fix Kreditnotaer breadcrumb "Salg"→"Fakturering" for consistency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nicolaj Hartmann 2026-02-06 01:15:45 +01:00
parent cd5333f07f
commit 1a0922b778
33 changed files with 355 additions and 172 deletions

View file

@ -8,7 +8,7 @@ namespace Books.Api.Commands.Invoices;
/// <summary>
/// Command handler for creating invoices.
/// Auto-assigns a sequential invoice number if one is not provided.
/// Always auto-assigns a sequential invoice number (Momsloven §52 - sequential numbering required).
/// Validates the company has a CVR number (required for invoicing).
/// </summary>
public class CreateInvoiceCommandHandler(
@ -39,15 +39,16 @@ public class CreateInvoiceCommandHandler(
"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))
{
invoiceNumber = await invoiceNumberService.GetNextInvoiceNumberAsync(
command.CompanyId,
command.InvoiceDate.Year,
cancellationToken);
}
// Always auto-assign invoice number (Momsloven §52 requires unbroken sequential numbering)
var invoiceNumber = await invoiceNumberService.GetNextInvoiceNumberAsync(
command.CompanyId,
command.InvoiceDate.Year,
cancellationToken);
// Build seller address from company info
var sellerAddress = string.Join(", ",
new[] { company.Address, company.PostalCode, company.City }
.Where(s => !string.IsNullOrWhiteSpace(s)));
aggregate.Create(
command.CompanyId,
@ -63,7 +64,10 @@ public class CreateInvoiceCommandHandler(
command.VatCode,
command.Notes,
command.Reference,
command.CreatedBy);
command.CreatedBy,
company.Cvr,
company.Name,
sellerAddress);
}
}
@ -124,19 +128,59 @@ public class RemoveInvoiceLineCommandHandler
}
}
public class MarkInvoiceSentCommandHandler
/// <summary>
/// Command handler for marking an invoice as sent.
/// Validates fiscal year status before allowing the invoice to be sent.
/// </summary>
public class MarkInvoiceSentCommandHandler(
IInvoiceRepository invoiceRepository,
IFiscalYearRepository fiscalYearRepository)
: CommandHandler<InvoiceAggregate, InvoiceId, MarkInvoiceSentCommand>
{
public override Task ExecuteAsync(
public override async Task ExecuteAsync(
InvoiceAggregate aggregate,
MarkInvoiceSentCommand command,
CancellationToken cancellationToken)
{
// Load the invoice read model to get fiscal year ID
var invoice = await invoiceRepository.GetByIdAsync(
aggregate.Id.Value, cancellationToken);
var fiscalYearId = invoice?.FiscalYearId;
// Validate fiscal year is set
if (string.IsNullOrWhiteSpace(fiscalYearId))
{
throw new DomainException(
"FISCAL_YEAR_REQUIRED",
"Fiscal year is required for sending an invoice",
"Regnskabsår er påkrævet for afsendelse af en faktura");
}
// 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.");
}
aggregate.Send(
command.LedgerTransactionId,
command.SentBy);
return Task.CompletedTask;
}
}

View file

@ -16,6 +16,9 @@ public class AuthController : ControllerBase
{
// The [Authorize] attribute triggers the OIDC challenge if not authenticated.
// If we reach here, the user is authenticated - redirect back to the app.
// Validate returnUrl to prevent open redirect attacks
if (returnUrl != null && !Url.IsLocalUrl(returnUrl))
returnUrl = "/";
return Redirect(returnUrl ?? "/");
}

View file

@ -25,7 +25,10 @@ public class InvoiceCreatedEvent(
InvoiceType type = InvoiceType.Invoice,
string? originalInvoiceId = null,
string? originalInvoiceNumber = null,
string? creditReason = null) : AggregateEvent<InvoiceAggregate, InvoiceId>
string? creditReason = null,
string? sellerCvr = null,
string? sellerName = null,
string? sellerAddress = null) : AggregateEvent<InvoiceAggregate, InvoiceId>
{
public string CompanyId { get; } = companyId;
public string FiscalYearId { get; } = fiscalYearId;
@ -61,4 +64,19 @@ public class InvoiceCreatedEvent(
/// For credit notes: Reason for issuing the credit note.
/// </summary>
public string? CreditReason { get; } = creditReason;
/// <summary>
/// Seller CVR number (company registration number).
/// </summary>
public string? SellerCvr { get; } = sellerCvr;
/// <summary>
/// Seller company name.
/// </summary>
public string? SellerName { get; } = sellerName;
/// <summary>
/// Seller company address.
/// </summary>
public string? SellerAddress { get; } = sellerAddress;
}

View file

@ -138,7 +138,10 @@ public class InvoiceAggregate(InvoiceId id) : AggregateRoot<InvoiceAggregate, In
string? vatCode,
string? notes,
string? reference,
string createdBy)
string createdBy,
string? sellerCvr = null,
string? sellerName = null,
string? sellerAddress = null)
{
if (_isCreated)
throw new DomainException("INVOICE_EXISTS", "Invoice already exists", "Faktura eksisterer allerede");
@ -166,7 +169,10 @@ public class InvoiceAggregate(InvoiceId id) : AggregateRoot<InvoiceAggregate, In
vatCode,
notes,
reference,
createdBy));
createdBy,
sellerCvr: sellerCvr,
sellerName: sellerName,
sellerAddress: sellerAddress));
}
public void AddLine(

View file

@ -20,6 +20,7 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
private string _companyId = string.Empty;
private string _voucherNumber = string.Empty;
private string? _fiscalYearId;
private string? _description;
private List<DraftLine> _lines = [];
public string CompanyId => _companyId;
@ -48,6 +49,7 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
public void Apply(JournalEntryDraftUpdatedEvent e)
{
_fiscalYearId = e.FiscalYearId;
_description = e.Description;
_lines = e.Lines.ToList();
}
@ -184,6 +186,13 @@ public class JournalEntryDraftAggregate(JournalEntryDraftId id)
"Posted by is required",
"Bogført af er påkrævet");
// Validate description is set (Bogføringsloven §7 stk. 1 nr. 3 requires a text describing the transaction)
if (string.IsNullOrWhiteSpace(_description))
throw new DomainException(
"DESCRIPTION_REQUIRED",
"A description is required for posting. Each registration must have a text describing the transaction (Bogføringsloven §7 stk. 1 nr. 3).",
"En beskrivelse er påkrævet for bogføring. Hver registrering skal have en tekst, der beskriver transaktionen (Bogføringsloven §7 stk. 1 nr. 3).");
// Validate minimum number of lines for double-entry bookkeeping
if (_lines.Count < 2)
throw new DomainException(

View file

@ -221,8 +221,9 @@ public static class StandardDanishAccounts
// =========================================
// FINANSIELLE POSTER (Financial) - 9xxx
// Standard: 3670 = Øvrige finansielle omkostninger
// Standard: 3510 = Finansielle indtægter, 3670 = Øvrige finansielle omkostninger
// =========================================
yield return new("9100", "Renteindtægter", AccountType.Revenue, "Finansielle indtægter", null, "9100");
yield return new("9200", "Bankrenter", AccountType.Financial, null, null, "3670");
yield return new("9210", "Leverandører mv.", AccountType.Financial, "Renter til leverandører", null, "3670");
yield return new("9220", "Ikke-fradragsberettigede renter", AccountType.Financial, null, null, "3670");

View file

@ -53,9 +53,15 @@ public class SaftXmlBuilder
writer.WriteElementString("SoftwareID", header.SoftwareID);
writer.WriteElementString("SoftwareVersion", header.SoftwareVersion);
// SAF-T requires DefaultCurrencyCode (ISO 4217)
writer.WriteElementString("DefaultCurrencyCode", "DKK");
WriteCompany(writer, header.Company);
WriteSelectionCriteria(writer, header.SelectionCriteria);
// SAF-T requires TaxAccountingBasis: A = Accrual, C = Cash, O = Other
writer.WriteElementString("TaxAccountingBasis", "A");
writer.WriteEndElement(); // Header
}
@ -133,10 +139,52 @@ public class SaftXmlBuilder
WriteGeneralLedgerAccounts(writer, masterFiles.GeneralLedgerAccounts);
WriteCustomers(writer, masterFiles.Customers);
WriteSuppliers(writer, masterFiles.Suppliers);
WriteTaxTable(writer);
writer.WriteEndElement(); // MasterFiles
}
/// <summary>
/// Writes the TaxTable element declaring all Danish VAT codes.
/// Required by SAF-T schema to describe the tax codes used in transactions.
/// </summary>
private static void WriteTaxTable(XmlWriter writer)
{
writer.WriteStartElement("TaxTable");
writer.WriteStartElement("TaxTableEntry");
writer.WriteElementString("TaxType", "MVA");
writer.WriteElementString("Description", "Dansk moms (VAT)");
// Output VAT codes
WriteTaxCodeDetails(writer, "U25", "Udgående moms 25%", 25.00m);
WriteTaxCodeDetails(writer, "UEU", "EU-salg (momsfrit)", 0.00m);
WriteTaxCodeDetails(writer, "UEXP", "Eksport uden for EU (momsfrit)", 0.00m);
// Input VAT codes
WriteTaxCodeDetails(writer, "I25", "Indgående moms 25%", 25.00m);
WriteTaxCodeDetails(writer, "IEUV", "EU-erhvervelse varer (reverse charge)", 25.00m);
WriteTaxCodeDetails(writer, "IEUY", "EU-erhvervelse ydelser (reverse charge)", 25.00m);
WriteTaxCodeDetails(writer, "IVV", "Import varer fra verden", 0.00m);
WriteTaxCodeDetails(writer, "IVY", "Import ydelser fra verden", 0.00m);
// Special codes
WriteTaxCodeDetails(writer, "REP", "Repræsentation (25% fradrag)", 25.00m);
WriteTaxCodeDetails(writer, "INGEN", "Ingen moms", 0.00m);
writer.WriteEndElement(); // TaxTableEntry
writer.WriteEndElement(); // TaxTable
}
private static void WriteTaxCodeDetails(XmlWriter writer, string taxCode, string description, decimal taxPercentage)
{
writer.WriteStartElement("TaxCodeDetails");
writer.WriteElementString("TaxCode", taxCode);
writer.WriteElementString("Description", description);
writer.WriteElementString("TaxPercentage", FormatDecimal(taxPercentage));
writer.WriteEndElement(); // TaxCodeDetails
}
private static void WriteGeneralLedgerAccounts(XmlWriter writer, List<SaftAccount> accounts)
{
if (accounts.Count == 0) return;
@ -160,17 +208,13 @@ public class SaftXmlBuilder
writer.WriteElementString("AccountType", account.AccountType);
// Opening balances
if (account.OpeningDebitBalance != 0)
writer.WriteElementString("OpeningDebitBalance", FormatDecimal(account.OpeningDebitBalance));
if (account.OpeningCreditBalance != 0)
writer.WriteElementString("OpeningCreditBalance", FormatDecimal(account.OpeningCreditBalance));
// Opening balances (SAF-T schema requires these elements even when zero)
writer.WriteElementString("OpeningDebitBalance", FormatDecimal(account.OpeningDebitBalance));
writer.WriteElementString("OpeningCreditBalance", FormatDecimal(account.OpeningCreditBalance));
// Closing balances
if (account.ClosingDebitBalance != 0)
writer.WriteElementString("ClosingDebitBalance", FormatDecimal(account.ClosingDebitBalance));
if (account.ClosingCreditBalance != 0)
writer.WriteElementString("ClosingCreditBalance", FormatDecimal(account.ClosingCreditBalance));
// Closing balances (SAF-T schema requires these elements even when zero)
writer.WriteElementString("ClosingDebitBalance", FormatDecimal(account.ClosingDebitBalance));
writer.WriteElementString("ClosingCreditBalance", FormatDecimal(account.ClosingCreditBalance));
writer.WriteEndElement(); // Account
}