Zum Inhalt springen

Subunternehmer-Vergabe mit §48b-Freistellung, SOKA-Check und §13b-Reverse-Charge-Automatik

In Planung

Feinkonzept · Subunternehmer-Vergabe & §48b-Kontrolle

Abschnitt betitelt „Feinkonzept · Subunternehmer-Vergabe & §48b-Kontrolle“

Modul: Handwerk · Quelle: FUNKTIONSUMFANG §4.8 · Roadmap: V2 (Monat 15–22) Stand: 2026-04-19 · Branch: feinkonzepte/v0.1


feature_id: handwerk/08-subunternehmer
title: Subunternehmer-Vergabe mit §48b-Freistellung, SOKA-Check und §13b-Reverse-Charge-Automatik
funktionsumfang_ref: §4.8
roadmap_horizont: V2
plattformen:
mobile: vollständig # Sub-Wahl, Einsatznachweis, Signatur, Offline-Erfassung
web: vollständig mit-Bulk
desktop: aus # Bulk-Aktionen im Web decken Desktop-Use-Case ab
owner_rolle: [Bauleitung, Einkauf, Admin, Buchhaltung]
modul_gate_flag: module.handwerk.subunternehmer
compliance_flags:
gobd: true # Sub-Rechnungen, Freistellungsbescheinigungen = aufbewahrungspflichtig
arbzg: false # Sub-Personal ist nicht unser Personal
vob: true # Sub-Leistungen fließen in VOB/B-Nachunternehmerhaftung
dsgvo: true # Personenbezogene Daten (Inhaber, Vorarbeiter, USt-ID)
bfsg: false # Kein ESS-Flow, nur Manager-/Admin-/Buchhaltungs-Fläche
betrvg: false # Keine Leistungs-/Verhaltenskontrolle eigener MA
stvg: false
weitere: [§48 EStG, §48b EStG, §13b UStG, §13 MiLoG, SOKA-Bau]
referenzkunde:
status: TBD
name: "zu klären mit Sales — Kandidat: Hildebrand Tiefbau GmbH (Köln, 48 MA), nutzt heute Excel-Liste für §48b-Bescheinigungen"
quelle: Interview-Protokoll 2026-03-11
estimate_eng_tage: 18
abhängigkeiten:
- handwerk/05-bau-abrechnung # §13b-Auto-Check beim Rechnungseingang
- kern/11-audit-log # Sub-Vertrags-Historie append-only
- kern/10-datev-export # §48 EStG-Einbehalt als Lohnart

Marktrealität (DE-Handwerk). Jeder Handwerksbetrieb ab ca. 20 MA arbeitet regelmäßig mit Subunternehmern — typischerweise 5–40 Sub-Firmen pro Hauptauftraggeber, davon ein fester Pool und saisonale Aushilfs-Kolonnen. Die Pflichten des beauftragenden Betriebs sind drakonisch:

  • §48 EStG (Bauabzugssteuer): 15 % der Brutto-Vergütung an den Sub sind direkt ans Finanzamt einzubehalten, außer der Sub legt eine gültige Freistellungsbescheinigung nach §48b EStG vor (max. 3 Jahre Laufzeit).
  • §13b UStG (Reverse-Charge): Bei Bauleistungen B2B schuldet der Leistungsempfänger die Umsatzsteuer. Die Sub-Rechnung darf keine USt ausweisen, muss aber den Hinweis „Steuerschuldnerschaft des Leistungsempfängers“ tragen.
  • SOKA-Bau-Meldepflicht (TV Bau): Alle gewerblichen Bau-Arbeitnehmer müssen bei der SOKA-Bau gemeldet sein. Die SOKA-Nummer ist de facto die Berechtigungs-ID für Bau-Subs.
  • §13 MiLoG (Mindestlohn-Bescheinigung): Der Hauptauftragnehmer haftet für den Mindestlohn seiner Subs (Generalunternehmerhaftung).
  • Nachunternehmerhaftung §28e Abs. 3f SGB IV: Bei Insolvenz des Subs haftet der Hauptauftragnehmer für Sozialversicherungsbeiträge.

Heute wird das in 95 % der Betriebe manuell geführt: Excel-Liste, gescannte Bescheinigungen in einem Windows-Ordner, Ablauf-Erinnerung per Outlook-Termin (wenn überhaupt). Eine abgelaufene §48b-Bescheinigung wird typischerweise erst entdeckt, wenn die nächste Rechnung in die Buchhaltung kommt — dann ist der 15 %-Einbehalt bereits versäumt, das Finanzamt verlangt ihn aber trotzdem vom Hauptauftragnehmer. Reale Nachzahlungs-Bescheide 5-stellig sind in der Branche Alltag.

Schmerzpunkt der Alt-App (LESSONS-LEARNED.md §5). Die Alt-App kannte „Subunternehmer“ als Customer-Typ mit einem Freitext-Feld „Freistellung_bis“ — ohne Format-Validierung, ohne Webservice-Prüfung, ohne Re-Check-Automatik. Ergebnis: Kein einziger Kunde hat das Feld je gepflegt. Das „Feature“ war reines Placebo.

Erwarteter Outcome.

  • Abgelaufene §48b-Bescheinigungen führen zu null übersehenen Einbehalts-Pflichten (heute: 1–2 pro Jahr pro Mittelständler).
  • Sub-Rechnungseingang mit §13b-Auto-Check spart ~15 min pro Rechnung in der Buchhaltung (Rechnung zurückschicken, Korrektur einfordern).
  • Zeit vom Sub-Onboarding bis zum ersten Einsatz: von 5 Werktagen auf 1 Werktag — alle Pflicht-Dokumente werden beim Anlegen eingefordert, nicht nachgereicht.

Rolle Aktion Scope Plattform
Bauleiter Sub für Baustelle anfordern, Einsatznachweis vom Sub-Vorarbeiter unterschreiben lassen team 📱🌐
Einkauf Sub-Stammdaten + Rahmenverträge pflegen, §48b-Bescheinigungen hochladen all 🌐
Buchhaltung Sub-Rechnung prüfen, §13b-Auto-Check sichten, §48-Einbehalt freigeben all 🌐
Admin Modul aktivieren, Pflichtfelder-Set konfigurieren, BZSt-Webservice-Zugang hinterlegen all 🌐
Mitarbeiter Subs auf seiner Baustelle sehen (keine Preise, keine Bescheinigungen) own 📱

Persona-Skizzen:

  • Tuncay Özdemir (Bauleiter, 42, Tiefbau) — koordiniert 2–3 Baustellen gleichzeitig. Ruft den Sub an, wenn ein Gewerk fehlt („Elektrik Yıldız, habt ihr morgen zwei Mann frei?“). Unterschreiben lassen am Feierabend auf dem Parkplatz — per Tablet im Auto, oft kein LTE.
  • Petra Hansen (Einkauf, 51, Bau-GmbH 80 MA) — pflegt 34 Rahmen-Subs. Hat aktuell 9 §48b-Bescheinigungen auslaufen binnen 120 Tagen und keinen Überblick.
  • Frau Müller (Buchhaltung, 56) — prüft eingehende Sub-Rechnungen gegen DATEV. Hasst es, Rechnungen mit falsch ausgewiesener USt zurückzuschicken.

