Subunternehmer-Vergabe mit §48b-Freistellung, SOKA-Check und §13b-Reverse-Charge-Automatik
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
1. Header & Metadaten
Abschnitt betitelt „1. Header & Metadaten“feature_id: handwerk/08-subunternehmertitle: Subunternehmer-Vergabe mit §48b-Freistellung, SOKA-Check und §13b-Reverse-Charge-Automatikfunktionsumfang_ref: §4.8roadmap_horizont: V2plattformen: mobile: vollständig # Sub-Wahl, Einsatznachweis, Signatur, Offline-Erfassung web: vollständig mit-Bulk desktop: aus # Bulk-Aktionen im Web decken Desktop-Use-Case abowner_rolle: [Bauleitung, Einkauf, Admin, Buchhaltung]modul_gate_flag: module.handwerk.subunternehmercompliance_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-11estimate_eng_tage: 18abhä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 Lohnart2. Kontext & Problem
Abschnitt betitelt „2. Kontext & Problem“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.
3. Personas & Rollen
Abschnitt betitelt „3. Personas & Rollen“| 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.
4. User-Stories
Abschnitt betitelt „4. User-Stories“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.5. Funktionale Anforderungen
Abschnitt betitelt „5. Funktionale Anforderungen“5.1 Mobile App (📱)
Abschnitt betitelt „5.1 Mobile App (📱)“- F-M-01 — Sub-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-02 — Ampel-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-03 — Anforderung anlegen mit Gewerk, Leistungszeitraum, erwarteter Anzahl MA, Ansprechpartner beim Sub. Offline erfassbar.
- F-M-04 — Einsatznachweis-Erfassung vor Ort. Anzahl MA je Tag, Stunden-Schätzung (später durch IST-Rechnung des Subs bestätigt), Freitext + Foto.
- F-M-05 — Touch-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-06 — Warn-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-07 — Sub-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.
5.2 Web-App (🌐)
Abschnitt betitelt „5.2 Web-App (🌐)“- F-W-01 — Sub-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-02 — Sub-Detail-Ansicht mit Tabs: Stammdaten · Dokumente · Rahmenverträge · Einsätze · Rechnungen · §48-Historie · Audit-Log.
- F-W-03 — Onboarding-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-05 — BZSt-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-06 — Rahmenvertrags-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-09 — Sperr-/Freigabe-Workflow. Buchhaltung kann Sub temporär sperren. Bauleiter wird beim Anfordern informiert (F-M-06). Freigabe nur mit Vier-Augen-Prinzip.
5.3 Cross-Plattform (🔄)
Abschnitt betitelt „5.3 Cross-Plattform (🔄)“- F-X-01 — Status-Benachrichtigungen. Push an Einkauf: 90 / 30 / 7 Tage vor Ablauf §48b. Push an Bauleiter + Buchhaltung: Ablauf am Einsatz-Tag.
- F-X-02 — Audit-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).
5.4 Admin-Konfiguration
Abschnitt betitelt „5.4 Admin-Konfiguration“- F-A-01 — BZSt-Webservice-Zertifikat hinterlegen (ELSTER-Zertifikat des Tenants).
- F-A-02 — Pflichtfelder-Profil wählen: „Bau-Sub“ (Full-Pack) vs. „Gewerbe-Sub“ (ohne SOKA). Default Bau-Sub, wenn Branche Handwerk+Bau.
- F-A-03 — Erinnerungsfristen anpassbar (default 90/30/7, max 180 Tage vor Ablauf).
- F-A-04 — Sperr-Policy: Automatische Sperre bei abgelaufener §48b (default: an) · manuelle Sperre (immer an) · Sperre bei SOKA-Kündigung (default: an).
5.5 Roadmap-Schichtung
Abschnitt betitelt „5.5 Roadmap-Schichtung“| 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) | ✅ |
6. Mockups & Flows
Abschnitt betitelt „6. Mockups & Flows“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.
6.1 Mobile · Sub-Anforderung mit Ampel-Status
Abschnitt betitelt „6.1 Mobile · Sub-Anforderung mit Ampel-Status“┌────────────────────────────────┐│ ← 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 ] │└────────────────────────────────┘6.2 Mobile · Einsatznachweis mit Signatur
Abschnitt betitelt „6.2 Mobile · Einsatznachweis mit Signatur“┌────────────────────────────────┐│ ← 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 ] │└────────────────────────────────┘6.3 Web · Sub-Dossier (Einkauf)
Abschnitt betitelt „6.3 Web · Sub-Dossier (Einkauf)“┌──────────────────────────────────────────────────────────────────────────────────┐│ 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 │ │││ └────┴──────────────────────────┴──────────┴──────────┴──────────────┴────────┘│└──────────────────────────────────────────────────────────────────────────────────┘6.5 Mobile · Sperr-Warnung (Grenzfall)
Abschnitt betitelt „6.5 Mobile · Sperr-Warnung (Grenzfall)“┌────────────────────────────────┐│ ⚠ 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.
7. Datenmodell-Skizze
Abschnitt betitelt „7. Datenmodell-Skizze“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.
8. API-Endpunkte
Abschnitt betitelt „8. API-Endpunkte“| 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.unblockedsub.freistellung.uploaded,sub.freistellung.expiring,sub.freistellung.expired,sub.freistellung.recheck_failedsub.assignment.requested,sub.assignment.performance_signedsub.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' }9. Offline-Profil
Abschnitt betitelt „9. Offline-Profil“| 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.
10. Compliance-Mapping
Abschnitt betitelt „10. Compliance-Mapping“10.1 GoBD
Abschnitt betitelt „10.1 GoBD“- Append-only. Freistellungsbescheinigungen, Einsatznachweise, Rahmenverträge sind aufbewahrungspflichtige Belege nach §147 AO (10 Jahre). Tabellen
sub_freistellung,sub_performance_records,sub_framework_contractsenthaltenhash_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.mdwird automatisch um die konkret aktiven BZSt-Webservice-Endpunkte und OCR-Modelle ergänzt.
10.2 ArbZG
Abschnitt betitelt „10.2 ArbZG“— nicht zutreffend, weil Sub-Personal nicht unserem ArbZG-Regime unterliegt. Die Sub-eigene ArbZG-Pflicht liegt beim Sub, nicht bei uns.
10.3 VOB/B
Abschnitt betitelt „10.3 VOB/B“- §4 Abs. 8 VOB/B (Übertragung von Leistungen): Sub-Vergabe erfordert Einverständnis des Bauherrn bei öffentlichen Aufträgen. Feld
bauherr_consent_refinsub_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.
10.4 DSGVO
Abschnitt betitelt „10.4 DSGVO“- 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.
10.5 BFSG / WCAG 2.2 AA
Abschnitt betitelt „10.5 BFSG / WCAG 2.2 AA“— 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.
10.6 BetrVG §87(1)6
Abschnitt betitelt „10.6 BetrVG §87(1)6“— nicht zutreffend, weil keine Leistungs-/Verhaltenskontrolle eigener Mitarbeiter erfolgt (Sub-MA sind nicht unsere Angestellten).
10.7 Spezial-Compliance
Abschnitt betitelt „10.7 Spezial-Compliance“- §48 EStG (Bauabzugssteuer): Bei jeder Sub-Rechnung, deren zugehöriger Sub keine gültige
sub_freistellungmitvalid_until >= invoice_datehat, 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. Beiunreachable→ 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.
11. Edge-Cases
Abschnitt betitelt „11. Edge-Cases“| # | 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“. |
12. Akzeptanzkriterien (Gherkin)
Abschnitt betitelt „12. Akzeptanzkriterien (Gherkin)“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 existiert13. Test-Cases
Abschnitt betitelt „13. Test-Cases“13.1 Unit-Tests
Abschnitt betitelt „13.1 Unit-Tests“- U-01 —
validateSokaBauNumber(s: string)— Prüfziffer-Algorithmus gegen 20 bekannte gültige + 20 ungültige Nummern. - U-02 —
validateFreistellungValidity(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-04 —
parseBZStResponse(xml)— Parser für alle drei BZSt-Response-Typen (valid,revoked,unknown), auch bei fehlerhaftem XML (nicht crashen,unreachablezurück). - U-05 —
detectReverseCharge(gewerk, projektTyp, subVatId)— Truth-Table für alle 12 Kombinationen Bau-Gewerk × B2B-Projekt.
13.2 Widget-/Component-Tests
Abschnitt betitelt „13.2 Widget-/Component-Tests“- 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-DELight + Dark Theme für Sub-Liste und Dossier-Tab.
13.3 Integrations-Tests
Abschnitt betitelt „13.3 Integrations-Tests“- 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
teamScope: Bauleiter Tuncay sieht nur Sub-Assignments seiner Projekte, nicht die von Kollege Krause. - I-03 — Outbox-Idempotenz:
POST /performance-recordsmit identischem Idempotency-Key zweimal → 201 + 409 (kein zweiter DB-Eintrag). - I-04 — Nightly-Job
checkFreistellungengegen Mock-BZSt: 100 Subs, 3 expired, 2 unreachable, 95 valid → entsprechende Events werden emittiert.
13.4 E2E-Tests
Abschnitt betitelt „13.4 E2E-Tests“- 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.
13.5 Compliance-Tests
Abschnitt betitelt „13.5 Compliance-Tests“- C-01 — Hash-Chain-Integrität: Mutation von
sub_freistellung.valid_untilper SQL → CI-CheckverifyHashChain()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.
13.6 Flakiness-Schutz
Abschnitt betitelt „13.6 Flakiness-Schutz“Lokaler 20× Re-Run der E-01 und E-02 grün vor Merge.
14. Nicht-Ziele
Abschnitt betitelt „14. Nicht-Ziele“| 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. |
15. Risiken & offene Annahmen
Abschnitt betitelt „15. Risiken & offene Annahmen“| 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. |
16. Abhängigkeiten
Abschnitt betitelt „16. Abhängigkeiten“- 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(konsumiertreverse_charge_applies+ §48-Einbehalt),kern/10-datev-export(bucht Bauabzugssteuer als Lohnart), Dashboard (KPIs offene §48b, offene §48-Einbehalte).
17. Referenzkunde-Slot
Abschnitt betitelt „17. Referenzkunde-Slot“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:
- Wie viele §48b-Bescheinigungen haben Sie im Umlauf? Wie erinnern Sie sich an Abläufe?
- Wer in Ihrem Team prüft eingehende Sub-Rechnungen auf §13b? Wie oft geht das schief?
- Würden Sie 14 €/User/Monat (Business-Plan) zahlen, wenn das Sub-Modul enthalten ist und pro Jahr 2–3 Einbehalts-Versäumnisse vermeidet?
- Wären Sie bereit, das Feature im Beta-Status für 60 Tage kostenlos zu testen?
18. Senior-Berater-Empfehlung
Abschnitt betitelt „18. Senior-Berater-Empfehlung“Build-Sequenz:
- Datenmodell + RLS + Multi-Tenant-Test zuerst.
subcontractors,sub_freistellung,sub_assignmentsmit Hash-Chain und I-01-Test. (3 Tage) - BZSt-Webservice-Client gegen BZSt-Testumgebung, mit
BzstClient.v2024-Abstraktion und Cache-Schicht in Redis. (2 Tage) - Backend-API + OpenAPI-Spec inkl.
POST /freistellungmit OCR-Hook (Bedrock Claude Sonnet 4.6, Prompt-Caching für Template). Dart-Client-Gen. (3 Tage) - Web-MVP — Onboarding-Wizard, Sub-Liste, Sub-Dossier, §48b-Upload. Ohne Rahmenvertrag. (4 Tage)
- Mobile-MVP — Sub-Anforderung, Einsatznachweis mit Signatur (offline-fähig, Outbox). (3 Tage)
- §13b-Hook in Rechnungseingangs-Pipeline (§4.5 muss bereits existieren). (1 Tag)
- Rahmenverträge + Auto-Verlängerung + Typst-PDF. (1 Tag)
- 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_freistellungin 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
| Methode | Pfad | Auth | Zweck |
|---|---|---|---|
| GET | /v1/handwerk/subunternehmer | bearerAuth | Subunternehmer (Keyset-Pagination) |
| POST | /v1/handwerk/subunternehmer | bearerAuth | Subunternehmer anlegen |
| POST | /v1/handwerk/subunternehmer-fsb-check | bearerAuth | FSB §48b EStG Freistellungsbescheinigung prüfen |
| GET | /v1/handwerk/subunternehmer-rechnungen-eingang | bearerAuth | Subunternehmer-Eingangsrechnungen (Keyset-Pagination) |
| POST | /v1/handwerk/subunternehmer-rechnungen-eingang | bearerAuth | Subunternehmer-Eingangsrechnung erfassen |
| GET | /v1/handwerk/subunternehmer-rechnungen-eingang/{id} | bearerAuth | Subunternehmer-Eingangsrechnung Detail |
| POST | /v1/handwerk/subunternehmer-rechnungen-eingang/{id}/bezahlen | bearerAuth | Eingangsrechnung als bezahlt buchen (freigegeben → bezahlt) |
| POST | /v1/handwerk/subunternehmer-rechnungen-eingang/{id}/freigeben | bearerAuth | Eingangsrechnung freigeben (geprueft → freigegeben) |
| POST | /v1/handwerk/subunternehmer-rechnungen-eingang/{id}/pruefen | bearerAuth | Eingangsrechnung prüfen (erfasst → geprueft) |
| GET | /v1/handwerk/subunternehmer-rechnungen-inbound | bearerAuth | Subunternehmer-Inbound-Rechnungen (Keyset-Pagination) |
| POST | /v1/handwerk/subunternehmer-rechnungen-inbound | bearerAuth | Subunternehmer-Inbound-Rechnung erfassen |
| GET | /v1/handwerk/subunternehmer-rechnungen-inbound/{id} | bearerAuth | Subunternehmer-Inbound-Rechnung Detail |
| POST | /v1/handwerk/subunternehmer-rechnungen-inbound/{id}/ablehnen | bearerAuth | Inbound-Rechnung ablehnen |
| POST | /v1/handwerk/subunternehmer-rechnungen-inbound/{id}/bezahlen | bearerAuth | Inbound-Rechnung als bezahlt buchen (validiert → bezahlt) |
| POST | /v1/handwerk/subunternehmer-rechnungen-inbound/{id}/validiere | bearerAuth | Inbound-Rechnung validieren (empfangen → validiert) |
| GET | /v1/handwerk/subunternehmer-vertraege | bearerAuth | Subunternehmer-Verträge (Keyset-Pagination) |
| POST | /v1/handwerk/subunternehmer-vertraege | bearerAuth | Subunternehmer-Vertrag anlegen |
| GET | /v1/handwerk/subunternehmer-vertraege/{id} | bearerAuth | Subunternehmer-Vertrag Detail |
| PUT | /v1/handwerk/subunternehmer-vertraege/{id} | bearerAuth | Subunternehmer-Vertrag aktualisieren |
| DELETE | /v1/handwerk/subunternehmer/{id} | bearerAuth | Subunternehmer löschen |
| GET | /v1/handwerk/subunternehmer/{id} | bearerAuth | Subunternehmer Detail |
| PUT | /v1/handwerk/subunternehmer/{id} | bearerAuth | Subunternehmer aktualisieren |