feat(founding-wizard): Gründungs-Wizard für 2-Mann GmbH + 14 Notar-Templates

[migration-approved]

Templates (Migrations 123-136):
- 123 GO-GF (Geschäftsordnung Geschäftsführung)
- 124 SHA (Shareholders' Agreement, 56 Platzhalter)
- 125 Satzung (Articles of Association mit UG-Variante)
- 126 GF-Dienstvertrag (Trennungsprinzip Organ/Anstellung)
- 127 Arbeitsvertrag (AGG-neutral, NachwG, eAU)
- 128 Gesellschafterliste (§ 40 GmbHG)
- 129 GF-Bestellungsbeschluss (mit § 6 Abs. 2 Versicherung)
- 130 HRB-Anmeldung (§§ 7, 8, 39 GmbHG, § 12 HGB)
- 131 IP-Assignment Agreement (Gründer→GmbH)
- 132 Term Sheet (Pre-Seed/Seed VC-Standard)
- 133 Wandeldarlehensvertrag (Convertible Loan)
- 134 Beteiligungsvertrag (Subscription Agreement)
- 135 ESOP/VSOP-Plan (3 Varianten)
- 136 Cap Table

Kategorisierung (Migrations 137-138):
- ALTER TABLE compliance_legal_templates ADD lifecycle_stage TEXT[],
  functional_category TEXT (mit CHECK Constraints + GIN-Index)
- Backfill aller 105 Templates: lifecycle_stage (pre_founding|founding|
  startup|kmu|konzern) + functional_category (founding_legal|employment|
  investor_funding|...)

Backend Founding-Wizard Service:
- template_renderer.py: Handlebars-light ({{VAR}}, {{#IF FLAG}}...{{/IF}})
- wizard_to_context.py: Mapping Wizard-State → SCREAMING_SNAKE_CASE Vars
- markdown_to_docx.py: Markdown → DOCX via python-docx
- founding_wizard_routes.py: POST /v1/founding-wizard/generate
  → liefert base64-DOCX-Files für ausgewählte Templates

Frontend Founding-Wizard (/sdk/founding-wizard):
- 8-Step Wizard (Basics, Gesellschafter, GF, Kapital, Notar, SHA, GF-Verträge, Generate)
- useFoundingWizardForm Hook mit localStorage-Persistenz
- TypeScript Code-Registry (template-categories.ts) als Backup zur DB
- Word-Download via data:URLs (base64)

Tests:
- 20 Unit-Tests grün (Renderer, Context-Mapping, DOCX-Conversion)
- Playwright E2E-Test mit 2-Mann GmbH (Benjamin + Sharang) Test-Daten
This commit is contained in:
Benjamin Admin
2026-05-20 09:30:51 +02:00
parent 98ec6d4284
commit 7a5f1e48dd
33 changed files with 6725 additions and 0 deletions
@@ -0,0 +1,178 @@
"""
Mapping vom Wizard-State (frontend) auf den Template-Context (Render-Variablen).
Frontend liefert ein JSON-Payload mit den Wizard-Schritten. Hier konvertieren
wir es in eine flache Dict-Struktur, deren Keys SCREAMING_SNAKE_CASE sind und
zu den Platzhaltern in den Templates passen (z.B. {{COMPANY_NAME}}).
Pro Dokumenttyp (document_type) wird der jeweils benoetigte Subset gebaut.
"""
from __future__ import annotations
from typing import Any
def _gs_table(gesellschafter: list[dict[str, Any]], stammkapital: int) -> str:
"""Erzeugt eine Markdown-Tabelle der Gesellschafter."""
rows = []
for g in gesellschafter:
nb = int(g.get("nennbetrag_eur") or 0)
pct = (nb / max(stammkapital, 1)) * 100 if stammkapital else 0
rows.append(
f"| {g.get('anteil_nr', '')} | {g.get('name', '')} | "
f"{g.get('geburtsdatum') or g.get('adresse', '')} | "
f"{g.get('adresse', '')} | {g.get('anteil_nr', '')} | "
f"{nb:,} | {pct:.2f}% |".replace(",", ".")
)
return "\n".join(rows)
def _parties_list(gesellschafter: list[dict[str, Any]]) -> str:
"""Aufzaehlung der Parteien fuer SHA, IP-Assignment etc."""
lines = []
for idx, g in enumerate(gesellschafter):
letter = chr(ord("a") + idx)
line = f"{letter}) **{g.get('name', '')}**"
if g.get("geburtsdatum"):
line += f", geboren am {g['geburtsdatum']}"
if g.get("adresse"):
line += f", wohnhaft in {g['adresse']}"
lines.append(line + ",")
return "\n".join(lines)
def _parties_list_with_shares(gesellschafter: list[dict[str, Any]]) -> str:
"""Erzeugt nummerierte Liste der Gesellschafter mit Anteilen fuer § 3 Satzung."""
lines = []
for g in gesellschafter:
nr = g.get("anteil_nr", "?")
name = g.get("name", "")
nb = int(g.get("nennbetrag_eur") or 0)
lines.append(
f"{nr}. {name} übernimmt den Geschäftsanteil Nr. {nr} mit einem "
f"Nennbetrag von {nb:,} Euro.".replace(",", ".")
)
return "\n".join(lines)
def _gf_liste(gf: list[dict[str, Any]]) -> str:
"""Liste der Geschaeftsfuehrer fuer Bestellungsbeschluss / HRB-Anmeldung."""
lines = []
for g in gf:
line = f"- **{g.get('name', '')}**"
if g.get("geburtsdatum"):
line += f", geboren am {g['geburtsdatum']}"
if g.get("adresse"):
line += f", wohnhaft in {g['adresse']}"
if g.get("internal_role"):
line += f"{g['internal_role']}"
lines.append(line)
return "\n".join(lines)
def _company_purpose_bullets(bullets: list[str]) -> str:
return "\n".join(bullets) if bullets else "a) Allgemeine geschäftliche Tätigkeit"
def _einzahlungsaufstellung(gesellschafter: list[dict[str, Any]], quote_pct: int) -> str:
rows = []
for g in gesellschafter:
nb = int(g.get("nennbetrag_eur") or 0)
paid = int(nb * quote_pct / 100)
rows.append(f"- {g.get('name', '')}: {paid:,} EUR von {nb:,} EUR ({quote_pct}%)".replace(",", "."))
return "\n".join(rows)
def base_context(state: dict[str, Any]) -> dict[str, Any]:
"""Gemeinsamer Context fuer alle Dokumente."""
basics = state.get("basics", {})
capital = state.get("capital", {})
notar = state.get("notar", {})
gesellschafter = state.get("gesellschafter", [])
gf_list = [g for g in gesellschafter if g.get("is_geschaeftsfuehrer")]
sha = state.get("sha", {})
stammkapital = int(capital.get("stammkapital_eur") or 25000)
num_gf = len(gf_list)
num_gs = len(gesellschafter)
has_academic = any(g.get("has_academic_background") for g in gesellschafter)
ctx: dict[str, Any] = {
# Company
"COMPANY_NAME": basics.get("company_name", ""),
"COMPANY_LEGAL_FORM": basics.get("legal_form", "GmbH"),
"COMPANY_SEAT": basics.get("company_seat", ""),
"COMPANY_ADDRESS": basics.get("company_address", ""),
"COMPANY_PURPOSE_DESCRIPTION": basics.get("company_purpose_description", ""),
"COMPANY_PURPOSE_BULLETS": _company_purpose_bullets(basics.get("company_purpose_bullets", [])),
"COMPANY_PURPOSE_SHORT": basics.get("industry", "")[:120],
"BUSINESS_YEAR": basics.get("business_year", "Kalenderjahr"),
"FIRST_YEAR_END": "31. Dezember des Eintragungsjahres",
"PUBLICATION_VENUE": "Bundesanzeiger",
# Capital
"STAMMKAPITAL_EUR": f"{stammkapital:,}".replace(",", "."),
"STAMMKAPITAL_HALF_EUR": f"{stammkapital // 2:,}".replace(",", "."),
"EINLAGE_METHOD": capital.get("einlage_method", "Geld"),
"EINLAGE_QUOTE_INITIAL_PCT": capital.get("einlage_quote_initial_pct", 50),
"EINLAGE_QUOTE_REMAINING_PCT": 100 - int(capital.get("einlage_quote_initial_pct") or 50),
"EINLAGE_QUOTE_INITIAL_LESS_THAN_100": (capital.get("einlage_quote_initial_pct") or 50) < 100,
"EINZAHLUNGSAUFSTELLUNG": _einzahlungsaufstellung(gesellschafter, capital.get("einlage_quote_initial_pct") or 50),
"HAS_SACHEINLAGE": capital.get("has_sacheinlage", False),
"VERZUGSFRIST_TAGE": 30,
"EINZIEHUNG_MEHRHEIT_PCT": 75,
"VORKAUFSRECHT_TAGE": 14,
"EINBERUFUNGSFRIST_TAGE": 7,
"VOTING_UNIT_EUR": "1,00",
"ERBFALL_AUFGRIFFSFRIST_MONATE": 6,
"ERBFALL_MEHRHEIT_PCT": 75,
"AUFLOESUNG_MEHRHEIT_PCT": 75,
"GRUENDUNGSKOSTEN_MAX_EUR": f"{int(stammkapital / 10):,}".replace(",", "."),
# Gesellschafter
"PARTIES_LIST": _parties_list(gesellschafter),
"PARTIES_LIST_WITH_SHARES": _parties_list_with_shares(gesellschafter),
"GESELLSCHAFTER_TABELLE": _gs_table(gesellschafter, stammkapital),
"GESCHAEFTSFUEHRER_LISTE": _gf_liste(gf_list),
"GESELLSCHAFTER_LISTE": _gf_liste(gesellschafter),
# GF
"NUM_GF": num_gf,
"NUM_GF_TEXT": {1: "einen", 2: "zwei", 3: "drei", 4: "vier", 5: "fünf"}.get(num_gf, str(num_gf)),
"IS_SINGLE_GF": num_gf == 1,
"IS_MULTI_GF": num_gf > 1,
"NUM_GF_IS_2": num_gf == 2,
"NUM_GF_GT_2": num_gf > 2,
"IS_MULTI_GESELLSCHAFTER": num_gs > 1,
"IS_FOUNDER_GROUP": num_gs >= 2,
"VERTRETUNGSART": "Gesamtvertretung; bei nur einem Geschäftsführer Einzelvertretung",
# Notar
"NOTARY_NAME": notar.get("notary_name", ""),
"NOTARY_PLACE": notar.get("notary_place", ""),
"NOTARY_ADDRESS": notar.get("notary_address", ""),
"NOTARY_URNR": notar.get("urnr", "[wird beim Termin vergeben]"),
"NOTARIAL_DATE": notar.get("notarial_date", "[Notartermin folgt]"),
"NOTARY_BEGLAUBIGUNG_URNR": "[wird beim Termin vergeben]",
"NOTARIAL_LOCATION": notar.get("notary_place", ""),
"ANMELDUNG_TYP": "Ersteintragung gemäß § 7 GmbHG",
"ANMELDUNG_DATE": notar.get("notarial_date", "[Notartermin folgt]"),
"REGISTRY_COURT_ADDRESS": "[Adresse des zuständigen Registergerichts]",
"COMPANY_REGISTRY_COURT": "[zuständiges Amtsgericht]",
# Common
"DOCUMENT_VERSION": "1.0.0",
"EFFECTIVE_DATE": notar.get("notarial_date", "[Datum der Beurkundung]"),
"RESOLUTION_DATE": notar.get("notarial_date", "[Datum der Beurkundung]"),
"NEXT_REVIEW_DATE": "[+ 12 Monate]",
"SIGNATURES_BLOCK": "Unterschriften gemäß notarieller Beurkundung",
# SHA Flags
"HAS_SHA": sha.get("has_sha", True),
"HAS_GO_GF": True,
"HAS_ACADEMIC_FOUNDER": has_academic,
"HAS_RESEARCH_FOCUS": basics.get("has_research_focus", False),
"HAS_BEIRAT": sha.get("has_beirat", False),
"HAS_TEXAS_SHOOTOUT": sha.get("has_texas_shootout", False),
"HAS_CEO_DESIGNATION": sha.get("has_ceo_designation", False),
"CEO_NAME": sha.get("ceo_name", ""),
"HAS_HRB": False,
"HRB_NUMBER": "[wird vergeben]",
"IS_UG": basics.get("legal_form") == "UG",
}
return ctx