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:
parent
cd5333f07f
commit
1a0922b778
33 changed files with 355 additions and 172 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? "/");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue