books/backend/Books.Api/Domain/Orders/OrderAggregate.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

417 lines
14 KiB
C#

using Books.Api.Domain.Orders.Events;
using EventFlow.Aggregates;
namespace Books.Api.Domain.Orders;
public class OrderAggregate(OrderId id) : AggregateRoot<OrderAggregate, OrderId>(id),
IEmit<OrderCreatedEvent>,
IEmit<OrderUpdatedEvent>,
IEmit<OrderLineAddedEvent>,
IEmit<OrderLineUpdatedEvent>,
IEmit<OrderLineRemovedEvent>,
IEmit<OrderConfirmedEvent>,
IEmit<OrderRevertedToDraftEvent>,
IEmit<OrderLinesInvoicedEvent>,
IEmit<OrderCompletedEvent>,
IEmit<OrderCancelledEvent>
{
private bool _isCreated;
private OrderStatus _status = OrderStatus.Draft;
private readonly List<OrderLine> _lines = [];
private string _customerId = string.Empty;
private decimal _amountExVat;
private decimal _amountVat;
private decimal _amountTotal;
// Expose read-only state for command handlers
public OrderStatus Status => _status;
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
public string CustomerId => _customerId;
public decimal AmountExVat => _amountExVat;
public decimal AmountVat => _amountVat;
public decimal AmountTotal => _amountTotal;
/// <summary>
/// Count of lines that have not been invoiced yet.
/// </summary>
public int UninvoicedLineCount => _lines.Count(l => !l.IsInvoiced);
/// <summary>
/// Total amount of uninvoiced lines.
/// </summary>
public decimal UninvoicedAmount => _lines.Where(l => !l.IsInvoiced).Sum(l => l.AmountTotal);
#region Apply Methods
public void Apply(OrderCreatedEvent e)
{
_isCreated = true;
_status = OrderStatus.Draft;
_customerId = e.CustomerId;
}
public void Apply(OrderUpdatedEvent e)
{
// Header updates don't change aggregate state that affects business rules
}
public void Apply(OrderLineAddedEvent e)
{
var line = new OrderLine
{
LineNumber = e.LineNumber,
Description = e.Description,
Quantity = e.Quantity,
Unit = e.Unit,
UnitPrice = e.UnitPrice,
DiscountPercent = e.DiscountPercent,
VatCode = e.VatCode,
AccountId = e.AccountId,
ProductId = e.ProductId,
IsInvoiced = false
};
_lines.Add(line);
}
public void Apply(OrderLineUpdatedEvent e)
{
var existingIndex = _lines.FindIndex(l => l.LineNumber == e.LineNumber);
if (existingIndex >= 0)
{
var existing = _lines[existingIndex];
_lines[existingIndex] = new OrderLine
{
LineNumber = e.LineNumber,
Description = e.Description,
Quantity = e.Quantity,
Unit = e.Unit,
UnitPrice = e.UnitPrice,
DiscountPercent = e.DiscountPercent,
VatCode = e.VatCode,
AccountId = e.AccountId,
ProductId = e.ProductId,
IsInvoiced = existing.IsInvoiced,
InvoiceId = existing.InvoiceId,
InvoicedAt = existing.InvoicedAt
};
}
}
public void Apply(OrderLineRemovedEvent e)
{
_lines.RemoveAll(l => l.LineNumber == e.LineNumber);
}
public void Apply(OrderConfirmedEvent e)
{
_status = OrderStatus.Confirmed;
_amountExVat = e.AmountExVat;
_amountVat = e.AmountVat;
_amountTotal = e.AmountTotal;
}
public void Apply(OrderRevertedToDraftEvent e)
{
_status = OrderStatus.Draft;
}
public void Apply(OrderCompletedEvent e)
{
// Completed is a terminal state - no additional state changes needed
}
public void Apply(OrderLinesInvoicedEvent e)
{
_status = e.NewStatus;
foreach (var lineNumber in e.LineNumbers)
{
var index = _lines.FindIndex(l => l.LineNumber == lineNumber);
if (index >= 0)
{
_lines[index] = _lines[index].MarkAsInvoiced(e.InvoiceId, e.InvoicedAt);
}
}
}
public void Apply(OrderCancelledEvent e)
{
_status = OrderStatus.Cancelled;
}
#endregion
#region Command Methods
public void Create(
string companyId,
string fiscalYearId,
string customerId,
string customerName,
string customerNumber,
string orderNumber,
DateOnly orderDate,
DateOnly? expectedDeliveryDate,
string currency,
string? vatCode,
string? notes,
string? reference,
string createdBy)
{
if (_isCreated)
throw new DomainException("ORDER_EXISTS", "Order already exists", "Ordre 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(orderNumber))
throw new DomainException("ORDER_NUMBER_REQUIRED", "Order number is required", "Ordrenummer er påkrævet");
Emit(new OrderCreatedEvent(
companyId,
fiscalYearId,
customerId,
customerName,
customerNumber,
orderNumber,
orderDate,
expectedDeliveryDate,
currency,
vatCode,
notes,
reference,
createdBy));
}
public void Update(
DateOnly? expectedDeliveryDate,
string? notes,
string? reference,
string updatedBy)
{
if (!_isCreated)
throw new DomainException("ORDER_NOT_FOUND", "Order does not exist", "Ordre findes ikke");
if (!_status.CanModify())
throw new DomainException("ORDER_NOT_MODIFIABLE", $"Cannot modify order in status {_status}", $"Kan ikke ændre ordre med status {_status}");
Emit(new OrderUpdatedEvent(
expectedDeliveryDate,
notes?.Trim(),
reference?.Trim(),
updatedBy));
}
public void AddLine(
string description,
decimal quantity,
decimal unitPrice,
string vatCode,
string? accountId = null,
string? unit = null,
decimal discountPercent = 0,
string? productId = null)
{
if (!_isCreated)
throw new DomainException("ORDER_NOT_FOUND", "Order does not exist", "Ordre findes ikke");
if (!_status.CanModify())
throw new DomainException("ORDER_NOT_MODIFIABLE", $"Cannot modify order in status {_status}", $"Kan ikke ændre ordre 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 OrderLineAddedEvent(
lineNumber,
description.Trim(),
quantity,
unit,
unitPrice,
discountPercent,
vatCode,
accountId,
productId));
}
public void UpdateLine(
int lineNumber,
string description,
decimal quantity,
decimal unitPrice,
string vatCode,
string? accountId = null,
string? unit = null,
decimal discountPercent = 0,
string? productId = null)
{
if (!_isCreated)
throw new DomainException("ORDER_NOT_FOUND", "Order does not exist", "Ordre findes ikke");
if (!_status.CanModify())
throw new DomainException("ORDER_NOT_MODIFIABLE", $"Cannot modify order in status {_status}", $"Kan ikke ændre ordre med status {_status}");
var line = _lines.FirstOrDefault(l => l.LineNumber == lineNumber);
if (line == null)
throw new DomainException("LINE_NOT_FOUND", $"Line {lineNumber} not found", $"Linje {lineNumber} findes ikke");
if (line.IsInvoiced)
throw new DomainException("LINE_ALREADY_INVOICED", $"Line {lineNumber} has already been invoiced", $"Linje {lineNumber} er allerede faktureret");
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 OrderLineUpdatedEvent(
lineNumber,
description.Trim(),
quantity,
unit,
unitPrice,
discountPercent,
vatCode,
accountId,
productId));
}
public void RemoveLine(int lineNumber)
{
if (!_isCreated)
throw new DomainException("ORDER_NOT_FOUND", "Order does not exist", "Ordre findes ikke");
if (!_status.CanModify())
throw new DomainException("ORDER_NOT_MODIFIABLE", $"Cannot modify order in status {_status}", $"Kan ikke ændre ordre med status {_status}");
var line = _lines.FirstOrDefault(l => l.LineNumber == lineNumber);
if (line == null)
throw new DomainException("LINE_NOT_FOUND", $"Line {lineNumber} not found", $"Linje {lineNumber} findes ikke");
if (line.IsInvoiced)
throw new DomainException("LINE_ALREADY_INVOICED", $"Line {lineNumber} has already been invoiced", $"Linje {lineNumber} er allerede faktureret");
Emit(new OrderLineRemovedEvent(lineNumber));
}
public void Confirm(string confirmedBy)
{
if (!_isCreated)
throw new DomainException("ORDER_NOT_FOUND", "Order does not exist", "Ordre findes ikke");
if (!_status.CanConfirm())
throw new DomainException("ORDER_NOT_CONFIRMABLE", $"Cannot confirm order in status {_status}", $"Kan ikke bekræfte ordre med status {_status}");
if (_lines.Count == 0)
throw new DomainException("NO_LINES", "Order must have at least one line", "Ordre 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 OrderConfirmedEvent(
confirmedBy,
DateTimeOffset.UtcNow,
amountExVat,
amountVat,
amountTotal));
}
public void RevertToDraft(string revertedBy, string? reason = null)
{
if (!_isCreated)
throw new DomainException("ORDER_NOT_FOUND", "Order does not exist", "Ordre findes ikke");
if (_status != OrderStatus.Confirmed)
throw new DomainException("ORDER_NOT_CONFIRMED", "Only confirmed orders can be reverted to draft", "Kun bekræftede ordrer kan tilbageføres til kladde");
Emit(new OrderRevertedToDraftEvent(
revertedBy,
DateTimeOffset.UtcNow,
reason?.Trim()));
}
/// <summary>
/// Mark specified lines as invoiced.
/// Called after invoice is created from this order.
/// </summary>
public void MarkLinesAsInvoiced(
string invoiceId,
string invoiceNumber,
IReadOnlyList<int> lineNumbers,
string invoicedBy)
{
if (!_isCreated)
throw new DomainException("ORDER_NOT_FOUND", "Order does not exist", "Ordre findes ikke");
if (!_status.CanInvoice())
throw new DomainException("ORDER_NOT_INVOICEABLE", $"Cannot invoice order in status {_status}", $"Kan ikke fakturere ordre med status {_status}");
if (lineNumbers.Count == 0)
throw new DomainException("NO_LINES_SELECTED", "At least one line must be selected for invoicing", "Mindst én linje skal vælges til fakturering");
// Validate all lines exist and are not already invoiced
foreach (var lineNumber in lineNumbers)
{
var line = _lines.FirstOrDefault(l => l.LineNumber == lineNumber);
if (line == null)
throw new DomainException("LINE_NOT_FOUND", $"Line {lineNumber} not found", $"Linje {lineNumber} findes ikke");
if (line.IsInvoiced)
throw new DomainException("LINE_ALREADY_INVOICED", $"Line {lineNumber} has already been invoiced", $"Linje {lineNumber} er allerede faktureret");
}
// Determine new status
var uninvoicedAfter = _lines.Count(l => !l.IsInvoiced && !lineNumbers.Contains(l.LineNumber));
var newStatus = uninvoicedAfter == 0 ? OrderStatus.FullyInvoiced : OrderStatus.PartiallyInvoiced;
Emit(new OrderLinesInvoicedEvent(
invoiceId,
invoiceNumber,
lineNumbers,
DateTimeOffset.UtcNow,
invoicedBy,
newStatus));
}
public void Complete(string completedBy)
{
if (!_isCreated)
throw new DomainException("ORDER_NOT_FOUND", "Order does not exist", "Ordre findes ikke");
if (_status != OrderStatus.FullyInvoiced)
throw new DomainException("ORDER_NOT_FULLY_INVOICED", "Only fully invoiced orders can be completed", "Kun fuldt fakturerede ordrer kan markeres som afsluttet");
Emit(new OrderCompletedEvent(
completedBy,
DateTimeOffset.UtcNow));
}
public void Cancel(string reason, string cancelledBy)
{
if (!_isCreated)
throw new DomainException("ORDER_NOT_FOUND", "Order does not exist", "Ordre findes ikke");
if (!_status.CanCancel())
throw new DomainException("CANNOT_CANCEL", $"Cannot cancel order in status {_status}", $"Kan ikke annullere ordre med status {_status}");
if (string.IsNullOrWhiteSpace(reason))
throw new DomainException("REASON_REQUIRED", "Cancellation reason is required", "Annulleringsårsag er påkrævet");
Emit(new OrderCancelledEvent(
reason.Trim(),
cancelledBy,
DateTimeOffset.UtcNow));
}
#endregion
}