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.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 ,
Audit v3: VAT alignment, security, encoding, UX, compliance
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>
2026-02-06 01:15:45 +01:00
string createdBy ,
string? sellerCvr = null ,
string? sellerName = null ,
string? sellerAddress = null )
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
{
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 ,
Audit v3: VAT alignment, security, encoding, UX, compliance
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>
2026-02-06 01:15:45 +01:00
createdBy ,
sellerCvr : sellerCvr ,
sellerName : sellerName ,
sellerAddress : sellerAddress ) ) ;
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
}
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" ) ;
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 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" ) ;
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 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
}