Busflow Docs

Internal documentation portal

Skip to content

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 RequirementSource EntitySource Field(s)Notes
1Full name and address of supplierbackoffice.operators (cross-schema soft FK)company_name, address (from operator profile)Denormalized to invoices.supplier_snapshot JSONB at generation time
2Full name and address of recipientcommerce.passengers WHERE is_primary_contact = truefirst_name, last_name, + address [TBD — billing address capture at checkout, see SB-10]Denormalized to invoices.recipient_snapshot JSONB
3Tax number or USt-IdNr.backoffice.operatorstax_number or vat_idFrom operator's legal profile
4Date of issuecommerce.invoicesissue_dateSet at generation time
5Sequential invoice numbercommerce.invoicesinvoice_numberFrom tenant_invoice_sequences (gap-free)
6Description of servicebackoffice.tour_templates + tour_offeringstitle, description, start_date, end_date, boarding pointsRendered as "Busreise: {title}, {start_date} – {end_date}, ab {boarding_point}"
7Date of service deliverycommerce.tour_offeringsstart_date / end_date"Leistungszeitraum: {start_date} – {end_date}"
8Net amount, tax rate, tax amount per ratecommerce.tax_ledger_entriesPer TLE: tax_base_amount, tax_rate, tax_amountOne line per tax_strategy (ADR-015: 1:N)
9Gross totalcommerce.bookingstotal_amountCross-verified: SUM(TLE.tax_base_amount + TLE.tax_amount) == booking.total_amount
10§ 25 UStG annotationcommerce.tax_ledger_entriestax_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

typescript
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:

PosDescriptionQtyUnit PriceNetTax RateTaxGross
1Busreise "Gardasee 7T", 01.06 – 07.06.2026, ab München2499.00998.0019% (§25)31.65*1029.65
2Reiserücktrittsversicherung229.0058.0019%11.0269.02
Gesamt1056.0042.671098.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

OperationInputPreconditionEffectOutputErrors
generateInvoicebooking_idBooking.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)
issueInvoiceinvoice_idInvoice.status = DRAFT. Period not locked.Sets status → ISSUED. Emit InvoiceIssued.{ invoice_id, issued_at }NotDraft (422), PeriodLockedException (423)
renderPdfinvoice_idInvoice 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:

StrategyTriggerNote
Automatic (default)BookingFullyPaid event → auto-generate + auto-issueMost operators want instant invoicing
ManualDispatcher triggers from Workspace UIFor 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:

sql
-- 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

PropertyValue
Input{ booking_id, original_invoice_id, refund_amount, reason }
Handler routePOST /hasura/actions/generate-credit-note
GuardsOriginal invoice.status = ISSUED. Period not locked.
Transaction1. 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 CaseExpected Behavior
1Invoice generation before FinancialLedger closurePermitted. 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.
2Period lock prevents invoice issuanceissueInvoice calls PeriodLockService.validateMutation(). If locked → PeriodLockedException. The invoice remains in DRAFT until the period is unlocked or moved to the next period.
3Multiple invoices for same bookingGuard: InvoiceAlreadyExists if a non-cancelled invoice exists. To create a new invoice, the old one must be cancelled (Storno) first.
4Invoice 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).
5Credit note after period lockSame as any financial mutation: PeriodLockedException. The credit note must wait for period unlock or be recorded in the next open period.
6Concurrent invoice number generationFOR UPDATE row-level lock on tenant_invoice_sequences ensures gap-free sequential numbering. Concurrent requests serialize at the DB level.
7Invoice for § 25 UStG tourThe 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.
8Invoice 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

TableSchema DocDetail
invoicesschema-commerce.mdSnapshot columns, status lifecycle
tenant_invoice_sequencesschema-commerce.mdGap-free numbering helper table
invoice_cancellationsschema-commerce.mdStorno + credit note linkage
tax_ledger_entriesschema-commerce.md§ 25 tax breakdown
financial_ledgersschema-commerce.mdPeriod lock validation
Event payloadsevent-contracts-commerce.mdInvoiceIssued
Storno workflowstorno-workflow.mdFull cancellation (GoBD-compliant)
DATEV exportdatev-integration.mdDATEV field mapping

Internal documentation — Busflow