Add all backend domain, commands, repositories, and tests
This commit includes all previously untracked backend files:
Domain:
- Accounts, Attachments, BankConnections, Customers
- FiscalYears, Invoices, JournalEntryDrafts
- Orders, Products, UserAccess
Commands & Handlers:
- Full CQRS command structure for all domains
Repositories:
- PostgreSQL repositories for all read models
- Bank transaction and ledger repositories
GraphQL:
- Input types, scalars, and types for all entities
- Mutations and queries
Infrastructure:
- Banking integration (Enable Banking client)
- File storage, Invoicing, Reporting, SAF-T export
- Database migrations (003-029)
Tests:
- Integration tests for GraphQL endpoints
- Domain tests
- Invoicing and reporting tests
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:19:42 +01:00
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" ) ;
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
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%" ) ;
Add all backend domain, commands, repositories, and tests
This commit includes all previously untracked backend files:
Domain:
- Accounts, Attachments, BankConnections, Customers
- FiscalYears, Invoices, JournalEntryDrafts
- Orders, Products, UserAccess
Commands & Handlers:
- Full CQRS command structure for all domains
Repositories:
- PostgreSQL repositories for all read models
- Bank transaction and ledger repositories
GraphQL:
- Input types, scalars, and types for all entities
- Mutations and queries
Infrastructure:
- Banking integration (Enable Banking client)
- File storage, Invoicing, Reporting, SAF-T export
- Database migrations (003-029)
Tests:
- Integration tests for GraphQL endpoints
- Domain tests
- Invoicing and reporting tests
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:19:42 +01:00
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" ) ;
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
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%" ) ;
Add all backend domain, commands, repositories, and tests
This commit includes all previously untracked backend files:
Domain:
- Accounts, Attachments, BankConnections, Customers
- FiscalYears, Invoices, JournalEntryDrafts
- Orders, Products, UserAccess
Commands & Handlers:
- Full CQRS command structure for all domains
Repositories:
- PostgreSQL repositories for all read models
- Bank transaction and ledger repositories
GraphQL:
- Input types, scalars, and types for all entities
- Mutations and queries
Infrastructure:
- Banking integration (Enable Banking client)
- File storage, Invoicing, Reporting, SAF-T export
- Database migrations (003-029)
Tests:
- Integration tests for GraphQL endpoints
- Domain tests
- Invoicing and reporting tests
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:19:42 +01:00
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
}