US-01 [V2] Als Einkauf möchte ich einen neuen Subunternehmer anlegen
und dabei alle Pflicht-Dokumente (§48b, SOKA-Nr., Betriebshaftpflicht,
Mindestlohn-Bescheinigung) in einem Schritt erfassen — nicht nachgereicht.
US-02 [V2] Als Einkauf möchte ich 30 Tage vor Ablauf der §48b-Bescheinigung
eine Push-Erinnerung bekommen, damit ich den Sub kontaktiere bevor
das Finanzamt den Einbehalt bei uns einfordert.
US-03 [V2] Als Bauleiter möchte ich vor Ort einen Einsatznachweis vom
Sub-Vorarbeiter per Touch-Signatur abzeichnen lassen — auch offline
im Heizungskeller ohne Empfang.
US-04 [V2] Als Buchhaltung möchte ich, dass beim Rechnungseingang automatisch
geprüft wird, ob §13b Reverse-Charge greift — und die Rechnung
wird abgelehnt, wenn Sub USt ausweist obwohl er es nicht darf.
US-05 [V2] Als Admin möchte ich die §48b-Bescheinigung per BZSt-Webservice
gegen die amtliche Datenbank prüfen lassen, statt einem gescannten
PDF zu vertrauen.
US-06 [V2] Als Einkauf möchte ich Rahmenverträge mit Stundenlohn-Staffelung
hinterlegen, damit Bauleiter bei Abruf automatisch die richtigen
Konditionen sehen.
US-07 [V2.5] Als Buchhaltung möchte ich bei §48-Einbehalt einen Daueranmeldungs-
export für ELSTER generieren.

  • F-M-01Sub-Suche mit GPS-Nähe. Bauleiter öffnet „Sub anfordern“, die App zeigt Rahmenvertrags-Subs sortiert nach (a) Gewerk der aktuellen Baustelle, (b) geographischer Nähe, (c) letzter Einsatz.
  • F-M-02Ampel-Status Sub. Pro Sub eine Ampel: 🟢 (alles aktuell, §48b >30 Tage gültig, SOKA aktiv) · 🟡 (Bescheinigung läuft in 7–30 Tagen ab) · 🔴 (abgelaufen oder SOKA-Lücke) · ⬛ (nicht Bau-berechtigt, nur Lieferant).
  • F-M-03Anforderung anlegen mit Gewerk, Leistungszeitraum, erwarteter Anzahl MA, Ansprechpartner beim Sub. Offline erfassbar.
  • F-M-04Einsatznachweis-Erfassung vor Ort. Anzahl MA je Tag, Stunden-Schätzung (später durch IST-Rechnung des Subs bestätigt), Freitext + Foto.
  • F-M-05Touch-Signatur Sub-Vorarbeiter. Kanonisches Signatur-Pad, PDF wird lokal generiert und bei Sync mit Hash-Chain an Backend übertragen. Keine Signatur ohne explizite Nennung des Unterzeichners (Pflichtfeld Name + Funktion).
  • F-M-06Warn-Modal bei Sperrung. Wenn Bauleiter einen Sub mit 🔴-Status wählt, zeigt die App ein rotes Modal: „Dieser Sub ist aktuell gesperrt. Grund: §48b-Bescheinigung abgelaufen am 15.03.2026. Einsatz trotz Sperre erfordert eine schriftliche Freigabe der Buchhaltung.“ — Button „Trotzdem anfordern” erzeugt Freigabe-Ticket, nicht direkten Einsatz.
  • F-M-07Sub-Dossier lesen. Bauleiter sieht: Gewerke, Rahmenvertrag-Konditionen (nur Stundensatz, nicht Marge), letzte 5 Einsätze, Qualitäts-Sterne (wenn aktiviert). Keine Rechnungsinformationen, kein §48-Einbehalts-Betrag.
  • F-W-01Sub-Liste mit Filter. Gewerk, Region, Ampel-Status, Rahmenvertrag-Status. Spalten: Name, Ort, USt-ID, §48b-Gültigkeit (Datum + Ampel), SOKA-Nr., letzter Einsatz, offene Rechnungen.
  • F-W-02Sub-Detail-Ansicht mit Tabs: Stammdaten · Dokumente · Rahmenverträge · Einsätze · Rechnungen · §48-Historie · Audit-Log.
  • F-W-03Onboarding-Wizard (5 Schritte): Firmenstamm → Steuer (USt-ID, Steuernummer, §48b-Bescheinigung) → Sozial (SOKA-Nr., Mindestlohn-Bescheinigung, Betriebshaftpflicht) → Bankverbindung → Review & Freigabe. Jeder Schritt blockt ohne Pflichtfelder.
  • F-W-04§48b-Upload mit Format-Erkennung. PDF oder Foto. OCR extrahiert Gültigkeitszeitraum, Steuernummer, Sicherheitsmerkmal-Kennung. Mensch bestätigt. BZSt-Webservice-Cross-Check läuft im Hintergrund.
  • F-W-05BZSt-Online-Check-Lauf. Nightly-Job prüft alle Bescheinigungen mit Ablauf <60 Tage erneut. Status wird in sub_freistellung_check-Tabelle append-only gespeichert.
  • F-W-06Rahmenvertrags-Editor. Laufzeit, automatische Verlängerung (mit Kündigungsfrist), Stundensätze pro Rolle/Lohngruppe, Fahrkosten-Pauschale, Rabatt-Staffel. PDF-Generierung via Typst.
  • F-W-07§13b-Rechnungseingangs-Prüfung (Batch). Liste offener Sub-Rechnungen mit Auto-Check-Ergebnis (🟢 Reverse-Charge korrekt / 🟡 unklar / 🔴 fehlerhaft). Bulk-Aktion „Ablehnen mit Standard-Text“.
  • F-W-08§48-Einbehalts-Dashboard. Pro Monat: welche Subs hatten keine gültige Bescheinigung → 15 %-Einbehalt fällig → Anmeldung ans Finanzamt (ELSTER-Schnittstelle, V2.5).
  • F-W-09Sperr-/Freigabe-Workflow. Buchhaltung kann Sub temporär sperren. Bauleiter wird beim Anfordern informiert (F-M-06). Freigabe nur mit Vier-Augen-Prinzip.
  • F-X-01Status-Benachrichtigungen. Push an Einkauf: 90 / 30 / 7 Tage vor Ablauf §48b. Push an Bauleiter + Buchhaltung: Ablauf am Einsatz-Tag.
  • F-X-02Audit-Log. Jede Statusänderung, jeder Upload, jede BZSt-Prüfung erzeugt einen Audit-Log-Eintrag (Append-only, Hash-Chain, vgl. ../kern/11-audit-log.md).
  • F-A-01BZSt-Webservice-Zertifikat hinterlegen (ELSTER-Zertifikat des Tenants).
  • F-A-02Pflichtfelder-Profil wählen: „Bau-Sub“ (Full-Pack) vs. „Gewerbe-Sub“ (ohne SOKA). Default Bau-Sub, wenn Branche Handwerk+Bau.
  • F-A-03Erinnerungsfristen anpassbar (default 90/30/7, max 180 Tage vor Ablauf).
  • F-A-04Sperr-Policy: Automatische Sperre bei abgelaufener §48b (default: an) · manuelle Sperre (immer an) · Sperre bei SOKA-Kündigung (default: an).
Anforderung-ID MVP V1 V1.5 V2 V2.5
F-W-01 bis F-W-04, F-W-06, F-M-01 bis F-M-04, F-M-07, F-A-02
F-M-05, F-M-06, F-W-05, F-W-07, F-W-09, F-A-01, F-A-03, F-A-04, F-X-01, F-X-02
F-W-08 (ELSTER-Export)

HTML-Hero-Mockup: 08-subunternehmer.html — Mobile (Bauleiter Tuncay Ö.) + Web (Einkauf Petra H.) Side-by-Side, echte Daten „Elektrik Yıldız GmbH, Köln, USt-ID DE275319428, §48b gültig bis 30.11.2027“, Tenant shk-gebruder-schmidt.

