books/backend/Books.Api/Domain/Invoices/InvoiceAggregate.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

484 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");
if (discountPercent < 0 || discountPercent > 100)
throw new DomainException("INVALID_DISCOUNT", "Discount must be between 0% and 100%", "Rabat skal være mellem 0% og 100%");
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");
if (discountPercent < 0 || discountPercent > 100)
throw new DomainException("INVALID_DISCOUNT", "Discount must be between 0% and 100%", "Rabat skal være mellem 0% og 100%");
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
}