Invoice Service Protocol
This document specifies the InvoiceService — the NestJS domain service responsible for generating GoBD-compliant invoices, issuing them, rendering PDFs, and producing credit notes for partial cancellations. Invoices are derived projections: the system reads from existing entities (Booking, Passenger, TaxLedgerEntry) and snapshots volatile data into JSONB at generation time.
Contexts involved: Commerce (invoice lifecycle, tax ledger) · Backoffice (operator legal profile for supplier data) Related protocols: Tax Engine Protocol · Supporting services: § Storno Workflow · § Period Lock · § DATEV Export
§ 14 UStG Invoice Field Mapping
German invoices must contain the following fields per § 14 Abs. 4 UStG:
| # | § 14 Requirement | Source Entity | Source Field(s) | Notes |
|---|---|---|---|---|
| 1 | Full name and address of supplier | backoffice.operators (cross-schema soft FK) | company_name, address (from operator profile) | Denormalized to invoices.supplier_snapshot JSONB at generation time |
| 2 | Full name and address of recipient | commerce.bookings.booker_profile_id → backoffice.person_profiles | first_name, last_name, + address [TBD — billing address capture at checkout, see SB-10] | Denormalized to invoices.recipient_snapshot JSONB. The booker (person with billing authority) is the invoice recipient — not the primary contact passenger. See ADR-037. |
| 3 | Tax number or USt-IdNr. | backoffice.operators | tax_number or vat_id | From operator's legal profile |
| 4 | Date of issue | commerce.invoices | issue_date | Set at generation time |
| 5 | Sequential invoice number | commerce.invoices | invoice_number | From tenant_invoice_sequences (gap-free) |
| 6 | Description of service | backoffice.tour_templates + tour_offerings | title, description, start_date, end_date, boarding points | Rendered as "Busreise: {title}, {start_date} – {end_date}, ab {boarding_point}" |
| 7 | Date of service delivery | commerce.tour_offerings | start_date / end_date | "Leistungszeitraum: {start_date} – {end_date}" |
| 8 | Net amount, tax rate, tax amount per rate | commerce.tax_ledger_entries | Per TLE: tax_base_amount, tax_rate, tax_amount | One line per tax_strategy (ADR-015: 1:N) |
| 9 | Gross total | commerce.bookings | total_amount | Cross-verified: SUM(TLE.tax_base_amount + TLE.tax_amount) == booking.total_amount |
| 10 | § 25 UStG annotation | commerce.tax_ledger_entries | tax_strategy = MARGIN_SCHEME_25 | "Umsatzsteuer nach § 25 UStG (Margenbesteuerung für Reiseleistungen)" |
Invoice Data Snapshots
At generation time, the InvoiceService snapshots volatile data into JSONB fields on the Invoice row. This ensures the invoice remains immutable even if source entities change later (GoBD compliance). See schema-commerce.md §invoices for the column definitions.
Line Items Snapshot Structure
interface InvoiceLineItem {
position: number; // 1-indexed
description: string; // Service description
quantity: number; // Passenger count or ancillary quantity
unit_price: number; // Per-passenger or per-unit price
net_amount: number; // quantity × unit_price
tax_rate: number; // e.g., 0.19
tax_amount: number; // net_amount × tax_rate
gross_amount: number; // net_amount + tax_amount
tax_strategy: 'STANDARD_VAT' | 'MARGIN_SCHEME_25';
}Example line items for a tour booking:
| Pos | Description | Qty | Unit Price | Net | Tax Rate | Tax | Gross |
|---|---|---|---|---|---|---|---|
| 1 | Busreise "Gardasee 7T", 01.06 – 07.06.2026, ab München | 2 | 499.00 | 998.00 | 19% (§25) | 31.65* | 1029.65 |
| 2 | Reiserücktrittsversicherung | 2 | 29.00 | 58.00 | 19% | 11.02 | 69.02 |
| Gesamt | 1056.00 | 42.67 | 1098.67 |
§ 25 UStG: tax is computed on the margin only, not the full net amount. The tax_amount reflects the margin-based calculation from TaxLedgerEntry.
InvoiceService Operations
| Operation | Input | Precondition | Effect | Output | Errors |
|---|---|---|---|---|---|
| generateInvoice | booking_id | Booking.status = FULLY_PAID. No existing non-cancelled Invoice for this booking. FinancialLedger exists and is OPEN or CLOSED. | 1. Fetch next invoice_number from tenant_invoice_sequences (atomic increment) 2. Snapshot supplier, recipient, line items 3. Fetch TaxLedgerEntry data for tax breakdown 4. Create invoices row (status: DRAFT) 5. Write change_events (scope: GOBD) | { invoice_id, invoice_number } | BookingNotFullyPaid (422), InvoiceAlreadyExists (409), NoFinancialLedger (422) |
| issueInvoice | invoice_id | Invoice.status = DRAFT. Period not locked. | Sets status → ISSUED. Emit InvoiceIssued. | { invoice_id, issued_at } | NotDraft (422), PeriodLockedException (423) |
| renderPdf | invoice_id | Invoice exists. | Generates PDF from invoice snapshots + template. Stores PDF in object storage. Returns URL. | { pdf_url } | InvoiceNotFound (404) |
Invoice Trigger
When is an invoice generated? Operator-configurable:
| Strategy | Trigger | Note |
|---|---|---|
| Automatic (default) | BookingFullyPaid event → auto-generate + auto-issue | Most operators want instant invoicing |
| Manual | Dispatcher triggers from Workspace UI | For operators who review before issuing |
V1: Automatic by default. The processPaymentWebhook handler includes an optional step after FULLY_PAID: InvoiceService.generateInvoice() + issueInvoice().
Invoice Numbering
Gap-free sequential numbering per tenant per fiscal year:
-- Atomic increment function
SELECT last_number + 1 INTO next_number
FROM commerce.tenant_invoice_sequences
WHERE tenant_id = $tenant_id AND fiscal_year = $year
FOR UPDATE; -- row-level lock
UPDATE commerce.tenant_invoice_sequences
SET last_number = next_number
WHERE tenant_id = $tenant_id AND fiscal_year = $year;Format: {tenant_prefix}-{year}-{number padded to 5 digits} → e.g., BUS-2026-00042
Credit Note for Partial Cancellation
When a passenger or ancillary is partially refunded (see cancellation-protocol.md), a credit note (Gutschrift) is needed if an invoice was already issued:
generateCreditNote
| Property | Value |
|---|---|
| Input | { booking_id, original_invoice_id, refund_amount, reason } |
| Handler route | POST /hasura/actions/generate-credit-note |
| Guards | Original invoice.status = ISSUED. Period not locked. |
| Transaction | 1. Get next invoice_number (credit notes use the same sequence — they ARE invoices, just with negative amounts) 2. Create new invoices row with negative total_gross 3. Create invoice_cancellations row linking cancelled_invoice_id = original_invoice_id, replacement_invoice_id = NULL (credit note, not replacement), reason 4. Original invoice.cancelled = false (it's NOT cancelled — a credit note is a separate document, not a Storno) 5. Write change_events (scope: GOBD) |
| Output | { credit_note_id, credit_note_number } |
Important: Credit notes are distinct from Storno (full cancellation). A credit note is a NEW invoice with negative amounts. The original invoice remains valid and unchanged. This differs from the Storno workflow where the original is cancelled.
Edge States
| # | Edge Case | Expected Behavior |
|---|---|---|
| 1 | Invoice generation before FinancialLedger closure | Permitted. The system generates invoices per-booking at FULLY_PAID. FinancialLedger closure is a separate process (post-trip aggregation). The invoice snapshot captures data at generation time. |
| 2 | Period lock prevents invoice issuance | issueInvoice calls PeriodLockService.validateMutation(). If locked → PeriodLockedException. The invoice remains in DRAFT until the period is unlocked or moved to the next period. |
| 3 | Multiple invoices for same booking | Guard: InvoiceAlreadyExists if a non-cancelled invoice exists. To create a new invoice, the old one must be cancelled (Storno) first. |
| 4 | Invoice for partial payment (only deposit paid) | V1: The system generates invoices only at FULLY_PAID. Deposit confirmations are NOT invoices — they are payment receipts (different document type, not in scope for V1). |
| 5 | Credit note after period lock | Same as any financial mutation: PeriodLockedException. The credit note must wait for period unlock or be recorded in the next open period. |
| 6 | Concurrent invoice number generation | FOR UPDATE row-level lock on tenant_invoice_sequences ensures gap-free sequential numbering. Concurrent requests serialize at the DB level. |
| 7 | Invoice for § 25 UStG tour | The rendered invoice MUST include the annotation "Umsatzbesteuerung von Reiseleistungen, § 25 UStG. Umsatzsteuer ist im Preis enthalten." (per § 14 Abs. 4 Nr. 8 UStG). Individual VAT amounts per line item need NOT be shown — the margin tax is calculated on the aggregate. |
| 8 | Invoice for mixed booking (Standard + § 25) | ADR-015 supports 1:N TaxLedgerEntry. The invoice shows two tax summary blocks: one for Standard VAT items, one for § 25 margin items. Each with its own tax_base_amount, tax_rate, tax_amount. |
Storno Workflow
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.
Storno Lifecycle
cancelInvoice
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
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
Storno 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 |
Storno 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
Period Lock Service
The PeriodLockService prevents mutation of financial records (FinancialLedger, Invoice, TaxLedgerEntry) after the user closes a reporting period or exports it to DATEV.
Lock Types
| Type | Trigger | Unlockable? |
|---|---|---|
EXPORT | Automatically created when the system generates a DATEV export for a period | No — irreversible |
MANUAL | Manually triggered by an operator (e.g., month-end close) | Only by manager with audit trail |
lockPeriod
mutation LockPeriod($tenantId: uuid!, $periodStart: date!, $periodEnd: date!, $lockType: String!) {
lockPeriod(tenant_id: $tenantId, period_start: $periodStart, period_end: $periodEnd, lock_type: $lockType) {
id
locked_at
}
}unlockPeriod
mutation UnlockPeriod($tenantId: uuid!, $lockId: uuid!) {
unlockPeriod(tenant_id: $tenantId, lock_id: $lockId) {
success
}
}(Note: Requires MANAGER role with strict audit logging for GoBD compliance. Admins can only unlock MANUAL lock types. EXPORT locks remain immutable).
isLocked (internal pre-mutation guard)
Called as a Hasura Action Permission or Pre-Insert/Update hook on commerce.invoices, commerce.financial_ledgers, and commerce.tax_ledger_entries.
Period Lock Validation Logic
validateMutation(tenantId, entityDate):
locks = SELECT * FROM commerce.ledger_period_locks
WHERE tenant_id = tenantId
AND period_start <= entityDate
AND period_end >= entityDate
IF locks.length > 0:
THROW PeriodLockedException("Period is locked since {lock.locked_at}")Period Lock Integration Points
| Caller | When | Purpose |
|---|---|---|
| Invoice mutation (Hasura ET) | Before any UPDATE on commerce.invoices | Block direct edits on locked periods |
| DATEV Export Service | After the system generates an export | Auto-create EXPORT lock for the exported period |
| Workspace UI | Manual month-end close | User-initiated MANUAL lock |
| Storno Workflow | Before cancelling an invoice | Validate the invoice's period is not locked |
DATEV Export Integration
IMPORTANT
DATEV-Schnittstellen-Klarstellung:
- DATEV XML online (Belegbilderservice / Rechnungsdatenservice) = Einzelbelege (Rechnungen als PDF + strukturierte XML-Metadaten). Nicht für aggregierte Buchungssätze.
- DATEV-Format CSV (ehemals KNE/OBE) = Aggregierte Buchungsstapel (Journal Entries) pro Periode.
- DATEV Buchungsdatenservice (REST API) = Alternative zu CSV für automatisierten Buchungsdaten-Transfer [future Phase 2].
Busflow braucht beide Wege: Einzelbelege (Quittungsfotos) + Buchungsstapel (Journal Entries).
Einzelbelege → DATEV XML online
For transmitting individual invoices and OCR-scanned receipts (fuel receipts, toll receipts) to the tax advisor.
Key Feature: <BelegLink> — Binds the URL/GUID of the photographed receipt (ExpenseReceipt) directly into the XML structure. The tax advisor has the receipt image immediately alongside the journal entry in DATEV Unternehmen online. → Zero-Touch Accounting.
Einzelbeleg Workflow
Einzelbeleg Data Mapping
| Busflow Entity | DATEV XML Element |
|---|---|
Invoice (PDF) | <Beleg> with <BelegLink> |
ExpenseReceipt (Foto) | <Beleg> with <BelegLink> to storage URL |
Invoice.invoice_number | <Belegfeld1> |
Invoice.issue_date | <Belegdatum> |
Buchungsstapel → DATEV-Format CSV
For transmitting aggregated, tax-resolved journal entries (Buchungssätze) of a period.
Buchungsstapel Workflow
Buchungsstapel Data Mapping
| Busflow Field | DATEV CSV Column | Notes |
|---|---|---|
TaxLedgerEntry.tax_base_amount | Umsatz | |
TaxLedgerEntry.tax_amount | Steuerbetrag | |
TaxLedgerEntry.tax_strategy | BU-Schlüssel (40 = § 25 UStG) | |
Invoice.invoice_number | Belegfeld 1 | |
Invoice.issue_date | Belegdatum | |
FinancialLedger.tenant_id | Mandantennummer | |
| FREMD procurement costs | Konto 3220 (SKR 03) | § 25 Abs. 4: Kein Vorsteuerabzug! Wareneingang Margenbesteuerung |
margin_exempt_net (Drittland) | Steuerfrei | § 25 Abs. 2 |
DATEV Export API Surface
mutation GenerateDatevExport(
$tenantId: uuid!
$periodStart: date!
$periodEnd: date!
$format: DatevExportFormat! # XML_ONLINE | CSV_BUCHUNGSSTAPEL
) {
generateDatevExport(
tenant_id: $tenantId
period_start: $periodStart
period_end: $periodEnd
format: $format
) {
file_url
record_count
period_locked
}
}Reconciliation: BWA/SuSa Upload
Accept exports from the operator's Steuerkanzlei and compare against Busflow's internal FinancialLedger actuals.
Accepted Formats
| Format | Parser Strategy |
|---|---|
| CSV | Flexible parser: configurable delimiters (;, ,, \t), header detection, encoding detection (UTF-8, ISO-8859-1). Supports SKR03 and SKR04 column orderings. |
| XLSX | Parsing via xlsx npm package. First sheet, auto-detect header row. |
Reconciliation API Surface
mutation UploadReconciliation($tenantId: uuid!, $period: String!, $file: Upload!) {
uploadReconciliation(tenant_id: $tenantId, period: $period, file: $file) {
upload_id
status
entry_count
}
}
query GetReconciliationDiff($uploadId: uuid!) {
reconciliation_entries(where: { reconciliation_upload_id: { _eq: $uploadId } }) {
account_code
busflow_amount
datev_amount
delta
}
}Delta Interpretation
| Scenario | delta | Action |
|---|---|---|
| Perfect match | 0 | ✅ No action |
| Busflow > DATEV | Positive | Investigate: revenue/expense not yet booked in DATEV |
| DATEV > Busflow | Negative | Investigate: manual booking in DATEV not reflected in Busflow |
| Account missing in Busflow | busflow_amount = null | New account in DATEV, no Busflow mapping |
Schema Cross-References
| Table | Schema Doc | Detail |
|---|---|---|
invoices | schema-commerce.md | Snapshot columns, status lifecycle |
tenant_invoice_sequences | schema-commerce.md | Gap-free numbering helper table |
invoice_cancellations | schema-commerce.md | Storno + credit note linkage |
tax_ledger_entries | schema-commerce.md | § 25 tax breakdown |
financial_ledgers | schema-commerce.md | Period lock validation |
ledger_period_locks | schema-commerce.md | Period immutability enforcement |
| Event payloads | event-contracts-commerce.md | InvoiceIssued |
| Tax engine | tax-engine-protocol.md | § 25 UStG strategy resolution and margin calculation |