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;
///
/// 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).
///
public class CreateInvoiceCommandHandler(
IInvoiceNumberService invoiceNumberService,
ICompanyRepository companyRepository)
: CommandHandler
{
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
{
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
{
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
{
public override Task ExecuteAsync(
InvoiceAggregate aggregate,
RemoveInvoiceLineCommand command,
CancellationToken cancellationToken)
{
aggregate.RemoveLine(command.LineNumber);
return Task.CompletedTask;
}
}
///
/// Command handler for marking an invoice as sent.
/// Validates fiscal year status before allowing the invoice to be sent.
///
public class MarkInvoiceSentCommandHandler(
IInvoiceRepository invoiceRepository,
IFiscalYearRepository fiscalYearRepository)
: CommandHandler
{
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
{
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
{
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
// =====================================================
public class CreateCreditNoteCommandHandler
: CommandHandler
{
public override Task ExecuteAsync(
InvoiceAggregate aggregate,
CreateCreditNoteCommand command,
CancellationToken cancellationToken)
{
aggregate.CreateCreditNote(
command.CompanyId,
command.FiscalYearId,
command.CustomerId,
command.CustomerName,
command.CustomerNumber,
command.CreditNoteNumber,
command.CreditNoteDate,
command.Currency,
command.VatCode,
command.Notes,
command.Reference,
command.CreatedBy,
command.OriginalInvoiceId,
command.OriginalInvoiceNumber,
command.CreditReason);
return Task.CompletedTask;
}
}
public class IssueCreditNoteCommandHandler
: CommandHandler
{
public override Task ExecuteAsync(
InvoiceAggregate aggregate,
IssueCreditNoteCommand command,
CancellationToken cancellationToken)
{
aggregate.Issue(
command.LedgerTransactionId,
command.IssuedBy);
return Task.CompletedTask;
}
}
public class ApplyCreditNoteCommandHandler
: CommandHandler
{
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;
}
}