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