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) Supporting specs: storno-workflow.md, datev-integration.md, period-lock.md, tax-engine.md
§ 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.passengers WHERE is_primary_contact = true | first_name, last_name, + address [TBD — billing address capture at checkout, see SB-10] | Denormalized to invoices.recipient_snapshot JSONB |
| 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. |
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 |
| Event payloads | event-contracts-commerce.md | InvoiceIssued |
| Storno workflow | storno-workflow.md | Full cancellation (GoBD-compliant) |
| DATEV export | datev-integration.md | DATEV field mapping |