┌────────────────────────────────┐
│ ← Sub anfordern ⚙ │
├────────────────────────────────┤
│ Baustelle: Rathausplatz 3 │
│ 📍 Köln-Innenstadt │
├────────────────────────────────┤
│ Gewerk suchen: │
│ [ Elektro ▼ ] │
├────────────────────────────────┤
│ 🟢 Elektrik Yıldız GmbH │
│ Köln · 2,1 km · 34 Einsätze │
│ §48b ✓ · SOKA ✓ · MiLoG ✓ │
│ Rahmenvertrag aktiv │
│ │
│ 🟡 Elektro Kowalski e.K. │
│ Leverkusen · 11 km │
│ §48b läuft in 18 Tagen ab │
│ │
│ 🔴 SparkLine Elektrotechnik │
│ Düsseldorf · 38 km │
│ §48b abgelaufen 15.03.2026 │
│ — nur mit Freigabe — │
│ │
├────────────────────────────────┤
│ [ Elektrik Yıldız wählen ] │
└────────────────────────────────┘
┌────────────────────────────────┐
│ ← Einsatznachweis ✓│
├────────────────────────────────┤
│ Elektrik Yıldız GmbH │
│ Rathausplatz 3 · Köln │
│ Di 21.04.2026 │
├────────────────────────────────┤
│ Anzahl MA: [ 2 ] │
│ Stunden ca.: [ 16:00 ] h │
│ Leistung: │
│ ┌────────────────────────────┐ │
│ │ NYM-J 5x2,5 verlegt, │ │
│ │ UV Küche + Bad gesetzt │ │
│ └────────────────────────────┘ │
│ │
│ 📷 [Foto Steigleitung] │
│ 📷 [+ Foto hinzufügen] │
│ │
├────────────────────────────────┤
│ Unterschrift Sub-Vorarbeiter: │
│ ╔════════════════════════════╗ │
│ ║ ✓ Murat Yıldız ║ │
│ ║ ✎✎✎ 19.04.2026 17:04 ║ │
│ ╚════════════════════════════╝ │
│ ⚠ Offline — sync bei Empfang │
│ [ Speichern & Abschließen ] │
└────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────────┐
│ Werkszeit · shk-gebruder-schmidt Petra H. · Einkauf [Profil ▾] │
├──────────────────────────────────────────────────────────────────────────────────┤
│ Sidebar │ ← Subs / Elektrik Yıldız GmbH 🟢 aktiv [Sperren] │
│ ───────── │ ┌──────────────────────────────────────────────────────────────────┐│
│ Zeit │ │ Stammdaten · Dokumente · Rahmenvertrag · Einsätze · §48-Historie ││
│ Plan │ ├──────────────────────────────────────────────────────────────────┤│
│ ▶ Subs │ │ §48b-Freistellungsbescheinigung ││
│ Rechnungen│ Gültig bis: 30.11.2027 (noch 591 Tage) 🟢 ││
│ DATEV │ │ Steuer-Nr.: 203/5678/1234 FA Köln-Altstadt ││
│ │ │ Sicherheits- BZSt-2024-KS-778412 ││
│ │ │ merkmal: ││
│ │ │ Letzte Prüfung BZSt: 2026-04-18 03:11:42 UTC ✓ gültig ││
│ │ │ Nächste Auto-Prüfung: 2026-05-18 [PDF ansehen] ││
│ │ ├──────────────────────────────────────────────────────────────────┤│
│ │ │ SOKA-Bau-Nr. 1234567890123 · aktiv seit 01/2019 ││
│ │ │ Mindestlohn hinterlegt · §13 MiLoG-Nachweis v. 15.01.2026 ││
│ │ │ Betriebs-HP Gothaer · 3 Mio € · gültig bis 01.01.2027 ││
│ │ │ USt-ID DE275319428 · qualifiziert geprüft 2026-01-04 ││
│ │ └──────────────────────────────────────────────────────────────────┘│
│ │ [⤓ Alle Belege als ZIP] [Rahmenvertrag verlängern] [Löschen…] │
└──────────────────────────────────────────────────────────────────────────────────┘

6.4 Web · §13b-Rechnungseingangs-Check (Grenzfall)

Abschnitt betitelt „6.4 Web · §13b-Rechnungseingangs-Check (Grenzfall)“
┌──────────────────────────────────────────────────────────────────────────────────┐
│ Rechnungseingang · Sub-Rechnungen · April 2026 [ + Upload ] [CSV] │
├──────────────────────────────────────────────────────────────────────────────────┤
│ [2 ausgewählt · 18.450 € Netto] [An Sub zurück] [Als korrekt freigeben] │
│ ┌────┬──────────────────────────┬──────────┬──────────┬──────────────┬────────┐│
│ │ ☑ │ Sub │ Nr. │ Netto │ §13b-Check │ Status ││
│ ├────┼──────────────────────────┼──────────┼──────────┼──────────────┼────────┤│
│ │ ☑ │ Elektrik Yıldız GmbH │ 2026-038 │ 9.820 € │ 🟢 RC korrekt│ offen ││
│ │ ☑ │ Elektrik Yıldız GmbH │ 2026-041 │ 8.630 € │ 🟢 RC korrekt│ offen ││
│ │ ☐ │ Abbruch Mertens │ 2026-112 │ 4.200 € │ 🔴 USt 19 % │ block ││
│ │ │ │ │ │ trotz §13b │ ││
│ │ ☐ │ Gerüstbau Pesch │ 2026-055 │ 1.890 € │ 🟡 Hinweis │ review ││
│ │ │ │ │ │ fehlt │ ││
│ └────┴──────────────────────────┴──────────┴──────────┴──────────────┴────────┘│
└──────────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────┐
│ ⚠ Sub gesperrt │
├────────────────────────────────┤
│ SparkLine Elektrotechnik │
│ kann aktuell nicht eingesetzt │
│ werden. │
│ │
│ Grund: │
│ §48b-Bescheinigung abgelaufen │
│ am 15.03.2026 (seit 35 Tagen). │
│ │
│ Weiteres Einsetzen zwingt uns, │
│ 15 % Bauabzugssteuer direkt │
│ ans Finanzamt abzuführen │
│ (§48 EStG). │
│ │
│ [ Abbrechen ] [ Freigabe anfordern ] │
└────────────────────────────────┘

Symbol-Konvention: wie in TEMPLATE.md §6.


