Busflow Docs

Internal documentation portal

Skip to content
Reviewed 08 May 2026

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 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.bookings.booker_profile_idbackoffice.person_profilesfirst_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.
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.

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

100%

cancelInvoice

graphql
mutation CancelInvoice($invoiceId: uuid!, $reason: String!) {
  cancelInvoice(invoice_id: $invoiceId, reason: $reason) {
    cancellation_id
    storno_invoice_id
  }
}

Side effects:

  1. Does NOT modify the original invoices row — it remains in ISSUED status with a cancelled: true flag
  2. Creates a new Stornorechnung (counter-invoice) with negative amounts mirroring the original
  3. Creates invoice_cancellations record linking cancelled_invoice_idstorno_invoice_id with reason and timestamp
  4. Writes commerce.change_events entries via AuditTrailService (scope: GOBD, entity_type: 'invoice', entity_id set to both original and storno invoices) — see ADR-019

reissueInvoice

graphql
mutation ReissueInvoice($cancellationId: uuid!) {
  reissueInvoice(cancellation_id: $cancellationId) {
    new_invoice_id
  }
}

Side effects:

  1. Creates a new invoices record with status DRAFT and corrected amounts
  2. Links via invoice_cancellations.replacement_invoice_id

Storno Validation Guards

GuardCheckError
Period LockPeriodLockService.validateMutation(tenantId, invoice.issue_date)PeriodLockedException
Statusinvoice.status must be 'ISSUED'InvalidInvoiceStatusException
Duplicate StornoNo existing cancellation for this invoice_idAlreadyCancelledException

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_id pointing to the affected invoice
  • old_values captured via SELECT ... FOR UPDATE, new_values capturing 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

TypeTriggerUnlockable?
EXPORTAutomatically created when the system generates a DATEV export for a periodNo — irreversible
MANUALManually triggered by an operator (e.g., month-end close)Only by manager with audit trail

lockPeriod

graphql
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

graphql
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

CallerWhenPurpose
Invoice mutation (Hasura ET)Before any UPDATE on commerce.invoicesBlock direct edits on locked periods
DATEV Export ServiceAfter the system generates an exportAuto-create EXPORT lock for the exported period
Workspace UIManual month-end closeUser-initiated MANUAL lock
Storno WorkflowBefore cancelling an invoiceValidate 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

100%

Einzelbeleg Data Mapping

Busflow EntityDATEV 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

100%

Buchungsstapel Data Mapping

Busflow FieldDATEV CSV ColumnNotes
TaxLedgerEntry.tax_base_amountUmsatz
TaxLedgerEntry.tax_amountSteuerbetrag
TaxLedgerEntry.tax_strategyBU-Schlüssel (40 = § 25 UStG)
Invoice.invoice_numberBelegfeld 1
Invoice.issue_dateBelegdatum
FinancialLedger.tenant_idMandantennummer
FREMD procurement costsKonto 3220 (SKR 03)§ 25 Abs. 4: Kein Vorsteuerabzug! Wareneingang Margenbesteuerung
margin_exempt_net (Drittland)Steuerfrei§ 25 Abs. 2

DATEV Export API Surface

graphql
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

FormatParser Strategy
CSVFlexible parser: configurable delimiters (;, ,, \t), header detection, encoding detection (UTF-8, ISO-8859-1). Supports SKR03 and SKR04 column orderings.
XLSXParsing via xlsx npm package. First sheet, auto-detect header row.

Reconciliation API Surface

graphql
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

ScenariodeltaAction
Perfect match0✅ No action
Busflow > DATEVPositiveInvestigate: revenue/expense not yet booked in DATEV
DATEV > BusflowNegativeInvestigate: manual booking in DATEV not reflected in Busflow
Account missing in Busflowbusflow_amount = nullNew account in DATEV, no Busflow mapping

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
ledger_period_locksschema-commerce.mdPeriod immutability enforcement
Event payloadsevent-contracts-commerce.mdInvoiceIssued
Tax enginetax-engine-protocol.md§ 25 UStG strategy resolution and margin calculation

Internal documentation — Busflow