using Books.Api.Domain.Invoices.Events; using EventFlow.Aggregates; namespace Books.Api.Domain.Invoices; public class InvoiceAggregate(InvoiceId id) : AggregateRoot(id), IEmit, IEmit, IEmit, IEmit, IEmit, IEmit, IEmit, IEmit { private bool _isCreated; private InvoiceType _type = InvoiceType.Invoice; private InvoiceStatus _status = InvoiceStatus.Draft; private readonly List _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 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; /// /// Remaining amount: For invoices = total - paid, for credit notes = total - applied /// 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 /// /// Create a new credit note draft. /// 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)); } /// /// Issue the credit note (post to ledger). /// This is the credit note equivalent of Send() for invoices. /// 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)); } /// /// Apply this credit note to an invoice. /// 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 }