apps/api/src/db/schema/subcontractor.ts
export const subcontractorsTable = pgTable('subcontractors', {
id: uuid('id').primaryKey().defaultRandom(), // UUID v7
tenantId: uuid('tenant_id').notNull().references(() => tenantsTable.id),
name: varchar('name', { length: 200 }).notNull(),
legalForm: varchar('legal_form', { length: 40 }), // GmbH, GbR, e.K., UG, …
addressStreet: varchar('address_street', { length: 200 }),
addressZip: varchar('address_zip', { length: 10 }),
addressCity: varchar('address_city', { length: 120 }),
addressCountry: varchar('address_country', { length: 2 }).default('DE').notNull(),
vatId: varchar('vat_id', { length: 20 }), // USt-ID, format DE\d{9}
taxNumber: varchar('tax_number', { length: 30 }), // deutsche Steuernummer
sokaBauNr: varchar('soka_bau_nr', { length: 15 }), // SOKA-Bau-Nummer
tradeCategories: text('trade_categories').array(), // [elektro, tiefbau, …]
isActive: boolean('is_active').default(true).notNull(),
blockedReason: text('blocked_reason'), // optional Sperrgrund
blockedAt: timestamp('blocked_at', { withTimezone: true }),
blockedBy: uuid('blocked_by').references(() => usersTable.id),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
createdBy: uuid('created_by').notNull().references(() => usersTable.id),
// GoBD-Hash-Chain
hashPrev: bytea('hash_prev'),
hashSelf: bytea('hash_self').notNull(),
}, (t) => ({
tenantNameIdx: uniqueIndex('subs_tenant_name_idx').on(t.tenantId, t.name),
tenantVatIdx: index('subs_tenant_vat_idx').on(t.tenantId, t.vatId),
}));
export const subFreistellungTable = pgTable('sub_freistellung', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull().references(() => tenantsTable.id),
subId: uuid('sub_id').notNull().references(() => subcontractorsTable.id),
certificateNumber: varchar('certificate_number', { length: 40 }), // BZSt-Sicherheitsmerkmal
issuedBy: varchar('issued_by', { length: 120 }), // Finanzamt
validFrom: date('valid_from').notNull(),
validUntil: date('valid_until').notNull(), // max. 3 Jahre nach validFrom
taxNumberOnCert: varchar('tax_number_on_cert', { length: 30 }).notNull(),
documentS3Key: varchar('document_s3_key', { length: 300 }).notNull(), // Object-Lock 10 J
ocrExtracted: jsonb('ocr_extracted'), // Roh-OCR-Ergebnis
status: varchar('status', { length: 20 }).notNull(), // pending | verified | revoked
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
createdBy: uuid('created_by').notNull().references(() => usersTable.id),
hashPrev: bytea('hash_prev'),
hashSelf: bytea('hash_self').notNull(),
}, (t) => ({
subValidIdx: index('freistellung_sub_valid_idx').on(t.subId, t.validUntil.desc()),
}));
export const subFreistellungChecksTable = pgTable('sub_freistellung_checks', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull().references(() => tenantsTable.id),
freistellungId: uuid('freistellung_id').notNull().references(() => subFreistellungTable.id),
checkedAt: timestamp('checked_at', { withTimezone: true }).notNull().defaultNow(),
source: varchar('source', { length: 20 }).notNull(), // bzst_webservice | cache | manual
result: varchar('result', { length: 20 }).notNull(), // valid | invalid | revoked | unreachable
rawResponseS3Key: varchar('raw_response_s3_key', { length: 300 }), // XML-Roh-Antwort (Audit)
hashPrev: bytea('hash_prev'),
hashSelf: bytea('hash_self').notNull(),
}, (t) => ({
freistellungIdx: index('checks_freistellung_idx').on(t.freistellungId, t.checkedAt.desc()),
}));
export const subFrameworkContractsTable = pgTable('sub_framework_contracts', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull().references(() => tenantsTable.id),
subId: uuid('sub_id').notNull().references(() => subcontractorsTable.id),
validFrom: date('valid_from').notNull(),
validUntil: date('valid_until').notNull(),
autoRenewMonths: integer('auto_renew_months'), // NULL = keine Auto-Verlängerung
terminationNoticeDays: integer('termination_notice_days').default(90),
hourlyRates: jsonb('hourly_rates').notNull(), // [{role, rate_eur, from_date}]
travelFlatEur: numeric('travel_flat_eur', { precision: 10, scale: 2 }),
discountStaircase: jsonb('discount_staircase'), // [{monthly_volume_eur, pct}]
contractPdfS3Key: varchar('contract_pdf_s3_key', { length: 300 }),
signedBySub: boolean('signed_by_sub').default(false).notNull(),
signedByOurs: boolean('signed_by_ours').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
hashPrev: bytea('hash_prev'),
hashSelf: bytea('hash_self').notNull(),
});
export const subAssignmentsTable = pgTable('sub_assignments', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull().references(() => tenantsTable.id),
subId: uuid('sub_id').notNull().references(() => subcontractorsTable.id),
projectId: uuid('project_id').references(() => projectsTable.id),
frameworkContractId: uuid('framework_contract_id').references(() => subFrameworkContractsTable.id),
requestedBy: uuid('requested_by').notNull().references(() => usersTable.id),
requestedAt: timestamp('requested_at', { withTimezone: true }).notNull(),
plannedStart: date('planned_start').notNull(),
plannedEnd: date('planned_end'),
expectedHeadcount: integer('expected_headcount'),
status: varchar('status', { length: 20 }).notNull(), // requested|accepted|in_progress|completed|cancelled|blocked
blockOverrideBy: uuid('block_override_by').references(() => usersTable.id),
blockOverrideReason: text('block_override_reason'),
reverseChargeApplies: boolean('reverse_charge_applies').notNull(), // §13b UStG auto-erkannt
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
hashPrev: bytea('hash_prev'),
hashSelf: bytea('hash_self').notNull(),
}, (t) => ({
projectIdx: index('sub_assignments_project_idx').on(t.projectId, t.plannedStart.desc()),
}));
export const subPerformanceRecordsTable = pgTable('sub_performance_records', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull().references(() => tenantsTable.id),
assignmentId: uuid('assignment_id').notNull().references(() => subAssignmentsTable.id),
workDate: date('work_date').notNull(),
headcount: integer('headcount').notNull(),
hoursApprox: numeric('hours_approx', { precision: 5, scale: 2 }),
description: text('description').notNull(),
photoS3Keys: text('photo_s3_keys').array(),
signedByName: varchar('signed_by_name', { length: 120 }).notNull(),
signedByRole: varchar('signed_by_role', { length: 80 }).notNull(),
signatureS3Key: varchar('signature_s3_key', { length: 300 }), // PNG
signedAt: timestamp('signed_at', { withTimezone: true }),
signedOffline: boolean('signed_offline').default(false).notNull(),
idempotencyKey: uuid('idempotency_key').notNull(), // UUID v7 von Client
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
hashPrev: bytea('hash_prev'),
hashSelf: bytea('hash_self').notNull(),
}, (t) => ({
assignmentDateIdx: index('sub_perf_assign_date_idx').on(t.assignmentId, t.workDate),
idemIdx: uniqueIndex('sub_perf_idem_idx').on(t.tenantId, t.idempotencyKey),
}));

RLS-Policy-Sketch:

ALTER TABLE subcontractors ENABLE ROW LEVEL SECURITY;
CREATE POLICY subs_tenant_isolation ON subcontractors
USING (tenant_id = current_setting('app.tenant_id')::uuid);
ALTER TABLE sub_freistellung ENABLE ROW LEVEL SECURITY;
CREATE POLICY freistellung_scope ON sub_freistellung
USING (
tenant_id = current_setting('app.tenant_id')::uuid
AND current_setting('app.scope') IN ('all') -- nur Einkauf/Admin/Buchhaltung
);
ALTER TABLE sub_assignments ENABLE ROW LEVEL SECURITY;
CREATE POLICY assign_scope ON sub_assignments
FOR SELECT USING (
tenant_id = current_setting('app.tenant_id')::uuid
AND (
current_setting('app.scope') = 'all'
OR (current_setting('app.scope') = 'team' AND project_id IN (
SELECT id FROM projects WHERE manager_id = current_setting('app.user_id')::uuid
))
)
);

ER-Bezug: tenants, users, projects, customers, invoices (für §13b-Cross-Check auf eingehende Sub-Rechnungen, siehe §4.5), audit_log.


Methode Pfad Scope Rate Idempotenz Beschreibung
POST /v1/handwerk/subs subs:write Standard Pflicht Sub anlegen
GET /v1/handwerk/subs subs:read:all Standard Liste mit Ampel
GET /v1/handwerk/subs/{id} subs:read:all Standard Dossier
PATCH /v1/handwerk/subs/{id} subs:write Standard Pflicht Sub aktualisieren (Append-only-Version)
POST /v1/handwerk/subs/{id}/freistellung subs:write Standard Pflicht §48b hochladen
POST /v1/handwerk/subs/{id}/freistellung/{fId}/recheck subs:write Privileged Pflicht BZSt-Webservice manuell triggern
POST /v1/handwerk/subs/{id}/block subs:approve Privileged Pflicht Sub sperren (nur Buchhaltung/Admin)
POST /v1/handwerk/subs/{id}/framework-contracts subs:write Standard Pflicht Rahmenvertrag anlegen
POST /v1/handwerk/sub-assignments sub_assign:write Standard Pflicht Einsatz anfordern
POST /v1/handwerk/sub-assignments/{id}/performance-records sub_assign:write Standard Pflicht Einsatznachweis
POST /v1/handwerk/sub-invoices/{id}/13b-check invoices:write Standard Pflicht §13b-Auto-Check (intern von §4.5)

Webhook-Events (AsyncAPI 3.0):

  • sub.created, sub.blocked, sub.unblocked
  • sub.freistellung.uploaded, sub.freistellung.expiring, sub.freistellung.expired, sub.freistellung.recheck_failed
  • sub.assignment.requested, sub.assignment.performance_signed
  • sub.invoice.13b_mismatch

OpenAPI-Skizze:

