Storno Workflow — Invoice Cancellation Service
NestJS Domain Service implementing GoBD-compliant invoice cancellation (Storno) and optional reissuance. Part of the Commerce Bounded Context.
Purpose
GoBD prohibits deleting or silently editing issued invoices. The InvoiceCancellationService enforces a formal cancel → counter-document → optional reissue workflow.
CAUTION
GoBD-Pflicht: VOIDED bedeutet nicht, dass die ursprüngliche Invoice-Zeile modifiziert wird. Ein Storno erfordert zwingend die Erstellung eines neuen, gegenläufigen Buchungsdokuments (Stornorechnung). Das Ursprungsdokument bleibt unverändert im System.
Lifecycle
mermaid
stateDiagram-v2
[*] --> ISSUED
ISSUED --> CANCELLED: cancelInvoice()
note right of CANCELLED: Original invoice remains\nunchanged. A new\nStornorechnung is created.
CANCELLED --> [*]: No replacement needed
CANCELLED --> DRAFT: reissueInvoice()
DRAFT --> ISSUED: New corrected invoice issuedAPI Surface (Hasura Actions)
cancelInvoice
graphql
mutation CancelInvoice($invoiceId: uuid!, $reason: String!) {
cancelInvoice(invoice_id: $invoiceId, reason: $reason) {
cancellation_id
storno_invoice_id
}
}Side effects:
- Does NOT modify the original
invoicesrow — it remains inISSUEDstatus with acancelled: trueflag - Creates a new Stornorechnung (counter-invoice) with negative amounts mirroring the original
- Creates
invoice_cancellationsrecord linkingcancelled_invoice_id→storno_invoice_idwithreasonand timestamp - Writes
commerce.change_eventsentries viaAuditTrailService(scope:GOBD,entity_type: 'invoice',entity_idset to both original and storno invoices) — see ADR-019
reissueInvoice
graphql
mutation ReissueInvoice($cancellationId: uuid!) {
reissueInvoice(cancellation_id: $cancellationId) {
new_invoice_id
}
}Side effects:
- Creates a new
invoicesrecord with statusDRAFTand corrected amounts - Links via
invoice_cancellations.replacement_invoice_id
Validation Guards
| Guard | Check | Error |
|---|---|---|
| Period Lock | PeriodLockService.validateMutation(tenantId, invoice.issue_date) | PeriodLockedException |
| Status | invoice.status must be 'ISSUED' | InvalidInvoiceStatusException |
| Duplicate Storno | No existing cancellation for this invoice_id | AlreadyCancelledException |
Audit Trail
Every cancelInvoice and reissueInvoice call writes to commerce.change_events via the shared AuditTrailService (ADR-019) with:
scope: 'GOBD'entity_type: 'invoice',entity_idpointing to the affected invoiceold_valuescaptured viaSELECT ... FOR UPDATE,new_valuescapturing the status change