books/backend/Books.Api/Domain/Invoices/InvoiceAggregate.cs
Nicolaj Hartmann 1a0922b778 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>
2026-02-06 01:15:45 +01:00

478 lines
17 KiB
C#

using Books.Api.Domain.Invoices.Events;
using EventFlow.Aggregates;
namespace Books.Api.Domain.Invoices;
public class InvoiceAggregate(InvoiceId id) : AggregateRoot<InvoiceAggregate, InvoiceId>(id),
IEmit<InvoiceCreatedEvent>,
IEmit<InvoiceLineAddedEvent>,
IEmit<InvoiceLineUpdatedEvent>,
IEmit<InvoiceLineRemovedEvent>,
IEmit<InvoiceSentEvent>,
IEmit<InvoicePaymentReceivedEvent>,
IEmit<InvoiceVoidedEvent>,
IEmit<InvoiceCreditAppliedEvent>
{
private bool _isCreated;
private InvoiceType _type = InvoiceType.Invoice;
private InvoiceStatus _status = InvoiceStatus.Draft;
private readonly List<InvoiceLine> _lines = [];
private string _customerId = string.Empty;
private string? _originalInvoiceId;
private string? _originalInvoiceNumber;
private decimal _amountPaid; // For invoices: total paid
private decimal _amountApplied; // For credit notes: total applied
private decimal _amountTotal;
// Expose read-only state for command handlers
public InvoiceType Type => _type;
public InvoiceStatus Status => _status;
public IReadOnlyList<InvoiceLine> Lines => _lines.AsReadOnly();
public string CustomerId => _customerId;
public string? OriginalInvoiceId => _originalInvoiceId;
public string? OriginalInvoiceNumber => _originalInvoiceNumber;
public decimal AmountPaid => _amountPaid;
public decimal AmountApplied => _amountApplied;
public decimal AmountTotal => _amountTotal;
/// <summary>
/// Remaining amount: For invoices = total - paid, for credit notes = total - applied
/// </summary>
public decimal AmountRemaining => _type == InvoiceType.CreditNote
? Math.Abs(_amountTotal) - _amountApplied
: _amountTotal - _amountPaid;
public bool IsCreditNote => _type == InvoiceType.CreditNote;
#region Apply Methods
public void Apply(InvoiceCreatedEvent e)
{
_isCreated = true;
_type = e.Type;
_status = InvoiceStatus.Draft;
_customerId = e.CustomerId;
_originalInvoiceId = e.OriginalInvoiceId;
_originalInvoiceNumber = e.OriginalInvoiceNumber;
}
public void Apply(InvoiceLineAddedEvent e)
{
var line = new InvoiceLine
{
LineNumber = e.LineNumber,
Description = e.Description,
Quantity = e.Quantity,
Unit = e.Unit,
UnitPrice = e.UnitPrice,
DiscountPercent = e.DiscountPercent,
VatCode = e.VatCode,
AccountId = e.AccountId
};
_lines.Add(line);
}
public void Apply(InvoiceLineUpdatedEvent e)
{
var existingIndex = _lines.FindIndex(l => l.LineNumber == e.LineNumber);
if (existingIndex >= 0)
{
_lines[existingIndex] = new InvoiceLine
{
LineNumber = e.LineNumber,
Description = e.Description,
Quantity = e.Quantity,
Unit = e.Unit,
UnitPrice = e.UnitPrice,
DiscountPercent = e.DiscountPercent,
VatCode = e.VatCode,
AccountId = e.AccountId
};
}
}
public void Apply(InvoiceLineRemovedEvent e)
{
_lines.RemoveAll(l => l.LineNumber == e.LineNumber);
}
public void Apply(InvoiceSentEvent e)
{
// Credit notes are "Issued", invoices are "Sent"
_status = _type == InvoiceType.CreditNote ? InvoiceStatus.Issued : InvoiceStatus.Sent;
_amountTotal = e.AmountTotal;
}
public void Apply(InvoicePaymentReceivedEvent e)
{
_amountPaid = e.NewAmountPaid;
_status = e.NewStatus;
}
public void Apply(InvoiceVoidedEvent e)
{
_status = InvoiceStatus.Voided;
}
public void Apply(InvoiceCreditAppliedEvent e)
{
_amountApplied = e.NewAmountApplied;
_status = e.NewStatus;
}
#endregion
#region Command Methods
public void Create(
string companyId,
string fiscalYearId,
string customerId,
string customerName,
string customerNumber,
string invoiceNumber,
DateOnly invoiceDate,
DateOnly dueDate,
int paymentTermsDays,
string currency,
string? vatCode,
string? notes,
string? reference,
string createdBy,
string? sellerCvr = null,
string? sellerName = null,
string? sellerAddress = null)
{
if (_isCreated)
throw new DomainException("INVOICE_EXISTS", "Invoice already exists", "Faktura eksisterer allerede");
if (string.IsNullOrWhiteSpace(companyId))
throw new DomainException("COMPANY_REQUIRED", "Company ID is required", "Virksomheds-ID er påkrævet");
if (string.IsNullOrWhiteSpace(customerId))
throw new DomainException("CUSTOMER_REQUIRED", "Customer ID is required", "Kunde-ID er påkrævet");
if (string.IsNullOrWhiteSpace(invoiceNumber))
throw new DomainException("INVOICE_NUMBER_REQUIRED", "Invoice number is required", "Fakturanummer er påkrævet");
Emit(new InvoiceCreatedEvent(
companyId,
fiscalYearId,
customerId,
customerName,
customerNumber,
invoiceNumber,
invoiceDate,
dueDate,
paymentTermsDays,
currency,
vatCode,
notes,
reference,
createdBy,
sellerCvr: sellerCvr,
sellerName: sellerName,
sellerAddress: sellerAddress));
}
public void AddLine(
string description,
decimal quantity,
decimal unitPrice,
string vatCode,
string? accountId = null,
string? unit = null,
decimal discountPercent = 0)
{
if (!_isCreated)
throw new DomainException("INVOICE_NOT_FOUND", "Invoice does not exist", "Faktura findes ikke");
if (!_status.CanModify())
throw new DomainException("INVOICE_NOT_MODIFIABLE", $"Cannot modify invoice in status {_status}", $"Kan ikke ændre faktura med status {_status}");
if (string.IsNullOrWhiteSpace(description))
throw new DomainException("DESCRIPTION_REQUIRED", "Description is required", "Beskrivelse er påkrævet");
if (quantity <= 0)
throw new DomainException("INVALID_QUANTITY", "Quantity must be positive", "Antal skal være positivt");
if (unitPrice < 0)
throw new DomainException("INVALID_UNIT_PRICE", "Unit price cannot be negative", "Stykpris kan ikke være negativ");
var lineNumber = _lines.Count > 0 ? _lines.Max(l => l.LineNumber) + 1 : 1;
Emit(new InvoiceLineAddedEvent(
lineNumber,
description.Trim(),
quantity,
unit,
unitPrice,
discountPercent,
vatCode,
accountId));
}
public void UpdateLine(
int lineNumber,
string description,
decimal quantity,
decimal unitPrice,
string vatCode,
string? accountId = null,
string? unit = null,
decimal discountPercent = 0)
{
if (!_isCreated)
throw new DomainException("INVOICE_NOT_FOUND", "Invoice does not exist", "Faktura findes ikke");
if (!_status.CanModify())
throw new DomainException("INVOICE_NOT_MODIFIABLE", $"Cannot modify invoice in status {_status}", $"Kan ikke ændre faktura med status {_status}");
if (!_lines.Any(l => l.LineNumber == lineNumber))
throw new DomainException("LINE_NOT_FOUND", $"Line {lineNumber} not found", $"Linje {lineNumber} findes ikke");
Emit(new InvoiceLineUpdatedEvent(
lineNumber,
description.Trim(),
quantity,
unit,
unitPrice,
discountPercent,
vatCode,
accountId));
}
public void RemoveLine(int lineNumber)
{
if (!_isCreated)
throw new DomainException("INVOICE_NOT_FOUND", "Invoice does not exist", "Faktura findes ikke");
if (!_status.CanModify())
throw new DomainException("INVOICE_NOT_MODIFIABLE", $"Cannot modify invoice in status {_status}", $"Kan ikke ændre faktura med status {_status}");
if (!_lines.Any(l => l.LineNumber == lineNumber))
throw new DomainException("LINE_NOT_FOUND", $"Line {lineNumber} not found", $"Linje {lineNumber} findes ikke");
Emit(new InvoiceLineRemovedEvent(lineNumber));
}
public void Send(
string ledgerTransactionId,
string sentBy)
{
if (!_isCreated)
throw new DomainException("INVOICE_NOT_FOUND", "Invoice does not exist", "Faktura findes ikke");
if (!_status.CanSend())
throw new DomainException("INVOICE_NOT_SENDABLE", $"Cannot send invoice in status {_status}", $"Kan ikke sende faktura med status {_status}");
if (_lines.Count == 0)
throw new DomainException("NO_LINES", "Invoice must have at least one line", "Faktura skal have mindst én linje");
var amountExVat = _lines.Sum(l => l.AmountExVat);
var amountVat = _lines.Sum(l => l.AmountVat);
var amountTotal = _lines.Sum(l => l.AmountTotal);
Emit(new InvoiceSentEvent(
ledgerTransactionId,
amountExVat,
amountVat,
amountTotal,
sentBy,
DateTimeOffset.UtcNow));
}
public void ReceivePayment(
decimal amount,
string? bankTransactionId,
string? ledgerTransactionId,
string? paymentReference,
DateOnly paymentDate,
string recordedBy)
{
if (!_isCreated)
throw new DomainException("INVOICE_NOT_FOUND", "Invoice does not exist", "Faktura findes ikke");
if (!_status.CanReceivePayment())
throw new DomainException("CANNOT_RECEIVE_PAYMENT", $"Cannot receive payment for invoice in status {_status}", $"Kan ikke modtage betaling for faktura med status {_status}");
if (amount <= 0)
throw new DomainException("INVALID_AMOUNT", "Payment amount must be positive", "Betalingsbeløb skal være positivt");
if (amount > AmountRemaining)
throw new DomainException("OVERPAYMENT", $"Payment amount ({amount:N2}) exceeds remaining amount ({AmountRemaining:N2})", $"Betalingsbeløb ({amount:N2}) overstiger udestående beløb ({AmountRemaining:N2})");
var newAmountPaid = _amountPaid + amount;
var newAmountRemaining = _amountTotal - newAmountPaid;
var newStatus = newAmountRemaining <= 0 ? InvoiceStatus.Paid : InvoiceStatus.PartiallyPaid;
Emit(new InvoicePaymentReceivedEvent(
amount,
bankTransactionId,
ledgerTransactionId,
paymentReference,
paymentDate,
recordedBy,
newAmountPaid,
newAmountRemaining,
newStatus));
}
public void Void(
string reason,
string? reversalLedgerTransactionId,
string voidedBy)
{
if (!_isCreated)
throw new DomainException("INVOICE_NOT_FOUND", "Invoice does not exist", "Faktura findes ikke");
if (!_status.CanVoid())
throw new DomainException("CANNOT_VOID", $"Cannot void invoice in status {_status}", $"Kan ikke annullere faktura med status {_status}");
if (string.IsNullOrWhiteSpace(reason))
throw new DomainException("REASON_REQUIRED", "Void reason is required", "Annulleringsårsag er påkrævet");
Emit(new InvoiceVoidedEvent(
reason.Trim(),
reversalLedgerTransactionId,
voidedBy,
DateTimeOffset.UtcNow));
}
#endregion
#region Credit Note Command Methods
/// <summary>
/// Create a new credit note draft.
/// </summary>
public void CreateCreditNote(
string companyId,
string fiscalYearId,
string customerId,
string customerName,
string customerNumber,
string creditNoteNumber,
DateOnly creditNoteDate,
string currency,
string? vatCode,
string? notes,
string? reference,
string createdBy,
string? originalInvoiceId = null,
string? originalInvoiceNumber = null,
string? creditReason = null)
{
if (_isCreated)
throw new DomainException("CREDIT_NOTE_EXISTS", "Credit note already exists", "Kreditnota eksisterer allerede");
if (string.IsNullOrWhiteSpace(companyId))
throw new DomainException("COMPANY_REQUIRED", "Company ID is required", "Virksomheds-ID er påkrævet");
if (string.IsNullOrWhiteSpace(customerId))
throw new DomainException("CUSTOMER_REQUIRED", "Customer ID is required", "Kunde-ID er påkrævet");
if (string.IsNullOrWhiteSpace(creditNoteNumber))
throw new DomainException("CREDIT_NOTE_NUMBER_REQUIRED", "Credit note number is required", "Kreditnotanummer er påkrævet");
// Credit notes typically have no due date - set to same as issue date
Emit(new InvoiceCreatedEvent(
companyId,
fiscalYearId,
customerId,
customerName,
customerNumber,
creditNoteNumber,
creditNoteDate,
creditNoteDate, // Due date = issue date for credit notes
0, // No payment terms for credit notes
currency,
vatCode,
notes,
reference,
createdBy,
InvoiceType.CreditNote,
originalInvoiceId,
originalInvoiceNumber,
creditReason));
}
/// <summary>
/// Issue the credit note (post to ledger).
/// This is the credit note equivalent of Send() for invoices.
/// </summary>
public void Issue(
string ledgerTransactionId,
string issuedBy)
{
if (!_isCreated)
throw new DomainException("CREDIT_NOTE_NOT_FOUND", "Credit note does not exist", "Kreditnota findes ikke");
if (_type != InvoiceType.CreditNote)
throw new DomainException("NOT_CREDIT_NOTE", "This operation is only valid for credit notes", "Denne handling er kun gyldig for kreditnotaer");
if (!_status.CanIssue())
throw new DomainException("CREDIT_NOTE_NOT_ISSUABLE", $"Cannot issue credit note in status {_status}", $"Kan ikke udstede kreditnota med status {_status}");
if (_lines.Count == 0)
throw new DomainException("NO_LINES", "Credit note must have at least one line", "Kreditnota skal have mindst én linje");
// Credit note amounts are stored as negative (opposite of invoice)
var amountExVat = -_lines.Sum(l => l.AmountExVat);
var amountVat = -_lines.Sum(l => l.AmountVat);
var amountTotal = -_lines.Sum(l => l.AmountTotal);
// We reuse InvoiceSentEvent but the status will be set to Issued
Emit(new InvoiceSentEvent(
ledgerTransactionId,
amountExVat,
amountVat,
amountTotal,
issuedBy,
DateTimeOffset.UtcNow));
}
/// <summary>
/// Apply this credit note to an invoice.
/// </summary>
public void ApplyCredit(
string targetInvoiceId,
string targetInvoiceNumber,
decimal amount,
DateOnly appliedDate,
string appliedBy,
string? ledgerTransactionId = null)
{
if (!_isCreated)
throw new DomainException("CREDIT_NOTE_NOT_FOUND", "Credit note does not exist", "Kreditnota findes ikke");
if (_type != InvoiceType.CreditNote)
throw new DomainException("NOT_CREDIT_NOTE", "This operation is only valid for credit notes", "Denne handling er kun gyldig for kreditnotaer");
if (!_status.CanApplyCredit())
throw new DomainException("CANNOT_APPLY_CREDIT", $"Cannot apply credit note in status {_status}", $"Kan ikke anvende kreditnota med status {_status}");
if (amount <= 0)
throw new DomainException("INVALID_AMOUNT", "Credit amount must be positive", "Kreditbeløb skal være positivt");
if (amount > AmountRemaining)
throw new DomainException("OVERCREDIT", $"Credit amount ({amount:N2}) exceeds remaining credit ({AmountRemaining:N2})", $"Kreditbeløb ({amount:N2}) overstiger udestående kredit ({AmountRemaining:N2})");
var newAmountApplied = _amountApplied + amount;
var newAmountRemaining = Math.Abs(_amountTotal) - newAmountApplied;
var newStatus = newAmountRemaining <= 0 ? InvoiceStatus.FullyApplied : InvoiceStatus.PartiallyApplied;
Emit(new InvoiceCreditAppliedEvent(
targetInvoiceId,
targetInvoiceNumber,
amount,
appliedDate,
appliedBy,
ledgerTransactionId,
newAmountApplied,
newAmountRemaining,
newStatus));
}
#endregion
}