paths:
/v1/handwerk/subs/{id}/freistellung:
post:
operationId: uploadSubFreistellung
x-werkszeit-scope: subs:write
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
document: { type: string, format: binary }
validFrom: { type: string, format: date }
validUntil: { type: string, format: date }
certificateNumber: { type: string }
issuedBy: { type: string }
required: [document, validFrom, validUntil, certificateNumber, issuedBy]
responses:
'201': { $ref: '#/components/responses/FreistellungCreated' }
'409': { description: 'Idempotency conflict' }
'422': { description: 'Gültigkeit > 3 Jahre oder OCR widerspricht Eingabe' }

Flow Profil Mechanik
Einsatznachweis mit Signatur Voll-offline Outbox + Idempotency-Key (UUID v7), Foto-Referenzen als Thumbnail in SQLite, Original per S3-Multipart-Upload bei Sync
Sub-Anforderung Best-effort-offline Anforderung offline speicherbar, Serverseite bei Sync, Ampel-Status aus letztem Cache (Stale-Marker im UI)
BZSt-Webservice-Prüfung Online-only Cache-Fallback: letzter bekannter valid-Stand wird 24 h akzeptiert, UI zeigt „Cache-Stand vom TT.MM.JJJJ HH:MM“
§48b-Upload Online-only (Web) OCR+BZSt-Prüfung benötigt Backend
§13b-Rechnungs-Check Online-only (Web) Läuft in §4.5-Rechnungseingangs-Pipeline

Konflikt-Strategie. Sub-Stammdaten-Mutationen sind append-only versioniert (neue Zeile mit hash_prev auf Vorgänger). Bei Sync-Konflikt (jemand hat offline eine Änderung, serverseitig auch) erscheint beiden Beteiligten im Audit-Log der Konflikt + Pflicht-Begründungsfeld für den Zweit-Schreiber. Kein Last-Write-Wins.

Foto-/Datei-Sync. §48b-PDF und Einsatznachweis-Foto laufen über S3-Multipart mit presigned URL; SQLite hält Thumbnail (max. 200 KB) + S3-Key.


  • Append-only. Freistellungsbescheinigungen, Einsatznachweise, Rahmenverträge sind aufbewahrungspflichtige Belege nach §147 AO (10 Jahre). Tabellen sub_freistellung, sub_performance_records, sub_framework_contracts enthalten hash_prev/hash_self (SHA-256 über kanonisierte JSON-Repräsentation der Zeile inkl. S3-Key).
  • Aufbewahrung. Alle hochgeladenen Dokumente landen in S3 mit Object Lock (Compliance-Mode, 10 Jahre). Metadaten in RDS werden nie gelöscht, nur logisch markiert (is_active = false, blocked_at).
  • Nachvollziehbarkeit. Audit-Log-Einträge bei jedem Upload, jeder BZSt-Prüfung, jeder Sperre/Freigabe. Jeder Audit-Eintrag trägt den Hash der betroffenen Zeile, sodass eine nachträgliche Mutation durch CI-Test detektiert würde.
  • Verfahrensdoku. Textbaustein „Subunternehmer-Verwaltung“ in gobd-verfahrensdoku.md wird automatisch um die konkret aktiven BZSt-Webservice-Endpunkte und OCR-Modelle ergänzt.

nicht zutreffend, weil Sub-Personal nicht unserem ArbZG-Regime unterliegt. Die Sub-eigene ArbZG-Pflicht liegt beim Sub, nicht bei uns.

  • §4 Abs. 8 VOB/B (Übertragung von Leistungen): Sub-Vergabe erfordert Einverständnis des Bauherrn bei öffentlichen Aufträgen. Feld bauherr_consent_ref in sub_assignments (optional, wird in §4.3 GAEB-Auftrag gesetzt).
  • Nachunternehmerhaftung §§ 14, 16 VOB/B. Fließt in Freigabe-Workflow: Kein Sub-Einsatz ohne gültige Mindestlohn-Bescheinigung (§13 MiLoG) + Betriebshaftpflicht-Nachweis. Hard-Gate vor Anforderung.
  • Art. 5 Datenminimierung. Wir speichern Inhaber-Name, Geschäftsführer, USt-ID, SOKA-Nr., Steuernummer, Bankverbindung, E-Mail, Telefon — aber kein Geburtsdatum, keine private Anschrift der Sub-MA.
  • Art. 6 Rechtsgrundlage. Vertrag (Art. 6 Abs. 1 b) — Erfüllung des Rahmenvertrags + gesetzliche Verpflichtungen §§ 48/48b EStG, §13 MiLoG, §28e SGB IV.
  • Art. 17 Löschung. Bei Löschwunsch des Subs: Logisches is_active = false + Maskierung der personenbezogenen Felder (Telefon/E-Mail) nach Ablauf der GoBD-Aufbewahrungsfrist (10 Jahre + Puffer). Finanzamt-relevante Belege bleiben erhalten (Zweckbindungs-Überwiegen).
  • Art. 20 Datenexport. Wenn Sub-Inhaber natürliche Person ist, erhält er auf Anfrage JSON-Export seiner Stammdaten + Historie seiner Bescheinigungen aus unserem System.
  • Art. 30 Verarbeitungsverzeichnis. Eintrag „Subunternehmer-Verwaltung“ mit Zweck „Steuer- und Sozialversicherungs-Compliance“, Kategorien (Geschäftskontakte), Empfänger (BZSt als Behörde), Löschfristen (10 Jahre nach Vertragsende).
  • DSFA. Nicht pflichtig gemäß Art. 35 DSGVO, weil B2B-Geschäftsbeziehung ohne systematische Bewertung persönlicher Aspekte der Sub-MA. Begründung im Tenant-Setup.

nicht zutreffend für Mobile-Flows, weil kein ESS-Flow. Web-Flows (Einkauf, Buchhaltung) sind interne Admin-UI und fallen nicht unter BFSG-Pflicht (§1 Abs. 3 BFSG). Trotzdem gilt Werkszeit-interner Standard: Flutter-Semantics-Test für Alle Screens, axe-core grün ohne Critical/Serious.

nicht zutreffend, weil keine Leistungs-/Verhaltenskontrolle eigener Mitarbeiter erfolgt (Sub-MA sind nicht unsere Angestellten).

  • §48 EStG (Bauabzugssteuer): Bei jeder Sub-Rechnung, deren zugehöriger Sub keine gültige sub_freistellung mit valid_until >= invoice_date hat, setzt das Backend Flag §48_withhold_15pct = true. Rechnung wird vom Auto-Workflow erst nach Buchhaltungs-Bestätigung zur Zahlung freigegeben; 15 % des Brutto werden auf Konto „Bauabzugssteuer-Verbindlichkeit“ gebucht.
  • §48b EStG (Freistellungsbescheinigung): Uploads prüfen: (a) Gültigkeitszeitraum ≤ 3 Jahre (§48b Abs. 1 Satz 3 EStG), (b) Steuernummer stimmt mit Sub-Stammdaten überein, (c) BZSt-Webservice liefert valid. Bei unreachable → Cache-Fallback (letzte erfolgreiche Prüfung < 24 h akzeptiert; ≥ 24 h führt zu Warnung im UI, Einsatz aber nicht blockiert, weil amtlich gültige Bescheinigung vorliegt).
  • §13b UStG (Reverse-Charge): Auto-Erkennung Bauleistung anhand Gewerk-Kategorie des Subs + Projekt-Typ (Bauvorhaben). Pflichtprüfung auf Sub-Rechnung: (a) USt-Ausweis 0 % oder leer, (b) Text „Steuerschuldnerschaft des Leistungsempfängers“ oder „§13b UStG“ im Langtext. Verstoß → Rechnung abgelehnt mit automatischer E-Mail an Sub mit Standard-Korrektur-Text.
  • §13 MiLoG (Mindestlohn-Bescheinigung): Jährlich neu anzufordern. Hard-Gate: ohne Bescheinigung ≤ 365 Tage alt kein Einsatz.
  • SOKA-Bau: Format-Validierung (13-stellige Zahl, Prüfziffer nach SOKA-Algorithmus). Cross-Check gegen SOKA-Onlineportal möglich (V2.5), im V2 nur Format.

