books/backend/Books.Api/Commands/Invoices/InvoiceCommandHandlers.cs
Nicolaj Hartmann 8096a19081 Audit v4: VAT calc, SAF-T compliance, security hardening, frontend quality
Backend (17 files):
- VAT: REP 25% deductibility (§42), EU reverse charge double-entry (IEUV/IEUY/IVY),
  IVY rate 0%→25%, VatReport Box C/D populated, Basis1 from real revenue
- SAF-T: correct OECD namespace, closing balance net calc, zero-amount fallback,
  credit note auto-numbering (§52)
- Security: BankingController CSRF state token + company auth check,
  attachment canonical path traversal check, discount 0-100% validation,
  deactivated product/customer update guard
- Quality: redact bank API logs, remove dead code (VatCalcService,
  PaymentMatchingService), CompanyAggregate IEmit interfaces, fix URL encoding

Frontend (15 files):
- Fix double "kr." in AmountText and Dashboard Statistic components
- Fix UserSettings Switch defaultChecked desync with Form state
- Remove dual useCompany/useCompanyStore pattern (Dashboard, Moms, Bank)
- Correct SKAT VAT deadline calculation per period type
- Add half-yearly/yearly VAT period options
- Guard console.error with import.meta.env.DEV
- Use shared formatDate in BankConnectionsTab
- Remove dead NONE vatCode check, purge 7 legacy VAT codes from type union
- Migrate S25→U25, K25→I25 across all pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 01:38:52 +01:00

300 lines
9.6 KiB
C#

using Books.Api.Domain;
using Books.Api.Domain.Invoices;
using Books.Api.EventFlow.Repositories;
using Books.Api.Invoicing.Services;
using EventFlow.Commands;
namespace Books.Api.Commands.Invoices;
/// <summary>
/// Command handler for creating invoices.
/// 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(
IInvoiceNumberService invoiceNumberService,
ICompanyRepository companyRepository)
: CommandHandler<InvoiceAggregate, InvoiceId, CreateInvoiceCommand>
{
public override async Task ExecuteAsync(
InvoiceAggregate aggregate,
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.");
}
// 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,
command.FiscalYearId,
command.CustomerId,
command.CustomerName,
command.CustomerNumber,
invoiceNumber,
command.InvoiceDate,
command.DueDate,
command.PaymentTermsDays,
command.Currency,
command.VatCode,
command.Notes,
command.Reference,
command.CreatedBy,
company.Cvr,
company.Name,
sellerAddress);
}
}
public class AddInvoiceLineCommandHandler
: CommandHandler<InvoiceAggregate, InvoiceId, AddInvoiceLineCommand>
{
public override Task ExecuteAsync(
InvoiceAggregate aggregate,
AddInvoiceLineCommand command,
CancellationToken cancellationToken)
{
aggregate.AddLine(
command.Description,
command.Quantity,
command.UnitPrice,
command.VatCode,
command.AccountId,
command.Unit,
command.DiscountPercent);
return Task.CompletedTask;
}
}
public class UpdateInvoiceLineCommandHandler
: CommandHandler<InvoiceAggregate, InvoiceId, UpdateInvoiceLineCommand>
{
public override Task ExecuteAsync(
InvoiceAggregate aggregate,
UpdateInvoiceLineCommand command,
CancellationToken cancellationToken)
{
aggregate.UpdateLine(
command.LineNumber,
command.Description,
command.Quantity,
command.UnitPrice,
command.VatCode,
command.AccountId,
command.Unit,
command.DiscountPercent);
return Task.CompletedTask;
}
}
public class RemoveInvoiceLineCommandHandler
: CommandHandler<InvoiceAggregate, InvoiceId, RemoveInvoiceLineCommand>
{
public override Task ExecuteAsync(
InvoiceAggregate aggregate,
RemoveInvoiceLineCommand command,
CancellationToken cancellationToken)
{
aggregate.RemoveLine(command.LineNumber);
return Task.CompletedTask;
}
}
/// <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 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);
}
}
public class ReceiveInvoicePaymentCommandHandler
: CommandHandler<InvoiceAggregate, InvoiceId, ReceiveInvoicePaymentCommand>
{
public override Task ExecuteAsync(
InvoiceAggregate aggregate,
ReceiveInvoicePaymentCommand command,
CancellationToken cancellationToken)
{
aggregate.ReceivePayment(
command.Amount,
command.BankTransactionId,
command.LedgerTransactionId,
command.PaymentReference,
command.PaymentDate,
command.RecordedBy);
return Task.CompletedTask;
}
}
public class VoidInvoiceCommandHandler
: CommandHandler<InvoiceAggregate, InvoiceId, VoidInvoiceCommand>
{
public override Task ExecuteAsync(
InvoiceAggregate aggregate,
VoidInvoiceCommand command,
CancellationToken cancellationToken)
{
aggregate.Void(
command.Reason,
command.ReversalLedgerTransactionId,
command.VoidedBy);
return Task.CompletedTask;
}
}
// =====================================================
// CREDIT NOTE COMMAND HANDLERS
// =====================================================
/// <summary>
/// Command handler for creating credit notes.
/// Auto-assigns sequential credit note numbers (Momsloven §52 - sequential numbering required).
/// </summary>
public class CreateCreditNoteCommandHandler(
IInvoiceNumberService invoiceNumberService)
: CommandHandler<InvoiceAggregate, InvoiceId, CreateCreditNoteCommand>
{
public override async Task ExecuteAsync(
InvoiceAggregate aggregate,
CreateCreditNoteCommand command,
CancellationToken cancellationToken)
{
// Auto-assign credit note number (Momsloven §52 requires unbroken sequential numbering)
var creditNoteNumber = await invoiceNumberService.GetNextCreditNoteNumberAsync(
command.CompanyId,
command.CreditNoteDate.Year,
cancellationToken);
aggregate.CreateCreditNote(
command.CompanyId,
command.FiscalYearId,
command.CustomerId,
command.CustomerName,
command.CustomerNumber,
creditNoteNumber,
command.CreditNoteDate,
command.Currency,
command.VatCode,
command.Notes,
command.Reference,
command.CreatedBy,
command.OriginalInvoiceId,
command.OriginalInvoiceNumber,
command.CreditReason);
}
}
public class IssueCreditNoteCommandHandler
: CommandHandler<InvoiceAggregate, InvoiceId, IssueCreditNoteCommand>
{
public override Task ExecuteAsync(
InvoiceAggregate aggregate,
IssueCreditNoteCommand command,
CancellationToken cancellationToken)
{
aggregate.Issue(
command.LedgerTransactionId,
command.IssuedBy);
return Task.CompletedTask;
}
}
public class ApplyCreditNoteCommandHandler
: CommandHandler<InvoiceAggregate, InvoiceId, ApplyCreditNoteCommand>
{
public override Task ExecuteAsync(
InvoiceAggregate aggregate,
ApplyCreditNoteCommand command,
CancellationToken cancellationToken)
{
aggregate.ApplyCredit(
command.TargetInvoiceId,
command.TargetInvoiceNumber,
command.Amount,
command.AppliedDate,
command.AppliedBy,
command.LedgerTransactionId);
return Task.CompletedTask;
}
}