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>
478 lines
17 KiB
C#
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
|
|
}
|