# Szenario Erwartetes Verhalten
EC-01 BZSt-Webservice nicht erreichbar (Cache-Fallback). Nightly-Job läuft, BZSt-XML-Endpoint liefert Timeout/500. Eintrag in sub_freistellung_checks mit result='unreachable', source='bzst_webservice'. UI zeigt Ampel weiter grün, wenn Cache-Check < 24 h alt UND letztes Ergebnis valid. Banner: „BZSt nicht erreichbar — Stand vom 18.04.2026 03:11“. Nach 72 h unreachable → Ampel wird gelb, Einsatz-Anforderung warnt.
EC-02 Abgelaufene §48b-Bescheinigung + offene eingehende Rechnung. §48b-Bescheinigung läuft am 31.03. ab, Rechnung datiert vom 10.04., keine neue Bescheinigung vorhanden. Rechnung-Status 48_withhold_required. Buchhaltung muss Rechnung bestätigen, Workflow bucht 15 % auf „Bauabzugssteuer-Verbindlichkeit“. Sub wird automatisch gesperrt (blocked_reason='§48b abgelaufen'). Audit-Log-Eintrag. Push an Einkauf + Buchhaltung.
EC-03 Sub ohne SOKA-Nr. wird als Bau-Sub angelegt. Einkauf legt „Tiefbau Beispiel GmbH“ als Sub mit Gewerk Tiefbau an, lässt SOKA-Nr. leer. Onboarding-Wizard blockt Schritt 3 „Sozial“ mit Fehler „SOKA-Nr. ist Pflicht für Bau-Subs (TV Bau). Entweder SOKA-Nr. eintragen oder Sub als Nicht-Bau-Lieferant kennzeichnen.“ Kein Speichern möglich.
EC-04 §13b-Verstoß in Sub-Rechnung (USt ausgewiesen). Sub stellt Rechnung mit 19 % USt, obwohl Bauleistung B2B. Rechnungseingang-Pipeline markiert §13b_mismatch. Status blocked. Automatisch erzeugte E-Mail an Sub: „Ihre Rechnung 2026-038 weist USt aus — gemäß §13b UStG schulden wir als Leistungsempfänger die USt. Bitte korrigieren und neu senden.“ Originalrechnung bleibt im Archiv (GoBD), Zahlung blockiert.
EC-05 BZSt-Sicherheitsmerkmal widerspricht OCR. Einkauf lädt PDF, OCR extrahiert Nummer 2024-KS-778412, Einkauf tippt versehentlich 2024-KS-778411. OCR-Widerspruch-Warnung im UI. Mensch muss aktiv bestätigen, welcher Wert korrekt ist. Beide Versionen im sub_freistellung.ocrExtracted-JSON gespeichert. BZSt-Recheck erfolgt mit dem bestätigten Wert.
EC-06 Sub-Freigabe durch Bauleiter bei 🔴-Status. Bauleiter wählt gesperrten Sub mit F-M-06-Override. Kein automatischer Einsatz. Stattdessen Ticket „Freigabe-Anfrage #4711“ in Buchhaltungs-Queue. Buchhaltung muss explizit freigeben (mit Begründungs-Pflichtfeld) oder ablehnen. Bis dahin Sub-Assignment im Status blocked. Audit-Eintrag mit Bauleiter-ID, Ticket-ID, Buchhaltungs-Entscheidung.
EC-07 Plan-Limit Starter (kein Sub-Modul). Tenant im Starter-Plan versucht /v1/handwerk/subs aufzurufen. Backend-Modul-Gate antwortet 403 mit Body {"code":"module_not_available","module":"handwerk.subunternehmer","upgrade":"business"}. Frontend rendert Upgrade-Banner, nicht leere Liste.
EC-08 Multi-Tenant-Bleed-Versuch. User von Tenant A ruft /v1/handwerk/subs/{id} mit ID eines Sub aus Tenant B auf. RLS liefert 0 Zeilen, API antwortet 404 (nicht 403), um Existenz nicht zu offenbaren. Kein Audit-Eintrag in Tenant B. Hard-Test in apps/api/test/compliance/.
EC-09 Rollen-Demotion mid-upload. Einkauf lädt gerade §48b-PDF hoch (Multipart-Stream aktiv), Admin demotet User gerade auf Mitarbeiter. Backend re-validiert Scope bei POST /freistellung → 403, Upload-Stream wird serverseitig abgebrochen, S3-Temp-Part verworfen. Keine verwaiste Teil-Bescheinigung.
EC-10 Signatur offline, Gerät wird gestohlen bevor Sync. Bauleiter signiert Einsatznachweis offline, Tablet wird geklaut, MDM-Remote-Wipe. Lokal signierte Nachweise sind mit Idempotency-Key (UUID v7) ausgestattet. Sub-Vorarbeiter hat Kopie (PDF auf Wunsch per E-Mail generiert). Beim nächsten Bauleiter-Login auf neuem Gerät: Warnung „Ungesynchte Nachweise gingen mit Gerät verloren. Bitte Sub-Vorarbeiter kontaktieren für Re-Signatur“.

