Tax Engine β Β§ 25 UStG Steuer-Engine β
NestJS Domain Service for autonomous tax strategy resolution and compliance computation. Part of the Busflow Nebenbuch (Sub-Ledger) architecture.
Purpose β
The Tax Engine is a NestJS Domain Service responsible for:
- Vorabkalkulation (Planning): Determining the correct
tax_strategyon aCostingSheetbased on itsprocurement_itemscomposition - Nachkalkulation (Actuals): Computing concrete tax amounts on
TaxLedgerEntryrecords from realizedFinancialLedgerdata - Invoice Generation: Applying the resolved tax strategy to produce legally compliant invoice line items
Tax Strategy Resolution Logic β
The engine classifies each tour/charter based on the presence of third-party procurement:
IF all CostComponent.service_type == "EIGEN"
β STANDARD_VAT (Regelbesteuerung, 19%)
β Tax base = full net selling price
IF any CostComponent.service_type == "FREMD"
β MARGIN_SCHEME_25 (Β§ 25 UStG Margenbesteuerung)
β Tax base = gross customer price β gross procurement costs (margin only)
β Input VAT deduction on Fremdleistungen is forfeited (Β§ 25 Abs. 4)Data Flow β
flowchart LR
subgraph Backoffice
CS[CostingSheet]
PI["procurement_items<br/>JSONB array<br/>(incl. Geography)"]
end
subgraph Tax Engine
TR[TaxResolver<br/>Domain Service]
TC[TaxCalculator<br/>Domain Service]
end
subgraph Commerce
FL[FinancialLedger]
TLE["TaxLedgerEntry<br/>(Β§ 25 Abs. 5 fields)"]
INV[Invoice]
end
CS --> PI
PI -->|service_type EIGEN/FREMD<br/>+ geography EU/THIRD_COUNTRY| TR
TR -->|tax_strategy| CS
FL -->|realized_revenue,<br/>realized_expense| TC
CS -->|tax_strategy,<br/>procurement costs,<br/>geography| TC
TC -->|Β§ 25 Abs. 5 fields:<br/>customer_gross, procurement_gross,<br/>margin_taxable, margin_exempt,<br/>tax_base, tax_amount, tax_rate| TLE
TLE --> INVArchitecture Placement β
| Concern | Location | Pattern |
|---|---|---|
| Tax strategy auto-resolution | apps/api/src/backoffice/services/tax-resolver.service.ts | Hasura Action handler |
| Tax computation on actuals | apps/api/src/commerce/services/tax-calculator.service.ts | Hasura Action handler |
| Invoice tax line generation | apps/api/src/commerce/services/invoice.service.ts | Hasura Action handler |
| CostingSheet mutation trigger | Hasura Event Trigger β NestJS webhook | @golevelup/nestjs-hasura decorator |
Domain Events β
| Event | Emitted By | Consumed By |
|---|---|---|
CostingSheetUpdated | Backoffice (Hasura ET) | Tax Engine: re-evaluates tax_strategy |
FinancialLedgerClosed | Commerce (FinancialLedgerService) | Tax Engine: generates TaxLedgerEntry from FinancialLedger |
TaxLedgerEntryCreated | Commerce (Tax Engine) | DATEV Export Service |
InvoiceIssued | Commerce (Invoice Service) | Audit Trail (GOBD scope), Period Lock validation |
Β§ 25 UStG β Implementation Notes β
Β§ 25 Abs. 1 β Anwendungsbereich β
Greift nur "soweit der Unternehmer Reisevorleistungen in Anspruch nimmt" β triggers MARGIN_SCHEME_25 when any CostComponent.service_type == "FREMD".
Β§ 25 Abs. 2 β Drittlands-Steuerbefreiung β
CAUTION
"Die sonstige Leistung ist steuerfrei, soweit die ihr zuzurechnenden Reisevorleistungen im Drittlandsgebiet bewirkt werden."
Jede CostComponent muss ein geography: EU | THIRD_COUNTRY Feld tragen. Die Tax Engine splittet die Marge in einen steuerpflichtigen EU-Anteil und einen steuerfreien Drittlands-Anteil.
Algorithmus:
procurement_eu = SUM(CostComponent WHERE geography == EU AND service_type == FREMD)
procurement_third = SUM(CostComponent WHERE geography == THIRD_COUNTRY AND service_type == FREMD)
procurement_total = procurement_eu + procurement_third
total_margin_gross = customer_gross_amount - procurement_gross_amount
IF total_margin_gross <= 0:
margin_taxable_net = 0 // Negativmarge β keine Steuer, kein Verlustvortrag
margin_exempt_net = 0
tax_amount = 0
ELSE:
eu_ratio = procurement_eu / procurement_total
third_ratio = procurement_third / procurement_total
margin_eu_gross = total_margin_gross Γ eu_ratio
margin_third_gross = total_margin_gross Γ third_ratio
// Herausrechnen der USt fΓΌr die Bemessungsgrundlage (Netto-Marge)
margin_taxable_net = margin_eu_gross / 1.19
margin_exempt_net = margin_third_gross // Steuerfrei, Brutto = Netto
tax_amount = margin_taxable_net Γ 0.19Β§ 25 Abs. 3 β Einzelmargenberechnung β
Die Steuer bemisst sich nach der Differenz pro Reise (Einzelmarge), nicht aggregiert ΓΌber die Periode. Die TaxLedgerEntry-Berechnung erfolgt auf Ebene des einzelnen FinancialLedger.
Β§ 25 Abs. 4 β Vorsteuerabzugsverbot β
WARNING
"Der Unternehmer ist nicht berechtigt, die ihm fΓΌr die Reisevorleistungen gesondert in Rechnung gestellten SteuerbetrΓ€ge als Vorsteuer abzuziehen."
Technische Konsequenz: Der datev-export.service.ts muss sicherstellen, dass Fremdleistungen (FREMD) auf spezielle DATEV-Konten ohne Vorsteuerabzug gemappt werden (z.B. SKR 03: Konto 3220 "Wareneingang Margenbesteuerung").
Β§ 25 Abs. 5 β Aufzeichnungspflichten β
IMPORTANT
Aus den Aufzeichnungen mΓΌssen vier exakte Werte hervorgehen:
customer_gross_amountβ Der Betrag, den der LeistungsempfΓ€nger aufwendetprocurement_gross_amountβ Die Aufwendungen fΓΌr Reisevorleistungenmargin_taxable_netβ Die Bemessungsgrundlage (steuerpflichtige Netto-Marge)margin_exempt_netβ Die Aufteilung auf steuerpflichtige und steuerfreie Leistungen
Diese Werte mΓΌssen als unverΓ€nderliche DB-Spalten in commerce.tax_ledger_entries persistiert werden. On-the-fly Frontend-Berechnung ist nicht GoBD-konform.
Margin Calculation Formula β
Brutto_Marge = Reisepreis (brutto) β Reisevorleistungen (brutto)
Bemessungsgrundlage (Netto_Marge) = Brutto_Marge / (1 + taxRate)
USt auf Marge = Bemessungsgrundlage Γ taxRateKey rules:
- Input VAT on third-party procurement (
FREMD) cannot be deducted (Β§ 25 Abs. 4) - The system must calculate margin tax per booking, not aggregated across the period (Β§ 25 Abs. 3)
- Mixed tours (EIGEN + FREMD components) β entire tour falls under Β§ 25 UStG
- Pure own services (charter transfer, no hotel/ferry) β standard VAT with
calculateStandardVat() - Negativmargen (Verlust-Tour):
margin_taxable_net = 0,tax_amount = 0. Verluste dΓΌrfen nicht mit anderen Reisen verrechnet werden.
Edge Cases β
| Scenario | Tax Treatment |
|---|---|
| Charter transfer (bus only, no hotel/ferry) | STANDARD_VAT β pure Eigenleistung |
| Multi-day tour with hotel booking | MARGIN_SCHEME_25 β Fremdleistung detected |
| Tour with Swiss hotel (Drittland) | MARGIN_SCHEME_25 β margin_exempt_net for Swiss portion |
| Mixed EU + Drittland procurement | Split: margin_taxable_net (EU ratio) + margin_exempt_net (third ratio) |
| Ancillaries (insurance, luggage) | Follow the parent booking's strategy |
| OnboardSales (beverages, snacks) | STANDARD_VAT β always Eigenleistung, tracked separately |
| Negative margin (loss-making tour) | margin_taxable_net = 0, no tax due, but TaxLedgerEntry still required |
Module Structure (Proposed) β
apps/api/src/
βββ backoffice/
β βββ services/
β βββ tax-resolver.service.ts # EIGEN/FREMD + Geography β tax_strategy
βββ commerce/
β βββ services/
β βββ tax-calculator.service.ts # Actuals β TaxLedgerEntry (Β§ 25 Abs. 5 fields)
β βββ invoice.service.ts # TaxLedgerEntry β Invoice
β βββ period-lock.service.ts # Immutability enforcement
βββ shared/
βββ tax/
βββ tax-strategy.enum.ts # STANDARD_VAT | MARGIN_SCHEME_25
βββ geography.enum.ts # EU | THIRD_COUNTRY
βββ schemas.ts # Valibot: CostComponent, TaxRule, MarginResult
βββ margin-calculator.ts # Β§ 25 UStG: dynamic rate, split, Negativmarge
βββ vat-calculator.ts # Standard VAT: configurable rateValidation Rules (Valibot) β
Tax-relevant data must be validated at the domain boundary using Valibot schemas:
CostComponent.service_typemust be"EIGEN"or"FREMD"CostComponent.geographymust be"EU"or"THIRD_COUNTRY"(required whenservice_type == "FREMD")CostComponent.tax_rule.ratemust match the resolved strategyTaxLedgerEntry.margin_taxable_netmust be>= 0(Negativmarge β clamp to 0)TaxLedgerEntry.margin_exempt_netmust be>= 0TaxLedgerEntry.customer_gross_amountandprocurement_gross_amountmust be> 0
Dependencies β
- Existing:
CostingSheetschema,FinancialLedgerschema,TaxLedgerEntryschema (extended),Invoiceschema - New:
commerce.change_events,commerce.ledger_period_locks - External: None β tax calculation is fully internal. DATEV export consumes the output but is a separate service.