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>
417 lines
14 KiB
C#
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
|
|
}
|