Funktionalität: Subunternehmer-Vergabe mit §48b/§13b-Compliance
Hintergrund:
Angenommen ein Tenant "shk-gebruder-schmidt" mit aktivem Modul-Gate "handwerk.subunternehmer"
Und ein Subunternehmer "Elektrik Yıldız GmbH" mit USt-ID "DE275319428"
Und eine gültige §48b-Bescheinigung gültig bis "2027-11-30"
Und ein Einkauf-User "Petra" mit Rolle "Einkauf" und Scope "all"
Und ein Bauleiter-User "Tuncay" mit Rolle "Bauleitung" und Scope "team"
Szenario: Happy Path — Sub-Anforderung mit gültiger Bescheinigung und Einsatznachweis
Wenn Tuncay die Mobile-App öffnet und "Sub anfordern" auf Baustelle "Rathausplatz 3" wählt
Und im Gewerk-Filter "Elektro" auswählt
Dann erscheint "Elektrik Yıldız GmbH" mit Ampel 🟢
Wenn Tuncay "Elektrik Yıldız" wählt, Zeitraum "21.–24.04.2026" und Headcount 2 einträgt
Und die Anforderung absendet
Dann wird ein `sub_assignment` mit Status "requested" angelegt
Und `reverse_charge_applies = true` ist gesetzt
Und ein Audit-Log-Eintrag "sub.assignment.requested" wird erzeugt
Und die Einkauf erhält Push "Neue Sub-Anforderung 2026-Z-089"
Szenario: Grenzfall — BZSt-Webservice nicht erreichbar (Cache-Fallback)
Angenommen die letzte erfolgreiche BZSt-Prüfung für "Elektrik Yıldız" war am "2026-04-18 03:11 UTC" mit Ergebnis "valid"
Und der BZSt-Webservice ist heute "2026-04-19" nicht erreichbar
Wenn der Nightly-Job "checkFreistellungen" läuft
Dann wird ein Eintrag in `sub_freistellung_checks` mit `source='bzst_webservice'` und `result='unreachable'` erzeugt
Und der effektive Ampel-Status bleibt 🟢, weil Cache-Stand < 24 h
Und das UI zeigt Banner "BZSt nicht erreichbar — Stand 2026-04-18 03:11"
Wenn nach 73 Stunden der BZSt-Webservice weiterhin nicht erreichbar ist
Dann fällt die Ampel auf 🟡 mit Hinweis "Re-Check seit >72 h nicht möglich"
Szenario: Grenzfall — Abgelaufene §48b-Bescheinigung und offene eingehende Rechnung
Angenommen die §48b-Bescheinigung von "Elektrik Yıldız" ist am "2026-03-31" abgelaufen
Und eine Rechnung mit Rechnungsdatum "2026-04-10", Netto 9.820 € liegt in der Rechnungseingangs-Pipeline
Wenn die Pipeline den §48-Check ausführt
Dann wird die Rechnung mit Flag "§48_withhold_required" markiert
Und der Sub wird automatisch gesperrt mit `blocked_reason='§48b abgelaufen'`
Und bei Zahlungsfreigabe durch die Buchhaltung werden 1.473 € (15 %) auf Konto "Bauabzugssteuer-Verbindlichkeit" gebucht
Und ein Audit-Log-Eintrag "sub.blocked" + "invoice.48_withhold_applied" wird erzeugt
Und eine Push-Benachrichtigung geht an Einkauf + Buchhaltung
Szenario: Grenzfall — Sub ohne SOKA-Nr. als Bau-Sub anlegen
Wenn Petra im Onboarding-Wizard "Tiefbau Beispiel GmbH" mit Gewerk "Tiefbau" anlegt
Und Schritt 3 "Sozial" ohne SOKA-Nr. verlässt
Dann erscheint eine Fehlermeldung "SOKA-Nr. ist Pflicht für Bau-Subs (TV Bau)"
Und der Wizard blockt bis entweder SOKA-Nr. eingetragen oder Gewerk auf Nicht-Bau geändert wird
Und der Sub wird NICHT gespeichert
Und KEIN Audit-Log-Eintrag wird erzeugt (kein Side-Effect)
Szenario: Grenzfall — Multi-Tenant-Bleed-Versuch
Angenommen ein zweiter Tenant "musterbetrieb-maler" mit Sub-ID `sub-xyz-123`
Wenn Petra (Tenant "shk-gebruder-schmidt") `GET /v1/handwerk/subs/sub-xyz-123` aufruft
Dann antwortet die API mit 404
Und es entsteht KEIN Audit-Log-Eintrag im Tenant "musterbetrieb-maler"
Und die Antwort enthält KEINE Information, dass der Sub in einem anderen Tenant existiert

  • U-01validateSokaBauNumber(s: string) — Prüfziffer-Algorithmus gegen 20 bekannte gültige + 20 ungültige Nummern.
  • U-02validateFreistellungValidity(from, until) — Abstand ≤ 3 Jahre (§48b Abs. 1 Satz 3 EStG), until > from, from > 1900, Zeitzonen-UTC.
  • U-03 — Property-based: calculate48Withhold(brutto) rundet auf 2 Nachkommastellen nach kaufmännischer Rundung, nie negativ.
  • U-04parseBZStResponse(xml) — Parser für alle drei BZSt-Response-Typen (valid, revoked, unknown), auch bei fehlerhaftem XML (nicht crashen, unreachable zurück).
  • U-05detectReverseCharge(gewerk, projektTyp, subVatId) — Truth-Table für alle 12 Kombinationen Bau-Gewerk × B2B-Projekt.
  • W-01 — Ampel-Badge rendert grün/gelb/rot nach gewählten Input-Props, Screen-Reader-Label ist „Status grün, §48b gültig bis …“.
  • W-02 — Sperr-Modal (F-M-06) zeigt genauen Grund + CTA „Freigabe anfordern“; disabled bei fehlender sub:approve-Rolle.
  • W-03 — Golden-Test de-DE Light + Dark Theme für Sub-Liste und Dossier-Tab.
  • I-01 — Multi-Tenant-Hard-Test (EC-08): 2 Tenants, beide mit je 1 Sub. Lese-, Schreib-, Löschversuche cross-tenant → 404.
  • I-02 — RLS team Scope: Bauleiter Tuncay sieht nur Sub-Assignments seiner Projekte, nicht die von Kollege Krause.
  • I-03 — Outbox-Idempotenz: POST /performance-records mit identischem Idempotency-Key zweimal → 201 + 409 (kein zweiter DB-Eintrag).
  • I-04 — Nightly-Job checkFreistellungen gegen Mock-BZSt: 100 Subs, 3 expired, 2 unreachable, 95 valid → entsprechende Events werden emittiert.
  • E-01 — Happy Path (§12) auf iOS + Android + Chromium + Firefox + WebKit.
  • E-02 — Offline-Pfad: Einsatznachweis offline signieren → Sync an → Server hat den Eintrag mit korrektem Hash-Chain-Link.
  • E-03 — Visuelle Regression: Mobile Sub-Anforderungs-Screen + Web-Dossier in de-DE Light.
  • E-04 — axe-core ohne Critical/Serious auf Web-Dossier und Onboarding-Wizard.
  • C-01 — Hash-Chain-Integrität: Mutation von sub_freistellung.valid_until per SQL → CI-Check verifyHashChain() schlägt fehl, Alert.
  • C-02 — BZSt-Mock-Response-Validator: Alle Response-Typen parsebar.
  • C-03 — §13b-Textpattern-Matcher: 15 positive („Steuerschuldnerschaft des Leistungsempfängers“, „§13b UStG“, „Reverse-Charge“, …) + 10 negative Beispiele.
  • C-04 — §48-Einbehalt-Rundung: 100 Beispiel-Bruttos, kaufmännische Rundung auf 2 Nachkommastellen.

Lokaler 20× Re-Run der E-01 und E-02 grün vor Merge.


Nicht-Ziel Begründung
Eigene §48b-Bescheinigung ausstellen Zuständig ist ausschließlich das Finanzamt (§48b EStG). Wir prüfen, wir stellen nicht aus.
Sub-Lohnbuchhaltung / SV-Meldungen für Sub-MA Zuständigkeit des Subs bzw. des SOKA-Bau-Portals. Kein Werkszeit-Scope.
Integration in SOKA-Onlineportal (Pflegen der Sub-Meldungen) Nur Lesen der Nr. in V2, V2.5 evtl. Read-only-Cross-Check.
Automatische ELSTER-Daueranmeldung §48 Abs. 2 EStG V2.5 als eigenes Feature, nicht im V2-MVP. Kostenstelle vorgesehen.
Sub-Bewertungs-/Rating-System (Sterne) Rechtlich heikel (UWG, Persönlichkeitsrecht); nur wenn ≥ 1 Design-Partner es konkret fordert.
Peppol-basierte Sub-Rechnungen empfangen §4.5 / V2 — Pfad via kosit-validator-lambda ist dort gesetzt, hier nur konsumierend.
Sub-Portal (Sub loggt sich selbst ein, lädt seine Bescheinigung hoch) V3-Kandidat, braucht eigenes Auth-Domänen-Konzept.
Whistleblower-Flow für Sub-Mitarbeiter (HinSchG) FUNKTIONSUMFANG §3.9 markiert HinSchG als on-demand; nicht Teil Subunternehmer-Modul.

Risiko / Annahme Impact Wahrscheinlichkeit Gegenmaßnahme
BZSt-Webservice-Schnittstelle ändert Response-Format (XML → JSON?) hoch niedrig Parser ist versioniert, BzstClient.v2024. Fallback: manuelle Eingabe. Vertrags-Tests gegen BZSt-Sandbox monatlich.
SOKA-Bau-Nr. Prüfziffer-Algorithmus nicht öffentlich dokumentiert mittel mittel Nur Format-Check (13 Ziffern) im V2; Cross-Check per Portal-API in V2.5, wenn erschlossen.
§13b-Textpattern-Matcher liefert False-Negatives bei ungewöhnlichen Formulierungen mittel mittel Whitelist-Pflege durch Buchhaltung (Ein-Klick „Diese Formulierung ist korrekt“); Regressionstests wachsen mit.
OCR-Qualität bei handgeschriebenen Bescheinigungen niedrig niedrig Bescheinigungen werden vom FA gedruckt; Foto-Upload mit Auto-Deskew. Mensch bestätigt OCR immer.
Referenzkunde für Sub-Modul nicht eindeutig hoch mittel Sales-Call „Hildebrand Tiefbau“ vor Sprint-Start verbindlich. Ohne Kunde kein Start (DOR §1.1.1).
§48b-Gültigkeitsregeln ändern sich (Steuergesetzgebung) hoch niedrig ADR mit jährlichem Review; Valibot-Schema als Single Source of Truth.
Performance: Nightly-Job bei 1.000 Subs > 10 min mittel mittel Parallelisierung, max 50 req/min an BZSt (laut Spec); Spread über 24 h Cron.

  • Vorbedingung: kern/11-audit-log (Hash-Chain-Framework), kern/10-datev-export (Lohnart „Bauabzugssteuer-Verbindlichkeit“), handwerk/05-bau-abrechnung (Rechnungseingangs-Pipeline mit §13b-Check-Hook).
  • Schnittstelle zu: BZSt-XML-Webservice (ELSTER-Zertifikat), SOKA-Bau-Portal (V2.5 read-only), S3 (Object Lock), OCR-Service (on-device + serverseitig Bedrock Claude Sonnet).
  • Wird konsumiert von: handwerk/05-bau-abrechnung (konsumiert reverse_charge_applies + §48-Einbehalt), kern/10-datev-export (bucht Bauabzugssteuer als Lohnart), Dashboard (KPIs offene §48b, offene §48-Einbehalte).

Status: TBD — zu klären mit Sales.

DOR §1.1.1 verlangt einen namentlich dokumentierten zahlenden Design-Partner.

Kandidaten-Profile:

  • Hildebrand Tiefbau GmbH (Köln, 48 MA, Tiefbau + GaLaBau) — arbeitet mit 12 Rahmen-Subs, führt §48b-Bescheinigungen heute in Excel. Pain aus Interview 2026-03-11: „zwei Mal im Jahr übersehen wir eine Ablaufende, das kostet uns vierstellig.“
  • Dachdecker Hennef & Söhne (Hennef, 22 MA) — nutzt 6 Subs für Gerüstbau. Pain: SOKA-Bau-Nachprüfung bei Betriebsprüfung offen.

Validierungs-Fragen:

  1. Wie viele §48b-Bescheinigungen haben Sie im Umlauf? Wie erinnern Sie sich an Abläufe?
  2. Wer in Ihrem Team prüft eingehende Sub-Rechnungen auf §13b? Wie oft geht das schief?
  3. Würden Sie 14 €/User/Monat (Business-Plan) zahlen, wenn das Sub-Modul enthalten ist und pro Jahr 2–3 Einbehalts-Versäumnisse vermeidet?
  4. Wären Sie bereit, das Feature im Beta-Status für 60 Tage kostenlos zu testen?

Build-Sequenz:

  1. Datenmodell + RLS + Multi-Tenant-Test zuerst. subcontractors, sub_freistellung, sub_assignments mit Hash-Chain und I-01-Test. (3 Tage)
  2. BZSt-Webservice-Client gegen BZSt-Testumgebung, mit BzstClient.v2024-Abstraktion und Cache-Schicht in Redis. (2 Tage)
  3. Backend-API + OpenAPI-Spec inkl. POST /freistellung mit OCR-Hook (Bedrock Claude Sonnet 4.6, Prompt-Caching für Template). Dart-Client-Gen. (3 Tage)
  4. Web-MVP — Onboarding-Wizard, Sub-Liste, Sub-Dossier, §48b-Upload. Ohne Rahmenvertrag. (4 Tage)
  5. Mobile-MVP — Sub-Anforderung, Einsatznachweis mit Signatur (offline-fähig, Outbox). (3 Tage)
  6. §13b-Hook in Rechnungseingangs-Pipeline (§4.5 muss bereits existieren). (1 Tag)
  7. Rahmenverträge + Auto-Verlängerung + Typst-PDF. (1 Tag)
  8. Compliance-Test-Suite erweitern: sub_freistellung_hash_chain_test.ts, bzst_parser_test.ts, reverse_charge_detection_test.ts. (1 Tag)

Risiko-Reihenfolge. Multi-Tenant-Isolation (I-01) und §48b-Validierungslogik sind nicht verhandelbar. Rahmenvertrags-Editor und Bulk-Aktionen sind Streich-Kandidaten, falls Zeitnot.

Stop-the-Bus-Triggers:

  • Multi-Tenant-Bleed bei Subs oder Freistellungs-PDFs (Zugriff auf Tenant-fremdes S3-Objekt).
  • Hash-Chain-Bruch in sub_freistellung in der Compliance-Suite.
  • BZSt-Webservice antwortet mit echten Daten in Testumgebung (Datenleck-Risiko).
  • §13b-Matcher classifiziert eine 19 %-USt-Rechnung fälschlich als korrekt.

Was uns 2027 dankbar macht. Der Hash-Chain auf sub_freistellung_checks erlaubt später lückenlosen BP-Nachweis „Wir haben täglich gegen BZSt geprüft, hier sind 730 nachvollziehbare Checks pro Sub“. Die Idempotency-Keys auf Einsatznachweisen machen Offline-Sync ohne Duplikate trivial — egal wie oft der Monteur den Stempel drückt. Der klare Modul-Gate auf module.handwerk.subunternehmer verhindert, dass Starter-Plan-Kunden versehentlich auf das Modul blicken und Feature-Neid aufbauen — das war in der Alt-App (planLimits nur im Frontend) ein stehender Audit-Befund.


Letzte Aktualisierung: 2026-04-19 · Branch feinkonzepte/v0.1

Für Entwickler — API-Endpoints22
MethodePfadAuthZweck
GET/v1/handwerk/subunternehmerbearerAuthSubunternehmer (Keyset-Pagination)
POST/v1/handwerk/subunternehmerbearerAuthSubunternehmer anlegen
POST/v1/handwerk/subunternehmer-fsb-checkbearerAuthFSB §48b EStG Freistellungsbescheinigung prüfen
GET/v1/handwerk/subunternehmer-rechnungen-eingangbearerAuthSubunternehmer-Eingangsrechnungen (Keyset-Pagination)
POST/v1/handwerk/subunternehmer-rechnungen-eingangbearerAuthSubunternehmer-Eingangsrechnung erfassen
GET/v1/handwerk/subunternehmer-rechnungen-eingang/{id}bearerAuthSubunternehmer-Eingangsrechnung Detail
POST/v1/handwerk/subunternehmer-rechnungen-eingang/{id}/bezahlenbearerAuthEingangsrechnung als bezahlt buchen (freigegeben → bezahlt)
POST/v1/handwerk/subunternehmer-rechnungen-eingang/{id}/freigebenbearerAuthEingangsrechnung freigeben (geprueft → freigegeben)
POST/v1/handwerk/subunternehmer-rechnungen-eingang/{id}/pruefenbearerAuthEingangsrechnung prüfen (erfasst → geprueft)
GET/v1/handwerk/subunternehmer-rechnungen-inboundbearerAuthSubunternehmer-Inbound-Rechnungen (Keyset-Pagination)
POST/v1/handwerk/subunternehmer-rechnungen-inboundbearerAuthSubunternehmer-Inbound-Rechnung erfassen
GET/v1/handwerk/subunternehmer-rechnungen-inbound/{id}bearerAuthSubunternehmer-Inbound-Rechnung Detail
POST/v1/handwerk/subunternehmer-rechnungen-inbound/{id}/ablehnenbearerAuthInbound-Rechnung ablehnen
POST/v1/handwerk/subunternehmer-rechnungen-inbound/{id}/bezahlenbearerAuthInbound-Rechnung als bezahlt buchen (validiert → bezahlt)
POST/v1/handwerk/subunternehmer-rechnungen-inbound/{id}/validierebearerAuthInbound-Rechnung validieren (empfangen → validiert)
GET/v1/handwerk/subunternehmer-vertraegebearerAuthSubunternehmer-Verträge (Keyset-Pagination)
POST/v1/handwerk/subunternehmer-vertraegebearerAuthSubunternehmer-Vertrag anlegen
GET/v1/handwerk/subunternehmer-vertraege/{id}bearerAuthSubunternehmer-Vertrag Detail
PUT/v1/handwerk/subunternehmer-vertraege/{id}bearerAuthSubunternehmer-Vertrag aktualisieren
DELETE/v1/handwerk/subunternehmer/{id}bearerAuthSubunternehmer löschen
GET/v1/handwerk/subunternehmer/{id}bearerAuthSubunternehmer Detail
PUT/v1/handwerk/subunternehmer/{id}bearerAuthSubunternehmer aktualisieren